diff --git a/eslint.config.js b/eslint.config.js index 226982eb3..3a13387f3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -144,6 +144,7 @@ export default tseslint.config( 'memfs/lib/volume.js', 'yargs/**', '@anthropic-ai/sdk/**', + '**/generated/**', ], }, ], diff --git a/packages/cli/src/providers/aliases/anthropic.config b/packages/cli/src/providers/aliases/anthropic.config new file mode 100644 index 000000000..b91d70d3e --- /dev/null +++ b/packages/cli/src/providers/aliases/anthropic.config @@ -0,0 +1,9 @@ +{ + "name": "anthropic", + "modelsDevProviderId": "anthropic", + "description": "Anthropic Claude API", + "baseProvider": "anthropic", + "baseUrl": "https://api.anthropic.com/v1", + "defaultModel": "claude-sonnet-4-20250514", + "apiKeyEnv": "ANTHROPIC_API_KEY" +} diff --git a/packages/cli/src/providers/aliases/cerebras-code.config b/packages/cli/src/providers/aliases/cerebras-code.config index d2bd88875..4de856211 100644 --- a/packages/cli/src/providers/aliases/cerebras-code.config +++ b/packages/cli/src/providers/aliases/cerebras-code.config @@ -1,7 +1,9 @@ { "name": "Cerebras Code", + "modelsDevProviderId": "cerebras", "baseProvider": "openai", "baseUrl": "https://api.cerebras.ai/v1/", "defaultModel": "qwen-3-coder-480b", - "description": "Cerebras Code compatibility profile" + "description": "Cerebras Code compatibility profile", + "apiKeyEnv": "CEREBRAS_API_KEY" } diff --git a/packages/cli/src/providers/aliases/chutes-ai.config b/packages/cli/src/providers/aliases/chutes-ai.config index f4f042300..e4412ed9c 100644 --- a/packages/cli/src/providers/aliases/chutes-ai.config +++ b/packages/cli/src/providers/aliases/chutes-ai.config @@ -1,7 +1,9 @@ { "name": "Chutes.ai", + "modelsDevProviderId": "chutes", "baseProvider": "openai", - "baseUrl": "https://llm.chutes.ai/v1/", + "baseUrl": "https://llm.chutes.ai/v1", "defaultModel": "zai-org/GLM-4.5-Air", - "description": "Chutes.ai compatibility profile" + "description": "Chutes.ai compatibility profile", + "apiKeyEnv": "CHUTES_API_KEY" } diff --git a/packages/cli/src/providers/aliases/codex.config b/packages/cli/src/providers/aliases/codex.config index 3799db67c..0ed8233c1 100644 --- a/packages/cli/src/providers/aliases/codex.config +++ b/packages/cli/src/providers/aliases/codex.config @@ -1,5 +1,6 @@ { "name": "codex", + "modelsDevProviderId": "openai", "baseProvider": "openai-responses", "baseUrl": "https://chatgpt.com/backend-api/codex", "defaultModel": "gpt-5.2", diff --git a/packages/cli/src/providers/aliases/fireworks.config b/packages/cli/src/providers/aliases/fireworks.config index 8c6d3c60f..9b62712ea 100644 --- a/packages/cli/src/providers/aliases/fireworks.config +++ b/packages/cli/src/providers/aliases/fireworks.config @@ -1,7 +1,9 @@ { "name": "Fireworks", + "modelsDevProviderId": "fireworks-ai", "baseProvider": "openai", "baseUrl": "https://api.fireworks.ai/inference/v1/", "defaultModel": "accounts/fireworks/models/llama-v3p3-70b-instruct", - "description": "Fireworks AI compatibility profile" + "description": "Fireworks AI compatibility profile", + "apiKeyEnv": "FIREWORKS_API_KEY" } diff --git a/packages/cli/src/providers/aliases/gemini.config b/packages/cli/src/providers/aliases/gemini.config new file mode 100644 index 000000000..d573b43a4 --- /dev/null +++ b/packages/cli/src/providers/aliases/gemini.config @@ -0,0 +1,9 @@ +{ + "name": "gemini", + "modelsDevProviderId": "google", + "description": "Google Gemini API", + "baseProvider": "gemini", + "baseUrl": "https://generativelanguage.googleapis.com/v1beta", + "defaultModel": "gemini-2.0-flash", + "apiKeyEnv": "GEMINI_API_KEY" +} diff --git a/packages/cli/src/providers/aliases/kimi.config b/packages/cli/src/providers/aliases/kimi.config index 47d4e52dd..2bca5d657 100644 --- a/packages/cli/src/providers/aliases/kimi.config +++ b/packages/cli/src/providers/aliases/kimi.config @@ -1,5 +1,6 @@ { "name": "kimi", + "modelsDevProviderId": "kimi-for-coding", "baseProvider": "openai", "baseUrl": "https://api.kimi.com/coding/v1", "defaultModel": "kimi-for-coding", diff --git a/packages/cli/src/providers/aliases/llama-cpp.config b/packages/cli/src/providers/aliases/llama-cpp.config index ff022d500..5b4bcaf04 100644 --- a/packages/cli/src/providers/aliases/llama-cpp.config +++ b/packages/cli/src/providers/aliases/llama-cpp.config @@ -1,5 +1,6 @@ { "name": "llama.cpp", + "modelsDevProviderId": "llama", "baseProvider": "openai", "baseUrl": "http://localhost:8080/v1/", "defaultModel": "local-model", diff --git a/packages/cli/src/providers/aliases/lm-studio.config b/packages/cli/src/providers/aliases/lm-studio.config index 3cb81a166..8ac7864f7 100644 --- a/packages/cli/src/providers/aliases/lm-studio.config +++ b/packages/cli/src/providers/aliases/lm-studio.config @@ -1,5 +1,6 @@ { "name": "LM Studio", + "modelsDevProviderId": "lmstudio", "baseProvider": "openai", "baseUrl": "http://127.0.0.1:1234/v1/", "defaultModel": "gemma-3b-it", diff --git a/packages/cli/src/providers/aliases/mistral.config b/packages/cli/src/providers/aliases/mistral.config index 82ff13395..b50a3d0f9 100644 --- a/packages/cli/src/providers/aliases/mistral.config +++ b/packages/cli/src/providers/aliases/mistral.config @@ -1,5 +1,6 @@ { "name": "mistral", + "modelsDevProviderId": "mistral", "baseProvider": "openai", "baseUrl": "https://api.mistral.ai/v1", "defaultModel": "mistral-large-latest", diff --git a/packages/cli/src/providers/aliases/openai-responses.config b/packages/cli/src/providers/aliases/openai-responses.config new file mode 100644 index 000000000..a9c800790 --- /dev/null +++ b/packages/cli/src/providers/aliases/openai-responses.config @@ -0,0 +1,9 @@ +{ + "name": "openai-responses", + "modelsDevProviderId": "openai", + "description": "OpenAI Responses API", + "baseProvider": "openai-responses", + "baseUrl": "https://api.openai.com/v1", + "defaultModel": "gpt-4o", + "apiKeyEnv": "OPENAI_API_KEY" +} diff --git a/packages/cli/src/providers/aliases/openai-vercel.config b/packages/cli/src/providers/aliases/openai-vercel.config new file mode 100644 index 000000000..2e4ec4dd8 --- /dev/null +++ b/packages/cli/src/providers/aliases/openai-vercel.config @@ -0,0 +1,9 @@ +{ + "name": "openai-vercel", + "modelsDevProviderId": "openai", + "description": "OpenAI via Vercel AI SDK", + "baseProvider": "openai-vercel", + "baseUrl": "https://api.openai.com/v1", + "defaultModel": "gpt-4o", + "apiKeyEnv": "OPENAI_API_KEY" +} diff --git a/packages/cli/src/providers/aliases/openai.config b/packages/cli/src/providers/aliases/openai.config new file mode 100644 index 000000000..e78165641 --- /dev/null +++ b/packages/cli/src/providers/aliases/openai.config @@ -0,0 +1,9 @@ +{ + "name": "openai", + "modelsDevProviderId": "openai", + "description": "OpenAI API", + "baseProvider": "openai", + "baseUrl": "https://api.openai.com/v1", + "defaultModel": "gpt-4o", + "apiKeyEnv": "OPENAI_API_KEY" +} diff --git a/packages/cli/src/providers/aliases/openrouter.config b/packages/cli/src/providers/aliases/openrouter.config index 1f9c98107..c3806d9d4 100644 --- a/packages/cli/src/providers/aliases/openrouter.config +++ b/packages/cli/src/providers/aliases/openrouter.config @@ -1,7 +1,9 @@ { "name": "OpenRouter", + "modelsDevProviderId": "openrouter", "baseProvider": "openai", "baseUrl": "https://openrouter.ai/api/v1/", "defaultModel": "nvidia/nemotron-nano-9b-v2:free", - "description": "OpenRouter compatibility profile" + "description": "OpenRouter compatibility profile", + "apiKeyEnv": "OPENROUTER_API_KEY" } diff --git a/packages/cli/src/providers/aliases/qwen.config b/packages/cli/src/providers/aliases/qwen.config index f9c2fac29..7d2d74089 100644 --- a/packages/cli/src/providers/aliases/qwen.config +++ b/packages/cli/src/providers/aliases/qwen.config @@ -1,5 +1,6 @@ { "name": "qwen", + "modelsDevProviderId": "alibaba", "baseProvider": "openai", "baseUrl": "https://portal.qwen.ai/v1", "defaultModel": "qwen3-coder-plus", diff --git a/packages/cli/src/providers/aliases/qwenvercel.config b/packages/cli/src/providers/aliases/qwenvercel.config index 3eb664096..c6f0023e9 100644 --- a/packages/cli/src/providers/aliases/qwenvercel.config +++ b/packages/cli/src/providers/aliases/qwenvercel.config @@ -1,5 +1,6 @@ { "name": "qwenvercel", + "modelsDevProviderId": "alibaba", "baseProvider": "openaivercel", "baseUrl": "https://portal.qwen.ai/v1", "defaultModel": "qwen3-coder-plus", diff --git a/packages/cli/src/providers/aliases/synthetic.config b/packages/cli/src/providers/aliases/synthetic.config index a8962c3b4..2ff18f9e9 100644 --- a/packages/cli/src/providers/aliases/synthetic.config +++ b/packages/cli/src/providers/aliases/synthetic.config @@ -1,5 +1,6 @@ { "name": "Synthetic", + "modelsDevProviderId": "synthetic", "baseProvider": "openai", "baseUrl": "https://api.synthetic.new/openai/v1", "defaultModel": "hf:zai-org/GLM-4.6", diff --git a/packages/cli/src/providers/aliases/xai.config b/packages/cli/src/providers/aliases/xai.config index 665471bf4..b6575e9a2 100644 --- a/packages/cli/src/providers/aliases/xai.config +++ b/packages/cli/src/providers/aliases/xai.config @@ -1,7 +1,9 @@ { "name": "xAI", + "modelsDevProviderId": "xai", "baseProvider": "openai", "baseUrl": "https://api.x.ai/v1/", "defaultModel": "grok-3", - "description": "xAI compatibility profile" + "description": "xAI compatibility profile", + "apiKeyEnv": "XAI_API_KEY" } diff --git a/packages/cli/src/providers/providerAliases.builtin-qwen.test.ts b/packages/cli/src/providers/providerAliases.builtin-qwen.test.ts index 5d8b17eba..da715ed0a 100644 --- a/packages/cli/src/providers/providerAliases.builtin-qwen.test.ts +++ b/packages/cli/src/providers/providerAliases.builtin-qwen.test.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +// This test needs real config files, not the global mock +vi.unmock('./providerAliases.js'); + import { loadProviderAliasEntries } from './providerAliases.js'; describe('Built-in provider aliases (Qwen defaults)', () => { diff --git a/packages/cli/src/providers/providerAliases.codex.test.ts b/packages/cli/src/providers/providerAliases.codex.test.ts index 1a2c5c59e..f7158f89c 100644 --- a/packages/cli/src/providers/providerAliases.codex.test.ts +++ b/packages/cli/src/providers/providerAliases.codex.test.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; + +// This test needs real config files, not the global mock +vi.unmock('./providerAliases.js'); + import { loadProviderAliasEntries } from './providerAliases.js'; import * as fs from 'fs'; import * as path from 'path'; diff --git a/packages/cli/src/providers/providerAliases.ts b/packages/cli/src/providers/providerAliases.ts index 014502d70..11bc57956 100644 --- a/packages/cli/src/providers/providerAliases.ts +++ b/packages/cli/src/providers/providerAliases.ts @@ -37,6 +37,8 @@ export interface ProviderAliasConfig { description?: string; providerConfig?: Record; apiKeyEnv?: string; + /** Provider ID from models.dev for filtering in ModelsDialog */ + modelsDevProviderId?: string | null; } export interface ProviderAliasEntry { diff --git a/packages/cli/src/providers/providerManagerInstance.oauthRegistration.test.ts b/packages/cli/src/providers/providerManagerInstance.oauthRegistration.test.ts index 1ed55b05c..c0b6f43ac 100644 --- a/packages/cli/src/providers/providerManagerInstance.oauthRegistration.test.ts +++ b/packages/cli/src/providers/providerManagerInstance.oauthRegistration.test.ts @@ -8,6 +8,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SettingsService } from '@vybestack/llxprt-code-core'; import type { Config } from '@vybestack/llxprt-code-core'; +// This test tests provider registration behavior, needs real providerAliases +vi.unmock('./providerAliases.js'); + describe('Anthropic OAuth registration with environment key', () => { let ensureOAuthProviderRegisteredMock: ReturnType; let anthropicCtor: ReturnType; diff --git a/packages/cli/src/providers/providerManagerInstance.ts b/packages/cli/src/providers/providerManagerInstance.ts index cb40ea964..684fa4b08 100644 --- a/packages/cli/src/providers/providerManagerInstance.ts +++ b/packages/cli/src/providers/providerManagerInstance.ts @@ -83,6 +83,8 @@ interface OpenAIRegistrationContext { baseUrl?: string; providerConfig: ProviderConfigWithToolMode; oauthManager: OAuthManager; + config?: Config; + authOnlyEnabled?: boolean; } type ProviderConfigWithToolMode = IProviderConfig & { @@ -261,15 +263,6 @@ export function createProviderManager( } const authOnlyEnabled = resolveAuthOnlyFlag(config, loadedSettings); - const geminiProvider = getGeminiProvider(oauthManager, config); - manager.registerProvider(geminiProvider); - - void ensureOAuthProviderRegistered( - 'gemini', - oauthManager, - tokenStore, - addItem, - ); const settingsData = loadedSettings?.merged || {}; const ephemeralSettings = config?.getEphemeralSettings?.() ?? {}; @@ -347,24 +340,7 @@ export function createProviderManager( : undefined, }; - manager.registerProvider( - getOpenAIProvider( - openaiApiKey, - openaiBaseUrl, - openaiProviderConfig, - oauthManager, - ), - ); - - manager.registerProvider( - new OpenAIVercelProvider( - openaiApiKey, - openaiBaseUrl, - openaiProviderConfig, - oauthManager, - ), - ); - + // All providers are now registered via alias configs const aliasEntries = loadProviderAliasEntries(); registerAliasProviders( manager, @@ -373,28 +349,19 @@ export function createProviderManager( openaiBaseUrl, openaiProviderConfig, oauthManager, + config, + authOnlyEnabled, ); - void ensureOAuthProviderRegistered('qwen', oauthManager, tokenStore, addItem); - - // Always register OpenAI Responses provider (openaiResponsesEnabled setting is obsolete) - // Pass oauthManager for Codex mode support - manager.registerProvider( - getOpenAIResponsesProvider( - openaiApiKey, - openaiBaseUrl, - allowBrowserEnvironment, - oauthManager, - ), + // Register OAuth providers for authentication support + void ensureOAuthProviderRegistered( + 'gemini', + oauthManager, + tokenStore, + addItem, ); - manager.registerProvider( - getAnthropicProvider( - authOnlyEnabled, - oauthManager, - allowBrowserEnvironment, - ), - ); + void ensureOAuthProviderRegistered('qwen', oauthManager, tokenStore, addItem); void ensureOAuthProviderRegistered( 'anthropic', @@ -418,6 +385,8 @@ export function createProviderManager( baseUrl: openaiBaseUrl ?? undefined, providerConfig: openaiProviderConfig, oauthManager, + config, + authOnlyEnabled, }; openAIContexts.set(manager, openAIContext); @@ -488,25 +457,13 @@ export function refreshAliasProviders(): void { context.baseUrl, context.providerConfig, context.oauthManager, + context.config, + context.authOnlyEnabled, ); } export { getProviderManager as providerManager }; -function getOpenAIProvider( - openaiApiKey: string | undefined, - openaiBaseUrl: string | undefined, - openaiProviderConfig: IProviderConfig, - oauthManager: OAuthManager, -): OpenAIProvider { - return new OpenAIProvider( - openaiApiKey || undefined, - openaiBaseUrl, - openaiProviderConfig, - oauthManager, - ); -} - function registerAliasProviders( providerManagerInstance: ProviderManager, aliasEntries: ProviderAliasEntry[], @@ -514,6 +471,8 @@ function registerAliasProviders( openaiBaseUrl: string | undefined, openaiProviderConfig: IProviderConfig, oauthManager: OAuthManager, + config?: Config, + authOnlyEnabled?: boolean, ): void { for (const entry of aliasEntries) { switch (entry.config.baseProvider.toLowerCase()) { @@ -543,7 +502,8 @@ function registerAliasProviders( } break; } - case 'openaivercel': { + case 'openaivercel': + case 'openai-vercel': { const provider = createOpenAIVercelAliasProvider( entry, openaiApiKey, @@ -556,6 +516,24 @@ function registerAliasProviders( } break; } + case 'gemini': { + const provider = createGeminiAliasProvider(entry, oauthManager, config); + if (provider) { + providerManagerInstance.registerProvider(provider); + } + break; + } + case 'anthropic': { + const provider = createAnthropicAliasProvider( + entry, + oauthManager, + authOnlyEnabled, + ); + if (provider) { + providerManagerInstance.registerProvider(provider); + } + break; + } default: { console.warn( `[ProviderManager] Unsupported base provider '${entry.config.baseProvider}' for alias '${entry.alias}', skipping.`, @@ -798,54 +776,86 @@ function createOpenAIVercelAliasProvider( return provider; } -function getOpenAIResponsesProvider( - openaiApiKey: string | undefined, - openaiBaseUrl: string | undefined, - allowBrowserEnvironment: boolean, - oauthManager?: OAuthManager, -): OpenAIResponsesProvider { - const openaiResponsesProvider = new OpenAIResponsesProvider( - openaiApiKey || undefined, - openaiBaseUrl, - { allowBrowserEnvironment }, - oauthManager, - ); - return openaiResponsesProvider; -} - -function getGeminiProvider( +function createGeminiAliasProvider( + entry: ProviderAliasEntry, oauthManager: OAuthManager, config?: Config, -): GeminiProvider { - const geminiProvider = new GeminiProvider( - undefined, - undefined, +): GeminiProvider | null { + let aliasApiKey: string | undefined; + if (entry.config.apiKeyEnv) { + const envValue = process.env[entry.config.apiKeyEnv]; + if (envValue && envValue.trim() !== '') { + aliasApiKey = sanitizeApiKey(envValue); + } + } + + const resolvedBaseUrl = entry.config.baseUrl; + + const provider = new GeminiProvider( + aliasApiKey || undefined, + resolvedBaseUrl, config, oauthManager, ); - if (config && typeof geminiProvider.setConfig === 'function') { - geminiProvider.setConfig(config); + + if (config && typeof provider.setConfig === 'function') { + provider.setConfig(config); } - return geminiProvider; + + if ( + entry.config.defaultModel && + typeof provider.getDefaultModel === 'function' + ) { + const configuredDefaultModel = entry.config.defaultModel; + const originalGetDefaultModel = provider.getDefaultModel.bind(provider); + provider.getDefaultModel = () => + configuredDefaultModel || originalGetDefaultModel(); + } + + bindProviderAliasIdentity(provider, entry.alias); + + return provider; } -function getAnthropicProvider( - authOnlyEnabled: boolean, +function createAnthropicAliasProvider( + entry: ProviderAliasEntry, oauthManager: OAuthManager, - allowBrowserEnvironment: boolean, -): AnthropicProvider { - let anthropicApiKey: string | undefined; + authOnlyEnabled?: boolean, +): AnthropicProvider | null { + let aliasApiKey: string | undefined; + // Only use environment variable API key if authOnly is not enabled + if (!authOnlyEnabled && entry.config.apiKeyEnv) { + const envValue = process.env[entry.config.apiKeyEnv]; + if (envValue && envValue.trim() !== '') { + aliasApiKey = sanitizeApiKey(envValue); + } + } - if (!authOnlyEnabled && process.env.ANTHROPIC_API_KEY) { - anthropicApiKey = sanitizeApiKey(process.env.ANTHROPIC_API_KEY); + const resolvedBaseUrl = entry.config.baseUrl; + + const providerConfig: IProviderConfig = {}; + if (entry.config.providerConfig) { + Object.assign(providerConfig, entry.config.providerConfig); } - const anthropicBaseUrl = process.env.ANTHROPIC_BASE_URL; - const anthropicProvider = new AnthropicProvider( - anthropicApiKey || undefined, - anthropicBaseUrl, - { allowBrowserEnvironment }, + const provider = new AnthropicProvider( + aliasApiKey || undefined, + resolvedBaseUrl, + providerConfig, oauthManager, ); - return anthropicProvider; + + if ( + entry.config.defaultModel && + typeof provider.getDefaultModel === 'function' + ) { + const configuredDefaultModel = entry.config.defaultModel; + const originalGetDefaultModel = provider.getDefaultModel.bind(provider); + provider.getDefaultModel = () => + configuredDefaultModel || originalGetDefaultModel(); + } + + bindProviderAliasIdentity(provider, entry.alias); + + return provider; } diff --git a/packages/cli/src/runtime/runtimeSettings.ts b/packages/cli/src/runtime/runtimeSettings.ts index 55cd271f7..cdfa493b0 100644 --- a/packages/cli/src/runtime/runtimeSettings.ts +++ b/packages/cli/src/runtime/runtimeSettings.ts @@ -18,10 +18,10 @@ import { import type { ProviderManager, Profile, - IModel, ModelParams, RuntimeAuthScopeFlushResult, LoadBalancerProfile, + HydratedModel, } from '@vybestack/llxprt-code-core'; import { OAuthManager } from '../auth/oauth-manager.js'; import type { HistoryItemWithoutId } from '../ui/types.js'; @@ -817,7 +817,7 @@ export function getActiveProviderStatus(): ProviderRuntimeStatus { export async function listAvailableModels( providerName?: string, -): Promise { +): Promise { const manager = getProviderManagerOrThrow(); return manager.getAvailableModels(providerName); } @@ -1693,7 +1693,7 @@ export async function switchActiveProvider( defaultModel ?? ''; - let availableModels: IModel[] = []; + let availableModels: HydratedModel[] = []; if (typeof providerManager.getAvailableModels === 'function') { try { availableModels = (await providerManager.getAvailableModels(name)) ?? []; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b3d8dfd82..146ee6539 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -21,6 +21,7 @@ import { type HistoryItem, type IndividualToolCallDisplay, } from './types.js'; +import { type ModelsDialogData } from './commands/types.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { useResponsive } from './hooks/useResponsive.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; @@ -61,7 +62,6 @@ import { EditorType, type IdeContext, ideContext, - type IModel, // type IdeInfo, // TODO: Fix IDE integration getSettingsService, DebugLogger, @@ -97,7 +97,6 @@ import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from '../utils/events.js'; import { useRuntimeApi } from './contexts/RuntimeContext.js'; import { submitOAuthCode } from './oauth-submission.js'; -import { useProviderModelDialog } from './hooks/useProviderModelDialog.js'; import { useProviderDialog } from './hooks/useProviderDialog.js'; import { useLoadProfileDialog } from './hooks/useLoadProfileDialog.js'; import { useCreateProfileDialog } from './hooks/useCreateProfileDialog.js'; @@ -895,7 +894,6 @@ export const AppContainer = (props: AppContainerProps) => { const [showEscapePrompt, setShowEscapePrompt] = useState(false); const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false); const [isProcessing, setIsProcessing] = useState(false); - const [providerModels, setProviderModels] = useState([]); const [isPermissionsDialogOpen, setIsPermissionsDialogOpen] = useState(false); const openPermissionsDialog = useCallback(() => { @@ -920,6 +918,12 @@ export const AppContainer = (props: AppContainerProps) => { string | undefined >(undefined); + // Models dialog state + const [isModelsDialogOpen, setIsModelsDialogOpen] = useState(false); + const [modelsDialogData, setModelsDialogData] = useState< + ModelsDialogData | undefined + >(undefined); + // Queue error message state (for preventing slash/shell commands from being queued) const [queueErrorMessage, setQueueErrorMessage] = useState( null, @@ -949,6 +953,16 @@ export const AppContainer = (props: AppContainerProps) => { setSubagentDialogInitialName(undefined); }, []); + const openModelsDialog = useCallback((data?: ModelsDialogData) => { + setModelsDialogData(data); + setIsModelsDialogOpen(true); + }, []); + + const closeModelsDialog = useCallback(() => { + setIsModelsDialogOpen(false); + setModelsDialogData(undefined); + }, []); + const { showWorkspaceMigrationDialog, workspaceGeminiCLIExtensions, @@ -1163,31 +1177,6 @@ export const AppContainer = (props: AppContainerProps) => { config, }); - const { - showDialog: isProviderModelDialogOpen, - openDialog: openProviderModelDialogRaw, - handleSelect: handleProviderModelChange, - closeDialog: exitProviderModelDialog, - } = useProviderModelDialog({ - addMessage: (msg) => - addItem( - { type: msg.type as MessageType, text: msg.content }, - msg.timestamp.getTime(), - ), - appState, - }); - - const openProviderModelDialog = useCallback(async () => { - try { - const models = await runtime.listAvailableModels(); - setProviderModels(models); - } catch (e) { - console.error('Failed to load models:', e); - setProviderModels([]); - } - await openProviderModelDialogRaw(); - }, [openProviderModelDialogRaw, runtime]); - // Watch for model changes from config useEffect(() => { const checkModelChange = () => { @@ -1463,7 +1452,7 @@ export const AppContainer = (props: AppContainerProps) => { openSettingsDialog, openLoggingDialog, openSubagentDialog, - openProviderModelDialog, + openModelsDialog, openPermissionsDialog, openProviderDialog, openLoadProfileDialog, @@ -1486,7 +1475,7 @@ export const AppContainer = (props: AppContainerProps) => { openSettingsDialog, openLoggingDialog, openSubagentDialog, - openProviderModelDialog, + openModelsDialog, openPermissionsDialog, openProviderDialog, openLoadProfileDialog, @@ -2052,7 +2041,6 @@ export const AppContainer = (props: AppContainerProps) => { !isThemeDialogOpen && !isEditorDialogOpen && !isProviderDialogOpen && - !isProviderModelDialogOpen && !isToolsDialogOpen && !isCreateProfileDialogOpen && !showPrivacyNotice && @@ -2070,7 +2058,6 @@ export const AppContainer = (props: AppContainerProps) => { isThemeDialogOpen, isEditorDialogOpen, isProviderDialogOpen, - isProviderModelDialogOpen, isToolsDialogOpen, isCreateProfileDialogOpen, showPrivacyNotice, @@ -2121,7 +2108,6 @@ export const AppContainer = (props: AppContainerProps) => { isAuthenticating, isEditorDialogOpen, isProviderDialogOpen, - isProviderModelDialogOpen, isLoadProfileDialogOpen, isCreateProfileDialogOpen, isProfileListDialogOpen, @@ -2135,13 +2121,13 @@ export const AppContainer = (props: AppContainerProps) => { isPermissionsDialogOpen, isLoggingDialogOpen, isSubagentDialogOpen, + isModelsDialogOpen, // Dialog data providerOptions: isCreateProfileDialogOpen ? createProfileProviders : providerOptions, selectedProvider, - providerModels, currentModel, profiles, toolsDialogAction, @@ -2151,6 +2137,7 @@ export const AppContainer = (props: AppContainerProps) => { loggingDialogData, subagentDialogInitialView, subagentDialogInitialName, + modelsDialogData, // Profile management dialog data profileListItems, @@ -2298,11 +2285,6 @@ export const AppContainer = (props: AppContainerProps) => { handleProviderSelect, exitProviderDialog, - // Provider model dialog - openProviderModelDialog, - handleProviderModelChange, - exitProviderModelDialog, - // Load profile dialog openLoadProfileDialog, handleProfileSelect, @@ -2348,6 +2330,10 @@ export const AppContainer = (props: AppContainerProps) => { openSubagentDialog, closeSubagentDialog, + // Models dialog + openModelsDialog, + closeModelsDialog, + // Workspace migration dialog onWorkspaceMigrationDialogOpen, onWorkspaceMigrationDialogClose, @@ -2416,9 +2402,6 @@ export const AppContainer = (props: AppContainerProps) => { openProviderDialog, handleProviderSelect, exitProviderDialog, - openProviderModelDialog, - handleProviderModelChange, - exitProviderModelDialog, openLoadProfileDialog, handleProfileSelect, exitLoadProfileDialog, @@ -2446,6 +2429,8 @@ export const AppContainer = (props: AppContainerProps) => { closeLoggingDialog, openSubagentDialog, closeSubagentDialog, + openModelsDialog, + closeModelsDialog, onWorkspaceMigrationDialogOpen, onWorkspaceMigrationDialogClose, openPrivacyNotice, diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index a31687b73..7a02a9f86 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -10,43 +10,126 @@ import { OpenDialogActionReturn, MessageActionReturn, CommandKind, + ModelsDialogData, } from './types.js'; import { getRuntimeApi } from '../contexts/RuntimeContext.js'; +/** + * Parse command arguments for /model command + */ +interface ModelCommandArgs { + search?: string; + provider?: string; + tools?: boolean; + vision?: boolean; + reasoning?: boolean; + audio?: boolean; + all?: boolean; +} + +function parseArgs(args: string): ModelCommandArgs { + const parts = args.trim().split(/\s+/).filter(Boolean); + const result: ModelCommandArgs = {}; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part === '--provider' || part === '-p') { + // Check bounds and ensure next arg isn't another flag + if (i + 1 < parts.length && !parts[i + 1].startsWith('-')) { + result.provider = parts[++i]; + } + // If no valid value, provider remains undefined + } else if (part === '--reasoning' || part === '-r') { + result.reasoning = true; + } else if (part === '--tools' || part === '-t') { + result.tools = true; + } else if (part === '--vision') { + result.vision = true; + } else if (part === '--audio' || part === '-a') { + result.audio = true; + } else if (part === '--all') { + result.all = true; + } else if (!part.startsWith('-')) { + // Positional arg is search term (or direct model name) + result.search = part; + } + // Ignore --limit, --verbose, -v, -l (removed per spec) + } + + return result; +} + +/** + * Check if any filter flags are set + */ +function hasAnyFlags(args: ModelCommandArgs): boolean { + return !!( + args.provider || + args.tools || + args.vision || + args.reasoning || + args.audio || + args.all + ); +} + +/** + * Convert parsed args to dialog props + */ +function argsToDialogData(args: ModelCommandArgs): ModelsDialogData { + return { + initialSearch: args.search, + initialFilters: { + tools: args.tools ?? false, + vision: args.vision ?? false, + reasoning: args.reasoning ?? false, + audio: args.audio ?? false, + }, + includeDeprecated: false, + // --provider X sets the provider filter + providerOverride: args.provider ?? undefined, + // --all shows all providers (ignores current provider) + showAllProviders: args.all ?? false, + }; +} + export const modelCommand: SlashCommand = { name: 'model', - description: 'select or switch model', + description: 'browse, search, or switch models', kind: CommandKind.BUILT_IN, action: async ( - context: CommandContext, + _context: CommandContext, args: string, - ): Promise => { - const modelName = args?.trim(); - - // Always use provider model dialog if no model specified - if (!modelName) { - return { - type: 'dialog', - dialog: 'providerModel', - }; - } + ): Promise => { + const parsedArgs = parseArgs(args); - // Switch model in provider - try { - const runtime = getRuntimeApi(); - const result = await runtime.setActiveModel(modelName); - - return { - type: 'message', - messageType: 'info', - content: `Switched from ${result.previousModel ?? 'unknown'} to ${result.nextModel} in provider '${result.providerName}'`, - }; - } catch (error) { - return { - type: 'message', - messageType: 'error', - content: `Failed to switch model: ${error instanceof Error ? error.message : String(error)}`, - }; + // Direct switch: positional arg with NO flags + // e.g., "/model gpt-4o" switches directly + // but "/model gpt-4o --tools" opens dialog with search + filter + if (parsedArgs.search && !hasAnyFlags(parsedArgs)) { + try { + const runtime = getRuntimeApi(); + const result = await runtime.setActiveModel(parsedArgs.search); + return { + type: 'message', + messageType: 'info', + content: `Switched from ${result.previousModel ?? 'unknown'} to ${result.nextModel} in provider '${result.providerName}'`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to switch model: ${error instanceof Error ? error.message : String(error)}`, + }; + } } + + // Open dialog with filters + const dialogData = argsToDialogData(parsedArgs); + return { + type: 'dialog', + dialog: 'models', + dialogData, + }; }, }; diff --git a/packages/cli/src/ui/commands/providerCommand.test.ts b/packages/cli/src/ui/commands/providerCommand.test.ts index dff360b35..ca7fd9a92 100644 --- a/packages/cli/src/ui/commands/providerCommand.test.ts +++ b/packages/cli/src/ui/commands/providerCommand.test.ts @@ -18,6 +18,9 @@ import * as os from 'os'; import * as path from 'path'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +// This test writes real alias files, needs real providerAliases module +vi.unmock('../../providers/providerAliases.js'); + const mocks = vi.hoisted(() => { const runtimeApi = { getActiveProviderName: vi.fn(), diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 86e4bfd3a..3590c911b 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -141,6 +141,27 @@ export interface LoggingDialogData { entries: unknown[]; } +/** + * Type-safe dialog data for models dialog. + */ +export interface ModelsDialogData { + /** Pre-fill search term (from positional arg) */ + initialSearch?: string; + /** Pre-set capability filters */ + initialFilters?: { + tools?: boolean; + vision?: boolean; + reasoning?: boolean; + audio?: boolean; + }; + /** Include deprecated models */ + includeDeprecated?: boolean; + /** Override provider filter from --provider arg */ + providerOverride?: string | null; + /** Show all providers (from --all flag) */ + showAllProviders?: boolean; +} + /** * Type-safe dialog data for profile dialogs. */ @@ -157,13 +178,13 @@ export type DialogType = | 'privacy' | 'settings' | 'logging' - | 'providerModel' | 'permissions' | 'provider' | 'loadProfile' | 'createProfile' | 'saveProfile' | 'subagent' + | 'models' | 'profileList' | 'profileDetail' | 'profileEditor'; @@ -172,6 +193,7 @@ export type DialogType = export interface DialogDataMap { subagent: SubagentDialogData; logging: LoggingDialogData; + models: ModelsDialogData; profileDetail: ProfileDialogData; profileEditor: ProfileDialogData; } @@ -187,10 +209,15 @@ export interface OpenDialogActionReturn { * Dialog-specific data. Type depends on dialog: * - 'subagent': SubagentDialogData * - 'logging': LoggingDialogData + * - 'models': ModelsDialogData * - 'profileDetail'/'profileEditor': ProfileDialogData * - others: undefined */ - dialogData?: SubagentDialogData | LoggingDialogData | ProfileDialogData; + dialogData?: + | SubagentDialogData + | LoggingDialogData + | ModelsDialogData + | ProfileDialogData; } /** diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 4ccaa562e..9148ae82b 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -5,8 +5,14 @@ */ import { Box, Text } from 'ink'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { IdeIntegrationNudge } from '../IdeIntegrationNudge.js'; +import { useRuntimeApi } from '../contexts/RuntimeContext.js'; +import type { + HydratedModel, + Config, + Profile, +} from '@vybestack/llxprt-code-core'; // import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js'; // TODO: Not yet ported from upstream import { FolderTrustDialog } from './FolderTrustDialog.js'; import { WelcomeDialog } from './WelcomeOnboarding/WelcomeDialog.js'; @@ -19,14 +25,12 @@ import { AuthDialog } from './AuthDialog.js'; import { OAuthCodeDialog } from './OAuthCodeDialog.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { ProviderDialog } from './ProviderDialog.js'; -import { ProviderModelDialog } from './ProviderModelDialog.js'; import { LoadProfileDialog } from './LoadProfileDialog.js'; import { ProfileCreateWizard } from './ProfileCreateWizard/index.js'; import { ProfileListDialog } from './ProfileListDialog.js'; import { ProfileDetailDialog } from './ProfileDetailDialog.js'; import { ProfileInlineEditor } from './ProfileInlineEditor.js'; import { ToolsDialog } from './ToolsDialog.js'; -import type { Profile, Config } from '@vybestack/llxprt-code-core'; import { PrivacyNotice } from '../privacy/PrivacyNotice.js'; import { WorkspaceMigrationDialog } from './WorkspaceMigrationDialog.js'; // import { ProQuotaDialog } from './ProQuotaDialog.js'; // TODO: Not yet ported from upstream @@ -35,6 +39,7 @@ import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js' import { LoggingDialog } from './LoggingDialog.js'; import { SubagentManagerDialog } from './SubagentManagement/index.js'; import { SubagentView } from './SubagentManagement/types.js'; +import { ModelsDialog } from './ModelDialog.js'; import { theme } from '../semantic-colors.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; @@ -58,14 +63,98 @@ export const DialogManager = ({ }: DialogManagerProps) => { const uiState = useUIState(); const uiActions = useUIActions(); + const runtime = useRuntimeApi(); const { constrainHeight, terminalHeight, mainAreaWidth } = uiState; // staticExtraHeight not yet implemented in LLxprt const staticExtraHeight = 0; + // Get current provider for ModelsDialog + const currentProvider = useMemo(() => { + try { + return runtime.getActiveProviderName() || null; + } catch { + return null; + } + }, [runtime]); + const handlePrivacyNoticeExit = useCallback(() => { uiActions.handlePrivacyNoticeExit(); }, [uiActions]); + // Handler for ModelsDialog selection + const handleModelsDialogSelect = useCallback( + async (model: HydratedModel) => { + try { + const selectedProvider = model.provider; + + // Check if we need to switch providers + // Switch if: provider differs OR no current provider set + if (selectedProvider !== currentProvider) { + // 1. Switch provider first + const switchResult = await runtime.switchActiveProvider( + selectedProvider, + { addItem }, + ); + + // 2. Build messages in correct order + const messages: string[] = []; + + // Provider switch message + messages.push( + currentProvider + ? `Switched from ${currentProvider} to ${switchResult.nextProvider}` + : `Switched to ${switchResult.nextProvider}`, + ); + + // Base URL message (extract from switchResult) + const baseUrlMsg = (switchResult.infoMessages ?? []).find( + (m) => m?.includes('Base URL') || m?.includes('base URL'), + ); + if (baseUrlMsg) messages.push(baseUrlMsg); + + // Set the selected model (override provider's default) + await runtime.setActiveModel(model.id); + + // Model message with user's selected model + messages.push( + `Active model is '${model.id}' for provider '${selectedProvider}'.`, + ); + + // /key reminder (if not gemini) + if (selectedProvider !== 'gemini') { + messages.push('Use /key to set API key if needed.'); + } + + // Show all messages + for (const msg of messages) { + addItem({ type: 'info', text: msg }, Date.now()); + } + } else { + // Same provider — just set model + const result = await runtime.setActiveModel(model.id); + addItem( + { + type: 'info', + text: `Active model is '${result.nextModel}' for provider '${result.providerName}'.`, + }, + Date.now(), + ); + } + } catch (e) { + const status = runtime.getActiveProviderStatus(); + addItem( + { + type: 'error', + text: `Failed to switch model for provider '${status.providerName ?? 'unknown'}': ${e instanceof Error ? e.message : String(e)}`, + }, + Date.now(), + ); + } + uiActions.closeModelsDialog(); + }, + [runtime, addItem, uiActions, currentProvider], + ); + // TODO: IdeTrustChangeDialog not yet ported from upstream // if (uiState.showIdeRestartPrompt) { // return ; @@ -238,18 +327,6 @@ export const DialogManager = ({ ); } - if (uiState.isProviderModelDialogOpen) { - return ( - - - - ); - } if (uiState.isLoadProfileDialogOpen) { return ( @@ -389,5 +466,22 @@ export const DialogManager = ({ ); } + if (uiState.isModelsDialogOpen) { + return ( + + + + ); + } + return null; }; diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx new file mode 100644 index 000000000..76482fd75 --- /dev/null +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -0,0 +1,628 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { + useState, + useMemo, + useEffect, + useCallback, + useRef, +} from 'react'; +import { Box, Text } from 'ink'; +import { SemanticColors } from '../colors.js'; +import { useResponsive } from '../hooks/useResponsive.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { useRuntimeApi } from '../contexts/RuntimeContext.js'; +import { type HydratedModel } from '@vybestack/llxprt-code-core'; + +export interface CapabilityFilters { + vision: boolean; + reasoning: boolean; +} + +export interface ModelsDialogProps { + onSelect: (model: HydratedModel) => void; + onClose: () => void; + initialSearch?: string; + initialFilters?: Partial; + includeDeprecated?: boolean; + /** User's current provider from runtime */ + currentProvider?: string | null; + /** Override provider filter from --provider arg */ + initialProviderFilter?: string | null; + /** Show all providers (from --all flag) */ + showAllProviders?: boolean; +} + +interface ModelsDialogState { + searchTerm: string; + filters: CapabilityFilters; + selectedIndex: number; + scrollOffset: number; + mode: 'search' | 'filter'; + allModels: HydratedModel[]; + isLoading: boolean; + /** Current provider filter, null means "all providers" */ + providerFilter: string | null; +} + +// Format context window (e.g., 200000 -> "200K", 1000000 -> "1M") +function formatContext(tokens: number | undefined): string { + if (!tokens) return '-'; + if (tokens >= 1_000_000) return `${tokens / 1_000_000}M`; + if (tokens >= 1_000) return `${Math.floor(tokens / 1_000)}K`; + return String(tokens); +} + +// Format capabilities as letters (V=vision, R=reasoning) +// Note: All models now have tool support (filtered at provider level), audio not supported +function formatCaps( + caps: HydratedModel['capabilities'], + narrow: boolean, +): string { + const letters: string[] = []; + if (caps?.vision) letters.push('V'); + if (caps?.reasoning) letters.push('R'); + return narrow ? letters.join('') : letters.join(' '); +} + +// Truncate model ID if too long +function truncateModelId(id: string, maxLen: number): string { + if (id.length <= maxLen) return id; + return id.slice(0, maxLen - 1) + '\u2026'; +} + +export const ModelsDialog: React.FC = ({ + onSelect, + onClose, + initialSearch = '', + initialFilters = {}, + includeDeprecated = false, + currentProvider = null, + initialProviderFilter, + showAllProviders = false, +}) => { + const { isNarrow, width } = useResponsive(); + const runtime = useRuntimeApi(); + + // Get supported providers from runtime + const supportedProviders = useMemo(() => { + try { + return runtime.listProviders(); + } catch { + return []; + } + }, [runtime]); + + // Determine initial provider filter: + // 1. --all flag → null (show all) + // 2. --provider X → X + // 3. Neither → currentProvider (if set) + // 4. No current provider → null (show all) + const computedInitialFilter = useMemo(() => { + if (showAllProviders) return null; + if (initialProviderFilter !== undefined) return initialProviderFilter; + return currentProvider ?? null; + }, [showAllProviders, initialProviderFilter, currentProvider]); + + const [state, setState] = useState({ + searchTerm: initialSearch, + filters: { + vision: initialFilters.vision ?? false, + reasoning: initialFilters.reasoning ?? false, + }, + selectedIndex: 0, + scrollOffset: 0, + mode: 'search', + allModels: [], + isLoading: true, + providerFilter: computedInitialFilter, + }); + + // Track which providers we've already fetched to avoid re-fetching + const fetchedProvidersRef = useRef>(new Set()); + + // Load models - only fetch from providers we need to display + useEffect(() => { + let cancelled = false; + + const loadModels = async () => { + // Determine which providers to fetch from: + // - If providerFilter is set → only that provider (fast path) + // - If showing all (null) → fetch from all providers + const providersToFetch = state.providerFilter + ? [state.providerFilter] + : supportedProviders; + + // Filter out providers we've already fetched + const newProviders = providersToFetch.filter( + (p) => !fetchedProvidersRef.current.has(p), + ); + + // If we have nothing new to fetch, just clear loading state + if (newProviders.length === 0) { + setState((prev) => ({ ...prev, isLoading: false })); + return; + } + + try { + // Fetch in parallel for better performance + const results = await Promise.allSettled( + newProviders.map((providerName) => + runtime.listAvailableModels(providerName), + ), + ); + + const newModels: HydratedModel[] = []; + for (const result of results) { + if (result.status === 'fulfilled') { + newModels.push(...result.value); + } + } + + if (!cancelled) { + // Track which providers we've now fetched + newProviders.forEach((p) => fetchedProvidersRef.current.add(p)); + + // Merge new models with existing, deduplicating + setState((prev) => { + const seen = new Set(); + const mergedModels = [...prev.allModels, ...newModels].filter( + (m) => { + const key = `${m.provider}:${m.id}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }, + ); + + return { + ...prev, + allModels: mergedModels, + isLoading: false, + }; + }); + } + } catch { + if (!cancelled) { + setState((prev) => ({ ...prev, isLoading: false })); + } + } + }; + + setState((prev) => ({ ...prev, isLoading: true })); + loadModels(); + + return () => { + cancelled = true; + }; + }, [runtime, supportedProviders, state.providerFilter]); + + // Filter models based on search and capability filters + const filteredModels = useMemo(() => { + let models = state.allModels; + + // 1. Provider filter - models already have provider name set + if (state.providerFilter) { + models = models.filter((m) => m.provider === state.providerFilter); + } + + // 2. Filter deprecated (only if hydrated with metadata) + if (!includeDeprecated) { + models = models.filter((m) => m.metadata?.status !== 'deprecated'); + } + + // 3. Filter by search term + if (state.searchTerm) { + const term = state.searchTerm.toLowerCase(); + models = models.filter( + (m) => + m.id.toLowerCase().includes(term) || + m.name.toLowerCase().includes(term) || + (m.modelId?.toLowerCase().includes(term) ?? false) || + m.provider.toLowerCase().includes(term), + ); + } + + // 4. Filter by capabilities (AND logic) - only if hydrated + // Note: All models now have tool support (filtered at provider level) + if (state.filters.vision) { + models = models.filter((m) => m.capabilities?.vision); + } + if (state.filters.reasoning) { + models = models.filter((m) => m.capabilities?.reasoning); + } + + // Sort by provider, then model ID + models.sort((a, b) => { + if (a.provider !== b.provider) { + return a.provider.localeCompare(b.provider); + } + return a.id.localeCompare(b.id); + }); + + return models; + }, [ + state.allModels, + state.providerFilter, + state.searchTerm, + state.filters, + includeDeprecated, + ]); + + // Reset selection and scroll when filters change + useEffect(() => { + setState((prev) => ({ ...prev, selectedIndex: 0, scrollOffset: 0 })); + }, [state.searchTerm, state.filters, state.providerFilter]); + + // Get active filter names for display + const activeFilters = useMemo(() => { + const active: string[] = []; + if (state.filters.vision) active.push('V'); + if (state.filters.reasoning) active.push('R'); + return active; + }, [state.filters]); + + // Baseline count: models after provider + deprecated filters, before search/caps + const baselineCount = useMemo(() => { + let models = state.allModels; + + // 1. Provider filter + if (state.providerFilter) { + models = models.filter((m) => m.provider === state.providerFilter); + } + + // 2. Filter deprecated + if (!includeDeprecated) { + models = models.filter((m) => m.metadata?.status !== 'deprecated'); + } + + return models.length; + }, [state.allModels, state.providerFilter, includeDeprecated]); + + // Check if search or capability filters are active + const hasActiveFilters = + state.searchTerm.length > 0 || activeFilters.length > 0; + + // Handle keyboard input + const handleKeypress = useCallback( + (key: { + name?: string; + sequence?: string; + ctrl?: boolean; + meta?: boolean; + }) => { + // Escape handling + if (key.name === 'escape') { + if (state.searchTerm.length > 0) { + setState((prev) => ({ ...prev, searchTerm: '' })); + } else { + onClose(); + } + return; + } + + // Tab to switch modes + if (key.name === 'tab') { + setState((prev) => ({ + ...prev, + mode: prev.mode === 'search' ? 'filter' : 'search', + })); + return; + } + + // Ctrl+A toggles provider filter (all providers vs current provider) + if (key.name === 'a' && key.ctrl && currentProvider) { + setState((prev) => ({ + ...prev, + providerFilter: + prev.providerFilter === null + ? currentProvider // Switch to current provider + : null, // Switch to all providers + selectedIndex: 0, + scrollOffset: 0, + })); + return; + } + + // Enter to select + if (key.name === 'return' && filteredModels.length > 0) { + const selected = filteredModels[state.selectedIndex]; + if (selected) { + onSelect(selected); + } + return; + } + + // Navigation with scroll (arrow keys only) + const maxVisible = 15; + if (key.name === 'up') { + setState((prev) => { + const newIndex = Math.max(0, prev.selectedIndex - 1); + let newOffset = prev.scrollOffset; + // Scroll up if selection goes above visible area + if (newIndex < prev.scrollOffset) { + newOffset = newIndex; + } + return { ...prev, selectedIndex: newIndex, scrollOffset: newOffset }; + }); + return; + } + if (key.name === 'down') { + setState((prev) => { + const newIndex = Math.min( + filteredModels.length - 1, + prev.selectedIndex + 1, + ); + let newOffset = prev.scrollOffset; + // Scroll down if selection goes below visible area + if (newIndex >= prev.scrollOffset + maxVisible) { + newOffset = newIndex - maxVisible + 1; + } + return { ...prev, selectedIndex: newIndex, scrollOffset: newOffset }; + }); + return; + } + + // Filter mode: toggle filters with letter keys (V=vision, R=reasoning) + if (state.mode === 'filter') { + if (key.name === 'v' || key.sequence === 'v') { + setState((prev) => ({ + ...prev, + filters: { ...prev.filters, vision: !prev.filters.vision }, + })); + return; + } + if (key.name === 'r' || key.sequence === 'r') { + setState((prev) => ({ + ...prev, + filters: { ...prev.filters, reasoning: !prev.filters.reasoning }, + })); + return; + } + } + + // Search mode: typing + if (state.mode === 'search') { + if (key.name === 'backspace' || key.name === 'delete') { + setState((prev) => ({ + ...prev, + searchTerm: prev.searchTerm.slice(0, -1), + })); + return; + } + // Add printable characters to search + if ( + key.sequence && + key.sequence.length === 1 && + !key.ctrl && + !key.meta && + key.sequence.match(/[\x20-\x7E]/) + ) { + setState((prev) => ({ + ...prev, + searchTerm: prev.searchTerm + key.sequence, + })); + } + } + }, + [ + state.mode, + state.searchTerm, + state.selectedIndex, + filteredModels, + onClose, + onSelect, + currentProvider, + ], + ); + + useKeypress(handleKeypress, { isActive: true }); + + // Column widths - model ID is dynamic based on longest name + const providerWidth = isNarrow ? 0 : 14; // Hide in narrow mode + const ctxWidth = 10; // Enough for "1.048576M" + const capsWidth = isNarrow ? 5 : 10; + const fixedWidth = 2 + 2 + providerWidth + ctxWidth + 2 + capsWidth + 4; // indicator + gap + padding + + // Calculate max model ID length from filtered models + const maxModelIdLen = useMemo(() => { + if (filteredModels.length === 0) return 20; + return Math.max(...filteredModels.map((m) => (m.modelId || m.id).length)); + }, [filteredModels]); + + // Model ID column: use actual max length, but cap at available space + const availableForModelId = Math.max(20, width - fixedWidth - 6); // -6 for border/padding + const modelIdWidth = Math.min(maxModelIdLen, availableForModelId); + + // Total row width for separator line + const tableRowWidth = + 2 + + modelIdWidth + + 2 + + (isNarrow ? 0 : providerWidth) + + ctxWidth + + 2 + + capsWidth; + + // Max visible rows and scrolling + const maxRows = 15; + const visibleModels = filteredModels.slice( + state.scrollOffset, + state.scrollOffset + maxRows, + ); + + // Render filter button + const renderFilterButton = (label: string, active: boolean) => ( + + [{label}] + + ); + + // Render model row (visibleIndex is 0-based within visible slice) + const renderRow = (model: HydratedModel, visibleIndex: number) => { + const absoluteIndex = state.scrollOffset + visibleIndex; + const isSelected = absoluteIndex === state.selectedIndex; + const indicator = isSelected ? '\u25CF ' : '\u25CB '; + const color = isSelected + ? SemanticColors.text.accent + : SemanticColors.text.primary; + + // Use modelId if hydrated, otherwise use id + const displayId = model.modelId || model.id; + + return ( + + + {indicator} + {truncateModelId(displayId, modelIdWidth).padEnd(modelIdWidth)} + {' '} + {!isNarrow && model.provider.padEnd(providerWidth)} + {formatContext(model.contextWindow).padStart(ctxWidth)} + {' '} + {formatCaps(model.capabilities, isNarrow).padEnd(capsWidth)} + + + ); + }; + + if (state.isLoading) { + return ( + + Loading models... + + ); + } + + return ( + + {/* Header */} + + + Models ({state.providerFilter ?? 'all providers'}) + + {currentProvider && ( + + {state.providerFilter === null + ? `[^A] ${currentProvider} only` + : '[^A] show all'} + + )} + + + {/* Search bar */} + + + Search:{' '} + + {state.searchTerm} + {state.mode === 'search' && ( + {'\u258C'} + )} + + {' '}Found {filteredModels.length} + {hasActiveFilters && ` of ${baselineCount}`} + + + + {/* Filter toggles */} + + + Filters:{' '} + + {renderFilterButton('V', state.filters.vision)} + + {renderFilterButton('R', state.filters.reasoning)} + {activeFilters.length > 0 && ( + + {' '}Active: {activeFilters.join(', ')} + + )} + + + {/* Table header */} + + + {' '} + {'MODEL ID'.padEnd(modelIdWidth)} + {' '} + {!isNarrow && 'PROVIDER'.padEnd(providerWidth)} + {'CTX'.padStart(ctxWidth)} + {' '} + {'CAPS'.padEnd(capsWidth)} + + + + + {'\u2500'.repeat(tableRowWidth)} + + + + {/* Model rows */} + {visibleModels.length > 0 ? ( + visibleModels.map((model, i) => renderRow(model, i)) + ) : ( + + No models match your search + + )} + + {/* Scroll position indicator */} + {filteredModels.length > maxRows && ( + + + Showing {state.scrollOffset + 1}- + {Math.min(state.scrollOffset + maxRows, filteredModels.length)} of{' '} + {filteredModels.length} + + + )} + + {/* Legend */} + + + V=vision R=reasoning (all models support tools) + + + + {/* Help bar */} + + + {isNarrow + ? `\u2191/\u2193 Enter${currentProvider ? ' ^A' : ''} Tab Esc` + : `\u2191/\u2193 select Enter copy ID${currentProvider ? ` ^A ${state.providerFilter === null ? currentProvider + ' only' : 'all providers'}` : ''} Tab filters Esc close`} + + + + ); +}; diff --git a/packages/cli/src/ui/components/ProfileCreateWizard/ModelSelectStep.tsx b/packages/cli/src/ui/components/ProfileCreateWizard/ModelSelectStep.tsx index b5a72d0fb..50996f940 100644 --- a/packages/cli/src/ui/components/ProfileCreateWizard/ModelSelectStep.tsx +++ b/packages/cli/src/ui/components/ProfileCreateWizard/ModelSelectStep.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { Box, Text } from 'ink'; import { Colors } from '../../colors.js'; import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; @@ -13,6 +13,8 @@ import { useKeypress } from '../../hooks/useKeypress.js'; import { PROVIDER_OPTIONS } from './constants.js'; import { getStepPosition } from './utils.js'; import type { WizardState } from './types.js'; +import { useRuntimeApi } from '../../contexts/RuntimeContext.js'; +import type { HydratedModel } from '@vybestack/llxprt-code-core'; interface ModelSelectStepProps { state: WizardState; @@ -75,13 +77,43 @@ export const ModelSelectStep: React.FC = ({ } }, [customModelInput, onUpdateModel, onContinue]); + // Fetch models from provider via runtime API (hydrated with models.dev data) + const runtime = useRuntimeApi(); + const [models, setModels] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadModels = async () => { + if (!state.config.provider) { + setIsLoading(false); + return; + } + + setIsLoading(true); + try { + const hydratedModels: HydratedModel[] = + await runtime.listAvailableModels(state.config.provider); + // Filter out deprecated models and extract IDs + const modelIds = hydratedModels + .filter((m) => m.metadata?.status !== 'deprecated') + .map((m) => m.id); + setModels(modelIds); + } catch { + // If fetching fails, allow manual entry + setModels([]); + } + setIsLoading(false); + }; + loadModels(); + }, [runtime, state.config.provider]); + + const hasKnownModels = models.length > 0; + + // Still need providerOption for label display const providerOption = PROVIDER_OPTIONS.find( (p) => p.value === state.config.provider, ); - const models = providerOption?.knownModels || []; - const hasKnownModels = models.length > 0; - // Build model list with "custom" option if provider has known models const modelItems = hasKnownModels ? [ @@ -111,6 +143,19 @@ export const ModelSelectStep: React.FC = ({ const { current, total } = getStepPosition(state); + // Show loading state while fetching models + if (isLoading) { + return ( + + + Create New Profile - Step {current} of {total} + + + Loading models... + + ); + } + return ( @@ -122,7 +167,7 @@ export const ModelSelectStep: React.FC = ({ {focusedComponent === 'input' - ? 'Enter the model name exactly as it appears in your provider's documentation' + ? "Enter the model name exactly as it appears in your provider's documentation" : `Choose the AI model for ${providerOption?.label || state.config.provider}`} diff --git a/packages/cli/src/ui/components/ProviderModelDialog.responsive.test.tsx b/packages/cli/src/ui/components/ProviderModelDialog.responsive.test.tsx deleted file mode 100644 index 6a9cfc45b..000000000 --- a/packages/cli/src/ui/components/ProviderModelDialog.responsive.test.tsx +++ /dev/null @@ -1,439 +0,0 @@ -/** - * @license - * Copyright 2025 Vybestack LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { render } from 'ink-testing-library'; -import { - describe, - it, - expect, - vi, - beforeEach, - type MockedFunction, -} from 'vitest'; -import { ProviderModelDialog } from './ProviderModelDialog.js'; -import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { IModel } from '../../providers/index.js'; - -vi.mock('../hooks/useTerminalSize.js'); - -const testModels: IModel[] = [ - { - id: 'gpt-4', - name: 'GPT-4', - provider: 'openai', - supportedToolFormats: ['openai'], - }, - { - id: 'gpt-3.5-turbo', - name: 'GPT-3.5 Turbo', - provider: 'openai', - supportedToolFormats: ['openai'], - }, - { - id: 'claude-3-opus-20240229', - name: 'Claude 3 Opus', - provider: 'anthropic', - supportedToolFormats: ['anthropic'], - }, - { - id: 'claude-3-sonnet-20240229', - name: 'Claude 3 Sonnet', - provider: 'anthropic', - supportedToolFormats: ['anthropic'], - }, - { - id: 'gemini-pro-very-long-model-name-that-should-be-truncated', - name: 'Gemini Pro Long', - provider: 'google', - supportedToolFormats: ['google'], - }, - { - id: 'text-embedding-ada-002', - name: 'Ada Embedding', - provider: 'openai', - supportedToolFormats: ['openai'], - }, - { - id: 'another-very-long-model-identifier-for-testing-truncation', - name: 'Long Model', - provider: 'test', - supportedToolFormats: ['test'], - }, -]; - -describe('ProviderModelDialog Responsive Behavior', () => { - let mockUseTerminalSize: MockedFunction; - const mockOnSelect = vi.fn(); - const mockOnClose = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - mockUseTerminalSize = useTerminalSize as MockedFunction< - typeof useTerminalSize - >; - }); - - describe('NARROW width behavior (< 80 cols)', () => { - beforeEach(() => { - mockUseTerminalSize.mockReturnValue({ columns: 60, rows: 20 }); - }); - - it('should prioritize search-first approach with help text below search', () => { - const { lastFrame } = render( - , - ); - - const output = lastFrame(); - - // Should prominently show search functionality - expect(output).toMatch(/search:/i); - expect(output).toContain('▌'); // Active search cursor - - // Should show help text below search - expect(output).toMatch(/tab.*switch modes/i); - expect(output).toMatch(/enter.*select/i); - expect(output).toMatch(/esc.*cancel/i); - - // Should have minimal borders to save space - expect(output).not.toMatch(/┌─+┐/); // No fancy borders or simpler borders - - // Should show model IDs (may not be truncated at narrow width anymore) - expect(output).toContain('gemini-pro'); // Should show at least part of the model name - - // Should show model results but prioritize search - expect(output).toContain('gpt-4'); - expect(output).toContain('claude-3-opus'); - }); - - it('should focus search over grid layout for narrow width', () => { - const { lastFrame } = render( - , - ); - - const output = lastFrame(); - - // Should not show complex multi-column layout - expect(output).not.toMatch(/├|┤|│.*│.*│/); // No multi-column separators - - // Search should be the primary focus - expect(output).toMatch(/search:.*▌/); - - // Should show models in simple list format (note: gpt-3.5-turbo is selected, hence ●) - expect(output).toMatch(/○ gpt-4/); - expect(output).toMatch(/● gpt-3.5-turbo/); - }); - }); - - describe('STANDARD width behavior (80-120 cols)', () => { - beforeEach(() => { - mockUseTerminalSize.mockReturnValue({ columns: 100, rows: 20 }); - }); - - it('should show condensed layout with abbreviated model descriptions', () => { - const { lastFrame } = render( - , - ); - - const output = lastFrame(); - - // Should have proper borders - expect(output).toMatch(/┌─+┐|╭─+╮/); - - // Should show search but not as prominently - expect(output).toMatch(/search:/i); - - // Should show model names (may not truncate anymore) - expect(output).toContain('gemini-pro'); // Should show model names - - // Should show model grid in condensed form - expect(output).toContain('gpt-4'); - expect(output).toContain('● claude-3-opus-20240229'); // Selected model - - // Should show model count information - expect(output).toMatch(/found \d+ of \d+ models/i); - - // Should have scrolling indicators if needed - expect(output).toMatch(/tab.*switch modes/i); - }); - - it('should balance search functionality with grid layout', () => { - const { lastFrame } = render( - , - ); - - const output = lastFrame(); - - // Should show both search and grid effectively - expect(output).toMatch(/search:.*▌/); - - // Should have multi-column layout but condensed - const lines = output!.split('\n'); - const modelLines = lines.filter( - (line) => line.includes('○') || line.includes('●'), - ); - expect(modelLines.length).toBeGreaterThan(2); // Multiple rows - - // Should show current selection info - expect(output).toMatch(/● gpt-4/); - }); - }); - - describe('WIDE width behavior (> 120 cols)', () => { - beforeEach(() => { - mockUseTerminalSize.mockReturnValue({ columns: 180, rows: 20 }); - }); - - it('should show full layout with all model details visible', () => { - const { lastFrame } = render( - , - ); - - const output = lastFrame(); - - // Should show full model names without truncation - expect(output).toContain( - 'gemini-pro-very-long-model-name-that-should-be-truncated', - ); - expect(output).toContain( - 'another-very-long-model-identifier-for-testing-truncation', - ); - - // Should have full decorative borders - expect(output).toMatch(/┌─+┐|╭─+╮/); - - // Should show comprehensive search interface - expect(output).toMatch(/search:.*▌/); - - // Should show complete instructions (simplified check) - expect(output).toMatch(/tab.*switch modes/i); - expect(output).toMatch(/search models/i); - - // Should show the selected model with ● marker - expect(output).toMatch(/● text-embedding-ada-002/); - - // Should show model count information - expect(output).toMatch(/found \d+ of \d+ models/i); - }); - - it('should utilize full width for optimal model grid layout', () => { - const { lastFrame } = render( - , - ); - - const output = lastFrame(); - - // Should efficiently use wide layout for multiple columns - expect(output).toContain('● claude-3-sonnet-20240229'); - - // Should show all models with full names - expect(output).toContain('gpt-4'); - expect(output).toContain('gpt-3.5-turbo'); - expect(output).toContain('claude-3-opus-20240229'); - - // Should pack efficiently - fewer rows due to more columns - const lines = output!.split('\n'); - const contentLines = lines.filter( - (line) => line.includes('○') || line.includes('●'), - ); - expect(contentLines.length).toBeLessThanOrEqual(4); // Efficient packing - }); - - it('should create proper fixed-width columns with consistent spacing', () => { - const { lastFrame } = render( - , - ); - - const output = lastFrame(); - - // Parse model grid lines to check column layout - const lines = output!.split('\n'); - const modelLines = lines.filter( - (line) => line.includes('○') || line.includes('●'), - ); - - // Each model line should have consistent column widths - // Models should be spaced with proper fixed-width columns, not just single spaces - expect(modelLines.length).toBeGreaterThan(0); - const firstLine = modelLines[0]; - - // Should NOT have models separated by only single spaces (the zigzag issue) - // Instead should have proper column alignment - const modelMatches = firstLine.match(/[○●]\s+[^\s]+/g); - // Verify proper column spacing if multiple models on same line - const hasProperSpacing = - !modelMatches || - modelMatches.length <= 1 || - (() => { - const model1End = - firstLine.indexOf(modelMatches[0]) + modelMatches[0].length; - const model2Start = firstLine.indexOf(modelMatches[1]); - return model2Start - model1End >= 2; - })(); - expect(hasProperSpacing).toBe(true); // Fixed-width columns should have adequate spacing - }); - }); - - describe('Search functionality across breakpoints', () => { - it('should maintain search functionality at all breakpoints', () => { - const widths = [60, 100, 180]; - - widths.forEach((width) => { - mockUseTerminalSize.mockReturnValue({ columns: width, rows: 20 }); - - const { lastFrame } = render( - , - ); - - const output = lastFrame(); - - // Search should be available at all breakpoints - expect(output).toMatch(/search:/i); - expect(output).toContain('▌'); // Search cursor - - // Models should be visible - expect(output).toContain('gpt-4'); - }); - }); - - it('should show search results count at standard and wide breakpoints', () => { - // Standard width - mockUseTerminalSize.mockReturnValue({ columns: 100, rows: 20 }); - - let { lastFrame } = render( - , - ); - - expect(lastFrame()).toMatch(/found \d+ of \d+ models/i); - - // Wide width - mockUseTerminalSize.mockReturnValue({ columns: 180, rows: 20 }); - - ({ lastFrame } = render( - , - )); - - expect(lastFrame()).toMatch(/found \d+ of \d+ models/i); - }); - }); - - describe('Responsive breakpoint transitions', () => { - it('should handle transitions between breakpoints correctly', () => { - // Test at NARROW/STANDARD boundary (80 cols) - mockUseTerminalSize.mockReturnValue({ columns: 80, rows: 20 }); - - const { lastFrame: standardFrame } = render( - , - ); - - const standardOutput = standardFrame(); - // At 80 columns, should be STANDARD behavior - expect(standardOutput).toMatch(/┌─+┐|╭─+╮/); // Should have borders - expect(standardOutput).toMatch(/found.*models/i); // Should show model count - - // Test at STANDARD/WIDE boundary (120 cols) - mockUseTerminalSize.mockReturnValue({ columns: 120, rows: 20 }); - - const { lastFrame: wideFrame } = render( - , - ); - - const wideOutput = wideFrame(); - // At 120 columns, should be STANDARD (not WIDE yet) - expect(wideOutput).toContain('gemini-pro'); // Should show model name - }); - }); - - describe('Semantic color preservation', () => { - it('should maintain semantic colors across all breakpoints', () => { - const widths = [60, 100, 180]; - - widths.forEach((width) => { - mockUseTerminalSize.mockReturnValue({ columns: width, rows: 20 }); - - const { lastFrame } = render( - , - ); - - const output = lastFrame(); - - // Should show selected model with accent color (●) - // Note: Model names might be truncated at narrow width - expect(output).toMatch(/● claude-3-opus/); - - // Should show unselected models with appropriate colors (○) - expect(output).toMatch(/○ gpt-4/); - - // Search cursor should use accent color - expect(output).toContain('▌'); - }); - }); - }); -}); diff --git a/packages/cli/src/ui/components/ProviderModelDialog.test.tsx b/packages/cli/src/ui/components/ProviderModelDialog.test.tsx deleted file mode 100644 index a34b4522b..000000000 --- a/packages/cli/src/ui/components/ProviderModelDialog.test.tsx +++ /dev/null @@ -1,231 +0,0 @@ -/** - * @license - * Copyright 2025 Vybestack LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { render } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { ProviderModelDialog } from './ProviderModelDialog.js'; -import { IModel } from '../../providers/index.js'; - -// Mock the responsive hooks and utilities -vi.mock('../hooks/useResponsive.js', () => ({ - useResponsive: vi.fn(), -})); - -vi.mock('../utils/responsive.js', () => ({ - truncateEnd: vi.fn((text: string, maxLength: number) => - text.length > maxLength ? text.slice(0, maxLength - 3) + '...' : text, - ), -})); - -import { useResponsive } from '../hooks/useResponsive.js'; - -const mockUseResponsive = vi.mocked(useResponsive); - -describe('ProviderModelDialog', () => { - const mockModels: IModel[] = [ - { - id: 'gpt-4', - name: 'GPT-4', - provider: 'openai', - supportedToolFormats: ['openai'], - }, - { - id: 'gpt-3.5-turbo', - name: 'GPT-3.5 Turbo', - provider: 'openai', - supportedToolFormats: ['openai'], - }, - { - id: 'claude-3-opus-20240229', - name: 'Claude 3 Opus', - provider: 'anthropic', - supportedToolFormats: ['anthropic'], - }, - { - id: 'claude-3-sonnet-20240229', - name: 'Claude 3 Sonnet', - provider: 'anthropic', - supportedToolFormats: ['anthropic'], - }, - { - id: 'gemini-pro', - name: 'Gemini Pro', - provider: 'google', - supportedToolFormats: ['google'], - }, - { - id: 'very-long-model-name-that-should-be-truncated', - name: 'Long Model', - provider: 'test', - supportedToolFormats: ['test'], - }, - ]; - - const defaultProps = { - models: mockModels, - currentModel: 'gpt-4', - onSelect: vi.fn(), - onClose: vi.fn(), - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('column layout calculation', () => { - it('should use 1 column for narrow width (< 80)', () => { - mockUseResponsive.mockReturnValue({ - width: 70, - breakpoint: 'NARROW', - isNarrow: true, - isStandard: false, - isWide: false, - }); - - const { container } = render(); - - // In narrow mode, should have single column layout - // Each model should be in its own row - const modelElements = container.querySelectorAll( - '[data-testid="model-item"]', - ); - expect(modelElements.length).toBe(0); // Need to add test IDs first - }); - - it('should calculate appropriate column width based on longest model name', () => { - mockUseResponsive.mockReturnValue({ - width: 120, - breakpoint: 'STANDARD', - isNarrow: false, - isStandard: true, - isWide: false, - }); - - render(); - - // The longest model name is 'very-long-model-name-that-should-be-truncated' (44 chars) - // Column width should be calculated based on this plus padding - // Expected column width should accommodate the content properly - }); - - it('should limit columns based on terminal width and content fit', () => { - mockUseResponsive.mockReturnValue({ - width: 200, - breakpoint: 'WIDE', - isNarrow: false, - isStandard: false, - isWide: true, - }); - - render(); - - // With terminal width 200, should calculate optimal number of columns - // that fit within the available space while showing all content - }); - - it('should prefer fewer columns when model names are very long', () => { - const longNameModels: IModel[] = [ - { - id: 'extremely-long-model-name-that-exceeds-reasonable-column-width-limits', - name: 'Long Model 1', - provider: 'test', - supportedToolFormats: ['test'], - }, - { - id: 'another-extremely-long-model-name-that-also-exceeds-limits', - name: 'Long Model 2', - provider: 'test', - supportedToolFormats: ['test'], - }, - ]; - - mockUseResponsive.mockReturnValue({ - width: 120, - breakpoint: 'STANDARD', - isNarrow: false, - isStandard: true, - isWide: false, - }); - - render(); - - // Should use fewer columns or single column when names are too long - }); - - it('should handle empty model list gracefully', () => { - mockUseResponsive.mockReturnValue({ - width: 120, - breakpoint: 'STANDARD', - isNarrow: false, - isStandard: true, - isWide: false, - }); - - render(); - - // Should not crash with empty models array - }); - }); - - describe('responsive behavior', () => { - it('should show different layouts for different breakpoints', () => { - const scenarios = [ - { - width: 70, - breakpoint: 'NARROW' as const, - isNarrow: true, - isStandard: false, - isWide: false, - }, - { - width: 100, - breakpoint: 'STANDARD' as const, - isNarrow: false, - isStandard: true, - isWide: false, - }, - { - width: 180, - breakpoint: 'WIDE' as const, - isNarrow: false, - isStandard: false, - isWide: true, - }, - ]; - - scenarios.forEach((scenario) => { - mockUseResponsive.mockReturnValue(scenario); - const { rerender } = render(); - - // Each breakpoint should render appropriately - // Narrow: single column, no truncation indicator - // Standard: 2-3 columns, some truncation - // Wide: 3 columns, minimal truncation - - rerender(); - }); - }); - }); - - describe('maximum width constraints', () => { - it('should constrain dialog width even on very wide terminals', () => { - mockUseResponsive.mockReturnValue({ - width: 300, - breakpoint: 'WIDE', - isNarrow: false, - isStandard: false, - isWide: true, - }); - - const { container } = render(); - - // Dialog should have maximum width constraint to prevent it from - // becoming too wide and causing overlapping dialogs - // For now, just verify the component renders without error - expect(container.firstChild).toBeTruthy(); - }); - }); -}); diff --git a/packages/cli/src/ui/components/ProviderModelDialog.tsx b/packages/cli/src/ui/components/ProviderModelDialog.tsx deleted file mode 100644 index c6dc1d972..000000000 --- a/packages/cli/src/ui/components/ProviderModelDialog.tsx +++ /dev/null @@ -1,361 +0,0 @@ -/** - * @license - * Copyright 2025 Vybestack LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useState, useMemo } from 'react'; -import { Box, Text } from 'ink'; -import { SemanticColors } from '../colors.js'; -import { IModel } from '../../providers/index.js'; -import { useResponsive } from '../hooks/useResponsive.js'; -import { truncateStart } from '../utils/responsive.js'; -import { useKeypress } from '../hooks/useKeypress.js'; - -interface ProviderModelDialogProps { - models: IModel[]; - currentModel: string; - onSelect: (id: string) => void; - onClose: () => void; -} - -export const ProviderModelDialog: React.FC = ({ - models, - currentModel, - onSelect, - onClose, -}) => { - const { isNarrow, isWide, width } = useResponsive(); - const [searchTerm, setSearchTerm] = useState(''); - const [isSearching, setIsSearching] = useState(true); - - // Sort models alphabetically by ID - const sortedModels = useMemo( - () => - [...models].sort((a, b) => - a.id.toLowerCase().localeCompare(b.id.toLowerCase()), - ), - [models], - ); - - // Filter models based on search term - const filteredModels = useMemo( - () => - sortedModels.filter((m) => - m.id.toLowerCase().includes(searchTerm.toLowerCase()), - ), - [sortedModels, searchTerm], - ); - - const [index, setIndex] = useState(() => - Math.max( - 0, - sortedModels.findIndex((m) => m.id === currentModel), - ), - ); - - // Reset index when search term changes - React.useEffect(() => { - const currentIndex = filteredModels.findIndex((m) => m.id === currentModel); - setIndex(Math.max(0, currentIndex)); - }, [searchTerm, filteredModels, currentModel]); - - // Calculate optimal layout based on available width and content - const calculateLayout = () => { - // Calculate minimum column width needed - const longestModelName = filteredModels.reduce( - (len, m) => Math.max(len, m.id.length), - 0, - ); - - if (isNarrow) { - return { columns: 1, colWidth: Math.max(longestModelName + 4, 25) }; - } - - // Step 1: Get actual content width - responsive to screen size - // For narrow screens, use full width; for wider screens, use 80% of width - const maxDialogWidth = isNarrow ? width : Math.floor(width * 0.8); - const contentWidth = maxDialogWidth - 4; // 4 for padding/borders - - // Step 2: Calculate column width needed (model name + marker + small buffer) - const markerWidth = 2; // "● " or "○ " - const spacingBetweenCols = 4; // Fixed spacing between columns - const colWidthNeeded = longestModelName + markerWidth + 1; // +1 for a tiny buffer - - // Step 3: Determine optimal column count - // Try to fit as many columns as possible without truncation - let optimalColumns = 1; - - for (let cols = 5; cols >= 1; cols--) { - // Calculate total width needed for this many columns - const totalWidthNeeded = - colWidthNeeded * cols + spacingBetweenCols * (cols - 1); - - if (totalWidthNeeded <= contentWidth) { - optimalColumns = cols; - break; - } - } - - // If even 1 column doesn't fit, we'll need to truncate - const columns = optimalColumns; - - // Step 4: Calculate actual column width - if (columns === 1) { - // Single column: use all available width - return { columns: 1, colWidth: contentWidth }; - } else { - // Multiple columns: use exact width needed + spacing - return { columns, colWidth: colWidthNeeded }; - } - }; - - const layout = calculateLayout(); - const { columns, colWidth } = layout; - const rows = Math.ceil(filteredModels.length / columns); - const maxDialogWidth = isNarrow ? width : Math.floor(width * 0.8); - - const move = (delta: number) => { - if (filteredModels.length === 0) return; - let next = index + delta; - if (next < 0) next = 0; - if (next >= filteredModels.length) next = filteredModels.length - 1; - setIndex(next); - }; - - useKeypress( - (key) => { - if (key.name === 'escape') { - if (isSearching && searchTerm.length > 0) { - setSearchTerm(''); - } else { - return onClose(); - } - } - - if (isSearching) { - if (key.name === 'return') { - if (filteredModels.length > 0) { - setIsSearching(false); - } - } else if ( - key.name === 'tab' || - (key.name === 'down' && searchTerm.length === 0) - ) { - setIsSearching(false); - } else if (key.name === 'backspace' || key.name === 'delete') { - setSearchTerm((prev) => prev.slice(0, -1)); - } else if ( - key.sequence && - typeof key.sequence === 'string' && - !key.ctrl && - !key.meta && - key.insertable !== false - ) { - setSearchTerm((prev) => prev + key.sequence); - } - } else { - if (key.name === 'return' && filteredModels.length > 0) { - return onSelect(filteredModels[index].id); - } - if (key.name === 'tab' || (key.name === 'up' && index === 0)) { - setIsSearching(true); - return; - } - if (key.name === 'left') move(-1); - if (key.name === 'right') move(1); - if (key.name === 'up') move(-columns); - if (key.name === 'down') move(columns); - } - }, - { isActive: true }, - ); - - const renderItem = (m: IModel, i: number, isLastInRow: boolean) => { - const selected = i === index; - // Calculate display name - truncate from start to preserve model name - let displayName: string; - const maxLength = colWidth - 3; // Account for marker and space - - if (m.id.length > maxLength) { - // Truncate from start to preserve the important model name at the end - displayName = truncateStart(m.id, maxLength); - } else { - displayName = m.id; - } - - return ( - - - {selected ? '● ' : '○ '} - {displayName} - - - ); - }; - - // Calculate visible items for scrolling (limit to reasonable amount) - const maxVisibleRows = Math.min(rows, 10); - const currentRow = Math.floor(index / columns); - const scrollOffset = Math.max( - 0, - Math.min( - currentRow - Math.floor(maxVisibleRows / 2), - rows - maxVisibleRows, - ), - ); - - const startIndex = scrollOffset * columns; - const endIndex = Math.min( - startIndex + maxVisibleRows * columns, - filteredModels.length, - ); - const visibleModels = filteredModels.slice(startIndex, endIndex); - - // Create the model grid with proper row/column layout - const renderModelGrid = () => { - const gridRows = []; - for (let row = 0; row < maxVisibleRows; row++) { - const rowItems = []; - for (let col = 0; col < columns; col++) { - const idx = row * columns + col; - if (idx < visibleModels.length) { - const isLastInRow = col === columns - 1; - rowItems.push( - renderItem(visibleModels[idx], startIndex + idx, isLastInRow), - ); - } - } - if (rowItems.length > 0) { - gridRows.push( - - {rowItems} - , - ); - } - } - return {gridRows}; - }; - - const renderContent = () => { - if (isNarrow) { - return ( - - - Select Model - - - {/* Search input - prominent for narrow */} - - - search: - - {searchTerm} - - - - Tab to switch modes, Enter to select, Esc to cancel - - - {/* Model count for narrow */} - - {filteredModels.length} models{searchTerm && ` found`} - - - {/* Results */} - {filteredModels.length > 0 ? ( - renderModelGrid() - ) : ( - - - No models match "{searchTerm}" - - - )} - - ); - } - - return ( - - - {isSearching - ? 'Search Models' - : 'Select Model (Tab to switch modes, Enter to select, Esc to cancel)'} - - - {/* Search input */} - - - search:{' '} - {isSearching && } - - {searchTerm} - - - {/* Results info - show for standard and wide */} - - Found {filteredModels.length} of {sortedModels.length} models - - - {/* Scrolling info for wide layouts */} - {isWide && rows > maxVisibleRows && ( - - Showing {scrollOffset + 1}- - {Math.min(scrollOffset + maxVisibleRows, rows)} of {rows} rows - - )} - - {/* Model grid */} - {filteredModels.length > 0 ? ( - renderModelGrid() - ) : ( - - - No models match "{searchTerm}" - - - )} - - {/* Current selection - show for non-searching in standard/wide */} - {filteredModels.length > 0 && !isSearching && ( - - Selected: {filteredModels[index].id} - - )} - - Tab to switch modes - - ); - }; - - return isNarrow ? ( - - {renderContent()} - - ) : ( - - {renderContent()} - - ); -}; diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 2b88f8b0d..13e304850 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -64,11 +64,6 @@ export interface UIActions { handleProviderSelect: (provider: string) => Promise; exitProviderDialog: () => void; - // Provider model dialog - openProviderModelDialog: () => Promise; - handleProviderModelChange: (model: string) => void; - exitProviderModelDialog: () => void; - // Load profile dialog openLoadProfileDialog: () => void; handleProfileSelect: (profile: string) => void; @@ -135,6 +130,19 @@ export interface UIActions { ) => void; closeSubagentDialog: () => void; + // Models dialog + openModelsDialog: (data?: { + initialSearch?: string; + initialFilters?: { + tools?: boolean; + vision?: boolean; + reasoning?: boolean; + audio?: boolean; + }; + includeDeprecated?: boolean; + }) => void; + closeModelsDialog: () => void; + // Workspace migration dialog onWorkspaceMigrationDialogOpen: () => void; onWorkspaceMigrationDialogClose: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index d0ec3ec14..bd08c8846 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -16,7 +16,6 @@ import type { } from '../types.js'; import type { IdeContext, - IModel, ApprovalMode, AnyDeclarativeTool, ThoughtSummary, @@ -64,7 +63,6 @@ export interface UIState { isAuthenticating: boolean; isEditorDialogOpen: boolean; isProviderDialogOpen: boolean; - isProviderModelDialogOpen: boolean; isLoadProfileDialogOpen: boolean; isCreateProfileDialogOpen: boolean; isProfileListDialogOpen: boolean; @@ -78,11 +76,11 @@ export interface UIState { isPermissionsDialogOpen: boolean; isLoggingDialogOpen: boolean; isSubagentDialogOpen: boolean; + isModelsDialogOpen: boolean; // Dialog data providerOptions: string[]; selectedProvider: string; - providerModels: IModel[]; currentModel: string; profiles: string[]; toolsDialogAction: 'enable' | 'disable'; @@ -92,6 +90,20 @@ export interface UIState { loggingDialogData: { entries: unknown[] }; subagentDialogInitialView?: SubagentView; subagentDialogInitialName?: string; + modelsDialogData?: { + initialSearch?: string; + initialFilters?: { + tools?: boolean; + vision?: boolean; + reasoning?: boolean; + audio?: boolean; + }; + includeDeprecated?: boolean; + /** Override provider filter from --provider arg */ + providerOverride?: string | null; + /** Show all providers (from --all flag) */ + showAllProviders?: boolean; + }; // Profile management dialog data profileListItems: Array<{ diff --git a/packages/cli/src/ui/hooks/index.ts b/packages/cli/src/ui/hooks/index.ts index 4319f46c8..818092c82 100644 --- a/packages/cli/src/ui/hooks/index.ts +++ b/packages/cli/src/ui/hooks/index.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -export * from './useProviderModelDialog.js'; export * from './useProviderDialog.js'; export * from './useToolsDialog.js'; export * from './useTerminalSize.js'; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 4fbdc6f2e..f18233062 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -197,7 +197,6 @@ describe('useSlashCommandProcessor', () => { let openAuthDialog: ReturnType; let openEditorDialog: ReturnType; let openProviderDialog: ReturnType; - let openProviderModelDialog: ReturnType; let openLoadProfileDialog: ReturnType; let openToolsDialog: ReturnType; let toggleCorgiMode: ReturnType; @@ -260,7 +259,6 @@ describe('useSlashCommandProcessor', () => { openAuthDialog = vi.fn(); openEditorDialog = vi.fn(); openProviderDialog = vi.fn(); - openProviderModelDialog = vi.fn(); openLoadProfileDialog = vi.fn(); openToolsDialog = vi.fn(); toggleCorgiMode = vi.fn(); @@ -290,7 +288,6 @@ describe('useSlashCommandProcessor', () => { openAuthDialog, openEditorDialog, openProviderDialog, - openProviderModelDialog, openLoadProfileDialog, openToolsDialog, toggleCorgiMode, diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 11ce05de0..02220dc99 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -37,6 +37,7 @@ import { type CommandContext, type SlashCommand, type SubagentDialogData, + type ModelsDialogData, } from '../commands/types.js'; import { CommandService } from '../../services/CommandService.js'; import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; @@ -63,7 +64,7 @@ interface SlashCommandProcessorActions { initialView?: SubagentView, initialName?: string, ) => void; - openProviderModelDialog: () => void; + openModelsDialog: (data?: ModelsDialogData) => void; openPermissionsDialog: () => void; openProviderDialog: () => void; openLoadProfileDialog: () => void; @@ -488,9 +489,6 @@ export const useSlashCommandProcessor = ( actions.openLoggingDialog(); } return { type: 'handled' }; - case 'providerModel': - actions.openProviderModelDialog(); - return { type: 'handled' }; case 'permissions': actions.openPermissionsDialog(); return { type: 'handled' }; @@ -558,6 +556,14 @@ export const useSlashCommandProcessor = ( ); return { type: 'handled' }; } + case 'models': { + // Type-safe access via discriminated union - dialogData is ModelsDialogData when dialog is 'models' + const modelsData = result.dialogData as + | ModelsDialogData + | undefined; + actions.openModelsDialog(modelsData); + return { type: 'handled' }; + } default: { const unhandled: never = result.dialog; throw new Error( diff --git a/packages/cli/src/ui/hooks/useEditorSettings.test.tsx b/packages/cli/src/ui/hooks/useEditorSettings.test.tsx index f82df59df..66de1a0eb 100644 --- a/packages/cli/src/ui/hooks/useEditorSettings.test.tsx +++ b/packages/cli/src/ui/hooks/useEditorSettings.test.tsx @@ -58,7 +58,6 @@ describe('useEditorSettings', () => { theme: false, auth: false, editor: false, - providerModel: false, provider: false, privacy: false, loadProfile: false, diff --git a/packages/cli/src/ui/hooks/useProviderModelDialog.ts b/packages/cli/src/ui/hooks/useProviderModelDialog.ts deleted file mode 100644 index 3af4f9d36..000000000 --- a/packages/cli/src/ui/hooks/useProviderModelDialog.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * @license - * Copyright 2025 Vybestack LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useCallback, useState } from 'react'; -import type { IModel } from '@vybestack/llxprt-code-core'; -import { MessageType } from '../types.js'; -import { useAppDispatch } from '../contexts/AppDispatchContext.js'; -import { AppState } from '../reducers/appReducer.js'; -import { useRuntimeApi } from '../contexts/RuntimeContext.js'; - -interface UseProviderModelDialogParams { - addMessage: (msg: { - type: MessageType; - content: string; - timestamp: Date; - }) => void; - onModelChange?: () => void; - appState: AppState; -} - -export const useProviderModelDialog = ({ - addMessage, - onModelChange, - appState, -}: UseProviderModelDialogParams) => { - const appDispatch = useAppDispatch(); - const runtime = useRuntimeApi(); - const showDialog = appState.openDialogs.providerModel; - const [models, setModels] = useState([]); - const [currentModel, setCurrentModel] = useState(''); - - const openDialog = useCallback(async () => { - try { - const list = await runtime.listAvailableModels(); - setModels(list); - setCurrentModel(runtime.getActiveModelName()); - appDispatch({ type: 'OPEN_DIALOG', payload: 'providerModel' }); - } catch (e) { - addMessage({ - type: MessageType.ERROR, - content: `Failed to load models: ${e instanceof Error ? e.message : String(e)}`, - timestamp: new Date(), - }); - } - }, [addMessage, appDispatch, runtime]); - - const closeDialog = useCallback( - () => appDispatch({ type: 'CLOSE_DIALOG', payload: 'providerModel' }), - [appDispatch], - ); - - const handleSelect = useCallback( - async (modelId: string) => { - try { - const result = await runtime.setActiveModel(modelId); - addMessage({ - type: MessageType.INFO, - content: `Switched from ${result.previousModel ?? 'unknown'} to ${result.nextModel} in provider '${result.providerName}'`, - timestamp: new Date(), - }); - onModelChange?.(); - } catch (e) { - const status = runtime.getActiveProviderStatus(); - addMessage({ - type: MessageType.ERROR, - content: `Failed to switch model for provider '${status.providerName ?? 'unknown'}': ${e instanceof Error ? e.message : String(e)}`, - timestamp: new Date(), - }); - } - appDispatch({ type: 'CLOSE_DIALOG', payload: 'providerModel' }); - }, - [addMessage, onModelChange, appDispatch, runtime], - ); - - return { - showDialog, - openDialog, - closeDialog, - models, - currentModel, - handleSelect, - }; -}; diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index bf6ec97ac..48b23d9a7 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -160,7 +160,6 @@ export const DefaultAppLayout = ({ uiState.isOAuthCodeDialogOpen || uiState.isEditorDialogOpen || uiState.isProviderDialogOpen || - uiState.isProviderModelDialogOpen || uiState.isLoadProfileDialogOpen || uiState.isCreateProfileDialogOpen || uiState.isProfileListDialogOpen || @@ -169,6 +168,7 @@ export const DefaultAppLayout = ({ uiState.isToolsDialogOpen || uiState.isLoggingDialogOpen || uiState.isSubagentDialogOpen || + uiState.isModelsDialogOpen || uiState.showPrivacyNotice; if (quittingMessages) { diff --git a/packages/cli/src/ui/reducers/appReducer.test.ts b/packages/cli/src/ui/reducers/appReducer.test.ts index 3bb6feead..8dfb97dd8 100644 --- a/packages/cli/src/ui/reducers/appReducer.test.ts +++ b/packages/cli/src/ui/reducers/appReducer.test.ts @@ -21,7 +21,6 @@ describe('appReducer', () => { theme: false, auth: false, editor: false, - providerModel: false, provider: false, privacy: false, loadProfile: false, @@ -104,7 +103,6 @@ describe('appReducer', () => { 'theme', 'auth', 'editor', - 'providerModel', 'provider', 'privacy', ] as const; @@ -151,7 +149,6 @@ describe('appReducer', () => { expect(state.openDialogs.theme).toBe(true); expect(state.openDialogs.auth).toBe(true); expect(state.openDialogs.editor).toBe(true); - expect(state.openDialogs.providerModel).toBe(false); expect(state.openDialogs.provider).toBe(false); expect(state.openDialogs.privacy).toBe(false); }); @@ -570,7 +567,6 @@ describe('appReducer', () => { theme: false, auth: false, editor: false, - providerModel: false, provider: false, privacy: false, loadProfile: false, diff --git a/packages/cli/src/ui/reducers/appReducer.ts b/packages/cli/src/ui/reducers/appReducer.ts index 9cdf45d18..1843edbe1 100644 --- a/packages/cli/src/ui/reducers/appReducer.ts +++ b/packages/cli/src/ui/reducers/appReducer.ts @@ -17,7 +17,6 @@ export type AppAction = | 'theme' | 'auth' | 'editor' - | 'providerModel' | 'provider' | 'privacy' | 'loadProfile' @@ -34,7 +33,6 @@ export type AppAction = | 'theme' | 'auth' | 'editor' - | 'providerModel' | 'provider' | 'privacy' | 'loadProfile' @@ -56,7 +54,6 @@ export interface AppState { theme: boolean; auth: boolean; editor: boolean; - providerModel: boolean; provider: boolean; privacy: boolean; loadProfile: boolean; @@ -84,7 +81,6 @@ export const initialAppState: AppState = { theme: false, auth: false, editor: false, - providerModel: false, provider: false, privacy: false, loadProfile: false, diff --git a/packages/cli/test-setup.ts b/packages/cli/test-setup.ts index 502f0ca49..7b2f09b30 100644 --- a/packages/cli/test-setup.ts +++ b/packages/cli/test-setup.ts @@ -16,6 +16,104 @@ if (process.env.NO_COLOR !== undefined) { import React from 'react'; import { vi } from 'vitest'; +// Mock provider aliases globally so tests don't need real config files +// This prevents "Provider not found" errors when fs is mocked +vi.mock('./src/providers/providerAliases.js', () => ({ + loadProviderAliasEntries: () => [ + { + alias: 'gemini', + config: { + name: 'gemini', + modelsDevProviderId: 'google', + baseProvider: 'gemini', + baseUrl: 'https://generativelanguage.googleapis.com/v1beta', + defaultModel: 'gemini-2.0-flash', + apiKeyEnv: 'GEMINI_API_KEY', + }, + filePath: '/mock/aliases/gemini.config', + source: 'builtin', + }, + { + alias: 'openai', + config: { + name: 'openai', + modelsDevProviderId: 'openai', + baseProvider: 'openai', + baseUrl: 'https://api.openai.com/v1', + defaultModel: 'gpt-4o', + apiKeyEnv: 'OPENAI_API_KEY', + }, + filePath: '/mock/aliases/openai.config', + source: 'builtin', + }, + { + alias: 'anthropic', + config: { + name: 'anthropic', + modelsDevProviderId: 'anthropic', + baseProvider: 'anthropic', + baseUrl: 'https://api.anthropic.com/v1', + defaultModel: 'claude-sonnet-4-20250514', + apiKeyEnv: 'ANTHROPIC_API_KEY', + }, + filePath: '/mock/aliases/anthropic.config', + source: 'builtin', + }, + { + alias: 'kimi', + config: { + name: 'kimi', + modelsDevProviderId: 'kimi-for-coding', + baseProvider: 'openai', + baseUrl: 'https://api.kimi.com/coding/v1', + defaultModel: 'kimi-for-coding', + description: 'Kimi For Coding OpenAI-compatible endpoint', + ephemeralSettings: { + 'context-limit': 262144, + max_tokens: 32768, + 'reasoning.effort': 'medium', + 'user-agent': 'RooCode/1.0', + }, + }, + filePath: '/mock/aliases/kimi.config', + source: 'builtin', + }, + { + alias: 'openai-responses', + config: { + name: 'openai-responses', + modelsDevProviderId: 'openai', + baseProvider: 'openai-responses', + baseUrl: 'https://api.openai.com/v1', + defaultModel: 'gpt-4o', + apiKeyEnv: 'OPENAI_API_KEY', + }, + filePath: '/mock/aliases/openai-responses.config', + source: 'builtin', + }, + { + alias: 'codex', + config: { + name: 'codex', + modelsDevProviderId: 'openai', + baseProvider: 'openai-responses', + baseUrl: 'https://chatgpt.com/backend-api/codex', + defaultModel: 'gpt-5.2', + description: 'OpenAI Codex (ChatGPT backend with OAuth)', + ephemeralSettings: { + 'context-limit': 262144, + }, + }, + filePath: '/mock/aliases/codex.config', + source: 'builtin', + }, + ], + getUserAliasDir: () => '/mock/home/.llxprt/providers', + getAliasFilePath: (alias: string) => + `/mock/home/.llxprt/providers/${alias}.config`, + writeProviderAliasConfig: vi.fn(), +})); + vi.mock('ink', () => import('./test-utils/ink-stub.ts'), { virtual: true, }); diff --git a/packages/cli/test/providers/providerAliases.test.ts b/packages/cli/test/providers/providerAliases.test.ts index c875a02a6..95f5e3c8f 100644 --- a/packages/cli/test/providers/providerAliases.test.ts +++ b/packages/cli/test/providers/providerAliases.test.ts @@ -4,10 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as os from 'os'; import * as fs from 'fs'; import * as path from 'path'; + +// This integration test needs real config files, not the global mock +vi.unmock('../../src/providers/providerAliases.js'); import { getProviderManager, resetProviderManager, diff --git a/packages/cli/test/ui/commands/modelCommand.test.ts b/packages/cli/test/ui/commands/modelCommand.test.ts new file mode 100644 index 000000000..f5e1e239c --- /dev/null +++ b/packages/cli/test/ui/commands/modelCommand.test.ts @@ -0,0 +1,426 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { modelCommand } from '../../../src/ui/commands/modelCommand.js'; +import { + CommandContext, + OpenDialogActionReturn, + MessageActionReturn, + ModelsDialogData, +} from '../../../src/ui/commands/types.js'; +import { LoadedSettings } from '../../../src/config/settings.js'; +import { Logger } from '@vybestack/llxprt-code-core'; +import { SessionStatsState } from '../../../src/ui/contexts/SessionContext.js'; + +// Mock the RuntimeContext +const mockSetActiveModel = vi.fn(); +vi.mock('../../../src/ui/contexts/RuntimeContext.js', () => ({ + getRuntimeApi: () => ({ + setActiveModel: mockSetActiveModel, + }), +})); + +// Create mock command context +function createMockContext(): CommandContext { + return { + services: { + config: null, + settings: {} as LoadedSettings, + git: undefined, + logger: {} as Logger, + }, + ui: { + addItem: vi.fn(), + clear: vi.fn(), + setDebugMessage: vi.fn(), + pendingItem: null, + setPendingItem: vi.fn(), + loadHistory: vi.fn(), + toggleCorgiMode: vi.fn(), + toggleVimEnabled: vi.fn().mockResolvedValue(true), + setLlxprtMdFileCount: vi.fn(), + reloadCommands: vi.fn(), + }, + session: { + stats: {} as SessionStatsState, + sessionShellAllowlist: new Set(), + }, + }; +} + +describe('modelCommand', () => { + let context: CommandContext; + + beforeEach(() => { + context = createMockContext(); + vi.clearAllMocks(); + }); + + describe('command metadata', () => { + it('has correct name', () => { + expect(modelCommand.name).toBe('model'); + }); + + it('has description', () => { + expect(modelCommand.description).toBeDefined(); + expect(modelCommand.description.length).toBeGreaterThan(0); + }); + }); + + describe('dialog action return (no args)', () => { + it('returns dialog type with models dialog', async () => { + const result = (await modelCommand.action( + context, + '', + )) as OpenDialogActionReturn; + expect(result.type).toBe('dialog'); + expect(result.dialog).toBe('models'); + }); + + it('returns dialogData object', async () => { + const result = (await modelCommand.action( + context, + '', + )) as OpenDialogActionReturn; + expect(result.dialogData).toBeDefined(); + }); + + it('returns empty filters when no args', async () => { + const result = (await modelCommand.action( + context, + '', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.initialSearch).toBeUndefined(); + expect(data.initialFilters).toEqual({ + tools: false, + vision: false, + reasoning: false, + audio: false, + }); + expect(data.includeDeprecated).toBe(false); + }); + }); + + describe('direct switch (positional arg only)', () => { + it('switches model directly when only positional arg', async () => { + mockSetActiveModel.mockResolvedValue({ + previousModel: 'gpt-4', + nextModel: 'gpt-4o', + providerName: 'openai', + }); + + const result = (await modelCommand.action( + context, + 'gpt-4o', + )) as MessageActionReturn; + + expect(result.type).toBe('message'); + expect(result.messageType).toBe('info'); + expect(result.content).toContain('Switched from gpt-4 to gpt-4o'); + expect(mockSetActiveModel).toHaveBeenCalledWith('gpt-4o'); + }); + + it('returns error message on switch failure', async () => { + mockSetActiveModel.mockRejectedValue(new Error('Model not found')); + + const result = (await modelCommand.action( + context, + 'invalid-model', + )) as MessageActionReturn; + + expect(result.type).toBe('message'); + expect(result.messageType).toBe('error'); + expect(result.content).toContain('Failed to switch model'); + expect(result.content).toContain('Model not found'); + }); + + it('handles unknown previous model', async () => { + mockSetActiveModel.mockResolvedValue({ + previousModel: null, + nextModel: 'gpt-4o', + providerName: 'openai', + }); + + const result = (await modelCommand.action( + context, + 'gpt-4o', + )) as MessageActionReturn; + + expect(result.content).toContain('Switched from unknown to gpt-4o'); + }); + }); + + describe('dialog with flags (positional + flags)', () => { + it('opens dialog when positional arg has flags', async () => { + const result = (await modelCommand.action( + context, + 'gpt-4o --tools', + )) as OpenDialogActionReturn; + + expect(result.type).toBe('dialog'); + expect(result.dialog).toBe('models'); + const data = result.dialogData as ModelsDialogData; + expect(data.initialSearch).toBe('gpt-4o'); + expect(data.initialFilters?.tools).toBe(true); + expect(mockSetActiveModel).not.toHaveBeenCalled(); + }); + + it('opens dialog with provider flag only', async () => { + const result = (await modelCommand.action( + context, + '--provider openai', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.providerOverride).toBe('openai'); + }); + + it('opens dialog with search and provider', async () => { + const result = (await modelCommand.action( + context, + 'claude --provider anthropic', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.initialSearch).toBe('claude'); + expect(data.providerOverride).toBe('anthropic'); + }); + }); + + describe('capability filter parsing', () => { + it('parses --tools flag', async () => { + const result = (await modelCommand.action( + context, + '--tools', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.initialFilters?.tools).toBe(true); + expect(data.initialFilters?.vision).toBe(false); + expect(data.initialFilters?.reasoning).toBe(false); + expect(data.initialFilters?.audio).toBe(false); + }); + + it('parses -t short flag', async () => { + const result = (await modelCommand.action( + context, + '-t', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.initialFilters?.tools).toBe(true); + }); + + it('parses --vision flag', async () => { + const result = (await modelCommand.action( + context, + '--vision', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.initialFilters?.vision).toBe(true); + }); + + it('parses --reasoning flag', async () => { + const result = (await modelCommand.action( + context, + '--reasoning', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.initialFilters?.reasoning).toBe(true); + }); + + it('parses -r short flag', async () => { + const result = (await modelCommand.action( + context, + '-r', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.initialFilters?.reasoning).toBe(true); + }); + + it('parses --audio flag', async () => { + const result = (await modelCommand.action( + context, + '--audio', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.initialFilters?.audio).toBe(true); + }); + + it('parses -a short flag', async () => { + const result = (await modelCommand.action( + context, + '-a', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.initialFilters?.audio).toBe(true); + }); + + it('parses multiple capability flags', async () => { + const result = (await modelCommand.action( + context, + '--tools --vision --reasoning', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.initialFilters?.tools).toBe(true); + expect(data.initialFilters?.vision).toBe(true); + expect(data.initialFilters?.reasoning).toBe(true); + expect(data.initialFilters?.audio).toBe(false); + }); + }); + + describe('--all flag parsing', () => { + it('parses --all flag to showAllProviders', async () => { + const result = (await modelCommand.action( + context, + '--all', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.showAllProviders).toBe(true); + }); + + it('defaults includeDeprecated to false', async () => { + const result = (await modelCommand.action( + context, + '', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.includeDeprecated).toBe(false); + }); + }); + + describe('combined args parsing', () => { + it('parses search term with tools filter (opens dialog)', async () => { + const result = (await modelCommand.action( + context, + 'gpt --tools', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.initialSearch).toBe('gpt'); + expect(data.initialFilters?.tools).toBe(true); + }); + + it('parses provider with reasoning and all', async () => { + const result = (await modelCommand.action( + context, + '-p openai -r --all', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.providerOverride).toBe('openai'); + expect(data.initialFilters?.reasoning).toBe(true); + expect(data.showAllProviders).toBe(true); + }); + + it('parses all capability flags together', async () => { + const result = (await modelCommand.action( + context, + '-t -r -a --vision', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.initialFilters?.tools).toBe(true); + expect(data.initialFilters?.vision).toBe(true); + expect(data.initialFilters?.reasoning).toBe(true); + expect(data.initialFilters?.audio).toBe(true); + }); + }); + + describe('ignored flags', () => { + it('ignores --limit flag (value becomes search term, triggers direct switch)', async () => { + // --limit is ignored, but 10 becomes search term triggering direct switch + mockSetActiveModel.mockRejectedValue(new Error('Model not found')); + const result = (await modelCommand.action( + context, + '--limit 10', + )) as MessageActionReturn; + expect(result.type).toBe('message'); + expect(mockSetActiveModel).toHaveBeenCalledWith('10'); + }); + + it('ignores -l short flag (value becomes search term, triggers direct switch)', async () => { + // -l is ignored, but 5 becomes search term triggering direct switch + mockSetActiveModel.mockRejectedValue(new Error('Model not found')); + const result = (await modelCommand.action( + context, + '-l 5', + )) as MessageActionReturn; + expect(result.type).toBe('message'); + expect(mockSetActiveModel).toHaveBeenCalledWith('5'); + }); + + it('ignores --verbose flag', async () => { + const result = (await modelCommand.action( + context, + '--verbose', + )) as OpenDialogActionReturn; + expect(result.type).toBe('dialog'); + }); + + it('ignores -v short flag', async () => { + const result = (await modelCommand.action( + context, + '-v', + )) as OpenDialogActionReturn; + expect(result.type).toBe('dialog'); + }); + + it('ignores unknown flags (value becomes search term, triggers direct switch)', async () => { + // --unknown-flag is ignored, but value becomes search term triggering direct switch + mockSetActiveModel.mockRejectedValue(new Error('Model not found')); + const result = (await modelCommand.action( + context, + '--unknown-flag value', + )) as MessageActionReturn; + expect(result.type).toBe('message'); + expect(mockSetActiveModel).toHaveBeenCalledWith('value'); + }); + }); + + describe('example command args', () => { + it('/model --tools --provider openai', async () => { + const result = (await modelCommand.action( + context, + '--tools --provider openai', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.providerOverride).toBe('openai'); + expect(data.initialFilters?.tools).toBe(true); + }); + + it('/model --tools --reasoning', async () => { + const result = (await modelCommand.action( + context, + '--tools --reasoning', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.initialFilters?.tools).toBe(true); + expect(data.initialFilters?.reasoning).toBe(true); + }); + + it('/model gpt-4o (direct switch)', async () => { + mockSetActiveModel.mockResolvedValue({ + previousModel: 'gpt-4', + nextModel: 'gpt-4o', + providerName: 'openai', + }); + + const result = (await modelCommand.action( + context, + 'gpt-4o', + )) as MessageActionReturn; + + expect(result.type).toBe('message'); + expect(mockSetActiveModel).toHaveBeenCalledWith('gpt-4o'); + }); + + it('/model claude --vision (opens dialog with search + filter)', async () => { + const result = (await modelCommand.action( + context, + 'claude --vision', + )) as OpenDialogActionReturn; + const data = result.dialogData as ModelsDialogData; + expect(data.initialSearch).toBe('claude'); + expect(data.initialFilters?.vision).toBe(true); + }); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6b6ef8d89..525492e35 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -350,9 +350,12 @@ export type { LogEntry as DebugLogEntry } from './debug/index.js'; // Export Storage export { Storage } from './config/storage.js'; -// Export models +// Export models (legacy constants) export * from './config/models.js'; +// Export models registry (models.dev integration) +export * from './models/index.js'; + // --- Subagent Feature: PLAN-20250117-SUBAGENTCONFIG --- export { SubagentManager } from './config/subagentManager.js'; export { SubagentOrchestrator } from './core/subagentOrchestrator.js'; diff --git a/packages/core/src/models/hydration.ts b/packages/core/src/models/hydration.ts new file mode 100644 index 000000000..c620ce85b --- /dev/null +++ b/packages/core/src/models/hydration.ts @@ -0,0 +1,231 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Model Hydration Utilities + * + * Enriches base IModel data with extended information from models.dev registry. + * Provides a unified path for all model-fetching flows. + */ + +import type { IModel } from '../providers/IModel.js'; +import type { + LlxprtModel, + LlxprtModelCapabilities, + LlxprtModelPricing, + LlxprtModelLimits, + LlxprtModelMetadata, +} from './schema.js'; +import { getModelRegistry } from './registry.js'; + +/** + * Extended model data from models.dev hydration. + * All fields optional since hydration may fail or model may not exist in registry. + */ +export interface ModelHydrationData { + // Capabilities + capabilities?: LlxprtModelCapabilities; + + // Pricing (USD per million tokens) + pricing?: LlxprtModelPricing; + + // Limits + limits?: LlxprtModelLimits; + + // Metadata + metadata?: LlxprtModelMetadata; + + // Provider info from registry + providerId?: string; + modelId?: string; + family?: string; + + // Hydration status + hydrated: boolean; +} + +/** + * Model with optional hydration data from models.dev + */ +export type HydratedModel = IModel & Partial; + +// Re-export for backward compatibility +export { getModelsDevProviderIds } from './provider-integration.js'; + +/** + * Hydrate a list of IModel with data from models.dev registry. + * + * @param models - Base models from provider.getModels() + * @param modelsDevProviderIds - The models.dev provider IDs to lookup (e.g., ["openrouter", "chutes"]) + * @returns Models with hydration data where available + */ +export async function hydrateModelsWithRegistry( + models: IModel[], + modelsDevProviderIds: string[], +): Promise { + const registry = getModelRegistry(); + + // If registry not initialized or no provider IDs, return unhydrated + if (!registry.isInitialized() || modelsDevProviderIds.length === 0) { + return models.map((m) => ({ ...m, hydrated: false })); + } + + // Collect all models from mapped provider IDs + const registryModels: LlxprtModel[] = []; + for (const providerId of modelsDevProviderIds) { + const providerModels = registry.getByProvider(providerId); + registryModels.push(...providerModels); + } + + // Build lookup map: modelId -> LlxprtModel + // Index by multiple keys for flexible matching + // + // NOTE: Name-based indexing can cause collisions if multiple models share + // the same display name. This is acceptable because: + // 1. modelsDevProviderIds typically maps to a single provider + // 2. Multi-provider IDs (e.g., ['google', 'google-vertex']) are same-vendor + // 3. Primary lookup is by model.id; name is a fallback only + const registryMap = new Map(); + for (const rm of registryModels) { + registryMap.set(rm.modelId, rm); // Short ID (e.g., "gpt-4o") + registryMap.set(rm.id, rm); // Full ID (e.g., "openai/gpt-4o") + registryMap.set(rm.name, rm); // Display name (fallback, may collide) + } + + // Hydrate each model + return models.map((model) => { + // Try multiple matching strategies + const registryModel = + registryMap.get(model.id) || + registryMap.get(model.name) || + // Try partial match for models with prefixes/suffixes + findPartialMatch(model.id, registryMap); + + if (!registryModel) { + // Model not found in registry - return unhydrated + return { ...model, hydrated: false }; + } + + // Merge registry data + return { + ...model, + // Override context/output if registry has them + contextWindow: registryModel.contextWindow ?? model.contextWindow, + maxOutputTokens: registryModel.maxOutputTokens ?? model.maxOutputTokens, + // Add hydration data + capabilities: registryModel.capabilities, + pricing: registryModel.pricing, + limits: registryModel.limits, + metadata: registryModel.metadata, + providerId: registryModel.providerId, + modelId: registryModel.modelId, + family: registryModel.family, + hydrated: true, + }; + }); +} + +/** + * Separators used for tokenizing model IDs + */ +const MODEL_ID_SEPARATORS = /[-_./]/; + +/** + * Minimum score threshold for a partial match to be considered valid. + * This prevents false positives from overly loose matching. + */ +const MATCH_SCORE_THRESHOLD = 50; + +/** + * Calculate match score between a model ID and a registry key. + * Higher scores indicate better matches. + * + * Scoring: + * - Exact match: 100 + * - Exact prefix/suffix with separator: 80 + * - Token overlap: proportional to matching tokens (max 60) + */ +function calculateMatchScore( + normalizedId: string, + normalizedKey: string, +): number { + // Exact match is best + if (normalizedId === normalizedKey) { + return 100; + } + + // Check for exact prefix/suffix with separator + // e.g., "gpt-4o-2024" matches "gpt-4o" as a prefix + if (normalizedId.startsWith(normalizedKey)) { + const remainder = normalizedId.slice(normalizedKey.length); + if (remainder.length === 0 || MODEL_ID_SEPARATORS.test(remainder[0])) { + return 80; + } + } + if (normalizedKey.startsWith(normalizedId)) { + const remainder = normalizedKey.slice(normalizedId.length); + if (remainder.length === 0 || MODEL_ID_SEPARATORS.test(remainder[0])) { + return 80; + } + } + + // Token-based matching + const idTokens = new Set( + normalizedId.split(MODEL_ID_SEPARATORS).filter(Boolean), + ); + const keyTokens = new Set( + normalizedKey.split(MODEL_ID_SEPARATORS).filter(Boolean), + ); + + if (idTokens.size === 0 || keyTokens.size === 0) { + return 0; + } + + // Count matching tokens + let matchingTokens = 0; + for (const token of idTokens) { + if (keyTokens.has(token)) { + matchingTokens++; + } + } + + // Score based on proportion of matching tokens + const maxTokens = Math.max(idTokens.size, keyTokens.size); + const tokenScore = (matchingTokens / maxTokens) * 60; + + return tokenScore; +} + +/** + * Find the best partial match for a model ID in the registry map. + * Uses scoring to prevent false positives from overly loose matching. + */ +function findPartialMatch( + modelId: string, + registryMap: Map, +): LlxprtModel | undefined { + const normalizedId = modelId.toLowerCase(); + + let bestMatch: LlxprtModel | undefined; + let bestScore = 0; + + for (const [key, model] of registryMap) { + const normalizedKey = key.toLowerCase(); + const score = calculateMatchScore(normalizedId, normalizedKey); + + if (score > bestScore) { + bestScore = score; + bestMatch = model; + } + } + + // Only return if score meets threshold + if (bestScore >= MATCH_SCORE_THRESHOLD) { + return bestMatch; + } + + return undefined; +} diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts new file mode 100644 index 000000000..58c595a25 --- /dev/null +++ b/packages/core/src/models/index.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Models Registry - models.dev integration for llxprt + * + * Provides automatic model discovery, pricing, and capabilities + * from the models.dev API with caching and fallback support. + * + * @example + * ```typescript + * import { initializeModelRegistry, getModelRegistry } from '@vybestack/llxprt-code-core/models'; + * + * // Initialize on startup + * await initializeModelRegistry(); + * + * // Get registry instance + * const registry = getModelRegistry(); + * + * // Query models + * const allModels = registry.getAll(); + * const claudeModels = registry.getByProvider('anthropic'); + * const reasoningModels = registry.search({ reasoning: true }); + * ``` + */ + +// Core registry +export { + ModelRegistry, + getModelRegistry, + initializeModelRegistry, + type ModelSearchQuery, + type ModelRegistryEvent, +} from './registry.js'; + +// Schemas and types +export { + // models.dev API schemas + ModelsDevModelSchema, + ModelsDevProviderSchema, + ModelsDevApiResponseSchema, + type ModelsDevModel, + type ModelsDevProvider, + type ModelsDevApiResponse, + + // llxprt internal schemas + LlxprtModelSchema, + LlxprtProviderSchema, + LlxprtModelCapabilitiesSchema, + LlxprtModelPricingSchema, + LlxprtModelLimitsSchema, + LlxprtModelMetadataSchema, + LlxprtDefaultProfileSchema, + type LlxprtModel, + type LlxprtProvider, + type LlxprtModelCapabilities, + type LlxprtModelPricing, + type LlxprtModelLimits, + type LlxprtModelMetadata, + type LlxprtDefaultProfile, + + // Cache metadata + ModelCacheMetadataSchema, + type ModelCacheMetadata, +} from './schema.js'; + +// Transformers +export { + transformModel, + transformProvider, + transformApiResponse, +} from './transformer.js'; + +// Profile utilities +export { + generateDefaultProfile, + getRecommendedThinkingBudget, + mergeProfileWithDefaults, +} from './profiles.js'; + +// Provider integration utilities +export { + llxprtModelToIModel, + hasModelInRegistry, + getExtendedModelInfo, + getRecommendedModel, +} from './provider-integration.js'; + +// Hydration utilities +export { + hydrateModelsWithRegistry, + getModelsDevProviderIds, + type HydratedModel, + type ModelHydrationData, +} from './hydration.js'; diff --git a/packages/core/src/models/profiles.ts b/packages/core/src/models/profiles.ts new file mode 100644 index 000000000..ee020ede6 --- /dev/null +++ b/packages/core/src/models/profiles.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ModelsDevModel, LlxprtDefaultProfile } from './schema.js'; + +/** + * Generate optimal default settings per model based on capabilities + */ +export function generateDefaultProfile( + model: ModelsDevModel, +): LlxprtDefaultProfile | undefined { + const profile: LlxprtDefaultProfile = {}; + + // Reasoning models + if (model.reasoning) { + profile.thinkingEnabled = true; + profile.thinkingBudget = 10000; // Conservative default + + // Lower temperature for reasoning models + if (model.temperature) { + profile.temperature = 0.7; + } + } else { + // Non-reasoning models + if (model.temperature) { + profile.temperature = 1.0; + } + } + + // Top-p recommendations + if (model.temperature) { + profile.topP = 0.95; // Standard default + } + + // Provider/family-specific tuning + const family = model.family?.toLowerCase() ?? ''; + const modelId = model.id.toLowerCase(); + + if (family.includes('gpt-5') || modelId.includes('gpt-5')) { + // GPT-5 models prefer higher temperature + profile.temperature = 1.2; + profile.topP = 0.98; + } else if (family.includes('claude') || modelId.includes('claude')) { + // Claude models work well with slightly lower values + profile.temperature = 0.8; + profile.topP = 0.9; + } else if (family.includes('gemini') || modelId.includes('gemini')) { + // Gemini tuning + profile.temperature = 1.0; + profile.topP = 0.95; + profile.topK = 40; + } else if (family.includes('deepseek') || modelId.includes('deepseek')) { + // DeepSeek models + profile.temperature = 0.7; + profile.topP = 0.9; + } else if (family.includes('qwen') || modelId.includes('qwen')) { + // Qwen models + profile.temperature = 0.7; + profile.topP = 0.8; + } + + // Return undefined if no profile settings were set + if (Object.keys(profile).length === 0) { + return undefined; + } + + return profile; +} + +/** + * Get recommended thinking budget based on model context window + */ +export function getRecommendedThinkingBudget(contextWindow: number): number { + // Use ~5% of context window for thinking, capped at reasonable limits + const budget = Math.floor(contextWindow * 0.05); + return Math.min(Math.max(budget, 5000), 50000); +} + +/** + * Merge user profile settings with model defaults + * User settings take precedence over defaults + */ +export function mergeProfileWithDefaults( + userProfile: Partial, + modelDefaults: LlxprtDefaultProfile | undefined, +): LlxprtDefaultProfile { + return { + ...modelDefaults, + ...userProfile, + }; +} diff --git a/packages/core/src/models/provider-integration.ts b/packages/core/src/models/provider-integration.ts new file mode 100644 index 000000000..af2fe7d84 --- /dev/null +++ b/packages/core/src/models/provider-integration.ts @@ -0,0 +1,201 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Provider Integration Layer for ModelsRegistry + * + * Provides utilities for IProvider implementations to fetch model data + * from the ModelsRegistry with graceful fallback to hardcoded lists. + */ + +import type { IModel } from '../providers/IModel.js'; +import type { LlxprtModel } from './schema.js'; +import { getModelRegistry } from './registry.js'; + +/** + * Maps llxprt provider names to models.dev provider IDs + * models.dev uses different naming in some cases + */ +export const PROVIDER_ID_MAP: Record = { + // llxprt provider name -> models.dev provider ID(s) + gemini: ['google', 'google-vertex'], + openai: ['openai'], + anthropic: ['anthropic'], + 'openai-responses': ['openai'], + 'openai-vercel': ['openai'], + deepseek: ['deepseek'], + groq: ['groq'], + mistral: ['mistral'], + cohere: ['cohere'], + xai: ['xai'], + ollama: ['ollama'], + togetherai: ['togetherai'], + perplexity: ['perplexity'], + fireworks: ['fireworks-ai'], + + // Alias provider display names -> models.dev provider IDs + 'Chutes.ai': ['chutes'], + xAI: ['xai'], + Synthetic: ['synthetic'], + Fireworks: ['fireworks-ai'], + OpenRouter: ['openrouter'], + 'Cerebras Code': ['cerebras'], + 'LM Studio': ['lmstudio'], + 'llama.cpp': ['llama'], + qwen: ['alibaba'], + qwenvercel: ['alibaba'], + codex: ['openai'], + kimi: ['kimi-for-coding'], +}; + +/** + * Converts an LlxprtModel to the IModel interface + * LlxprtModel already has all IModel fields, but this ensures type safety + */ +export function llxprtModelToIModel(model: LlxprtModel): IModel { + return { + id: model.modelId, // Use the short model ID, not the full "provider/model" ID + name: model.name, + provider: model.provider, + supportedToolFormats: model.supportedToolFormats, + contextWindow: model.contextWindow, + maxOutputTokens: model.maxOutputTokens, + }; +} + +/** + * Get the models.dev provider IDs for a given llxprt provider name. + * Falls back to using the provider name itself if no mapping exists. + * + * @param providerName - The llxprt provider name (e.g., 'gemini', 'openai') + * @returns Array of models.dev provider IDs + */ +export function getModelsDevProviderIds(providerName: string): string[] { + return PROVIDER_ID_MAP[providerName] ?? [providerName]; +} + +/** + * Check if a specific model ID exists in the registry for a provider + */ +export function hasModelInRegistry( + providerName: string, + modelId: string, +): boolean { + try { + const registry = getModelRegistry(); + if (!registry.isInitialized()) { + return false; + } + + const providerIds = getModelsDevProviderIds(providerName); + + for (const providerId of providerIds) { + // Try full ID format (provider/model) + const fullId = `${providerId}/${modelId}`; + if (registry.getById(fullId)) { + return true; + } + } + + return false; + } catch { + return false; + } +} + +/** + * Get extended model info from registry (pricing, capabilities, etc.) + * Returns undefined if model not found or registry unavailable + */ +export function getExtendedModelInfo( + providerName: string, + modelId: string, +): LlxprtModel | undefined { + try { + const registry = getModelRegistry(); + if (!registry.isInitialized()) { + return undefined; + } + + const providerIds = getModelsDevProviderIds(providerName); + + for (const providerId of providerIds) { + const fullId = `${providerId}/${modelId}`; + const model = registry.getById(fullId); + if (model) { + return model; + } + } + + return undefined; + } catch { + return undefined; + } +} + +/** + * Get recommended model for a provider based on capabilities + * Useful for selecting default models + */ +export function getRecommendedModel( + providerName: string, + options?: { + requireToolCalling?: boolean; + requireReasoning?: boolean; + preferCheaper?: boolean; + }, +): LlxprtModel | undefined { + try { + const registry = getModelRegistry(); + if (!registry.isInitialized()) { + return undefined; + } + + const providerIds = getModelsDevProviderIds(providerName); + + // Collect all models from mapped providers + let candidates: LlxprtModel[] = []; + for (const providerId of providerIds) { + candidates.push(...registry.getByProvider(providerId)); + } + + // Filter out deprecated + candidates = candidates.filter((m) => m.metadata?.status !== 'deprecated'); + + // Apply capability filters + if (options?.requireToolCalling) { + candidates = candidates.filter((m) => m.capabilities.toolCalling); + } + + if (options?.requireReasoning) { + candidates = candidates.filter((m) => m.capabilities.reasoning); + } + + if (candidates.length === 0) { + return undefined; + } + + // Sort by preference + if (options?.preferCheaper) { + candidates.sort((a, b) => { + const priceA = a.pricing?.input ?? Infinity; + const priceB = b.pricing?.input ?? Infinity; + return priceA - priceB; + }); + } else { + // Default: prefer larger context window (usually better models) + candidates.sort((a, b) => { + const ctxA = a.limits.contextWindow ?? 0; + const ctxB = b.limits.contextWindow ?? 0; + return ctxB - ctxA; + }); + } + + return candidates[0]; + } catch { + return undefined; + } +} diff --git a/packages/core/src/models/registry.ts b/packages/core/src/models/registry.ts new file mode 100644 index 000000000..26f26a05b --- /dev/null +++ b/packages/core/src/models/registry.ts @@ -0,0 +1,402 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Storage } from '../config/storage.js'; +import { + ModelsDevApiResponseSchema, + type ModelsDevApiResponse, + type LlxprtModel, + type LlxprtProvider, + type ModelCacheMetadata, +} from './schema.js'; +import { transformApiResponse } from './transformer.js'; + +const MODELS_DEV_API_URL = 'https://models.dev/api.json'; +const CACHE_FILENAME = 'models.json'; +const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours +const FETCH_TIMEOUT_MS = 10000; // 10 seconds + +/** + * Search query options for filtering models + */ +export interface ModelSearchQuery { + provider?: string; + capability?: keyof LlxprtModel['capabilities']; + maxPrice?: number; // Max input price per million tokens + minContext?: number; // Minimum context window + reasoning?: boolean; + toolCalling?: boolean; +} + +/** + * Event types emitted by the registry + */ +export type ModelRegistryEvent = 'models:updated' | 'models:error'; + +type EventCallback = () => void; + +/** + * ModelRegistry - Central registry for AI model metadata from models.dev + * + * Provides: + * - Automatic loading from cache or bundled fallback + * - Background periodic refresh (24h) + * - Search and filtering by capabilities, price, context + * - Event emission for UI updates + */ +export class ModelRegistry { + private models = new Map(); + private providers = new Map(); + private lastRefresh: Date | null = null; + private refreshTimer: ReturnType | null = null; + private listeners = new Map(); + private initialized = false; + private initPromise: Promise | null = null; + + /** + * Get the cache file path + */ + private static getCachePath(): string { + const cacheDir = path.join(Storage.getGlobalLlxprtDir(), 'cache'); + return path.join(cacheDir, CACHE_FILENAME); + } + + /** + * Initialize the registry - loads models and starts background refresh + */ + async initialize(): Promise { + if (this.initialized) return; + if (this.initPromise) return this.initPromise; + + this.initPromise = (async () => { + await this.loadModels(); + this.initialized = true; + this.startBackgroundRefresh(); + })(); + await this.initPromise; + } + + /** + * Load models from cache or trigger fresh fetch + */ + private async loadModels(): Promise { + // Try cache first + const cached = await this.loadFromCache(); + if (cached) { + this.populateModels(cached); + return; + } + + // No cache - trigger refresh + this.refresh().catch(() => { + // Silent failure - models will be empty until next refresh + }); + } + + /** + * Load from cache file if fresh + */ + private async loadFromCache(): Promise { + try { + const cachePath = ModelRegistry.getCachePath(); + + if (!fs.existsSync(cachePath)) { + return null; + } + + const stats = fs.statSync(cachePath); + const age = Date.now() - stats.mtimeMs; + + // Check if cache is stale + if (age > CACHE_MAX_AGE_MS) { + return null; + } + + const content = fs.readFileSync(cachePath, 'utf-8'); + const data = JSON.parse(content); + + // Validate schema + const validated = ModelsDevApiResponseSchema.safeParse(data); + if (!validated.success) { + return null; + } + + return validated.data; + } catch { + return null; + } + } + + /** + * Refresh models from models.dev API (non-blocking) + */ + async refresh(): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(MODELS_DEV_API_URL, { + headers: { + 'User-Agent': 'llxprt/1.0', + }, + signal: controller.signal, + }); + + if (!response.ok) { + return false; + } + + const data = await response.json(); + + // Validate schema + const validated = ModelsDevApiResponseSchema.safeParse(data); + if (!validated.success) { + return false; + } + + // Save to cache + await this.saveToCache(validated.data); + + // Update in-memory registry + this.populateModels(validated.data); + this.lastRefresh = new Date(); + + this.emit('models:updated'); + return true; + } catch { + this.emit('models:error'); + return false; + } finally { + clearTimeout(timeout); + } + } + + /** + * Save data to cache file + */ + private async saveToCache(data: ModelsDevApiResponse): Promise { + try { + const cachePath = ModelRegistry.getCachePath(); + const cacheDir = path.dirname(cachePath); + + // Ensure cache directory exists + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + + fs.writeFileSync(cachePath, JSON.stringify(data, null, 2)); + } catch { + // Silent failure - cache is optional + } + } + + /** + * Populate internal maps from API response + */ + private populateModels(data: ModelsDevApiResponse): void { + const { models, providers } = transformApiResponse(data); + this.models = models; + this.providers = providers; + } + + /** + * Start background refresh timer + */ + private startBackgroundRefresh(): void { + if (this.refreshTimer) return; + + this.refreshTimer = setInterval(() => { + this.refresh().catch(() => { + // Silent failure + }); + }, REFRESH_INTERVAL_MS); + + // Don't prevent process exit + this.refreshTimer.unref?.(); + } + + /** + * Stop background refresh and cleanup + */ + dispose(): void { + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + this.refreshTimer = null; + } + this.listeners.clear(); + } + + // ============== Public Query API ============== + + /** + * Get all models + */ + getAll(): LlxprtModel[] { + return Array.from(this.models.values()); + } + + /** + * Get model by full ID (provider/model-id) + */ + getById(id: string): LlxprtModel | undefined { + return this.models.get(id); + } + + /** + * Get all models for a specific provider + */ + getByProvider(providerId: string): LlxprtModel[] { + return this.getAll().filter((m) => m.providerId === providerId); + } + + /** + * Search models by various criteria + */ + search(query: ModelSearchQuery): LlxprtModel[] { + let results = this.getAll(); + + if (query.provider) { + results = results.filter((m) => m.providerId === query.provider); + } + + if (query.capability) { + results = results.filter((m) => m.capabilities[query.capability!]); + } + + if (query.reasoning !== undefined) { + results = results.filter( + (m) => m.capabilities.reasoning === query.reasoning, + ); + } + + if (query.toolCalling !== undefined) { + results = results.filter( + (m) => m.capabilities.toolCalling === query.toolCalling, + ); + } + + if (query.maxPrice !== undefined) { + results = results.filter( + (m) => m.pricing && m.pricing.input <= query.maxPrice!, + ); + } + + if (query.minContext !== undefined) { + results = results.filter( + (m) => m.limits.contextWindow >= query.minContext!, + ); + } + + return results; + } + + /** + * Get all providers + */ + getProviders(): LlxprtProvider[] { + return Array.from(this.providers.values()); + } + + /** + * Get provider by ID + */ + getProvider(providerId: string): LlxprtProvider | undefined { + return this.providers.get(providerId); + } + + /** + * Get cache metadata + */ + getCacheMetadata(): ModelCacheMetadata | null { + if (!this.lastRefresh) return null; + + return { + fetchedAt: this.lastRefresh.toISOString(), + version: '1.0', + providerCount: this.providers.size, + modelCount: this.models.size, + }; + } + + /** + * Check if registry has been initialized + */ + isInitialized(): boolean { + return this.initialized; + } + + /** + * Get model count + */ + getModelCount(): number { + return this.models.size; + } + + /** + * Get provider count + */ + getProviderCount(): number { + return this.providers.size; + } + + // ============== Event System ============== + + /** + * Subscribe to registry events + */ + on(event: ModelRegistryEvent, callback: EventCallback): void { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event)!.push(callback); + } + + /** + * Unsubscribe from registry events + */ + off(event: ModelRegistryEvent, callback: EventCallback): void { + const callbacks = this.listeners.get(event); + if (callbacks) { + const index = callbacks.indexOf(callback); + if (index !== -1) { + callbacks.splice(index, 1); + } + } + } + + /** + * Emit an event + */ + private emit(event: ModelRegistryEvent): void { + const callbacks = this.listeners.get(event) || []; + callbacks.forEach((cb) => cb()); + } +} + +// Singleton instance +let registryInstance: ModelRegistry | null = null; + +/** + * Get the global ModelRegistry instance + */ +export function getModelRegistry(): ModelRegistry { + if (!registryInstance) { + registryInstance = new ModelRegistry(); + } + return registryInstance; +} + +/** + * Initialize the global ModelRegistry + */ +export async function initializeModelRegistry(): Promise { + const registry = getModelRegistry(); + await registry.initialize(); + return registry; +} diff --git a/packages/core/src/models/schema.ts b/packages/core/src/models/schema.ts new file mode 100644 index 000000000..aa37e245c --- /dev/null +++ b/packages/core/src/models/schema.ts @@ -0,0 +1,209 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; + +/** + * models.dev API schemas (source format) + * @see https://models.dev/api.json + */ + +export const ModelsDevModelSchema = z.object({ + id: z.string(), + name: z.string(), + family: z.string().optional(), + + // Capabilities - all optional since API data is inconsistent + attachment: z.boolean().optional(), + reasoning: z.boolean().optional(), + tool_call: z.boolean().optional(), + temperature: z.boolean().optional(), + structured_output: z.boolean().optional(), + + // Interleaved thinking (for reasoning models) + interleaved: z + .union([ + z.literal(true), + z.object({ + field: z.enum(['reasoning_content', 'reasoning_details']), + }), + ]) + .optional(), + + // Pricing (per million tokens, USD) + cost: z + .object({ + input: z.number(), + output: z.number(), + reasoning: z.number().optional(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + context_over_200k: z + .object({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + }) + .optional(), + }) + .optional(), + + // Limits (tokens) + limit: z.object({ + context: z.number(), + output: z.number(), + }), + + // Modalities + modalities: z + .object({ + input: z.array(z.enum(['text', 'audio', 'image', 'video', 'pdf'])), + output: z.array(z.enum(['text', 'audio', 'image', 'video', 'pdf'])), + }) + .optional(), + + // Metadata + knowledge: z.string().optional(), + release_date: z.string(), + last_updated: z.string().optional(), + open_weights: z.boolean(), + status: z.enum(['alpha', 'beta', 'deprecated']).optional(), + experimental: z.boolean().optional(), + + // Provider-specific options + options: z.record(z.string(), z.unknown()).optional(), + headers: z.record(z.string(), z.string()).optional(), + provider: z.object({ npm: z.string() }).optional(), + variants: z.record(z.string(), z.record(z.string(), z.unknown())).optional(), +}); + +export const ModelsDevProviderSchema = z.object({ + id: z.string(), + name: z.string(), + env: z.array(z.string()), + api: z.string().optional(), + npm: z.string().optional(), + doc: z.string().optional(), + models: z.record(z.string(), ModelsDevModelSchema), +}); + +export const ModelsDevApiResponseSchema = z.record( + z.string(), + ModelsDevProviderSchema, +); + +export type ModelsDevModel = z.infer; +export type ModelsDevProvider = z.infer; +export type ModelsDevApiResponse = z.infer; + +/** + * llxprt internal model format (enriched format) + * Extends the base IModel interface with models.dev data + */ + +export const LlxprtModelCapabilitiesSchema = z.object({ + vision: z.boolean(), + audio: z.boolean(), + pdf: z.boolean(), + toolCalling: z.boolean(), + reasoning: z.boolean(), + temperature: z.boolean(), + structuredOutput: z.boolean(), + attachment: z.boolean(), +}); + +export const LlxprtModelPricingSchema = z.object({ + input: z.number(), // USD per million tokens + output: z.number(), + reasoning: z.number().optional(), + cacheRead: z.number().optional(), + cacheWrite: z.number().optional(), +}); + +export const LlxprtModelLimitsSchema = z.object({ + contextWindow: z.number(), + maxOutput: z.number(), +}); + +export const LlxprtModelMetadataSchema = z.object({ + knowledgeCutoff: z.string().optional(), + releaseDate: z.string(), + lastUpdated: z.string().optional(), + openWeights: z.boolean(), + status: z.enum(['stable', 'beta', 'alpha', 'deprecated']).optional(), +}); + +export const LlxprtDefaultProfileSchema = z.object({ + temperature: z.number().optional(), + topP: z.number().optional(), + topK: z.number().optional(), + thinkingBudget: z.number().optional(), + thinkingEnabled: z.boolean().optional(), +}); + +export const LlxprtModelSchema = z.object({ + // Core identity (compatible with IModel) + id: z.string(), // Format: provider/model-id + name: z.string(), + provider: z.string(), // Provider name for IModel compatibility + + // Provider info + providerId: z.string(), + providerName: z.string(), + modelId: z.string(), + family: z.string().optional(), + + // IModel compatibility + supportedToolFormats: z.array(z.string()), + contextWindow: z.number().optional(), + maxOutputTokens: z.number().optional(), + + // Extended models.dev data + capabilities: LlxprtModelCapabilitiesSchema, + pricing: LlxprtModelPricingSchema.optional(), + limits: LlxprtModelLimitsSchema, + metadata: LlxprtModelMetadataSchema, + defaultProfile: LlxprtDefaultProfileSchema.optional(), + + // Provider config + envVars: z.array(z.string()), + apiEndpoint: z.string().optional(), + npmPackage: z.string().optional(), + docUrl: z.string().optional(), +}); + +export const LlxprtProviderSchema = z.object({ + id: z.string(), + name: z.string(), + envVars: z.array(z.string()), + apiEndpoint: z.string().optional(), + npmPackage: z.string().optional(), + docUrl: z.string().optional(), + modelCount: z.number(), +}); + +export type LlxprtModelCapabilities = z.infer< + typeof LlxprtModelCapabilitiesSchema +>; +export type LlxprtModelPricing = z.infer; +export type LlxprtModelLimits = z.infer; +export type LlxprtModelMetadata = z.infer; +export type LlxprtDefaultProfile = z.infer; +export type LlxprtModel = z.infer; +export type LlxprtProvider = z.infer; + +/** + * Cache metadata schema + */ +export const ModelCacheMetadataSchema = z.object({ + fetchedAt: z.string(), // ISO date string + version: z.string(), + providerCount: z.number(), + modelCount: z.number(), +}); + +export type ModelCacheMetadata = z.infer; diff --git a/packages/core/src/models/transformer.ts b/packages/core/src/models/transformer.ts new file mode 100644 index 000000000..41e39cadc --- /dev/null +++ b/packages/core/src/models/transformer.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + ModelsDevModel, + ModelsDevProvider, + LlxprtModel, + LlxprtProvider, + LlxprtModelCapabilities, +} from './schema.js'; +import { generateDefaultProfile } from './profiles.js'; + +/** + * Known provider type mappings for tool format support + */ +const PROVIDER_TOOL_FORMATS: Record = { + anthropic: ['anthropic'], + openai: ['openai'], + google: ['google', 'gemini'], + 'google-vertex': ['google', 'gemini'], + deepseek: ['openai'], + groq: ['openai'], + mistral: ['openai'], + cohere: ['openai'], + xai: ['openai'], + togetherai: ['openai'], + perplexity: ['openai'], + deepinfra: ['openai'], + fireworks: ['openai'], + ollama: ['openai'], +}; + +/** + * Transform a models.dev model to llxprt format + */ +export function transformModel( + providerId: string, + provider: ModelsDevProvider, + modelId: string, + model: ModelsDevModel, +): LlxprtModel { + const fullId = `${providerId}/${modelId}`; + + // Extract capabilities from modalities + const inputModalities = model.modalities?.input ?? ['text']; + + const capabilities: LlxprtModelCapabilities = { + vision: inputModalities.includes('image'), + audio: inputModalities.includes('audio'), + pdf: inputModalities.includes('pdf'), + toolCalling: model.tool_call ?? false, + reasoning: model.reasoning ?? false, + temperature: model.temperature ?? false, + structuredOutput: model.structured_output ?? false, + attachment: model.attachment ?? false, + }; + + // Determine tool formats based on provider + const supportedToolFormats = PROVIDER_TOOL_FORMATS[providerId] ?? ['openai']; + + // Map status + const status = mapStatus(model.status); + + return { + // Core identity (IModel compatible) + id: fullId, + name: model.name, + provider: provider.name, + + // Provider info + providerId, + providerName: provider.name, + modelId, + family: model.family, + + // IModel compatibility + supportedToolFormats, + contextWindow: model.limit.context, + maxOutputTokens: model.limit.output, + + // Extended data + capabilities, + + pricing: model.cost + ? { + input: model.cost.input, + output: model.cost.output, + reasoning: model.cost.reasoning, + cacheRead: model.cost.cache_read, + cacheWrite: model.cost.cache_write, + } + : undefined, + + limits: { + contextWindow: model.limit.context, + maxOutput: model.limit.output, + }, + + metadata: { + knowledgeCutoff: model.knowledge, + releaseDate: model.release_date, + lastUpdated: model.last_updated, + openWeights: model.open_weights, + status, + }, + + defaultProfile: generateDefaultProfile(model), + + // Provider config + envVars: provider.env, + apiEndpoint: provider.api, + npmPackage: provider.npm, + docUrl: provider.doc, + }; +} + +/** + * Transform a models.dev provider to llxprt format + */ +export function transformProvider( + providerId: string, + provider: ModelsDevProvider, +): LlxprtProvider { + return { + id: providerId, + name: provider.name, + envVars: provider.env, + apiEndpoint: provider.api, + npmPackage: provider.npm, + docUrl: provider.doc, + modelCount: Object.keys(provider.models).length, + }; +} + +/** + * Map models.dev status to llxprt status + */ +function mapStatus( + status?: 'alpha' | 'beta' | 'deprecated', +): 'stable' | 'beta' | 'alpha' | 'deprecated' { + if (!status) return 'stable'; + return status; +} + +/** + * Transform entire models.dev API response to llxprt format + */ +export function transformApiResponse(data: Record): { + models: Map; + providers: Map; +} { + const models = new Map(); + const providers = new Map(); + + for (const [providerId, provider] of Object.entries(data)) { + // Transform provider + providers.set(providerId, transformProvider(providerId, provider)); + + // Transform models + for (const [modelId, model] of Object.entries(provider.models)) { + const transformed = transformModel(providerId, provider, modelId, model); + models.set(transformed.id, transformed); + } + } + + return { models, providers }; +} diff --git a/packages/core/src/providers/IProviderManager.ts b/packages/core/src/providers/IProviderManager.ts index 6162ee367..d13b6b1a5 100644 --- a/packages/core/src/providers/IProviderManager.ts +++ b/packages/core/src/providers/IProviderManager.ts @@ -5,7 +5,7 @@ */ import { type IProvider } from './IProvider.js'; -import { type IModel } from './IModel.js'; +import { type HydratedModel } from '../models/hydration.js'; import { Config } from '../config/config.js'; /** @@ -48,9 +48,13 @@ export interface IProviderManager { getActiveProviderName(): string; /** - * Get available models from a provider + * Get available models from a provider, hydrated with models.dev data. + * + * Models are first fetched from provider.getModels(), then enriched with + * capabilities, pricing, and metadata from models.dev registry. + * If hydration fails, models are still returned with `hydrated: false`. */ - getAvailableModels(providerName?: string): Promise; + getAvailableModels(providerName?: string): Promise; /** * List all registered providers diff --git a/packages/core/src/providers/ProviderManager.ts b/packages/core/src/providers/ProviderManager.ts index 62b3a576d..02514de89 100644 --- a/packages/core/src/providers/ProviderManager.ts +++ b/packages/core/src/providers/ProviderManager.ts @@ -6,9 +6,17 @@ */ import { type IProvider, type GenerateChatOptions } from './IProvider.js'; -import { type IModel } from './IModel.js'; import { type IProviderManager } from './IProviderManager.js'; import { Config } from '../config/config.js'; +import { + hydrateModelsWithRegistry, + getModelsDevProviderIds, + type HydratedModel, +} from '../models/hydration.js'; +import { + initializeModelRegistry, + getModelRegistry, +} from '../models/registry.js'; import { LoggingProviderWrapper } from './LoggingProviderWrapper.js'; import { logProviderSwitch, @@ -812,7 +820,7 @@ export class ProviderManager implements IProviderManager { return provider; } - async getAvailableModels(providerName?: string): Promise { + async getAvailableModels(providerName?: string): Promise { let provider: IProvider | undefined; if (providerName) { @@ -824,7 +832,77 @@ export class ProviderManager implements IProviderManager { provider = this.getActiveProvider(); } - return provider.getModels(); + // Step 1: Get models from provider (live API or fallback) + const baseModels = await provider.getModels(); + + // Step 2: Initialize registry if needed (non-blocking failure) + try { + await initializeModelRegistry(); + } catch { + // Registry init failed - return unhydrated + logger.debug( + () => + `[getAvailableModels] Registry init failed for provider: ${provider!.name}`, + ); + return baseModels.map((m) => ({ ...m, hydrated: false })); + } + + // Step 3: Get modelsDevProviderIds for hydration lookup + const modelsDevProviderIds = getModelsDevProviderIds(provider.name); + + // Step 4: If provider returned no models, fall back to registry-only models + if (baseModels.length === 0 && modelsDevProviderIds.length > 0) { + logger.debug( + () => + `[getAvailableModels] Provider ${provider!.name} returned 0 models, falling back to registry`, + ); + const registry = getModelRegistry(); + if (registry.isInitialized()) { + // Get models from registry for this provider (only those with tool support) + const registryModels: HydratedModel[] = []; + for (const providerId of modelsDevProviderIds) { + const providerModels = registry.getByProvider(providerId); + for (const rm of providerModels) { + // Only exclude models that explicitly disable tool support + if (rm.capabilities?.toolCalling === false) continue; + + registryModels.push({ + id: rm.modelId, + name: rm.name, + provider: provider!.name, + supportedToolFormats: [], + contextWindow: rm.contextWindow, + maxOutputTokens: rm.maxOutputTokens, + capabilities: rm.capabilities, + pricing: rm.pricing, + limits: rm.limits, + metadata: rm.metadata, + providerId: rm.providerId, + modelId: rm.modelId, + family: rm.family, + hydrated: true, + }); + } + } + if (registryModels.length > 0) { + return registryModels; + } + } + } + + logger.debug( + () => + `[getAvailableModels] Hydrating ${baseModels.length} models for provider: ${provider!.name} with modelsDevIds: ${JSON.stringify(modelsDevProviderIds)}`, + ); + + // Step 5: Hydrate with models.dev data + const hydratedModels = await hydrateModelsWithRegistry( + baseModels, + modelsDevProviderIds, + ); + + // Step 6: Filter to only models with tool support (required for CLI) + return hydratedModels.filter((m) => m.capabilities?.toolCalling !== false); } listProviders(): string[] { diff --git a/packages/core/src/providers/anthropic/AnthropicProvider.ts b/packages/core/src/providers/anthropic/AnthropicProvider.ts index b368bf854..826015471 100644 --- a/packages/core/src/providers/anthropic/AnthropicProvider.ts +++ b/packages/core/src/providers/anthropic/AnthropicProvider.ts @@ -290,7 +290,7 @@ export class AnthropicProvider extends BaseProvider { { id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5', - provider: 'anthropic', + provider: this.name, supportedToolFormats: ['anthropic'], contextWindow: 500000, maxOutputTokens: 32000, @@ -298,7 +298,7 @@ export class AnthropicProvider extends BaseProvider { { id: 'claude-opus-4-5', name: 'Claude Opus 4.5', - provider: 'anthropic', + provider: this.name, supportedToolFormats: ['anthropic'], contextWindow: 500000, maxOutputTokens: 32000, @@ -306,7 +306,7 @@ export class AnthropicProvider extends BaseProvider { { id: 'claude-opus-4-1-20250805', name: 'Claude Opus 4.1', - provider: 'anthropic', + provider: this.name, supportedToolFormats: ['anthropic'], contextWindow: 500000, maxOutputTokens: 32000, @@ -314,7 +314,7 @@ export class AnthropicProvider extends BaseProvider { { id: 'claude-opus-4-1', name: 'Claude Opus 4.1', - provider: 'anthropic', + provider: this.name, supportedToolFormats: ['anthropic'], contextWindow: 500000, maxOutputTokens: 32000, @@ -322,7 +322,7 @@ export class AnthropicProvider extends BaseProvider { { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', - provider: 'anthropic', + provider: this.name, supportedToolFormats: ['anthropic'], contextWindow: 400000, maxOutputTokens: 64000, @@ -330,7 +330,7 @@ export class AnthropicProvider extends BaseProvider { { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', - provider: 'anthropic', + provider: this.name, supportedToolFormats: ['anthropic'], contextWindow: 400000, maxOutputTokens: 64000, @@ -338,7 +338,7 @@ export class AnthropicProvider extends BaseProvider { { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', - provider: 'anthropic', + provider: this.name, supportedToolFormats: ['anthropic'], contextWindow: 400000, maxOutputTokens: 64000, @@ -346,7 +346,7 @@ export class AnthropicProvider extends BaseProvider { { id: 'claude-sonnet-4', name: 'Claude Sonnet 4', - provider: 'anthropic', + provider: this.name, supportedToolFormats: ['anthropic'], contextWindow: 400000, maxOutputTokens: 64000, @@ -354,7 +354,7 @@ export class AnthropicProvider extends BaseProvider { { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', - provider: 'anthropic', + provider: this.name, supportedToolFormats: ['anthropic'], contextWindow: 500000, maxOutputTokens: 16000, @@ -362,7 +362,7 @@ export class AnthropicProvider extends BaseProvider { { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', - provider: 'anthropic', + provider: this.name, supportedToolFormats: ['anthropic'], contextWindow: 500000, maxOutputTokens: 16000, @@ -384,7 +384,7 @@ export class AnthropicProvider extends BaseProvider { models.push({ id: model.id, name: model.display_name || model.id, - provider: 'anthropic', + provider: this.name, supportedToolFormats: ['anthropic'], contextWindow: this.getContextWindowForModel(model.id), maxOutputTokens: this.getMaxTokensForModel(model.id), @@ -441,7 +441,7 @@ export class AnthropicProvider extends BaseProvider { { id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5', - provider: 'anthropic', + provider: this.name, supportedToolFormats: ['anthropic'], contextWindow: 500000, maxOutputTokens: 32000, @@ -449,7 +449,7 @@ export class AnthropicProvider extends BaseProvider { { id: 'claude-opus-4-1-20250805', name: 'Claude Opus 4.1', - provider: 'anthropic', + provider: this.name, supportedToolFormats: ['anthropic'], contextWindow: 500000, maxOutputTokens: 32000, @@ -457,7 +457,7 @@ export class AnthropicProvider extends BaseProvider { { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', - provider: 'anthropic', + provider: this.name, supportedToolFormats: ['anthropic'], contextWindow: 400000, maxOutputTokens: 64000, @@ -465,7 +465,7 @@ export class AnthropicProvider extends BaseProvider { { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', - provider: 'anthropic', + provider: this.name, supportedToolFormats: ['anthropic'], contextWindow: 500000, maxOutputTokens: 16000, diff --git a/packages/core/src/providers/gemini/GeminiProvider.ts b/packages/core/src/providers/gemini/GeminiProvider.ts index 604e7360a..32f904b94 100644 --- a/packages/core/src/providers/gemini/GeminiProvider.ts +++ b/packages/core/src/providers/gemini/GeminiProvider.ts @@ -464,6 +464,28 @@ export class GeminiProvider extends BaseProvider { * Determine auth mode per call instead of using cached state */ async getModels(): Promise { + // Default fallback models used when API is unavailable + const fallbackModels: IModel[] = [ + { + id: 'gemini-2.5-pro', + name: 'Gemini 2.5 Pro', + provider: this.name, + supportedToolFormats: ['google', 'gemini'], + }, + { + id: 'gemini-2.5-flash', + name: 'Gemini 2.5 Flash', + provider: this.name, + supportedToolFormats: ['google', 'gemini'], + }, + { + id: 'gemini-2.5-flash-exp', + name: 'Gemini 2.5 Flash Experimental', + provider: this.name, + supportedToolFormats: ['google', 'gemini'], + }, + ]; + // Determine auth mode for this call const { authMode } = await this.determineBestAuth(); @@ -474,25 +496,25 @@ export class GeminiProvider extends BaseProvider { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', provider: this.name, - supportedToolFormats: [], + supportedToolFormats: ['google', 'gemini'], }, { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: this.name, - supportedToolFormats: [], + supportedToolFormats: ['google', 'gemini'], }, { id: 'gemini-2.5-flash-lite', name: 'Gemini 2.5 Flash Lite', provider: this.name, - supportedToolFormats: [], + supportedToolFormats: ['google', 'gemini'], }, { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro Preview', provider: this.name, - supportedToolFormats: [], + supportedToolFormats: ['google', 'gemini'], }, ]; } @@ -529,7 +551,7 @@ export class GeminiProvider extends BaseProvider { id: model.name.replace('models/', ''), // Remove 'models/' prefix name: model.displayName || model.name, provider: this.name, - supportedToolFormats: [], + supportedToolFormats: ['google', 'gemini'], })); } } @@ -540,26 +562,7 @@ export class GeminiProvider extends BaseProvider { } // Return default models as fallback - return [ - { - id: 'gemini-2.5-pro', - name: 'Gemini 2.5 Pro', - provider: this.name, - supportedToolFormats: [], - }, - { - id: 'gemini-2.5-flash', - name: 'Gemini 2.5 Flash', - provider: this.name, - supportedToolFormats: [], - }, - { - id: 'gemini-2.5-flash-exp', - name: 'Gemini 2.5 Flash Experimental', - provider: this.name, - supportedToolFormats: [], - }, - ]; + return fallbackModels; } /** diff --git a/packages/core/src/providers/openai/OpenAIProvider.ts b/packages/core/src/providers/openai/OpenAIProvider.ts index baa20aed3..264fb4e7a 100644 --- a/packages/core/src/providers/openai/OpenAIProvider.ts +++ b/packages/core/src/providers/openai/OpenAIProvider.ts @@ -823,17 +823,34 @@ export class OpenAIProvider extends BaseProvider implements IProvider { } override async getModels(): Promise { + // HYDRATION NOTE: Registry lookup has been moved to ProviderManager.getAvailableModels() + // This method now only fetches from the live API or falls back to hardcoded list. + // The ProviderManager will hydrate these results with models.dev data. + + this.getLogger().debug( + () => `[getModels] Called for provider: ${this.name}`, + ); + try { - // Always try to fetch models, regardless of auth status + // Try to fetch models from the provider's API // Local endpoints often work without authentication const authToken = await this.getAuthToken(); const baseURL = this.getBaseURL(); const agents = this.createHttpAgents(); const client = this.instantiateClient(authToken, baseURL, agents); + + const modelsEndpoint = `${baseURL ?? 'https://api.openai.com/v1'}/models`; + this.getLogger().debug( + () => + `[getModels] Fetching models from: ${modelsEndpoint} (provider: ${this.name}, hasAuth: ${!!authToken})`, + ); + const response = await client.models.list(); const models: IModel[] = []; + const allModelIds: string[] = []; for await (const model of response) { + allModelIds.push(model.id); // Filter out non-chat models (embeddings, audio, image, vision, DALL·E, etc.) if ( !/embedding|whisper|audio|tts|image|vision|dall[- ]?e|moderation/i.test( @@ -843,12 +860,17 @@ export class OpenAIProvider extends BaseProvider implements IProvider { models.push({ id: model.id, name: model.id, - provider: 'openai', + provider: this.name, supportedToolFormats: ['openai'], }); } } + this.getLogger().debug( + () => + `[getModels] Response from ${modelsEndpoint}: total=${allModelIds.length}, filtered=${models.length}, models=${JSON.stringify(allModelIds)}`, + ); + return models; } catch (error) { this.getLogger().debug( @@ -861,23 +883,24 @@ export class OpenAIProvider extends BaseProvider implements IProvider { private getFallbackModels(): IModel[] { // Return commonly available OpenAI models as fallback + // Use this.name so it works for providers that extend OpenAIProvider (e.g., Chutes.ai) return [ { id: 'gpt-5', name: 'GPT-5', - provider: 'openai', + provider: this.name, supportedToolFormats: ['openai'], }, { id: 'gpt-4.2-turbo-preview', name: 'GPT-4.2 Turbo Preview', - provider: 'openai', + provider: this.name, supportedToolFormats: ['openai'], }, { id: 'gpt-4.2-turbo', name: 'GPT-4.2 Turbo', - provider: 'openai', + provider: this.name, supportedToolFormats: ['openai'], }, ]; diff --git a/packages/core/test/models/__fixtures__/mock-data.ts b/packages/core/test/models/__fixtures__/mock-data.ts new file mode 100644 index 000000000..701855756 --- /dev/null +++ b/packages/core/test/models/__fixtures__/mock-data.ts @@ -0,0 +1,312 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + ModelsDevModel, + ModelsDevProvider, + ModelsDevApiResponse, +} from '../../../src/models/schema.js'; + +/** + * Minimal valid model with only required fields + */ +export const minimalModel: ModelsDevModel = { + id: 'test-model', + name: 'Test Model', + limit: { + context: 8000, + output: 4000, + }, + release_date: '2024-01-01', + open_weights: false, +}; + +/** + * Full model with all fields populated + */ +export const fullModel: ModelsDevModel = { + id: 'gpt-4-turbo', + name: 'GPT-4 Turbo', + family: 'gpt-4', + attachment: true, + reasoning: false, + tool_call: true, + temperature: true, + structured_output: true, + cost: { + input: 10, + output: 30, + cache_read: 2.5, + cache_write: 5, + }, + limit: { + context: 128000, + output: 4096, + }, + modalities: { + input: ['text', 'image'], + output: ['text'], + }, + knowledge: '2024-04', + release_date: '2024-04-09', + last_updated: '2024-06-01', + open_weights: false, + status: undefined, +}; + +/** + * Reasoning model (like o1) + */ +export const reasoningModel: ModelsDevModel = { + id: 'o1-preview', + name: 'O1 Preview', + family: 'o1', + attachment: false, + reasoning: true, + tool_call: false, + temperature: true, + limit: { + context: 128000, + output: 32768, + }, + release_date: '2024-09-12', + open_weights: false, +}; + +/** + * Deprecated model + */ +export const deprecatedModel: ModelsDevModel = { + id: 'gpt-3.5-turbo-0301', + name: 'GPT-3.5 Turbo (0301)', + family: 'gpt-3.5', + attachment: false, + reasoning: false, + tool_call: true, + temperature: true, + limit: { + context: 4096, + output: 4096, + }, + release_date: '2023-03-01', + open_weights: false, + status: 'deprecated', +}; + +/** + * Model with vision capability + */ +export const visionModel: ModelsDevModel = { + id: 'gpt-4o', + name: 'GPT-4o', + family: 'gpt-4o', + attachment: true, + reasoning: false, + tool_call: true, + temperature: true, + structured_output: true, + cost: { + input: 2.5, + output: 10, + }, + limit: { + context: 128000, + output: 16384, + }, + modalities: { + input: ['text', 'image', 'audio'], + output: ['text', 'audio'], + }, + release_date: '2024-05-13', + open_weights: false, +}; + +/** + * Claude model for family-specific profile testing + */ +export const claudeModel: ModelsDevModel = { + id: 'claude-3-5-sonnet', + name: 'Claude 3.5 Sonnet', + family: 'claude-3.5', + attachment: true, + reasoning: false, + tool_call: true, + temperature: true, + cost: { + input: 3, + output: 15, + }, + limit: { + context: 200000, + output: 8192, + }, + modalities: { + input: ['text', 'image', 'pdf'], + output: ['text'], + }, + release_date: '2024-06-20', + open_weights: false, +}; + +/** + * Gemini model for family-specific profile testing + */ +export const geminiModel: ModelsDevModel = { + id: 'gemini-2.0-flash', + name: 'Gemini 2.0 Flash', + family: 'gemini-2.0', + attachment: true, + reasoning: false, + tool_call: true, + temperature: true, + structured_output: true, + cost: { + input: 0.1, + output: 0.4, + }, + limit: { + context: 1000000, + output: 8192, + }, + modalities: { + input: ['text', 'image', 'audio', 'video'], + output: ['text'], + }, + release_date: '2024-12-11', + open_weights: false, +}; + +/** + * DeepSeek model + */ +export const deepseekModel: ModelsDevModel = { + id: 'deepseek-chat', + name: 'DeepSeek Chat', + family: 'deepseek-v3', + attachment: false, + reasoning: false, + tool_call: true, + temperature: true, + cost: { + input: 0.14, + output: 0.28, + }, + limit: { + context: 64000, + output: 8000, + }, + release_date: '2024-12-26', + open_weights: true, +}; + +/** + * Mock OpenAI provider + */ +export const openaiProvider: ModelsDevProvider = { + id: 'openai', + name: 'OpenAI', + env: ['OPENAI_API_KEY'], + api: 'https://api.openai.com/v1', + npm: '@ai-sdk/openai', + doc: 'https://platform.openai.com/docs', + models: { + 'gpt-4-turbo': fullModel, + 'gpt-4o': visionModel, + 'o1-preview': reasoningModel, + 'gpt-3.5-turbo-0301': deprecatedModel, + }, +}; + +/** + * Mock Anthropic provider + */ +export const anthropicProvider: ModelsDevProvider = { + id: 'anthropic', + name: 'Anthropic', + env: ['ANTHROPIC_API_KEY'], + api: 'https://api.anthropic.com', + npm: '@ai-sdk/anthropic', + doc: 'https://docs.anthropic.com', + models: { + 'claude-3-5-sonnet': claudeModel, + }, +}; + +/** + * Mock Google provider + */ +export const googleProvider: ModelsDevProvider = { + id: 'google', + name: 'Google AI', + env: ['GOOGLE_API_KEY'], + api: 'https://generativelanguage.googleapis.com/v1beta', + npm: '@ai-sdk/google', + doc: 'https://ai.google.dev/docs', + models: { + 'gemini-2.0-flash': geminiModel, + }, +}; + +/** + * Mock DeepSeek provider + */ +export const deepseekProvider: ModelsDevProvider = { + id: 'deepseek', + name: 'DeepSeek', + env: ['DEEPSEEK_API_KEY'], + api: 'https://api.deepseek.com', + npm: '@ai-sdk/openai-compatible', + doc: 'https://platform.deepseek.com/docs', + models: { + 'deepseek-chat': deepseekModel, + }, +}; + +/** + * Complete mock API response + */ +export const mockApiResponse: ModelsDevApiResponse = { + openai: openaiProvider, + anthropic: anthropicProvider, + google: googleProvider, + deepseek: deepseekProvider, +}; + +/** + * Empty API response + */ +export const emptyApiResponse: ModelsDevApiResponse = {}; + +/** + * Provider with no models + */ +export const emptyProvider: ModelsDevProvider = { + id: 'empty', + name: 'Empty Provider', + env: ['EMPTY_API_KEY'], + models: {}, +}; + +/** + * Invalid model data (for negative testing) + */ +export const invalidModelData = { + // Missing required 'id' + name: 'Invalid Model', + limit: { context: 8000, output: 4000 }, + release_date: '2024-01-01', + open_weights: false, +}; + +/** + * Invalid provider data (for negative testing) + */ +export const invalidProviderData = { + // Missing required 'env' + id: 'invalid', + name: 'Invalid Provider', + models: {}, +}; diff --git a/packages/core/test/models/profiles.test.ts b/packages/core/test/models/profiles.test.ts new file mode 100644 index 000000000..1fbf24931 --- /dev/null +++ b/packages/core/test/models/profiles.test.ts @@ -0,0 +1,234 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + generateDefaultProfile, + getRecommendedThinkingBudget, + mergeProfileWithDefaults, +} from '../../src/models/profiles.js'; +import type { + ModelsDevModel, + LlxprtDefaultProfile, +} from '../../src/models/schema.js'; +import { + minimalModel, + reasoningModel, + claudeModel, + geminiModel, + deepseekModel, +} from './__fixtures__/mock-data.js'; + +describe('generateDefaultProfile', () => { + describe('reasoning models', () => { + it('sets thinkingEnabled: true for reasoning model', () => { + const profile = generateDefaultProfile(reasoningModel); + expect(profile?.thinkingEnabled).toBe(true); + }); + + it('sets thinkingBudget: 10000 for reasoning model', () => { + const profile = generateDefaultProfile(reasoningModel); + expect(profile?.thinkingBudget).toBe(10000); + }); + + it('sets temperature: 0.7 for reasoning model with temperature support', () => { + const profile = generateDefaultProfile(reasoningModel); + expect(profile?.temperature).toBe(0.7); + }); + }); + + describe('non-reasoning models', () => { + it('sets temperature: 1.0 for non-reasoning model with temperature support', () => { + const model: ModelsDevModel = { + ...minimalModel, + reasoning: false, + temperature: true, + }; + const profile = generateDefaultProfile(model); + expect(profile?.temperature).toBe(1.0); + }); + + it('does not set thinkingEnabled for non-reasoning model', () => { + const model: ModelsDevModel = { + ...minimalModel, + reasoning: false, + temperature: true, + }; + const profile = generateDefaultProfile(model); + expect(profile?.thinkingEnabled).toBeUndefined(); + }); + }); + + describe('topP setting', () => { + it('sets topP: 0.95 when temperature is supported', () => { + const model: ModelsDevModel = { + ...minimalModel, + temperature: true, + }; + const profile = generateDefaultProfile(model); + expect(profile?.topP).toBe(0.95); + }); + + it('does not set topP when temperature not supported', () => { + const profile = generateDefaultProfile(minimalModel); + expect(profile?.topP).toBeUndefined(); + }); + }); + + describe('family-specific tuning', () => { + it('GPT-5 family gets temperature: 1.2, topP: 0.98', () => { + const gpt5Model: ModelsDevModel = { + ...minimalModel, + family: 'gpt-5', + temperature: true, + }; + const profile = generateDefaultProfile(gpt5Model); + expect(profile?.temperature).toBe(1.2); + expect(profile?.topP).toBe(0.98); + }); + + it('GPT-5 detected by model ID', () => { + const gpt5Model: ModelsDevModel = { + ...minimalModel, + id: 'gpt-5-turbo', + temperature: true, + }; + const profile = generateDefaultProfile(gpt5Model); + expect(profile?.temperature).toBe(1.2); + }); + + it('Claude family gets temperature: 0.8, topP: 0.9', () => { + const profile = generateDefaultProfile(claudeModel); + expect(profile?.temperature).toBe(0.8); + expect(profile?.topP).toBe(0.9); + }); + + it('Gemini family gets topK: 40', () => { + const profile = generateDefaultProfile(geminiModel); + expect(profile?.topK).toBe(40); + expect(profile?.temperature).toBe(1.0); + expect(profile?.topP).toBe(0.95); + }); + + it('DeepSeek family gets temperature: 0.7, topP: 0.9', () => { + const profile = generateDefaultProfile(deepseekModel); + expect(profile?.temperature).toBe(0.7); + expect(profile?.topP).toBe(0.9); + }); + + it('Qwen family gets temperature: 0.7, topP: 0.8', () => { + const qwenModel: ModelsDevModel = { + ...minimalModel, + family: 'qwen-2.5', + temperature: true, + }; + const profile = generateDefaultProfile(qwenModel); + expect(profile?.temperature).toBe(0.7); + expect(profile?.topP).toBe(0.8); + }); + }); + + describe('undefined profile', () => { + it('returns undefined when no settings apply', () => { + const profile = generateDefaultProfile(minimalModel); + expect(profile).toBeUndefined(); + }); + + it('returns undefined for model with no capabilities', () => { + const bareModel: ModelsDevModel = { + id: 'bare', + name: 'Bare', + limit: { context: 4000, output: 2000 }, + release_date: '2024-01-01', + open_weights: false, + }; + const profile = generateDefaultProfile(bareModel); + expect(profile).toBeUndefined(); + }); + }); +}); + +describe('getRecommendedThinkingBudget', () => { + it('returns 5000 for small context (minimum)', () => { + const budget = getRecommendedThinkingBudget(8000); + expect(budget).toBe(5000); + }); + + it('returns 5% of context for medium context', () => { + const budget = getRecommendedThinkingBudget(128000); + expect(budget).toBe(6400); // 128000 * 0.05 = 6400 + }); + + it('caps at 50000 for very large context', () => { + const budget = getRecommendedThinkingBudget(2000000); + expect(budget).toBe(50000); + }); + + it('calculates correctly for 1M context', () => { + const budget = getRecommendedThinkingBudget(1000000); + expect(budget).toBe(50000); // 1M * 0.05 = 50K, capped at 50K + }); + + it('floors the result', () => { + // 100001 * 0.05 = 5000.05, should floor to 5000 + const budget = getRecommendedThinkingBudget(100001); + expect(budget).toBe(5000); + }); +}); + +describe('mergeProfileWithDefaults', () => { + it('user settings override defaults', () => { + const defaults: LlxprtDefaultProfile = { + temperature: 1.0, + topP: 0.95, + }; + const userProfile: Partial = { + temperature: 0.5, + }; + const merged = mergeProfileWithDefaults(userProfile, defaults); + expect(merged.temperature).toBe(0.5); + expect(merged.topP).toBe(0.95); + }); + + it('preserves defaults when user setting missing', () => { + const defaults: LlxprtDefaultProfile = { + temperature: 1.0, + topP: 0.95, + topK: 40, + }; + const userProfile: Partial = {}; + const merged = mergeProfileWithDefaults(userProfile, defaults); + expect(merged.temperature).toBe(1.0); + expect(merged.topP).toBe(0.95); + expect(merged.topK).toBe(40); + }); + + it('handles undefined defaults gracefully', () => { + const userProfile: Partial = { + temperature: 0.7, + }; + const merged = mergeProfileWithDefaults(userProfile, undefined); + expect(merged.temperature).toBe(0.7); + }); + + it('returns empty object when both are empty', () => { + const merged = mergeProfileWithDefaults({}, undefined); + expect(merged).toEqual({}); + }); + + it('user can override thinking settings', () => { + const defaults: LlxprtDefaultProfile = { + thinkingEnabled: true, + thinkingBudget: 10000, + }; + const userProfile: Partial = { + thinkingBudget: 20000, + }; + const merged = mergeProfileWithDefaults(userProfile, defaults); + expect(merged.thinkingEnabled).toBe(true); + expect(merged.thinkingBudget).toBe(20000); + }); +}); diff --git a/packages/core/test/models/provider-integration.test.ts b/packages/core/test/models/provider-integration.test.ts new file mode 100644 index 000000000..2db0418fa --- /dev/null +++ b/packages/core/test/models/provider-integration.test.ts @@ -0,0 +1,285 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import { + llxprtModelToIModel, + hasModelInRegistry, + getExtendedModelInfo, + getRecommendedModel, +} from '../../src/models/provider-integration.js'; +import { ModelRegistry, getModelRegistry } from '../../src/models/registry.js'; +import type { LlxprtModel } from '../../src/models/schema.js'; +import { + mockApiResponse, + fullModel, + openaiProvider, +} from './__fixtures__/mock-data.js'; +import { transformModel } from '../../src/models/transformer.js'; + +// Mock fs module +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + statSync: vi.fn(), + }; +}); + +// Mock fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +// Reset singleton between tests +vi.mock('../../src/models/registry.js', async () => { + const actual = await vi.importActual< + typeof import('../../src/models/registry.js') + >('../../src/models/registry.js'); + let instance: ModelRegistry | null = null; + + return { + ...actual, + getModelRegistry: () => { + if (!instance) { + instance = new actual.ModelRegistry(); + } + return instance; + }, + // Expose reset for tests + __resetRegistry: () => { + if (instance) { + instance.dispose(); + } + instance = null; + }, + }; +}); + +// Get reset function +const resetRegistry = async () => { + const mod = await import('../../src/models/registry.js'); + // @ts-expect-error - test helper + mod.__resetRegistry?.(); +}; + +describe('llxprtModelToIModel', () => { + const sampleLlxprtModel: LlxprtModel = transformModel( + 'openai', + openaiProvider, + 'gpt-4-turbo', + fullModel, + ); + + it('converts to IModel with correct fields', () => { + const result = llxprtModelToIModel(sampleLlxprtModel); + expect(result.name).toBe('GPT-4 Turbo'); + expect(result.provider).toBe('OpenAI'); + expect(result.contextWindow).toBe(128000); + expect(result.maxOutputTokens).toBe(4096); + }); + + it('uses modelId (short) not full ID', () => { + const result = llxprtModelToIModel(sampleLlxprtModel); + expect(result.id).toBe('gpt-4-turbo'); + expect(result.id).not.toBe('openai/gpt-4-turbo'); + }); + + it('preserves supportedToolFormats array', () => { + const result = llxprtModelToIModel(sampleLlxprtModel); + expect(result.supportedToolFormats).toEqual(['openai']); + }); +}); + +describe('hasModelInRegistry', () => { + beforeEach(async () => { + await resetRegistry(); + vi.clearAllMocks(); + }); + + afterEach(async () => { + await resetRegistry(); + }); + + it('returns false when registry not initialized', () => { + const result = hasModelInRegistry('openai', 'gpt-4'); + expect(result).toBe(false); + }); + + describe('with initialized registry', () => { + beforeEach(async () => { + const now = Date.now(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + mtimeMs: now - 1000, + } as fs.Stats); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(mockApiResponse), + ); + mockFetch.mockRejectedValue(new Error('Network error')); + + const registry = getModelRegistry(); + await registry.initialize(); + }); + + it('returns true for existing model', () => { + const result = hasModelInRegistry('openai', 'gpt-4-turbo'); + expect(result).toBe(true); + }); + + it('returns false for non-existent model', () => { + const result = hasModelInRegistry('openai', 'nonexistent-model'); + expect(result).toBe(false); + }); + + it('checks across mapped provider IDs', () => { + // 'gemini' maps to ['google', 'google-vertex'] + const result = hasModelInRegistry('gemini', 'gemini-2.0-flash'); + expect(result).toBe(true); + }); + }); +}); + +describe('getExtendedModelInfo', () => { + beforeEach(async () => { + await resetRegistry(); + vi.clearAllMocks(); + }); + + afterEach(async () => { + await resetRegistry(); + }); + + it('returns undefined when registry not initialized', () => { + const result = getExtendedModelInfo('openai', 'gpt-4'); + expect(result).toBeUndefined(); + }); + + describe('with initialized registry', () => { + beforeEach(async () => { + const now = Date.now(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + mtimeMs: now - 1000, + } as fs.Stats); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(mockApiResponse), + ); + mockFetch.mockRejectedValue(new Error('Network error')); + + const registry = getModelRegistry(); + await registry.initialize(); + }); + + it('returns LlxprtModel for existing model', () => { + const result = getExtendedModelInfo('openai', 'gpt-4-turbo'); + expect(result).toBeDefined(); + expect(result?.id).toBe('openai/gpt-4-turbo'); + expect(result?.pricing).toBeDefined(); + expect(result?.capabilities).toBeDefined(); + }); + + it('returns undefined for non-existent model', () => { + const result = getExtendedModelInfo('openai', 'nonexistent'); + expect(result).toBeUndefined(); + }); + + it('searches across mapped provider IDs', () => { + const result = getExtendedModelInfo('gemini', 'gemini-2.0-flash'); + expect(result).toBeDefined(); + expect(result?.name).toBe('Gemini 2.0 Flash'); + }); + }); +}); + +describe('getRecommendedModel', () => { + beforeEach(async () => { + await resetRegistry(); + vi.clearAllMocks(); + }); + + afterEach(async () => { + await resetRegistry(); + }); + + it('returns undefined when registry not initialized', () => { + const result = getRecommendedModel('openai'); + expect(result).toBeUndefined(); + }); + + describe('with initialized registry', () => { + beforeEach(async () => { + const now = Date.now(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + mtimeMs: now - 1000, + } as fs.Stats); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(mockApiResponse), + ); + mockFetch.mockRejectedValue(new Error('Network error')); + + const registry = getModelRegistry(); + await registry.initialize(); + }); + + it('returns a model for valid provider', () => { + const result = getRecommendedModel('openai'); + expect(result).toBeDefined(); + expect(result?.providerId).toBe('openai'); + }); + + it('filters by requireToolCalling', () => { + const result = getRecommendedModel('openai', { + requireToolCalling: true, + }); + expect(result).toBeDefined(); + expect(result?.capabilities.toolCalling).toBe(true); + }); + + it('filters by requireReasoning', () => { + const result = getRecommendedModel('openai', { requireReasoning: true }); + expect(result).toBeDefined(); + expect(result?.capabilities.reasoning).toBe(true); + }); + + it('sorts by price when preferCheaper: true', () => { + const result = getRecommendedModel('openai', { preferCheaper: true }); + expect(result).toBeDefined(); + // Should return model with lowest input price + }); + + it('sorts by context window by default', () => { + const result = getRecommendedModel('openai'); + expect(result).toBeDefined(); + // Should return model with highest context window + }); + + it('returns undefined when no candidates match', () => { + const result = getRecommendedModel('openai', { + requireToolCalling: true, + requireReasoning: true, + }); + // o1-preview has reasoning but no tool_call + // gpt-4-turbo has tool_call but no reasoning + expect(result).toBeUndefined(); + }); + + it('excludes deprecated models', () => { + const result = getRecommendedModel('openai'); + expect(result?.metadata?.status).not.toBe('deprecated'); + }); + + it('returns undefined for unknown provider', () => { + const result = getRecommendedModel('nonexistent'); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/test/models/registry.test.ts b/packages/core/test/models/registry.test.ts new file mode 100644 index 000000000..fe71c8f41 --- /dev/null +++ b/packages/core/test/models/registry.test.ts @@ -0,0 +1,449 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import { ModelRegistry } from '../../src/models/registry.js'; +import { mockApiResponse } from './__fixtures__/mock-data.js'; + +// Mock fs module +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + mkdirSync: vi.fn(), + statSync: vi.fn(), + }; +}); + +// Mock fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +describe('ModelRegistry', () => { + let registry: ModelRegistry; + + beforeEach(() => { + // Create fresh registry for each test + registry = new ModelRegistry(); + vi.clearAllMocks(); + + // Default: no cache, no bundled fallback + vi.mocked(fs.existsSync).mockReturnValue(false); + }); + + afterEach(() => { + registry.dispose(); + }); + + describe('initialization', () => { + it('isInitialized returns false before init', () => { + expect(registry.isInitialized()).toBe(false); + }); + + it('isInitialized returns true after init', async () => { + // Mock fresh cache to load data + const now = Date.now(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + mtimeMs: now - 1000, + } as fs.Stats); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(mockApiResponse), + ); + mockFetch.mockRejectedValue(new Error('Network error')); + + await registry.initialize(); + expect(registry.isInitialized()).toBe(true); + }); + + it('initialize is idempotent', async () => { + const now = Date.now(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + mtimeMs: now - 1000, + } as fs.Stats); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(mockApiResponse), + ); + mockFetch.mockRejectedValue(new Error('Network error')); + + await registry.initialize(); + const countAfterFirst = registry.getModelCount(); + + await registry.initialize(); + const countAfterSecond = registry.getModelCount(); + + expect(countAfterFirst).toBe(countAfterSecond); + }); + + it('concurrent initialize calls do not duplicate work', async () => { + const now = Date.now(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + mtimeMs: now - 1000, + } as fs.Stats); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(mockApiResponse), + ); + mockFetch.mockRejectedValue(new Error('Network error')); + + // Start multiple initializations concurrently + const promises = [ + registry.initialize(), + registry.initialize(), + registry.initialize(), + ]; + + await Promise.all(promises); + + // readFileSync should only be called once for cache + // It may be called multiple times due to cache check, but initialization logic runs once + expect(registry.isInitialized()).toBe(true); + }); + }); + + describe('loading strategy', () => { + it('loads from cache if fresh', async () => { + const now = Date.now(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + mtimeMs: now - 1000, // 1 second ago (fresh) + } as fs.Stats); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(mockApiResponse), + ); + mockFetch.mockRejectedValue(new Error('Network error')); + + await registry.initialize(); + + expect(registry.getModelCount()).toBeGreaterThan(0); + }); + + it('skips stale cache older than 7 days and triggers refresh', async () => { + const now = Date.now(); + const eightDaysAgo = now - 8 * 24 * 60 * 60 * 1000; + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + mtimeMs: eightDaysAgo, + } as fs.Stats); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(mockApiResponse), + ); + mockFetch.mockRejectedValue(new Error('Network error')); + + await registry.initialize(); + + // Stale cache is skipped, refresh fails, models remain empty + expect(registry.getModelCount()).toBe(0); + }); + + it('loads from API when no cache exists', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockApiResponse, + } as Response); + + await registry.initialize(); + // Wait for background refresh + await new Promise((r) => setTimeout(r, 100)); + + expect(registry.getModelCount()).toBe(7); + }); + }); + + describe('query API', () => { + beforeEach(async () => { + const now = Date.now(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + mtimeMs: now - 1000, + } as fs.Stats); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(mockApiResponse), + ); + mockFetch.mockRejectedValue(new Error('Network error')); + await registry.initialize(); + }); + + describe('getAll', () => { + it('returns all models', () => { + const models = registry.getAll(); + expect(models.length).toBe(7); // From mockApiResponse + }); + }); + + describe('getById', () => { + it('returns model by full ID', () => { + const model = registry.getById('openai/gpt-4-turbo'); + expect(model).toBeDefined(); + expect(model?.name).toBe('GPT-4 Turbo'); + }); + + it('returns undefined for missing ID', () => { + const model = registry.getById('nonexistent/model'); + expect(model).toBeUndefined(); + }); + }); + + describe('getByProvider', () => { + it('filters by provider ID', () => { + const models = registry.getByProvider('openai'); + expect(models.length).toBe(4); + models.forEach((m) => expect(m.providerId).toBe('openai')); + }); + + it('returns empty array for unknown provider', () => { + const models = registry.getByProvider('unknown-provider'); + expect(models).toEqual([]); + }); + }); + + describe('search', () => { + it('filters by provider', () => { + const results = registry.search({ provider: 'anthropic' }); + expect(results.length).toBe(1); + expect(results[0].providerId).toBe('anthropic'); + }); + + it('filters by reasoning capability', () => { + const results = registry.search({ reasoning: true }); + expect(results.length).toBeGreaterThan(0); + results.forEach((m) => expect(m.capabilities.reasoning).toBe(true)); + }); + + it('filters by toolCalling capability', () => { + const results = registry.search({ toolCalling: true }); + expect(results.length).toBeGreaterThan(0); + results.forEach((m) => expect(m.capabilities.toolCalling).toBe(true)); + }); + + it('filters by maxPrice', () => { + const results = registry.search({ maxPrice: 1 }); + results.forEach((m) => { + if (m.pricing) { + expect(m.pricing.input).toBeLessThanOrEqual(1); + } + }); + }); + + it('filters by minContext', () => { + const results = registry.search({ minContext: 100000 }); + results.forEach((m) => { + expect(m.limits.contextWindow).toBeGreaterThanOrEqual(100000); + }); + }); + + it('combines multiple filters', () => { + const results = registry.search({ + provider: 'openai', + toolCalling: true, + }); + results.forEach((m) => { + expect(m.providerId).toBe('openai'); + expect(m.capabilities.toolCalling).toBe(true); + }); + }); + }); + }); + + describe('provider API', () => { + beforeEach(async () => { + const now = Date.now(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + mtimeMs: now - 1000, + } as fs.Stats); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(mockApiResponse), + ); + mockFetch.mockRejectedValue(new Error('Network error')); + await registry.initialize(); + }); + + it('getProviders returns all providers', () => { + const providers = registry.getProviders(); + expect(providers.length).toBe(4); + }); + + it('getProvider returns provider by ID', () => { + const provider = registry.getProvider('openai'); + expect(provider).toBeDefined(); + expect(provider?.name).toBe('OpenAI'); + }); + + it('getProvider returns undefined for missing', () => { + const provider = registry.getProvider('nonexistent'); + expect(provider).toBeUndefined(); + }); + }); + + describe('counts', () => { + beforeEach(async () => { + const now = Date.now(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + mtimeMs: now - 1000, + } as fs.Stats); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(mockApiResponse), + ); + mockFetch.mockRejectedValue(new Error('Network error')); + await registry.initialize(); + }); + + it('getModelCount returns correct count', () => { + expect(registry.getModelCount()).toBe(7); + }); + + it('getProviderCount returns correct count', () => { + expect(registry.getProviderCount()).toBe(4); + }); + }); + + describe('cache metadata', () => { + it('getCacheMetadata returns null before refresh', async () => { + const now = Date.now(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.statSync).mockReturnValue({ + mtimeMs: now - 1000, + } as fs.Stats); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(mockApiResponse), + ); + mockFetch.mockRejectedValue(new Error('Network error')); + await registry.initialize(); + + // No successful refresh yet (loaded from cache, not via refresh()) + const metadata = registry.getCacheMetadata(); + expect(metadata).toBeNull(); + }); + + it('getCacheMetadata returns data after successful refresh', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockApiResponse, + } as Response); + + await registry.initialize(); + // Explicitly call refresh to populate metadata + await registry.refresh(); + + // Wait a bit for refresh to complete + await new Promise((r) => setTimeout(r, 100)); + + const metadata = registry.getCacheMetadata(); + expect(metadata).not.toBeNull(); + expect(metadata?.modelCount).toBe(7); + expect(metadata?.providerCount).toBe(4); + }); + }); + + describe('refresh', () => { + it('refresh updates models from API', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockApiResponse, + } as Response); + + await registry.initialize(); + const success = await registry.refresh(); + + expect(success).toBe(true); + expect(registry.getModelCount()).toBe(7); + }); + + it('refresh returns false on network error', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + mockFetch.mockRejectedValue(new Error('Network error')); + + await registry.initialize(); + const success = await registry.refresh(); + + expect(success).toBe(false); + }); + + it('refresh returns false on non-ok response', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + } as Response); + + await registry.initialize(); + const success = await registry.refresh(); + + expect(success).toBe(false); + }); + }); + + describe('event system', () => { + it('emits models:updated on successful refresh', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockApiResponse, + } as Response); + + const callback = vi.fn(); + registry.on('models:updated', callback); + + await registry.initialize(); + await registry.refresh(); + + expect(callback).toHaveBeenCalled(); + }); + + it('emits models:error on refresh failure', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + mockFetch.mockRejectedValue(new Error('Network error')); + + const callback = vi.fn(); + registry.on('models:error', callback); + + await registry.initialize(); + await registry.refresh(); + + expect(callback).toHaveBeenCalled(); + }); + + it('off removes event listener', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockApiResponse, + } as Response); + + const callback = vi.fn(); + registry.on('models:updated', callback); + registry.off('models:updated', callback); + + await registry.initialize(); + await registry.refresh(); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('dispose', () => { + it('clears listeners on dispose', () => { + const callback = vi.fn(); + registry.on('models:updated', callback); + registry.dispose(); + + // Internal state cleared - can't easily test this without exposing internals + // Just ensure dispose doesn't throw + expect(true).toBe(true); + }); + }); +}); diff --git a/packages/core/test/models/schema.test.ts b/packages/core/test/models/schema.test.ts new file mode 100644 index 000000000..ca21ff047 --- /dev/null +++ b/packages/core/test/models/schema.test.ts @@ -0,0 +1,409 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + ModelsDevModelSchema, + ModelsDevProviderSchema, + ModelsDevApiResponseSchema, + LlxprtModelSchema, +} from '../../src/models/schema.js'; +import { + minimalModel, + fullModel, + reasoningModel, + deprecatedModel, + openaiProvider, + mockApiResponse, + invalidModelData, + invalidProviderData, +} from './__fixtures__/mock-data.js'; + +describe('ModelsDevModelSchema', () => { + describe('valid models', () => { + it('validates model with all fields', () => { + const result = ModelsDevModelSchema.safeParse(fullModel); + expect(result.success).toBe(true); + }); + + it('validates model with minimal required fields', () => { + const result = ModelsDevModelSchema.safeParse(minimalModel); + expect(result.success).toBe(true); + }); + + it('validates reasoning model', () => { + const result = ModelsDevModelSchema.safeParse(reasoningModel); + expect(result.success).toBe(true); + }); + + it('validates deprecated model with status', () => { + const result = ModelsDevModelSchema.safeParse(deprecatedModel); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.status).toBe('deprecated'); + } + }); + }); + + describe('required fields', () => { + it('fails when id is missing', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id: _, ...modelWithoutId } = minimalModel; + const result = ModelsDevModelSchema.safeParse(modelWithoutId); + expect(result.success).toBe(false); + }); + + it('fails when name is missing', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { name: _, ...modelWithoutName } = minimalModel; + const result = ModelsDevModelSchema.safeParse(modelWithoutName); + expect(result.success).toBe(false); + }); + + it('fails when limit is missing', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { limit: _, ...modelWithoutLimit } = minimalModel; + const result = ModelsDevModelSchema.safeParse(modelWithoutLimit); + expect(result.success).toBe(false); + }); + + it('fails when release_date is missing', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { release_date: _, ...modelWithoutDate } = minimalModel; + const result = ModelsDevModelSchema.safeParse(modelWithoutDate); + expect(result.success).toBe(false); + }); + + it('fails when open_weights is missing', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { open_weights: _, ...modelWithoutOW } = minimalModel; + const result = ModelsDevModelSchema.safeParse(modelWithoutOW); + expect(result.success).toBe(false); + }); + }); + + describe('optional fields', () => { + it('accepts undefined capability booleans', () => { + // minimalModel has no capability booleans + const result = ModelsDevModelSchema.safeParse(minimalModel); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.tool_call).toBeUndefined(); + expect(result.data.reasoning).toBeUndefined(); + expect(result.data.temperature).toBeUndefined(); + expect(result.data.attachment).toBeUndefined(); + } + }); + + it('accepts undefined cost', () => { + const result = ModelsDevModelSchema.safeParse(minimalModel); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.cost).toBeUndefined(); + } + }); + + it('accepts undefined modalities', () => { + const result = ModelsDevModelSchema.safeParse(minimalModel); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.modalities).toBeUndefined(); + } + }); + }); + + describe('enum validation', () => { + it('fails on invalid status value', () => { + const modelWithBadStatus = { + ...minimalModel, + status: 'invalid-status', + }; + const result = ModelsDevModelSchema.safeParse(modelWithBadStatus); + expect(result.success).toBe(false); + }); + + it('accepts valid status values', () => { + for (const status of ['alpha', 'beta', 'deprecated']) { + const model = { ...minimalModel, status }; + const result = ModelsDevModelSchema.safeParse(model); + expect(result.success).toBe(true); + } + }); + + it('fails on invalid modality values', () => { + const modelWithBadModality = { + ...minimalModel, + modalities: { + input: ['text', 'invalid-modality'], + output: ['text'], + }, + }; + const result = ModelsDevModelSchema.safeParse(modelWithBadModality); + expect(result.success).toBe(false); + }); + }); + + describe('cost object validation', () => { + it('validates complete cost object', () => { + const result = ModelsDevModelSchema.safeParse(fullModel); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.cost?.input).toBe(10); + expect(result.data.cost?.output).toBe(30); + expect(result.data.cost?.cache_read).toBe(2.5); + } + }); + + it('accepts cost with only required fields', () => { + const modelWithMinimalCost = { + ...minimalModel, + cost: { input: 1, output: 2 }, + }; + const result = ModelsDevModelSchema.safeParse(modelWithMinimalCost); + expect(result.success).toBe(true); + }); + }); + + describe('interleaved field', () => { + it('accepts boolean true', () => { + const model = { ...minimalModel, interleaved: true }; + const result = ModelsDevModelSchema.safeParse(model); + expect(result.success).toBe(true); + }); + + it('accepts object form with field', () => { + const model = { + ...minimalModel, + interleaved: { field: 'reasoning_content' }, + }; + const result = ModelsDevModelSchema.safeParse(model); + expect(result.success).toBe(true); + }); + + it('fails on invalid interleaved field value', () => { + const model = { + ...minimalModel, + interleaved: { field: 'invalid_field' }, + }; + const result = ModelsDevModelSchema.safeParse(model); + expect(result.success).toBe(false); + }); + }); +}); + +describe('ModelsDevProviderSchema', () => { + describe('valid providers', () => { + it('validates provider with models', () => { + const result = ModelsDevProviderSchema.safeParse(openaiProvider); + expect(result.success).toBe(true); + }); + + it('validates provider with empty models', () => { + const emptyProvider = { + id: 'empty', + name: 'Empty', + env: ['API_KEY'], + models: {}, + }; + const result = ModelsDevProviderSchema.safeParse(emptyProvider); + expect(result.success).toBe(true); + }); + }); + + describe('required fields', () => { + it('fails when env is missing', () => { + const result = ModelsDevProviderSchema.safeParse(invalidProviderData); + expect(result.success).toBe(false); + }); + + it('fails when env array is missing', () => { + const providerWithoutEnv = { + id: 'test', + name: 'Test', + models: {}, + }; + const result = ModelsDevProviderSchema.safeParse(providerWithoutEnv); + expect(result.success).toBe(false); + }); + + it('fails when models is missing', () => { + const providerWithoutModels = { + id: 'test', + name: 'Test', + env: ['KEY'], + }; + const result = ModelsDevProviderSchema.safeParse(providerWithoutModels); + expect(result.success).toBe(false); + }); + }); + + describe('optional fields', () => { + it('accepts undefined api', () => { + const provider = { + id: 'test', + name: 'Test', + env: ['KEY'], + models: {}, + }; + const result = ModelsDevProviderSchema.safeParse(provider); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.api).toBeUndefined(); + } + }); + }); +}); + +describe('ModelsDevApiResponseSchema', () => { + it('validates complete API response', () => { + const result = ModelsDevApiResponseSchema.safeParse(mockApiResponse); + expect(result.success).toBe(true); + }); + + it('validates empty response', () => { + const result = ModelsDevApiResponseSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('fails when provider has invalid model', () => { + const badResponse = { + test: { + id: 'test', + name: 'Test', + env: ['KEY'], + models: { + 'bad-model': invalidModelData, + }, + }, + }; + const result = ModelsDevApiResponseSchema.safeParse(badResponse); + expect(result.success).toBe(false); + }); +}); + +describe('LlxprtModelSchema', () => { + it('validates transformed model structure', () => { + const llxprtModel = { + id: 'openai/gpt-4', + name: 'GPT-4', + provider: 'OpenAI', + providerId: 'openai', + providerName: 'OpenAI', + modelId: 'gpt-4', + supportedToolFormats: ['openai'], + contextWindow: 128000, + maxOutputTokens: 4096, + capabilities: { + vision: true, + audio: false, + pdf: false, + toolCalling: true, + reasoning: false, + temperature: true, + structuredOutput: true, + attachment: true, + }, + limits: { + contextWindow: 128000, + maxOutput: 4096, + }, + metadata: { + releaseDate: '2024-01-01', + openWeights: false, + }, + envVars: ['OPENAI_API_KEY'], + }; + + const result = LlxprtModelSchema.safeParse(llxprtModel); + expect(result.success).toBe(true); + }); + + it('requires all capability booleans', () => { + const modelMissingCaps = { + id: 'test/model', + name: 'Test', + provider: 'Test', + providerId: 'test', + providerName: 'Test', + modelId: 'model', + supportedToolFormats: ['openai'], + capabilities: { + vision: true, + // Missing other capabilities + }, + limits: { contextWindow: 8000, maxOutput: 4000 }, + metadata: { releaseDate: '2024-01-01', openWeights: false }, + envVars: ['KEY'], + }; + + const result = LlxprtModelSchema.safeParse(modelMissingCaps); + expect(result.success).toBe(false); + }); + + it('accepts optional pricing', () => { + const modelWithPricing = { + id: 'test/model', + name: 'Test', + provider: 'Test', + providerId: 'test', + providerName: 'Test', + modelId: 'model', + supportedToolFormats: ['openai'], + capabilities: { + vision: false, + audio: false, + pdf: false, + toolCalling: true, + reasoning: false, + temperature: true, + structuredOutput: false, + attachment: false, + }, + pricing: { + input: 10, + output: 30, + }, + limits: { contextWindow: 8000, maxOutput: 4000 }, + metadata: { releaseDate: '2024-01-01', openWeights: false }, + envVars: ['KEY'], + }; + + const result = LlxprtModelSchema.safeParse(modelWithPricing); + expect(result.success).toBe(true); + }); + + it('validates status enum mapping', () => { + const modelWithStatus = { + id: 'test/model', + name: 'Test', + provider: 'Test', + providerId: 'test', + providerName: 'Test', + modelId: 'model', + supportedToolFormats: ['openai'], + capabilities: { + vision: false, + audio: false, + pdf: false, + toolCalling: false, + reasoning: false, + temperature: false, + structuredOutput: false, + attachment: false, + }, + limits: { contextWindow: 8000, maxOutput: 4000 }, + metadata: { + releaseDate: '2024-01-01', + openWeights: false, + status: 'stable', + }, + envVars: ['KEY'], + }; + + const result = LlxprtModelSchema.safeParse(modelWithStatus); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/core/test/models/transformer.test.ts b/packages/core/test/models/transformer.test.ts new file mode 100644 index 000000000..e71c5125f --- /dev/null +++ b/packages/core/test/models/transformer.test.ts @@ -0,0 +1,510 @@ +/** + * @license + * Copyright 2025 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + transformModel, + transformProvider, + transformApiResponse, +} from '../../src/models/transformer.js'; +import { + minimalModel, + fullModel, + visionModel, + reasoningModel, + deprecatedModel, + claudeModel, + geminiModel, + deepseekModel, + openaiProvider, + anthropicProvider, + googleProvider, + deepseekProvider, + mockApiResponse, + emptyApiResponse, +} from './__fixtures__/mock-data.js'; + +describe('transformModel', () => { + describe('ID generation', () => { + it('creates correct full ID format provider/model', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'gpt-4-turbo', + fullModel, + ); + expect(result.id).toBe('openai/gpt-4-turbo'); + }); + + it('preserves short modelId separately', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'gpt-4-turbo', + fullModel, + ); + expect(result.modelId).toBe('gpt-4-turbo'); + }); + }); + + describe('capability mapping from modalities', () => { + it('maps image in input to vision: true', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'gpt-4o', + visionModel, + ); + expect(result.capabilities.vision).toBe(true); + }); + + it('maps audio in input to audio: true', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'gpt-4o', + visionModel, + ); + expect(result.capabilities.audio).toBe(true); + }); + + it('maps pdf in input to pdf: true', () => { + const result = transformModel( + 'anthropic', + anthropicProvider, + 'claude-3-5-sonnet', + claudeModel, + ); + expect(result.capabilities.pdf).toBe(true); + }); + + it('sets vision/audio/pdf to false when not in modalities', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'o1-preview', + reasoningModel, + ); + expect(result.capabilities.vision).toBe(false); + expect(result.capabilities.audio).toBe(false); + expect(result.capabilities.pdf).toBe(false); + }); + + it('handles undefined modalities', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'test', + minimalModel, + ); + expect(result.capabilities.vision).toBe(false); + expect(result.capabilities.audio).toBe(false); + expect(result.capabilities.pdf).toBe(false); + }); + }); + + describe('defaults for missing booleans', () => { + it('defaults tool_call undefined to toolCalling: false', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'test', + minimalModel, + ); + expect(result.capabilities.toolCalling).toBe(false); + }); + + it('defaults reasoning undefined to reasoning: false', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'test', + minimalModel, + ); + expect(result.capabilities.reasoning).toBe(false); + }); + + it('defaults temperature undefined to temperature: false', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'test', + minimalModel, + ); + expect(result.capabilities.temperature).toBe(false); + }); + + it('defaults attachment undefined to attachment: false', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'test', + minimalModel, + ); + expect(result.capabilities.attachment).toBe(false); + }); + + it('preserves true values when present', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'gpt-4-turbo', + fullModel, + ); + expect(result.capabilities.toolCalling).toBe(true); + expect(result.capabilities.temperature).toBe(true); + expect(result.capabilities.attachment).toBe(true); + }); + }); + + describe('provider tool format mapping', () => { + it('maps anthropic provider to anthropic format', () => { + const result = transformModel( + 'anthropic', + anthropicProvider, + 'claude-3-5-sonnet', + claudeModel, + ); + expect(result.supportedToolFormats).toEqual(['anthropic']); + }); + + it('maps openai provider to openai format', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'gpt-4-turbo', + fullModel, + ); + expect(result.supportedToolFormats).toEqual(['openai']); + }); + + it('maps google provider to google and gemini formats', () => { + const result = transformModel( + 'google', + googleProvider, + 'gemini-2.0-flash', + geminiModel, + ); + expect(result.supportedToolFormats).toEqual(['google', 'gemini']); + }); + + it('maps google-vertex provider to google and gemini formats', () => { + const vertexProvider = { ...googleProvider, id: 'google-vertex' }; + const result = transformModel( + 'google-vertex', + vertexProvider, + 'gemini-2.0-flash', + geminiModel, + ); + expect(result.supportedToolFormats).toEqual(['google', 'gemini']); + }); + + it('defaults unknown provider to openai format', () => { + const unknownProvider = { + ...openaiProvider, + id: 'unknown-provider', + name: 'Unknown', + }; + const result = transformModel( + 'unknown-provider', + unknownProvider, + 'model', + minimalModel, + ); + expect(result.supportedToolFormats).toEqual(['openai']); + }); + + it('maps deepseek to openai format', () => { + const result = transformModel( + 'deepseek', + deepseekProvider, + 'deepseek-chat', + deepseekModel, + ); + expect(result.supportedToolFormats).toEqual(['openai']); + }); + }); + + describe('pricing transformation', () => { + it('transforms cost object to pricing', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'gpt-4-turbo', + fullModel, + ); + expect(result.pricing).toBeDefined(); + expect(result.pricing?.input).toBe(10); + expect(result.pricing?.output).toBe(30); + expect(result.pricing?.cacheRead).toBe(2.5); + expect(result.pricing?.cacheWrite).toBe(5); + }); + + it('sets pricing undefined when no cost', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'test', + minimalModel, + ); + expect(result.pricing).toBeUndefined(); + }); + }); + + describe('status mapping', () => { + it('maps undefined status to stable', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'gpt-4-turbo', + fullModel, + ); + expect(result.metadata.status).toBe('stable'); + }); + + it('maps alpha status to alpha', () => { + const alphaModel = { ...minimalModel, status: 'alpha' as const }; + const result = transformModel( + 'openai', + openaiProvider, + 'test', + alphaModel, + ); + expect(result.metadata.status).toBe('alpha'); + }); + + it('maps beta status to beta', () => { + const betaModel = { ...minimalModel, status: 'beta' as const }; + const result = transformModel( + 'openai', + openaiProvider, + 'test', + betaModel, + ); + expect(result.metadata.status).toBe('beta'); + }); + + it('maps deprecated status to deprecated', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'gpt-3.5-turbo-0301', + deprecatedModel, + ); + expect(result.metadata.status).toBe('deprecated'); + }); + }); + + describe('limits transformation', () => { + it('sets contextWindow from limit.context', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'gpt-4-turbo', + fullModel, + ); + expect(result.contextWindow).toBe(128000); + expect(result.limits.contextWindow).toBe(128000); + }); + + it('sets maxOutputTokens from limit.output', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'gpt-4-turbo', + fullModel, + ); + expect(result.maxOutputTokens).toBe(4096); + expect(result.limits.maxOutput).toBe(4096); + }); + }); + + describe('metadata transformation', () => { + it('maps knowledge to knowledgeCutoff', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'gpt-4-turbo', + fullModel, + ); + expect(result.metadata.knowledgeCutoff).toBe('2024-04'); + }); + + it('maps release_date to releaseDate', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'gpt-4-turbo', + fullModel, + ); + expect(result.metadata.releaseDate).toBe('2024-04-09'); + }); + + it('maps last_updated to lastUpdated', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'gpt-4-turbo', + fullModel, + ); + expect(result.metadata.lastUpdated).toBe('2024-06-01'); + }); + + it('maps open_weights to openWeights', () => { + const result = transformModel( + 'deepseek', + deepseekProvider, + 'deepseek-chat', + deepseekModel, + ); + expect(result.metadata.openWeights).toBe(true); + }); + }); + + describe('provider config', () => { + it('includes envVars from provider', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'gpt-4-turbo', + fullModel, + ); + expect(result.envVars).toEqual(['OPENAI_API_KEY']); + }); + + it('includes apiEndpoint from provider', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'gpt-4-turbo', + fullModel, + ); + expect(result.apiEndpoint).toBe('https://api.openai.com/v1'); + }); + + it('includes npmPackage from provider', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'gpt-4-turbo', + fullModel, + ); + expect(result.npmPackage).toBe('@ai-sdk/openai'); + }); + + it('includes docUrl from provider', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'gpt-4-turbo', + fullModel, + ); + expect(result.docUrl).toBe('https://platform.openai.com/docs'); + }); + }); + + describe('default profile generation', () => { + it('includes defaultProfile for reasoning model', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'o1-preview', + reasoningModel, + ); + expect(result.defaultProfile).toBeDefined(); + expect(result.defaultProfile?.thinkingEnabled).toBe(true); + }); + + it('may return undefined defaultProfile for minimal model', () => { + const result = transformModel( + 'openai', + openaiProvider, + 'test', + minimalModel, + ); + // minimalModel has no capabilities, so profile may be undefined + // This depends on generateDefaultProfile implementation + expect(result.defaultProfile).toBeUndefined(); + }); + }); +}); + +describe('transformProvider', () => { + it('creates provider with correct ID', () => { + const result = transformProvider('openai', openaiProvider); + expect(result.id).toBe('openai'); + }); + + it('includes provider name', () => { + const result = transformProvider('openai', openaiProvider); + expect(result.name).toBe('OpenAI'); + }); + + it('counts models correctly', () => { + const result = transformProvider('openai', openaiProvider); + expect(result.modelCount).toBe(4); // fullModel, visionModel, reasoningModel, deprecatedModel + }); + + it('includes all provider metadata', () => { + const result = transformProvider('openai', openaiProvider); + expect(result.envVars).toEqual(['OPENAI_API_KEY']); + expect(result.apiEndpoint).toBe('https://api.openai.com/v1'); + expect(result.npmPackage).toBe('@ai-sdk/openai'); + expect(result.docUrl).toBe('https://platform.openai.com/docs'); + }); + + it('handles provider with no optional fields', () => { + const minimalProvider = { + id: 'minimal', + name: 'Minimal', + env: ['KEY'], + models: {}, + }; + const result = transformProvider('minimal', minimalProvider); + expect(result.apiEndpoint).toBeUndefined(); + expect(result.npmPackage).toBeUndefined(); + expect(result.docUrl).toBeUndefined(); + }); +}); + +describe('transformApiResponse', () => { + it('transforms multiple providers', () => { + const { providers } = transformApiResponse(mockApiResponse); + expect(providers.size).toBe(4); // openai, anthropic, google, deepseek + }); + + it('creates model and provider maps', () => { + const { models, providers } = transformApiResponse(mockApiResponse); + expect(models).toBeInstanceOf(Map); + expect(providers).toBeInstanceOf(Map); + }); + + it('model IDs are unique across providers', () => { + const { models } = transformApiResponse(mockApiResponse); + const ids = Array.from(models.keys()); + const uniqueIds = new Set(ids); + expect(ids.length).toBe(uniqueIds.size); + }); + + it('models have correct full IDs', () => { + const { models } = transformApiResponse(mockApiResponse); + expect(models.has('openai/gpt-4-turbo')).toBe(true); + expect(models.has('anthropic/claude-3-5-sonnet')).toBe(true); + expect(models.has('google/gemini-2.0-flash')).toBe(true); + }); + + it('empty response returns empty maps', () => { + const { models, providers } = transformApiResponse(emptyApiResponse); + expect(models.size).toBe(0); + expect(providers.size).toBe(0); + }); + + it('transforms all models from all providers', () => { + const { models } = transformApiResponse(mockApiResponse); + // Count expected models: openai(4) + anthropic(1) + google(1) + deepseek(1) = 7 + expect(models.size).toBe(7); + }); +});