providers.ts
1 import type { 2 ModelInfo, 3 ProviderType, 4 ProviderWithSettings, 5 } from '@/lib/shared/providers' 6 import { getDb } from '@/server/storage/db' 7 8 const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1' 9 const OPENAI_PROVIDER_ID = 'openai' 10 const DEFAULT_OPENAI_API_PATH = '/responses' 11 const DEFAULT_COMPAT_API_PATH = '/chat/completions' 12 const VALID_PROVIDER_TYPES: ProviderType[] = [ 13 'openai', 14 'openai-responses', 15 'anthropic', 16 'gemini', 17 'chatglm', 18 ] 19 const VALID_MODEL_CAPABILITIES = new Set([ 20 'vision', 21 'reasoning', 22 'tool_use', 23 'web_search', 24 ]) 25 26 type ModelKind = 'chat' | 'embedding' | 'transcription' | 'rerank' 27 28 interface ProviderProfileRow { 29 id: string 30 name: string 31 type: string 32 is_builtin: number 33 api_host: string | null 34 api_path: string | null 35 api_key: string | null 36 endpoint_mode: string 37 default_model_id: string | null 38 last_probe_at: string | null 39 created_at: string 40 } 41 42 interface ProviderModelRow { 43 provider_id: string 44 model_id: string 45 provider_model_ref: string 46 capabilities_json: string | null 47 enabled: number 48 display_name: string 49 kind: string 50 } 51 52 interface ProviderProfileWriteRow { 53 id: string 54 name: string 55 type: ProviderType 56 isBuiltin: boolean 57 apiHost: string | null 58 apiPath: string | null 59 apiKey: string | null 60 endpointMode: string 61 defaultModelId: string | null 62 models: Array<{ 63 modelId: string 64 providerModelRef: string 65 displayName: string 66 kind: ModelKind 67 capabilities: string[] 68 }> 69 } 70 71 export function listProviderProfiles(): ProviderWithSettings[] { 72 const db = getDb() 73 ensureOpenAiProviderExists(db) 74 75 const providerRows = db 76 .prepare( 77 `SELECT 78 id, 79 name, 80 type, 81 is_builtin, 82 api_host, 83 api_path, 84 api_key, 85 endpoint_mode, 86 default_model_id, 87 last_probe_at, 88 created_at 89 FROM provider_profiles 90 ORDER BY is_builtin DESC, created_at ASC, id ASC`, 91 ) 92 .all() as ProviderProfileRow[] 93 94 const modelRows = db 95 .prepare( 96 `SELECT 97 pm.provider_id, 98 pm.model_id, 99 pm.provider_model_ref, 100 pm.capabilities_json, 101 pm.enabled, 102 m.display_name, 103 m.kind 104 FROM provider_models pm 105 JOIN models m ON m.id = pm.model_id 106 ORDER BY pm.provider_id ASC, pm.created_at ASC, pm.provider_model_ref ASC`, 107 ) 108 .all() as ProviderModelRow[] 109 110 const modelRowsByProvider = new Map<string, ProviderModelRow[]>() 111 for (const row of modelRows) { 112 const list = modelRowsByProvider.get(row.provider_id) ?? [] 113 list.push(row) 114 modelRowsByProvider.set(row.provider_id, list) 115 } 116 117 return providerRows.map((row) => 118 mapProviderProfileRow(row, modelRowsByProvider.get(row.id) ?? []), 119 ) 120 } 121 122 export function getProviderProfileById( 123 providerId: string, 124 ): ProviderWithSettings | null { 125 const trimmedProviderId = providerId.trim() 126 if (!trimmedProviderId) return null 127 return ( 128 listProviderProfiles().find((provider) => provider.id === trimmedProviderId) ?? 129 null 130 ) 131 } 132 133 export function replaceProviderProfiles( 134 providers: ProviderWithSettings[], 135 ): ProviderWithSettings[] { 136 const db = getDb() 137 const now = new Date().toISOString() 138 const existingById = new Map( 139 listProviderProfiles().map((provider) => [provider.id, provider] as const), 140 ) 141 142 const nextById = new Map<string, ProviderProfileWriteRow>() 143 for (const provider of providers) { 144 const normalized = normalizeProviderForWrite(provider, existingById) 145 if (!normalized) continue 146 nextById.set(normalized.id, normalized) 147 } 148 149 if (!nextById.has(OPENAI_PROVIDER_ID)) { 150 const existingOpenAi = existingById.get(OPENAI_PROVIDER_ID) 151 nextById.set(OPENAI_PROVIDER_ID, { 152 id: OPENAI_PROVIDER_ID, 153 name: 'OpenAI', 154 type: 'openai', 155 isBuiltin: true, 156 apiHost: 157 normalizeNullableString(existingOpenAi?.settings.apiHost) ?? 158 DEFAULT_OPENAI_BASE_URL, 159 apiPath: 160 normalizeNullableString(existingOpenAi?.settings.apiPath) ?? 161 DEFAULT_OPENAI_API_PATH, 162 apiKey: normalizeNullableString(existingOpenAi?.settings.apiKey), 163 endpointMode: 'auto', 164 defaultModelId: null, 165 models: normalizeModelsForWrite( 166 OPENAI_PROVIDER_ID, 167 existingOpenAi?.settings.models ?? existingOpenAi?.models ?? [], 168 ), 169 }) 170 } 171 172 const nextRows = Array.from(nextById.values()) 173 const nextIds = nextRows.map((row) => row.id) 174 const deleteSql = `DELETE FROM provider_profiles WHERE id NOT IN (${nextIds 175 .map(() => '?') 176 .join(', ')})` 177 const deleteMissing = db.prepare(deleteSql) 178 179 const transaction = db.transaction(() => { 180 for (const row of nextRows) { 181 upsertProviderProfileRow(row, now) 182 replaceProviderModelsForProvider(row, now) 183 } 184 deleteMissing.run(...nextIds) 185 }) 186 transaction() 187 188 return listProviderProfiles() 189 } 190 191 export function toNamespacedModelId( 192 kind: ModelKind, 193 providerId: string, 194 providerModelRef: string, 195 ): string { 196 return `${kind}:${providerId.trim().toLowerCase()}:${providerModelRef.trim()}` 197 } 198 199 export function parseNamespacedModelId( 200 value: string, 201 ): { 202 kind: ModelKind 203 providerId: string 204 providerModelRef: string 205 } | null { 206 const trimmed = value.trim() 207 if (!trimmed) return null 208 209 const match = trimmed.match( 210 /^(chat|embedding|transcription|rerank):([^:]+):(.+)$/i, 211 ) 212 if (!match) return null 213 214 const kind = match[1].toLowerCase() as ModelKind 215 const providerId = match[2].trim() 216 const providerModelRef = match[3].trim() 217 if (!providerId || !providerModelRef) return null 218 219 return { 220 kind, 221 providerId, 222 providerModelRef, 223 } 224 } 225 226 export function normalizeModelReferenceForProvider( 227 providerId: string, 228 modelIdOrRef: string, 229 ): string | null { 230 const normalizedProviderId = providerId.trim() 231 const candidate = modelIdOrRef.trim() 232 if (!normalizedProviderId || !candidate) return null 233 234 const parsed = parseNamespacedModelId(candidate) 235 if (parsed) { 236 if (parsed.providerId.toLowerCase() !== normalizedProviderId.toLowerCase()) { 237 return null 238 } 239 return parsed.providerModelRef 240 } 241 242 const resolved = resolveProviderModelReference(normalizedProviderId, candidate).trim() 243 return resolved || null 244 } 245 246 export function resolveProviderModelReference( 247 providerId: string, 248 modelIdOrRef: string, 249 ): string { 250 const normalizedProviderId = providerId.trim() 251 const candidate = modelIdOrRef.trim() 252 if (!normalizedProviderId || !candidate) return modelIdOrRef 253 254 const db = getDb() 255 const byModelId = db 256 .prepare( 257 `SELECT provider_model_ref 258 FROM provider_models 259 WHERE provider_id = ? AND model_id = ?`, 260 ) 261 .get(normalizedProviderId, candidate) as { provider_model_ref: string } | undefined 262 if (byModelId?.provider_model_ref) { 263 return byModelId.provider_model_ref 264 } 265 266 const byRef = db 267 .prepare( 268 `SELECT provider_model_ref 269 FROM provider_models 270 WHERE provider_id = ? AND provider_model_ref = ?`, 271 ) 272 .get(normalizedProviderId, candidate) as { provider_model_ref: string } | undefined 273 if (byRef?.provider_model_ref) { 274 return byRef.provider_model_ref 275 } 276 277 return modelIdOrRef 278 } 279 280 export function resolveProviderModelId( 281 providerId: string, 282 providerModelRef: string, 283 ): string | null { 284 const normalizedProviderId = providerId.trim() 285 const normalizedModelRef = providerModelRef.trim() 286 if (!normalizedProviderId || !normalizedModelRef) return null 287 288 const db = getDb() 289 const row = db 290 .prepare( 291 `SELECT model_id 292 FROM provider_models 293 WHERE provider_id = ? AND provider_model_ref = ?`, 294 ) 295 .get(normalizedProviderId, normalizedModelRef) as { model_id: string } | undefined 296 return row?.model_id ?? null 297 } 298 299 export function resolveProviderDefaultModelRef(providerId: string): string | null { 300 const normalizedProviderId = providerId.trim() 301 if (!normalizedProviderId) return null 302 303 const db = getDb() 304 const row = db 305 .prepare( 306 `SELECT pm.provider_model_ref 307 FROM provider_profiles pp 308 LEFT JOIN provider_models pm 309 ON pm.provider_id = pp.id 310 AND pm.model_id = pp.default_model_id 311 WHERE pp.id = ?`, 312 ) 313 .get(normalizedProviderId) as { provider_model_ref: string | null } | undefined 314 return normalizeNullableString(row?.provider_model_ref) 315 } 316 317 function ensureOpenAiProviderExists(db: ReturnType<typeof getDb>): void { 318 const row = db 319 .prepare('SELECT id FROM provider_profiles WHERE id = ?') 320 .get(OPENAI_PROVIDER_ID) as { id: string } | undefined 321 if (row) return 322 323 const now = new Date().toISOString() 324 db.prepare( 325 `INSERT INTO provider_profiles ( 326 id, name, type, is_builtin, api_host, api_path, api_key, 327 endpoint_mode, default_model_id, last_probe_at, created_at, updated_at 328 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`, 329 ).run( 330 OPENAI_PROVIDER_ID, 331 'OpenAI', 332 'openai', 333 1, 334 DEFAULT_OPENAI_BASE_URL, 335 DEFAULT_OPENAI_API_PATH, 336 null, 337 'auto', 338 now, 339 now, 340 ) 341 } 342 343 function normalizeProviderForWrite( 344 provider: ProviderWithSettings, 345 existingById: Map<string, ProviderWithSettings>, 346 ): ProviderProfileWriteRow | null { 347 const id = provider.id.trim() 348 if (!id) return null 349 350 const existing = existingById.get(id) 351 const settings = provider.settings 352 353 const rawApiKey = settings.apiKey ?? provider.apiKey 354 const normalizedApiKey = 355 rawApiKey === 'server-configured' 356 ? normalizeNullableString(existing?.settings.apiKey) 357 : rawApiKey === undefined 358 ? normalizeNullableString(existing?.settings.apiKey) 359 : normalizeNullableString(rawApiKey) 360 const normalizedApiHost = normalizeNullableString( 361 settings.apiHost ?? provider.apiHost ?? existing?.settings.apiHost, 362 ) 363 const normalizedApiPath = normalizeNullableString( 364 settings.apiPath ?? provider.apiPath ?? existing?.settings.apiPath, 365 ) 366 const resolvedType = normalizeProviderType(provider.type) 367 const isOpenAi = id === OPENAI_PROVIDER_ID 368 369 const models = normalizeModelsForWrite( 370 id, 371 settings.models ?? provider.models ?? existing?.settings.models ?? [], 372 ) 373 const defaultModelId = models.find((model) => model.kind === 'chat')?.modelId ?? null 374 375 return { 376 id, 377 name: isOpenAi 378 ? 'OpenAI' 379 : normalizeNullableString(provider.name) ?? existing?.name ?? id, 380 type: isOpenAi ? 'openai' : resolvedType, 381 isBuiltin: isOpenAi, 382 apiHost: isOpenAi 383 ? normalizedApiHost ?? DEFAULT_OPENAI_BASE_URL 384 : normalizedApiHost, 385 apiPath: 386 normalizedApiPath ?? 387 (isOpenAi ? DEFAULT_OPENAI_API_PATH : DEFAULT_COMPAT_API_PATH), 388 apiKey: normalizedApiKey, 389 endpointMode: 'auto', 390 defaultModelId, 391 models, 392 } 393 } 394 395 function normalizeModelsForWrite( 396 providerId: string, 397 models: ModelInfo[], 398 ): ProviderProfileWriteRow['models'] { 399 const next: ProviderProfileWriteRow['models'] = [] 400 401 for (const model of models) { 402 const providerModelRef = model.modelId.trim() 403 if (!providerModelRef) continue 404 405 const kind: ModelKind = 406 model.type === 'embedding' 407 ? 'embedding' 408 : model.type === 'rerank' 409 ? 'rerank' 410 : 'chat' 411 const modelId = toNamespacedModelId(kind, providerId, providerModelRef) 412 const capabilities = Array.from( 413 new Set( 414 (model.capabilities ?? []) 415 .map((capability) => capability.trim()) 416 .filter((capability) => VALID_MODEL_CAPABILITIES.has(capability)), 417 ), 418 ) 419 420 next.push({ 421 modelId, 422 providerModelRef, 423 displayName: model.nickname?.trim() || providerModelRef, 424 kind, 425 capabilities, 426 }) 427 } 428 429 return dedupeModels(next) 430 } 431 432 function mapProviderProfileRow( 433 row: ProviderProfileRow, 434 modelRows: ProviderModelRow[], 435 ): ProviderWithSettings { 436 const models: ModelInfo[] = modelRows 437 .filter((model) => model.enabled === 1) 438 .map((model) => ({ 439 modelId: model.provider_model_ref, 440 nickname: 441 model.display_name.trim() && model.display_name !== model.provider_model_ref 442 ? model.display_name 443 : undefined, 444 type: mapModelKindToInfoType(model.kind), 445 capabilities: parseModelCapabilities(model.capabilities_json), 446 })) 447 448 const apiHost = normalizeNullableString(row.api_host) ?? undefined 449 const apiPath = normalizeNullableString(row.api_path) ?? undefined 450 const apiKey = normalizeNullableString(row.api_key) ?? undefined 451 const settings = { 452 apiHost, 453 apiPath, 454 apiKey, 455 models, 456 } 457 const isCustom = row.is_builtin === 0 458 459 return { 460 id: row.id, 461 name: row.name, 462 type: normalizeProviderType(row.type), 463 isCustom, 464 apiHost, 465 apiPath, 466 apiKey, 467 models, 468 enabled: true, 469 settings, 470 available: Boolean(apiHost), 471 lastProbeAt: row.last_probe_at, 472 } 473 } 474 475 function replaceProviderModelsForProvider( 476 row: ProviderProfileWriteRow, 477 now: string, 478 ): void { 479 const db = getDb() 480 db.prepare('DELETE FROM provider_models WHERE provider_id = ?').run(row.id) 481 482 const insertModel = db.prepare( 483 `INSERT OR IGNORE INTO models (id, display_name, kind, created_at, updated_at) 484 VALUES (?, ?, ?, ?, ?)`, 485 ) 486 const updateModel = db.prepare( 487 'UPDATE models SET display_name = ?, kind = ?, updated_at = ? WHERE id = ?', 488 ) 489 const insertProviderModel = db.prepare( 490 `INSERT INTO provider_models ( 491 provider_id, model_id, provider_model_ref, capabilities_json, enabled, created_at, updated_at 492 ) VALUES (?, ?, ?, ?, 1, ?, ?)`, 493 ) 494 495 for (const model of row.models) { 496 insertModel.run(model.modelId, model.displayName, model.kind, now, now) 497 updateModel.run(model.displayName, model.kind, now, model.modelId) 498 insertProviderModel.run( 499 row.id, 500 model.modelId, 501 model.providerModelRef, 502 JSON.stringify(model.capabilities), 503 now, 504 now, 505 ) 506 } 507 } 508 509 function upsertProviderProfileRow( 510 row: ProviderProfileWriteRow, 511 now: string, 512 ): void { 513 const db = getDb() 514 db.prepare( 515 `INSERT INTO provider_profiles ( 516 id, 517 name, 518 type, 519 is_builtin, 520 api_host, 521 api_path, 522 api_key, 523 endpoint_mode, 524 default_model_id, 525 last_probe_at, 526 created_at, 527 updated_at 528 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?) 529 ON CONFLICT(id) DO UPDATE SET 530 name = excluded.name, 531 type = excluded.type, 532 is_builtin = excluded.is_builtin, 533 api_host = excluded.api_host, 534 api_path = excluded.api_path, 535 api_key = excluded.api_key, 536 endpoint_mode = excluded.endpoint_mode, 537 default_model_id = excluded.default_model_id, 538 updated_at = excluded.updated_at`, 539 ).run( 540 row.id, 541 row.name, 542 row.type, 543 row.isBuiltin ? 1 : 0, 544 row.apiHost, 545 row.apiPath, 546 row.apiKey, 547 row.endpointMode, 548 row.defaultModelId, 549 now, 550 now, 551 ) 552 } 553 554 function parseModelCapabilities( 555 rawValue: string | null, 556 ): Array<'vision' | 'reasoning' | 'tool_use' | 'web_search'> | undefined { 557 if (!rawValue) return undefined 558 try { 559 const parsed = JSON.parse(rawValue) as unknown 560 if (!Array.isArray(parsed)) return undefined 561 const capabilities = Array.from( 562 new Set( 563 parsed 564 .filter((value): value is string => typeof value === 'string') 565 .map((value) => value.trim()) 566 .filter((value) => VALID_MODEL_CAPABILITIES.has(value)), 567 ), 568 ) as Array<'vision' | 'reasoning' | 'tool_use' | 'web_search'> 569 return capabilities.length > 0 ? capabilities : undefined 570 } catch { 571 return undefined 572 } 573 } 574 575 function mapModelKindToInfoType(kind: string): ModelInfo['type'] { 576 if (kind === 'embedding') return 'embedding' 577 if (kind === 'rerank') return 'rerank' 578 return 'chat' 579 } 580 581 function dedupeModels( 582 models: ProviderProfileWriteRow['models'], 583 ): ProviderProfileWriteRow['models'] { 584 const byModelId = new Map<string, ProviderProfileWriteRow['models'][number]>() 585 for (const model of models) { 586 byModelId.set(model.modelId, model) 587 } 588 return Array.from(byModelId.values()) 589 } 590 591 function normalizeProviderType(value: string): ProviderType { 592 if (VALID_PROVIDER_TYPES.includes(value as ProviderType)) { 593 return value as ProviderType 594 } 595 return 'openai' 596 } 597 598 function normalizeNullableString(value: string | null | undefined): string | null { 599 const trimmed = value?.trim() 600 return trimmed ? trimmed : null 601 }