import type { CatalogModelDefinition } from '#/llm/provider-catalog'; export type ModelsDevWire = | 'anthropic' | 'openai' | 'openai_responses' | 'google-genai' | 'google-vertex' | 'kimi'; export interface ModelsDevModelEntry { readonly id?: string; readonly name?: string; readonly family?: string; readonly limit?: { readonly context?: number; readonly input?: number; readonly output?: number }; readonly tool_call?: boolean; readonly dynamically_loaded_tools?: boolean; readonly reasoning?: boolean; readonly reasoning_options?: readonly ModelsDevReasoningOption[]; readonly status?: string; readonly provider?: ModelsDevModelProviderOverride; readonly interleaved?: boolean | { readonly field?: string }; readonly modalities?: { readonly input?: readonly string[]; readonly output?: readonly string[]; }; } export interface ModelsDevReasoningOption { readonly type?: string; readonly values?: unknown; } export interface ModelsDevModelProviderOverride { readonly npm?: string; readonly api?: string; } export interface ModelsDevProviderEntry { readonly id?: string; readonly api?: string; readonly npm?: string; readonly type?: string; readonly models?: Record; } export type ModelsDevImportInvalidReason = | 'unknown-explicit-type' | 'proprietary-sdk' | 'empty-base-url' | 'placeholder-base-url'; export type ModelsDevImportResolution = | { readonly kind: 'ok'; readonly wire: ModelsDevWire; readonly guessed: boolean; readonly baseUrl?: string; } | { readonly kind: 'needs-base-url'; readonly wire: ModelsDevWire; readonly guessed: boolean } | { readonly kind: 'invalid'; readonly reason: ModelsDevImportInvalidReason }; const KNOWN_WIRES = [ 'anthropic', 'openai', 'openai_responses', 'google-genai', 'google-vertex', 'kimi', ] as const satisfies readonly ModelsDevWire[]; function isModelsDevWire(value: unknown): value is ModelsDevWire { return typeof value === 'string' && (KNOWN_WIRES as readonly string[]).includes(value); } function hasEmbeddingMarker(value: string | undefined): boolean { if (value === undefined) return false; const lower = value.toLowerCase(); return lower.includes('embedding') || /(?:^|[-_/])embed(?:$|[-_/])/.test(lower); } function isUsableChatModel(model: ModelsDevModelEntry): boolean { const outputModalities = model.modalities?.output; if (outputModalities !== undefined && !outputModalities.includes('text')) return false; if (model.status === 'deprecated' || model.status === 'alpha') return false; return ( !hasEmbeddingMarker(model.family) && !hasEmbeddingMarker(model.id) && !hasEmbeddingMarker(model.name) ); } export function resolveModelsDevImport( entry: ModelsDevProviderEntry, userBaseUrl?: string, ): ModelsDevImportResolution { const wire = resolveModelsDevWire(entry); if (wire === undefined) { return { kind: 'invalid', reason: typeof entry.type === 'string' && entry.type.length > 0 ? 'unknown-explicit-type' : 'proprietary-sdk', }; } const guessed = inferDeclaredWire(entry) === undefined; if (userBaseUrl !== undefined) { const trimmed = userBaseUrl.trim(); if (trimmed.length === 0) return { kind: 'invalid', reason: 'empty-base-url' }; if (trimmed.includes('${')) return { kind: 'invalid', reason: 'placeholder-base-url' }; return { kind: 'ok', wire, guessed, baseUrl: adaptBaseUrlForWire(trimmed, wire) }; } const modelsDevUrl = modelsDevBaseUrl(entry, wire); if (modelsDevUrl !== undefined) return { kind: 'ok', wire, guessed, baseUrl: modelsDevUrl }; if (modelsDevEndpointRequired(entry, wire)) return { kind: 'needs-base-url', wire, guessed }; return { kind: 'ok', wire, guessed }; } function resolveModelsDevWire(entry: ModelsDevProviderEntry): ModelsDevWire | undefined { if (isModelsDevWire(entry.type)) return entry.type; if (typeof entry.type === 'string' && entry.type.length > 0) return undefined; const declared = inferDeclaredWire(entry); if (declared !== undefined) return declared; const npm = (entry.npm ?? '').toLowerCase(); if (npm.includes('amazon-bedrock') || npm.includes('cohere')) return undefined; return 'openai'; } function inferDeclaredWire(entry: ModelsDevProviderEntry): ModelsDevWire | undefined { if (isModelsDevWire(entry.type)) return entry.type; const npm = (entry.npm ?? '').toLowerCase(); const id = (entry.id ?? '').toLowerCase(); if (npm.includes('anthropic') || id.includes('anthropic') || id.includes('claude')) { return 'anthropic'; } if (id.includes('vertex')) return 'google-vertex'; if (npm.includes('google') || id.includes('google') || id.includes('gemini')) { return 'google-genai'; } if (npm.includes('openai') || id.includes('openai')) return 'openai'; return undefined; } function modelsDevBaseUrl(entry: ModelsDevProviderEntry, wire: ModelsDevWire): string | undefined { const api = entry.api; if (typeof api !== 'string' || api.length === 0 || api.includes('${')) return undefined; return adaptBaseUrlForWire(api, wire); } function adaptBaseUrlForWire(baseUrl: string, wire: ModelsDevWire): string { return wire === 'anthropic' ? baseUrl.replace(/\/v1\/?$/, '') : baseUrl; } function modelsDevEndpointRequired(entry: ModelsDevProviderEntry, wire: ModelsDevWire): boolean { if (typeof entry.api === 'string' && entry.api.length > 0) return true; const npm = (entry.npm ?? '').toLowerCase(); if (wire === 'openai' || wire === 'openai_responses') return npm !== '@ai-sdk/openai'; if (wire === 'anthropic') return npm !== '@ai-sdk/anthropic'; return false; } function normalizeModelsDevModel( providerId: string, model: ModelsDevModelEntry, ): CatalogModelDefinition | undefined { if (typeof model.id !== 'string' || model.id.length === 0) return undefined; const context = model.limit?.context; if (typeof context !== 'number' || !Number.isInteger(context) || context <= 0) return undefined; if (!isUsableChatModel(model)) return undefined; const inputs = model.modalities?.input ?? []; const output = model.limit?.output; const thinking = modelsDevThinkingOptions(model.reasoning_options); const input = model.limit?.input; const maxInputTokens = typeof input === 'number' && Number.isInteger(input) && input > 0 ? Math.min(input, context) : undefined; return { provider: providerId, model: model.id, displayName: typeof model.name === 'string' && model.name.length > 0 ? model.name : undefined, maxContextSize: context, maxInputSize: maxInputTokens, maxOutputSize: typeof output === 'number' && output > 0 ? output : undefined, reasoningKey: modelsDevReasoningKey(model.interleaved), supportEfforts: thinking.efforts, offEffort: thinking.offEffort, alwaysThinking: thinking.alwaysThinking, capability: { image_in: inputs.includes('image'), video_in: inputs.includes('video'), audio_in: inputs.includes('audio'), thinking: Boolean(model.reasoning) || thinking.efforts !== undefined || thinking.hasToggle, tool_use: model.tool_call ?? true, dynamically_loaded_tools: model.dynamically_loaded_tools === true, }, }; } function modelsDevThinkingOptions(options: ModelsDevModelEntry['reasoning_options']): { readonly efforts: readonly string[] | undefined; readonly offEffort: string | undefined; readonly hasToggle: boolean; readonly alwaysThinking: boolean | undefined; } { if (!Array.isArray(options)) { return { efforts: undefined, offEffort: undefined, hasToggle: false, alwaysThinking: undefined, }; } let efforts: readonly string[] | undefined; let offEffort: string | undefined; let hasToggle = false; for (const option of options) { if (option?.type === 'toggle') { hasToggle = true; continue; } if (option?.type !== 'effort' || !Array.isArray(option.values)) continue; const hasNullTier = (option.values as unknown[]).some((value) => value === null); const levels = (option.values as unknown[]).filter( (value: unknown): value is string => typeof value === 'string' && value.length > 0, ); const off = levels.find((value) => value.toLowerCase() === 'none'); if (off !== undefined) offEffort = off; else if (hasNullTier) offEffort = 'none'; const selectable = levels.filter((value) => value.toLowerCase() !== 'none'); if (selectable.length > 0) efforts = selectable; } const alwaysThinking = efforts !== undefined && offEffort === undefined && !hasToggle ? true : undefined; return { efforts, offEffort, hasToggle, alwaysThinking }; } function modelsDevReasoningKey(interleaved: ModelsDevModelEntry['interleaved']): string | undefined { if (typeof interleaved !== 'object' || interleaved === null) return undefined; const field = interleaved.field?.trim(); return field !== undefined && field.length > 0 ? field : undefined; } export function modelsDevProviderModels( providerId: string, entry: ModelsDevProviderEntry, ): CatalogModelDefinition[] { const providerWire = resolveModelsDevWire(entry); return Object.values(entry.models ?? {}) .map((raw) => { const resolved = applyModelProviderOverride( normalizeModelsDevModel(providerId, raw), raw, entry, providerWire, ); return resolved === undefined ? undefined : dropAlwaysThinkingForWire(resolved.model, resolved.wire); }) .filter((model): model is CatalogModelDefinition => model !== undefined); } function dropAlwaysThinkingForWire( model: CatalogModelDefinition, wire: ModelsDevWire | undefined, ): CatalogModelDefinition { return model.alwaysThinking === true && (wire === 'anthropic' || wire === 'kimi') ? { ...model, alwaysThinking: undefined } : model; } function applyModelProviderOverride( model: CatalogModelDefinition | undefined, raw: ModelsDevModelEntry, entry: ModelsDevProviderEntry, providerWire: ModelsDevWire | undefined, ): { model: CatalogModelDefinition; wire: ModelsDevWire | undefined } | undefined { if (model === undefined) return undefined; const override = raw.provider; if (override === undefined) return { model, wire: providerWire }; const overrideNpm = typeof override.npm === 'string' ? override.npm.toLowerCase() : undefined; if ( overrideNpm !== undefined && (overrideNpm.includes('amazon-bedrock') || overrideNpm.includes('cohere')) ) { return undefined; } const overrideWire = overrideNpm !== undefined ? (inferOverrideWire(overrideNpm) ?? 'openai') : providerWire; if (overrideWire === undefined) return { model, wire: providerWire }; const rawApi = override.api; const api = rawApi ?? entry.api; const usableApi = typeof api === 'string' && api.length > 0 && !api.includes('${') ? api : undefined; if (overrideWire === providerWire) { if (typeof rawApi === 'string' && rawApi.includes('${')) return undefined; if (usableApi !== undefined && usableApi !== entry.api) { return { model: { ...model, baseUrl: adaptBaseUrlForWire(usableApi, overrideWire) }, wire: overrideWire, }; } return { model, wire: overrideWire }; } if (overrideWire === 'anthropic' && usableApi !== undefined) { return { model: { ...model, protocol: 'anthropic', baseUrl: adaptBaseUrlForWire(usableApi, 'anthropic'), }, wire: 'anthropic', }; } return undefined; } function inferOverrideWire(npm: string): ModelsDevWire | undefined { const normalized = npm.toLowerCase(); if (normalized.includes('anthropic')) return 'anthropic'; if (normalized.includes('vertex')) return 'google-vertex'; if (normalized.includes('google')) return 'google-genai'; if (normalized.includes('openai')) return 'openai'; return undefined; }