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..a05a58e42c --- /dev/null +++ b/packages/types/src/__tests__/custom-model-info.test.ts @@ -0,0 +1,75 @@ +import { applyCustomModelInfo, customModelInfoSchema, type ModelInfo } from "../model.js" +import { providerIdentifiers, providerSettingsSchemaDiscriminated } from "../index.js" + +describe("custom model info", () => { + it("overlays only supported metadata and preserves provider-owned fields", () => { + const model: ModelInfo = { + maxTokens: 4096, + contextWindow: 8192, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.1, + outputPrice: 0.2, + description: "Provider metadata", + } + + expect( + applyCustomModelInfo(model, { + customModelInfo: { + contextWindow: 128_000, + maxTokens: 16_384, + supportsImages: true, + supportsPromptCache: true, + }, + }), + ).toEqual({ + ...model, + contextWindow: 128_000, + maxTokens: 16_384, + supportsImages: true, + supportsPromptCache: true, + }) + }) + + it("does not synthesize model info without a valid context window", () => { + expect( + applyCustomModelInfo(undefined, { + customModelInfo: { + contextWindow: 0, + maxTokens: -1, + supportsImages: true, + }, + }), + ).toBeUndefined() + }) + + it("synthesizes safe defaults when only a context window is supplied", () => { + expect( + applyCustomModelInfo(undefined, { + customModelInfo: { contextWindow: 64_000, supportsImages: true }, + }), + ).toEqual({ + maxTokens: undefined, + contextWindow: 64_000, + supportsImages: true, + supportsPromptCache: false, + }) + }) + + it("rejects unsupported pricing fields in the persisted override schema", () => { + expect(customModelInfoSchema.safeParse({ contextWindow: 64_000, inputPrice: 1 }).success).toBe(false) + expect( + providerSettingsSchemaDiscriminated.safeParse({ + apiProvider: providerIdentifiers.openrouter, + customModelInfo: { contextWindow: 64_000, outputPrice: 1 }, + }).success, + ).toBe(false) + }) + + it("rejects unsafe integer overrides", () => { + const unsafeInteger = Number.MAX_SAFE_INTEGER + 1 + + expect(customModelInfoSchema.safeParse({ contextWindow: unsafeInteger }).success).toBe(false) + expect(customModelInfoSchema.safeParse({ maxTokens: unsafeInteger }).success).toBe(false) + }) +}) diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 9fbf9e358b..9784ae0ba3 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -183,6 +183,86 @@ export const modelInfoSchema = z.object({ export type ModelInfo = z.infer +/** + * User-supplied metadata for a model whose discovered metadata is incomplete + * or unavailable. This is intentionally narrower than ModelInfo: prices and + * other accounting fields must remain provider-owned. + */ +const positiveSafeIntegerSchema = z + .number() + .int() + .positive() + .refine(Number.isSafeInteger, { message: "Expected a safe integer" }) + +export const customModelInfoSchema = z + .object({ + maxTokens: positiveSafeIntegerSchema.optional(), + contextWindow: positiveSafeIntegerSchema.optional(), + supportsImages: z.boolean().optional(), + supportsPromptCache: z.boolean().optional(), + }) + .strict() + +export type CustomModelInfo = z.infer + +export type CustomModelInfoSettings = { + customModelInfo?: Partial | null +} + +const isPositiveInteger = (value: unknown): value is number => + typeof value === "number" && Number.isSafeInteger(value) && value > 0 + +/** + * Applies the user metadata overlay without allowing invalid values to enter + * model arithmetic or cost/capability fields outside the supported override. + * When no discovered info exists, a context-window override is required to + * synthesize a usable ModelInfo. + */ +export const applyCustomModelInfo = ( + info: ModelInfo | undefined, + settings: CustomModelInfoSettings | undefined, +): ModelInfo | undefined => { + const override = settings?.customModelInfo + + if (!override) { + return info + } + + const validOverride: CustomModelInfo = {} + + if (isPositiveInteger(override.contextWindow)) { + validOverride.contextWindow = override.contextWindow + } + + if (isPositiveInteger(override.maxTokens)) { + validOverride.maxTokens = override.maxTokens + } + + if (typeof override.supportsImages === "boolean") { + validOverride.supportsImages = override.supportsImages + } + + if (typeof override.supportsPromptCache === "boolean") { + validOverride.supportsPromptCache = override.supportsPromptCache + } + + if (info) { + return Object.keys(validOverride).length > 0 ? { ...info, ...validOverride } : info + } + + if (!validOverride.contextWindow) { + return undefined + } + + return { + maxTokens: undefined, + contextWindow: validOverride.contextWindow, + supportsImages: false, + supportsPromptCache: false, + ...validOverride, + } +} + 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 99b75de2e4..2fe76091aa 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -2,6 +2,7 @@ import { z } from "zod" import { modelInfoSchema, + customModelInfoSchema, openAiCodexServiceTierSchema, reasoningEffortSettingSchema, verbosityLevelsSchema, @@ -72,6 +73,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 * @@ -185,6 +208,7 @@ const baseProviderSettingsSchema = z.object({ reasoningEffort: reasoningEffortSettingSchema.optional(), modelMaxTokens: z.number().optional(), modelMaxThinkingTokens: z.number().optional(), + customModelInfo: customModelInfoSchema.nullish(), // Model verbosity. verbosity: verbosityLevelsSchema.optional(), diff --git a/src/api/providers/__tests__/kenari.spec.ts b/src/api/providers/__tests__/kenari.spec.ts index f9d07873c6..3dee6a3001 100644 --- a/src/api/providers/__tests__/kenari.spec.ts +++ b/src/api/providers/__tests__/kenari.spec.ts @@ -77,6 +77,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 }, + }) + 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 f0000918d8..05ad461977 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -18,6 +18,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" 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" @@ -102,13 +103,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) @@ -140,6 +150,67 @@ 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 }, + }) + + 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: { + 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() @@ -147,7 +218,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 c685da0ed2..5ad13f37b9 100644 --- a/src/api/providers/__tests__/requesty.spec.ts +++ b/src/api/providers/__tests__/requesty.spec.ts @@ -159,6 +159,45 @@ 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: { + 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 9b45713386..2ee524ce50 100644 --- a/src/api/providers/__tests__/unbound.spec.ts +++ b/src/api/providers/__tests__/unbound.spec.ts @@ -39,6 +39,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("synthesizes metadata for an unlisted configured model", async () => { + const modelId = "provider/unlisted-model" + const handler = new UnboundHandler({ + unboundApiKey: "test-key", + unboundModelId: modelId, + customModelInfo: { + 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.info.supportsImages).toBe(false) + 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 ffad3fa0d1..cd61dc0f98 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -166,6 +166,36 @@ 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 }, + }) + 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("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 }, + }) + await handler.completePrompt("test") + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + max_completion_tokens: 10_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 66131d7cb1..9ec4e8ca9e 100644 --- a/src/api/providers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/__tests__/zoo-gateway.spec.ts @@ -211,6 +211,36 @@ 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("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 }, + }) + await handler.completePrompt("test") + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + max_completion_tokens: 10_000, + }), + ) + }) + 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() @@ -249,7 +279,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 3e59b4360b..4e7a854bfb 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, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -551,15 +552,19 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH override getModel() { const id = this.options.openRouterModelId ?? openRouterDefaultModelId - let info = this.models[id] ?? openRouterDefaultModelInfo + const discoveredInfo = this.models[id] + let hasDiscoveredInfo = discoveredInfo !== undefined + let info = discoveredInfo ?? openRouterDefaultModelInfo // If a specific provider is requested, use the endpoint for that provider. if (this.options.openRouterSpecificProvider && this.endpoints[this.options.openRouterSpecificProvider]) { info = this.endpoints[this.options.openRouterSpecificProvider] + hasDiscoveredInfo = true } // Apply tool preferences for models accessed through routers (OpenAI, Gemini) info = applyRouterToolPreferences(id, info) + info = applyCustomModelInfo(hasDiscoveredInfo ? info : undefined, 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 5753660de5..19531e47db 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -1,7 +1,13 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { type ModelInfo, type ModelRecord, requestyDefaultModelId, requestyDefaultModelInfo } from "@roo-code/types" +import { + applyCustomModelInfo, + type ModelInfo, + type ModelRecord, + requestyDefaultModelId, + requestyDefaultModelInfo, +} from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" @@ -80,11 +86,13 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan override getModel() { const id = this.options.requestyModelId ?? requestyDefaultModelId - const cachedInfo = this.models[id] ?? requestyDefaultModelInfo + const discoveredInfo = this.models[id] + const cachedInfo = discoveredInfo ?? requestyDefaultModelInfo let info: ModelInfo = cachedInfo // Apply tool preferences for models accessed through routers (OpenAI, Gemini) info = applyRouterToolPreferences(id, info) + info = applyCustomModelInfo(discoveredInfo ? info : undefined, 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 cbdd49e58b..420067fdfc 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, type ModelInfo, type ModelRecord } from "@roo-code/types" import { ApiHandlerOptions, RouterName } from "../../shared/api" @@ -58,6 +58,28 @@ export abstract class RouterProvider extends BaseProvider { private modelFetchPromise?: Promise<{ id: string; info: ModelInfo }> + /** + * Apply user-supplied `customModelInfo` overrides for gateway providers. + * + * Only vercel-ai-gateway and zoo-gateway opt in here because the other + * RouterProvider subclasses (openrouter, requesty, unbound) apply overrides + * in their own `getModel()` methods — they need to merge with provider- + * specific logic (e.g. specific-provider endpoints, tool preferences) that + * runs before the overlay. LiteLLM, Kenari, and OpenCode Go don't support + * `customModelInfo` because they have their own discovery mechanisms and + * are not exposed in the settings UI. + * + * See also: `customModelInfoProviders` in @roo-code/types for the full set + * of providers whose UI exposes the override panel. + */ + private resolveModelInfo(info: ModelInfo | undefined, fallback: ModelInfo): ModelInfo { + if (this.name !== "vercel-ai-gateway" && this.name !== "zoo-gateway") { + return info ?? fallback + } + + return applyCustomModelInfo(info, this.options) ?? fallback + } + public async fetchModel() { if (Object.keys(this.models).length > 0) { return this.getModel() @@ -96,7 +118,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). @@ -110,14 +132,14 @@ 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: preserve the configured model ID (falling back to the default // only when none is configured) so an as-yet-unfetched model isn't silently // swapped for the hardcoded default. info still comes from defaults since we // have no fetched or cached metadata for the configured model at this point. - 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 c3ec9c44fc..0ee069b1f5 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -1,7 +1,13 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { type ModelInfo, type ModelRecord, unboundDefaultModelId, unboundDefaultModelInfo } from "@roo-code/types" +import { + applyCustomModelInfo, + type ModelInfo, + type ModelRecord, + unboundDefaultModelId, + unboundDefaultModelInfo, +} from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" import { calculateApiCostOpenAI } from "../../shared/cost" @@ -74,11 +80,13 @@ export class UnboundHandler extends BaseProvider implements SingleCompletionHand override getModel() { const id = this.options.unboundModelId ?? unboundDefaultModelId - const cachedInfo = this.models[id] ?? unboundDefaultModelInfo + const discoveredInfo = this.models[id] + const cachedInfo = discoveredInfo ?? unboundDefaultModelInfo let info: ModelInfo = cachedInfo // Apply tool preferences for models accessed through routers (OpenAI, Gemini) info = applyRouterToolPreferences(id, info) + info = applyCustomModelInfo(discoveredInfo ? info : undefined, 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 3eb0abac7b..135e3646dc 100644 --- a/webview-ui/playwright-ct.config.ts +++ b/webview-ui/playwright-ct.config.ts @@ -89,6 +89,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/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 2a302e6b18..6c1bf849eb 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -328,5 +328,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 0cc61052db..9978eb9c21 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -448,6 +448,7 @@ const ApiOptions = ({ setApiConfigurationField={setApiConfigurationField} routerModels={routerModels} selectedModelId={selectedModelId} + selectedModelInfo={selectedModelInfo} uriScheme={uriScheme} simplifySettings={fromWelcomeView} organizationAllowList={organizationAllowList} @@ -461,6 +462,7 @@ const ApiOptions = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} routerModels={routerModels} + selectedModelInfo={selectedModelInfo} refetchRouterModels={refetchRouterModels} organizationAllowList={organizationAllowList} modelValidationError={modelValidationError} @@ -473,6 +475,7 @@ const ApiOptions = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} routerModels={routerModels} + selectedModelInfo={selectedModelInfo} refetchRouterModels={refetchRouterModels} organizationAllowList={organizationAllowList} modelValidationError={modelValidationError} @@ -649,6 +652,7 @@ const ApiOptions = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} routerModels={routerModels} + selectedModelInfo={selectedModelInfo} organizationAllowList={organizationAllowList} modelValidationError={modelValidationError} simplifySettings={fromWelcomeView} @@ -682,6 +686,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..32690b560d --- /dev/null +++ b/webview-ui/src/components/settings/CustomModelInfoSettings.tsx @@ -0,0 +1,227 @@ +import { useEffect, useState } from "react" +import { VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import { 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 +} + +const getEventValue = (event: ValueChangeEvent): string => { + const target = event.target + + return target && "value" in target && typeof target.value === "string" ? target.value : "" +} + +const getCheckboxValue = (event: ValueChangeEvent): boolean => { + const target = event.target + + return target && "checked" in target && typeof target.checked === "boolean" ? target.checked : false +} + +const getInputBorderColor = (value: string): string | undefined => { + if (!value.trim()) { + return undefined + } + + return parsePositiveInteger(value) + ? "var(--vscode-testing-iconPassed)" + : "var(--vscode-inputValidation-errorBorder)" +} + +export const CustomModelInfoSettings = ({ + apiConfiguration, + setApiConfigurationField, + selectedModelInfo, +}: CustomModelInfoSettingsProps) => { + const { t } = useAppTranslation() + const [isOpen, setIsOpen] = useState(!selectedModelInfo) + const [contextWindowInput, setContextWindowInput] = useState( + apiConfiguration.customModelInfo?.contextWindow?.toString() ?? "", + ) + const [maxTokensInput, setMaxTokensInput] = useState(apiConfiguration.customModelInfo?.maxTokens?.toString() ?? "") + + const configuredContextWindow = apiConfiguration.customModelInfo?.contextWindow + const configuredMaxTokens = apiConfiguration.customModelInfo?.maxTokens + const customModelInfo = apiConfiguration.customModelInfo ?? {} + + useEffect(() => { + if (parsePositiveInteger(contextWindowInput) !== configuredContextWindow) { + setContextWindowInput(configuredContextWindow?.toString() ?? "") + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- Sync external config into local state only when the persisted value changes, not on every keystroke. + }, [configuredContextWindow]) + + useEffect(() => { + if (parsePositiveInteger(maxTokensInput) !== configuredMaxTokens) { + setMaxTokensInput(configuredMaxTokens?.toString() ?? "") + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- Sync external config into local state only when the persisted value changes, not on every keystroke. + }, [configuredMaxTokens]) + + useEffect(() => { + if (!selectedModelInfo) { + setIsOpen(true) + } + }, [selectedModelInfo]) + + const updateOverride = (field: K, value: CustomModelInfo[K] | undefined) => { + const next: CustomModelInfo = { ...customModelInfo } + + if (value === undefined) { + delete next[field] + } else { + next[field] = value + } + + setApiConfigurationField("customModelInfo", Object.keys(next).length > 0 ? next : undefined) + } + + const handleContextWindowInput = (event: ValueChangeEvent) => { + const value = getEventValue(event) + setContextWindowInput(value) + + const parsed = parsePositiveInteger(value) + + // Only persist when the input is valid OR deliberately empty (= user + // cleared the field). Non-empty invalid input (e.g. "12abc") stays in + // local state without deleting an existing valid override. + if (parsed !== undefined || value.trim() === "") { + updateOverride("contextWindow", parsed) + } + } + + const handleMaxTokensInput = (event: ValueChangeEvent) => { + const value = getEventValue(event) + setMaxTokensInput(value) + + const parsed = parsePositiveInteger(value) + + if (parsed !== undefined || value.trim() === "") { + updateOverride("maxTokens", parsed) + } + } + + const resetOverrides = () => { + setContextWindowInput("") + setMaxTokensInput("") + setApiConfigurationField("customModelInfo", undefined) + } + + const supportsImages = customModelInfo.supportsImages ?? selectedModelInfo?.supportsImages ?? false + const supportsPromptCache = customModelInfo.supportsPromptCache ?? selectedModelInfo?.supportsPromptCache ?? false + const contextWindowOverride = parsePositiveInteger(contextWindowInput) + const maxTokensOverride = parsePositiveInteger(maxTokensInput) + const hasInvalidContextWindow = contextWindowInput.trim().length > 0 && contextWindowOverride === undefined + const hasInvalidMaxTokens = maxTokensInput.trim().length > 0 && maxTokensOverride === undefined + const hasInvalidRange = + contextWindowOverride !== undefined && + maxTokensOverride !== undefined && + maxTokensOverride > contextWindowOverride + + return ( +
+ + + + {t("settings:providers.customModelInfo.title")} + + +

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

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

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

+ )} + +
+ updateOverride("supportsImages", getCheckboxValue(event))}> + {t("settings:providers.customModelInfo.supportsImages.label")} + + + {t("settings:providers.customModelInfo.supportsImages.description")} + + + updateOverride("supportsPromptCache", getCheckboxValue(event))}> + {t("settings:providers.customModelInfo.supportsPromptCache.label")} + + + {t("settings:providers.customModelInfo.supportsPromptCache.description")} + +
+ + +
+
+
+ ) +} 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..9e89713da0 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx @@ -0,0 +1,192 @@ +import { fireEvent, render, screen } from "@testing-library/react" + +import type { ModelInfo, ProviderSettings } from "@roo-code/types" + +import { CustomModelInfoSettings } from "../CustomModelInfoSettings" + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +describe("CustomModelInfoSettings", () => { + const modelInfo: ModelInfo = { + contextWindow: 128_000, + maxTokens: 16_384, + supportsImages: false, + supportsPromptCache: true, + } + + it("keeps numeric edits in the cached provider configuration and supports reset", () => { + const setApiConfigurationField = vi.fn() + const apiConfiguration: ProviderSettings = { + apiProvider: "openrouter", + customModelInfo: { contextWindow: 64_000 }, + } + + render( + , + ) + + fireEvent.click(screen.getByText("settings:providers.customModelInfo.title")) + + const contextWindowInput = screen.getByLabelText("settings:providers.customModelInfo.contextWindow.label") + fireEvent.input(contextWindowInput, { target: { value: "128000" } }) + + expect(setApiConfigurationField).toHaveBeenCalledTimes(1) + expect(setApiConfigurationField).toHaveBeenLastCalledWith("customModelInfo", { contextWindow: 128_000 }) + + fireEvent.click(screen.getByText("settings:providers.customModelInfo.reset")) + expect(setApiConfigurationField).toHaveBeenCalledTimes(2) + expect(setApiConfigurationField).toHaveBeenLastCalledWith("customModelInfo", undefined) + }) + + it("keeps invalid numeric input visible without persisting it", () => { + const setApiConfigurationField = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByText("settings:providers.customModelInfo.title")) + + const maxTokensInput = screen.getByLabelText("settings:providers.customModelInfo.maxTokens.label") + fireEvent.input(maxTokensInput, { target: { value: "12abc" } }) + + expect(maxTokensInput).toHaveValue("12abc") + expect(maxTokensInput).toHaveAttribute("aria-invalid", "true") + // Invalid non-empty input should NOT persist — the override callback is not called + expect(setApiConfigurationField).not.toHaveBeenCalled() + }) + + it("does not delete an existing valid override when the user types invalid input", () => { + const setApiConfigurationField = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByText("settings:providers.customModelInfo.title")) + + const contextWindowInput = screen.getByLabelText("settings:providers.customModelInfo.contextWindow.label") + fireEvent.input(contextWindowInput, { target: { value: "12abc" } }) + + expect(contextWindowInput).toHaveValue("12abc") + expect(contextWindowInput).toHaveAttribute("aria-invalid", "true") + // The existing 64000 override must NOT be deleted + expect(setApiConfigurationField).not.toHaveBeenCalled() + }) + + it("clears the override when the user empties the input field", () => { + const setApiConfigurationField = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByText("settings:providers.customModelInfo.title")) + + const contextWindowInput = screen.getByLabelText("settings:providers.customModelInfo.contextWindow.label") + fireEvent.input(contextWindowInput, { target: { value: "" } }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("customModelInfo", undefined) + }) + + it("accumulates capability overrides across toggles with rerender", () => { + const setApiConfigurationField = vi.fn() + const baseConfig: ProviderSettings = { + apiProvider: "unbound", + customModelInfo: { contextWindow: 1000, maxTokens: 2000 }, + } + + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByText("settings:providers.customModelInfo.title")) + + expect(screen.getByText("settings:providers.customModelInfo.maxTokensWarning")).toBeInTheDocument() + + // Toggle 1: enable supportsImages + fireEvent.click(screen.getByText("settings:providers.customModelInfo.supportsImages.label")) + expect(setApiConfigurationField).toHaveBeenCalledTimes(1) + expect(setApiConfigurationField).toHaveBeenLastCalledWith("customModelInfo", { + contextWindow: 1000, + maxTokens: 2000, + supportsImages: true, + }) + + // Re-render with the updated configuration so toggle 2 sees toggle 1's effect + rerender( + , + ) + + // Toggle 2: disable supportsPromptCache — should accumulate with supportsImages: true + fireEvent.click(screen.getByText("settings:providers.customModelInfo.supportsPromptCache.label")) + expect(setApiConfigurationField).toHaveBeenCalledTimes(2) + expect(setApiConfigurationField).toHaveBeenLastCalledWith("customModelInfo", { + contextWindow: 1000, + maxTokens: 2000, + supportsImages: true, + supportsPromptCache: false, + }) + }) + + it("syncs externally updated numeric overrides into the inputs", () => { + const setApiConfigurationField = vi.fn() + + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByText("settings:providers.customModelInfo.title")) + + rerender( + , + ) + + expect(screen.getByLabelText("settings:providers.customModelInfo.maxTokens.label")).toHaveValue("200") + }) +}) 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..1a0fa0a667 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.visual.fixture.tsx @@ -0,0 +1,99 @@ +/* v8 ignore file -- Playwright component fixture; covered by the visual test. */ +import React from "react" + +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, +} + +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": + "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "settings:providers.customModelInfo.contextWindow.label": "Context window", + "settings:providers.customModelInfo.contextWindow.description": + "Total tokens the model can process, including input and output.", + "settings:providers.customModelInfo.maxTokens.label": "Max output tokens", + "settings:providers.customModelInfo.maxTokens.description": + "Maximum number of tokens the model can generate in one response.", + "settings:providers.customModelInfo.supportsImages.label": "Supports images", + "settings:providers.customModelInfo.supportsImages.description": + "Override whether the model accepts image content.", + "settings:providers.customModelInfo.supportsPromptCache.label": "Supports prompt caching", + "settings:providers.customModelInfo.supportsPromptCache.description": + "Override whether prompt caching is supported.", + "settings:providers.customModelInfo.maxTokensWarning": "Max output tokens exceed the context window.", + "settings:providers.customModelInfo.reset": "Reset to detected values", +} + +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..b0b4aeb434 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.visual.tsx @@ -0,0 +1,60 @@ +import React from "react" + +import { expect, test } from "../../../../playwright/coverage-fixture" +import { + CollapsedFixture, + ExpandedWithOverridesFixture, + WarningFixture, + UnresolvedFixture, +} from "./CustomModelInfoSettings.visual.fixture" + +test("renders the collapsed panel in the VS Code dark theme", async ({ mount }) => { + const component = await mount() + + 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 = await mount() + + const trigger = component.getByText("Custom model metadata") + await trigger.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 = await mount() + + const trigger = component.getByText("Custom model metadata") + await trigger.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 }) => { + const component = await mount() + + // When selectedModelInfo is undefined the panel auto-opens + 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..4c952ce05c 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..5aad7bd914 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..05655b33ff 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..f20b2e16f4 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 ba24a6aafb..7bc3718875 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, @@ -14,6 +15,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" @@ -22,6 +24,7 @@ type RequestyProps = { apiConfiguration: ProviderSettings setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void routerModels?: RouterModels + selectedModelInfo?: ModelInfo refetchRouterModels: () => void organizationAllowList: OrganizationAllowList modelValidationError?: string @@ -37,6 +40,7 @@ export const Requesty = ({ modelValidationError, uriScheme, simplifySettings, + selectedModelInfo, }: RequestyProps) => { const { t } = useAppTranslation() @@ -148,6 +152,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 8c68241415..ae180fce05 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, @@ -14,11 +15,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 @@ -32,6 +35,7 @@ export const Unbound = ({ organizationAllowList, modelValidationError, simplifySettings, + selectedModelInfo, }: UnboundProps) => { const { t } = useAppTranslation() @@ -96,6 +100,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 1f003ed52b..b83355f8e9 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, @@ -13,11 +14,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 @@ -30,6 +33,7 @@ export const VercelAiGateway = ({ organizationAllowList, modelValidationError, simplifySettings, + selectedModelInfo, }: VercelAiGatewayProps) => { const { t } = useAppTranslation() @@ -77,6 +81,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 ac99f464a4..ba04148bdc 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, @@ -12,12 +13,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 @@ -55,6 +58,7 @@ export const ZooGateway = ({ organizationAllowList, modelValidationError, simplifySettings, + selectedModelInfo, }: ZooGatewayProps) => { const { t } = useAppTranslation() const { zooCodeIsAuthenticated, zooCodeUserEmail, zooCodeUserName, zooCodeBaseUrl, uriScheme, deviceName } = @@ -67,13 +71,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]) @@ -120,6 +128,11 @@ export const ZooGateway = ({ errorMessage={modelValidationError} simplifySettings={simplifySettings} /> + ) } 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 9bdda1f433..adcf7a48db 100644 --- a/webview-ui/src/components/settings/providers/__tests__/ZooGateway.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/ZooGateway.spec.tsx @@ -106,7 +106,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() }) }) @@ -149,6 +149,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 2571614085..35bda91e53 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, @@ -53,28 +54,202 @@ 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": {}, + 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({ @@ -311,7 +486,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, @@ -360,22 +535,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", () => { @@ -478,7 +639,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: { @@ -516,23 +677,108 @@ 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: { + 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: { + 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, + }, + } + + 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, @@ -1282,19 +1528,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 6ed20ef47d..560329536b 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -24,6 +24,7 @@ import { mainlandZAiModels, fireworksModels, friendliModels, + applyCustomModelInfo, basetenModels, qwenCodeModels, kimiCodeDefaultModelInfo, @@ -34,6 +35,7 @@ import { BEDROCK_1M_CONTEXT_MODEL_IDS, VERTEX_1M_CONTEXT_MODEL_IDS, isDynamicProvider, + isCustomModelInfoProvider, isRetiredProvider, getProviderDefaultModelId, providerIdentifiers, @@ -45,17 +47,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 + } +} + export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { const provider = apiConfiguration?.apiProvider || providerIdentifiers.openrouter const activeProvider: ProviderName | undefined = isRetiredProvider(provider) ? undefined : provider @@ -97,7 +122,7 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { hasValidRouterData && (!needOpenRouterProviders || typeof openRouterModelProviders.data !== "undefined") - const { id, info } = + const selectedModel = apiConfiguration && isReady && activeProvider ? getSelectedModel({ provider: activeProvider, @@ -112,7 +137,20 @@ 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 ?? "openrouter"), + info: undefined, + } + + const { id } = selectedModel + const info = + activeProvider && isCustomModelInfoProvider(activeProvider) + ? applyCustomModelInfo(selectedModel.info, apiConfiguration) + : selectedModel.info return { provider, @@ -154,10 +192,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]) { @@ -174,19 +213,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: { @@ -407,6 +443,7 @@ function getSelectedModel({ apiConfiguration.vercelAiGatewayModelId, routerModels[providerIdentifiers.vercelAiGateway], defaultModelId, + true, ) const info = routerModels[providerIdentifiers.vercelAiGateway]?.[id] return { id, info } @@ -438,6 +475,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 73827b1ec1..ba3f375e1b 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -617,6 +617,29 @@ }, "resetDefaults": "Restablir als valors per defecte" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "contextWindow": { + "label": "Context window", + "description": "Total tokens the model can process, including input and output." + }, + "maxTokens": { + "label": "Max output tokens", + "description": "Maximum number of tokens the model can generate in one response." + }, + "supportsImages": { + "label": "Supports images", + "description": "Override whether the model accepts image content." + }, + "supportsPromptCache": { + "label": "Supports prompt caching", + "description": "Override whether prompt caching is supported." + }, + "maxTokensWarning": "Max output tokens exceed the context window.", + "reset": "Reset to detected values" + }, "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 8078037525..ec5f6258eb 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -617,6 +617,29 @@ }, "resetDefaults": "Auf Standardwerte zurücksetzen" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "contextWindow": { + "label": "Context window", + "description": "Total tokens the model can process, including input and output." + }, + "maxTokens": { + "label": "Max output tokens", + "description": "Maximum number of tokens the model can generate in one response." + }, + "supportsImages": { + "label": "Supports images", + "description": "Override whether the model accepts image content." + }, + "supportsPromptCache": { + "label": "Supports prompt caching", + "description": "Override whether prompt caching is supported." + }, + "maxTokensWarning": "Max output tokens exceed the context window.", + "reset": "Reset to detected values" + }, "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 14a7476a75..1ae1bce500 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -704,6 +704,29 @@ }, "resetDefaults": "Reset to Defaults" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "contextWindow": { + "label": "Context window", + "description": "Total tokens the model can process, including input and output." + }, + "maxTokens": { + "label": "Max output tokens", + "description": "Maximum number of tokens the model can generate in one response." + }, + "supportsImages": { + "label": "Supports images", + "description": "Override whether the model accepts image content." + }, + "supportsPromptCache": { + "label": "Supports prompt caching", + "description": "Override whether prompt caching is supported." + }, + "maxTokensWarning": "Max output tokens exceed the context window.", + "reset": "Reset to detected values" + }, "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 e629e43b50..18b56c6d51 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -617,6 +617,29 @@ }, "resetDefaults": "Restablecer valores predeterminados" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "contextWindow": { + "label": "Context window", + "description": "Total tokens the model can process, including input and output." + }, + "maxTokens": { + "label": "Max output tokens", + "description": "Maximum number of tokens the model can generate in one response." + }, + "supportsImages": { + "label": "Supports images", + "description": "Override whether the model accepts image content." + }, + "supportsPromptCache": { + "label": "Supports prompt caching", + "description": "Override whether prompt caching is supported." + }, + "maxTokensWarning": "Max output tokens exceed the context window.", + "reset": "Reset to detected values" + }, "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 6048e2274c..16c60e2564 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -617,6 +617,29 @@ }, "resetDefaults": "Réinitialiser les valeurs par défaut" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "contextWindow": { + "label": "Context window", + "description": "Total tokens the model can process, including input and output." + }, + "maxTokens": { + "label": "Max output tokens", + "description": "Maximum number of tokens the model can generate in one response." + }, + "supportsImages": { + "label": "Supports images", + "description": "Override whether the model accepts image content." + }, + "supportsPromptCache": { + "label": "Supports prompt caching", + "description": "Override whether prompt caching is supported." + }, + "maxTokensWarning": "Max output tokens exceed the context window.", + "reset": "Reset to detected values" + }, "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 28d0b8699b..53d41f5cd6 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -617,6 +617,29 @@ }, "resetDefaults": "डिफ़ॉल्ट पर रीसेट करें" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "contextWindow": { + "label": "Context window", + "description": "Total tokens the model can process, including input and output." + }, + "maxTokens": { + "label": "Max output tokens", + "description": "Maximum number of tokens the model can generate in one response." + }, + "supportsImages": { + "label": "Supports images", + "description": "Override whether the model accepts image content." + }, + "supportsPromptCache": { + "label": "Supports prompt caching", + "description": "Override whether prompt caching is supported." + }, + "maxTokensWarning": "Max output tokens exceed the context window.", + "reset": "Reset to detected values" + }, "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 bf049395c4..9d5405f679 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -617,6 +617,29 @@ }, "resetDefaults": "Reset ke Default" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "contextWindow": { + "label": "Context window", + "description": "Total tokens the model can process, including input and output." + }, + "maxTokens": { + "label": "Max output tokens", + "description": "Maximum number of tokens the model can generate in one response." + }, + "supportsImages": { + "label": "Supports images", + "description": "Override whether the model accepts image content." + }, + "supportsPromptCache": { + "label": "Supports prompt caching", + "description": "Override whether prompt caching is supported." + }, + "maxTokensWarning": "Max output tokens exceed the context window.", + "reset": "Reset to detected values" + }, "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 577a74a77a..8d4013a728 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -617,6 +617,29 @@ }, "resetDefaults": "Ripristina valori predefiniti" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "contextWindow": { + "label": "Context window", + "description": "Total tokens the model can process, including input and output." + }, + "maxTokens": { + "label": "Max output tokens", + "description": "Maximum number of tokens the model can generate in one response." + }, + "supportsImages": { + "label": "Supports images", + "description": "Override whether the model accepts image content." + }, + "supportsPromptCache": { + "label": "Supports prompt caching", + "description": "Override whether prompt caching is supported." + }, + "maxTokensWarning": "Max output tokens exceed the context window.", + "reset": "Reset to detected values" + }, "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 1113ac32a6..98597dd2ac 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -617,6 +617,29 @@ }, "resetDefaults": "デフォルトにリセット" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "contextWindow": { + "label": "Context window", + "description": "Total tokens the model can process, including input and output." + }, + "maxTokens": { + "label": "Max output tokens", + "description": "Maximum number of tokens the model can generate in one response." + }, + "supportsImages": { + "label": "Supports images", + "description": "Override whether the model accepts image content." + }, + "supportsPromptCache": { + "label": "Supports prompt caching", + "description": "Override whether prompt caching is supported." + }, + "maxTokensWarning": "Max output tokens exceed the context window.", + "reset": "Reset to detected values" + }, "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 27e8493bd4..35ce0e0ff5 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -617,6 +617,29 @@ }, "resetDefaults": "기본값으로 재설정" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "contextWindow": { + "label": "Context window", + "description": "Total tokens the model can process, including input and output." + }, + "maxTokens": { + "label": "Max output tokens", + "description": "Maximum number of tokens the model can generate in one response." + }, + "supportsImages": { + "label": "Supports images", + "description": "Override whether the model accepts image content." + }, + "supportsPromptCache": { + "label": "Supports prompt caching", + "description": "Override whether prompt caching is supported." + }, + "maxTokensWarning": "Max output tokens exceed the context window.", + "reset": "Reset to detected values" + }, "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 394cdd48f2..1935d9ff74 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -617,6 +617,29 @@ }, "resetDefaults": "Standaardwaarden herstellen" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "contextWindow": { + "label": "Context window", + "description": "Total tokens the model can process, including input and output." + }, + "maxTokens": { + "label": "Max output tokens", + "description": "Maximum number of tokens the model can generate in one response." + }, + "supportsImages": { + "label": "Supports images", + "description": "Override whether the model accepts image content." + }, + "supportsPromptCache": { + "label": "Supports prompt caching", + "description": "Override whether prompt caching is supported." + }, + "maxTokensWarning": "Max output tokens exceed the context window.", + "reset": "Reset to detected values" + }, "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 864e4ffde1..37d676889f 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -617,6 +617,29 @@ }, "resetDefaults": "Przywróć domyślne" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "contextWindow": { + "label": "Context window", + "description": "Total tokens the model can process, including input and output." + }, + "maxTokens": { + "label": "Max output tokens", + "description": "Maximum number of tokens the model can generate in one response." + }, + "supportsImages": { + "label": "Supports images", + "description": "Override whether the model accepts image content." + }, + "supportsPromptCache": { + "label": "Supports prompt caching", + "description": "Override whether prompt caching is supported." + }, + "maxTokensWarning": "Max output tokens exceed the context window.", + "reset": "Reset to detected values" + }, "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 a1948a0218..913bd36930 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -617,6 +617,29 @@ }, "resetDefaults": "Restaurar Padrões" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "contextWindow": { + "label": "Context window", + "description": "Total tokens the model can process, including input and output." + }, + "maxTokens": { + "label": "Max output tokens", + "description": "Maximum number of tokens the model can generate in one response." + }, + "supportsImages": { + "label": "Supports images", + "description": "Override whether the model accepts image content." + }, + "supportsPromptCache": { + "label": "Supports prompt caching", + "description": "Override whether prompt caching is supported." + }, + "maxTokensWarning": "Max output tokens exceed the context window.", + "reset": "Reset to detected values" + }, "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 f36fe62539..80b5adc0b6 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -617,6 +617,29 @@ }, "resetDefaults": "Сбросить к значениям по умолчанию" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "contextWindow": { + "label": "Context window", + "description": "Total tokens the model can process, including input and output." + }, + "maxTokens": { + "label": "Max output tokens", + "description": "Maximum number of tokens the model can generate in one response." + }, + "supportsImages": { + "label": "Supports images", + "description": "Override whether the model accepts image content." + }, + "supportsPromptCache": { + "label": "Supports prompt caching", + "description": "Override whether prompt caching is supported." + }, + "maxTokensWarning": "Max output tokens exceed the context window.", + "reset": "Reset to detected values" + }, "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 9099677679..1f68dbd8b3 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -617,6 +617,29 @@ }, "resetDefaults": "Varsayılanlara Sıfırla" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "contextWindow": { + "label": "Context window", + "description": "Total tokens the model can process, including input and output." + }, + "maxTokens": { + "label": "Max output tokens", + "description": "Maximum number of tokens the model can generate in one response." + }, + "supportsImages": { + "label": "Supports images", + "description": "Override whether the model accepts image content." + }, + "supportsPromptCache": { + "label": "Supports prompt caching", + "description": "Override whether prompt caching is supported." + }, + "maxTokensWarning": "Max output tokens exceed the context window.", + "reset": "Reset to detected values" + }, "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 c66b236165..416af4f578 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -617,6 +617,29 @@ }, "resetDefaults": "Đặt lại về mặc định" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "contextWindow": { + "label": "Context window", + "description": "Total tokens the model can process, including input and output." + }, + "maxTokens": { + "label": "Max output tokens", + "description": "Maximum number of tokens the model can generate in one response." + }, + "supportsImages": { + "label": "Supports images", + "description": "Override whether the model accepts image content." + }, + "supportsPromptCache": { + "label": "Supports prompt caching", + "description": "Override whether prompt caching is supported." + }, + "maxTokensWarning": "Max output tokens exceed the context window.", + "reset": "Reset to detected values" + }, "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 22742e0e0e..1a389c761c 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -617,6 +617,29 @@ }, "resetDefaults": "重置为默认值" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "contextWindow": { + "label": "Context window", + "description": "Total tokens the model can process, including input and output." + }, + "maxTokens": { + "label": "Max output tokens", + "description": "Maximum number of tokens the model can generate in one response." + }, + "supportsImages": { + "label": "Supports images", + "description": "Override whether the model accepts image content." + }, + "supportsPromptCache": { + "label": "Supports prompt caching", + "description": "Override whether prompt caching is supported." + }, + "maxTokensWarning": "Max output tokens exceed the context window.", + "reset": "Reset to detected values" + }, "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 4255a2e697..f2a6b96058 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -644,6 +644,29 @@ }, "resetDefaults": "重設為預設值" }, + "customModelInfo": { + "title": "Custom model metadata", + "description": "Override context and capability metadata when the provider cannot detect your model accurately.", + "unresolved": "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "contextWindow": { + "label": "Context window", + "description": "Total tokens the model can process, including input and output." + }, + "maxTokens": { + "label": "Max output tokens", + "description": "Maximum number of tokens the model can generate in one response." + }, + "supportsImages": { + "label": "Supports images", + "description": "Override whether the model accepts image content." + }, + "supportsPromptCache": { + "label": "Supports prompt caching", + "description": "Override whether prompt caching is supported." + }, + "maxTokensWarning": "Max output tokens exceed the context window.", + "reset": "Reset to detected values" + }, "rateLimitSeconds": { "label": "速率限制", "description": "API 請求間的最短時間"
-
+
@@ -360,6 +377,12 @@ const TaskHeader = ({
+
{condenseButton}
+