From 61bca0d6425114d57ec2e69b2f3762811e52c4e5 Mon Sep 17 00:00:00 2001 From: everyoneexe Date: Tue, 4 Aug 2026 03:06:05 +0200 Subject: [PATCH 1/3] docs: add design spec for custom model settings override Custom model IDs on router providers (e.g. anthropic/claude-sonnet-4-6 on OpenRouter) render a context window of 1 and a 7000% usage figure when the model list is unavailable. Spec covers three defects: the TaskHeader `|| 1` fallback with no upper clamp, webview/host divergence in model resolution, and the absence of any override UI outside the OpenAI-compatible provider. Design: one `customModelInfo` overlay field on the base provider schema, one shared `applyCustomModelInfo` helper bound at both resolution layers, display hardening independent of any override, and a collapsible settings panel for the five router providers. Co-Authored-By: Claude Opus 5 --- ...2026-08-04-custom-model-settings-design.md | 301 ++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-custom-model-settings-design.md diff --git a/docs/superpowers/specs/2026-08-04-custom-model-settings-design.md b/docs/superpowers/specs/2026-08-04-custom-model-settings-design.md new file mode 100644 index 0000000000..fe9bc538ee --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-custom-model-settings-design.md @@ -0,0 +1,301 @@ +# Custom Model Settings Override + +**Date:** 2026-08-04 +**Status:** Approved, ready for implementation plan + +## Problem + +A user selects a model ID that is not present in a router provider's fetched +model list — for example typing `anthropic/claude-sonnet-4-6` into the +OpenRouter model picker via the "use custom model" affordance +(`webview-ui/src/components/settings/ModelPicker.tsx:277`). Three distinct +defects follow. + +### Defect 1 — context window collapses to `1`, percentage renders as 7000% + +`webview-ui/src/components/chat/TaskHeader.tsx:72`: + +```ts +const contextWindow = model?.contextWindow || 1 +``` + +When `useSelectedModel` cannot resolve the model, `model` is `undefined` and +`contextWindow` becomes `1`. That value flows into the percentage at +`TaskHeader.tsx:253-258`: + +```ts +const availableInputSpace = contextWindow - reservedForOutput +const percentage = + availableInputSpace > 0 + ? Math.round(((contextTokens || 0) / availableInputSpace) * 100) + : 0 +``` + +With `contextWindow === 1` and `reservedForOutput === 0`, `availableInputSpace` +is `1`, so the percentage equals `contextTokens * 100`. 70 context tokens +render as **7000%**. There is no upper clamp on this path. + +**Precise trigger.** For OpenRouter an unknown ID is normally rewritten to the +default model by `getValidatedModelId` +(`webview-ui/src/components/ui/hooks/useSelectedModel.ts:56`), which yields a +valid `info`. The `undefined` case therefore arises when the router model list +is empty rather than merely missing the ID: no API key configured, a failed or +in-flight fetch, or offline. In that state the default-model lookup also misses, +`info` is `undefined`, and the `|| 1` fallback produces both the "token limit +shows 1" symptom and the 7000% reading. They are two faces of one fault. + +### Defect 2 — webview and extension host disagree on the model + +`getValidatedModelId` silently substitutes the provider default when the +configured ID is absent from the list, while `openRouterModelId` continues to +hold the user's typed value. The extension host does not perform the same +substitution — `src/api/providers/openrouter.ts:554`: + +```ts +let info = this.models[id] ?? openRouterDefaultModelInfo +``` + +The host sends the user's real ID with a 200K-context default profile; the +webview displays a different model entirely. Requests may succeed while the UI +describes something else. + +### Defect 3 — no override UI outside the OpenAI-compatible provider + +`openAiCustomModelInfo` (`packages/types/src/provider-settings.ts:245`) is the +only user-facing way to supply `contextWindow` / `maxTokens`, and it is wired +solely to the `openai` provider's settings panel +(`webview-ui/src/components/settings/providers/OpenAICompatible.tsx:286-347`). +OpenRouter, Requesty, Unbound, Vercel AI Gateway and Zoo Gateway offer no +equivalent, so a custom model on those providers can never be given correct +token limits. + +## Goals + +1. Let the user override context window and max output tokens for any model on + the router providers, and have that override govern both the UI and the real + request/truncation path. +2. Ensure the UI never displays a nonsensical figure when no override is set. + +## Non-goals + +- Editing per-token pricing. Overridden prices would corrupt cost reporting; + that is separate work. +- Migrating or removing `openAiCustomModelInfo`. It keeps working unchanged. +- Reworking `ModelPicker`'s custom-model entry flow. + +## Architecture + +Two layers resolve model info independently and must not diverge: + +| Layer | Resolver | +|---|---| +| Webview | `getSelectedModel()` in `useSelectedModel.ts:132` | +| Extension host | each provider's `getModel()` (30 implementations) | + +An override applied to only one layer would fix the display while leaving +context truncation wrong. The design therefore applies one shared helper at both +layers, each through a single chokepoint. + +Two facts from the codebase make the host-side chokepoint viable: there is +exactly one factory, `buildApiHandler` (`src/api/index.ts:153`), and no +`instanceof Handler` check exists anywhere in `src/`. A wrapper around the +returned handler is therefore safe. + +### Data model + +Add one field to `baseProviderSettingsSchema` +(`packages/types/src/provider-settings.ts:176`): + +```ts +customModelInfo: modelInfoSchema.partial().nullish(), +``` + +`partial()` is deliberate. The field is an **overlay**, not a replacement: a user +who sets only `contextWindow` keeps the fetched values for price, image support +and reasoning. Placing it on the base schema means every provider inherits it, +avoiding the five near-identical fields that a per-provider approach would need. + +`openAiCustomModelInfo` remains as-is. Where both are present, `customModelInfo` +is applied second and wins on the fields it defines. + +### Shared helper + +In `packages/types` (importable by both webview and host): + +```ts +applyCustomModelInfo( + info: ModelInfo | undefined, + settings: { customModelInfo?: Partial | null } | undefined, +): ModelInfo | undefined +``` + +Behaviour: + +- `info` present → return `info` with the override's **defined and valid** keys + merged over it. +- `info` absent but the override supplies a positive `contextWindow` → synthesise + a `ModelInfo` from a synthesis base plus the override. This is what makes a + genuinely unknown model usable. +- Neither → return `undefined`, preserving today's "invalid selection" signal. + +The synthesis base is defined locally rather than reusing +`openAiModelInfoSaneDefaults`, whose `maxTokens: -1` sentinel +(`packages/types/src/providers/openai.ts:692-693`) would propagate a negative +value into arithmetic: + +```ts +const CUSTOM_MODEL_SYNTHESIS_BASE = { + maxTokens: undefined, + supportsImages: false, + supportsPromptCache: false, +} satisfies Partial +``` + +`contextWindow` is deliberately absent from the base: synthesis only runs when +the override supplies a positive one, so the merged result always has a real +value and never a fabricated default. + +Leaving `maxTokens` undefined is safe rather than lossy. `getModelMaxOutputTokens` +(`src/shared/api.ts:131-133`) supplies `ANTHROPIC_DEFAULT_MAX_TOKENS` whenever the +model ID contains `claude` and `maxTokens` is absent — which covers the reported +`anthropic/claude-sonnet-4-6` case. For non-Anthropic IDs it returns `undefined` +(line 158-160), which `TaskHeader` already handles by reserving nothing. + +A key is treated as "valid" when it is not `undefined`/`null`, and — for the +numeric fields `contextWindow` and `maxTokens` — is a finite number greater than +zero. Invalid entries are dropped, never coerced to `0`, because +`contextWindow: 0` would reproduce the original division fault. + +### Integration points + +**Webview** — apply the helper to the `{ id, info }` produced by the ternary at +`useSelectedModel.ts:98-113`, not to `getSelectedModel()`'s return. That ternary +has three branches: the resolved call, a `kimi-code` fallback, and a +not-ready/invalid-provider fallback that yields `info: undefined`. The override +must cover all three — the third is precisely the still-loading state that +produces the reported symptom, and `getSelectedModel()` is not called there at +all. Applying it after the ternary covers every branch and leaves the 30 `switch` +cases untouched. + +**Host** — in `buildApiHandler`, wrap the constructed handler in a `Proxy` that +decorates `getModel()` and forwards everything else. Forwarding uses +`Reflect.get(target, prop, target)` — passing `target` rather than the proxy as +receiver, so private class fields continue to resolve. All twelve +`this.api.getModel().info` consumers in `Task.ts` inherit the corrected value, +including the context-window-exceeded and condense paths. + +### Display hardening + +Independent of any override, so the UI is correct when the user sets nothing: + +- `TaskHeader.tsx:72` — drop `|| 1`. When no context window is known, skip + rendering the percentage entirely rather than printing a fabricated number. +- `TaskHeader.tsx:253-258` — clamp the upper bound with `Math.min(100, …)` and + render at/over 100% in a warning colour. Keep the existing + `availableInputSpace > 0` guard: it is the lower bound, and an over-large + `maxTokens` override can still drive `availableInputSpace` to zero or below. +- `useSelectedModel.ts:51-57` — stop substituting the provider default for a + configured-but-unlisted ID on the router providers. The condition is *the + configured ID is absent from the list*, which covers both an empty list and a + populated list that lacks the user's custom ID; the current guard conflates + them. The litellm case (lines 178-189) is the in-repo precedent for returning + the configured ID untouched. + + This aligns the webview with the host, which never substitutes — closing + Defect 2's divergence. It does not make the two produce identical `info`: the + host still falls back to `openRouterDefaultModelInfo` (200K) at + `openrouter.ts:554` while the webview yields `undefined`. Full convergence is + what the shared helper delivers once an override exists, and is why the helper + must be bound at both layers rather than the webview alone. + + Callers that assume a non-empty, listed ID must be checked. `ModelPicker` + already tolerates it: `modelIds` explicitly retains `selectedModelId` + (lines 122-127) and the initialization effect at 187-194 only fires when + `selectedModelId` is falsy, so a preserved custom ID is displayed rather than + overwritten. + +### UI + +New shared component `CustomModelInfoSettings.tsx`, following the field pattern +already established in `OpenAICompatible.tsx` (text field, green/red border +validation, label plus description). Rendered beneath `ModelPicker` for the +router providers: OpenRouter, Requesty, Unbound, Vercel AI Gateway, Zoo Gateway. + +Collapsible, collapsed by default. It auto-expands, with an explanatory note, +when the selected model has no resolved info — the exact situation this feature +addresses. + +Fields: **context window**, **max output tokens**, **supportsImages**, +**supportsPromptCache**. A "reset to detected values" control clears the +override. + +New i18n keys under `settings:providers.customModelInfo.*` in +`webview-ui/src/i18n/locales/en/settings.json`. Only English is authored; other +locales fall back until translated. + +## Error handling + +| Input | Result | +|---|---| +| Empty string | Key omitted from overlay | +| `NaN` / non-numeric | Key omitted, red border | +| `<= 0` | Key omitted, red border | +| Valid positive integer | Applied, green border | + +`maxTokens` exceeding `contextWindow` is accepted but flagged with an inline +warning. The 20% context-window clamp in `getModelMaxOutputTokens` +(`src/shared/api.ts:154`) is **not** a reliable backstop here — three earlier +branches return before reaching it: reasoning-budget models (line 117), Anthropic +contexts with `supportsReasoningBudget` or absent `maxTokens` (lines 126-133), +and `supportsMaxTokens` models honouring an explicit `modelMaxTokens` (line 138). +The first two are exactly the `anthropic/claude-*` path in this bug report. + +Since the clamp cannot be relied on, the inline warning is the actual guard, and +`TaskHeader`'s `availableInputSpace` must tolerate `reservedForOutput >= +contextWindow`. Its existing `> 0` guard already returns `0%` rather than a +negative percentage; the display-hardening change must preserve that guard rather +than replace it with the new `Math.min(100, …)` clamp. + +## Testing + +- `applyCustomModelInfo` unit tests: overlay onto existing info; synthesis from + absent info; empty/invalid/zero/negative input dropped rather than coerced; + `undefined` returned when nothing is available. +- `TaskHeader` regression test: with `info === undefined`, assert no `7000%`-class + output — the percentage element is absent. This is the lock on the reported bug. +- `TaskHeader` clamp test: `contextTokens` exceeding the window renders `100%`, + not more. +- `useSelectedModel` tests: an empty router model list preserves the configured + custom ID rather than substituting the default; a *populated* list that lacks + the configured ID also preserves it. The second case is the one the current + guard gets wrong. +- `useSelectedModel` test: the override applies in the not-ready branch (router + models still loading), where `getSelectedModel()` is never called. +- `buildApiHandler` proxy test: `getModel()` reflects the override while other + methods and private field access remain intact. +- `CustomModelInfoSettings` component tests: validation borders, persistence, + reset, auto-expansion when info is unresolved. + +## Files affected + +| File | Change | +|---|---| +| `packages/types/src/provider-settings.ts` | Add `customModelInfo` to base schema | +| `packages/types/src/model.ts` (or sibling) | Add `applyCustomModelInfo` + synthesis base | +| `webview-ui/src/components/ui/hooks/useSelectedModel.ts` | Apply helper after the ternary (98-113); stop substituting the default for an unlisted ID | +| `src/api/index.ts` | Proxy-wrap handler in `buildApiHandler` | +| `webview-ui/src/components/chat/TaskHeader.tsx` | Remove `\|\| 1`; clamp; conditional render | +| `webview-ui/src/components/settings/CustomModelInfoSettings.tsx` | New component | +| `webview-ui/src/components/settings/providers/{OpenRouter,Requesty,Unbound,VercelAiGateway,ZooGateway}.tsx` | Mount component | +| `webview-ui/src/i18n/locales/en/settings.json` | New keys | + +## Risks + +- **Proxy overhead** — `getModel()` is called frequently (twelve sites in + `Task.ts` alone). The decoration is a shallow object spread over a plain + object; negligible, but the overlay should not be recomputed per call beyond + that. +- **Stale override after model switch** — an override set for one custom model + persists when the user picks a different one. Accepted: the reset control and + the collapsed-by-default panel keep this visible. Auto-clearing on model change + risks discarding deliberate configuration. From 3f7641f4162c8ff15bec110da9d20799032ce3df Mon Sep 17 00:00:00 2001 From: everyoneexe Date: Tue, 4 Aug 2026 17:28:39 +0200 Subject: [PATCH 2/3] feat: support custom model info overrides --- .../src/__tests__/custom-model-info.test.ts | 68 ++++++ packages/types/src/model.ts | 74 ++++++ packages/types/src/provider-settings.ts | 2 + .../providers/__tests__/openrouter.spec.ts | 20 ++ src/api/providers/__tests__/requesty.spec.ts | 18 ++ src/api/providers/__tests__/unbound.spec.ts | 21 ++ src/api/providers/openrouter.ts | 7 +- src/api/providers/requesty.ts | 12 +- src/api/providers/router-provider.ts | 16 +- src/api/providers/unbound.ts | 12 +- webview-ui/src/components/chat/TaskHeader.tsx | 142 +++++++----- .../chat/__tests__/TaskHeader.spec.tsx | 22 ++ .../src/components/settings/ApiOptions.tsx | 5 + .../settings/CustomModelInfoSettings.tsx | 218 ++++++++++++++++++ .../CustomModelInfoSettings.spec.tsx | 67 ++++++ .../settings/providers/OpenRouter.tsx | 9 + .../settings/providers/Requesty.tsx | 9 + .../components/settings/providers/Unbound.tsx | 9 + .../settings/providers/VercelAiGateway.tsx | 9 + .../settings/providers/ZooGateway.tsx | 11 +- .../providers/__tests__/ZooGateway.spec.tsx | 7 +- .../hooks/__tests__/useSelectedModel.spec.ts | 71 +++--- .../components/ui/hooks/useSelectedModel.ts | 73 +++++- webview-ui/src/i18n/locales/en/settings.json | 23 ++ 24 files changed, 811 insertions(+), 114 deletions(-) create mode 100644 packages/types/src/__tests__/custom-model-info.test.ts create mode 100644 webview-ui/src/components/settings/CustomModelInfoSettings.tsx create mode 100644 webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx 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..1d2b3da830 --- /dev/null +++ b/packages/types/src/__tests__/custom-model-info.test.ts @@ -0,0 +1,68 @@ +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) + }) +}) diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 9fbf9e358b..b7c74219c0 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -183,6 +183,80 @@ 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. + */ +export const customModelInfoSchema = z + .object({ + maxTokens: z.number().int().positive().optional(), + contextWindow: z.number().int().positive().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__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index 254cd1dad4..6f5d42ab10 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -139,6 +139,26 @@ 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("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..8d0d203d1d 100644 --- a/src/api/providers/__tests__/requesty.spec.ts +++ b/src/api/providers/__tests__/requesty.spec.ts @@ -158,6 +158,24 @@ 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("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..b9d7f8acb2 100644 --- a/src/api/providers/__tests__/unbound.spec.ts +++ b/src/api/providers/__tests__/unbound.spec.ts @@ -38,6 +38,27 @@ 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("identifies itself as Zoo Code in the Unbound request headers", () => { new UnboundHandler({ unboundApiKey: "test-key", 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..4de87d9bdb 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -69,7 +69,8 @@ const TaskHeader = ({ const textContainerRef = useRef(null) const textRef = useRef(null) - const contextWindow = model?.contextWindow || 1 + const contextWindow = model?.contextWindow + const contextWindowForDisplay = typeof contextWindow === "number" && contextWindow > 0 ? contextWindow : undefined // Calculate maxTokens (reserved for output) once for reuse in percentage and tooltip const maxTokens = useMemo( @@ -201,71 +202,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 +319,13 @@ const TaskHeader = ({
e.stopPropagation()}> - + {contextWindowForDisplay !== undefined && ( + + )} {condenseButton}
@@ -342,7 +356,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..7e5a9e3112 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -334,5 +334,27 @@ 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() + }) }) }) 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..52fb15062f --- /dev/null +++ b/webview-ui/src/components/settings/CustomModelInfoSettings.tsx @@ -0,0 +1,218 @@ +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 + + if (target && "value" in target && typeof target.value === "string") { + return target.value + } + + return "" +} + +const getCheckboxValue = (event: ValueChangeEvent): boolean => { + const target = event.target + + if (target && "checked" in target && typeof target.checked === "boolean") { + return target.checked + } + + return 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..36d9537772 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx @@ -0,0 +1,67 @@ +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) + }) +}) 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..e1af24a3c7 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -110,7 +110,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 +159,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 +263,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 +301,46 @@ 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("preserves a router model ID and applies custom metadata while model data is loading", () => { + mockUseRouterModels.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + } as any) + + mockUseOpenRouterModelProviders.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + } as any) + + 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/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." From e382bfe3482226f5f694d3eaaeffd990f1793d64 Mon Sep 17 00:00:00 2001 From: everyoneexe Date: Tue, 4 Aug 2026 21:40:07 +0200 Subject: [PATCH 3/3] fix: address custom model metadata review feedback --- .../src/__tests__/custom-model-info.test.ts | 7 + packages/types/src/model.ts | 10 +- src/api/providers/__tests__/kenari.spec.ts | 11 + .../providers/__tests__/openrouter.spec.ts | 46 ++++ src/api/providers/__tests__/requesty.spec.ts | 19 ++ src/api/providers/__tests__/unbound.spec.ts | 19 ++ .../__tests__/vercel-ai-gateway.spec.ts | 12 + .../providers/__tests__/zoo-gateway.spec.ts | 12 + webview-ui/src/components/chat/TaskHeader.tsx | 5 +- .../chat/__tests__/TaskHeader.spec.tsx | 10 + .../settings/CustomModelInfoSettings.tsx | 12 +- .../CustomModelInfoSettings.spec.tsx | 57 +++++ .../hooks/__tests__/useSelectedModel.spec.ts | 227 +++++++++++++++++- webview-ui/src/i18n/locales/ca/settings.json | 23 ++ webview-ui/src/i18n/locales/de/settings.json | 23 ++ webview-ui/src/i18n/locales/es/settings.json | 23 ++ webview-ui/src/i18n/locales/fr/settings.json | 23 ++ webview-ui/src/i18n/locales/hi/settings.json | 23 ++ webview-ui/src/i18n/locales/id/settings.json | 23 ++ webview-ui/src/i18n/locales/it/settings.json | 23 ++ webview-ui/src/i18n/locales/ja/settings.json | 23 ++ webview-ui/src/i18n/locales/ko/settings.json | 23 ++ webview-ui/src/i18n/locales/nl/settings.json | 23 ++ webview-ui/src/i18n/locales/pl/settings.json | 23 ++ .../src/i18n/locales/pt-BR/settings.json | 23 ++ webview-ui/src/i18n/locales/ru/settings.json | 23 ++ webview-ui/src/i18n/locales/tr/settings.json | 23 ++ webview-ui/src/i18n/locales/vi/settings.json | 23 ++ .../src/i18n/locales/zh-CN/settings.json | 23 ++ .../src/i18n/locales/zh-TW/settings.json | 23 ++ 30 files changed, 814 insertions(+), 24 deletions(-) diff --git a/packages/types/src/__tests__/custom-model-info.test.ts b/packages/types/src/__tests__/custom-model-info.test.ts index 1d2b3da830..a05a58e42c 100644 --- a/packages/types/src/__tests__/custom-model-info.test.ts +++ b/packages/types/src/__tests__/custom-model-info.test.ts @@ -65,4 +65,11 @@ describe("custom model info", () => { }).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 b7c74219c0..9784ae0ba3 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -188,10 +188,16 @@ export type ModelInfo = z.infer * 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: z.number().int().positive().optional(), - contextWindow: z.number().int().positive().optional(), + maxTokens: positiveSafeIntegerSchema.optional(), + contextWindow: positiveSafeIntegerSchema.optional(), supportsImages: z.boolean().optional(), supportsPromptCache: z.boolean().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 6f5d42ab10..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", @@ -159,6 +164,47 @@ describe("OpenRouterHandler", () => { 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 8d0d203d1d..0cb2b017ee 100644 --- a/src/api/providers/__tests__/requesty.spec.ts +++ b/src/api/providers/__tests__/requesty.spec.ts @@ -176,6 +176,25 @@ describe("RequestyHandler", () => { 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 b9d7f8acb2..7af68942a3 100644 --- a/src/api/providers/__tests__/unbound.spec.ts +++ b/src/api/providers/__tests__/unbound.spec.ts @@ -59,6 +59,25 @@ describe("UnboundHandler", () => { 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/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 4de87d9bdb..bb070a71cd 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -70,7 +70,10 @@ const TaskHeader = ({ const textContainerRef = useRef(null) const textRef = useRef(null) const contextWindow = model?.contextWindow - const contextWindowForDisplay = typeof contextWindow === "number" && contextWindow > 0 ? contextWindow : undefined + 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( diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 7e5a9e3112..9d7cbf5e08 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -356,5 +356,15 @@ describe("TaskHeader", () => { 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/CustomModelInfoSettings.tsx b/webview-ui/src/components/settings/CustomModelInfoSettings.tsx index 52fb15062f..dc39ef8e69 100644 --- a/webview-ui/src/components/settings/CustomModelInfoSettings.tsx +++ b/webview-ui/src/components/settings/CustomModelInfoSettings.tsx @@ -30,21 +30,13 @@ const parsePositiveInteger = (value: string): number | undefined => { const getEventValue = (event: ValueChangeEvent): string => { const target = event.target - if (target && "value" in target && typeof target.value === "string") { - return target.value - } - - return "" + return target && "value" in target && typeof target.value === "string" ? target.value : "" } const getCheckboxValue = (event: ValueChangeEvent): boolean => { const target = event.target - if (target && "checked" in target && typeof target.checked === "boolean") { - return target.checked - } - - return false + return target && "checked" in target && typeof target.checked === "boolean" ? target.checked : false } const getInputBorderColor = (value: string): string | undefined => { diff --git a/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx index 36d9537772..1966bf8860 100644 --- a/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx @@ -64,4 +64,61 @@ describe("CustomModelInfoSettings", () => { 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/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index e1af24a3c7..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: { @@ -307,18 +450,80 @@ describe("useSelectedModel", () => { }) 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({ - data: undefined, - isLoading: true, - isError: false, - } as any) + mockUseRouterModels.mockReturnValue(createQueryResult(undefined, emptyRouterModels, true)) - mockUseOpenRouterModelProviders.mockReturnValue({ - data: undefined, - isLoading: false, - isError: false, - } as any) + mockUseOpenRouterModelProviders.mockReturnValue(createQueryResult({}, {}, false)) const apiConfiguration: ProviderSettings = { apiProvider: providerIdentifiers.openrouter, 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/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 +374,12 @@ const TaskHeader = ({
+
{condenseButton}
+