From 1fbef240297c2ec130b35f84bb4b09bc126f9b7c Mon Sep 17 00:00:00 2001 From: DaviSM Date: Mon, 17 Aug 2026 19:09:51 -0300 Subject: [PATCH 1/2] feat: Support OpenRouter presets --- .../fetchers/__tests__/modelCache.spec.ts | 53 ++++- .../fetchers/__tests__/openrouter.spec.ts | 204 ++++++++++++++++++ src/api/providers/fetchers/modelCache.ts | 6 +- src/api/providers/fetchers/openrouter.ts | 128 +++++++++-- src/api/providers/openrouter.ts | 12 +- .../webview/__tests__/ClineProvider.spec.ts | 2 +- .../__tests__/webviewMessageHandler.spec.ts | 2 +- src/core/webview/webviewMessageHandler.ts | 12 +- src/shared/api.ts | 2 +- .../src/components/settings/ModelInfoView.tsx | 14 +- .../hooks/__tests__/useSelectedModel.spec.ts | 44 ++++ .../src/utils/__tests__/validate.spec.ts | 23 ++ 12 files changed, 460 insertions(+), 42 deletions(-) diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 108aa1827b..fac0ba4c90 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -145,6 +145,24 @@ describe("getModels with new GetModelsOptions", () => { expect(result).toEqual(mockModels) }) + it("forwards the OpenRouter API key to getOpenRouterModels", async () => { + const mockModels = { + "openrouter/model": { + maxTokens: 8192, + contextWindow: 128000, + supportsPromptCache: false, + }, + } + mockGetOpenRouterModels.mockResolvedValue(mockModels) + + await getModels({ provider: providerIdentifiers.openrouter, apiKey: "openrouter-key" }) + + expect(mockGetOpenRouterModels).toHaveBeenCalledWith({ + openRouterApiKey: "openrouter-key", + openRouterBaseUrl: undefined, + }) + }) + it("calls getRequestyModels with optional API key", async () => { const mockModels = { "requesty/model": { @@ -1125,9 +1143,10 @@ describe("NanoGPT key-scoped cache isolation", () => { describe("compound cache key derivation across scoping dimensions", () => { // Exercises every branch of getCacheKey via the public getModels() entry point. - // litellm is url-scoped AND key-scoped; openrouter is neither, so it hits the bare - // provider fallback. The fetcher mocks let us observe the cache key the result is - // written under (first arg of the matching memoryCache.set call). + // litellm is url-scoped AND key-scoped; openrouter is key-scoped only, so it hits the + // key discriminator branch (or the bare provider fallback when no key is supplied). The + // fetcher mocks let us observe the cache key the result is written under (first arg of + // the matching memoryCache.set call). const mockModels = { "compound/model": { maxTokens: 4096, @@ -1193,14 +1212,30 @@ describe("compound cache key derivation across scoping dimensions", () => { expect(cacheKey).toBe("litellm:http://host:4000") }) - it("falls back to the bare provider name for providers that are neither url- nor key-scoped", async () => { - await getModels({ - provider: providerIdentifiers.openrouter, - apiKey: "ignored-key", - baseUrl: "http://ignored:4000", - }) + it("falls back to the bare provider name for a key-scoped provider without an API key", async () => { + await getModels({ provider: providerIdentifiers.openrouter }) const cacheKey = writtenCacheKey() expect(cacheKey).toBe("openrouter") }) + + it("includes only the key discriminator for a key-scoped provider without a custom URL", async () => { + await getModels({ provider: providerIdentifiers.openrouter, apiKey: "openrouter-key" }) + const cacheKey = writtenCacheKey() + + expect(cacheKey).toMatch(/^openrouter:[0-9a-f]{8}$/) + }) + + it("writes different cache keys for two different OpenRouter API keys", async () => { + await getModels({ provider: providerIdentifiers.openrouter, apiKey: "key-one" }) + const firstKey = writtenCacheKey() + + mockSet.mockClear() + await getModels({ provider: providerIdentifiers.openrouter, apiKey: "key-two" }) + const secondKey = writtenCacheKey() + + expect(firstKey).toBeDefined() + expect(secondKey).toBeDefined() + expect(firstKey).not.toEqual(secondKey) + }) }) diff --git a/src/api/providers/fetchers/__tests__/openrouter.spec.ts b/src/api/providers/fetchers/__tests__/openrouter.spec.ts index 89169c9c56..b7a95c8b5f 100644 --- a/src/api/providers/fetchers/__tests__/openrouter.spec.ts +++ b/src/api/providers/fetchers/__tests__/openrouter.spec.ts @@ -540,4 +540,208 @@ describe("OpenRouter API", () => { expect(resultWithoutTools.supportedParameters).toContain("max_tokens") }) }) + + describe("getOpenRouterModels auth and private/preset models", () => { + it("omits the Authorization header and skips user/preset endpoints when no API key is provided", async () => { + const axios = await import("axios") + const getSpy = vi.spyOn(axios.default, "get").mockResolvedValue({ data: { data: [] } }) + + await getOpenRouterModels() + + expect(getSpy).toHaveBeenCalledWith(expect.stringContaining("/models"), { headers: undefined }) + expect(getSpy).not.toHaveBeenCalledWith(expect.stringContaining("/models/user"), expect.anything()) + expect(getSpy).not.toHaveBeenCalledWith(expect.stringContaining("/presets"), expect.anything()) + + getSpy.mockRestore() + }) + + it("sends the Authorization header to models, user models, and presets when a key is provided", async () => { + const axios = await import("axios") + const getSpy = vi.spyOn(axios.default, "get").mockResolvedValue({ data: { data: [] } }) + + await getOpenRouterModels({ openRouterApiKey: "test-key" }) + + const authHeader = expect.objectContaining({ headers: { Authorization: "Bearer test-key" } }) + + expect(getSpy).toHaveBeenCalledWith(expect.stringContaining("/models"), authHeader) + expect(getSpy).toHaveBeenCalledWith(expect.stringContaining("/models/user"), authHeader) + expect(getSpy).toHaveBeenCalledWith(expect.stringContaining("/presets"), authHeader) + + getSpy.mockRestore() + }) + + it("merges public, user, and preset models into the returned record", async () => { + const publicModel = { + id: "openai/gpt-4o", + name: "GPT-4o", + context_length: 128000, + pricing: { prompt: "0.000005", completion: "0.000015" }, + } + const userModel = { + id: "private/account-model", + name: "Account model", + context_length: 65536, + pricing: { prompt: "0", completion: "0" }, + } + + const axios = await import("axios") + const getSpy = vi + .spyOn(axios.default, "get") + .mockResolvedValueOnce({ data: { data: [publicModel] } }) + .mockResolvedValueOnce({ data: { data: [userModel] } }) + .mockResolvedValueOnce({ + data: { + data: [ + { + id: "preset-1", + name: "Flash", + slug: "flash", + description: null, + models: ["openai/gpt-4o"], + }, + ], + }, + }) + + const models = await getOpenRouterModels({ openRouterApiKey: "test-key" }) + + expect(models["openai/gpt-4o"]).toBeDefined() + expect(models["private/account-model"]).toBeDefined() + const preset = models["@preset/flash"] + expect(preset).toBeDefined() + expect(preset).not.toHaveProperty("contextWindow") + expect(preset).not.toHaveProperty("description") + expect(preset?.supportsPromptCache).toBe(false) + + getSpy.mockRestore() + }) + + it("derives a single-model preset context window from its underlying model", async () => { + const publicModel = { + id: "openai/gpt-4o", + name: "GPT-4o", + context_length: 128000, + pricing: { prompt: "0.000005", completion: "0.000015" }, + } + + const axios = await import("axios") + const getSpy = vi + .spyOn(axios.default, "get") + .mockResolvedValueOnce({ data: { data: [publicModel] } }) + .mockResolvedValueOnce({ data: { data: [] } }) + .mockResolvedValueOnce({ + data: { + data: [ + { + id: "preset-1", + name: "Flash", + slug: "flash", + description: null, + models: ["openai/gpt-4o"], + }, + ], + }, + }) + + const models = await getOpenRouterModels({ openRouterApiKey: "test-key" }) + + const preset = models["@preset/flash"] + expect(preset).toBeDefined() + expect(preset).not.toHaveProperty("contextWindow") + expect(preset).not.toHaveProperty("description") + expect(preset?.supportsPromptCache).toBe(false) + + getSpy.mockRestore() + }) + + it("derives a multi-model preset context window as the max across its models", async () => { + const smallModel = { + id: "openai/gpt-4o-mini", + name: "GPT-4o mini", + context_length: 128000, + pricing: { prompt: "0.00000015", completion: "0.0000006" }, + } + const largeModel = { + id: "anthropic/claude-3.7-sonnet", + name: "Claude 3.7 Sonnet", + context_length: 200000, + pricing: { prompt: "0.000003", completion: "0.000015" }, + } + + const axios = await import("axios") + const getSpy = vi + .spyOn(axios.default, "get") + .mockResolvedValueOnce({ data: { data: [smallModel, largeModel] } }) + .mockResolvedValueOnce({ data: { data: [] } }) + .mockResolvedValueOnce({ + data: { + data: [ + { + id: "preset-1", + name: "Mixed", + slug: "mixed", + description: null, + models: ["openai/gpt-4o-mini", "anthropic/claude-3.7-sonnet"], + }, + ], + }, + }) + + const models = await getOpenRouterModels({ openRouterApiKey: "test-key" }) + + const preset = models["@preset/mixed"] + expect(preset).toBeDefined() + expect(preset).not.toHaveProperty("contextWindow") + expect(preset).not.toHaveProperty("description") + expect(preset?.supportsPromptCache).toBe(false) + + getSpy.mockRestore() + }) + + it("falls back to a conservative context window when a preset's models cannot be resolved", async () => { + const axios = await import("axios") + const getSpy = vi + .spyOn(axios.default, "get") + .mockResolvedValueOnce({ data: { data: [] } }) + .mockResolvedValueOnce({ data: { data: [] } }) + .mockResolvedValueOnce({ + data: { + data: [ + { + id: "preset-1", + name: "Orphan", + slug: "orphan", + description: null, + models: ["unknown/model-not-in-list"], + }, + ], + }, + }) + + const models = await getOpenRouterModels({ openRouterApiKey: "test-key" }) + + const preset = models["@preset/orphan"] + expect(preset).toBeDefined() + expect(preset).not.toHaveProperty("contextWindow") + expect(preset).not.toHaveProperty("description") + expect(preset?.supportsPromptCache).toBe(false) + + getSpy.mockRestore() + }) + }) + + describe("parseOpenRouterModel accepts preset ids", () => { + it("parses an @preset/flash id without rejecting the @ prefix", () => { + const result = parseOpenRouterModel({ + id: "@preset/flash", + model: { name: "Flash preset", context_length: 200000 }, + inputModality: ["text"], + outputModality: ["text"], + maxTokens: undefined, + }) + + expect(result.contextWindow).toBe(200000) + expect(result.supportsPromptCache).toBe(false) + }) + }) }) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 50dbe12f6e..b392e8b63e 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -90,6 +90,7 @@ const URL_SCOPED_PROVIDERS: ReadonlySet = new Set([ // identity -- see the URL_SCOPED_PROVIDERS comment above for why this matters despite caching // being skipped for both. const KEY_SCOPED_PROVIDERS: ReadonlySet = new Set([ + providerIdentifiers.openrouter, // Per-key private/preset models (e.g. @preset/*) providerIdentifiers.litellm, // Per-key model allowlists are a first-class LiteLLM proxy feature providerIdentifiers.poe, // Per-account model availability providerIdentifiers.requesty, // Per-account custom model policies @@ -228,7 +229,10 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise +/** + * OpenRouterPreset + */ + +const openRouterPresetSchema = z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + description: z.string().nullish(), +}) + +/** + * OpenRouterPresetsResponse + */ + +const openRouterPresetsResponseSchema = z.object({ + data: z.array(openRouterPresetSchema), +}) + +type OpenRouterPresetsResponse = z.infer + +// Presets are user-defined aliases (referenced as `@preset/{slug}`) that route to a +// user-configured list of underlying models. The /presets endpoint does not expose a fixed +// context window or pricing, so we synthesize a conservative ModelInfo that keeps the +// webview validation path working without inventing per-token costs. Requests still route +// server-side, and a missing maxTokens lets OpenRouter apply its own default. +const OPENROUTER_PRESET_DEFAULT_CONTEXT_WINDOW = 200_000 + +function buildOpenRouterAuthHeaders(apiKey?: string): Record | undefined { + if (!apiKey) { + return undefined + } + + return { Authorization: `Bearer ${apiKey}` } +} + +function addOpenRouterModels(models: Record, data: OpenRouterModel[]): void { + for (const model of data) { + const { id, architecture, top_provider, supported_parameters = [] } = model + + // Skip image generation models (models that output images) + if (architecture?.output_modalities?.includes("image")) { + continue + } + + models[id] = parseOpenRouterModel({ + id, + model, + inputModality: architecture?.input_modalities, + outputModality: architecture?.output_modalities, + maxTokens: top_provider?.max_completion_tokens, + supportedParameters: supported_parameters, + }) + } +} + /** * getOpenRouterModels */ @@ -97,9 +153,11 @@ type OpenRouterModelEndpointsResponse = z.infer> { const models: Record = {} const baseURL = options?.openRouterBaseUrl || "https://openrouter.ai/api/v1" + const apiKey = options?.openRouterApiKey + const headers = buildOpenRouterAuthHeaders(apiKey) try { - const response = await axios.get(`${baseURL}/models`) + const response = await axios.get(`${baseURL}/models`, { headers }) const result = openRouterModelsResponseSchema.safeParse(response.data) const data = result.success ? result.data.data : response.data.data @@ -107,29 +165,56 @@ export async function getOpenRouterModels(options?: ApiHandlerOptions): Promise< console.error("OpenRouter models response is invalid", result.error.format()) } - for (const model of data) { - const { id, architecture, top_provider, supported_parameters = [] } = model + addOpenRouterModels(models, data) + } catch (error) { + console.error( + `Error fetching OpenRouter models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, + ) + } + + // Private/user-scoped models (e.g. per-account allowlists) are only returned by the + // authenticated /models/user endpoint, so merge them into the public list. + if (apiKey) { + try { + const response = await axios.get(`${baseURL}/models/user`, { headers }) + const result = openRouterModelsResponseSchema.safeParse(response.data) + const data = result.success ? result.data.data : response.data.data - // Skip image generation models (models that output images) - if (architecture?.output_modalities?.includes("image")) { - continue + if (!result.success) { + console.error("OpenRouter user models response is invalid", result.error.format()) } - const parsedModel = parseOpenRouterModel({ - id, - model, - inputModality: architecture?.input_modalities, - outputModality: architecture?.output_modalities, - maxTokens: top_provider?.max_completion_tokens, - supportedParameters: supported_parameters, - }) + addOpenRouterModels(models, data) + } catch (error) { + console.error( + `Error fetching OpenRouter user models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, + ) + } + + // Presets are only surfaced by the authenticated /presets endpoint and are referenced + // as `@preset/{slug}`. Add them so preset selections validate instead of being + // rejected/substituted by the webview model picker. + try { + const response = await axios.get(`${baseURL}/presets`, { headers }) + const result = openRouterPresetsResponseSchema.safeParse(response.data) + const data = result.success ? result.data.data : response.data.data + + if (!result.success) { + console.error("OpenRouter presets response is invalid", result.error.format()) + } - models[id] = parsedModel + for (const preset of data) { + models[`@preset/${preset.slug}`] = { + contextWindow: OPENROUTER_PRESET_DEFAULT_CONTEXT_WINDOW, + supportsPromptCache: false, + description: preset.description ?? preset.name, + } + } + } catch (error) { + console.error( + `Error fetching OpenRouter presets: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, + ) } - } catch (error) { - console.error( - `Error fetching OpenRouter models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, - ) } return models @@ -145,9 +230,12 @@ export async function getOpenRouterModelEndpoints( ): Promise> { const models: Record = {} const baseURL = options?.openRouterBaseUrl || "https://openrouter.ai/api/v1" + const headers = buildOpenRouterAuthHeaders(options?.openRouterApiKey) try { - const response = await axios.get(`${baseURL}/models/${modelId}/endpoints`) + const response = await axios.get(`${baseURL}/models/${modelId}/endpoints`, { + headers, + }) const result = openRouterModelEndpointsResponseSchema.safeParse(response.data) const data = result.success ? result.data.data : response.data.data diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 3e59b4360b..c160985f9a 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -164,7 +164,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH private async loadDynamicModels(): Promise { try { const [models, endpoints] = await Promise.all([ - getModels({ provider: "openrouter" }), + getModels({ + provider: "openrouter", + apiKey: this.options.openRouterApiKey, + baseUrl: this.options.openRouterBaseUrl, + }), getModelEndpoints({ router: "openrouter", modelId: this.options.openRouterModelId, @@ -535,7 +539,11 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH public async fetchModel() { const [models, endpoints] = await Promise.all([ - getModels({ provider: "openrouter" }), + getModels({ + provider: "openrouter", + apiKey: this.options.openRouterApiKey, + baseUrl: this.options.openRouterBaseUrl, + }), getModelEndpoints({ router: "openrouter", modelId: this.options.openRouterModelId, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index e336ac8fac..44275af807 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3426,7 +3426,7 @@ describe("ClineProvider - Router Models", () => { await messageHandler({ type: "requestRouterModels" }) // Verify getModels was called for each provider with correct options - expect(getModels).toHaveBeenCalledWith({ provider: "openrouter" }) + expect(getModels).toHaveBeenCalledWith({ provider: "openrouter", apiKey: "openrouter-key" }) expect(getModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" }) expect(getModels).toHaveBeenCalledWith({ provider: "unbound" }) expect(getModels).toHaveBeenCalledWith({ provider: "vercel-ai-gateway" }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index bc92522790..fc34d396dc 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -468,7 +468,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { }) // Verify getModels was called for each provider - expect(mockGetModels).toHaveBeenCalledWith({ provider: "openrouter" }) + expect(mockGetModels).toHaveBeenCalledWith({ provider: "openrouter", apiKey: "openrouter-key" }) expect(mockGetModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" }) expect(mockGetModels).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e88fd864cd..c1191132e2 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1098,9 +1098,19 @@ export const webviewMessageHandler = async ( } } + // OpenRouter's public /models endpoint works without auth, but private models and + // presets (e.g. @preset/flash) are only returned by the authenticated endpoints, so + // forward the saved key when present. Prefer an explicitly supplied unsaved key. + const openRouterApiKey = message?.values?.openRouterApiKey ?? apiConfiguration.openRouterApiKey + + // Refresh the cache when a new key is explicitly provided (e.g. the Refresh Models button). + if (message?.values?.openRouterApiKey !== undefined) { + await flushModels({ provider: "openrouter", apiKey: openRouterApiKey }, true) + } + // Base candidates (only those handled by this aggregate fetcher) const candidates: { key: RouterName; options: GetModelsOptions }[] = [ - { key: "openrouter", options: { provider: "openrouter" } }, + { key: "openrouter", options: { provider: "openrouter", apiKey: openRouterApiKey } }, { key: "requesty", options: { diff --git a/src/shared/api.ts b/src/shared/api.ts index 1787e15e88..af910d66b8 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -176,7 +176,7 @@ type CommonFetchParams = { // If a new dynamic provider is added in packages/types, this will fail to compile // until a corresponding entry is added here. const dynamicProviderExtras = { - openrouter: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type + openrouter: {} as { apiKey?: string; baseUrl?: string }, "vercel-ai-gateway": {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type "zoo-gateway": {} as { apiKey?: string; baseUrl?: string }, litellm: {} as { apiKey?: string; baseUrl: string }, diff --git a/webview-ui/src/components/settings/ModelInfoView.tsx b/webview-ui/src/components/settings/ModelInfoView.tsx index 34feecb2bb..7528b8c74b 100644 --- a/webview-ui/src/components/settings/ModelInfoView.tsx +++ b/webview-ui/src/components/settings/ModelInfoView.tsx @@ -57,12 +57,14 @@ export const ModelInfoView = ({ const fmt = (n?: number) => (typeof n === "number" ? formatPrice(n) : "—") const baseInfoItems = [ - typeof modelInfo?.contextWindow === "number" && modelInfo.contextWindow > 0 && ( - <> - {t("settings:modelInfo.contextWindow")}{" "} - {modelInfo.contextWindow?.toLocaleString()} tokens - - ), + typeof modelInfo?.contextWindow === "number" && + modelInfo.contextWindow > 0 && + !selectedModelId.startsWith("@preset/") && ( + <> + {t("settings:modelInfo.contextWindow")}{" "} + {modelInfo.contextWindow?.toLocaleString()} tokens + + ), typeof modelInfo?.maxTokens === "number" && modelInfo.maxTokens > 0 && ( <> {t("settings:modelInfo.maxOutput")}:{" "} 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 dbc52e390b..7400a530ec 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -383,6 +383,50 @@ describe("useSelectedModel", () => { }) }) + it("keeps @preset/flash selected when it is present in OpenRouter models (no silent swap)", () => { + mockUseRouterModels.mockReturnValue({ + data: { + openrouter: { + "@preset/flash": { + contextWindow: 200_000, + supportsPromptCache: false, + description: "Flash preset", + }, + "anthropic/claude-sonnet-4.5": { + maxTokens: 8192, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + }, + }, + requesty: {}, + litellm: {}, + }, + isLoading: false, + isError: false, + } as any) + + mockUseOpenRouterModelProviders.mockReturnValue({ + data: {}, + isLoading: false, + isError: false, + } as any) + + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "@preset/flash", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.id).toBe("@preset/flash") + expect(result.current.info).toMatchObject({ + contextWindow: 200_000, + supportsPromptCache: false, + }) + }) + it("should demonstrate the merging behavior validates the comment about missing fields", () => { const baseModelInfo: ModelInfo = { maxTokens: 4096, diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index 6535e3ba61..f9e0b6ebb6 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -90,6 +90,29 @@ describe("Model Validation Functions", () => { expect(result).toBeUndefined() }) + it("accepts @preset/flash when it is present in the OpenRouter model map", () => { + const presetRouterModels: RouterModels = { + ...mockRouterModels, + openrouter: { + ...mockRouterModels.openrouter, + "@preset/flash": { + contextWindow: 200_000, + supportsPromptCache: false, + description: "Flash preset", + }, + }, + } + + const config: ProviderSettings = { + apiProvider: "openrouter", + openRouterApiKey: "valid-key", + openRouterModelId: "@preset/flash", + } + + const result = getModelValidationError(config, presetRouterModels, allowAllOrganization) + expect(result).toBeUndefined() + }) + it("returns error for invalid OpenRouter model", () => { const config: ProviderSettings = { apiProvider: "openrouter", From 092961bb98a35857dc216078c45f00ca5e27c82b Mon Sep 17 00:00:00 2001 From: DaviSM Date: Mon, 17 Aug 2026 19:36:07 -0300 Subject: [PATCH 2/2] coderabbitai suggestions: - preset descriptions - context conditional --- .../fetchers/__tests__/openrouter.spec.ts | 3 --- .../src/components/settings/ModelInfoView.tsx | 14 ++++++-------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/api/providers/fetchers/__tests__/openrouter.spec.ts b/src/api/providers/fetchers/__tests__/openrouter.spec.ts index b7a95c8b5f..676b632639 100644 --- a/src/api/providers/fetchers/__tests__/openrouter.spec.ts +++ b/src/api/providers/fetchers/__tests__/openrouter.spec.ts @@ -609,9 +609,6 @@ describe("OpenRouter API", () => { expect(models["private/account-model"]).toBeDefined() const preset = models["@preset/flash"] expect(preset).toBeDefined() - expect(preset).not.toHaveProperty("contextWindow") - expect(preset).not.toHaveProperty("description") - expect(preset?.supportsPromptCache).toBe(false) getSpy.mockRestore() }) diff --git a/webview-ui/src/components/settings/ModelInfoView.tsx b/webview-ui/src/components/settings/ModelInfoView.tsx index 7528b8c74b..34feecb2bb 100644 --- a/webview-ui/src/components/settings/ModelInfoView.tsx +++ b/webview-ui/src/components/settings/ModelInfoView.tsx @@ -57,14 +57,12 @@ export const ModelInfoView = ({ const fmt = (n?: number) => (typeof n === "number" ? formatPrice(n) : "—") const baseInfoItems = [ - typeof modelInfo?.contextWindow === "number" && - modelInfo.contextWindow > 0 && - !selectedModelId.startsWith("@preset/") && ( - <> - {t("settings:modelInfo.contextWindow")}{" "} - {modelInfo.contextWindow?.toLocaleString()} tokens - - ), + typeof modelInfo?.contextWindow === "number" && modelInfo.contextWindow > 0 && ( + <> + {t("settings:modelInfo.contextWindow")}{" "} + {modelInfo.contextWindow?.toLocaleString()} tokens + + ), typeof modelInfo?.maxTokens === "number" && modelInfo.maxTokens > 0 && ( <> {t("settings:modelInfo.maxOutput")}:{" "}