/ src / server / storage / providers.ts
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  }