mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
♻️ chore: refactor router runtime (#9306)
This commit is contained in:
@@ -18,13 +18,13 @@ The project uses the following technologies:
|
||||
- Next.js 15 for frontend and backend, using app router instead of pages router
|
||||
- react 19, using hooks, functional components, react server components
|
||||
- TypeScript programming language
|
||||
- antd, @lobehub/ui for component framework
|
||||
- antd, `@lobehub/ui` for component framework
|
||||
- antd-style for css-in-js framework
|
||||
- react-layout-kit for flex layout
|
||||
- react-i18next for i18n
|
||||
- lucide-react, @ant-design/icons for icons
|
||||
- @lobehub/icons for AI provider/model logo icon
|
||||
- @formkit/auto-animate for react list animation
|
||||
- lucide-react, `@ant-design/icons` for icons
|
||||
- `@lobehub/icons` for AI provider/model logo icon
|
||||
- `@formkit/auto-animate` for react list animation
|
||||
- zustand for global state management
|
||||
- nuqs for type-safe search params state manager
|
||||
- SWR for react data fetch
|
||||
|
||||
@@ -86,9 +86,9 @@ const Card: FC<CardProps> = ({ title, content }) => {
|
||||
|
||||
## Lobe UI 包含的组件
|
||||
|
||||
- 不知道 @lobehub/ui 的组件怎么用,有哪些属性,就自己搜下这个项目其它地方怎么用的,不要瞎猜,大部分组件都是在 antd 的基础上扩展了属性
|
||||
- 不知道 `@lobehub/ui` 的组件怎么用,有哪些属性,就自己搜下这个项目其它地方怎么用的,不要瞎猜,大部分组件都是在 antd 的基础上扩展了属性
|
||||
- 具体用法不懂可以联网搜索,例如 ActionIcon 就爬取 https://ui.lobehub.com/components/action-icon
|
||||
- 可以阅读 node_modules/@lobehub/ui/es/index.js 了解有哪些组件,每个组件的属性是什么
|
||||
- 可以阅读 `node_modules/@lobehub/ui/es/index.js` 了解有哪些组件,每个组件的属性是什么
|
||||
|
||||
- General
|
||||
- ActionIcon
|
||||
|
||||
@@ -8,11 +8,63 @@ alwaysApply: false
|
||||
|
||||
## Types and Type Safety
|
||||
|
||||
- Avoid explicit type annotations when TypeScript can infer types.
|
||||
- Avoid implicitly `any` variables; explicitly type when necessary (e.g., `let a: number` instead of `let a`).
|
||||
- Use the most accurate type possible (e.g., prefer `Record<PropertyKey, unknown>` over `object`).
|
||||
- Prefer `interface` over `type` for object shapes (e.g., React component props). Keep `type` for unions, intersections, and utility types.
|
||||
- Prefer `as const satisfies XyzInterface` over plain `as const` when suitable.
|
||||
- avoid explicit type annotations when TypeScript can infer types.
|
||||
- avoid implicitly `any` variables; explicitly type when necessary (e.g., `let a: number` instead of `let a`).
|
||||
- use the most accurate type possible (e.g., prefer `Record<PropertyKey, unknown>` over `object`).
|
||||
- prefer `interface` over `type` for object shapes (e.g., React component props). Keep `type` for unions, intersections, and utility types.
|
||||
- prefer `as const satisfies XyzInterface` over plain `as const` when suitable.
|
||||
- prefer `@ts-expect-error` over `@ts-ignore`
|
||||
- prefer `Record<string, any>` over `any`
|
||||
|
||||
- **Avoid unnecessary null checks**: Before adding `xxx !== null`, `?.`, `??`, or `!.`, read the type definition to confirm the necessary. **Example:**
|
||||
|
||||
```typescript
|
||||
// ❌ Wrong: budget.spend and budget.maxBudget is number, not number | null
|
||||
if (budget.spend !== null && budget.maxBudget !== null && budget.spend >= budget.maxBudget) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ Right
|
||||
if (budget.spend >= budget.maxBudget) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
- **Avoid redundant runtime checks**: Don't add runtime validation for conditions already guaranteed by types or previous checks. Trust the type system and calling contract. **Example:**
|
||||
|
||||
```typescript
|
||||
// ❌ Wrong: Adding impossible-to-fail checks
|
||||
const due = await db.query.budgets.findMany({
|
||||
where: and(isNotNull(budgets.budgetDuration)), // Already filtered non-null
|
||||
});
|
||||
const result = due.map(b => {
|
||||
const nextReset = computeNextResetAt(b.budgetResetAt!, b.budgetDuration!);
|
||||
if (!nextReset) { // This check is impossible to fail
|
||||
throw new Error(`Unexpected null nextResetAt`);
|
||||
}
|
||||
return nextReset;
|
||||
});
|
||||
|
||||
// ✅ Right: Trust the contract
|
||||
const due = await db.query.budgets.findMany({
|
||||
where: and(isNotNull(budgets.budgetDuration)),
|
||||
});
|
||||
const result = due.map(b => computeNextResetAt(b.budgetResetAt!, b.budgetDuration!));
|
||||
```
|
||||
|
||||
- **Avoid meaningless null/undefined parameters**: Don't accept null/undefined for parameters that have no business meaning when null. Design strict function contracts. **Example:**
|
||||
|
||||
```typescript
|
||||
// ❌ Wrong: Function accepts meaningless null input
|
||||
function computeNextResetAt(currentResetAt: Date, durationStr: string | null): Date | null {
|
||||
if (!durationStr) return null; // Why accept null if it just returns null?
|
||||
}
|
||||
|
||||
// ✅ Right: Strict contract, clear responsibility
|
||||
function computeNextResetAt(currentResetAt: Date, durationStr: string): Date {
|
||||
// Function has single clear purpose, caller ensures valid input
|
||||
}
|
||||
```
|
||||
|
||||
## Imports and Modules
|
||||
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -23,6 +23,7 @@ Desktop.ini
|
||||
.history/
|
||||
.windsurfrules
|
||||
*.code-workspace
|
||||
.vscode/sessions.json
|
||||
|
||||
# Temporary files
|
||||
.temp/
|
||||
@@ -115,3 +116,4 @@ CLAUDE.local.md
|
||||
*.xls*
|
||||
|
||||
prd
|
||||
GEMINI.md
|
||||
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -88,6 +88,8 @@
|
||||
"**/src/server/routers/async/*.ts": "${filename} • async",
|
||||
"**/src/server/routers/edge/*.ts": "${filename} • edge",
|
||||
|
||||
"**/src/locales/default/*.ts": "${filename} • locale"
|
||||
"**/src/locales/default/*.ts": "${filename} • locale",
|
||||
|
||||
"**/index.*": "${dirname}/${filename}.${extname}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ Built with modern technologies:
|
||||
- **Database**: PostgreSQL, PGLite, Drizzle ORM
|
||||
- **Testing**: Vitest, Testing Library
|
||||
- **Package Manager**: pnpm (monorepo structure)
|
||||
- **Build Tools**: Next.js (Turbopack in dev, Webpack in prod), Vitest
|
||||
- **Build Tools**: Next.js (Turbopack in dev, Webpack in prod)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -28,7 +28,7 @@ The project follows a well-organized monorepo structure:
|
||||
|
||||
### Git Workflow
|
||||
|
||||
- Use rebase for git pull: `git pull --rebase`
|
||||
- Use rebase for git pull
|
||||
- Git commit messages should prefix with gitmoji
|
||||
- Git branch name format: `username/feat/feature-name`
|
||||
- Use `.github/PULL_REQUEST_TEMPLATE.md` for PR descriptions
|
||||
@@ -52,9 +52,6 @@ The project follows a well-organized monorepo structure:
|
||||
#### React Components
|
||||
|
||||
- Use functional components with hooks
|
||||
- Follow the component structure guidelines
|
||||
- Use antd-style & @lobehub/ui for styling
|
||||
- Implement proper error boundaries
|
||||
|
||||
#### Database Schema
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
"stylelint": "stylelint \"src/**/*.{js,jsx,ts,tsx}\" --fix",
|
||||
"test": "npm run test-app && npm run test-server",
|
||||
"test-app": "vitest run",
|
||||
"test-app:coverage": "vitest run --coverage",
|
||||
"test-app:coverage": "vitest --coverage --silent='passed-only'",
|
||||
"test:update": "vitest -u",
|
||||
"type-check": "tsgo --noEmit",
|
||||
"webhook:ngrok": "ngrok http http://localhost:3011",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"simple": "tsx examples/tools-calling.ts",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest --coverage"
|
||||
"test:coverage": "vitest --coverage --silent='passed-only'"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:coverage": "vitest --coverage --silent='passed-only'",
|
||||
"test:update": "vitest -u"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"scripts": {
|
||||
"test": "npm run test:client-db && npm run test:server-db",
|
||||
"test:client-db": "vitest run",
|
||||
"test:coverage": "vitest --coverage --config vitest.config.server.mts",
|
||||
"test:coverage": "vitest --coverage --silent='passed-only' --config vitest.config.server.mts",
|
||||
"test:server-db": "vitest run --config vitest.config.server.mts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest --coverage"
|
||||
"test:coverage": "vitest --coverage --silent='passed-only'"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4"
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"main": "./src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest --coverage"
|
||||
"test:coverage": "vitest --coverage --silent='passed-only'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/community": "^0.3.41",
|
||||
|
||||
@@ -71,9 +71,9 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest --coverage"
|
||||
"test:coverage": "vitest --coverage --silent='passed-only'"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:coverage": "vitest --coverage --silent='passed-only'",
|
||||
"test:update": "vitest -u"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -9,14 +9,15 @@ describe('createRouterRuntime', () => {
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should throw error when routers array is empty', () => {
|
||||
expect(() => {
|
||||
const Runtime = createRouterRuntime({
|
||||
id: 'test-runtime',
|
||||
routers: [],
|
||||
});
|
||||
new Runtime();
|
||||
}).toThrow('empty providers');
|
||||
it('should throw error when routers array is empty', async () => {
|
||||
const Runtime = createRouterRuntime({
|
||||
id: 'test-runtime',
|
||||
routers: [],
|
||||
});
|
||||
const runtime = new Runtime();
|
||||
|
||||
// 现在错误在使用时才抛出,因为是延迟创建
|
||||
await expect(runtime.getRuntimeByModel('test-model')).rejects.toThrow('empty providers');
|
||||
});
|
||||
|
||||
it('should create UniformRuntime class with valid routers', () => {
|
||||
@@ -44,7 +45,7 @@ describe('createRouterRuntime', () => {
|
||||
expect(runtime).toBeDefined();
|
||||
});
|
||||
|
||||
it('should merge router options with constructor options', () => {
|
||||
it('should merge router options with constructor options', async () => {
|
||||
const mockConstructor = vi.fn();
|
||||
|
||||
class MockRuntime implements LobeRuntimeAI {
|
||||
@@ -52,6 +53,10 @@ describe('createRouterRuntime', () => {
|
||||
mockConstructor(options);
|
||||
}
|
||||
chat = vi.fn();
|
||||
textToImage = vi.fn();
|
||||
models = vi.fn();
|
||||
embeddings = vi.fn();
|
||||
textToSpeech = vi.fn();
|
||||
}
|
||||
|
||||
const Runtime = createRouterRuntime({
|
||||
@@ -61,11 +66,15 @@ describe('createRouterRuntime', () => {
|
||||
apiType: 'openai',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
runtime: MockRuntime as any,
|
||||
models: ['test-model'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
new Runtime({ apiKey: 'constructor-key' });
|
||||
const runtime = new Runtime({ apiKey: 'constructor-key' });
|
||||
|
||||
// 触发 runtime 创建
|
||||
await runtime.getRuntimeByModel('test-model');
|
||||
|
||||
expect(mockConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -77,10 +86,14 @@ describe('createRouterRuntime', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModels', () => {
|
||||
it('should return synchronous models array directly', async () => {
|
||||
describe('model matching', () => {
|
||||
it('should return correct runtime for matching model', async () => {
|
||||
const mockRuntime = {
|
||||
chat: vi.fn(),
|
||||
textToImage: vi.fn(),
|
||||
models: vi.fn(),
|
||||
embeddings: vi.fn(),
|
||||
textToSpeech: vi.fn(),
|
||||
} as unknown as LobeRuntimeAI;
|
||||
|
||||
const Runtime = createRouterRuntime({
|
||||
@@ -96,50 +109,54 @@ describe('createRouterRuntime', () => {
|
||||
});
|
||||
|
||||
const runtime = new Runtime();
|
||||
const models = await runtime['getRouterMatchModels']({
|
||||
id: 'test',
|
||||
models: ['model-1', 'model-2'],
|
||||
runtime: mockRuntime,
|
||||
});
|
||||
const selectedRuntime = await runtime.getRuntimeByModel('model-1');
|
||||
|
||||
expect(models).toEqual(['model-1', 'model-2']);
|
||||
expect(selectedRuntime).toBeDefined();
|
||||
});
|
||||
|
||||
it('should call asynchronous models function', async () => {
|
||||
it('should support dynamic routers with asynchronous model fetching', async () => {
|
||||
const mockRuntime = {
|
||||
chat: vi.fn(),
|
||||
textToImage: vi.fn(),
|
||||
models: vi.fn(),
|
||||
embeddings: vi.fn(),
|
||||
textToSpeech: vi.fn(),
|
||||
} as unknown as LobeRuntimeAI;
|
||||
|
||||
const mockModelsFunction = vi.fn().mockResolvedValue(['async-model-1', 'async-model-2']);
|
||||
|
||||
const Runtime = createRouterRuntime({
|
||||
id: 'test-runtime',
|
||||
routers: [
|
||||
{
|
||||
apiType: 'openai',
|
||||
options: {},
|
||||
runtime: mockRuntime.constructor as any,
|
||||
models: mockModelsFunction,
|
||||
},
|
||||
],
|
||||
routers: async () => {
|
||||
// 异步获取模型列表
|
||||
const models = await mockModelsFunction();
|
||||
return [
|
||||
{
|
||||
apiType: 'openai',
|
||||
options: {},
|
||||
runtime: mockRuntime.constructor as any,
|
||||
models, // 静态数组
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const runtime = new Runtime();
|
||||
const runtimeItem = {
|
||||
id: 'test',
|
||||
models: mockModelsFunction,
|
||||
runtime: mockRuntime,
|
||||
};
|
||||
|
||||
// Call the function
|
||||
const models = await runtime['getRouterMatchModels'](runtimeItem);
|
||||
expect(models).toEqual(['async-model-1', 'async-model-2']);
|
||||
expect(mockModelsFunction).toHaveBeenCalledTimes(1);
|
||||
// 触发 routers 函数调用
|
||||
const selectedRuntime = await runtime.getRuntimeByModel('async-model-1');
|
||||
|
||||
expect(selectedRuntime).toBeDefined();
|
||||
expect(mockModelsFunction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty array when models is undefined', async () => {
|
||||
it('should return fallback runtime when model not found', async () => {
|
||||
const mockRuntime = {
|
||||
chat: vi.fn(),
|
||||
textToImage: vi.fn(),
|
||||
models: vi.fn(),
|
||||
embeddings: vi.fn(),
|
||||
textToSpeech: vi.fn(),
|
||||
} as unknown as LobeRuntimeAI;
|
||||
|
||||
const Runtime = createRouterRuntime({
|
||||
@@ -149,17 +166,15 @@ describe('createRouterRuntime', () => {
|
||||
apiType: 'openai',
|
||||
options: {},
|
||||
runtime: mockRuntime.constructor as any,
|
||||
models: ['known-model'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const runtime = new Runtime();
|
||||
const models = await runtime['getRouterMatchModels']({
|
||||
id: 'test',
|
||||
runtime: mockRuntime,
|
||||
});
|
||||
const selectedRuntime = await runtime.getRuntimeByModel('unknown-model');
|
||||
|
||||
expect(models).toEqual([]);
|
||||
expect(selectedRuntime).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -194,10 +209,10 @@ describe('createRouterRuntime', () => {
|
||||
const runtime = new Runtime();
|
||||
|
||||
const result1 = await runtime.getRuntimeByModel('gpt-4');
|
||||
expect(result1).toBe(runtime['_runtimes'][0].runtime);
|
||||
expect(result1).toBeInstanceOf(MockRuntime1);
|
||||
|
||||
const result2 = await runtime.getRuntimeByModel('claude-3');
|
||||
expect(result2).toBe(runtime['_runtimes'][1].runtime);
|
||||
expect(result2).toBeInstanceOf(MockRuntime2);
|
||||
});
|
||||
|
||||
it('should return last runtime when no model matches', async () => {
|
||||
@@ -230,7 +245,7 @@ describe('createRouterRuntime', () => {
|
||||
const runtime = new Runtime();
|
||||
const result = await runtime.getRuntimeByModel('unknown-model');
|
||||
|
||||
expect(result).toBe(runtime['_runtimes'][1].runtime);
|
||||
expect(result).toBeInstanceOf(MockRuntime2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -277,6 +292,9 @@ describe('createRouterRuntime', () => {
|
||||
|
||||
const Runtime = createRouterRuntime({
|
||||
id: 'test-runtime',
|
||||
chatCompletion: {
|
||||
handleError,
|
||||
},
|
||||
routers: [
|
||||
{
|
||||
apiType: 'openai',
|
||||
@@ -287,11 +305,7 @@ describe('createRouterRuntime', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const runtime = new Runtime({
|
||||
chat: {
|
||||
handleError,
|
||||
},
|
||||
});
|
||||
const runtime = new Runtime();
|
||||
|
||||
await expect(
|
||||
runtime.chat({ model: 'gpt-4', messages: [], temperature: 0.7 }),
|
||||
@@ -452,7 +466,7 @@ describe('createRouterRuntime', () => {
|
||||
});
|
||||
|
||||
describe('dynamic routers configuration', () => {
|
||||
it('should support function-based routers configuration', () => {
|
||||
it('should support function-based routers configuration', async () => {
|
||||
class MockRuntime implements LobeRuntimeAI {
|
||||
chat = vi.fn();
|
||||
textToImage = vi.fn();
|
||||
@@ -461,7 +475,7 @@ describe('createRouterRuntime', () => {
|
||||
textToSpeech = vi.fn();
|
||||
}
|
||||
|
||||
const dynamicRoutersFunction = (options: any) => [
|
||||
const dynamicRoutersFunction = vi.fn((options: any) => [
|
||||
{
|
||||
apiType: 'openai' as const,
|
||||
options: {
|
||||
@@ -478,7 +492,7 @@ describe('createRouterRuntime', () => {
|
||||
runtime: MockRuntime as any,
|
||||
models: ['claude-3'],
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
const Runtime = createRouterRuntime({
|
||||
id: 'test-runtime',
|
||||
@@ -493,21 +507,32 @@ describe('createRouterRuntime', () => {
|
||||
const runtime = new Runtime(userOptions);
|
||||
|
||||
expect(runtime).toBeDefined();
|
||||
expect(runtime['_runtimes']).toHaveLength(2);
|
||||
expect(runtime['_runtimes'][0].id).toBe('openai');
|
||||
expect(runtime['_runtimes'][1].id).toBe('anthropic');
|
||||
|
||||
// 测试动态 routers 是否能正确工作
|
||||
const result = await runtime.getRuntimeByModel('gpt-4');
|
||||
expect(result).toBeDefined();
|
||||
|
||||
// 验证动态函数被调用时传入了正确的参数
|
||||
expect(dynamicRoutersFunction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://yourapi.cn',
|
||||
}),
|
||||
{ model: 'gpt-4' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when dynamic routers function returns empty array', () => {
|
||||
it('should throw error when dynamic routers function returns empty array', async () => {
|
||||
const emptyRoutersFunction = () => [];
|
||||
|
||||
expect(() => {
|
||||
const Runtime = createRouterRuntime({
|
||||
id: 'test-runtime',
|
||||
routers: emptyRoutersFunction,
|
||||
});
|
||||
new Runtime();
|
||||
}).toThrow('empty providers');
|
||||
const Runtime = createRouterRuntime({
|
||||
id: 'test-runtime',
|
||||
routers: emptyRoutersFunction,
|
||||
});
|
||||
const runtime = new Runtime();
|
||||
|
||||
// 现在错误在使用时才抛出,因为是延迟创建
|
||||
await expect(runtime.getRuntimeByModel('test-model')).rejects.toThrow('empty providers');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ import { baseRuntimeMap } from './baseRuntimeMap';
|
||||
|
||||
export interface RuntimeItem {
|
||||
id: string;
|
||||
models?: string[] | (() => Promise<string[]>);
|
||||
models?: string[];
|
||||
runtime: LobeRuntimeAI;
|
||||
}
|
||||
|
||||
@@ -45,13 +45,22 @@ export type RuntimeClass = typeof LobeOpenAI;
|
||||
|
||||
interface RouterInstance {
|
||||
apiType: keyof typeof baseRuntimeMap;
|
||||
models?: string[] | (() => Promise<string[]>);
|
||||
models?: string[];
|
||||
options: ProviderIniOptions;
|
||||
runtime?: RuntimeClass;
|
||||
}
|
||||
|
||||
type ConstructorOptions<T extends Record<string, any> = any> = ClientOptions & T;
|
||||
|
||||
type Routers =
|
||||
| RouterInstance[]
|
||||
| ((
|
||||
options: ClientOptions & Record<string, any>,
|
||||
runtimeContext: {
|
||||
model?: string;
|
||||
},
|
||||
) => RouterInstance[] | Promise<RouterInstance[]>);
|
||||
|
||||
interface CreateRouterRuntimeOptions<T extends Record<string, any> = any> {
|
||||
apiKey?: string;
|
||||
chatCompletion?: {
|
||||
@@ -104,77 +113,82 @@ interface CreateRouterRuntimeOptions<T extends Record<string, any> = any> {
|
||||
options: ConstructorOptions<T>,
|
||||
) => ChatStreamPayload;
|
||||
};
|
||||
routers: RouterInstance[] | ((options: ClientOptions & Record<string, any>) => RouterInstance[]);
|
||||
routers: Routers;
|
||||
}
|
||||
|
||||
export const createRouterRuntime = ({
|
||||
id,
|
||||
routers,
|
||||
apiKey: DEFAULT_API_LEY,
|
||||
models,
|
||||
models: modelsOption,
|
||||
...params
|
||||
}: CreateRouterRuntimeOptions) => {
|
||||
return class UniformRuntime implements LobeRuntimeAI {
|
||||
private _runtimes: RuntimeItem[];
|
||||
private _options: ClientOptions & Record<string, any>;
|
||||
private _routers: Routers;
|
||||
private _params: any;
|
||||
private _id: string;
|
||||
|
||||
constructor(options: ClientOptions & Record<string, any> = {}) {
|
||||
const _options = {
|
||||
this._options = {
|
||||
...options,
|
||||
apiKey: options.apiKey?.trim() || DEFAULT_API_LEY,
|
||||
baseURL: options.baseURL?.trim(),
|
||||
};
|
||||
|
||||
// 支持动态 routers 配置
|
||||
const resolvedRouters = typeof routers === 'function' ? routers(_options) : routers;
|
||||
// 保存配置但不创建 runtimes
|
||||
this._routers = routers;
|
||||
this._params = params;
|
||||
this._id = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: routers 如果是静态对象,可以提前生成 runtimes, 避免运行时生成开销
|
||||
*/
|
||||
private async createRuntimesByRouters(model?: string): Promise<RuntimeItem[]> {
|
||||
// 动态获取 routers,支持传入 model
|
||||
const resolvedRouters =
|
||||
typeof this._routers === 'function'
|
||||
? await this._routers(this._options, { model })
|
||||
: this._routers;
|
||||
|
||||
if (resolvedRouters.length === 0) {
|
||||
throw new Error('empty providers');
|
||||
}
|
||||
|
||||
this._runtimes = resolvedRouters.map((router) => {
|
||||
return resolvedRouters.map((router) => {
|
||||
const providerAI = router.runtime ?? baseRuntimeMap[router.apiType] ?? LobeOpenAI;
|
||||
const finalOptions = { ...this._params, ...this._options, ...router.options };
|
||||
const runtime: LobeRuntimeAI = new providerAI({ ...finalOptions, id: this._id });
|
||||
|
||||
const finalOptions = { ...params, ...options, ...router.options };
|
||||
// @ts-ignore
|
||||
const runtime: LobeRuntimeAI = new providerAI({ ...finalOptions, id });
|
||||
|
||||
return { id: router.apiType, models: router.models, runtime };
|
||||
return {
|
||||
id: router.apiType,
|
||||
models: router.models,
|
||||
runtime,
|
||||
};
|
||||
});
|
||||
|
||||
this._options = _options;
|
||||
}
|
||||
|
||||
// Get runtime's models list, supporting both synchronous arrays and asynchronous functions
|
||||
private async getRouterMatchModels(runtimeItem: RuntimeItem): Promise<string[]> {
|
||||
// If it's a synchronous array, return directly
|
||||
if (typeof runtimeItem.models !== 'function') {
|
||||
return runtimeItem.models || [];
|
||||
}
|
||||
|
||||
// Get model list
|
||||
return await runtimeItem.models();
|
||||
}
|
||||
|
||||
// Check if it can match a specific model, otherwise default to using the last runtime
|
||||
async getRuntimeByModel(model: string) {
|
||||
for (const runtimeItem of this._runtimes) {
|
||||
const models = await this.getRouterMatchModels(runtimeItem);
|
||||
const runtimes = await this.createRuntimesByRouters(model);
|
||||
|
||||
for (const runtimeItem of runtimes) {
|
||||
const models = runtimeItem.models || [];
|
||||
if (models.includes(model)) {
|
||||
return runtimeItem.runtime;
|
||||
}
|
||||
}
|
||||
return this._runtimes.at(-1)!.runtime;
|
||||
return runtimes.at(-1)!.runtime;
|
||||
}
|
||||
|
||||
async chat(payload: ChatStreamPayload, options?: ChatMethodOptions) {
|
||||
try {
|
||||
const runtime = await this.getRuntimeByModel(payload.model);
|
||||
|
||||
return await runtime.chat!(payload, options);
|
||||
} catch (e) {
|
||||
if (this._options.chat?.handleError) {
|
||||
const error = this._options.chat.handleError(e);
|
||||
if (params.chatCompletion?.handleError) {
|
||||
const error = params.chatCompletion.handleError(e, this._options);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
@@ -192,20 +206,24 @@ export const createRouterRuntime = ({
|
||||
|
||||
async textToImage(payload: TextToImagePayload) {
|
||||
const runtime = await this.getRuntimeByModel(payload.model);
|
||||
|
||||
return runtime.textToImage!(payload);
|
||||
}
|
||||
|
||||
async models() {
|
||||
if (models && typeof models === 'function') {
|
||||
// If it's function-style configuration, use the last runtime's client to call the function
|
||||
const lastRuntime = this._runtimes.at(-1)?.runtime;
|
||||
if (modelsOption && typeof modelsOption === 'function') {
|
||||
// 延迟创建 runtimes
|
||||
const runtimes = await this.createRuntimesByRouters();
|
||||
// 如果是函数式配置,使用最后一个运行时的客户端来调用函数
|
||||
const lastRuntime = runtimes.at(-1)?.runtime;
|
||||
if (lastRuntime && 'client' in lastRuntime) {
|
||||
const modelList = await models({ client: (lastRuntime as any).client });
|
||||
const modelList = await modelsOption({ client: (lastRuntime as any).client });
|
||||
return await postProcessModelList(modelList);
|
||||
}
|
||||
}
|
||||
return this._runtimes.at(-1)?.runtime.models?.();
|
||||
|
||||
// 延迟创建 runtimes
|
||||
const runtimes = await this.createRuntimesByRouters();
|
||||
return runtimes.at(-1)?.runtime.models?.();
|
||||
}
|
||||
|
||||
async embeddings(payload: EmbeddingsPayload, options?: EmbeddingsOptions) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"main": "./src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest --coverage"
|
||||
"test:coverage": "vitest --coverage --silent='passed-only'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lobechat/types": "workspace:*"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest --coverage"
|
||||
"test:coverage": "vitest --coverage --silent='passed-only'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lobechat/const": "workspace:*",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest --coverage"
|
||||
"test:coverage": "vitest --coverage --silent='passed-only'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
|
||||
@@ -2,16 +2,7 @@ import { EdgeConfigClient, createClient } from '@vercel/edge-config';
|
||||
|
||||
import { appEnv } from '@/envs/app';
|
||||
|
||||
const EdgeConfigKeys = {
|
||||
/**
|
||||
* Assistant whitelist
|
||||
*/
|
||||
AssistantBlacklist: 'assistant_blacklist',
|
||||
/**
|
||||
* Assistant whitelist
|
||||
*/
|
||||
AssistantWhitelist: 'assistant_whitelist',
|
||||
};
|
||||
import { EdgeConfigData } from './types';
|
||||
|
||||
export class EdgeConfig {
|
||||
get client(): EdgeConfigClient {
|
||||
@@ -30,14 +21,7 @@ export class EdgeConfig {
|
||||
|
||||
getAgentRestrictions = async () => {
|
||||
const { assistant_blacklist: blacklist, assistant_whitelist: whitelist } =
|
||||
await this.client.getAll([
|
||||
EdgeConfigKeys.AssistantWhitelist,
|
||||
EdgeConfigKeys.AssistantBlacklist,
|
||||
]);
|
||||
|
||||
return { blacklist, whitelist } as {
|
||||
blacklist: string[] | undefined;
|
||||
whitelist: string[] | undefined;
|
||||
};
|
||||
await this.client.getAll<EdgeConfigData>(['assistant_whitelist', 'assistant_blacklist']);
|
||||
return { blacklist, whitelist };
|
||||
};
|
||||
}
|
||||
|
||||
9
src/server/modules/EdgeConfig/types.ts
Normal file
9
src/server/modules/EdgeConfig/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
|
||||
|
||||
/**
|
||||
* EdgeConfig 完整配置类型
|
||||
*/
|
||||
export interface EdgeConfigData {
|
||||
assistant_blacklist?: string[];
|
||||
assistant_whitelist?: string[];
|
||||
}
|
||||
Reference in New Issue
Block a user