From d87bdd571244d08f831385d294a4c1bf81cec9a6 Mon Sep 17 00:00:00 2001 From: digua Date: Thu, 14 May 2026 22:07:54 +0800 Subject: [PATCH] feat(server,web): add custom provider/model CRUD HTTP endpoints - Create custom-store.ts with platform-agnostic CRUD for custom-providers.json and custom-models.json (parameterized by aiDataDir) - Add 6 HTTP endpoints: POST/PUT/DELETE for custom providers and models - Update web shim to call new endpoints instead of returning WEB_STUB --- packages/server/src/ai/custom-store.ts | 142 +++++++++++++++++++++++++ packages/server/src/http/routes/ai.ts | 83 +++++++++++++++ src/services/registry.ts | 15 ++- 3 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/ai/custom-store.ts diff --git a/packages/server/src/ai/custom-store.ts b/packages/server/src/ai/custom-store.ts new file mode 100644 index 00000000..00a5fe70 --- /dev/null +++ b/packages/server/src/ai/custom-store.ts @@ -0,0 +1,142 @@ +/** + * 自定义 Provider / Model 持久化存储(平台无关) + * + * 文件位置: + * - {aiDataDir}/custom-providers.json + * - {aiDataDir}/custom-models.json + */ + +import * as fs from 'fs' +import * as path from 'path' +import { randomUUID } from 'crypto' +import type { ProviderDefinition, ModelDefinition } from '@openchatlab/core' + +// ==================== Custom Providers ==================== + +function readProviders(aiDataDir: string): ProviderDefinition[] { + const storePath = path.join(aiDataDir, 'custom-providers.json') + if (!fs.existsSync(storePath)) return [] + try { + const content = fs.readFileSync(storePath, 'utf-8') + return JSON.parse(content) as ProviderDefinition[] + } catch { + return [] + } +} + +function writeProviders(aiDataDir: string, providers: ProviderDefinition[]): void { + const storePath = path.join(aiDataDir, 'custom-providers.json') + const dir = path.dirname(storePath) + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(storePath, JSON.stringify(providers, null, 2), 'utf-8') +} + +export function addCustomProvider( + aiDataDir: string, + input: Omit +): ProviderDefinition { + const providers = readProviders(aiDataDir) + const newProvider: ProviderDefinition = { + ...input, + id: `custom:${randomUUID()}`, + builtin: false, + enabledByDefault: false, + } + providers.push(newProvider) + writeProviders(aiDataDir, providers) + return newProvider +} + +export function updateCustomProvider( + aiDataDir: string, + id: string, + updates: Partial> +): { success: boolean; error?: string } { + const providers = readProviders(aiDataDir) + const index = providers.findIndex((p) => p.id === id) + if (index === -1) return { success: false, error: 'Custom provider not found' } + + providers[index] = { ...providers[index], ...updates } + writeProviders(aiDataDir, providers) + return { success: true } +} + +export function deleteCustomProvider(aiDataDir: string, id: string): { success: boolean; error?: string } { + const providers = readProviders(aiDataDir) + const index = providers.findIndex((p) => p.id === id) + if (index === -1) return { success: false, error: 'Custom provider not found' } + + providers.splice(index, 1) + writeProviders(aiDataDir, providers) + return { success: true } +} + +// ==================== Custom Models ==================== + +function readModels(aiDataDir: string): ModelDefinition[] { + const storePath = path.join(aiDataDir, 'custom-models.json') + if (!fs.existsSync(storePath)) return [] + try { + const content = fs.readFileSync(storePath, 'utf-8') + return JSON.parse(content) as ModelDefinition[] + } catch { + return [] + } +} + +function writeModels(aiDataDir: string, models: ModelDefinition[]): void { + const storePath = path.join(aiDataDir, 'custom-models.json') + const dir = path.dirname(storePath) + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(storePath, JSON.stringify(models, null, 2), 'utf-8') +} + +export function addCustomModel( + aiDataDir: string, + input: Omit +): { success: boolean; model?: ModelDefinition; error?: string } { + const models = readModels(aiDataDir) + + const existing = models.find((m) => m.id === input.id && m.providerId === input.providerId) + if (existing) { + return { success: false, error: `Model "${input.id}" already exists under provider "${input.providerId}"` } + } + + const newModel: ModelDefinition = { + ...input, + builtin: false, + editable: true, + } + models.push(newModel) + writeModels(aiDataDir, models) + return { success: true, model: newModel } +} + +export function updateCustomModel( + aiDataDir: string, + providerId: string, + modelId: string, + updates: Partial> +): { success: boolean; error?: string } { + const models = readModels(aiDataDir) + const index = models.findIndex((m) => m.id === modelId && m.providerId === providerId) + if (index === -1) return { success: false, error: 'Custom model not found' } + + models[index] = { ...models[index], ...updates } + writeModels(aiDataDir, models) + return { success: true } +} + +export function deleteCustomModel( + aiDataDir: string, + providerId: string, + modelId: string +): { success: boolean; error?: string } { + const models = readModels(aiDataDir) + const index = models.findIndex((m) => m.id === modelId && m.providerId === providerId) + if (index === -1) return { success: false, error: 'Custom model not found' } + + models.splice(index, 1) + writeModels(aiDataDir, models) + return { success: true } +} diff --git a/packages/server/src/http/routes/ai.ts b/packages/server/src/http/routes/ai.ts index 78d9d484..4d83b902 100644 --- a/packages/server/src/http/routes/ai.ts +++ b/packages/server/src/http/routes/ai.ts @@ -27,6 +27,14 @@ import { setDefaultAssistantSlot, setFastModelSlot, } from '../../ai/llm-config' +import { + addCustomProvider, + updateCustomProvider, + deleteCustomProvider, + addCustomModel, + updateCustomModel, + deleteCustomModel, +} from '../../ai/custom-store' function getAiDir(dbManager: DatabaseManager): string { const pathProvider = (dbManager as any)['pathProvider'] @@ -292,6 +300,81 @@ export function registerAiRoutes( return setFastModelSlot(aiDataDir, request.body) }) + // ==================== Custom Provider CRUD ==================== + + server.post<{ + Body: { + name: string + kind: string + defaultBaseUrl: string + authMode?: string + supportsCustomModels?: boolean + modelIds?: string[] + website?: string + consoleUrl?: string + } + }>('/_web/ai/llm/custom-providers', async (request) => { + const aiDataDir = getAiDir(dbManager) + return addCustomProvider(aiDataDir, { + ...request.body, + kind: (request.body.kind || 'openai-compatible') as 'official' | 'aggregator' | 'openai-compatible', + authMode: 'api-key', + supportsCustomModels: request.body.supportsCustomModels ?? true, + modelIds: request.body.modelIds ?? [], + }) + }) + + server.put<{ + Params: { id: string } + Body: Record + }>('/_web/ai/llm/custom-providers/:id', async (request) => { + const aiDataDir = getAiDir(dbManager) + return updateCustomProvider(aiDataDir, request.params.id, request.body as any) + }) + + server.delete<{ Params: { id: string } }>('/_web/ai/llm/custom-providers/:id', async (request) => { + const aiDataDir = getAiDir(dbManager) + return deleteCustomProvider(aiDataDir, request.params.id) + }) + + // ==================== Custom Model CRUD ==================== + + server.post<{ + Body: { + id: string + providerId: string + name: string + description?: string + contextWindow?: number + capabilities?: string[] + recommendedFor?: string[] + status?: string + } + }>('/_web/ai/llm/custom-models', async (request) => { + const aiDataDir = getAiDir(dbManager) + return addCustomModel(aiDataDir, { + ...request.body, + capabilities: (request.body.capabilities ?? ['chat']) as any, + recommendedFor: (request.body.recommendedFor ?? ['chat']) as any, + status: (request.body.status ?? 'stable') as any, + }) + }) + + server.put<{ + Params: { providerId: string; modelId: string } + Body: Record + }>('/_web/ai/llm/custom-models/:providerId/:modelId', async (request) => { + const aiDataDir = getAiDir(dbManager) + return updateCustomModel(aiDataDir, request.params.providerId, request.params.modelId, request.body as any) + }) + + server.delete<{ + Params: { providerId: string; modelId: string } + }>('/_web/ai/llm/custom-models/:providerId/:modelId', async (request) => { + const aiDataDir = getAiDir(dbManager) + return deleteCustomModel(aiDataDir, request.params.providerId, request.params.modelId) + }) + // ==================== Tool Catalog ==================== server.get('/_web/ai/tools/catalog', async () => { diff --git a/src/services/registry.ts b/src/services/registry.ts index 1eba347d..cd69c0b7 100644 --- a/src/services/registry.ts +++ b/src/services/registry.ts @@ -216,9 +216,18 @@ async function installAiApiShims(): Promise { validateApiKey: () => Promise.resolve(WEB_STUB), fetchRemoteModels: () => Promise.resolve({ success: false, error: 'Not available in web mode', models: [] }), chatStream: () => Promise.resolve({ success: false, error: 'Not available in web mode' }), - addCustomModel: () => Promise.resolve(WEB_STUB), - updateCustomModel: () => Promise.resolve(WEB_STUB), - deleteCustomModel: () => Promise.resolve(WEB_STUB), + addCustomModel: async (input: Record) => { + const { post: httpPost } = await import('./utils/http') + return httpPost('/ai/llm/custom-models', input) + }, + updateCustomModel: async (providerId: string, modelId: string, updates: Record) => { + const { put: httpPut } = await import('./utils/http') + return httpPut(`/ai/llm/custom-models/${providerId}/${encodeURIComponent(modelId)}`, updates) + }, + deleteCustomModel: async (providerId: string, modelId: string) => { + const { del } = await import('./utils/http') + return del(`/ai/llm/custom-models/${providerId}/${encodeURIComponent(modelId)}`) + }, } const { fetchSSE } = await import('./utils/sse') const { post: httpPost } = await import('./utils/http')