diff --git a/packages/types/src/__tests__/custom-model-info.test.ts b/packages/types/src/__tests__/custom-model-info.test.ts new file mode 100644 index 0000000000..a0224f876e --- /dev/null +++ b/packages/types/src/__tests__/custom-model-info.test.ts @@ -0,0 +1,135 @@ +import { applyCustomModelInfo, customModelInfoSchema, toCustomModelInfo, type ModelInfo } from "../model.js" +import { providerIdentifiers, providerSettingsSchemaDiscriminated } from "../index.js" + +describe("custom model info", () => { + const catalogModel: ModelInfo = { + maxTokens: 4096, + contextWindow: 8192, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.1, + outputPrice: 0.2, + cacheWritesPrice: 0.3, + cacheReadsPrice: 0.4, + description: "Provider metadata", + } + + it("replaces discovered metadata with the configured snapshot", () => { + const resolved = applyCustomModelInfo(catalogModel, { + customModelInfo: { + contextWindow: 128_000, + maxTokens: 16_384, + supportsImages: true, + supportsPromptCache: true, + }, + }) + + expect(resolved).toMatchObject({ + contextWindow: 128_000, + maxTokens: 16_384, + supportsImages: true, + supportsPromptCache: true, + }) + // The snapshot wins wholesale: catalog metadata it does not carry is dropped + // rather than merged, so the resolved value matches what the user configured. + expect(resolved?.description).toBeUndefined() + }) + + it("keeps provider-owned pricing from the catalog entry", () => { + const resolved = applyCustomModelInfo(catalogModel, { + customModelInfo: { contextWindow: 128_000, supportsPromptCache: false }, + }) + + expect(resolved).toMatchObject({ + inputPrice: 0.1, + outputPrice: 0.2, + cacheWritesPrice: 0.3, + cacheReadsPrice: 0.4, + }) + }) + + it("resolves an unlisted model without any catalog entry", () => { + const resolved = applyCustomModelInfo(undefined, { + customModelInfo: { contextWindow: 64_000, maxTokens: 8_192, supportsPromptCache: false }, + }) + + expect(resolved).toMatchObject({ contextWindow: 64_000, maxTokens: 8_192 }) + }) + + it("does not invent pricing when the catalog has none", () => { + const resolved = applyCustomModelInfo(undefined, { + customModelInfo: { contextWindow: 64_000, supportsPromptCache: false }, + }) + + expect(resolved && "inputPrice" in resolved).toBe(false) + expect(resolved && "tiers" in resolved).toBe(false) + }) + + it("passes discovered metadata through untouched when nothing is configured", () => { + expect(applyCustomModelInfo(catalogModel, {})).toEqual(catalogModel) + expect(applyCustomModelInfo(catalogModel, { customModelInfo: null })).toEqual(catalogModel) + expect(applyCustomModelInfo(undefined, {})).toBeUndefined() + }) + + it("strips pricing when seeding the editor from a catalog entry", () => { + const seeded = toCustomModelInfo(catalogModel) + + expect(seeded).toMatchObject({ contextWindow: 8192, maxTokens: 4096, description: "Provider metadata" }) + expect("inputPrice" in seeded).toBe(false) + expect("cacheReadsPrice" in seeded).toBe(false) + }) + + it("rejects pricing fields in the persisted override schema", () => { + expect( + customModelInfoSchema.safeParse({ contextWindow: 64_000, supportsPromptCache: false, inputPrice: 1 }) + .success, + ).toBe(false) + expect( + providerSettingsSchemaDiscriminated.safeParse({ + apiProvider: providerIdentifiers.openrouter, + customModelInfo: { contextWindow: 64_000, supportsPromptCache: false, outputPrice: 1 }, + }).success, + ).toBe(false) + }) + + it("requires a context window in the persisted override schema", () => { + expect(customModelInfoSchema.safeParse({ supportsPromptCache: false }).success).toBe(false) + expect(customModelInfoSchema.safeParse({ contextWindow: 64_000, supportsPromptCache: false }).success).toBe( + true, + ) + }) + + // `modelInfoSchema` accepts any number because a provider catalog is not ours + // to police. User input is, and these two values drive token accounting and the + // outgoing max_completion_tokens. + it.each([ + { label: "zero", contextWindow: 0 }, + { label: "negative", contextWindow: -1 }, + { label: "fractional", contextWindow: 1.5 }, + { label: "non-finite", contextWindow: Number.POSITIVE_INFINITY }, + { label: "beyond safe integer range", contextWindow: Number.MAX_SAFE_INTEGER + 2 }, + ])("rejects a $label context window override", ({ contextWindow }) => { + expect(customModelInfoSchema.safeParse({ contextWindow, supportsPromptCache: false }).success).toBe(false) + }) + + it.each([ + { label: "zero", maxTokens: 0 }, + { label: "negative", maxTokens: -1 }, + { label: "fractional", maxTokens: 1.5 }, + { label: "non-finite", maxTokens: Number.POSITIVE_INFINITY }, + ])("rejects a $label max output tokens override", ({ maxTokens }) => { + expect( + customModelInfoSchema.safeParse({ contextWindow: 64_000, maxTokens, supportsPromptCache: false }).success, + ).toBe(false) + }) + + it("accepts an absent max output tokens as provider-decided", () => { + expect(customModelInfoSchema.safeParse({ contextWindow: 64_000, supportsPromptCache: false }).success).toBe( + true, + ) + expect( + customModelInfoSchema.safeParse({ contextWindow: 64_000, maxTokens: null, supportsPromptCache: false }) + .success, + ).toBe(true) + }) +}) diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 3f4081e78d..abc70cf321 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -185,6 +185,95 @@ export const modelInfoSchema = z.object({ export type ModelInfo = z.infer +/** + * User-supplied model metadata for a model whose discovered metadata is + * incomplete or unavailable. + * + * This mirrors the long-standing `openAiCustomModelInfo` contract: the stored + * value is a complete snapshot that the settings UI prefills from the + * discovered catalog entry. Resolution therefore stays a single expression at + * every call site and a configured override can never resolve to `undefined`. + * + * Pricing is deliberately excluded. Router catalogs own prices and refresh + * them, so a user-held price snapshot would go stale and silently corrupt cost + * reporting. + */ +export const customModelInfoSchema = modelInfoSchema + .omit({ + inputPrice: true, + outputPrice: true, + cacheWritesPrice: true, + cacheReadsPrice: true, + longContextPricing: true, + tiers: true, + }) + .extend({ + // `modelInfoSchema` accepts any number because a provider catalog is not + // ours to validate. User input is, and these two values drive token + // accounting and the outgoing max_completion_tokens, so reject the values + // that would produce NaN percentages or a request the gateway rejects. + contextWindow: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + maxTokens: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).nullish(), + }) + // `strict()` is what actually rejects a pricing field: omitting a key only + // drops it from the shape, it does not make the value invalid. + .strict() + +export type CustomModelInfo = z.infer + +export type CustomModelInfoSettings = { + customModelInfo?: CustomModelInfo | null +} + +/** + * Strips provider-owned pricing so the settings UI can prefill the editor from + * a discovered catalog entry. + */ +export const toCustomModelInfo = (info: ModelInfo): CustomModelInfo => { + const { + inputPrice: _inputPrice, + outputPrice: _outputPrice, + cacheWritesPrice: _cacheWritesPrice, + cacheReadsPrice: _cacheReadsPrice, + longContextPricing: _longContextPricing, + tiers: _tiers, + ...rest + } = info + + return rest +} + +/** + * Resolves the effective model metadata for providers that expose the custom + * model info editor. + * + * A configured override replaces the discovered metadata wholesale; pricing is + * always read back from the catalog entry. Without an override the discovered + * metadata passes through untouched. + */ +export const applyCustomModelInfo = ( + info: ModelInfo | undefined, + settings: CustomModelInfoSettings | undefined, +): ModelInfo | undefined => { + const override = settings?.customModelInfo + + if (!override) { + return info + } + + // Copy only the price keys the catalog actually carries, so the result never + // gains explicit `undefined` pricing fields. + return { + ...override, + ...(info?.inputPrice !== undefined && { inputPrice: info.inputPrice }), + ...(info?.outputPrice !== undefined && { outputPrice: info.outputPrice }), + ...(info?.cacheWritesPrice !== undefined && { cacheWritesPrice: info.cacheWritesPrice }), + ...(info?.cacheReadsPrice !== undefined && { cacheReadsPrice: info.cacheReadsPrice }), + ...(info?.longContextPricing !== undefined && { longContextPricing: info.longContextPricing }), + ...(info?.tiers !== undefined && { tiers: info.tiers }), + } +} + export type ModelRecord = Record export type RouterModels = Record diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 0b898f1b66..67a24a6b38 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -81,6 +81,28 @@ export type DynamicProvider = (typeof dynamicProviders)[number] export const isDynamicProvider = (key: string): key is DynamicProvider => dynamicProviders.includes(key as DynamicProvider) +/** + * Providers that accept user-supplied `customModelInfo` overrides in the + * settings UI. This is a strict subset of `dynamicProviders` — only providers + * whose model metadata can be manually adjusted by the user. + * + * NOTE: Of these, only vercel-ai-gateway and zoo-gateway apply the overlay + * inside `RouterProvider.resolveModelInfo()`. The others (openrouter, requesty, + * unbound) apply it in their own overridden `getModel()` methods. + */ +export const customModelInfoProviders = [ + providerIdentifiers.openrouter, + providerIdentifiers.requesty, + providerIdentifiers.unbound, + providerIdentifiers.vercelAiGateway, + providerIdentifiers.zooGateway, +] as const + +export type CustomModelInfoProvider = (typeof customModelInfoProviders)[number] + +export const isCustomModelInfoProvider = (key: string): key is CustomModelInfoProvider => + customModelInfoProviders.includes(key as CustomModelInfoProvider) + /** * LocalProvider * diff --git a/packages/types/src/provider-settings/common.ts b/packages/types/src/provider-settings/common.ts index e73a05f143..8fe6e28a8e 100644 --- a/packages/types/src/provider-settings/common.ts +++ b/packages/types/src/provider-settings/common.ts @@ -1,6 +1,6 @@ import { z } from "zod" -import { reasoningEffortSettingSchema, verbosityLevelsSchema } from "../model.js" +import { customModelInfoSchema, reasoningEffortSettingSchema, verbosityLevelsSchema } from "../model.js" import type { ProviderIdentifier } from "../provider-identifiers.js" export const API_PROVIDER_FIELD = "apiProvider" @@ -18,6 +18,7 @@ export const baseProviderSettingsShape = { modelMaxTokens: z.number().optional(), modelMaxThinkingTokens: z.number().optional(), verbosity: verbosityLevelsSchema.optional(), + customModelInfo: customModelInfoSchema.nullish(), } export const apiModelIdProviderModelShape = { diff --git a/src/api/providers/__tests__/kenari.spec.ts b/src/api/providers/__tests__/kenari.spec.ts index ce629f0377..e90a3ac374 100644 --- a/src/api/providers/__tests__/kenari.spec.ts +++ b/src/api/providers/__tests__/kenari.spec.ts @@ -81,6 +81,17 @@ describe("KenariHandler", () => { expect(result.info.supportsPromptCache).toBe(false) }) + it("does not apply gateway-only custom metadata overrides", async () => { + const handler = new KenariHandler({ + ...mockOptions, + customModelInfo: { contextWindow: 100_000, maxTokens: 10_000, supportsPromptCache: false }, + }) + const result = await handler.fetchModel() + + expect(result.info.contextWindow).toBe(1_048_576) + expect(result.info.maxTokens).toBe(32_768) + }) + it("falls back to the default model id when none is configured", async () => { const handler = new KenariHandler({ kenariApiKey: "test-key" }) const result = await handler.fetchModel() diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index 27a84363f6..057ed6ddd9 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -20,6 +20,7 @@ import OpenAI from "openai" import { providerIdentifiers } from "@roo-code/types" import { OpenRouterHandler } from "../openrouter" +import { getModelEndpoints } from "../fetchers/modelEndpointCache" import { Package } from "../../../shared/package" import { makeApiHandlerOptions } from "../../../test-utils/api" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" @@ -109,13 +110,22 @@ vitest.mock("../fetchers/modelCache", () => ({ }), })) +vitest.mock("../fetchers/modelEndpointCache", () => ({ + getModelEndpoints: vitest.fn().mockResolvedValue({}), +})) + describe("OpenRouterHandler", () => { const mockOptions = makeApiHandlerOptions({ openRouterApiKey: "test-key", openRouterModelId: "anthropic/claude-sonnet-4", }) - beforeEach(() => clearAllMocks()) + beforeEach(() => { + clearAllMocks() + // Reset getModelEndpoints to its default empty-object return so per-test + // overrides (e.g. specific-provider test) don't leak into subsequent tests. + vitest.mocked(getModelEndpoints).mockResolvedValue({}) + }) it("initializes with correct options", () => { const handler = new OpenRouterHandler(mockOptions) @@ -147,6 +157,68 @@ describe("OpenRouterHandler", () => { }) }) + it("applies custom metadata before deriving request parameters", async () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + customModelInfo: { + contextWindow: 100_000, + maxTokens: 10_000, + supportsImages: false, + supportsPromptCache: false, + }, + }) + + const result = await handler.fetchModel() + + expect(result.info.contextWindow).toBe(100_000) + expect(result.info.maxTokens).toBe(10_000) + expect(result.info.supportsImages).toBe(false) + expect(result.info.supportsPromptCache).toBe(false) + expect(result.maxTokens).toBe(10_000) + }) + + it("applies custom metadata to a discovered specific-provider endpoint", async () => { + vitest.mocked(getModelEndpoints).mockResolvedValue({ + "test-provider": { + contextWindow: 128_000, + maxTokens: 16_384, + supportsImages: true, + supportsPromptCache: true, + }, + }) + + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterSpecificProvider: "test-provider", + customModelInfo: { contextWindow: 100_000, maxTokens: 10_000, supportsPromptCache: false }, + }) + + const result = await handler.fetchModel() + + expect(result.info.contextWindow).toBe(100_000) + expect(result.info.maxTokens).toBe(10_000) + }) + + it("synthesizes metadata for an unlisted configured model", async () => { + const modelId = "provider/unlisted-model" + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterModelId: modelId, + customModelInfo: { + supportsPromptCache: false, + contextWindow: 100_000, + maxTokens: 10_000, + }, + }) + + const result = await handler.fetchModel() + + expect(result.id).toBe(modelId) + expect(result.info.contextWindow).toBe(100_000) + expect(result.info.maxTokens).toBe(10_000) + expect(result.maxTokens).toBe(10_000) + }) + it("returns default model info when options are not provided", async () => { const handler = new OpenRouterHandler({}) const result = await handler.fetchModel() @@ -154,7 +226,7 @@ describe("OpenRouterHandler", () => { expect(result.info.supportsPromptCache).toBe(true) }) - it("honors custom maxTokens for thinking models", async () => { + it("clamps maxTokens to 20% of context window for thinking models", async () => { const handler = new OpenRouterHandler( makeApiHandlerOptions({ openRouterApiKey: "test-key", diff --git a/src/api/providers/__tests__/requesty.spec.ts b/src/api/providers/__tests__/requesty.spec.ts index f256e5b1c1..d6b730dd74 100644 --- a/src/api/providers/__tests__/requesty.spec.ts +++ b/src/api/providers/__tests__/requesty.spec.ts @@ -177,6 +177,46 @@ describe("RequestyHandler", () => { }) }) + it("applies custom metadata before deriving request parameters", async () => { + const handler = new RequestyHandler({ + ...mockOptions, + customModelInfo: { + contextWindow: 100_000, + maxTokens: 10_000, + supportsImages: false, + supportsPromptCache: false, + }, + }) + + const result = await handler.fetchModel() + + expect(result.info.contextWindow).toBe(100_000) + expect(result.info.maxTokens).toBe(10_000) + expect(result.info.supportsImages).toBe(false) + expect(result.info.supportsPromptCache).toBe(false) + expect(result.maxTokens).toBe(10_000) + }) + + it("synthesizes metadata for an unlisted configured model", async () => { + const modelId = "provider/unlisted-model" + const handler = new RequestyHandler({ + ...mockOptions, + requestyModelId: modelId, + customModelInfo: { + supportsPromptCache: false, + contextWindow: 100_000, + maxTokens: 10_000, + }, + }) + + const result = await handler.fetchModel() + + expect(result.id).toBe(modelId) + expect(result.info.contextWindow).toBe(100_000) + expect(result.info.maxTokens).toBe(10_000) + expect(result.maxTokens).toBe(10_000) + }) + it("returns default model info when options are not provided", async () => { const handler = new RequestyHandler({}) const result = await handler.fetchModel() diff --git a/src/api/providers/__tests__/unbound.spec.ts b/src/api/providers/__tests__/unbound.spec.ts index 99f11d7b6f..75030f4ec3 100644 --- a/src/api/providers/__tests__/unbound.spec.ts +++ b/src/api/providers/__tests__/unbound.spec.ts @@ -43,6 +43,48 @@ describe("UnboundHandler", () => { clearAllMocks() }) + it("applies custom metadata before deriving request parameters", async () => { + const handler = new UnboundHandler({ + unboundApiKey: "test-key", + unboundModelId: "openai/gpt-4o", + customModelInfo: { + contextWindow: 100_000, + maxTokens: 10_000, + supportsImages: false, + supportsPromptCache: true, + }, + }) + + const result = await handler.fetchModel() + + expect(result.info.contextWindow).toBe(100_000) + expect(result.info.maxTokens).toBe(10_000) + expect(result.info.supportsImages).toBe(false) + expect(result.info.supportsPromptCache).toBe(true) + expect(result.maxTokens).toBe(10_000) + }) + + it("resolves an unlisted configured model from the custom snapshot", async () => { + const modelId = "provider/unlisted-model" + const handler = new UnboundHandler({ + unboundApiKey: "test-key", + unboundModelId: modelId, + customModelInfo: { + contextWindow: 100_000, + maxTokens: 10_000, + supportsPromptCache: false, + }, + }) + + const result = await handler.fetchModel() + + expect(result.id).toBe(modelId) + expect(result.info.contextWindow).toBe(100_000) + expect(result.info.maxTokens).toBe(10_000) + expect(result.info.supportsPromptCache).toBe(false) + expect(result.maxTokens).toBe(10_000) + }) + it("identifies itself as Zoo Code in the Unbound request headers", () => { new UnboundHandler({ unboundApiKey: "test-key", diff --git a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts index 268e42a7e2..02d348c5d0 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -192,6 +192,87 @@ describe("VercelAiGatewayHandler", () => { expect(result.info.supportsPromptCache).toBe(true) }) + it("applies custom metadata overrides to the discovered model", async () => { + const handler = new VercelAiGatewayHandler({ + ...mockOptions, + customModelInfo: { contextWindow: 100_000, maxTokens: 10_000, supportsImages: false, supportsPromptCache: false }, + }) + const result = await handler.fetchModel() + + expect(result.info.contextWindow).toBe(100_000) + expect(result.info.maxTokens).toBe(10_000) + expect(result.info.supportsImages).toBe(false) + }) + + it("clamps a custom maxTokens override to the context window before requesting", async () => { + mockCreate.mockResolvedValue({ choices: [{ message: { role: "assistant", content: "ok" } }] }) + + const handler = new VercelAiGatewayHandler({ + ...mockOptions, + customModelInfo: { contextWindow: 1_000, maxTokens: 2_000, supportsPromptCache: false }, + }) + const result = await handler.fetchModel() + + expect(result.info.maxTokens).toBe(1_000) + + await handler.completePrompt("test") + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ max_completion_tokens: 1_000 })) + }) + + it("propagates customModelInfo maxTokens into the completePrompt request body", async () => { + mockCreate.mockImplementation(async () => ({ + choices: [{ message: { role: "assistant", content: "ok" } }], + })) + + const handler = new VercelAiGatewayHandler({ + ...mockOptions, + customModelInfo: { contextWindow: 100_000, maxTokens: 10_000, supportsPromptCache: false }, + }) + await handler.completePrompt("test") + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + max_completion_tokens: 10_000, + }), + ) + }) + + // Same contract as the Zoo gateway: `info.maxTokens` is forwarded verbatim, + // so the clamp is the only guard on the outgoing token budget. + it("sends the catalog maxTokens unchanged when the context window is unusable", async () => { + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockResolvedValueOnce({ + "anthropic/claude-sonnet-4": { + maxTokens: 5_000, + contextWindow: 0, + supportsPromptCache: false, + }, + }) + mockCreate.mockResolvedValue({ choices: [{ message: { role: "assistant", content: "ok" } }] }) + + const handler = new VercelAiGatewayHandler(mockOptions) + await handler.completePrompt("test") + + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ max_completion_tokens: 5_000 })) + }) + + it("clamps a catalog maxTokens above the context window", async () => { + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockResolvedValueOnce({ + "anthropic/claude-sonnet-4": { + maxTokens: 500_000, + contextWindow: 200_000, + supportsPromptCache: false, + }, + }) + mockCreate.mockResolvedValue({ choices: [{ message: { role: "assistant", content: "ok" } }] }) + + const handler = new VercelAiGatewayHandler(mockOptions) + await handler.completePrompt("test") + + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ max_completion_tokens: 200_000 })) + }) + it("returns default model info when options are not provided", async () => { const handler = new VercelAiGatewayHandler({}) const result = await handler.fetchModel() diff --git a/src/api/providers/__tests__/zoo-gateway.spec.ts b/src/api/providers/__tests__/zoo-gateway.spec.ts index fe088bb5c9..402ffc5cc1 100644 --- a/src/api/providers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/__tests__/zoo-gateway.spec.ts @@ -230,6 +230,137 @@ describe("ZooGatewayHandler", () => { expect(result.info.supportsPromptCache).toBe(true) }) + it("applies custom metadata overrides to the discovered model", async () => { + const handler = new ZooGatewayHandler({ + ...mockOptions, + customModelInfo: { contextWindow: 100_000, maxTokens: 10_000, supportsPromptCache: false }, + }) + const result = await handler.fetchModel() + + expect(result.info.contextWindow).toBe(100_000) + expect(result.info.maxTokens).toBe(10_000) + expect(result.info.supportsPromptCache).toBe(false) + }) + + it("clamps a custom maxTokens override to the context window before requesting", async () => { + mockCreate.mockResolvedValue({ choices: [{ message: { role: "assistant", content: "ok" } }] }) + + const handler = new ZooGatewayHandler({ + ...mockOptions, + customModelInfo: { contextWindow: 1_000, maxTokens: 2_000, supportsPromptCache: false }, + }) + const result = await handler.fetchModel() + + expect(result.info.maxTokens).toBe(1_000) + + await handler.completePrompt("test") + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ max_completion_tokens: 1_000 })) + }) + + it("propagates customModelInfo maxTokens into the completePrompt request body", async () => { + mockCreate.mockImplementation(async () => ({ + choices: [{ message: { role: "assistant", content: "ok" } }], + })) + + const handler = new ZooGatewayHandler({ + ...mockOptions, + customModelInfo: { contextWindow: 100_000, maxTokens: 10_000, supportsPromptCache: false }, + }) + await handler.completePrompt("test") + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + max_completion_tokens: 10_000, + }), + ) + }) + + // This gateway forwards `info.maxTokens` verbatim as `max_completion_tokens`, + // so the clamp is the only thing keeping an out-of-range value out of the + // request. Catalog entries reach it unvalidated: `modelInfoSchema` does not + // constrain these numbers, only user-supplied overrides are constrained. + it.each([ + { label: "zero", contextWindow: 0 }, + { label: "negative", contextWindow: -1 }, + ])( + "sends the catalog maxTokens unchanged when the context window is $label", + async ({ contextWindow }) => { + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockResolvedValue({ + "anthropic/claude-sonnet-4": { + ...DEFAULT_MODEL_CATALOG["anthropic/claude-sonnet-4"], + contextWindow, + maxTokens: 5_000, + }, + }) + mockCreate.mockResolvedValue({ choices: [{ message: { role: "assistant", content: "ok" } }] }) + + const handler = new ZooGatewayHandler(mockOptions) + await handler.completePrompt("test") + + // Clamping to a meaningless window would send 0 or -1 as the token + // budget and the gateway would reject the request outright. + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ max_completion_tokens: 5_000 })) + }, + ) + + it("clamps a non-finite catalog maxTokens down to the context window", async () => { + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockResolvedValue({ + "anthropic/claude-sonnet-4": { + ...DEFAULT_MODEL_CATALOG["anthropic/claude-sonnet-4"], + contextWindow: 200_000, + maxTokens: Number.POSITIVE_INFINITY, + }, + }) + mockCreate.mockResolvedValue({ choices: [{ message: { role: "assistant", content: "ok" } }] }) + + const handler = new ZooGatewayHandler(mockOptions) + await handler.completePrompt("test") + + // `Infinity` serializes to `null` in JSON, which the gateway rejects. + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ max_completion_tokens: 200_000 })) + }) + + it("preserves a null catalog maxTokens as the provider-decides sentinel", async () => { + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockResolvedValue({ + "anthropic/claude-sonnet-4": { + ...DEFAULT_MODEL_CATALOG["anthropic/claude-sonnet-4"], + contextWindow: 1_000, + maxTokens: null, + }, + }) + mockCreate.mockResolvedValue({ choices: [{ message: { role: "assistant", content: "ok" } }] }) + + const handler = new ZooGatewayHandler(mockOptions) + const result = await handler.fetchModel() + + // Substituting the context window here would cap a response the provider + // was asked to size itself. + expect(result.info.maxTokens).toBeNull() + + await handler.completePrompt("test") + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ max_completion_tokens: null })) + }) + + it("leaves a catalog maxTokens below the context window untouched", async () => { + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockResolvedValue({ + "anthropic/claude-sonnet-4": { + ...DEFAULT_MODEL_CATALOG["anthropic/claude-sonnet-4"], + contextWindow: 200_000, + maxTokens: 4_096, + }, + }) + mockCreate.mockResolvedValue({ choices: [{ message: { role: "assistant", content: "ok" } }] }) + + const handler = new ZooGatewayHandler(mockOptions) + await handler.completePrompt("test") + + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ max_completion_tokens: 4_096 })) + }) + it("falls back to the default model when none is configured", async () => { const handler = new ZooGatewayHandler({ zooSessionToken: "zoo_ext_test_token" }) const result = await handler.fetchModel() @@ -268,7 +399,7 @@ describe("ZooGatewayHandler", () => { ) }) - it("requires authentication at request time when no session token is available", async () => { + it("requires authentication when draining the stream with no session token", async () => { const handler = new ZooGatewayHandler({}) const stream = handler.createMessage("You are helpful.", [{ role: "user", content: "Hello" }]) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index ed53c111b5..c6fca1050b 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -10,6 +10,7 @@ import { OPENROUTER_DEFAULT_PROVIDER_NAME, OPEN_ROUTER_PROMPT_CACHING_MODELS, DEEP_SEEK_DEFAULT_TEMPERATURE, + applyCustomModelInfo, providerIdentifiers, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -568,6 +569,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH // Apply tool preferences for models accessed through routers (OpenAI, Gemini) info = applyRouterToolPreferences(id, info) + info = applyCustomModelInfo(info, this.options) ?? info const isDeepSeekR1 = id.startsWith("deepseek/deepseek-r1") || id === "perplexity/sonar-reasoning" diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 2c1092d303..f70937d3ed 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -2,6 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { + applyCustomModelInfo, type ModelInfo, type ModelRecord, providerIdentifiers, @@ -86,11 +87,11 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan override getModel() { const id = this.options.requestyModelId ?? requestyDefaultModelId - const cachedInfo = this.models[id] ?? requestyDefaultModelInfo - let info: ModelInfo = cachedInfo + let info: ModelInfo = this.models[id] ?? requestyDefaultModelInfo // Apply tool preferences for models accessed through routers (OpenAI, Gemini) info = applyRouterToolPreferences(id, info) + info = applyCustomModelInfo(info, this.options) ?? info const params = getModelParams({ format: "anthropic", diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts index 5c457a71e7..8895d09a0c 100644 --- a/src/api/providers/router-provider.ts +++ b/src/api/providers/router-provider.ts @@ -1,6 +1,6 @@ import OpenAI from "openai" -import { type ModelInfo, type ModelRecord } from "@roo-code/types" +import { applyCustomModelInfo, isCustomModelInfoProvider, type ModelInfo, type ModelRecord } from "@roo-code/types" import { ApiHandlerOptions, RouterName } from "../../shared/api" @@ -55,6 +55,43 @@ export abstract class RouterProvider extends BaseProvider { private missingModelRefreshAt = new Map() private static readonly MISSING_MODEL_RETRY_MS = 5 * 60 * 1000 + /** + * Resolve the effective model metadata, applying the user's `customModelInfo` + * snapshot for providers whose settings UI exposes the editor. + * + * openrouter, requesty and unbound resolve overrides in their own + * `getModel()` because they first merge provider-specific metadata (endpoint + * selection, router tool preferences) that must run before the override wins. + */ + private resolveModelInfo(info: ModelInfo | undefined, fallback: ModelInfo): ModelInfo { + if (!isCustomModelInfoProvider(this.name)) { + return info ?? fallback + } + + const resolvedInfo = applyCustomModelInfo(info, this.options) ?? fallback + const { maxTokens, contextWindow } = resolvedInfo + + // These gateways forward `info.maxTokens` verbatim as max_completion_tokens + // with no clamp of their own, so a value above the context window produces a + // request the gateway rejects. openrouter/requesty/unbound instead go through + // `getModelParams()`, which already clamps, and must not be clamped twice. + // + // `contextWindow > 0` skips a catalog entry whose window is missing or + // nonsensical, because clamping to it would send 0 or a negative budget. + // The `typeof` check is required by the compiler (`maxTokens` is + // `number | null | undefined`); dropping it raises TS18049. It is not + // separately observable either, since a nullish value coerces to 0 and can + // never exceed a positive window. `>` rather than `>=` is the same: when + // the two are equal the clamp writes back the value already there. A + // negative or NaN `maxTokens` fails the comparison and passes through + // unchanged, which is what the provider asked for. + if (typeof maxTokens === "number" && contextWindow > 0 && maxTokens > contextWindow) { + return { ...resolvedInfo, maxTokens: contextWindow } + } + + return resolvedInfo + } + public async fetchModel() { // Refetch when the selected model is missing — a stale non-empty map // would otherwise keep serving defaultModelInfo prices for cost estimates. @@ -150,7 +187,7 @@ export abstract class RouterProvider extends BaseProvider { // First check instance models (populated by fetchModel) if (this.models[id]) { - return { id, info: this.models[id] } + return { id, info: this.resolveModelInfo(this.models[id], this.models[id]) } } // Fall back to global cache (synchronous disk/memory cache). @@ -164,25 +201,28 @@ export abstract class RouterProvider extends BaseProvider { if (cachedModels?.[id]) { // Also populate instance models for future calls this.models = cachedModels - return { id, info: cachedModels[id] } + return { id, info: this.resolveModelInfo(cachedModels[id], cachedModels[id]) } } // Last resort: keep the configured id so we don't swap models, but zero // prices so we don't bill the UI with defaultModelInfo's $/token rates. + // Route the fallback through resolveModelInfo so a user-supplied + // customModelInfo override (gateway providers) is still applied even when + // no fetched or cached metadata exists for the configured model. if (id !== this.defaultModelId) { return { id, - info: { + info: this.resolveModelInfo(undefined, { ...this.defaultModelInfo, inputPrice: 0, outputPrice: 0, cacheWritesPrice: 0, cacheReadsPrice: 0, - }, + }), } } - return { id, info: this.defaultModelInfo } + return { id, info: this.resolveModelInfo(undefined, this.defaultModelInfo) } } protected supportsTemperature(modelId: string): boolean { diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts index 61a2d1ae38..c4bbcb2dde 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -2,6 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { + applyCustomModelInfo, type ModelInfo, type ModelRecord, providerIdentifiers, @@ -83,11 +84,11 @@ export class UnboundHandler extends BaseProvider implements SingleCompletionHand override getModel() { const id = this.options.unboundModelId ?? unboundDefaultModelId - const cachedInfo = this.models[id] ?? unboundDefaultModelInfo - let info: ModelInfo = cachedInfo + let info: ModelInfo = this.models[id] ?? unboundDefaultModelInfo // Apply tool preferences for models accessed through routers (OpenAI, Gemini) info = applyRouterToolPreferences(id, info) + info = applyCustomModelInfo(info, this.options) ?? info const params = getModelParams({ format: "openai", diff --git a/webview-ui/playwright-ct.config.ts b/webview-ui/playwright-ct.config.ts index 5e128e445c..6955ecd076 100644 --- a/webview-ui/playwright-ct.config.ts +++ b/webview-ui/playwright-ct.config.ts @@ -52,6 +52,9 @@ export default defineConfig({ expect: { toHaveScreenshot: { animations: "disabled", + // Allow minor anti-aliasing differences between local and CI + // rendering environments (different OS / font stacks). + maxDiffPixelRatio: 0.02, }, }, projects: [ diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 04ca36b2c6..de7ac8e6dc 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -60,6 +60,26 @@ export const stories: Record = { const { ChatTextAreaStory } = await import("@/components/chat/__tests__/ChatTextArea.visual.fixture") return }, + "custom-model-info-collapsed": async () => { + const { CollapsedFixture } = await import("@/components/settings/__tests__/CustomModelInfoSettings.visual.fixture") + return + }, + "custom-model-info-expanded-overrides": async () => { + const { ExpandedWithOverridesFixture } = await import( + "@/components/settings/__tests__/CustomModelInfoSettings.visual.fixture" + ) + return + }, + "custom-model-info-unresolved": async () => { + const { UnresolvedFixture } = await import( + "@/components/settings/__tests__/CustomModelInfoSettings.visual.fixture" + ) + return + }, + "custom-model-info-warning": async () => { + const { WarningFixture } = await import("@/components/settings/__tests__/CustomModelInfoSettings.visual.fixture") + return + }, "history-empty": async () => { const [{ AppProviders }, { default: HistoryView }] = await Promise.all([ import("../AppProviders"), diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 0941a22e2b..bb070a71cd 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -69,7 +69,11 @@ const TaskHeader = ({ const textContainerRef = useRef(null) const textRef = useRef(null) - const contextWindow = model?.contextWindow || 1 + const contextWindow = model?.contextWindow + const contextWindowForDisplay = + typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0 + ? contextWindow + : undefined // Calculate maxTokens (reserved for output) once for reuse in percentage and tooltip const maxTokens = useMemo( @@ -201,71 +205,82 @@ const TaskHeader = ({ - {!isTaskExpanded && contextWindow > 0 && ( + {!isTaskExpanded && (
e.stopPropagation()}>
- { - const availableSpace = contextWindow - (contextTokens || 0) - reservedForOutput + {contextWindowForDisplay !== undefined && ( + { + const availableSpace = + contextWindowForDisplay - (contextTokens || 0) - reservedForOutput - return ( - - - - - {t("chat:tokenProgress.tokensUsedLabel")} - - - {formatLargeNumber(contextTokens || 0)} /{" "} - {formatLargeNumber(contextWindow)} - - - {reservedForOutput > 0 && ( - - - {t("chat:tokenProgress.reservedForResponseLabel")} - - - {formatLargeNumber(reservedForOutput)} - - - )} - {availableSpace > 0 && ( + return ( +
+ - {t("chat:tokenProgress.availableSpaceLabel")} + {t("chat:tokenProgress.tokensUsedLabel")} - {formatLargeNumber(availableSpace)} + {formatLargeNumber(contextTokens || 0)} /{" "} + {formatLargeNumber(contextWindowForDisplay)} - )} - -
- ) - })()} - side="top" - sideOffset={8}> - - {(() => { - // Calculate percentage of available input space used - // Available input space = context window - reserved for output - const availableInputSpace = contextWindow - reservedForOutput - const percentage = - availableInputSpace > 0 - ? Math.round(((contextTokens || 0) / availableInputSpace) * 100) - : 0 - return ( - <> - - {percentage}% - + {reservedForOutput > 0 && ( + + + {t("chat:tokenProgress.reservedForResponseLabel")} + + + {formatLargeNumber(reservedForOutput)} + + + )} + {availableSpace > 0 && ( + + + {t("chat:tokenProgress.availableSpaceLabel")} + + + {formatLargeNumber(availableSpace)} + + + )} + + ) })()} - -
+ side="top" + sideOffset={8}> + + {(() => { + // Calculate percentage of available input space used + // Available input space = context window - reserved for output + const availableInputSpace = contextWindowForDisplay - reservedForOutput + const rawPercentage = + availableInputSpace > 0 + ? Math.round(((contextTokens || 0) / availableInputSpace) * 100) + : 0 + const percentage = Math.min(100, rawPercentage) + const isAtLimit = + availableInputSpace > 0 && (contextTokens || 0) >= availableInputSpace + return ( + <> + + + {percentage}% + + + ) + })()} + +
+ )} {!!totalCost && ( <> · @@ -307,11 +322,13 @@ const TaskHeader = ({
e.stopPropagation()}> - + {contextWindowForDisplay !== undefined && ( + + )} {condenseButton}
@@ -342,7 +359,7 @@ const TaskHeader = ({
- {contextWindow > 0 && ( + {contextWindowForDisplay !== undefined ? ( + ) : ( + + + )} diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 022186f40e..24a764d39b 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -329,5 +329,40 @@ describe("TaskHeader", () => { expect(screen.getByText("25%")).toBeInTheDocument() }) + + it("should clamp over-limit usage to 100% and mark it as an error", () => { + renderTaskHeader({ contextTokens: 1000 }) + + const percentage = screen.getByText("100%") + expect(percentage).toHaveClass("text-vscode-errorForeground") + expect(screen.queryByText("125%")).not.toBeInTheDocument() + }) + + it("should keep the condense action available when context metadata is unavailable", () => { + mockModelInfo = undefined + mockMaxOutputTokens = 0 + + renderTaskHeader() + + const condenseButton = screen + .getAllByRole("button") + .find((button) => button.querySelector("svg.lucide-list-chevrons-down-up")) + + expect(condenseButton).toBeDefined() + expect(screen.queryByText(/%$/)).not.toBeInTheDocument() + }) + + it("should not display context progress when the context window is infinite", () => { + mockModelInfo = { contextWindow: Number.POSITIVE_INFINITY, maxTokens: 200 } + + renderTaskHeader() + + // Positive assertion: the component rendered and the task text is visible. + expect(screen.getByText("Test task")).toBeInTheDocument() + + expect(screen.queryByTestId("context-tokens-count")).not.toBeInTheDocument() + expect(screen.queryByTestId("context-window-size")).not.toBeInTheDocument() + expect(screen.queryByText(/%$/)).not.toBeInTheDocument() + }) }) }) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 714b507774..831fb5f6cd 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -463,6 +463,7 @@ const ApiOptions = ({ setApiConfigurationField={setApiConfigurationField} routerModels={routerModels} selectedModelId={selectedModelId} + selectedModelInfo={selectedModelInfo} uriScheme={uriScheme} simplifySettings={fromWelcomeView} organizationAllowList={organizationAllowList} @@ -476,6 +477,7 @@ const ApiOptions = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} routerModels={routerModels} + selectedModelInfo={selectedModelInfo} refetchRouterModels={refetchRouterModels} organizationAllowList={organizationAllowList} modelValidationError={modelValidationError} @@ -488,6 +490,7 @@ const ApiOptions = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} routerModels={routerModels} + selectedModelInfo={selectedModelInfo} refetchRouterModels={refetchRouterModels} organizationAllowList={organizationAllowList} modelValidationError={modelValidationError} @@ -664,6 +667,7 @@ const ApiOptions = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} routerModels={routerModels} + selectedModelInfo={selectedModelInfo} organizationAllowList={organizationAllowList} modelValidationError={modelValidationError} simplifySettings={fromWelcomeView} @@ -708,6 +712,7 @@ const ApiOptions = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} routerModels={routerModels} + selectedModelInfo={selectedModelInfo} organizationAllowList={organizationAllowList} modelValidationError={modelValidationError} simplifySettings={fromWelcomeView} diff --git a/webview-ui/src/components/settings/CustomModelInfoSettings.tsx b/webview-ui/src/components/settings/CustomModelInfoSettings.tsx new file mode 100644 index 0000000000..b0347b592a --- /dev/null +++ b/webview-ui/src/components/settings/CustomModelInfoSettings.tsx @@ -0,0 +1,232 @@ +import { useEffect, useRef, useState } from "react" +import { VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import { + openAiModelInfoSaneDefaults, + toCustomModelInfo, + type CustomModelInfo, + type ModelInfo, + type ProviderSettings, +} from "@roo-code/types" + +import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } from "@src/components/ui" +import { useAppTranslation } from "@src/i18n/TranslationContext" + +type CustomModelInfoSettingsProps = { + apiConfiguration: ProviderSettings + setApiConfigurationField: (field: "customModelInfo", value: ProviderSettings["customModelInfo"]) => void + selectedModelInfo?: ModelInfo +} + +type ValueChangeEvent = { + target: EventTarget | null +} + +const parsePositiveInteger = (value: string): number | undefined => { + const normalized = value.trim() + + if (!/^\d+$/.test(normalized)) { + return undefined + } + + const parsed = Number(normalized) + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined +} + +export const CustomModelInfoSettings = ({ + apiConfiguration, + setApiConfigurationField, + selectedModelInfo, +}: CustomModelInfoSettingsProps) => { + const { t } = useAppTranslation() + const override = apiConfiguration.customModelInfo + const hasOverride = !!override + + // The stored value is a complete snapshot, so editing always starts from the + // discovered catalog entry (or the shared sane defaults when the model is + // unlisted). The editor never presents an empty field that silently resolves + // to something else at request time. + const seeded: CustomModelInfo = toCustomModelInfo(selectedModelInfo ?? openAiModelInfoSaneDefaults) + // `openAiModelInfoSaneDefaults.maxTokens` is the `-1` "provider decides" + // sentinel, which must not surface as a literal value in the editor. + const baseline: CustomModelInfo = { + ...seeded, + maxTokens: typeof seeded.maxTokens === "number" && seeded.maxTokens > 0 ? seeded.maxTokens : undefined, + } + const effective: CustomModelInfo = override ?? baseline + + const [isOpen, setIsOpen] = useState(!selectedModelInfo) + const [contextWindowInput, setContextWindowInput] = useState(effective.contextWindow.toString()) + const [maxTokensInput, setMaxTokensInput] = useState(effective.maxTokens?.toString() ?? "") + + // Mirror externally changed values into the inputs. Comparing parsed text + // against the stored value cannot distinguish "user typed invalid text" from + // "no value stored" (both parse to undefined), so track what each input was + // last synced from instead. + const syncedContextWindow = useRef(effective.contextWindow) + const syncedMaxTokens = useRef(effective.maxTokens) + + useEffect(() => { + if (syncedContextWindow.current !== effective.contextWindow) { + syncedContextWindow.current = effective.contextWindow + setContextWindowInput(effective.contextWindow.toString()) + } + }, [effective.contextWindow]) + + useEffect(() => { + if (syncedMaxTokens.current !== effective.maxTokens) { + syncedMaxTokens.current = effective.maxTokens + setMaxTokensInput(effective.maxTokens?.toString() ?? "") + } + }, [effective.maxTokens]) + + useEffect(() => { + if (!selectedModelInfo) { + setIsOpen(true) + } + }, [selectedModelInfo]) + + const commit = (patch: Partial) => + setApiConfigurationField("customModelInfo", { ...effective, ...patch }) + + const handleContextWindowInput = (event: ValueChangeEvent) => { + const target = event.target + const value = target && "value" in target && typeof target.value === "string" ? target.value : "" + setContextWindowInput(value) + + const parsed = parsePositiveInteger(value) + + // Context window is required for token accounting, so invalid or empty + // text is kept on screen without persisting a broken snapshot. + if (parsed !== undefined) { + syncedContextWindow.current = parsed + commit({ contextWindow: parsed }) + } + } + + const handleMaxTokensInput = (event: ValueChangeEvent) => { + const target = event.target + const value = target && "value" in target && typeof target.value === "string" ? target.value : "" + setMaxTokensInput(value) + + const parsed = parsePositiveInteger(value) + + // An empty field means "let the provider decide", which is a valid state. + if (parsed !== undefined || value.trim() === "") { + syncedMaxTokens.current = parsed + commit({ maxTokens: parsed }) + } + } + + const handleCapabilityChange = (field: "supportsImages" | "supportsPromptCache") => (event: ValueChangeEvent) => { + const target = event.target + const checked = target && "checked" in target && typeof target.checked === "boolean" ? target.checked : false + commit({ [field]: checked }) + } + + const resetOverrides = () => { + setContextWindowInput(baseline.contextWindow.toString()) + setMaxTokensInput(baseline.maxTokens?.toString() ?? "") + syncedContextWindow.current = baseline.contextWindow + syncedMaxTokens.current = baseline.maxTokens + setApiConfigurationField("customModelInfo", undefined) + } + + const contextWindowOverride = parsePositiveInteger(contextWindowInput) + const maxTokensOverride = parsePositiveInteger(maxTokensInput) + const hasInvalidContextWindow = contextWindowOverride === undefined + const hasInvalidMaxTokens = maxTokensInput.trim().length > 0 && maxTokensOverride === undefined + const hasInvalidRange = + contextWindowOverride !== undefined && + maxTokensOverride !== undefined && + maxTokensOverride > contextWindowOverride + + const borderFor = (invalid: boolean) => + invalid ? "var(--vscode-inputValidation-errorBorder)" : "var(--vscode-input-border)" + + return ( +
+ + + + {t("settings:providers.customModelInfo.title")} + + +

+ {selectedModelInfo + ? t("settings:providers.customModelInfo.description") + : t("settings:providers.customModelInfo.unresolved")} +

+ +
+
+ + + + {t("settings:providers.customModel.contextWindow.description")} + +
+ +
+ + + + {t("settings:providers.customModelInfo.maxTokens.description")} + +
+
+ + {hasInvalidRange && ( +

+ {t("settings:providers.customModelInfo.maxTokensWarning")} +

+ )} + +
+ + {t("settings:providers.customModel.imageSupport.label")} + + + {t("settings:providers.customModel.imageSupport.description")} + + + + {t("settings:providers.customModel.promptCache.label")} + + + {t("settings:providers.customModel.promptCache.description")} + +
+ + {hasOverride && ( + + )} +
+
+
+ ) +} diff --git a/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx new file mode 100644 index 0000000000..ae94d02008 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx @@ -0,0 +1,264 @@ +import { fireEvent, render, screen } from "@testing-library/react" + +import { openAiModelInfoSaneDefaults, providerIdentifiers, type ModelInfo, type ProviderSettings } from "@roo-code/types" + +import { CustomModelInfoSettings } from "../CustomModelInfoSettings" + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +const contextWindowLabel = "settings:providers.customModel.contextWindow.label" +const maxTokensLabel = "settings:providers.customModel.maxTokens.label" +const imagesLabel = "settings:providers.customModel.imageSupport.label" +const promptCacheLabel = "settings:providers.customModel.promptCache.label" + +describe("CustomModelInfoSettings", () => { + const modelInfo: ModelInfo = { + contextWindow: 128_000, + maxTokens: 16_384, + supportsImages: false, + supportsPromptCache: true, + inputPrice: 3, + outputPrice: 15, + } + + const open = () => fireEvent.click(screen.getByText("settings:providers.customModelInfo.title")) + + it("seeds the editor from the discovered model and omits provider pricing when saving", () => { + const setApiConfigurationField = vi.fn() + + render( + , + ) + + open() + + // Prefilled from the catalog entry rather than left blank. + expect(screen.getByLabelText(contextWindowLabel)).toHaveValue("128000") + expect(screen.getByLabelText(maxTokensLabel)).toHaveValue("16384") + + fireEvent.input(screen.getByLabelText(contextWindowLabel), { target: { value: "200000" } }) + + const saved = setApiConfigurationField.mock.calls.at(-1)?.[1] + expect(saved).toMatchObject({ contextWindow: 200_000, maxTokens: 16_384, supportsPromptCache: true }) + // Pricing stays provider-owned so a stored snapshot cannot go stale. + expect(saved && "inputPrice" in saved).toBe(false) + expect(saved && "outputPrice" in saved).toBe(false) + }) + + it("falls back to shared defaults when the model is not in the catalog", () => { + render( + , + ) + + // The panel auto-opens for an unresolved model. + expect(screen.getByText("settings:providers.customModelInfo.unresolved")).toBeInTheDocument() + expect(screen.getByLabelText(contextWindowLabel)).toHaveValue( + openAiModelInfoSaneDefaults.contextWindow.toString(), + ) + + // `openAiModelInfoSaneDefaults.maxTokens` is the -1 "provider decides" + // sentinel; showing it verbatim would render a permanently invalid field. + expect(screen.getByLabelText(maxTokensLabel)).toHaveValue("") + expect(screen.getByLabelText(maxTokensLabel)).toHaveAttribute("aria-invalid", "false") + }) + + it("keeps invalid context window text visible without persisting it", () => { + const setApiConfigurationField = vi.fn() + + render( + , + ) + + open() + + const contextWindowInput = screen.getByLabelText(contextWindowLabel) + fireEvent.input(contextWindowInput, { target: { value: "12abc" } }) + + expect(contextWindowInput).toHaveValue("12abc") + expect(contextWindowInput).toHaveAttribute("aria-invalid", "true") + // A context window is required for token accounting, so nothing is stored. + expect(setApiConfigurationField).not.toHaveBeenCalled() + }) + + it("treats an empty max output tokens field as provider-decided", () => { + const setApiConfigurationField = vi.fn() + + render( + , + ) + + open() + + fireEvent.input(screen.getByLabelText(maxTokensLabel), { target: { value: "" } }) + + expect(setApiConfigurationField.mock.calls.at(-1)?.[1]).toMatchObject({ + contextWindow: 64_000, + maxTokens: undefined, + }) + }) + + it("accumulates capability toggles on top of the stored snapshot", () => { + const setApiConfigurationField = vi.fn() + const baseConfig: ProviderSettings = { + apiProvider: providerIdentifiers.unbound, + customModelInfo: { contextWindow: 1_000, maxTokens: 2_000, supportsPromptCache: false }, + } + + const { rerender } = render( + , + ) + + open() + + // maxTokens above the context window is surfaced to the user. + expect(screen.getByText("settings:providers.customModelInfo.maxTokensWarning")).toBeInTheDocument() + + fireEvent.click(screen.getByText(imagesLabel)) + expect(setApiConfigurationField).toHaveBeenLastCalledWith("customModelInfo", { + contextWindow: 1_000, + maxTokens: 2_000, + supportsImages: true, + supportsPromptCache: false, + }) + + rerender( + , + ) + + fireEvent.click(screen.getByText(promptCacheLabel)) + expect(setApiConfigurationField).toHaveBeenLastCalledWith("customModelInfo", { + contextWindow: 1_000, + maxTokens: 2_000, + supportsImages: true, + supportsPromptCache: true, + }) + }) + + it("reverts the inputs to the discovered values when the override is cleared", () => { + const setApiConfigurationField = vi.fn() + + const { rerender } = render( + , + ) + + open() + expect(screen.getByLabelText(contextWindowLabel)).toHaveValue("64000") + + fireEvent.click(screen.getByText("settings:providers.customModel.resetDefaults")) + expect(setApiConfigurationField).toHaveBeenLastCalledWith("customModelInfo", undefined) + + rerender( + , + ) + + expect(screen.getByLabelText(contextWindowLabel)).toHaveValue("128000") + expect(screen.getByLabelText(maxTokensLabel)).toHaveValue("16384") + }) + + it("mirrors an externally updated snapshot into the inputs", () => { + const setApiConfigurationField = vi.fn() + + const { rerender } = render( + , + ) + + open() + + rerender( + , + ) + + expect(screen.getByLabelText(contextWindowLabel)).toHaveValue("32000") + expect(screen.getByLabelText(maxTokensLabel)).toHaveValue("200") + }) + + it("resets half-typed invalid text when switching to a profile without overrides", () => { + const setApiConfigurationField = vi.fn() + + const { rerender } = render( + , + ) + + open() + + const contextWindowInput = screen.getByLabelText(contextWindowLabel) + fireEvent.input(contextWindowInput, { target: { value: "12abc" } }) + expect(contextWindowInput).toHaveValue("12abc") + + // Invalid text and an absent override both parse to undefined, so the sync + // must key off the stored value rather than the parsed input. + rerender( + , + ) + + expect(contextWindowInput).toHaveValue("128000") + expect(contextWindowInput).toHaveAttribute("aria-invalid", "false") + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.visual.fixture.tsx b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.visual.fixture.tsx new file mode 100644 index 0000000000..85b0e60431 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.visual.fixture.tsx @@ -0,0 +1,101 @@ +/* v8 ignore file -- Playwright component fixture; covered by the visual test. */ +import React from "react" + +import { providerIdentifiers } from "@roo-code/types" +import type { ModelInfo } from "@roo-code/types/model" + +import { TranslationContext } from "@src/i18n/TranslationContext" +import { CustomModelInfoSettings } from "../CustomModelInfoSettings" + +const selectedModelInfo: ModelInfo = { + contextWindow: 200_000, + maxTokens: 64_000, + supportsImages: true, + supportsPromptCache: true, +} + +// Mirrors the English strings the panel actually renders: the terminology keys +// are shared with the OpenAI-compatible custom-model editor, so only the +// panel-specific copy lives under `customModelInfo`. +const translations: Record = { + "settings:providers.customModelInfo.title": "Custom model metadata", + "settings:providers.customModelInfo.description": + "Override context and capability metadata when the provider cannot detect your model accurately.", + "settings:providers.customModelInfo.unresolved": + "This model is not in the provider catalog. The values below start from safe defaults — adjust them to match the model.", + "settings:providers.customModelInfo.maxTokens.description": + "Maximum number of tokens the model can generate in one response.", + "settings:providers.customModelInfo.maxTokensWarning": "Max output tokens exceed the context window.", + "settings:providers.customModel.contextWindow.label": "Context Window Size", + "settings:providers.customModel.contextWindow.description": "Total tokens (input + output) the model can process.", + "settings:providers.customModel.maxTokens.label": "Max Output Tokens", + "settings:providers.customModel.imageSupport.label": "Image Support", + "settings:providers.customModel.imageSupport.description": + "Is this model capable of processing and understanding images?", + "settings:providers.customModel.promptCache.label": "Prompt Caching", + "settings:providers.customModel.promptCache.description": "Is this model capable of caching prompts?", + "settings:providers.customModel.resetDefaults": "Reset to Defaults", +} + +const translationValue = { + t: (key: string) => translations[key] ?? key, + i18n: null as unknown as typeof import("../../../i18n/setup").default, +} + +/** Collapsed panel — the default when selectedModelInfo is present. */ +export const CollapsedFixture = () => ( + +
+ {}} + selectedModelInfo={selectedModelInfo} + /> +
+
+) + +/** Expanded panel with populated overrides. */ +export const ExpandedWithOverridesFixture = () => ( + +
+ {}} + selectedModelInfo={selectedModelInfo} + /> +
+
+) + +/** Expanded panel with maxTokens > contextWindow warning. */ +export const WarningFixture = () => ( + +
+ {}} + selectedModelInfo={selectedModelInfo} + /> +
+
+) + +/** Expanded panel with no selectedModelInfo (unresolved model — auto-opens). */ +export const UnresolvedFixture = () => ( + +
+ {}} + selectedModelInfo={undefined} + /> +
+
+) diff --git a/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.visual.tsx b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.visual.tsx new file mode 100644 index 0000000000..899ace42a2 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.visual.tsx @@ -0,0 +1,51 @@ +import { expect, test } from "../../../../playwright/coverage-fixture" +import { mountedStory } from "../../../../playwright/mounted-story" + +test("renders the collapsed panel in the VS Code dark theme", async ({ mount }) => { + const component = mountedStory(await mount("custom-model-info-collapsed")) + + await component.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + + await expect(component).toHaveScreenshot("custom-model-info-collapsed-dark.png") +}) + +test("renders the expanded panel with overrides in the VS Code dark theme", async ({ mount }) => { + const component = mountedStory(await mount("custom-model-info-expanded-overrides")) + + await component.getByText("Custom model metadata").click() + + await component.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + + await expect(component).toHaveScreenshot("custom-model-info-expanded-overrides-dark.png") +}) + +test("renders the maxTokens exceeds contextWindow warning in the VS Code dark theme", async ({ mount }) => { + const component = mountedStory(await mount("custom-model-info-warning")) + + await component.getByText("Custom model metadata").click() + + await component.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + + await expect(component).toHaveScreenshot("custom-model-info-warning-dark.png") +}) + +test("renders the unresolved model state in the VS Code dark theme", async ({ mount }) => { + // When selectedModelInfo is undefined the panel auto-opens. + const component = mountedStory(await mount("custom-model-info-unresolved")) + + await component.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + + await expect(component).toHaveScreenshot("custom-model-info-unresolved-dark.png") +}) diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-collapsed-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-collapsed-dark.png new file mode 100644 index 0000000000..280b79ade2 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-collapsed-dark.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-expanded-overrides-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-expanded-overrides-dark.png new file mode 100644 index 0000000000..256b2ebe35 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-expanded-overrides-dark.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-unresolved-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-unresolved-dark.png new file mode 100644 index 0000000000..81cb18aaec Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-unresolved-dark.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-warning-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-warning-dark.png new file mode 100644 index 0000000000..1c2abdb0e0 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-warning-dark.png differ diff --git a/webview-ui/src/components/settings/providers/OpenRouter.tsx b/webview-ui/src/components/settings/providers/OpenRouter.tsx index 2dba8c8459..8e7402f2be 100644 --- a/webview-ui/src/components/settings/providers/OpenRouter.tsx +++ b/webview-ui/src/components/settings/providers/OpenRouter.tsx @@ -4,6 +4,7 @@ import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { type ProviderSettings, + type ModelInfo, type OrganizationAllowList, type RouterModels, openRouterDefaultModelId, @@ -16,6 +17,7 @@ import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" import { inputEventTransform } from "../transforms" import { ModelPicker } from "../ModelPicker" +import { CustomModelInfoSettings } from "../CustomModelInfoSettings" import { OpenRouterBalanceDisplay } from "./OpenRouterBalanceDisplay" type OpenRouterProps = { @@ -23,6 +25,7 @@ type OpenRouterProps = { setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void routerModels?: RouterModels selectedModelId: string + selectedModelInfo?: ModelInfo uriScheme: string | undefined simplifySettings?: boolean organizationAllowList: OrganizationAllowList @@ -37,6 +40,7 @@ export const OpenRouter = ({ simplifySettings, organizationAllowList, modelValidationError, + selectedModelInfo, }: OpenRouterProps) => { const { t } = useAppTranslation() @@ -115,6 +119,11 @@ export const OpenRouter = ({ errorMessage={modelValidationError} simplifySettings={simplifySettings} /> + ) } diff --git a/webview-ui/src/components/settings/providers/Requesty.tsx b/webview-ui/src/components/settings/providers/Requesty.tsx index 4149dbc2c8..df86c32a41 100644 --- a/webview-ui/src/components/settings/providers/Requesty.tsx +++ b/webview-ui/src/components/settings/providers/Requesty.tsx @@ -3,6 +3,7 @@ import { VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/reac import { type ProviderSettings, + type ModelInfo, type OrganizationAllowList, type RouterModels, requestyDefaultModelId, @@ -16,6 +17,7 @@ import { Button } from "@src/components/ui" import { inputEventTransform } from "../transforms" import { ModelPicker } from "../ModelPicker" +import { CustomModelInfoSettings } from "../CustomModelInfoSettings" import { RequestyBalanceDisplay } from "./RequestyBalanceDisplay" import { getCallbackUrl } from "@/oauth/urls" import { toRequestyServiceUrl } from "@roo/utils/requesty" @@ -24,6 +26,7 @@ type RequestyProps = { apiConfiguration: ProviderSettings setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void routerModels?: RouterModels + selectedModelInfo?: ModelInfo refetchRouterModels: () => void organizationAllowList: OrganizationAllowList modelValidationError?: string @@ -39,6 +42,7 @@ export const Requesty = ({ modelValidationError, uriScheme, simplifySettings, + selectedModelInfo, }: RequestyProps) => { const { t } = useAppTranslation() @@ -153,6 +157,11 @@ export const Requesty = ({ errorMessage={modelValidationError} simplifySettings={simplifySettings} /> + ) } diff --git a/webview-ui/src/components/settings/providers/Unbound.tsx b/webview-ui/src/components/settings/providers/Unbound.tsx index b3211f0f8f..9528c0f67a 100644 --- a/webview-ui/src/components/settings/providers/Unbound.tsx +++ b/webview-ui/src/components/settings/providers/Unbound.tsx @@ -3,6 +3,7 @@ import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { type ProviderSettings, + type ModelInfo, type OrganizationAllowList, type RouterModels, unboundDefaultModelId, @@ -16,11 +17,13 @@ import { Button } from "@src/components/ui" import { inputEventTransform } from "../transforms" import { ModelPicker } from "../ModelPicker" +import { CustomModelInfoSettings } from "../CustomModelInfoSettings" type UnboundProps = { apiConfiguration: ProviderSettings setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void routerModels?: RouterModels + selectedModelInfo?: ModelInfo refetchRouterModels: () => void organizationAllowList: OrganizationAllowList modelValidationError?: string @@ -34,6 +37,7 @@ export const Unbound = ({ organizationAllowList, modelValidationError, simplifySettings, + selectedModelInfo, }: UnboundProps) => { const { t } = useAppTranslation() @@ -101,6 +105,11 @@ export const Unbound = ({ errorMessage={modelValidationError} simplifySettings={simplifySettings} /> + ) } diff --git a/webview-ui/src/components/settings/providers/VercelAiGateway.tsx b/webview-ui/src/components/settings/providers/VercelAiGateway.tsx index 0951f39c57..71d1515a42 100644 --- a/webview-ui/src/components/settings/providers/VercelAiGateway.tsx +++ b/webview-ui/src/components/settings/providers/VercelAiGateway.tsx @@ -3,6 +3,7 @@ import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import { type ProviderSettings, + type ModelInfo, type OrganizationAllowList, type RouterModels, vercelAiGatewayDefaultModelId, @@ -14,11 +15,13 @@ import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" import { inputEventTransform } from "../transforms" import { ModelPicker } from "../ModelPicker" +import { CustomModelInfoSettings } from "../CustomModelInfoSettings" type VercelAiGatewayProps = { apiConfiguration: ProviderSettings setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void routerModels?: RouterModels + selectedModelInfo?: ModelInfo organizationAllowList: OrganizationAllowList modelValidationError?: string simplifySettings?: boolean @@ -31,6 +34,7 @@ export const VercelAiGateway = ({ organizationAllowList, modelValidationError, simplifySettings, + selectedModelInfo, }: VercelAiGatewayProps) => { const { t } = useAppTranslation() @@ -78,6 +82,11 @@ export const VercelAiGateway = ({ errorMessage={modelValidationError} simplifySettings={simplifySettings} /> + ) } diff --git a/webview-ui/src/components/settings/providers/ZooGateway.tsx b/webview-ui/src/components/settings/providers/ZooGateway.tsx index bd2b35274c..ab5d6cd5df 100644 --- a/webview-ui/src/components/settings/providers/ZooGateway.tsx +++ b/webview-ui/src/components/settings/providers/ZooGateway.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo } from "react" import { type ProviderSettings, + type ModelInfo, type OrganizationAllowList, type RouterModels, zooGatewayDefaultModelId, @@ -13,12 +14,14 @@ import { useAppTranslation } from "@src/i18n/TranslationContext" import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" import { ModelPicker } from "../ModelPicker" +import { CustomModelInfoSettings } from "../CustomModelInfoSettings" import { ApiErrorMessage } from "../ApiErrorMessage" type ZooGatewayProps = { apiConfiguration: ProviderSettings setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void routerModels?: RouterModels + selectedModelInfo?: ModelInfo organizationAllowList: OrganizationAllowList modelValidationError?: string simplifySettings?: boolean @@ -56,6 +59,7 @@ export const ZooGateway = ({ organizationAllowList, modelValidationError, simplifySettings, + selectedModelInfo, }: ZooGatewayProps) => { const { t } = useAppTranslation() const { zooCodeIsAuthenticated, zooCodeUserEmail, zooCodeUserName, zooCodeBaseUrl, uriScheme, deviceName } = @@ -68,13 +72,17 @@ export const ZooGateway = ({ const modelIds = useMemo(() => Object.keys(zooModels), [zooModels]) const resolvedDefaultModelId = useMemo(() => pickZooGatewayDefaultModelId(modelIds), [modelIds]) + // Auto-select the default model only when no model is configured yet. + // We intentionally do NOT reset the selection when the configured model + // is absent from the catalog — router providers accept arbitrary model + // IDs (e.g. custom deployments) that may not appear in the fetched list. useEffect(() => { if (modelIds.length === 0) { return } const current = apiConfiguration.zooGatewayModelId - if (!current || !modelIds.includes(current)) { + if (!current) { setApiConfigurationField("zooGatewayModelId", resolvedDefaultModelId) } }, [apiConfiguration.zooGatewayModelId, modelIds, resolvedDefaultModelId, setApiConfigurationField]) @@ -121,6 +129,11 @@ export const ZooGateway = ({ errorMessage={modelValidationError} simplifySettings={simplifySettings} /> + ) } diff --git a/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx index e355baa08c..beaabe0cb4 100644 --- a/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/ProviderRouting.spec.tsx @@ -15,10 +15,14 @@ vi.mock("@src/i18n/TranslationContext", () => ({ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ VSCodeTextField: ({ children }: { children: React.ReactNode }) =>
{children}
, + VSCodeCheckbox: ({ children }: { children: React.ReactNode }) =>
{children}
, })) vi.mock("@src/components/ui", () => ({ Button: ({ children, onClick }: React.ComponentProps<"button">) => , + Collapsible: ({ children }: { children: React.ReactNode }) =>
{children}
, + CollapsibleTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + CollapsibleContent: ({ children }: { children: React.ReactNode }) =>
{children}
, })) vi.mock("../../ModelPicker", () => ({ ModelPicker: modelPickerMock })) diff --git a/webview-ui/src/components/settings/providers/__tests__/Requesty.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Requesty.spec.tsx index bb7d4e234f..6f9e8ce27c 100644 --- a/webview-ui/src/components/settings/providers/__tests__/Requesty.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/Requesty.spec.tsx @@ -16,11 +16,10 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ })) vi.mock("@src/components/ui", () => ({ - Button: ({ children, onClick }: React.ComponentProps<"button">) => ( - - ), + Button: ({ children, onClick }: React.ComponentProps<"button">) => , + Collapsible: ({ children }: { children: React.ReactNode }) =>
{children}
, + CollapsibleTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + CollapsibleContent: ({ children }: { children: React.ReactNode }) =>
{children}
, })) vi.mock("../../ModelPicker", () => ({ ModelPicker: () => null })) @@ -48,7 +47,7 @@ describe("Requesty", () => { expect(callbackUrl).not.toBeNull() expect(new URL(callbackUrl!).pathname).toMatch(new RegExp(`/${providerIdentifiers.requesty}$`)) - fireEvent.click(screen.getByTestId("refresh-button")) + fireEvent.click(screen.getByRole("button", { name: "settings:providers.refreshModels.label" })) expect(postMessage).toHaveBeenCalledWith({ type: "requestRouterModels", values: { provider: providerIdentifiers.requesty, refresh: true }, diff --git a/webview-ui/src/components/settings/providers/__tests__/ZooGateway.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/ZooGateway.spec.tsx index 4f063b57ba..1f9b78ec45 100644 --- a/webview-ui/src/components/settings/providers/__tests__/ZooGateway.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/ZooGateway.spec.tsx @@ -107,7 +107,7 @@ describe("ZooGateway component", () => { }) }) - it("reassigns a stale model id that is not in the catalog", async () => { + it("preserves a configured model id that is not in the catalog", async () => { const setApiConfigurationField = vi.fn() render( { />, ) + // Verify the component rendered (not a crash) by checking the model picker is present. + expect(screen.getByTestId("model-picker")).toBeInTheDocument() + await waitFor(() => { - expect(setApiConfigurationField).toHaveBeenCalledWith( - "zooGatewayModelId", - "anthropic.claude-sonnet-4-5-20250929-v1:0", - ) + expect(setApiConfigurationField).not.toHaveBeenCalled() }) }) @@ -150,6 +150,9 @@ describe("ZooGateway component", () => { />, ) + // Verify the component actually rendered — prevents false positive on crash. + expect(screen.getByTestId("model-picker")).toBeInTheDocument() + await waitFor(() => { expect(setApiConfigurationField).not.toHaveBeenCalled() }) 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 64d3067092..ad3cd62b35 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -1,15 +1,16 @@ // npx vitest src/components/ui/hooks/__tests__/useSelectedModel.spec.ts import React from "react" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { QueryClient, QueryClientProvider, type UseQueryResult } from "@tanstack/react-query" import { renderHook } from "@testing-library/react" import type { Mock } from "vitest" import { ProviderSettings, ModelInfo, - type ModelRecord, type RouterModels, + type ProviderName, + type ModelRecord, anthropicModels, BEDROCK_1M_CONTEXT_MODEL_IDS, litellmDefaultModelInfo, @@ -59,28 +60,203 @@ const mockUseOllamaModels = useOllamaModels as Mock type TestRouterModels = Partial> type QueryResultOptions = { isLoading?: boolean; isError?: boolean } -const createQueryResult = (data: TData, options: QueryResultOptions = {}) => ({ - data, - isLoading: options.isLoading ?? false, - isError: options.isError ?? false, -}) +const createQueryResult = ( + data: TData | undefined, + fallbackData: TData, + options: QueryResultOptions | boolean = {}, +): UseQueryResult => { + const isLoading = typeof options === "boolean" ? options : (options.isLoading ?? false) + const isError = typeof options === "boolean" ? false : (options.isError ?? false) + + if (isLoading) { + return { + data: undefined, + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isError: false, + isFetched: false, + isFetchedAfterMount: false, + isFetching: true, + isLoading: true, + isPending: true, + isLoadingError: false, + isInitialLoading: true, + isPaused: false, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: false, + isSuccess: false, + isEnabled: true, + refetch: vi.fn(), + status: "pending", + fetchStatus: "fetching", + promise: Promise.resolve(fallbackData), + } + } + + if (isError) { + const error = new Error("Test query error") + + return { + data: data ?? fallbackData, + dataUpdatedAt: 0, + error, + errorUpdatedAt: 0, + failureCount: 1, + failureReason: error, + errorUpdateCount: 1, + isError: true, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isLoading: false, + isPending: false, + isLoadingError: false, + isInitialLoading: false, + isPaused: false, + isPlaceholderData: false, + isRefetchError: true, + isRefetching: false, + isStale: true, + isSuccess: false, + isEnabled: true, + refetch: vi.fn(), + status: "error", + fetchStatus: "idle", + promise: Promise.resolve(data ?? fallbackData), + } + } + + return { + data: data ?? fallbackData, + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isError: false, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isLoading: false, + isPending: false, + isLoadingError: false, + isInitialLoading: false, + isPaused: false, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: false, + isSuccess: true, + isEnabled: true, + refetch: vi.fn(), + status: "success", + fetchStatus: "idle", + promise: Promise.resolve(data ?? fallbackData), + } +} // React Query exposes a discriminated union with additional runtime fields; these tests only need the stable query state fields. const createRouterModelsResult = ( data: TestRouterModels | undefined, options?: QueryResultOptions, -): ReturnType => createQueryResult(data, options) as ReturnType +): ReturnType => createQueryResult(data, {}, options) as ReturnType const createOpenRouterModelProvidersResult = ( data: Record | undefined, options?: QueryResultOptions, ): ReturnType => - createQueryResult(data, options) as ReturnType + createQueryResult(data, {}, options) as ReturnType const createLocalModelsResult = ( data: ModelRecord | undefined, options?: QueryResultOptions, -): ReturnType => createQueryResult(data, options) as ReturnType +): ReturnType => createQueryResult(data, {}, options) as ReturnType + +type OpenRouterModelProviders = NonNullable["data"]> + +const emptyRouterModels: RouterModels = { + openrouter: {}, + "vercel-ai-gateway": {}, + "zoo-gateway": {}, + litellm: {}, + requesty: {}, + unbound: {}, + poe: {}, + deepseek: {}, + moonshot: {}, + "opencode-go": {}, + kenari: {}, + "kimi-code": {}, + nanogpt: {}, + ollama: {}, + lmstudio: {}, +} + +const routerProviderCases = [ + { + provider: providerIdentifiers.openrouter, + modelKey: "openrouter", + modelId: "openrouter/future-model", + settings: { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openrouter/future-model", + }, + }, + { + provider: providerIdentifiers.requesty, + modelKey: "requesty", + modelId: "requesty/future-model", + settings: { + apiProvider: providerIdentifiers.requesty, + requestyModelId: "requesty/future-model", + }, + }, + { + provider: providerIdentifiers.unbound, + modelKey: "unbound", + modelId: "unbound/future-model", + settings: { + apiProvider: providerIdentifiers.unbound, + unboundModelId: "unbound/future-model", + }, + }, + { + provider: providerIdentifiers.vercelAiGateway, + modelKey: "vercel-ai-gateway", + modelId: "vercel/future-model", + settings: { + apiProvider: providerIdentifiers.vercelAiGateway, + vercelAiGatewayModelId: "vercel/future-model", + }, + }, + { + provider: providerIdentifiers.zooGateway, + modelKey: "zoo-gateway", + modelId: "zoo/future-model", + settings: { + apiProvider: providerIdentifiers.zooGateway, + zooGatewayModelId: "zoo/future-model", + }, + }, +] as const satisfies ReadonlyArray<{ + provider: ProviderName + modelKey: keyof RouterModels + modelId: string + settings: ProviderSettings +}> + +const createRouterModels = (modelKey: keyof RouterModels, modelId: string, info?: ModelInfo): RouterModels => { + const models = { ...emptyRouterModels } + models[modelKey] = info ? { [modelId]: info } : {} + return models +} const createWrapper = () => { const queryClient = new QueryClient({ @@ -361,7 +537,7 @@ describe("useSelectedModel", () => { }) }) - it("should fall back to default when configured model doesn't exist in available models", () => { + it("should preserve a configured model when it is absent from available models", () => { const specificProviderInfo: ModelInfo = { maxTokens: 8192, contextWindow: 16384, @@ -410,22 +586,8 @@ describe("useSelectedModel", () => { const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - // Should fall back to provider default since "test-model" doesn't exist - expect(result.current.id).toBe("anthropic/claude-sonnet-4.5") - // Should still use specific provider info for the default model if specified - expect(result.current.info).toEqual({ - ...{ - maxTokens: 8192, - contextWindow: 200_000, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 3.0, - outputPrice: 15.0, - cacheWritesPrice: 3.75, - cacheReadsPrice: 0.3, - }, - ...specificProviderInfo, - }) + expect(result.current.id).toBe("test-model") + expect(result.current.info).toEqual(specificProviderInfo) }) it("should demonstrate the merging behavior validates the comment about missing fields", () => { @@ -528,7 +690,7 @@ describe("useSelectedModel", () => { expect(result.current.info).toEqual(baseModelInfo) }) - it("should fall back to default when configured model and provider don't exist", () => { + it("should preserve an unknown configured model when its provider metadata is unavailable", () => { mockUseRouterModels.mockReturnValue({ data: { openrouter: { @@ -566,23 +728,111 @@ describe("useSelectedModel", () => { const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - // Should fall back to provider default since "non-existent-model" doesn't exist - expect(result.current.id).toBe("anthropic/claude-sonnet-4.5") - // Should use base model info since provider doesn't exist - expect(result.current.info).toEqual({ - maxTokens: 8192, - contextWindow: 200_000, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 3.0, - outputPrice: 15.0, - cacheWritesPrice: 3.75, - cacheReadsPrice: 0.3, - }) + expect(result.current.id).toBe("non-existent-model") + expect(result.current.info).toBeUndefined() }) }) describe("loading and error states", () => { + it.each(routerProviderCases)( + "preserves the configured %s model ID and applies custom metadata while data is loading", + ({ provider, modelId, settings }) => { + mockUseRouterModels.mockReturnValue(createQueryResult(undefined, emptyRouterModels, true)) + mockUseOpenRouterModelProviders.mockReturnValue( + createQueryResult({}, {}, false), + ) + + const apiConfiguration: ProviderSettings = { + ...settings, + customModelInfo: { + supportsPromptCache: false, + contextWindow: 100_000, + maxTokens: 10_000, + supportsImages: true, + }, + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.provider).toBe(provider) + expect(result.current.id).toBe(modelId) + expect(result.current.info).toMatchObject({ + contextWindow: 100_000, + maxTokens: 10_000, + supportsImages: true, + }) + }, + ) + + it.each(routerProviderCases)( + "applies custom metadata to a listed %s model", + ({ provider, modelKey, modelId, settings }) => { + const discoveredInfo: ModelInfo = { + contextWindow: 8192, + maxTokens: 4096, + supportsImages: false, + supportsPromptCache: false, + } + + mockUseRouterModels.mockReturnValue( + createQueryResult(createRouterModels(modelKey, modelId, discoveredInfo), emptyRouterModels, false), + ) + mockUseOpenRouterModelProviders.mockReturnValue( + createQueryResult({}, {}, false), + ) + + const apiConfiguration: ProviderSettings = { + ...settings, + customModelInfo: { + supportsPromptCache: false, + contextWindow: 100_000, + maxTokens: 10_000, + supportsImages: true, + }, + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.provider).toBe(provider) + expect(result.current.id).toBe(modelId) + expect(result.current.info).toMatchObject({ + contextWindow: 100_000, + maxTokens: 10_000, + supportsImages: true, + supportsPromptCache: false, + }) + }, + ) + + it("preserves a router model ID and applies custom metadata while model data is loading", () => { + mockUseRouterModels.mockReturnValue(createQueryResult(undefined, emptyRouterModels, true)) + + mockUseOpenRouterModelProviders.mockReturnValue(createQueryResult({}, {}, false)) + + const apiConfiguration: ProviderSettings = { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "provider/future-model", + customModelInfo: { + contextWindow: 100_000, + maxTokens: 10_000, + supportsImages: true, + supportsPromptCache: false, + }, + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.id).toBe("provider/future-model") + expect(result.current.info).toMatchObject({ + contextWindow: 100_000, + maxTokens: 10_000, + supportsImages: true, + }) + }) + it("should set loading when router models are loading for the default OpenRouter provider", () => { mockUseRouterModels.mockReturnValue({ data: undefined, @@ -1428,19 +1678,12 @@ describe("useSelectedModel", () => { }) describe("Kimi Code provider", () => { - it("should use the Kimi Code default while router models are loading and no model is configured", () => { - mockUseRouterModels.mockReturnValue(createRouterModelsResult(undefined, { isLoading: true })) - - const apiConfiguration: ProviderSettings = { - apiProvider: providerIdentifiers.kimiCode, - } - - const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper: createWrapper() }) - - expect(result.current.provider).toBe(providerIdentifiers.kimiCode) - expect(result.current.id).toBe("kimi-for-coding") - expect(result.current.info).toEqual(kimiCodeDefaultModelInfo) - expect(result.current.isLoading).toBe(true) + beforeEach(() => { + mockUseOpenRouterModelProviders.mockReturnValue({ + data: {}, + isLoading: false, + isError: false, + } as any) }) it("should resolve the configured model from router models", () => { diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 8d4b70ad4a..63dbb45da1 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -24,6 +24,7 @@ import { zaiApiLineConfigs, fireworksModels, friendliModels, + applyCustomModelInfo, basetenModels, qwenCodeModels, kimiCodeDefaultModelInfo, @@ -35,6 +36,7 @@ import { BEDROCK_1M_CONTEXT_MODEL_IDS, VERTEX_1M_CONTEXT_MODEL_IDS, isDynamicProvider, + isCustomModelInfoProvider, isRetiredProvider, getProviderDefaultModelId, providerIdentifiers, @@ -46,17 +48,40 @@ import { useLmStudioModels } from "./useLmStudioModels" import { useOllamaModels } from "./useOllamaModels" /** - * Helper to get a validated model ID for dynamic providers. - * Returns the configured model ID if it exists in the available models, otherwise returns the default. + * Helper to get a model ID for dynamic providers. + * Some router providers accept arbitrary model IDs, so their configured value + * must survive both an empty list and a list that does not contain the ID. */ function getValidatedModelId( configuredId: string | undefined, availableModels: ModelRecord | undefined, defaultModelId: string, + preserveConfiguredId = false, ): string { + if (preserveConfiguredId && configuredId) { + return configuredId + } + return configuredId && availableModels?.[configuredId] ? configuredId : defaultModelId } +function getConfiguredRouterModelId(provider: ProviderName, apiConfiguration: ProviderSettings): string | undefined { + switch (provider) { + case providerIdentifiers.openrouter: + return apiConfiguration.openRouterModelId + case providerIdentifiers.requesty: + return apiConfiguration.requestyModelId + case providerIdentifiers.unbound: + return apiConfiguration.unboundModelId + case providerIdentifiers.vercelAiGateway: + return apiConfiguration.vercelAiGatewayModelId + case providerIdentifiers.zooGateway: + return apiConfiguration.zooGatewayModelId + default: + return undefined + } +} + /** * Resolves the model currently selected for the active API provider. * @@ -116,7 +141,7 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { hasValidRouterData && (!needOpenRouterProviders || typeof openRouterModelProviders.data !== "undefined") - const { id, info } = + const selectedModel = apiConfiguration && isReady && activeProvider ? getSelectedModel({ provider: activeProvider, @@ -131,7 +156,22 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { id: apiConfiguration.apiModelId || getProviderDefaultModelId(providerIdentifiers.kimiCode), info: kimiCodeDefaultModelInfo, } - : { id: getProviderDefaultModelId(activeProvider ?? providerIdentifiers.openrouter), info: undefined } + : { + id: + (activeProvider && + apiConfiguration && + getConfiguredRouterModelId(activeProvider, apiConfiguration)) || + getProviderDefaultModelId(activeProvider ?? providerIdentifiers.openrouter), + info: undefined, + } + + const { id } = selectedModel + // Mirrors the extension-side resolution so the settings UI and the outgoing + // request never disagree about the effective metadata. + const info = + activeProvider && isCustomModelInfoProvider(activeProvider) + ? (applyCustomModelInfo(selectedModel.info, apiConfiguration) ?? selectedModel.info) + : selectedModel.info return { provider, @@ -173,10 +213,11 @@ function getSelectedModel({ case providerIdentifiers.openrouter: { const id = getValidatedModelId( apiConfiguration.openRouterModelId, - routerModels[providerIdentifiers.openrouter], + routerModels.openrouter, defaultModelId, + true, ) - let info = routerModels[providerIdentifiers.openrouter]?.[id] + let info = routerModels.openrouter?.[id] const specificProvider = apiConfiguration.openRouterSpecificProvider if (specificProvider && openRouterModelProviders[specificProvider]) { @@ -193,19 +234,16 @@ function getSelectedModel({ case providerIdentifiers.requesty: { const id = getValidatedModelId( apiConfiguration.requestyModelId, - routerModels[providerIdentifiers.requesty], + routerModels.requesty, defaultModelId, + true, ) - const routerInfo = routerModels[providerIdentifiers.requesty]?.[id] + const routerInfo = routerModels.requesty?.[id] return { id, info: routerInfo } } case providerIdentifiers.unbound: { - const id = getValidatedModelId( - apiConfiguration.unboundModelId, - routerModels[providerIdentifiers.unbound], - defaultModelId, - ) - const routerInfo = routerModels[providerIdentifiers.unbound]?.[id] + const id = getValidatedModelId(apiConfiguration.unboundModelId, routerModels.unbound, defaultModelId, true) + const routerInfo = routerModels.unbound?.[id] return { id, info: routerInfo } } case providerIdentifiers.litellm: { @@ -425,6 +463,7 @@ function getSelectedModel({ apiConfiguration.vercelAiGatewayModelId, routerModels[providerIdentifiers.vercelAiGateway], defaultModelId, + true, ) const info = routerModels[providerIdentifiers.vercelAiGateway]?.[id] return { id, info } @@ -465,6 +504,7 @@ function getSelectedModel({ apiConfiguration.zooGatewayModelId, routerModels[providerIdentifiers.zooGateway], defaultModelId, + true, ) const info = routerModels[providerIdentifiers.zooGateway]?.[id] return { id, info } diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 762a1290af..85d91acb69 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -652,6 +652,15 @@ }, "resetDefaults": "Restablir als valors per defecte" }, + "customModelInfo": { + "title": "Metadades del model personalitzat", + "description": "Sobreescriviu les metadades de context i capacitats quan el proveïdor no pugui detectar el vostre model amb precisió.", + "unresolved": "Aquest model no és al catàleg del proveïdor. Els valors següents parteixen de valors predeterminats segurs — ajusteu-los perquè coincideixin amb el model.", + "maxTokens": { + "description": "Nombre màxim de tokens que el model pot generar en una sola resposta." + }, + "maxTokensWarning": "El màxim de tokens de sortida supera la mida de la finestra de context" + }, "rateLimitSeconds": { "label": "Límit de freqüència", "description": "Temps mínim entre sol·licituds d'API." diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index f56984bc73..fb184832f1 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -652,6 +652,15 @@ }, "resetDefaults": "Auf Standardwerte zurücksetzen" }, + "customModelInfo": { + "title": "Benutzerdefinierte Modell-Metadaten", + "description": "Überschreibe die Kontext- und Fähigkeits-Metadaten, wenn der Anbieter dein Modell nicht korrekt erkennen kann.", + "unresolved": "Dieses Modell ist nicht im Anbieterkatalog enthalten. Die Werte unten beginnen mit sicheren Standardwerten – passe sie an das Modell an.", + "maxTokens": { + "description": "Maximale Anzahl an Tokens, die das Modell in einer Antwort generieren kann." + }, + "maxTokensWarning": "Maximale Ausgabe-Tokens überschreiten die Kontextfenstergröße" + }, "rateLimitSeconds": { "label": "Ratenbegrenzung", "description": "Minimale Zeit zwischen API-Anfragen." diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 77f8a4f86d..e71b7d0f48 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -739,6 +739,15 @@ }, "resetDefaults": "Reset to Defaults" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "This model is not in the provider catalog. The values below start from safe defaults — adjust them to match the model.", + "maxTokens": { + "description": "Maximum number of tokens the model can generate in one response." + }, + "maxTokensWarning": "Max output tokens exceed the context window." + }, "rateLimitSeconds": { "label": "Rate limit", "description": "Minimum time between API requests." diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index e730483fa0..96fa206bd4 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -652,6 +652,15 @@ }, "resetDefaults": "Restablecer valores predeterminados" }, + "customModelInfo": { + "title": "Metadatos del modelo personalizado", + "description": "Anule los metadatos de contexto y capacidades cuando el proveedor no pueda detectar su modelo con precisión.", + "unresolved": "Este modelo no está en el catálogo del proveedor. Los valores siguientes parten de valores predeterminados seguros — ajústelos para que coincidan con el modelo.", + "maxTokens": { + "description": "Número máximo de tokens que el modelo puede generar en una sola respuesta." + }, + "maxTokensWarning": "Los tokens máximos de salida exceden el tamaño de ventana de contexto" + }, "rateLimitSeconds": { "label": "Límite de tasa", "description": "Tiempo mínimo entre solicitudes de API." diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 78770da21d..0e7d21e7f3 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -652,6 +652,15 @@ }, "resetDefaults": "Réinitialiser les valeurs par défaut" }, + "customModelInfo": { + "title": "Métadonnées de modèle personnalisées", + "description": "Remplacez les métadonnées de contexte et de capacités lorsque le fournisseur ne peut pas détecter votre modèle avec précision.", + "unresolved": "Ce modèle ne figure pas dans le catalogue du fournisseur. Les valeurs ci-dessous partent de valeurs par défaut sûres — ajustez-les pour correspondre au modèle.", + "maxTokens": { + "description": "Nombre maximum de tokens que le modèle peut générer dans une seule réponse." + }, + "maxTokensWarning": "Les tokens de sortie maximum dépassent la taille de la fenêtre de contexte" + }, "rateLimitSeconds": { "label": "Limite de débit", "description": "Temps minimum entre les requêtes API." diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 413d3515bc..920d1c13a9 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -652,6 +652,15 @@ }, "resetDefaults": "डिफ़ॉल्ट पर रीसेट करें" }, + "customModelInfo": { + "title": "कस्टम मॉडल मेटाडेटा", + "description": "जब प्रदाता आपके मॉडल का सटीक पता नहीं लगा सके, तो संदर्भ और क्षमता मेटाडेटा को ओवरराइड करें।", + "unresolved": "यह मॉडल प्रदाता कैटलॉग में नहीं है। नीचे दिए गए मान सुरक्षित डिफ़ॉल्ट से शुरू होते हैं — उन्हें मॉडल के अनुरूप समायोजित करें।", + "maxTokens": { + "description": "टोकन की अधिकतम संख्या जो मॉडल एक प्रतिक्रिया में उत्पन्न कर सकता है।" + }, + "maxTokensWarning": "अधिकतम आउटपुट टोकन संदर्भ विंडो आकार से अधिक हैं" + }, "rateLimitSeconds": { "label": "दर सीमा", "description": "API अनुरोधों के बीच न्यूनतम समय।" diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 9b9928da64..631ff10344 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -652,6 +652,15 @@ }, "resetDefaults": "Reset ke Default" }, + "customModelInfo": { + "title": "Metadata Model Kustom", + "description": "Timpa metadata konteks dan kemampuan ketika penyedia tidak dapat mendeteksi model Anda secara akurat.", + "unresolved": "Model ini tidak ada dalam katalog penyedia. Nilai di bawah ini dimulai dari default yang aman — sesuaikan agar cocok dengan model.", + "maxTokens": { + "description": "Jumlah maksimum token yang dapat dihasilkan model dalam satu respons." + }, + "maxTokensWarning": "Token Output Maksimum melebihi Ukuran Context Window" + }, "rateLimitSeconds": { "label": "Rate limit", "description": "Waktu minimum antara permintaan API." diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 8a43ec3d35..3be3e9d6bb 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -652,6 +652,15 @@ }, "resetDefaults": "Ripristina valori predefiniti" }, + "customModelInfo": { + "title": "Metadati modello personalizzato", + "description": "Sostituisci i metadati di contesto e capacità quando il provider non riesce a rilevare correttamente il tuo modello.", + "unresolved": "Questo modello non è presente nel catalogo del provider. I valori riportati di seguito partono da impostazioni predefinite sicure — modificali in base al modello.", + "maxTokens": { + "description": "Numero massimo di token che il modello può generare in una singola risposta." + }, + "maxTokensWarning": "I token di output massimi superano la dimensione finestra di contesto" + }, "rateLimitSeconds": { "label": "Limite di frequenza", "description": "Tempo minimo tra le richieste API." diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index b2cfbe977e..78db95c481 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -652,6 +652,15 @@ }, "resetDefaults": "デフォルトにリセット" }, + "customModelInfo": { + "title": "カスタムモデルのメタデータ", + "description": "プロバイダーがモデルを正確に検出できない場合に、コンテキストや機能のメタデータを上書きします。", + "unresolved": "このモデルはプロバイダーのカタログに存在しません。以下の値は安全なデフォルト値から始まります — モデルに合わせて調整してください。", + "maxTokens": { + "description": "モデルが1回の応答で生成できる最大トークン数です。" + }, + "maxTokensWarning": "最大出力トークンがコンテキストウィンドウサイズを超えています" + }, "rateLimitSeconds": { "label": "レート制限", "description": "APIリクエスト間の最小時間。" diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 1ff9addeb4..44465a5894 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -652,6 +652,15 @@ }, "resetDefaults": "기본값으로 재설정" }, + "customModelInfo": { + "title": "사용자 정의 모델 메타데이터", + "description": "공급자가 모델을 정확하게 감지할 수 없는 경우 컨텍스트 및 기능 메타데이터를 재정의하세요.", + "unresolved": "이 모델은 공급자 카탈로그에 없습니다. 아래 값은 안전한 기본값에서 시작합니다 — 모델에 맞게 조정하세요.", + "maxTokens": { + "description": "모델이 한 번의 응답에서 생성할 수 있는 최대 토큰 수입니다." + }, + "maxTokensWarning": "최대 출력 토큰이 컨텍스트 창 크기를 초과합니다" + }, "rateLimitSeconds": { "label": "속도 제한", "description": "API 요청 간 최소 시간." diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 4361d091a1..7359328432 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -652,6 +652,15 @@ }, "resetDefaults": "Standaardwaarden herstellen" }, + "customModelInfo": { + "title": "Aangepaste modelmetadata", + "description": "Overschrijf de context- en mogelijkhedenmetadata wanneer de provider je model niet nauwkeurig kan detecteren.", + "unresolved": "Dit model staat niet in de providercatalogus. De onderstaande waarden beginnen bij veilige standaardwaarden — pas ze aan zodat ze overeenkomen met het model.", + "maxTokens": { + "description": "Maximaal aantal tokens dat het model in één reactie kan genereren." + }, + "maxTokensWarning": "Maximaal aantal outputtokens overschrijdt de Contextvenstergrootte" + }, "rateLimitSeconds": { "label": "Snelheidslimiet", "description": "Minimale tijd tussen API-verzoeken." diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 277bcaa470..39615b5ebb 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -652,6 +652,15 @@ }, "resetDefaults": "Przywróć domyślne" }, + "customModelInfo": { + "title": "Metadane modelu niestandardowego", + "description": "Zastąp metadane kontekstu i możliwości, gdy dostawca nie może dokładnie rozpoznać Twojego modelu.", + "unresolved": "Tego modelu nie ma w katalogu dostawcy. Poniższe wartości wynikają z bezpiecznych ustawień domyślnych — dostosuj je do swojego modelu.", + "maxTokens": { + "description": "Maksymalna liczba tokenów, które model może wygenerować w jednej odpowiedzi." + }, + "maxTokensWarning": "Maksymalna liczba tokenów wyjściowych przekracza rozmiar okna kontekstu" + }, "rateLimitSeconds": { "label": "Limit szybkości", "description": "Minimalny czas między żądaniami API." diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 8ce67bcd48..c7900b23f7 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -652,6 +652,15 @@ }, "resetDefaults": "Restaurar Padrões" }, + "customModelInfo": { + "title": "Metadados personalizados do modelo", + "description": "Substitua os metadados de contexto e capacidades quando o provedor não conseguir detectar seu modelo com precisão.", + "unresolved": "Este modelo não está no catálogo do provedor. Os valores abaixo partem de padrões seguros — ajuste-os para corresponder ao modelo.", + "maxTokens": { + "description": "Número máximo de tokens que o modelo pode gerar em uma resposta." + }, + "maxTokensWarning": "O Máximo de Tokens de Saída excede o Tamanho da Janela de Contexto" + }, "rateLimitSeconds": { "label": "Limite de taxa", "description": "Tempo mínimo entre requisições de API." diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 0ac516c190..c9f7474ec6 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -652,6 +652,15 @@ }, "resetDefaults": "Сбросить к значениям по умолчанию" }, + "customModelInfo": { + "title": "Метаданные пользовательской модели", + "description": "Переопределите метаданные контекста и возможностей, если провайдер не может точно определить вашу модель.", + "unresolved": "Этой модели нет в каталоге провайдера. Значения ниже соответствуют безопасным значениям по умолчанию — измените их в соответствии с моделью.", + "maxTokens": { + "description": "Максимальное количество токенов, которое модель может сгенерировать в одном ответе." + }, + "maxTokensWarning": "Максимум токенов на вывод превышает размер окна контекста" + }, "rateLimitSeconds": { "label": "Лимит скорости", "description": "Минимальное время между запросами к API." diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 5d3a5cb89a..f4802b1681 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -652,6 +652,15 @@ }, "resetDefaults": "Varsayılanlara Sıfırla" }, + "customModelInfo": { + "title": "Özel model meta verileri", + "description": "Sağlayıcı modelinizi doğru şekilde algılayamadığında bağlam ve yetenek meta verilerini geçersiz kılın.", + "unresolved": "Bu model, sağlayıcı kataloğunda bulunmuyor. Aşağıdaki değerler güvenli varsayılanlardan başlar — bunları modele uyacak şekilde ayarlayın.", + "maxTokens": { + "description": "Modelin tek bir yanıtta üretebileceği maksimum token sayısı." + }, + "maxTokensWarning": "Maksimum Çıktı Token'ları, Bağlam Penceresi Boyutu'nu aşıyor" + }, "rateLimitSeconds": { "label": "Hız sınırı", "description": "API istekleri arasındaki minimum süre." diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index adb1be64e3..74bf335c9a 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -652,6 +652,15 @@ }, "resetDefaults": "Đặt lại về mặc định" }, + "customModelInfo": { + "title": "Siêu dữ liệu mẫu tùy chỉnh", + "description": "Ghi đè siêu dữ liệu về ngữ cảnh và khả năng khi nhà cung cấp không thể nhận diện chính xác mô hình của bạn.", + "unresolved": "Mẫu này không có trong danh mục của nhà cung cấp. Các giá trị bên dưới bắt đầu từ các giá trị mặc định an toàn — hãy điều chỉnh chúng cho phù hợp với mô hình.", + "maxTokens": { + "description": "Số lượng token tối đa mà mô hình có thể tạo ra trong một phản hồi." + }, + "maxTokensWarning": "Số token đầu ra tối đa vượt quá Kích thước cửa sổ ngữ cảnh" + }, "rateLimitSeconds": { "label": "Giới hạn tốc độ", "description": "Thời gian tối thiểu giữa các yêu cầu API." diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 916f629efe..4b656a3ef4 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -652,6 +652,15 @@ }, "resetDefaults": "重置为默认值" }, + "customModelInfo": { + "title": "自定义模型元数据", + "description": "当提供商无法准确识别您的模型时,可覆盖上下文与能力元数据。", + "unresolved": "该模型不在提供商模型目录中。以下数值以安全默认值为起点 —— 请调整为与该模型相符的配置。", + "maxTokens": { + "description": "模型在单次响应中可生成的最大Token数。" + }, + "maxTokensWarning": "最大输出Token数超过上下文窗口大小" + }, "rateLimitSeconds": { "label": "API 请求频率限制", "description": "设置API请求的最小间隔时间" diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index d1f9258dcf..e35e868d00 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -679,6 +679,15 @@ }, "resetDefaults": "重設為預設值" }, + "customModelInfo": { + "title": "自訂模型中繼資料", + "description": "當提供者無法準確偵測您的模型時,可覆寫上下文與功能中繼資料。", + "unresolved": "此模型不在提供者目錄中。以下數值以安全的預設值為起點 — 請調整這些數值以符合該模型。", + "maxTokens": { + "description": "模型在單一回應中可產生的最大 Token 數量。" + }, + "maxTokensWarning": "最大輸出 Token 超過上下文視窗大小" + }, "rateLimitSeconds": { "label": "速率限制", "description": "API 請求間的最短時間"
-
+
@@ -360,6 +377,12 @@ const TaskHeader = ({
+
{condenseButton}
+