Support custom model configuration files

This commit is contained in:
Matt Rubens 2025-11-08 00:59:06 -05:00
parent f93aafefe1
commit 5de3431905
25 changed files with 1594 additions and 169 deletions

View file

@ -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<typeof customModelsFileSchema>
/**
* Type for custom model information
*/
export type CustomModelInfo = z.infer<typeof customModelInfoSchema>

View file

@ -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"

View file

@ -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<string, ModelInfo>)
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"

View file

@ -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<string, ModelInfo>)
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) {

View file

@ -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<string, ModelInfo>)
// 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,
},
}

View file

@ -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<string, any> = {}
// 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)
})
})

View file

@ -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<ModelRecord>
}
}
// 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)

View file

@ -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<ProviderName, Record<string, ModelInfo>>()
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<string, ModelInfo>,
): Promise<Record<string, ModelInfo>> {
// 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<void> {
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<string, ModelInfo>,
): Record<string, ModelInfo> {
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<string, ModelInfo>,
): Promise<boolean> {
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<string, ModelInfo>,
): Promise<ModelInfo | undefined> {
const models = await getProviderModels(provider, staticModels)
return models[modelId]
}

View file

@ -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<string, ModelInfo>)
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",

View file

@ -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

View file

@ -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<string, ModelInfo>)
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"

View file

@ -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 () => {

View file

@ -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<string, Record<string, any>>
// 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<string, Record<string, any>>
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
})
})

View file

@ -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,
})

View file

@ -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<RouterName, ModelRecord> = providerFilter
? ({} as Record<RouterName, ModelRecord>)
: {
// 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<ModelRecord> => {
@ -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,

View file

@ -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 })
/**

View file

@ -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()
})
})

View file

@ -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<ModelRecord> {
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<ModelRecord> {
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"

View file

@ -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<string, ModelInfo>> = {
anthropic: anthropicModels as Record<string, ModelInfo>,
bedrock: bedrockModels as Record<string, ModelInfo>,
cerebras: cerebrasModels as Record<string, ModelInfo>,
"claude-code": claudeCodeModels as Record<string, ModelInfo>,
deepseek: deepSeekModels as Record<string, ModelInfo>,
doubao: doubaoModels as Record<string, ModelInfo>,
featherless: featherlessModels as Record<string, ModelInfo>,
fireworks: fireworksModels as Record<string, ModelInfo>,
gemini: geminiModels as Record<string, ModelInfo>,
groq: groqModels as Record<string, ModelInfo>,
"io-intelligence": ioIntelligenceModels as Record<string, ModelInfo>,
mistral: mistralModels as Record<string, ModelInfo>,
moonshot: moonshotModels as Record<string, ModelInfo>,
minimax: minimaxModels as Record<string, ModelInfo>,
"openai-native": openAiNativeModels as Record<string, ModelInfo>,
"qwen-code": qwenCodeModels as Record<string, ModelInfo>,
sambanova: sambaNovaModels as Record<string, ModelInfo>,
vertex: vertexModels as Record<string, ModelInfo>,
"vscode-lm": vscodeLlmModels as Record<string, ModelInfo>,
xai: xaiModels as Record<string, ModelInfo>,
zai: internationalZAiModels as Record<string, ModelInfo>,
}
/**
* 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<Record<string, ModelInfo>> {
const staticModels =
(STATIC_MODEL_DICTIONARIES as Partial<Record<ProviderName, Record<string, ModelInfo>>>)[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[]
}

View file

@ -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,
)
}

View file

@ -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<ProviderSettings, "apiProvider"> & {
@ -26,11 +29,22 @@ export type ApiHandlerOptions = Omit<ProviderSettings, "apiProvider"> & {
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<RouterName, object>
} as const satisfies Record<DynamicProvider | LocalProvider, object>
// 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]

View file

@ -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 (
<div className="flex flex-col gap-3">

View file

@ -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,

View file

@ -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 (

View file

@ -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 = {