diff --git a/packages/types/src/custom-models.ts b/packages/types/src/custom-models.ts new file mode 100644 index 0000000000..91d1b971a2 --- /dev/null +++ b/packages/types/src/custom-models.ts @@ -0,0 +1,39 @@ +import { z } from "zod" + +/** + * Schema for custom model information + * Defines the properties that can be specified for custom models + */ +export const customModelInfoSchema = z.object({ + maxTokens: z.number().positive().optional(), + contextWindow: z.number().positive(), + supportsImages: z.boolean().optional(), + supportsPromptCache: z.boolean(), // Required in ModelInfo + supportsTemperature: z.boolean().optional(), + inputPrice: z.number().nonnegative().optional(), + outputPrice: z.number().nonnegative().optional(), + cacheWritesPrice: z.number().nonnegative().optional(), + cacheReadsPrice: z.number().nonnegative().optional(), + description: z.string().optional(), + supportsReasoningEffort: z.boolean().optional(), + supportsReasoningBudget: z.boolean().optional(), + requiredReasoningBudget: z.boolean().optional(), + reasoningEffort: z.string().optional(), +}) + +/** + * Schema for a custom models file + * The file is a simple record of model IDs to model information + * The provider is determined by the filename (e.g., openrouter.json) + */ +export const customModelsFileSchema = z.record(z.string(), customModelInfoSchema) + +/** + * Type for the content of a custom models file + */ +export type CustomModelsFile = z.infer + +/** + * Type for custom model information + */ +export type CustomModelInfo = z.infer diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7a7d5059eb..514de17bf6 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -2,6 +2,7 @@ export * from "./api.js" export * from "./cloud.js" export * from "./codebase-index.js" export * from "./cookie-consent.js" +export * from "./custom-models.js" export * from "./events.js" export * from "./experiment.js" export * from "./followup.js" diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index c70a15926d..c2f9a4d7a4 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -16,6 +16,7 @@ import { safeJsonParse } from "../../shared/safeJsonParse" import { ApiStream } from "../transform/stream" import { addCacheBreakpoints } from "../transform/caching/vertex" import { getModelParams } from "../transform/model-params" +import { getProviderModelsSync } from "./model-lookup" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" @@ -164,8 +165,9 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple getModel() { const modelId = this.options.apiModelId - let id = modelId && modelId in vertexModels ? (modelId as VertexModelId) : vertexDefaultModelId - const info: ModelInfo = vertexModels[id] + const models = getProviderModelsSync("vertex", vertexModels as Record) + let id = modelId && modelId in models ? (modelId as VertexModelId) : vertexDefaultModelId + const info: ModelInfo = models[id] const params = getModelParams({ format: "anthropic", modelId: id, model: info, settings: this.options }) // The `:thinking` suffix indicates that the model is a "Hybrid" diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 0e767ce237..378fd186fb 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -14,6 +14,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" +import { getProviderModelsSync } from "./model-lookup" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" @@ -249,8 +250,9 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa getModel() { const modelId = this.options.apiModelId - let id = modelId && modelId in anthropicModels ? (modelId as AnthropicModelId) : anthropicDefaultModelId - let info: ModelInfo = anthropicModels[id] + const models = getProviderModelsSync("anthropic", anthropicModels as Record) + let id = modelId && modelId in models ? (modelId as AnthropicModelId) : anthropicDefaultModelId + let info: ModelInfo = models[id] // If 1M context beta is enabled for Claude Sonnet 4 or 4.5, update the model info if ((id === "claude-sonnet-4-20250514" || id === "claude-sonnet-4-5") && this.options.anthropicBeta1MContext) { diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index faaee0360f..192ba6ed69 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -27,6 +27,7 @@ import { import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" +import { getProviderModelsSync } from "./model-lookup" import { logger } from "../../utils/logging" import { Package } from "../../shared/package" import { MultiPointStrategy } from "../transform/cache-strategy/multi-point-strategy" @@ -899,19 +900,22 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH //Prompt Router responses come back in a different sequence and the model used is in the response and must be fetched by name getModelById(modelId: string, modelType?: string): { id: BedrockModelId | string; info: ModelInfo } { - // Try to find the model in bedrockModels + // Get merged models (static + custom) + const models = getProviderModelsSync("bedrock", bedrockModels as Record) + + // Try to find the model in merged models const baseModelId = this.parseBaseModelId(modelId) as BedrockModelId let model - if (baseModelId in bedrockModels) { + if (baseModelId in models) { //Do a deep copy of the model info so that later in the code the model id and maxTokens can be set. - // The bedrockModels array is a constant and updating the model ID from the returned invokedModelID value + // The models array is a constant and updating the model ID from the returned invokedModelID value // in a prompt router response isn't possible on the constant. - model = { id: baseModelId, info: JSON.parse(JSON.stringify(bedrockModels[baseModelId])) } + model = { id: baseModelId, info: JSON.parse(JSON.stringify(models[baseModelId])) } } else if (modelType && modelType.includes("router")) { model = { id: bedrockDefaultPromptRouterModelId, - info: JSON.parse(JSON.stringify(bedrockModels[bedrockDefaultPromptRouterModelId])), + info: JSON.parse(JSON.stringify(models[bedrockDefaultPromptRouterModelId])), } } else { // Use heuristics for model info, then allow overrides from ProviderSettings @@ -919,7 +923,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH model = { id: bedrockDefaultModelId, info: { - ...JSON.parse(JSON.stringify(bedrockModels[bedrockDefaultModelId])), + ...JSON.parse(JSON.stringify(models[bedrockDefaultModelId])), ...guessed, }, } diff --git a/src/api/providers/fetchers/__tests__/modelCache.custom.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.custom.spec.ts new file mode 100644 index 0000000000..cbfb43deca --- /dev/null +++ b/src/api/providers/fetchers/__tests__/modelCache.custom.spec.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { getModels, flushModels } from "../modelCache" +import * as customModels from "../../../../services/custom-models" +import * as openrouter from "../openrouter" + +// Mock file data storage +const mockReadFileData: Record = {} + +// Mock the custom models service +vi.mock("../../../../services/custom-models", () => ({ + getCustomModelsForProvider: vi.fn(), +})) + +// Mock the openrouter fetcher +vi.mock("../openrouter", () => ({ + getOpenRouterModels: vi.fn(), +})) + +// Mock other dependencies +vi.mock("../../../../utils/path", () => ({ + getWorkspacePath: vi.fn(() => "/test/workspace"), +})) + +vi.mock("../../../../core/config/ContextProxy", () => ({ + ContextProxy: { + instance: { + globalStorageUri: { + fsPath: "/test/storage", + }, + }, + }, +})) + +vi.mock("../../../../utils/storage", () => ({ + getCacheDirectoryPath: vi.fn(() => "/test/cache"), +})) + +// Mock safeWriteJson to populate our mock file data +vi.mock("../../../../utils/safeWriteJson", () => ({ + safeWriteJson: vi.fn((filePath: string, data: any) => { + mockReadFileData[filePath] = data + return Promise.resolve() + }), +})) + +// Mock fs.readFile to return the models that were written +vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn((filePath: string) => { + const data = mockReadFileData[filePath] + if (!data) throw new Error("File not found") + return Promise.resolve(JSON.stringify(data)) + }), + }, + readFile: vi.fn((filePath: string) => { + const data = mockReadFileData[filePath] + if (!data) throw new Error("File not found") + return Promise.resolve(JSON.stringify(data)) + }), +})) + +vi.mock("../../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn((filePath: string) => { + return Promise.resolve(filePath in mockReadFileData) + }), +})) + +describe("Model Cache with Custom Models", () => { + beforeEach(async () => { + vi.clearAllMocks() + // Clear both memory cache and mock file cache before each test + await flushModels("openrouter") + // Clear the mock file cache + Object.keys(mockReadFileData).forEach((key) => delete mockReadFileData[key]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should merge custom models with provider-fetched models", async () => { + const providerModels = { + "openai/gpt-4": { + maxTokens: 8000, + contextWindow: 128000, + supportsImages: true, + supportsPromptCache: false, + }, + } + + const customModelDefs = { + "custom/my-model": { + maxTokens: 4096, + contextWindow: 32000, + supportsPromptCache: false, + description: "My custom model", + }, + } + + vi.mocked(openrouter.getOpenRouterModels).mockResolvedValueOnce(providerModels) + vi.mocked(customModels.getCustomModelsForProvider).mockResolvedValueOnce(customModelDefs) + + const result = await getModels({ provider: "openrouter" }) + + expect(result).toEqual({ + ...providerModels, + ...customModelDefs, + }) + expect(openrouter.getOpenRouterModels).toHaveBeenCalledTimes(1) + expect(customModels.getCustomModelsForProvider).toHaveBeenCalledWith("openrouter", "/test/workspace") + }) + + it("should allow custom models to override provider models", async () => { + const providerModels = { + "openai/gpt-4": { + maxTokens: 8000, + contextWindow: 128000, + supportsImages: true, + supportsPromptCache: false, + }, + } + + const customModelDefs = { + "openai/gpt-4": { + maxTokens: 16000, // Override max tokens + contextWindow: 128000, + supportsImages: true, + supportsPromptCache: false, + description: "Custom GPT-4 with higher token limit", + }, + } + + vi.mocked(openrouter.getOpenRouterModels).mockResolvedValueOnce(providerModels) + vi.mocked(customModels.getCustomModelsForProvider).mockResolvedValueOnce(customModelDefs) + + const result = await getModels({ provider: "openrouter" }) + + expect(result["openai/gpt-4"]).toEqual(customModelDefs["openai/gpt-4"]) + expect(result["openai/gpt-4"].maxTokens).toBe(16000) + }) + + it("should handle empty custom models gracefully", async () => { + const providerModels = { + "openai/gpt-4": { + maxTokens: 8000, + contextWindow: 128000, + supportsPromptCache: false, + }, + } + + vi.mocked(openrouter.getOpenRouterModels).mockResolvedValueOnce(providerModels) + vi.mocked(customModels.getCustomModelsForProvider).mockResolvedValueOnce({}) + + const result = await getModels({ provider: "openrouter" }) + + expect(result).toEqual(providerModels) + }) + + it("should work when provider returns no models", async () => { + const customModelDefs = { + "custom/model-1": { + maxTokens: 4096, + contextWindow: 32000, + supportsPromptCache: false, + }, + } + + vi.mocked(openrouter.getOpenRouterModels).mockResolvedValueOnce({}) + vi.mocked(customModels.getCustomModelsForProvider).mockResolvedValueOnce(customModelDefs) + + const result = await getModels({ provider: "openrouter" }) + + expect(result).toEqual(customModelDefs) + }) + + it("should handle errors in custom models loading gracefully", async () => { + const providerModels = { + "openai/gpt-4": { + maxTokens: 8000, + contextWindow: 128000, + supportsPromptCache: false, + }, + } + + vi.mocked(openrouter.getOpenRouterModels).mockResolvedValueOnce(providerModels) + vi.mocked(customModels.getCustomModelsForProvider).mockRejectedValueOnce( + new Error("Failed to load custom models"), + ) + + // The error in loading custom models should cause the overall fetch to fail + await expect(getModels({ provider: "openrouter" })).rejects.toThrow("Failed to load custom models") + }) + + it("should flush cache for specific provider", async () => { + const providerModels = { + "openai/gpt-4": { + maxTokens: 8000, + contextWindow: 128000, + supportsPromptCache: false, + }, + } + + // First call - should fetch + vi.mocked(openrouter.getOpenRouterModels).mockResolvedValueOnce(providerModels) + vi.mocked(customModels.getCustomModelsForProvider).mockResolvedValueOnce({}) + await getModels({ provider: "openrouter" }) + expect(openrouter.getOpenRouterModels).toHaveBeenCalledTimes(1) + + // Second call - should use cache (no new mocks needed) + await getModels({ provider: "openrouter" }) + expect(openrouter.getOpenRouterModels).toHaveBeenCalledTimes(1) + + // Flush cache + await flushModels("openrouter") + + // Third call - should fetch again (set up mock again) + vi.mocked(openrouter.getOpenRouterModels).mockResolvedValueOnce(providerModels) + vi.mocked(customModels.getCustomModelsForProvider).mockResolvedValueOnce({}) + await getModels({ provider: "openrouter" }) + expect(openrouter.getOpenRouterModels).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 722e66dd72..18a0dc978e 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -11,6 +11,8 @@ import { ContextProxy } from "../../../core/config/ContextProxy" import { getCacheDirectoryPath } from "../../../utils/storage" import type { RouterName, ModelRecord } from "../../../shared/api" import { fileExistsAtPath } from "../../../utils/fs" +import { getCustomModelsForProvider } from "../../../services/custom-models" +import { getWorkspacePath } from "../../../utils/path" import { getOpenRouterModels } from "./openrouter" import { getVercelAiGatewayModels } from "./vercel-ai-gateway" @@ -118,6 +120,10 @@ export const getModels = async (options: GetModelsOptions): Promise } } + // Load and merge custom models + const customModels = await getCustomModelsForProvider(provider, getWorkspacePath()) + models = { ...models, ...customModels } + // Cache the fetched models (even if empty, to signify a successful fetch with no models). memoryCache.set(provider, models) diff --git a/src/api/providers/model-lookup.ts b/src/api/providers/model-lookup.ts new file mode 100644 index 0000000000..2f2fccf962 --- /dev/null +++ b/src/api/providers/model-lookup.ts @@ -0,0 +1,143 @@ +import type { ModelInfo, ProviderName } from "@roo-code/types" + +import { + getModelsForStaticProvider, + getStaticProviderNames, + isStaticProvider, +} from "../../services/custom-models/static-providers" +import { getWorkspacePath } from "../../utils/path" + +/** + * Cache for merged static provider models + * Pre-loaded during extension activation for synchronous access + */ +const mergedModelsCache = new Map>() +let isPreloaded = false + +/** + * Get models for a provider, including custom models from .roo/models/ + * This works for both static providers (anthropic, bedrock, etc.) and is used + * to look up model information in provider handlers. + * + * @param provider The provider name + * @param staticModels The base static models dictionary + * @returns Promise of merged models (static + custom) + */ +export async function getProviderModels( + provider: ProviderName, + staticModels: Record, +): Promise> { + // Check cache first + if (mergedModelsCache.has(provider)) { + return mergedModelsCache.get(provider)! + } + + try { + const cwd = getWorkspacePath() + const mergedModels = await getModelsForStaticProvider(provider, cwd) + + // Cache the result + mergedModelsCache.set(provider, mergedModels) + + return mergedModels + } catch (error) { + console.error(`[ModelLookup] Error loading custom models for ${provider}:`, error) + // Fallback to static models only + return staticModels + } +} + +/** + * Pre-load custom models for all static providers + * Should be called during extension activation + * @param cwd Current working directory + */ +export async function preloadStaticProviderModels(cwd: string): Promise { + try { + // Get all static provider names from the source of truth + const staticProviders = getStaticProviderNames() + + // Load custom models for each static provider + await Promise.all( + staticProviders.map(async (provider) => { + try { + const models = await getModelsForStaticProvider(provider, cwd) + mergedModelsCache.set(provider, models) + } catch (error) { + console.error(`[ModelLookup] Error preloading custom models for ${provider}:`, error) + } + }), + ) + + isPreloaded = true + console.log(`[ModelLookup] Preloaded custom models for ${staticProviders.length} static providers`) + } catch (error) { + console.error("[ModelLookup] Error during preload:", error) + } +} + +/** + * Get models synchronously for a provider (uses pre-loaded cache) + * Falls back to static models if not pre-loaded or provider not found + * @param provider The provider name + * @param staticModels The base static models dictionary + * @returns Merged models (static + custom) + */ +export function getProviderModelsSync( + provider: ProviderName, + staticModels: Record, +): Record { + if (!isPreloaded || !mergedModelsCache.has(provider)) { + // Not preloaded yet or not in cache, return static models only + return staticModels + } + + return mergedModelsCache.get(provider)! +} + +/** + * Clear the cache for a specific provider or all providers + * @param provider Optional provider to clear cache for. If not provided, clears all. + */ +export function clearProviderModelsCache(provider?: ProviderName): void { + if (provider) { + mergedModelsCache.delete(provider) + } else { + mergedModelsCache.clear() + isPreloaded = false + } +} + +/** + * Check if a model ID exists in the provider's models (including custom models) + * @param provider The provider name + * @param modelId The model ID to check + * @param staticModels The base static models dictionary + * @returns Promise of boolean indicating if model exists + */ +export async function hasModel( + provider: ProviderName, + modelId: string, + staticModels: Record, +): Promise { + const models = await getProviderModels(provider, staticModels) + return modelId in models +} + +export { isPreloaded } + +/** + * Get model info for a specific model ID + * @param provider The provider name + * @param modelId The model ID + * @param staticModels The base static models dictionary + * @returns Promise of ModelInfo or undefined if not found + */ +export async function getModelInfo( + provider: ProviderName, + modelId: string, + staticModels: Record, +): Promise { + const models = await getProviderModels(provider, staticModels) + return models[modelId] +} diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index daf6278822..b69c7b610e 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -20,6 +20,7 @@ import { calculateApiCostOpenAI } from "../../shared/cost" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" +import { getProviderModelsSync } from "./model-lookup" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" @@ -1220,11 +1221,11 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio override getModel() { const modelId = this.options.apiModelId + const models = getProviderModelsSync("openai-native", openAiNativeModels as Record) - let id = - modelId && modelId in openAiNativeModels ? (modelId as OpenAiNativeModelId) : openAiNativeDefaultModelId + let id = modelId && modelId in models ? (modelId as OpenAiNativeModelId) : openAiNativeDefaultModelId - const info: ModelInfo = openAiNativeModels[id] + const info: ModelInfo = models[id] const params = getModelParams({ format: "openai", diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts index 25e9a11e1b..f144c4d6c5 100644 --- a/src/api/providers/router-provider.ts +++ b/src/api/providers/router-provider.ts @@ -1,8 +1,8 @@ import OpenAI from "openai" -import type { ModelInfo } from "@roo-code/types" +import type { ModelInfo, DynamicProvider, LocalProvider } from "@roo-code/types" -import { ApiHandlerOptions, RouterName, ModelRecord } from "../../shared/api" +import { ApiHandlerOptions, ModelRecord } from "../../shared/api" import { BaseProvider } from "./base-provider" import { getModels } from "./fetchers/modelCache" @@ -10,7 +10,7 @@ import { getModels } from "./fetchers/modelCache" import { DEFAULT_HEADERS } from "./constants" type RouterProviderOptions = { - name: RouterName + name: DynamicProvider | LocalProvider baseURL: string apiKey?: string modelId?: string @@ -21,7 +21,7 @@ type RouterProviderOptions = { export abstract class RouterProvider extends BaseProvider { protected readonly options: ApiHandlerOptions - protected readonly name: RouterName + protected readonly name: DynamicProvider | LocalProvider protected models: ModelRecord = {} protected readonly modelId?: string protected readonly defaultModelId: string diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index 2c077d97b7..f0a2a27c8d 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -3,6 +3,7 @@ import { type ModelInfo, type VertexModelId, vertexDefaultModelId, vertexModels import type { ApiHandlerOptions } from "../../shared/api" import { getModelParams } from "../transform/model-params" +import { getProviderModelsSync } from "./model-lookup" import { GeminiHandler } from "./gemini" import { SingleCompletionHandler } from "../index" @@ -14,8 +15,9 @@ export class VertexHandler extends GeminiHandler implements SingleCompletionHand override getModel() { const modelId = this.options.apiModelId - let id = modelId && modelId in vertexModels ? (modelId as VertexModelId) : vertexDefaultModelId - const info: ModelInfo = vertexModels[id] + const models = getProviderModelsSync("vertex", vertexModels as Record) + let id = modelId && modelId in models ? (modelId as VertexModelId) : vertexDefaultModelId + const info: ModelInfo = models[id] const params = getModelParams({ format: "gemini", modelId: id, model: info, settings: this.options }) // The `:thinking` suffix indicates that the model is a "Hybrid" diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index a8ab39108d..9e06caeb51 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2703,26 +2703,49 @@ describe("ClineProvider - Router Models", () => { }) expect(getModels).toHaveBeenCalledWith({ provider: "chutes" }) - // Verify response was sent - expect(mockPostMessage).toHaveBeenCalledWith({ - type: "routerModels", - routerModels: { - deepinfra: mockModels, - openrouter: mockModels, - requesty: mockModels, - glama: mockModels, - unbound: mockModels, - roo: mockModels, - chutes: mockModels, - litellm: mockModels, - ollama: {}, - lmstudio: {}, - "vercel-ai-gateway": mockModels, - huggingface: {}, - "io-intelligence": {}, - }, - values: undefined, - }) + // Verify response was sent with static providers containing their actual model dictionaries + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "routerModels", + routerModels: expect.objectContaining({ + deepinfra: mockModels, + openrouter: mockModels, + requesty: mockModels, + glama: mockModels, + unbound: mockModels, + roo: mockModels, + chutes: mockModels, + litellm: mockModels, + ollama: {}, + lmstudio: {}, + "vercel-ai-gateway": mockModels, + huggingface: {}, + "io-intelligence": {}, + // Static providers will have their actual model dictionaries + anthropic: expect.any(Object), + bedrock: expect.any(Object), + cerebras: expect.any(Object), + "claude-code": expect.any(Object), + deepseek: expect.any(Object), + doubao: expect.any(Object), + featherless: expect.any(Object), + fireworks: expect.any(Object), + gemini: expect.any(Object), + groq: expect.any(Object), + minimax: expect.any(Object), + mistral: expect.any(Object), + moonshot: expect.any(Object), + "openai-native": expect.any(Object), + "qwen-code": expect.any(Object), + sambanova: expect.any(Object), + vertex: expect.any(Object), + "vscode-lm": expect.any(Object), + xai: expect.any(Object), + zai: expect.any(Object), + }), + values: undefined, + }), + ) }) test("handles requestRouterModels with individual provider failures", async () => { @@ -2760,25 +2783,48 @@ describe("ClineProvider - Router Models", () => { await messageHandler({ type: "requestRouterModels" }) // Verify main response includes successful providers and empty objects for failed ones - expect(mockPostMessage).toHaveBeenCalledWith({ - type: "routerModels", - routerModels: { - deepinfra: mockModels, - openrouter: mockModels, - requesty: {}, - glama: mockModels, - unbound: {}, - roo: mockModels, - chutes: {}, - ollama: {}, - lmstudio: {}, - litellm: {}, - "vercel-ai-gateway": mockModels, - huggingface: {}, - "io-intelligence": {}, - }, - values: undefined, - }) + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "routerModels", + routerModels: expect.objectContaining({ + deepinfra: mockModels, + openrouter: mockModels, + requesty: {}, + glama: mockModels, + unbound: {}, + roo: mockModels, + chutes: {}, + ollama: {}, + lmstudio: {}, + litellm: {}, + "vercel-ai-gateway": mockModels, + huggingface: {}, + "io-intelligence": {}, + // Static providers will have their actual model dictionaries + anthropic: expect.any(Object), + bedrock: expect.any(Object), + cerebras: expect.any(Object), + "claude-code": expect.any(Object), + deepseek: expect.any(Object), + doubao: expect.any(Object), + featherless: expect.any(Object), + fireworks: expect.any(Object), + gemini: expect.any(Object), + groq: expect.any(Object), + minimax: expect.any(Object), + mistral: expect.any(Object), + moonshot: expect.any(Object), + "openai-native": expect.any(Object), + "qwen-code": expect.any(Object), + sambanova: expect.any(Object), + vertex: expect.any(Object), + "vscode-lm": expect.any(Object), + xai: expect.any(Object), + zai: expect.any(Object), + }), + values: undefined, + }), + ) // Verify error messages were sent for failed providers expect(mockPostMessage).toHaveBeenCalledWith({ @@ -2884,25 +2930,48 @@ describe("ClineProvider - Router Models", () => { ) // Verify response includes empty object for LiteLLM - expect(mockPostMessage).toHaveBeenCalledWith({ - type: "routerModels", - routerModels: { - deepinfra: mockModels, - openrouter: mockModels, - requesty: mockModels, - glama: mockModels, - unbound: mockModels, - roo: mockModels, - chutes: mockModels, - litellm: {}, - ollama: {}, - lmstudio: {}, - "vercel-ai-gateway": mockModels, - huggingface: {}, - "io-intelligence": {}, - }, - values: undefined, - }) + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "routerModels", + routerModels: expect.objectContaining({ + deepinfra: mockModels, + openrouter: mockModels, + requesty: mockModels, + glama: mockModels, + unbound: mockModels, + roo: mockModels, + chutes: mockModels, + litellm: {}, + ollama: {}, + lmstudio: {}, + "vercel-ai-gateway": mockModels, + huggingface: {}, + "io-intelligence": {}, + // Static providers will have their actual model dictionaries + anthropic: expect.any(Object), + bedrock: expect.any(Object), + cerebras: expect.any(Object), + "claude-code": expect.any(Object), + deepseek: expect.any(Object), + doubao: expect.any(Object), + featherless: expect.any(Object), + fireworks: expect.any(Object), + gemini: expect.any(Object), + groq: expect.any(Object), + minimax: expect.any(Object), + mistral: expect.any(Object), + moonshot: expect.any(Object), + "openai-native": expect.any(Object), + "qwen-code": expect.any(Object), + sambanova: expect.any(Object), + vertex: expect.any(Object), + "vscode-lm": expect.any(Object), + xai: expect.any(Object), + zai: expect.any(Object), + }), + values: undefined, + }), + ) }) test("handles requestLmStudioModels with proper response", async () => { diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 746a013f0f..446dc359e7 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -56,6 +56,8 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { // Only methods used by this code path postMessageToWebview: vi.fn(), getState: vi.fn().mockResolvedValue({ apiConfiguration: {} }), + getCurrentTask: vi.fn().mockReturnValue({ cwd: "/mock/workspace" }), + cwd: "/mock/workspace", contextProxy: { getValue: vi.fn(), setValue: vi.fn(), @@ -112,14 +114,18 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { const payload = call[0] const routerModels = payload.routerModels as Record> - // Only "roo" key should be present - const keys = Object.keys(routerModels) - expect(keys).toEqual(["roo"]) + // Verify "roo" is present with its models expect(Object.keys(routerModels.roo || {})).toContain("roo/sonnet") - // getModels should have been called exactly once for roo + // getModels should have been called exactly once for roo (dynamic provider) const providersCalled = getModelsMock.mock.calls.map((c: any[]) => c[0]?.provider) expect(providersCalled).toEqual(["roo"]) + + // Static providers should also be present (they're always loaded) + const keys = Object.keys(routerModels) + expect(keys).toContain("roo") // The requested provider + expect(keys).toContain("anthropic") // Static providers are always included + expect(keys.length).toBeGreaterThan(1) // More than just the requested provider }) it("defaults to aggregate fetching when no provider filter is sent", async () => { @@ -156,12 +162,18 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { ) expect(call).toBeTruthy() const routerModels = call[0].routerModels as Record> - const keys = Object.keys(routerModels) - expect(keys).toEqual(["openrouter"]) + // Verify "openrouter" is present with its models expect(Object.keys(routerModels.openrouter || {})).toContain("openrouter/qwen2.5") + // getModels should have been called exactly once for openrouter (dynamic provider) const providersCalled = getModelsMock.mock.calls.map((c: any[]) => c[0]?.provider) expect(providersCalled).toEqual(["openrouter"]) + + // Static providers should also be present (they're always loaded) + const keys = Object.keys(routerModels) + expect(keys).toContain("openrouter") // The requested provider + expect(keys).toContain("anthropic") // Static providers are always included + expect(keys.length).toBeGreaterThan(1) // More than just the requested provider }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 3fd2a47f37..82e97ea1f5 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -238,26 +238,49 @@ describe("webviewMessageHandler - requestRouterModels", () => { // Note: huggingface is not fetched in requestRouterModels - it has its own handler // Note: io-intelligence is not fetched because no API key is provided in the mock state - // Verify response was sent - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "routerModels", - routerModels: { - deepinfra: mockModels, - openrouter: mockModels, - requesty: mockModels, - glama: mockModels, - unbound: mockModels, - litellm: mockModels, - roo: mockModels, - chutes: mockModels, - ollama: {}, - lmstudio: {}, - "vercel-ai-gateway": mockModels, - huggingface: {}, - "io-intelligence": {}, - }, - values: undefined, - }) + // Verify response was sent with static providers containing their actual model dictionaries + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "routerModels", + routerModels: expect.objectContaining({ + deepinfra: mockModels, + openrouter: mockModels, + requesty: mockModels, + glama: mockModels, + unbound: mockModels, + litellm: mockModels, + roo: mockModels, + chutes: mockModels, + ollama: {}, + lmstudio: {}, + "vercel-ai-gateway": mockModels, + huggingface: {}, + "io-intelligence": {}, + // Static providers will have their actual model dictionaries + anthropic: expect.any(Object), + bedrock: expect.any(Object), + cerebras: expect.any(Object), + "claude-code": expect.any(Object), + deepseek: expect.any(Object), + doubao: expect.any(Object), + featherless: expect.any(Object), + fireworks: expect.any(Object), + gemini: expect.any(Object), + groq: expect.any(Object), + minimax: expect.any(Object), + mistral: expect.any(Object), + moonshot: expect.any(Object), + "openai-native": expect.any(Object), + "qwen-code": expect.any(Object), + sambanova: expect.any(Object), + vertex: expect.any(Object), + "vscode-lm": expect.any(Object), + xai: expect.any(Object), + zai: expect.any(Object), + }), + values: undefined, + }), + ) }) it("handles LiteLLM models with values from message when config is missing", async () => { @@ -333,25 +356,48 @@ describe("webviewMessageHandler - requestRouterModels", () => { ) // Verify response includes empty object for LiteLLM - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "routerModels", - routerModels: { - deepinfra: mockModels, - openrouter: mockModels, - requesty: mockModels, - glama: mockModels, - unbound: mockModels, - roo: mockModels, - chutes: mockModels, - litellm: {}, - ollama: {}, - lmstudio: {}, - "vercel-ai-gateway": mockModels, - huggingface: {}, - "io-intelligence": {}, - }, - values: undefined, - }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: "routerModels", + routerModels: expect.objectContaining({ + deepinfra: mockModels, + openrouter: mockModels, + requesty: mockModels, + glama: mockModels, + unbound: mockModels, + roo: mockModels, + chutes: mockModels, + litellm: {}, + ollama: {}, + lmstudio: {}, + "vercel-ai-gateway": mockModels, + huggingface: {}, + "io-intelligence": {}, + // Static providers will have their actual model dictionaries + anthropic: expect.any(Object), + bedrock: expect.any(Object), + cerebras: expect.any(Object), + "claude-code": expect.any(Object), + deepseek: expect.any(Object), + doubao: expect.any(Object), + featherless: expect.any(Object), + fireworks: expect.any(Object), + gemini: expect.any(Object), + groq: expect.any(Object), + minimax: expect.any(Object), + mistral: expect.any(Object), + moonshot: expect.any(Object), + "openai-native": expect.any(Object), + "qwen-code": expect.any(Object), + sambanova: expect.any(Object), + vertex: expect.any(Object), + "vscode-lm": expect.any(Object), + xai: expect.any(Object), + zai: expect.any(Object), + }), + values: undefined, + }), + ) }) it("handles individual provider failures gracefully", async () => { @@ -426,6 +472,27 @@ describe("webviewMessageHandler - requestRouterModels", () => { "vercel-ai-gateway": mockModels, huggingface: {}, "io-intelligence": {}, + // Static providers + anthropic: {}, + bedrock: {}, + cerebras: {}, + "claude-code": {}, + deepseek: {}, + doubao: {}, + featherless: {}, + fireworks: {}, + gemini: {}, + groq: {}, + minimax: {}, + mistral: {}, + moonshot: {}, + "openai-native": {}, + "qwen-code": {}, + sambanova: {}, + vertex: {}, + "vscode-lm": {}, + xai: {}, + zai: {}, }, values: undefined, }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 847ea1a161..50cb08f964 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -24,8 +24,9 @@ import { ClineProvider } from "./ClineProvider" import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler" import { changeLanguage, t } from "../../i18n" import { Package } from "../../shared/package" -import { type RouterName, type ModelRecord, toRouterName } from "../../shared/api" +import { type RouterName, type ModelRecord, toRouterName, staticProvidersWithCustomModels } from "../../shared/api" import { MessageEnhancer } from "./messageEnhancer" +import { getModelsForStaticProvider } from "../../services/custom-models/static-providers" import { type WebviewMessage, @@ -764,6 +765,7 @@ export const webviewMessageHandler = async ( const routerModels: Record = providerFilter ? ({} as Record) : { + // Dynamic providers openrouter: {}, "vercel-ai-gateway": {}, huggingface: {}, @@ -773,10 +775,32 @@ export const webviewMessageHandler = async ( requesty: {}, unbound: {}, glama: {}, - ollama: {}, - lmstudio: {}, roo: {}, chutes: {}, + // Local providers + ollama: {}, + lmstudio: {}, + // Static providers (for custom models support) + anthropic: {}, + bedrock: {}, + vertex: {}, + gemini: {}, + "openai-native": {}, + mistral: {}, + deepseek: {}, + doubao: {}, + moonshot: {}, + minimax: {}, + xai: {}, + groq: {}, + cerebras: {}, + sambanova: {}, + fireworks: {}, + featherless: {}, + "qwen-code": {}, + "claude-code": {}, + zai: {}, + "vscode-lm": {}, } const safeGetModels = async (options: GetModelsOptions): Promise => { @@ -884,6 +908,25 @@ export const webviewMessageHandler = async ( } }) + // Fetch static provider models (includes custom models from .roo/models/) + const cwd = getCurrentCwd() + const staticProviderPromises = staticProvidersWithCustomModels.map(async (staticProvider) => { + try { + const models = await getModelsForStaticProvider(staticProvider, cwd) + return { provider: staticProvider, models } + } catch (error) { + console.error(`Error fetching static provider models for ${staticProvider}:`, error) + return { provider: staticProvider, models: {} as ModelRecord } + } + }) + + const staticResults = await Promise.allSettled(staticProviderPromises) + staticResults.forEach((result) => { + if (result.status === "fulfilled") { + routerModels[result.value.provider] = result.value.models + } + }) + provider.postMessageToWebview({ type: "routerModels", routerModels, diff --git a/src/extension.ts b/src/extension.ts index bf0ceec02c..fb49cf6e30 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -30,6 +30,9 @@ import { CodeIndexManager } from "./services/code-index/manager" import { MdmService } from "./services/mdm/MdmService" import { migrateSettings } from "./utils/migrateSettings" import { autoImportSettings } from "./utils/autoImportSettings" +import { setupCustomModelsWatcher } from "./services/custom-models/watcher" +import { preloadStaticProviderModels } from "./api/providers/model-lookup" +import { getWorkspacePath } from "./utils/path" import { API } from "./extension/api" import { @@ -254,6 +257,27 @@ export async function activate(context: vscode.ExtensionContext) { ) } + // Preload custom models for static providers + try { + await preloadStaticProviderModels(getWorkspacePath()) + outputChannel.appendLine("[CustomModels] Static provider models preloaded") + } catch (error) { + outputChannel.appendLine( + `[CustomModels] Failed to preload static provider models: ${error instanceof Error ? error.message : String(error)}`, + ) + } + + // Setup custom models file watcher for hot-reloading + try { + const customModelsWatcher = setupCustomModelsWatcher(getWorkspacePath()) + context.subscriptions.push(customModelsWatcher) + outputChannel.appendLine("[CustomModels] File watcher initialized") + } catch (error) { + outputChannel.appendLine( + `[CustomModels] Failed to setup file watcher: ${error instanceof Error ? error.message : String(error)}`, + ) + } + registerCommands({ context, outputChannel, provider }) /** diff --git a/src/services/custom-models/__tests__/index.spec.ts b/src/services/custom-models/__tests__/index.spec.ts new file mode 100644 index 0000000000..b342376b10 --- /dev/null +++ b/src/services/custom-models/__tests__/index.spec.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import * as path from "path" + +// Use vi.hoisted to ensure mocks are available during hoisting +const { mockReadFile, mockFileExists, mockGetGlobalRooDirectory, mockGetProjectRooDirectoryForCwd } = vi.hoisted( + () => ({ + mockReadFile: vi.fn(), + mockFileExists: vi.fn(), + mockGetGlobalRooDirectory: vi.fn(), + mockGetProjectRooDirectoryForCwd: vi.fn(), + }), +) + +// Mock fs/promises module +vi.mock("fs/promises", () => ({ + default: { + readFile: mockReadFile, + }, + readFile: mockReadFile, +})) + +// Mock the roo-config module +vi.mock("../../roo-config", () => ({ + getGlobalRooDirectory: mockGetGlobalRooDirectory, + getProjectRooDirectoryForCwd: mockGetProjectRooDirectoryForCwd, + fileExists: mockFileExists, +})) + +// Import after mocks +import { getCustomModelsForProvider } from "../index" + +describe("getCustomModelsForProvider", () => { + const mockCwd = "/test/workspace" + const mockGlobalDir = "/home/user/.roo" + const mockProjectDir = "/test/workspace/.roo" + + beforeEach(() => { + vi.clearAllMocks() + mockGetGlobalRooDirectory.mockReturnValue(mockGlobalDir) + mockGetProjectRooDirectoryForCwd.mockReturnValue(mockProjectDir) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should return empty object when no files exist", async () => { + mockFileExists.mockResolvedValue(false) + + const result = await getCustomModelsForProvider("openrouter", mockCwd) + + expect(result).toEqual({}) + expect(mockFileExists).toHaveBeenCalledTimes(2) + }) + + it("should load models from global file only", async () => { + const globalModels = { + "custom/model-1": { + contextWindow: 32000, + maxTokens: 4096, + supportsPromptCache: false, + description: "Global model", + }, + } + + mockFileExists.mockImplementation(async (filePath: string) => { + return filePath === path.join(mockGlobalDir, "models", "openrouter.json") + }) + + mockReadFile.mockImplementation(async (filePath: any) => { + if (filePath === path.join(mockGlobalDir, "models", "openrouter.json")) { + return JSON.stringify(globalModels) + } + throw new Error("File not found") + }) + + const result = await getCustomModelsForProvider("openrouter", mockCwd) + + expect(result).toEqual(globalModels) + }) + + it("should load models from project file only", async () => { + const projectModels = { + "custom/project-model": { + contextWindow: 64000, + maxTokens: 8192, + supportsPromptCache: false, + description: "Project model", + }, + } + + mockFileExists.mockImplementation(async (filePath: string) => { + return filePath === path.join(mockProjectDir, "models", "openrouter.json") + }) + + mockReadFile.mockImplementation(async (filePath: any) => { + if (filePath === path.join(mockProjectDir, "models", "openrouter.json")) { + return JSON.stringify(projectModels) + } + throw new Error("File not found") + }) + + const result = await getCustomModelsForProvider("openrouter", mockCwd) + + expect(result).toEqual(projectModels) + }) + + it("should merge global and project models with project overriding global", async () => { + const globalModels = { + "custom/model-1": { + contextWindow: 32000, + maxTokens: 4096, + supportsPromptCache: false, + description: "Global model 1", + }, + "custom/model-2": { + contextWindow: 64000, + maxTokens: 8192, + supportsPromptCache: false, + description: "Global model 2", + }, + } + + const projectModels = { + "custom/model-1": { + contextWindow: 128000, + maxTokens: 16384, + supportsPromptCache: true, + description: "Project override for model 1", + }, + "custom/model-3": { + contextWindow: 16000, + maxTokens: 2048, + supportsPromptCache: false, + description: "Project-only model", + }, + } + + mockFileExists.mockResolvedValue(true) + + mockReadFile.mockImplementation(async (filePath: any) => { + if (filePath === path.join(mockGlobalDir, "models", "openrouter.json")) { + return JSON.stringify(globalModels) + } + if (filePath === path.join(mockProjectDir, "models", "openrouter.json")) { + return JSON.stringify(projectModels) + } + throw new Error("File not found") + }) + + const result = await getCustomModelsForProvider("openrouter", mockCwd) + + expect(result).toEqual({ + "custom/model-1": projectModels["custom/model-1"], // Project overrides + "custom/model-2": globalModels["custom/model-2"], // From global + "custom/model-3": projectModels["custom/model-3"], // From project + }) + }) + + it("should handle invalid JSON gracefully", async () => { + mockFileExists.mockResolvedValue(true) + mockReadFile.mockResolvedValue("invalid json {") + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + const result = await getCustomModelsForProvider("openrouter", mockCwd) + + expect(result).toEqual({}) + expect(consoleSpy).toHaveBeenCalled() + + consoleSpy.mockRestore() + }) + + it("should handle invalid schema gracefully", async () => { + const invalidModels = { + "custom/model-1": { + // Missing required contextWindow + maxTokens: 4096, + }, + } + + mockFileExists.mockResolvedValue(true) + mockReadFile.mockResolvedValue(JSON.stringify(invalidModels)) + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + const result = await getCustomModelsForProvider("openrouter", mockCwd) + + expect(result).toEqual({}) + expect(consoleSpy).toHaveBeenCalled() + + consoleSpy.mockRestore() + }) + + it("should work with different providers", async () => { + const glamaModels = { + "custom-glama": { + contextWindow: 200000, + maxTokens: 8192, + supportsPromptCache: true, + description: "Custom Glama", + }, + } + + mockFileExists.mockImplementation(async (filePath: string) => { + return filePath === path.join(mockGlobalDir, "models", "glama.json") + }) + + mockReadFile.mockImplementation(async (filePath: any) => { + if (filePath === path.join(mockGlobalDir, "models", "glama.json")) { + return JSON.stringify(glamaModels) + } + throw new Error("File not found") + }) + + const result = await getCustomModelsForProvider("glama", mockCwd) + + expect(result).toEqual(glamaModels) + }) + + it("should validate all ModelInfo fields", async () => { + const completeModel = { + "complete/model": { + contextWindow: 32000, + maxTokens: 4096, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 0.001, + outputPrice: 0.002, + cacheWritesPrice: 0.0005, + cacheReadsPrice: 0.0001, + description: "Complete model with all fields", + supportsReasoningEffort: true, + supportsReasoningBudget: true, + requiredReasoningBudget: false, + reasoningEffort: "high", + }, + } + + mockFileExists.mockResolvedValue(true) + mockReadFile.mockResolvedValue(JSON.stringify(completeModel)) + + const result = await getCustomModelsForProvider("openrouter", mockCwd) + + expect(result).toEqual(completeModel) + }) + + it("should handle file read errors gracefully", async () => { + mockFileExists.mockResolvedValue(true) + mockReadFile.mockRejectedValue(new Error("Permission denied")) + + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + const result = await getCustomModelsForProvider("openrouter", mockCwd) + + expect(result).toEqual({}) + expect(consoleSpy).toHaveBeenCalled() + + consoleSpy.mockRestore() + }) +}) diff --git a/src/services/custom-models/index.ts b/src/services/custom-models/index.ts new file mode 100644 index 0000000000..b17cc92455 --- /dev/null +++ b/src/services/custom-models/index.ts @@ -0,0 +1,58 @@ +import * as path from "path" +import * as fs from "fs/promises" + +import { customModelsFileSchema } from "@roo-code/types" + +import { getGlobalRooDirectory, getProjectRooDirectoryForCwd, fileExists } from "../roo-config" +import type { RouterName, ModelRecord } from "../../shared/api" + +const MODELS_DIR = "models" + +/** + * Load custom models for a specific provider from a single JSON file + * @param filePath Path to the JSON file + * @returns ModelRecord or empty object if file doesn't exist/is invalid + */ +async function loadModelsFromFile(filePath: string): Promise { + try { + if (!(await fileExists(filePath))) { + return {} + } + + const content = await fs.readFile(filePath, "utf-8") + const parsed = JSON.parse(content) + + const result = customModelsFileSchema.safeParse(parsed) + + if (!result.success) { + console.error(`[CustomModels] Invalid schema in ${filePath}:`, result.error) + return {} + } + + return result.data as ModelRecord + } catch (error) { + console.error(`[CustomModels] Error loading ${filePath}:`, error) + return {} + } +} + +/** + * Get custom models for a provider by merging global and project files + * @param provider The provider slug (e.g., "openrouter") + * @param cwd Current working directory for project path + * @returns Merged ModelRecord with project overriding global + */ +export async function getCustomModelsForProvider(provider: RouterName, cwd: string): Promise { + const filename = `${provider}.json` + + const globalPath = path.join(getGlobalRooDirectory(), MODELS_DIR, filename) + const projectPath = path.join(getProjectRooDirectoryForCwd(cwd), MODELS_DIR, filename) + + const globalModels = await loadModelsFromFile(globalPath) + const projectModels = await loadModelsFromFile(projectPath) + + // Merge: project overrides global + return { ...globalModels, ...projectModels } +} + +export * from "./static-providers" diff --git a/src/services/custom-models/static-providers.ts b/src/services/custom-models/static-providers.ts new file mode 100644 index 0000000000..fe93d5e200 --- /dev/null +++ b/src/services/custom-models/static-providers.ts @@ -0,0 +1,136 @@ +import { + anthropicModels, + bedrockModels, + cerebrasModels, + claudeCodeModels, + deepSeekModels, + doubaoModels, + featherlessModels, + fireworksModels, + geminiModels, + groqModels, + ioIntelligenceModels, + mistralModels, + moonshotModels, + openAiNativeModels, + qwenCodeModels, + sambaNovaModels, + vertexModels, + vscodeLlmModels, + xaiModels, + internationalZAiModels, + minimaxModels, + type ProviderName, + type ModelInfo, +} from "@roo-code/types" + +import { getCustomModelsForProvider } from "./index" + +// Single source of truth for static provider names +// This const tuple provides proper type narrowing for StaticProviderWithCustomModels +export const staticProviderNames = [ + "anthropic", + "bedrock", + "cerebras", + "claude-code", + "deepseek", + "doubao", + "featherless", + "fireworks", + "gemini", + "groq", + "io-intelligence", + "mistral", + "moonshot", + "minimax", + "openai-native", + "qwen-code", + "sambanova", + "vertex", + "vscode-lm", + "xai", + "zai", +] as const + +// Map of provider names to their static model dictionaries +const STATIC_MODEL_DICTIONARIES: Record<(typeof staticProviderNames)[number], Record> = { + anthropic: anthropicModels as Record, + bedrock: bedrockModels as Record, + cerebras: cerebrasModels as Record, + "claude-code": claudeCodeModels as Record, + deepseek: deepSeekModels as Record, + doubao: doubaoModels as Record, + featherless: featherlessModels as Record, + fireworks: fireworksModels as Record, + gemini: geminiModels as Record, + groq: groqModels as Record, + "io-intelligence": ioIntelligenceModels as Record, + mistral: mistralModels as Record, + moonshot: moonshotModels as Record, + minimax: minimaxModels as Record, + "openai-native": openAiNativeModels as Record, + "qwen-code": qwenCodeModels as Record, + sambanova: sambaNovaModels as Record, + vertex: vertexModels as Record, + "vscode-lm": vscodeLlmModels as Record, + xai: xaiModels as Record, + zai: internationalZAiModels as Record, +} + +/** + * Get models for a static provider, merging built-in models with custom models + * @param provider The provider name + * @param cwd Current working directory for project path + * @returns Merged record of models + */ +export async function getModelsForStaticProvider( + provider: ProviderName, + cwd: string, +): Promise> { + const staticModels = + (STATIC_MODEL_DICTIONARIES as Partial>>)[provider] || {} + const customModels = await getCustomModelsForProvider(provider as any, cwd) + + // Merge: custom models override static models + return { ...staticModels, ...customModels } +} + +/** + * Check if a provider is a static provider (has hard-coded models) + * @param provider The provider name + * @returns True if the provider has static models + */ +export function isStaticProvider(provider: ProviderName): boolean { + return provider in STATIC_MODEL_DICTIONARIES +} + +/** + * Get all static provider names (providers with hard-coded models) + * @returns Array of static provider names + */ +export function getStaticProviderNames(): readonly ProviderName[] { + return staticProviderNames +} + +/** + * Get all provider names that support custom models (both static and dynamic) + * @returns Array of provider names + */ +export function getSupportedProviders(): ProviderName[] { + return [ + ...staticProviderNames, + // Dynamic providers from modelCache.ts + "openrouter", + "requesty", + "glama", + "unbound", + "litellm", + "ollama", + "lmstudio", + "deepinfra", + "vercel-ai-gateway", + "huggingface", + "roo", + "chutes", + ] as ProviderName[] +} diff --git a/src/services/custom-models/watcher.ts b/src/services/custom-models/watcher.ts new file mode 100644 index 0000000000..059a92104f --- /dev/null +++ b/src/services/custom-models/watcher.ts @@ -0,0 +1,60 @@ +import * as vscode from "vscode" +import * as path from "path" + +import { getGlobalRooDirectory, getProjectRooDirectoryForCwd } from "../roo-config" +import { flushModels } from "../../api/providers/fetchers/modelCache" +import { clearProviderModelsCache, preloadStaticProviderModels } from "../../api/providers/model-lookup" +import { getWorkspacePath } from "../../utils/path" +import type { RouterName } from "../../shared/api" +import type { ProviderName } from "@roo-code/types" + +const MODELS_DIR = "models" + +/** + * Setup file watchers for custom model JSON files + * Watches both global and project-local .roo/models/ directories + * @param cwd Current working directory for project path + * @returns Disposable to clean up watchers + */ +export function setupCustomModelsWatcher(cwd: string): vscode.Disposable { + const globalPath = path.join(getGlobalRooDirectory(), MODELS_DIR) + const projectPath = path.join(getProjectRooDirectoryForCwd(cwd), MODELS_DIR) + + const globalWatcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(globalPath, "*.json")) + + const projectWatcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(projectPath, "*.json")) + + const handleChange = async (uri: vscode.Uri) => { + // Extract provider from filename (e.g., "openrouter.json" → "openrouter") + const filename = path.basename(uri.fsPath, ".json") as ProviderName + console.log(`[CustomModels] Detected change in custom models for provider: ${filename}`) + + // Clear cache for dynamic providers + try { + await flushModels(filename as RouterName) + } catch (error) { + // Not a dynamic provider, that's fine + } + + // Clear cache for static providers and re-preload + clearProviderModelsCache(filename) + + // Re-preload all static provider models to ensure cache is updated + try { + await preloadStaticProviderModels(getWorkspacePath()) + } catch (error) { + console.error(`[CustomModels] Error reloading static provider models:`, error) + } + } + + return vscode.Disposable.from( + globalWatcher.onDidChange(handleChange), + globalWatcher.onDidCreate(handleChange), + globalWatcher.onDidDelete(handleChange), + projectWatcher.onDidChange(handleChange), + projectWatcher.onDidCreate(handleChange), + projectWatcher.onDidDelete(handleChange), + globalWatcher, + projectWatcher, + ) +} diff --git a/src/shared/api.ts b/src/shared/api.ts index 802654adaa..6ef8194831 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -3,12 +3,15 @@ import { type ProviderSettings, type DynamicProvider, type LocalProvider, + type ProviderName, ANTHROPIC_DEFAULT_MAX_TOKENS, CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS, isDynamicProvider, isLocalProvider, } from "@roo-code/types" +import { staticProviderNames } from "../services/custom-models/static-providers" + // ApiHandlerOptions // Extend ProviderSettings (minus apiProvider) with handler-specific toggles. export type ApiHandlerOptions = Omit & { @@ -26,11 +29,22 @@ export type ApiHandlerOptions = Omit & { ollamaNumCtx?: number } -// RouterName +// StaticProvider - providers with hard-coded model definitions that support custom models +// Imported from the single source of truth in static-providers.ts -export type RouterName = DynamicProvider | LocalProvider +export const staticProvidersWithCustomModels = staticProviderNames -export const isRouterName = (value: string): value is RouterName => isDynamicProvider(value) || isLocalProvider(value) +export type StaticProviderWithCustomModels = (typeof staticProvidersWithCustomModels)[number] + +export const isStaticProviderWithCustomModels = (key: string): key is StaticProviderWithCustomModels => + (staticProvidersWithCustomModels as readonly string[]).includes(key) + +// RouterName - includes dynamic, local, and static providers that support custom models + +export type RouterName = DynamicProvider | LocalProvider | StaticProviderWithCustomModels + +export const isRouterName = (value: string): value is RouterName => + isDynamicProvider(value) || isLocalProvider(value) || isStaticProviderWithCustomModels(value) export function toRouterName(value?: string): RouterName { if (value && isRouterName(value)) { @@ -148,9 +162,8 @@ type CommonFetchParams = { baseUrl?: string } -// Exhaustive, value-level map for all dynamic providers. -// If a new dynamic provider is added in packages/types, this will fail to compile -// until a corresponding entry is added here. +// Exhaustive, value-level map for dynamic and local providers that can be fetched. +// Static providers don't need entries here as they're loaded differently. const dynamicProviderExtras = { openrouter: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type "vercel-ai-gateway": {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type @@ -165,10 +178,10 @@ const dynamicProviderExtras = { lmstudio: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type roo: {} as { apiKey?: string; baseUrl?: string }, chutes: {} as { apiKey?: string }, -} as const satisfies Record +} as const satisfies Record // Build the dynamic options union from the map, intersected with CommonFetchParams // so extra fields are always allowed while required ones are enforced. export type GetModelsOptions = { [P in keyof typeof dynamicProviderExtras]: ({ provider: P } & (typeof dynamicProviderExtras)[P]) & CommonFetchParams -}[RouterName] +}[DynamicProvider | LocalProvider] diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 28eec04180..191a7f7f2d 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -270,7 +270,8 @@ const ApiOptions = ({ }, [apiConfiguration, routerModels, organizationAllowList, setErrorMessage]) const selectedProviderModels = useMemo(() => { - const models = MODELS_BY_PROVIDER[selectedProvider] + // Prefer routerModels (includes custom models) over static MODELS_BY_PROVIDER + const models = (routerModels as any)?.[selectedProvider] ?? MODELS_BY_PROVIDER[selectedProvider] if (!models) return [] const filteredModels = filterModels(models, selectedProvider, organizationAllowList) @@ -292,7 +293,7 @@ const ApiOptions = ({ : [] return availableModels - }, [selectedProvider, organizationAllowList, selectedModelId]) + }, [selectedProvider, organizationAllowList, selectedModelId, routerModels]) const onProviderChange = useCallback( (value: ProviderName) => { @@ -422,17 +423,17 @@ const ApiOptions = ({ return true } - // Check if this is a static provider (has models in MODELS_BY_PROVIDER) - const staticModels = MODELS_BY_PROVIDER[value as ProviderName] + // Check if this provider has models (prefer routerModels for custom model support) + const models = (routerModels as any)?.[value] ?? MODELS_BY_PROVIDER[value as ProviderName] // If it's a static provider, check if it has any models after filtering - if (staticModels) { - const filteredModels = filterModels(staticModels, value as ProviderName, organizationAllowList) + if (models) { + const filteredModels = filterModels(models, value as ProviderName, organizationAllowList) // Hide the provider if it has no models after filtering return filteredModels && Object.keys(filteredModels).length > 0 } - // If it's a dynamic provider (not in MODELS_BY_PROVIDER), always show it + // If provider has no models yet (dynamic provider not yet fetched), always show it // to avoid race conditions with async model fetching return true }) @@ -441,7 +442,7 @@ const ApiOptions = ({ value, label, })) - }, [organizationAllowList, apiConfiguration.apiProvider]) + }, [organizationAllowList, apiConfiguration.apiProvider, routerModels]) return (
diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 945b8aa63b..c422be91a2 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -59,6 +59,27 @@ describe("useSelectedModel", () => { unbound: {}, litellm: {}, "io-intelligence": {}, + // Static providers (empty since not testing them here) + anthropic: {}, + bedrock: {}, + vertex: {}, + gemini: {}, + "openai-native": {}, + mistral: {}, + deepseek: {}, + doubao: {}, + moonshot: {}, + minimax: {}, + xai: {}, + groq: {}, + cerebras: {}, + sambanova: {}, + fireworks: {}, + featherless: {}, + "qwen-code": {}, + "claude-code": {}, + zai: {}, + "vscode-lm": {}, }, isLoading: false, isError: false, @@ -123,6 +144,27 @@ describe("useSelectedModel", () => { unbound: {}, litellm: {}, "io-intelligence": {}, + // Static providers (empty since not testing them here) + anthropic: {}, + bedrock: {}, + vertex: {}, + gemini: {}, + "openai-native": {}, + mistral: {}, + deepseek: {}, + doubao: {}, + moonshot: {}, + minimax: {}, + xai: {}, + groq: {}, + cerebras: {}, + sambanova: {}, + fireworks: {}, + featherless: {}, + "qwen-code": {}, + "claude-code": {}, + zai: {}, + "vscode-lm": {}, }, isLoading: false, isError: false, @@ -191,6 +233,27 @@ describe("useSelectedModel", () => { unbound: {}, litellm: {}, "io-intelligence": {}, + // Static providers (empty since not testing them here) + anthropic: {}, + bedrock: {}, + vertex: {}, + gemini: {}, + "openai-native": {}, + mistral: {}, + deepseek: {}, + doubao: {}, + moonshot: {}, + minimax: {}, + xai: {}, + groq: {}, + cerebras: {}, + sambanova: {}, + fireworks: {}, + featherless: {}, + "qwen-code": {}, + "claude-code": {}, + zai: {}, + "vscode-lm": {}, }, isLoading: false, isError: false, @@ -246,6 +309,27 @@ describe("useSelectedModel", () => { unbound: {}, litellm: {}, "io-intelligence": {}, + // Static providers (empty since not testing them here) + anthropic: {}, + bedrock: {}, + vertex: {}, + gemini: {}, + "openai-native": {}, + mistral: {}, + deepseek: {}, + doubao: {}, + moonshot: {}, + minimax: {}, + xai: {}, + groq: {}, + cerebras: {}, + sambanova: {}, + fireworks: {}, + featherless: {}, + "qwen-code": {}, + "claude-code": {}, + zai: {}, + "vscode-lm": {}, }, isLoading: false, isError: false, @@ -290,6 +374,27 @@ describe("useSelectedModel", () => { unbound: {}, litellm: {}, "io-intelligence": {}, + // Static providers (empty since not testing them here) + anthropic: {}, + bedrock: {}, + vertex: {}, + gemini: {}, + "openai-native": {}, + mistral: {}, + deepseek: {}, + doubao: {}, + moonshot: {}, + minimax: {}, + xai: {}, + groq: {}, + cerebras: {}, + sambanova: {}, + fireworks: {}, + featherless: {}, + "qwen-code": {}, + "claude-code": {}, + zai: {}, + "vscode-lm": {}, }, isLoading: false, isError: false, @@ -327,7 +432,7 @@ describe("useSelectedModel", () => { }) describe("loading and error states", () => { - it("should NOT set loading when router models are loading but provider is static (anthropic)", () => { + it("should set loading when router models are loading for static provider (anthropic)", () => { mockUseRouterModels.mockReturnValue({ data: undefined, isLoading: true, @@ -343,13 +448,41 @@ describe("useSelectedModel", () => { const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(), { wrapper }) - // With static provider default (anthropic), useSelectedModel gates router fetches, so loading should be false - expect(result.current.isLoading).toBe(false) + // Static providers now fetch router models (for custom models support), so loading should be true + expect(result.current.isLoading).toBe(true) }) it("should NOT set loading when openrouter provider metadata is loading but provider is static (anthropic)", () => { mockUseRouterModels.mockReturnValue({ - data: { openrouter: {}, requesty: {}, glama: {}, unbound: {}, litellm: {}, "io-intelligence": {} }, + data: { + openrouter: {}, + requesty: {}, + glama: {}, + unbound: {}, + litellm: {}, + "io-intelligence": {}, + // Static providers (anthropic is the default provider being tested) + anthropic: {}, + bedrock: {}, + vertex: {}, + gemini: {}, + "openai-native": {}, + mistral: {}, + deepseek: {}, + doubao: {}, + moonshot: {}, + minimax: {}, + xai: {}, + groq: {}, + cerebras: {}, + sambanova: {}, + fireworks: {}, + featherless: {}, + "qwen-code": {}, + "claude-code": {}, + zai: {}, + "vscode-lm": {}, + }, isLoading: false, isError: false, } as any) @@ -367,7 +500,7 @@ describe("useSelectedModel", () => { expect(result.current.isLoading).toBe(false) }) - it("should NOT set error when hooks error but provider is static (anthropic)", () => { + it("should set error when router models error for static provider (anthropic)", () => { mockUseRouterModels.mockReturnValue({ data: undefined, isLoading: false, @@ -383,15 +516,37 @@ describe("useSelectedModel", () => { const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(), { wrapper }) - // Error from gated routerModels should not bubble for static provider default - expect(result.current.isError).toBe(false) + // Error from routerModels should bubble for static provider (since they now fetch router models) + expect(result.current.isError).toBe(true) }) }) describe("default behavior", () => { it("should return anthropic default when no configuration is provided", () => { mockUseRouterModels.mockReturnValue({ - data: undefined, + data: { + // Static providers - anthropic is the default provider + anthropic: {}, + bedrock: {}, + vertex: {}, + gemini: {}, + "openai-native": {}, + mistral: {}, + deepseek: {}, + doubao: {}, + moonshot: {}, + minimax: {}, + xai: {}, + groq: {}, + cerebras: {}, + sambanova: {}, + fireworks: {}, + featherless: {}, + "qwen-code": {}, + "claude-code": {}, + zai: {}, + "vscode-lm": {}, + }, isLoading: false, isError: false, } as any) @@ -421,6 +576,27 @@ describe("useSelectedModel", () => { unbound: {}, litellm: {}, "io-intelligence": {}, + // Static providers (claude-code is being tested) + anthropic: {}, + bedrock: {}, + vertex: {}, + gemini: {}, + "openai-native": {}, + mistral: {}, + deepseek: {}, + doubao: {}, + moonshot: {}, + minimax: {}, + xai: {}, + groq: {}, + cerebras: {}, + sambanova: {}, + fireworks: {}, + featherless: {}, + "qwen-code": {}, + "claude-code": {}, + zai: {}, + "vscode-lm": {}, }, isLoading: false, isError: false, @@ -459,6 +635,27 @@ describe("useSelectedModel", () => { unbound: {}, litellm: {}, "io-intelligence": {}, + // Static providers (claude-code is being tested) + anthropic: {}, + bedrock: {}, + vertex: {}, + gemini: {}, + "openai-native": {}, + mistral: {}, + deepseek: {}, + doubao: {}, + moonshot: {}, + minimax: {}, + xai: {}, + groq: {}, + cerebras: {}, + sambanova: {}, + fireworks: {}, + featherless: {}, + "qwen-code": {}, + "claude-code": {}, + zai: {}, + "vscode-lm": {}, }, isLoading: false, isError: false, @@ -494,6 +691,27 @@ describe("useSelectedModel", () => { unbound: {}, litellm: {}, "io-intelligence": {}, + // Static providers (bedrock is being tested) + anthropic: {}, + bedrock: {}, + vertex: {}, + gemini: {}, + "openai-native": {}, + mistral: {}, + deepseek: {}, + doubao: {}, + moonshot: {}, + minimax: {}, + xai: {}, + groq: {}, + cerebras: {}, + sambanova: {}, + fireworks: {}, + featherless: {}, + "qwen-code": {}, + "claude-code": {}, + zai: {}, + "vscode-lm": {}, }, isLoading: false, isError: false, diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index c2a57942d2..f3d2158680 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -32,6 +32,7 @@ import { } from "@roo-code/types" import type { ModelRecord, RouterModels } from "@roo/api" +import { isStaticProviderWithCustomModels } from "@roo/api" import { useRouterModels } from "./useRouterModels" import { useOpenRouterModelProviders } from "./useOpenRouterModelProviders" @@ -56,8 +57,8 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { const lmStudioModelId = provider === "lmstudio" ? apiConfiguration?.lmStudioModelId : undefined const ollamaModelId = provider === "ollama" ? apiConfiguration?.ollamaModelId : undefined - // Only fetch router models for dynamic providers - const shouldFetchRouterModels = isDynamicProvider(provider) + // Fetch router models for dynamic providers and static providers with custom models + const shouldFetchRouterModels = isDynamicProvider(provider) || isStaticProviderWithCustomModels(provider) const routerModels = useRouterModels({ provider: shouldFetchRouterModels ? provider : undefined, enabled: shouldFetchRouterModels, @@ -173,12 +174,14 @@ function getSelectedModel({ } case "xai": { const id = apiConfiguration.apiModelId ?? defaultModelId - const info = xaiModels[id as keyof typeof xaiModels] + // Check routerModels first (for custom models), then fall back to hard-coded models + const info = routerModels.xai?.[id] ?? xaiModels[id as keyof typeof xaiModels] return info ? { id, info } : { id, info: undefined } } case "groq": { const id = apiConfiguration.apiModelId ?? defaultModelId - const info = groqModels[id as keyof typeof groqModels] + // Check routerModels first (for custom models), then fall back to hard-coded models + const info = routerModels.groq?.[id] ?? groqModels[id as keyof typeof groqModels] return { id, info } } case "huggingface": { @@ -198,7 +201,8 @@ function getSelectedModel({ } case "bedrock": { const id = apiConfiguration.apiModelId ?? defaultModelId - const baseInfo = bedrockModels[id as keyof typeof bedrockModels] + // Check routerModels first (for custom models), then fall back to hard-coded models + const baseInfo = routerModels.bedrock?.[id] ?? bedrockModels[id as keyof typeof bedrockModels] // Special case for custom ARN. if (id === "custom-arn") { @@ -222,32 +226,38 @@ function getSelectedModel({ } case "vertex": { const id = apiConfiguration.apiModelId ?? defaultModelId - const info = vertexModels[id as keyof typeof vertexModels] + // Check routerModels first (for custom models), then fall back to hard-coded models + const info = routerModels.vertex?.[id] ?? vertexModels[id as keyof typeof vertexModels] return { id, info } } case "gemini": { const id = apiConfiguration.apiModelId ?? defaultModelId - const info = geminiModels[id as keyof typeof geminiModels] + // Check routerModels first (for custom models), then fall back to hard-coded models + const info = routerModels.gemini?.[id] ?? geminiModels[id as keyof typeof geminiModels] return { id, info } } case "deepseek": { const id = apiConfiguration.apiModelId ?? defaultModelId - const info = deepSeekModels[id as keyof typeof deepSeekModels] + // Check routerModels first (for custom models), then fall back to hard-coded models + const info = routerModels.deepseek?.[id] ?? deepSeekModels[id as keyof typeof deepSeekModels] return { id, info } } case "doubao": { const id = apiConfiguration.apiModelId ?? defaultModelId - const info = doubaoModels[id as keyof typeof doubaoModels] + // Check routerModels first (for custom models), then fall back to hard-coded models + const info = routerModels.doubao?.[id] ?? doubaoModels[id as keyof typeof doubaoModels] return { id, info } } case "moonshot": { const id = apiConfiguration.apiModelId ?? defaultModelId - const info = moonshotModels[id as keyof typeof moonshotModels] + // Check routerModels first (for custom models), then fall back to hard-coded models + const info = routerModels.moonshot?.[id] ?? moonshotModels[id as keyof typeof moonshotModels] return { id, info } } case "minimax": { const id = apiConfiguration.apiModelId ?? defaultModelId - const info = minimaxModels[id as keyof typeof minimaxModels] + // Check routerModels first (for custom models), then fall back to hard-coded models + const info = routerModels.minimax?.[id] ?? minimaxModels[id as keyof typeof minimaxModels] return { id, info } } case "zai": { @@ -260,12 +270,15 @@ function getSelectedModel({ } case "openai-native": { const id = apiConfiguration.apiModelId ?? defaultModelId - const info = openAiNativeModels[id as keyof typeof openAiNativeModels] + // Check routerModels first (for custom models), then fall back to hard-coded models + const info = + routerModels["openai-native"]?.[id] ?? openAiNativeModels[id as keyof typeof openAiNativeModels] return { id, info } } case "mistral": { const id = apiConfiguration.apiModelId ?? defaultModelId - const info = mistralModels[id as keyof typeof mistralModels] + // Check routerModels first (for custom models), then fall back to hard-coded models + const info = routerModels.mistral?.[id] ?? mistralModels[id as keyof typeof mistralModels] return { id, info } } case "openai": { @@ -313,27 +326,32 @@ function getSelectedModel({ case "claude-code": { // Claude Code models extend anthropic models but with images and prompt caching disabled const id = apiConfiguration.apiModelId ?? defaultModelId - const info = claudeCodeModels[id as keyof typeof claudeCodeModels] + // Check routerModels first (for custom models), then fall back to hard-coded models + const info = routerModels["claude-code"]?.[id] ?? claudeCodeModels[id as keyof typeof claudeCodeModels] return { id, info: { ...openAiModelInfoSaneDefaults, ...info } } } case "cerebras": { const id = apiConfiguration.apiModelId ?? defaultModelId - const info = cerebrasModels[id as keyof typeof cerebrasModels] + // Check routerModels first (for custom models), then fall back to hard-coded models + const info = routerModels.cerebras?.[id] ?? cerebrasModels[id as keyof typeof cerebrasModels] return { id, info } } case "sambanova": { const id = apiConfiguration.apiModelId ?? defaultModelId - const info = sambaNovaModels[id as keyof typeof sambaNovaModels] + // Check routerModels first (for custom models), then fall back to hard-coded models + const info = routerModels.sambanova?.[id] ?? sambaNovaModels[id as keyof typeof sambaNovaModels] return { id, info } } case "fireworks": { const id = apiConfiguration.apiModelId ?? defaultModelId - const info = fireworksModels[id as keyof typeof fireworksModels] + // Check routerModels first (for custom models), then fall back to hard-coded models + const info = routerModels.fireworks?.[id] ?? fireworksModels[id as keyof typeof fireworksModels] return { id, info } } case "featherless": { const id = apiConfiguration.apiModelId ?? defaultModelId - const info = featherlessModels[id as keyof typeof featherlessModels] + // Check routerModels first (for custom models), then fall back to hard-coded models + const info = routerModels.featherless?.[id] ?? featherlessModels[id as keyof typeof featherlessModels] return { id, info } } case "io-intelligence": { @@ -353,7 +371,8 @@ function getSelectedModel({ } case "qwen-code": { const id = apiConfiguration.apiModelId ?? defaultModelId - const info = qwenCodeModels[id as keyof typeof qwenCodeModels] + // Check routerModels first (for custom models), then fall back to hard-coded models + const info = routerModels["qwen-code"]?.[id] ?? qwenCodeModels[id as keyof typeof qwenCodeModels] return { id, info } } case "vercel-ai-gateway": { @@ -371,7 +390,8 @@ function getSelectedModel({ default: { provider satisfies "anthropic" | "gemini-cli" | "qwen-code" | "human-relay" | "fake-ai" const id = apiConfiguration.apiModelId ?? defaultModelId - const baseInfo = anthropicModels[id as keyof typeof anthropicModels] + // Check routerModels first (for custom models), then fall back to hard-coded models + const baseInfo = routerModels.anthropic?.[id] ?? anthropicModels[id as keyof typeof anthropicModels] // Apply 1M context beta tier pricing for Claude Sonnet 4 if ( diff --git a/webview-ui/src/utils/__tests__/validate.test.ts b/webview-ui/src/utils/__tests__/validate.test.ts index 0bd7a15962..851a5e561b 100644 --- a/webview-ui/src/utils/__tests__/validate.test.ts +++ b/webview-ui/src/utils/__tests__/validate.test.ts @@ -45,6 +45,27 @@ describe("Model Validation Functions", () => { huggingface: {}, roo: {}, chutes: {}, + // Static providers with custom models support + anthropic: {}, + bedrock: {}, + vertex: {}, + gemini: {}, + "openai-native": {}, + mistral: {}, + deepseek: {}, + doubao: {}, + moonshot: {}, + minimax: {}, + xai: {}, + groq: {}, + cerebras: {}, + sambanova: {}, + fireworks: {}, + featherless: {}, + "qwen-code": {}, + "claude-code": {}, + zai: {}, + "vscode-lm": {}, } const allowAllOrganization: OrganizationAllowList = {