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 e17cd5ddbc..344ed93daa 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, @@ -185,6 +186,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 d6b95ce0b1..bdbb1d2000 100644 --- a/src/api/providers/__tests__/kenari.spec.ts +++ b/src/api/providers/__tests__/kenari.spec.ts @@ -76,6 +76,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 254cd1dad4..5721b45872 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 { ApiHandlerOptions } from "../../../shared/api" import { Package } from "../../../shared/package" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" @@ -101,6 +102,10 @@ vitest.mock("../fetchers/modelCache", () => ({ }), })) +vitest.mock("../fetchers/modelEndpointCache", () => ({ + getModelEndpoints: vitest.fn().mockResolvedValue({}), +})) + describe("OpenRouterHandler", () => { const mockOptions: ApiHandlerOptions = { openRouterApiKey: "test-key", @@ -139,6 +144,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() diff --git a/src/api/providers/__tests__/requesty.spec.ts b/src/api/providers/__tests__/requesty.spec.ts index 77adb8724f..0cb2b017ee 100644 --- a/src/api/providers/__tests__/requesty.spec.ts +++ b/src/api/providers/__tests__/requesty.spec.ts @@ -158,6 +158,43 @@ 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.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 0e18c4b175..7af68942a3 100644 --- a/src/api/providers/__tests__/unbound.spec.ts +++ b/src/api/providers/__tests__/unbound.spec.ts @@ -38,6 +38,46 @@ describe("UnboundHandler", () => { vi.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.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 92cc785951..999be4855e 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -165,6 +165,18 @@ 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("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 e797dc9745..254f76d2c4 100644 --- a/src/api/providers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/__tests__/zoo-gateway.spec.ts @@ -214,6 +214,18 @@ 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("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() 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..6b0eb8b4e4 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,14 @@ export abstract class RouterProvider extends BaseProvider { private modelFetchPromise?: Promise<{ id: string; info: ModelInfo }> + 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 +104,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 +118,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/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 9d23ca6886..9d7cbf5e08 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -334,5 +334,37 @@ 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() + + 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 c5e69978ff..7c1dea3203 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -447,6 +447,7 @@ const ApiOptions = ({ setApiConfigurationField={setApiConfigurationField} routerModels={routerModels} selectedModelId={selectedModelId} + selectedModelInfo={selectedModelInfo} uriScheme={uriScheme} simplifySettings={fromWelcomeView} organizationAllowList={organizationAllowList} @@ -460,6 +461,7 @@ const ApiOptions = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} routerModels={routerModels} + selectedModelInfo={selectedModelInfo} refetchRouterModels={refetchRouterModels} organizationAllowList={organizationAllowList} modelValidationError={modelValidationError} @@ -472,6 +474,7 @@ const ApiOptions = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} routerModels={routerModels} + selectedModelInfo={selectedModelInfo} refetchRouterModels={refetchRouterModels} organizationAllowList={organizationAllowList} modelValidationError={modelValidationError} @@ -648,6 +651,7 @@ const ApiOptions = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} routerModels={routerModels} + selectedModelInfo={selectedModelInfo} organizationAllowList={organizationAllowList} modelValidationError={modelValidationError} simplifySettings={fromWelcomeView} @@ -681,6 +685,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..dc39ef8e69 --- /dev/null +++ b/webview-ui/src/components/settings/CustomModelInfoSettings.tsx @@ -0,0 +1,210 @@ +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() ?? "") + } + }, [configuredContextWindow, contextWindowInput]) + + useEffect(() => { + if (parsePositiveInteger(maxTokensInput) !== configuredMaxTokens) { + setMaxTokensInput(configuredMaxTokens?.toString() ?? "") + } + }, [configuredMaxTokens, maxTokensInput]) + + 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) + updateOverride("contextWindow", parsePositiveInteger(value)) + } + + const handleMaxTokensInput = (event: ValueChangeEvent) => { + const value = getEventValue(event) + setMaxTokensInput(value) + updateOverride("maxTokens", parsePositiveInteger(value)) + } + + 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..1966bf8860 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx @@ -0,0 +1,124 @@ +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).toHaveBeenLastCalledWith("customModelInfo", { contextWindow: 128_000 }) + + fireEvent.click(screen.getByText("settings:providers.customModelInfo.reset")) + 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") + expect(setApiConfigurationField).toHaveBeenLastCalledWith("customModelInfo", undefined) + }) + + it("updates capability overrides and warns when output exceeds the context window", () => { + const setApiConfigurationField = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByText("settings:providers.customModelInfo.title")) + + expect(screen.getByText("settings:providers.customModelInfo.maxTokensWarning")).toBeInTheDocument() + + fireEvent.click(screen.getByText("settings:providers.customModelInfo.supportsImages.label")) + expect(setApiConfigurationField).toHaveBeenLastCalledWith("customModelInfo", { + contextWindow: 1000, + maxTokens: 2000, + supportsImages: true, + }) + + fireEvent.click(screen.getByText("settings:providers.customModelInfo.supportsPromptCache.label")) + expect(setApiConfigurationField).toHaveBeenLastCalledWith("customModelInfo", { + contextWindow: 1000, + maxTokens: 2000, + 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/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..87e7290d65 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 } = @@ -73,7 +77,7 @@ export const ZooGateway = ({ } const current = apiConfiguration.zooGatewayModelId - if (!current || !modelIds.includes(current)) { + if (!current) { setApiConfigurationField("zooGatewayModelId", resolvedDefaultModelId) } }, [apiConfiguration.zooGatewayModelId, modelIds, resolvedDefaultModelId, setApiConfigurationField]) @@ -120,6 +124,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..aabf8a4796 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( { ) await waitFor(() => { - expect(setApiConfigurationField).toHaveBeenCalledWith( - "zooGatewayModelId", - "anthropic.claude-sonnet-4-5-20250929-v1:0", - ) + 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 5fca23ba8e..af8accb7cb 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -1,13 +1,15 @@ // 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 RouterModels, + type ProviderName, anthropicModels, BEDROCK_1M_CONTEXT_MODEL_IDS, litellmDefaultModelInfo, @@ -37,6 +39,147 @@ vi.mock("../useOpenRouterModelProviders") const mockUseRouterModels = useRouterModels as Mock const mockUseOpenRouterModelProviders = useOpenRouterModelProviders as Mock +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 createQueryResult = ( + data: TData | undefined, + fallbackData: TData, + isLoading: boolean, +): UseQueryResult => + isLoading + ? { + 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), + } + : { + 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), + } + const createWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { @@ -110,7 +253,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, @@ -159,22 +302,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", () => { @@ -277,7 +406,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: { @@ -315,23 +444,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, diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index ec513ce885..6a0f503850 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, @@ -45,17 +46,50 @@ 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 + } +} + +function supportsCustomModelInfo(provider: ProviderName): boolean { + return ( + provider === providerIdentifiers.openrouter || + provider === providerIdentifiers.requesty || + provider === providerIdentifiers.unbound || + provider === providerIdentifiers.vercelAiGateway || + provider === providerIdentifiers.zooGateway + ) +} + export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { const provider = apiConfiguration?.apiProvider || "openrouter" const activeProvider: ProviderName | undefined = isRetiredProvider(provider) ? undefined : provider @@ -95,7 +129,7 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { hasValidRouterData && (!needOpenRouterProviders || typeof openRouterModelProviders.data !== "undefined") - const { id, info } = + const selectedModel = apiConfiguration && isReady && activeProvider ? getSelectedModel({ provider: activeProvider, @@ -110,7 +144,20 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { id: apiConfiguration.apiModelId || getProviderDefaultModelId("kimi-code"), info: kimiCodeDefaultModelInfo, } - : { id: getProviderDefaultModelId(activeProvider ?? "openrouter"), info: undefined } + : { + id: + (activeProvider && + apiConfiguration && + getConfiguredRouterModelId(activeProvider, apiConfiguration)) || + getProviderDefaultModelId(activeProvider ?? "openrouter"), + info: undefined, + } + + const { id } = selectedModel + const info = + activeProvider && supportsCustomModelInfo(activeProvider) + ? applyCustomModelInfo(selectedModel.info, apiConfiguration) + : selectedModel.info return { provider, @@ -150,7 +197,12 @@ function getSelectedModel({ const defaultModelId = getProviderDefaultModelId(provider) switch (provider) { case providerIdentifiers.openrouter: { - const id = getValidatedModelId(apiConfiguration.openRouterModelId, routerModels.openrouter, defaultModelId) + const id = getValidatedModelId( + apiConfiguration.openRouterModelId, + routerModels.openrouter, + defaultModelId, + true, + ) let info = routerModels.openrouter?.[id] const specificProvider = apiConfiguration.openRouterSpecificProvider @@ -166,12 +218,17 @@ function getSelectedModel({ return { id, info } } case providerIdentifiers.requesty: { - const id = getValidatedModelId(apiConfiguration.requestyModelId, routerModels.requesty, defaultModelId) + const id = getValidatedModelId( + apiConfiguration.requestyModelId, + routerModels.requesty, + defaultModelId, + true, + ) const routerInfo = routerModels.requesty?.[id] return { id, info: routerInfo } } case providerIdentifiers.unbound: { - const id = getValidatedModelId(apiConfiguration.unboundModelId, routerModels.unbound, defaultModelId) + const id = getValidatedModelId(apiConfiguration.unboundModelId, routerModels.unbound, defaultModelId, true) const routerInfo = routerModels.unbound?.[id] return { id, info: routerInfo } } @@ -387,6 +444,7 @@ function getSelectedModel({ apiConfiguration.vercelAiGatewayModelId, routerModels["vercel-ai-gateway"], defaultModelId, + true, ) const info = routerModels["vercel-ai-gateway"]?.[id] return { id, info } @@ -414,6 +472,7 @@ function getSelectedModel({ apiConfiguration.zooGatewayModelId, routerModels["zoo-gateway"], defaultModelId, + true, ) const info = routerModels["zoo-gateway"]?.[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 7c0454f55c..bf8f256b96 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -610,6 +610,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 504bb56cba..bc90b462f0 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -610,6 +610,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 3d84065849..49b2d61e10 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -697,6 +697,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 eba338005f..f365cafe83 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -610,6 +610,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 d6e6e0e64e..aa4cd1cbb2 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -610,6 +610,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 3ff02125c5..a4eb5774c9 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -610,6 +610,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 6c4b91243f..81e2d24b04 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -610,6 +610,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 8f7fd7e917..49aaac46bc 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -610,6 +610,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 ab692a49f8..119783f39e 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -610,6 +610,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 4e44f8170d..731ba5fdb8 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -610,6 +610,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 d517df4bd0..d49b5fe29c 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -610,6 +610,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 3ef8e06c32..b753b82c9a 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -610,6 +610,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 9c67418d16..dfdadefb92 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -610,6 +610,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 6d81073dbe..234db259eb 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -610,6 +610,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 0456367efc..bf3504afdb 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -610,6 +610,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 4beb3f7171..4a247b12f6 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -610,6 +610,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 8624c1899b..8990783fa5 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -610,6 +610,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 8556e8b2f4..290b427107 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -637,6 +637,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}
+