| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { configManager } from '@/lib/config/storage'; |
| import { ProviderId, ProviderModel, InputModality, OutputModality } from '@/lib/llm/providers/types'; |
| import { getProvider } from '@/lib/llm/providers/registry'; |
| import { getAvailableModels } from '@/lib/llm/llm-client'; |
| import { |
| fetchAvailableModels, |
| } from '@/lib/llm/models-api'; |
| import { |
| registerOpenRouterPricingFromApi, |
| registerPricingFromProviderModels, |
| } from '@/lib/llm/pricing-cache'; |
| import { logger } from '@/lib/utils'; |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function loadProviderModels(provider: ProviderId): Promise<ProviderModel[]> { |
| const providerConfig = getProvider(provider); |
| const apiKey = configManager.getProviderApiKey(provider); |
|
|
| |
| if (providerConfig.apiKeyRequired && !apiKey) { |
| return providerConfig.models ?? []; |
| } |
|
|
| |
| const cachedEntry = configManager.getCachedModels(provider); |
| if (cachedEntry) { |
| const cached = cachedEntry.models as ProviderModel[]; |
| if (provider === 'openrouter') { |
| registerPricingFromProviderModels('openrouter', cached); |
| } |
| return cached; |
| } |
|
|
| |
| let loadedModels: ProviderModel[] = []; |
|
|
| try { |
| if (provider === 'openrouter') { |
| const availableModels = await fetchAvailableModels(); |
| registerOpenRouterPricingFromApi(availableModels); |
|
|
| const norm = (desc: unknown): string => { |
| if (typeof desc === 'string') return desc; |
| if (desc && typeof desc === 'object') { |
| const record = desc as Record<string, unknown>; |
| const candidate = ['description', 'name', 'summary'] |
| .map((key) => record[key]) |
| .find((value): value is string => typeof value === 'string'); |
| if (candidate) return candidate; |
| try { return JSON.stringify(record); } catch { } |
| } |
| if (desc == null) return ''; |
| return String(desc); |
| }; |
|
|
| loadedModels = availableModels.map((model) => { |
| const promptRate = model.pricing?.prompt ? Number(model.pricing.prompt) : undefined; |
| const completionRate = model.pricing?.completion ? Number(model.pricing.completion) : undefined; |
| const reasoningRate = model.pricing?.internal_reasoning |
| ? Number(model.pricing.internal_reasoning) |
| : undefined; |
|
|
| const normalizeRate = (value?: number) => { |
| if (value === undefined || !Number.isFinite(value)) return undefined; |
| return value * 1_000_000; |
| }; |
|
|
| const normalizedInput = normalizeRate(promptRate); |
| const normalizedOutput = normalizeRate(completionRate); |
| const normalizedReasoning = normalizeRate(reasoningRate); |
|
|
| const pricing = |
| normalizedInput !== undefined && normalizedOutput !== undefined |
| ? { input: normalizedInput, output: normalizedOutput, reasoning: normalizedReasoning } |
| : undefined; |
|
|
| const orModalities = model.architecture?.input_modalities as InputModality[] | undefined; |
| const orOutputModalities = model.architecture?.output_modalities as OutputModality[] | undefined; |
| const providerModel: ProviderModel = { |
| id: model.id, |
| name: model.name, |
| description: norm(model.description), |
| contextLength: model.context_length, |
| maxTokens: model.top_provider?.max_completion_tokens, |
| supportsFunctions: model.supported_parameters?.includes('tools'), |
| supportsVision: orModalities?.includes('image'), |
| supportsReasoning: model.supported_parameters?.includes('reasoning'), |
| ...(orModalities ? { inputModalities: orModalities } : {}), |
| ...(orOutputModalities ? { outputModalities: orOutputModalities } : {}), |
| pricing, |
| }; |
| return providerModel; |
| }); |
| } else if (provider === 'huggingface') { |
| try { |
| const hfResponse = await fetch('https://router.huggingface.co/v1/models'); |
| if (hfResponse.ok) { |
| const hfData = await hfResponse.json(); |
| |
| loadedModels = (hfData.data || []).map((model: any) => { |
| |
| const hfProviders = model.providers || [] as any[]; |
| const bestProvider = |
| |
| hfProviders.find((p: any) => p.supports_tools && p.status === 'live') || |
| |
| hfProviders.find((p: any) => p.status === 'live') || |
| hfProviders[0]; |
|
|
| const contextLength = bestProvider?.context_length || 32768; |
| |
| const supportsFunctions = hfProviders.some((p: any) => p.supports_tools); |
| const hfModalities = model.architecture?.input_modalities as InputModality[] | undefined; |
| const hfOutputModalities = model.architecture?.output_modalities as OutputModality[] | undefined; |
| const supportsVision = hfModalities?.includes('image'); |
|
|
| let pricing: { input: number; output: number } | undefined; |
| if ( |
| bestProvider?.pricing?.input != null && |
| bestProvider?.pricing?.output != null |
| ) { |
| pricing = { |
| input: bestProvider.pricing.input, |
| output: bestProvider.pricing.output, |
| }; |
| } |
|
|
| return { |
| id: model.id, |
| name: model.id.split('/').pop() || model.id, |
| contextLength, |
| supportsFunctions, |
| supportsVision, |
| ...(hfModalities ? { inputModalities: hfModalities } : {}), |
| ...(hfOutputModalities ? { outputModalities: hfOutputModalities } : {}), |
| pricing, |
| } as ProviderModel; |
| }); |
| } |
| } catch (error) { |
| logger.error('HuggingFace models fetch error:', error); |
| } |
| if (loadedModels.length > 0) { |
| registerPricingFromProviderModels('huggingface', loadedModels); |
| } |
| } else if (providerConfig.supportsModelDiscovery) { |
| const modelEntries = await getAvailableModels(apiKey || undefined, provider, providerConfig.baseUrl); |
| loadedModels = modelEntries.map((entry) => { |
| const id = typeof entry === 'string' ? entry : entry.id; |
| const contextLength = |
| typeof entry === 'object' && entry.contextLength ? entry.contextLength : 128000; |
| const inputModalities = |
| typeof entry === 'object' && entry.inputModalities |
| ? (entry.inputModalities as InputModality[]) |
| : undefined; |
| return { |
| id, |
| name: id.split('/').pop() || id, |
| contextLength, |
| supportsFunctions: true, |
| ...(inputModalities |
| ? { inputModalities, supportsVision: inputModalities.includes('image') } |
| : {}), |
| }; |
| }); |
| } else if (providerConfig.models) { |
| loadedModels = providerConfig.models; |
| } |
|
|
| |
| if (loadedModels.length > 0) { |
| configManager.setCachedModels(provider, loadedModels); |
| if (provider === 'openrouter') { |
| registerPricingFromProviderModels('openrouter', loadedModels); |
| } |
| } |
| } catch (error) { |
| logger.error(`loadProviderModels: failed to load models for ${provider}:`, error); |
| |
| return providerConfig.models ?? []; |
| } |
|
|
| return loadedModels; |
| } |
|
|
| |
| |
| |
|
|
| export type SlotKind = 'agent' | 'imageGen' | 'voiceInput'; |
|
|
| export interface ModelsForSlotOptions { |
| |
| all?: boolean; |
| } |
|
|
| export interface SlotModelEntry { |
| provider: ProviderId; |
| model: ProviderModel; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function modelsForSlot( |
| slot: SlotKind, |
| providers: ProviderId[], |
| opts?: ModelsForSlotOptions, |
| _loader: (provider: ProviderId) => Promise<ProviderModel[]> = loadProviderModels, |
| ): Promise<SlotModelEntry[]> { |
| const settled = await Promise.allSettled( |
| providers.map(async (provider) => { |
| const models = await _loader(provider); |
| return { provider, models }; |
| }), |
| ); |
|
|
| const rows: SlotModelEntry[] = []; |
|
|
| for (const result of settled) { |
| if (result.status === 'rejected') { |
| logger.error('modelsForSlot: provider load failed:', result.reason); |
| continue; |
| } |
| const { provider, models } = result.value; |
| for (const model of models) { |
| rows.push({ provider, model }); |
| } |
| } |
|
|
| if (opts?.all) return rows; |
|
|
| return rows.filter(({ model }) => matchesSlot(slot, model)); |
| } |
|
|
| |
| export function matchesSlot(slot: SlotKind, model: ProviderModel): boolean { |
| |
| const effectiveOutput: string[] = model.outputModalities ?? ['text']; |
| const effectiveInput: string[] = model.inputModalities ?? []; |
|
|
| switch (slot) { |
| case 'agent': |
| return effectiveOutput.includes('text'); |
| case 'imageGen': |
| return effectiveOutput.includes('image'); |
| case 'voiceInput': |
| return effectiveInput.includes('audio'); |
| default: |
| return false; |
| } |
| } |
|
|