From 61bca0d6425114d57ec2e69b2f3762811e52c4e5 Mon Sep 17 00:00:00 2001 From: everyoneexe Date: Tue, 4 Aug 2026 03:06:05 +0200 Subject: [PATCH 1/9] 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/9] 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/9] 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 請求間的最短時間" From ee085a2bd846f349ad81d4ef5dd03af327891080 Mon Sep 17 00:00:00 2001 From: everyoneexe Date: Sat, 8 Aug 2026 00:25:06 +0200 Subject: [PATCH 4/9] chore: remove internal design spec from PR The design spec was an internal planning document and should not be included in the upstream pull request. Co-Authored-By: Claude Opus 4.6 --- ...2026-08-04-custom-model-settings-design.md | 301 ------------------ 1 file changed, 301 deletions(-) delete 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 deleted file mode 100644 index fe9bc538ee..0000000000 --- a/docs/superpowers/specs/2026-08-04-custom-model-settings-design.md +++ /dev/null @@ -1,301 +0,0 @@ -# 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 546ddf46a9afc7b0bb738cbe8ea50390ecbfa980 Mon Sep 17 00:00:00 2001 From: everyoneexe Date: Sat, 8 Aug 2026 00:58:15 +0200 Subject: [PATCH 5/9] fix: harden tests and improve code clarity across custom model info - Fix false-positive ZooGateway tests by adding positive render assertions - Remove local input state from useEffect deps to prevent unnecessary re-runs - Reset getModelEndpoints mock between OpenRouter tests to prevent leak - Add missing boolean override assertions in requesty/unbound specs - Add positive assertions to TaskHeader infinite-context and call-count checks - Add missing useOpenRouterModelProviders mock for Kimi Code test block - Extract shared CUSTOM_MODEL_INFO_PROVIDERS constant in useSelectedModel - Add explanatory comments to router-provider gate and ZooGateway effect - Fix duplicate test name in zoo-gateway spec - Add aria-describedby for accessibility on custom model info inputs Co-Authored-By: Claude Opus 4.6 --- .../providers/__tests__/openrouter.spec.ts | 9 ++++++-- src/api/providers/__tests__/requesty.spec.ts | 2 ++ src/api/providers/__tests__/unbound.spec.ts | 2 ++ .../providers/__tests__/zoo-gateway.spec.ts | 2 +- src/api/providers/router-provider.ts | 11 ++++++++++ .../chat/__tests__/TaskHeader.spec.tsx | 3 +++ .../settings/CustomModelInfoSettings.tsx | 12 +++++++---- .../CustomModelInfoSettings.spec.tsx | 7 +++++++ .../settings/providers/ZooGateway.tsx | 4 ++++ .../providers/__tests__/ZooGateway.spec.tsx | 6 ++++++ .../hooks/__tests__/useSelectedModel.spec.ts | 8 +++++++ .../components/ui/hooks/useSelectedModel.ts | 21 ++++++++++++------- 12 files changed, 73 insertions(+), 14 deletions(-) diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index 5721b45872..2a397802ff 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -112,7 +112,12 @@ describe("OpenRouterHandler", () => { openRouterModelId: "anthropic/claude-sonnet-4", } - beforeEach(() => vitest.clearAllMocks()) + beforeEach(() => { + vitest.clearAllMocks() + // Reset getModelEndpoints to its default empty-object return so per-test + // overrides (e.g. specific-provider test) don't leak into subsequent tests. + vitest.mocked(getModelEndpoints).mockResolvedValue({}) + }) it("initializes with correct options", () => { const handler = new OpenRouterHandler(mockOptions) @@ -212,7 +217,7 @@ describe("OpenRouterHandler", () => { expect(result.info.supportsPromptCache).toBe(true) }) - it("honors custom maxTokens for thinking models", async () => { + it("clamps maxTokens to 20% of context window for thinking models", async () => { const handler = new OpenRouterHandler({ openRouterApiKey: "test-key", openRouterModelId: "anthropic/claude-3.7-sonnet:thinking", diff --git a/src/api/providers/__tests__/requesty.spec.ts b/src/api/providers/__tests__/requesty.spec.ts index 0cb2b017ee..3288955488 100644 --- a/src/api/providers/__tests__/requesty.spec.ts +++ b/src/api/providers/__tests__/requesty.spec.ts @@ -173,6 +173,8 @@ describe("RequestyHandler", () => { 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) }) diff --git a/src/api/providers/__tests__/unbound.spec.ts b/src/api/providers/__tests__/unbound.spec.ts index 7af68942a3..fbe8a48c07 100644 --- a/src/api/providers/__tests__/unbound.spec.ts +++ b/src/api/providers/__tests__/unbound.spec.ts @@ -75,6 +75,8 @@ describe("UnboundHandler", () => { expect(result.id).toBe(modelId) expect(result.info.contextWindow).toBe(100_000) expect(result.info.maxTokens).toBe(10_000) + expect(result.info.supportsImages).toBe(false) + expect(result.info.supportsPromptCache).toBe(false) expect(result.maxTokens).toBe(10_000) }) diff --git a/src/api/providers/__tests__/zoo-gateway.spec.ts b/src/api/providers/__tests__/zoo-gateway.spec.ts index 254f76d2c4..c7b06987f2 100644 --- a/src/api/providers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/__tests__/zoo-gateway.spec.ts @@ -264,7 +264,7 @@ describe("ZooGatewayHandler", () => { })) }) - it("requires authentication at request time when no session token is available", async () => { + it("requires authentication when draining the stream with no session token", async () => { const handler = new ZooGatewayHandler({}) const stream = handler.createMessage("You are helpful.", [{ role: "user", content: "Hello" }]) diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts index 6b0eb8b4e4..2d56afaeba 100644 --- a/src/api/providers/router-provider.ts +++ b/src/api/providers/router-provider.ts @@ -58,6 +58,17 @@ export abstract class RouterProvider extends BaseProvider { private modelFetchPromise?: Promise<{ id: string; info: ModelInfo }> + /** + * Apply user-supplied `customModelInfo` overrides for gateway providers. + * + * Only vercel-ai-gateway and zoo-gateway opt in here because the other + * RouterProvider subclasses (openrouter, requesty, unbound) apply overrides + * in their own `getModel()` methods — they need to merge with provider- + * specific logic (e.g. specific-provider endpoints, tool preferences) that + * runs before the overlay. LiteLLM, Kenari, and OpenCode Go don't support + * `customModelInfo` because they have their own discovery mechanisms and + * are not exposed in the settings UI. + */ private resolveModelInfo(info: ModelInfo | undefined, fallback: ModelInfo): ModelInfo { if (this.name !== "vercel-ai-gateway" && this.name !== "zoo-gateway") { return info ?? fallback diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 9d7cbf5e08..1e80719674 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -362,6 +362,9 @@ describe("TaskHeader", () => { renderTaskHeader() + // Positive assertion: the component rendered and the task text is visible. + expect(screen.getByText("Test task")).toBeInTheDocument() + expect(screen.queryByTestId("context-tokens-count")).not.toBeInTheDocument() expect(screen.queryByTestId("context-window-size")).not.toBeInTheDocument() expect(screen.queryByText(/%$/)).not.toBeInTheDocument() diff --git a/webview-ui/src/components/settings/CustomModelInfoSettings.tsx b/webview-ui/src/components/settings/CustomModelInfoSettings.tsx index dc39ef8e69..faba75a6ec 100644 --- a/webview-ui/src/components/settings/CustomModelInfoSettings.tsx +++ b/webview-ui/src/components/settings/CustomModelInfoSettings.tsx @@ -69,13 +69,15 @@ export const CustomModelInfoSettings = ({ if (parsePositiveInteger(contextWindowInput) !== configuredContextWindow) { setContextWindowInput(configuredContextWindow?.toString() ?? "") } - }, [configuredContextWindow, contextWindowInput]) + // eslint-disable-next-line react-hooks/exhaustive-deps -- Sync external config into local state only when the persisted value changes, not on every keystroke. + }, [configuredContextWindow]) useEffect(() => { if (parsePositiveInteger(maxTokensInput) !== configuredMaxTokens) { setMaxTokensInput(configuredMaxTokens?.toString() ?? "") } - }, [configuredMaxTokens, maxTokensInput]) + // eslint-disable-next-line react-hooks/exhaustive-deps -- Sync external config into local state only when the persisted value changes, not on every keystroke. + }, [configuredMaxTokens]) useEffect(() => { if (!selectedModelInfo) { @@ -150,8 +152,9 @@ export const CustomModelInfoSettings = ({ placeholder={selectedModelInfo?.contextWindow?.toString() ?? "0"} style={{ borderColor: getInputBorderColor(contextWindowInput), width: "100%" }} aria-invalid={hasInvalidContextWindow} + aria-describedby="custom-context-window-desc" /> - + {t("settings:providers.customModelInfo.contextWindow.description")} @@ -167,8 +170,9 @@ export const CustomModelInfoSettings = ({ placeholder={selectedModelInfo?.maxTokens?.toString() ?? "0"} style={{ borderColor: getInputBorderColor(maxTokensInput), width: "100%" }} aria-invalid={hasInvalidMaxTokens} + aria-describedby="custom-max-tokens-desc" /> - + {t("settings:providers.customModelInfo.maxTokens.description")} diff --git a/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx index 1966bf8860..cb0bcea24b 100644 --- a/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx @@ -38,9 +38,11 @@ describe("CustomModelInfoSettings", () => { const contextWindowInput = screen.getByLabelText("settings:providers.customModelInfo.contextWindow.label") fireEvent.input(contextWindowInput, { target: { value: "128000" } }) + expect(setApiConfigurationField).toHaveBeenCalledTimes(1) expect(setApiConfigurationField).toHaveBeenLastCalledWith("customModelInfo", { contextWindow: 128_000 }) fireEvent.click(screen.getByText("settings:providers.customModelInfo.reset")) + expect(setApiConfigurationField).toHaveBeenCalledTimes(2) expect(setApiConfigurationField).toHaveBeenLastCalledWith("customModelInfo", undefined) }) @@ -84,13 +86,18 @@ describe("CustomModelInfoSettings", () => { expect(screen.getByText("settings:providers.customModelInfo.maxTokensWarning")).toBeInTheDocument() fireEvent.click(screen.getByText("settings:providers.customModelInfo.supportsImages.label")) + expect(setApiConfigurationField).toHaveBeenCalledTimes(1) expect(setApiConfigurationField).toHaveBeenLastCalledWith("customModelInfo", { contextWindow: 1000, maxTokens: 2000, supportsImages: true, }) + // Note: the component reads customModelInfo from apiConfiguration props, which + // doesn't re-render with the updated value — so the second toggle operates on + // the original prop state. This tests each toggle independently. fireEvent.click(screen.getByText("settings:providers.customModelInfo.supportsPromptCache.label")) + expect(setApiConfigurationField).toHaveBeenCalledTimes(2) expect(setApiConfigurationField).toHaveBeenLastCalledWith("customModelInfo", { contextWindow: 1000, maxTokens: 2000, diff --git a/webview-ui/src/components/settings/providers/ZooGateway.tsx b/webview-ui/src/components/settings/providers/ZooGateway.tsx index 87e7290d65..ba04148bdc 100644 --- a/webview-ui/src/components/settings/providers/ZooGateway.tsx +++ b/webview-ui/src/components/settings/providers/ZooGateway.tsx @@ -71,6 +71,10 @@ export const ZooGateway = ({ const modelIds = useMemo(() => Object.keys(zooModels), [zooModels]) const resolvedDefaultModelId = useMemo(() => pickZooGatewayDefaultModelId(modelIds), [modelIds]) + // Auto-select the default model only when no model is configured yet. + // We intentionally do NOT reset the selection when the configured model + // is absent from the catalog — router providers accept arbitrary model + // IDs (e.g. custom deployments) that may not appear in the fetched list. useEffect(() => { if (modelIds.length === 0) { return 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 aabf8a4796..adcf7a48db 100644 --- a/webview-ui/src/components/settings/providers/__tests__/ZooGateway.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/ZooGateway.spec.tsx @@ -125,6 +125,9 @@ describe("ZooGateway component", () => { />, ) + // Verify the component rendered (not a crash) by checking the model picker is present. + expect(screen.getByTestId("model-picker")).toBeInTheDocument() + await waitFor(() => { expect(setApiConfigurationField).not.toHaveBeenCalled() }) @@ -146,6 +149,9 @@ describe("ZooGateway component", () => { />, ) + // Verify the component actually rendered — prevents false positive on crash. + expect(screen.getByTestId("model-picker")).toBeInTheDocument() + await waitFor(() => { expect(setApiConfigurationField).not.toHaveBeenCalled() }) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index af8accb7cb..18293f64b7 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -1295,6 +1295,14 @@ describe("useSelectedModel", () => { }) describe("Kimi Code provider", () => { + beforeEach(() => { + mockUseOpenRouterModelProviders.mockReturnValue({ + data: {}, + isLoading: false, + isError: false, + } as any) + }) + it("should resolve the configured model from router models", () => { const modelInfo: ModelInfo = { ...kimiCodeDefaultModelInfo, diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 6a0f503850..a22274bbe4 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -63,7 +63,20 @@ function getValidatedModelId( return configuredId && availableModels?.[configuredId] ? configuredId : defaultModelId } +/** Providers that accept user-supplied `customModelInfo` overrides. */ +const CUSTOM_MODEL_INFO_PROVIDERS: ReadonlySet = new Set([ + providerIdentifiers.openrouter, + providerIdentifiers.requesty, + providerIdentifiers.unbound, + providerIdentifiers.vercelAiGateway, + providerIdentifiers.zooGateway, +]) + function getConfiguredRouterModelId(provider: ProviderName, apiConfiguration: ProviderSettings): string | undefined { + if (!CUSTOM_MODEL_INFO_PROVIDERS.has(provider)) { + return undefined + } + switch (provider) { case providerIdentifiers.openrouter: return apiConfiguration.openRouterModelId @@ -81,13 +94,7 @@ function getConfiguredRouterModelId(provider: ProviderName, apiConfiguration: Pr } function supportsCustomModelInfo(provider: ProviderName): boolean { - return ( - provider === providerIdentifiers.openrouter || - provider === providerIdentifiers.requesty || - provider === providerIdentifiers.unbound || - provider === providerIdentifiers.vercelAiGateway || - provider === providerIdentifiers.zooGateway - ) + return CUSTOM_MODEL_INFO_PROVIDERS.has(provider) } export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { From 13ce7f0c51046f0207795de782d33867858c7388 Mon Sep 17 00:00:00 2001 From: everyoneexe Date: Sun, 9 Aug 2026 00:42:38 +0200 Subject: [PATCH 6/9] fix: address maintainer review feedback on custom model info - Fix invalid input deleting existing overrides: non-empty invalid text now stays in local display state without removing the persisted value - Add accumulation test using rerender to verify toggles accumulate - Extract shared customModelInfoProviders constant to @roo-code/types - Add end-to-end tests verifying customModelInfo.maxTokens reaches max_completion_tokens in zoo-gateway and vercel-ai-gateway requests - Add Playwright CT visual snapshots for CustomModelInfoSettings (collapsed, expanded, warning, unresolved states) - Add cross-reference comment in router-provider.ts Co-Authored-By: Claude Opus 4.6 --- packages/types/src/provider-settings.ts | 22 +++++ .../__tests__/vercel-ai-gateway.spec.ts | 18 ++++ .../providers/__tests__/zoo-gateway.spec.ts | 18 ++++ src/api/providers/router-provider.ts | 3 + .../settings/CustomModelInfoSettings.tsx | 17 +++- .../CustomModelInfoSettings.spec.tsx | 75 ++++++++++++-- ...CustomModelInfoSettings.visual.fixture.tsx | 99 +++++++++++++++++++ .../CustomModelInfoSettings.visual.tsx | 60 +++++++++++ .../components/ui/hooks/useSelectedModel.ts | 20 +--- 9 files changed, 305 insertions(+), 27 deletions(-) create mode 100644 webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.visual.fixture.tsx create mode 100644 webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.visual.tsx diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index ea7f602f62..2fe76091aa 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -73,6 +73,28 @@ export type DynamicProvider = (typeof dynamicProviders)[number] export const isDynamicProvider = (key: string): key is DynamicProvider => dynamicProviders.includes(key as DynamicProvider) +/** + * Providers that accept user-supplied `customModelInfo` overrides in the + * settings UI. This is a strict subset of `dynamicProviders` — only providers + * whose model metadata can be manually adjusted by the user. + * + * NOTE: Of these, only vercel-ai-gateway and zoo-gateway apply the overlay + * inside `RouterProvider.resolveModelInfo()`. The others (openrouter, requesty, + * unbound) apply it in their own overridden `getModel()` methods. + */ +export const customModelInfoProviders = [ + providerIdentifiers.openrouter, + providerIdentifiers.requesty, + providerIdentifiers.unbound, + providerIdentifiers.vercelAiGateway, + providerIdentifiers.zooGateway, +] as const + +export type CustomModelInfoProvider = (typeof customModelInfoProviders)[number] + +export const isCustomModelInfoProvider = (key: string): key is CustomModelInfoProvider => + customModelInfoProviders.includes(key as CustomModelInfoProvider) + /** * LocalProvider * diff --git a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts index 2e53300ade..91213539ff 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -177,6 +177,24 @@ describe("VercelAiGatewayHandler", () => { expect(result.info.supportsImages).toBe(false) }) + it("propagates customModelInfo maxTokens into the completePrompt request body", async () => { + mockCreate.mockImplementation(async () => ({ + choices: [{ message: { role: "assistant", content: "ok" } }], + })) + + const handler = new VercelAiGatewayHandler({ + ...mockOptions, + customModelInfo: { contextWindow: 100_000, maxTokens: 10_000 }, + }) + await handler.completePrompt("test") + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + max_completion_tokens: 10_000, + }), + ) + }) + it("returns default model info when options are not provided", async () => { const handler = new VercelAiGatewayHandler({}) const result = await handler.fetchModel() diff --git a/src/api/providers/__tests__/zoo-gateway.spec.ts b/src/api/providers/__tests__/zoo-gateway.spec.ts index 6176e2e04c..3a2581a204 100644 --- a/src/api/providers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/__tests__/zoo-gateway.spec.ts @@ -222,6 +222,24 @@ describe("ZooGatewayHandler", () => { expect(result.info.supportsPromptCache).toBe(false) }) + it("propagates customModelInfo maxTokens into the completePrompt request body", async () => { + mockCreate.mockImplementation(async () => ({ + choices: [{ message: { role: "assistant", content: "ok" } }], + })) + + const handler = new ZooGatewayHandler({ + ...mockOptions, + customModelInfo: { contextWindow: 100_000, maxTokens: 10_000 }, + }) + await handler.completePrompt("test") + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + max_completion_tokens: 10_000, + }), + ) + }) + it("falls back to the default model when none is configured", async () => { const handler = new ZooGatewayHandler({ zooSessionToken: "zoo_ext_test_token" }) const result = await handler.fetchModel() diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts index 2d56afaeba..420067fdfc 100644 --- a/src/api/providers/router-provider.ts +++ b/src/api/providers/router-provider.ts @@ -68,6 +68,9 @@ export abstract class RouterProvider extends BaseProvider { * runs before the overlay. LiteLLM, Kenari, and OpenCode Go don't support * `customModelInfo` because they have their own discovery mechanisms and * are not exposed in the settings UI. + * + * See also: `customModelInfoProviders` in @roo-code/types for the full set + * of providers whose UI exposes the override panel. */ private resolveModelInfo(info: ModelInfo | undefined, fallback: ModelInfo): ModelInfo { if (this.name !== "vercel-ai-gateway" && this.name !== "zoo-gateway") { diff --git a/webview-ui/src/components/settings/CustomModelInfoSettings.tsx b/webview-ui/src/components/settings/CustomModelInfoSettings.tsx index faba75a6ec..32690b560d 100644 --- a/webview-ui/src/components/settings/CustomModelInfoSettings.tsx +++ b/webview-ui/src/components/settings/CustomModelInfoSettings.tsx @@ -100,13 +100,26 @@ export const CustomModelInfoSettings = ({ const handleContextWindowInput = (event: ValueChangeEvent) => { const value = getEventValue(event) setContextWindowInput(value) - updateOverride("contextWindow", parsePositiveInteger(value)) + + const parsed = parsePositiveInteger(value) + + // Only persist when the input is valid OR deliberately empty (= user + // cleared the field). Non-empty invalid input (e.g. "12abc") stays in + // local state without deleting an existing valid override. + if (parsed !== undefined || value.trim() === "") { + updateOverride("contextWindow", parsed) + } } const handleMaxTokensInput = (event: ValueChangeEvent) => { const value = getEventValue(event) setMaxTokensInput(value) - updateOverride("maxTokens", parsePositiveInteger(value)) + + const parsed = parsePositiveInteger(value) + + if (parsed !== undefined || value.trim() === "") { + updateOverride("maxTokens", parsed) + } } const resetOverrides = () => { diff --git a/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx index cb0bcea24b..9e89713da0 100644 --- a/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.spec.tsx @@ -64,17 +64,43 @@ describe("CustomModelInfoSettings", () => { expect(maxTokensInput).toHaveValue("12abc") expect(maxTokensInput).toHaveAttribute("aria-invalid", "true") - expect(setApiConfigurationField).toHaveBeenLastCalledWith("customModelInfo", undefined) + // Invalid non-empty input should NOT persist — the override callback is not called + expect(setApiConfigurationField).not.toHaveBeenCalled() + }) + + it("does not delete an existing valid override when the user types invalid input", () => { + const setApiConfigurationField = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByText("settings:providers.customModelInfo.title")) + + const contextWindowInput = screen.getByLabelText("settings:providers.customModelInfo.contextWindow.label") + fireEvent.input(contextWindowInput, { target: { value: "12abc" } }) + + expect(contextWindowInput).toHaveValue("12abc") + expect(contextWindowInput).toHaveAttribute("aria-invalid", "true") + // The existing 64000 override must NOT be deleted + expect(setApiConfigurationField).not.toHaveBeenCalled() }) - it("updates capability overrides and warns when output exceeds the context window", () => { + it("clears the override when the user empties the input field", () => { const setApiConfigurationField = vi.fn() render( { fireEvent.click(screen.getByText("settings:providers.customModelInfo.title")) + const contextWindowInput = screen.getByLabelText("settings:providers.customModelInfo.contextWindow.label") + fireEvent.input(contextWindowInput, { target: { value: "" } }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("customModelInfo", undefined) + }) + + it("accumulates capability overrides across toggles with rerender", () => { + const setApiConfigurationField = vi.fn() + const baseConfig: ProviderSettings = { + apiProvider: "unbound", + customModelInfo: { contextWindow: 1000, maxTokens: 2000 }, + } + + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByText("settings:providers.customModelInfo.title")) + expect(screen.getByText("settings:providers.customModelInfo.maxTokensWarning")).toBeInTheDocument() + // Toggle 1: enable supportsImages fireEvent.click(screen.getByText("settings:providers.customModelInfo.supportsImages.label")) expect(setApiConfigurationField).toHaveBeenCalledTimes(1) expect(setApiConfigurationField).toHaveBeenLastCalledWith("customModelInfo", { @@ -93,14 +143,25 @@ describe("CustomModelInfoSettings", () => { supportsImages: true, }) - // Note: the component reads customModelInfo from apiConfiguration props, which - // doesn't re-render with the updated value — so the second toggle operates on - // the original prop state. This tests each toggle independently. + // Re-render with the updated configuration so toggle 2 sees toggle 1's effect + rerender( + , + ) + + // Toggle 2: disable supportsPromptCache — should accumulate with supportsImages: true fireEvent.click(screen.getByText("settings:providers.customModelInfo.supportsPromptCache.label")) expect(setApiConfigurationField).toHaveBeenCalledTimes(2) expect(setApiConfigurationField).toHaveBeenLastCalledWith("customModelInfo", { contextWindow: 1000, maxTokens: 2000, + supportsImages: true, supportsPromptCache: false, }) }) diff --git a/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.visual.fixture.tsx b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.visual.fixture.tsx new file mode 100644 index 0000000000..1a0fa0a667 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.visual.fixture.tsx @@ -0,0 +1,99 @@ +/* v8 ignore file -- Playwright component fixture; covered by the visual test. */ +import React from "react" + +import type { ModelInfo } from "@roo-code/types/model" + +import { TranslationContext } from "@src/i18n/TranslationContext" +import { CustomModelInfoSettings } from "../CustomModelInfoSettings" + +const selectedModelInfo: ModelInfo = { + contextWindow: 200_000, + maxTokens: 64_000, + supportsImages: true, + supportsPromptCache: true, +} + +const translations: Record = { + "settings:providers.customModelInfo.title": "Custom model metadata", + "settings:providers.customModelInfo.description": + "Override context and capability metadata when the provider cannot detect your model accurately.", + "settings:providers.customModelInfo.unresolved": + "Model metadata is unavailable. Enter the context window to enable accurate token tracking.", + "settings:providers.customModelInfo.contextWindow.label": "Context window", + "settings:providers.customModelInfo.contextWindow.description": + "Total tokens the model can process, including input and output.", + "settings:providers.customModelInfo.maxTokens.label": "Max output tokens", + "settings:providers.customModelInfo.maxTokens.description": + "Maximum number of tokens the model can generate in one response.", + "settings:providers.customModelInfo.supportsImages.label": "Supports images", + "settings:providers.customModelInfo.supportsImages.description": + "Override whether the model accepts image content.", + "settings:providers.customModelInfo.supportsPromptCache.label": "Supports prompt caching", + "settings:providers.customModelInfo.supportsPromptCache.description": + "Override whether prompt caching is supported.", + "settings:providers.customModelInfo.maxTokensWarning": "Max output tokens exceed the context window.", + "settings:providers.customModelInfo.reset": "Reset to detected values", +} + +const translationValue = { + t: (key: string) => translations[key] ?? key, + i18n: null as unknown as typeof import("../../../i18n/setup").default, +} + +/** Collapsed panel — the default when selectedModelInfo is present. */ +export const CollapsedFixture = () => ( + +
+ {}} + selectedModelInfo={selectedModelInfo} + /> +
+
+) + +/** Expanded panel with populated overrides. */ +export const ExpandedWithOverridesFixture = () => ( + +
+ {}} + selectedModelInfo={selectedModelInfo} + /> +
+
+) + +/** Expanded panel with maxTokens > contextWindow warning. */ +export const WarningFixture = () => ( + +
+ {}} + selectedModelInfo={selectedModelInfo} + /> +
+
+) + +/** Expanded panel with no selectedModelInfo (unresolved model — auto-opens). */ +export const UnresolvedFixture = () => ( + +
+ {}} + selectedModelInfo={undefined} + /> +
+
+) diff --git a/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.visual.tsx b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.visual.tsx new file mode 100644 index 0000000000..b0b4aeb434 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/CustomModelInfoSettings.visual.tsx @@ -0,0 +1,60 @@ +import React from "react" + +import { expect, test } from "../../../../playwright/coverage-fixture" +import { + CollapsedFixture, + ExpandedWithOverridesFixture, + WarningFixture, + UnresolvedFixture, +} from "./CustomModelInfoSettings.visual.fixture" + +test("renders the collapsed panel in the VS Code dark theme", async ({ mount }) => { + const component = await mount() + + await component.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + + await expect(component).toHaveScreenshot("custom-model-info-collapsed-dark.png") +}) + +test("renders the expanded panel with overrides in the VS Code dark theme", async ({ mount }) => { + const component = await mount() + + const trigger = component.getByText("Custom model metadata") + await trigger.click() + + await component.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + + await expect(component).toHaveScreenshot("custom-model-info-expanded-overrides-dark.png") +}) + +test("renders the maxTokens exceeds contextWindow warning in the VS Code dark theme", async ({ mount }) => { + const component = await mount() + + const trigger = component.getByText("Custom model metadata") + await trigger.click() + + await component.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + + await expect(component).toHaveScreenshot("custom-model-info-warning-dark.png") +}) + +test("renders the unresolved model state in the VS Code dark theme", async ({ mount }) => { + const component = await mount() + + // When selectedModelInfo is undefined the panel auto-opens + await component.evaluate(async () => { + await document.fonts.ready + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + }) + + await expect(component).toHaveScreenshot("custom-model-info-unresolved-dark.png") +}) diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index a22274bbe4..e42babaab1 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -35,6 +35,7 @@ import { BEDROCK_1M_CONTEXT_MODEL_IDS, VERTEX_1M_CONTEXT_MODEL_IDS, isDynamicProvider, + isCustomModelInfoProvider, isRetiredProvider, getProviderDefaultModelId, providerIdentifiers, @@ -63,20 +64,7 @@ function getValidatedModelId( return configuredId && availableModels?.[configuredId] ? configuredId : defaultModelId } -/** Providers that accept user-supplied `customModelInfo` overrides. */ -const CUSTOM_MODEL_INFO_PROVIDERS: ReadonlySet = new Set([ - providerIdentifiers.openrouter, - providerIdentifiers.requesty, - providerIdentifiers.unbound, - providerIdentifiers.vercelAiGateway, - providerIdentifiers.zooGateway, -]) - function getConfiguredRouterModelId(provider: ProviderName, apiConfiguration: ProviderSettings): string | undefined { - if (!CUSTOM_MODEL_INFO_PROVIDERS.has(provider)) { - return undefined - } - switch (provider) { case providerIdentifiers.openrouter: return apiConfiguration.openRouterModelId @@ -93,10 +81,6 @@ function getConfiguredRouterModelId(provider: ProviderName, apiConfiguration: Pr } } -function supportsCustomModelInfo(provider: ProviderName): boolean { - return CUSTOM_MODEL_INFO_PROVIDERS.has(provider) -} - export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { const provider = apiConfiguration?.apiProvider || "openrouter" const activeProvider: ProviderName | undefined = isRetiredProvider(provider) ? undefined : provider @@ -162,7 +146,7 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { const { id } = selectedModel const info = - activeProvider && supportsCustomModelInfo(activeProvider) + activeProvider && isCustomModelInfoProvider(activeProvider) ? applyCustomModelInfo(selectedModel.info, apiConfiguration) : selectedModel.info From eac42d65351fe24e1fd24c828153149f63a013a2 Mon Sep 17 00:00:00 2001 From: everyoneexe Date: Sun, 9 Aug 2026 00:56:08 +0200 Subject: [PATCH 7/9] test: add Playwright CT baseline screenshots for CustomModelInfoSettings Co-Authored-By: Claude Opus 4.6 --- .../custom-model-info-collapsed-dark.png | Bin 0 -> 1871 bytes ...ustom-model-info-expanded-overrides-dark.png | Bin 0 -> 24273 bytes .../custom-model-info-unresolved-dark.png | Bin 0 -> 22636 bytes .../custom-model-info-warning-dark.png | Bin 0 -> 26909 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-collapsed-dark.png create mode 100644 webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-expanded-overrides-dark.png create mode 100644 webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-unresolved-dark.png create mode 100644 webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-warning-dark.png diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-collapsed-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-collapsed-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..4c952ce05c63f120a51582b0ffb26369888dedc8 GIT binary patch literal 1871 zcmchY={wtr7RP^{($Sb3xH#C`&$0V{DDJL^lBS)z ziYWc5dSaLP{l^{T}&V7);EEn*QbsgSmNmVMan`SlTSVvb3~xiE_WV zc!(1#)-^D@4lg?zg-uLcQCA8}4%}W!SsK|`8f_hXHb=9KfKkf^b3mIL z@=xd#X>jv-?@t5e6m+ofSUY)3)DXl9eV!Bq}UBhNQBHa4fY3FCI{xN?*Y z-@+knO{(sY-F9Kn%)A{WKhu1k6D_E^G}Q%nNf!=WmCuM+5tPr9ohVfNNXx|Q92$*w zAu7t$G1bRtnwPxbd`-!J7AGMz62o4k`f|<{9Q0mUS$UQ)&iw9GEaO_RYG>O5Ujc)| zS*3SO(KWfj7aqA#($zyGvl)!;E$pm>D|>VEn^;kOAc2sgm=$cT%W}qlnzjIyPE7aG zjAZd^(vOhGwTh{?cN7W*{*iOCY>Yhc(4o4zniS1*lk;aKjlWz(v@JYpHPNaO3V;&mpq`CL4S7cVTcHx-X&kJIfn2ISl z81pnYDNB48;bw&k={eG7mBifMT5sqZZ@t38rY#B{{0of^*$R|d$AvuxR~{MVB{#$q zR3fCXA;|X2rnQ5wayJC_0-c;uIMrKx_=A!a@p2xAl=PJz8!SiHevMB5(o$w@!#@Nv zNl_aiZGEZSg%a*R=erQcJ=gQk?Hj+hMZ8ScclDma?^^VYs%2dK*c8R~s0Vemw44Y+ zs{P=HC!Ac{T#e1T9b`<}RLs%KoGdInC1O@YkV-6JVF{R8h<{FnGr8W0WWvzaFLfAx zdfOCjJ)Hr~54!W7-k7J#M{kYoH$`Zzeg}fKv}5XnWMH zgcIvOTv;5hj^h-;sF3bQ`*KM7)`VYupu^Vobl)iQ+G(y_KJwZ6lw5`R6Wn~8}D5%hzX7k{lILkropF`+)td1xwm zcM%&Ebe_tU7MGMTBuN?LMBh-7brtU(n{5ZC-#Df%!-1FA7DGCjr~^E-%rnV^RB#ns zlby?;3qAEW1cD5^u(Nv&2(0gme!IAbaqQ>mcSYy>%VKunk#xdd(cM5G!ryfoS z=$>PvixMvWmgy*cAXT{>$*~ML1V<%B53^$|EiFkW<~L?HLffx7Tq_~*{cCZe*coA+ zKk>RyE?l8Mvp8MyScjWFq``2Q%#0Wk)#Q8O@#{n`0K8Nh@&PJEivrdKj8D00QGK=T r{&%oyp#Fv`)%_hHQw=(Q|3fX{6FbJjhJmP2X8_D8oICUTvzdPZ^DBE+ literal 0 HcmV?d00001 diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-expanded-overrides-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-expanded-overrides-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..5aad7bd9146da31ce013e551bc5769940967da76 GIT binary patch literal 24273 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%0AhTm- zia-C&M^Sq@VHSAr`O}^qErmLs2A_7UBgz%3ivh;-F=rvSEr&_KJ^zdsMvS`QA zNTsC_Ep4YSV8FIi5@F2u zzU%9YolX5t!a`W`3jYhwA3+2hoTy0!iOTZJ&?I2!+j7KDlv--D3u3wUJ}u{;Df4R&$FGb%TWAVy@?WwQvk|0)WjkJulgTKVuj4Mvj~5h5 zT@{1r?l4}w7!X4)*y$%aDQn;V?7n4Pd0W}91Xa;CKR%k6?|NU~iD`7awCwj+!5W-Z z5k2k6XblDUCSvj{PY%%|-R*-$`e?DC;JlMnu>c*rOLsi|VNQai?V+3YrZz<^TV69% z!MhkZ{bgG{aYB5R8e(V&pV7oN5e}@ah*0H3jw_axk7f?q%9|HrKMenKIEY1XQo1Pb zdxtUI_=D0mQEw|53))GzrMUawU*E8VRkFW?YksFY=$7i9g)bCWSeRs&=#zqS$|Xj4 z44?T-=|*WPUzjP-8XmVrxwra;Hh*ZQbp^kaat08=_`KZQa@s*$_{j*<{ zH7Loimo258d%dk+FWaX3#+*y=2^tcMn)5Hr1Ns)=kLT{YmKG5~uTB_owqabJ_PalW zS8AnCF*BNK7LZ;~{Be_HT!4pC*fG2df2gy(3)^kk2sxDUUaRC~J)Q7(Xe{ZHX_^OO z!pIqYy5xlVHji5PW=J~I$Iw}D{ky+;XY1>v(Gvlb#@Rne%Gr}ruU^(Mm)Q;$Q$n=I zRG8VpIS-(o9CABMZcAXbtUa^wk?o^SN|W(+@nFw?Rd2qXUF)wmfNe3j!11JtHP%zPwCXk;<$OVSm-wob`tS)`vhy=NH#73*VfKxQyw8D{z*GD zQaAT>G{;~a+KdGvd6lx=%q*LvAQ2VL{)wec+WR)@zeRX84h;K6&3kN>%I;!Q_R%D%+T&TulDTB7fGa~tpRWaRQr7~bD* z&0Wot#X>&37#^dlOj$2MPO>O8jNrI{f@aYUn~*!V zvuJ{Q?XJq3H6-uzA8a#!HcZZ}D#?-ugNjqGm65}=q(bBQNbi^`RbIu^}QB@;t&Pk?ZgoE)X< zy>YjwtiU8^=veGcO-yHiUe8W)?f}atw6u0{+kL**E?C^t^VojJx&NsJ@asAj^iuJC z$bP3`DHdAP)RaT3MW};qO!@ns7b^o&L+}UL>Q%0OCW1~iv3{659x=j5)CiEvoPqyk_SYSQ^?O-l;%nh zwAzzd(VSts_1%l(ny~Yr?DVCQ3GnMX-azL!O6cN7OL#=ai}!~07YtDwq>2(Nl^(WM zbOB0Py5EXc-z8sKv{&>k2jN;K$GQkxEC$gA)&FSnf3c07KA;s+Lo;|cqvIOc?7N6e ztM#j#_M;6my*EG8A~c_;Tx}%Y`6~F_|LI_*!-UiCA4LJo?zwhE_?*W&j>Eez`#&iN zW+SI`i7Ila$wg1P2_OzA_u$?a48&eTa&N03Crv!l2-3T6oVb9Fn941gU+A$ps&Qv5 zDFkhkcZ2mmKT3yR^cB-=8%H#6kZpzy58u5H!OA1p;SSTqX#jI_2d@QCE@=MY$HaxV|Xl2I#ggr-= z?@noan37HXU(9;T99+Vw_NRp;^D-mL@-qeu-5cd&NE98G{K7^}p?LFo`QF_<{%`TD zUJ=pIS31pR@H;#C%T{c1w((jiWS}bu{+`KpR=U&sR=h5%wbY^mgQA1TMric=T0}MR zqCMH1WVvzhj5lu6L3eqh$^6KMaP(hM&FvpWgc45em zN61`P+ zF{mei*$35G_tyrd3)Bc7Ja~Vfn1qCcRf}prnb$1m5OfG2>-pNb(resEE9~&6?b&)Y zwQTX{RgP0^ttGNQYGs4&PF2{-c<-9S^rH~6(*arl(gBs2+X{RyF^8T(bAUepeW24P zK-A;oVJ3z&6TiRr4X~8>v)o42H<#OLbwOxg4S<02;^2877iMZTxtJutDgP27duCP zr%&c@uDyV%&3iA$gqxYd8qUR>cy67VJSTysEOb6@FB0Ksa) z^u=GO-pdA;MfscSD_~E3uIGoF5?3E&#S77qsc84LC(u_lKYIUUdfS4QD9^^=dJU*6kS_yzaomjgbUK z6}R2g=F9i+e%6O_WnLT?+eb~CJD!4a7O=n4L&E(;2j8~mH8Y$X(VGw?=qzWZEK^wKn(k1} z7OU~td;lfjcd#4*G_Ii;8~^XtR7H{~urwm}2MdOQM?W8L6xWmVKJ{stSObQdlGl*J zJq|E-cc2Pr*^l&KQ10l*+n@3UAH5V1BhG0BtxLYko>A)xx+o?oPk$(vYNro$Ggh?% zsg0|Plg!eVjwn*i`)wiUy1@R&@!56<>{ssPb&KPzrU}}{I%5Q3UMszdkPh=AcH#}- z35o?yox=Gz_9NyJ=&PQUKTABrUPeVunwT|IA|q%p)nizTP!2b_;`e|iywU^=^)rp z%@)7sFa*j2*cskSimO9gpt%0}^oVn*4b2XO=?IFO!!hlGgXpiIE5E=0Mt%)#i{Js; z=>)}wIfzvX3JMW6czGKWCG$`lItnZ0iV*~Cyra?K`E8;VNP#{xDlbnDhGf>C=CdPM z52bKV_rBrOw1-K<&`&*M!!Qx@Q2K0z@Ey4%>&jbe*M@ji3ojdu`8v zzz?D;ZGizS=vqW1>eVlY68e1T4tttg02OYU&ek$-PY*0+ymg{D)nleA?Z-Yn`V7zW zT4PxSI<1s;jwH-Ar3^J=7_gvU^^lmB!9EVpu%p2k2D>SxvUBV?z)CEvuHA$UWGy3ukM31EysgB6u= zo~e1-RNdCRwmhG5MNhnsS0npj;Bh0il`7P4u|b(1p%{yicLxT2`+@8snviP*v|I_9 z1l@yd=WES)y4IVYM4TjY(Xf$;-GU0Ng5+P#ng0X@Zn}pqCf}w26hv z=G6PxQX$uOZ6^|UWYHD-Y$`ClYpes5xS?zbk=vaXhyYhY+I0yj4oSp$&Tnt21FAbI z?N;>9=>Lz$PPk@y+MmYHs+qBpSq?i5O+FwsJZIDU z7;oK7Tmq#zPCIlf*_PUGYk8xD<1==?*l8qf?Z>aJIQlce3q?^7`Mf(_aDDmEB_4rF z4ugnoxWUJrGf}Z>4$KOA{o)ND%hFwzgr<7;wSG8F81G#3m18i{!fLxjr$L!T>b)4! zfn{z#J6QiBlLjLpmD|X3y6RcVDWdo3?o*iFUp83&>7Xesdw0J8#YKo<%9ng742QZd z@TQ45NB7;+vktXl7>;nNXeGT%?MB`Kcm7fIFf8=8(?f~cgT$zXR|x30+&0a-#X2+#gfbc|yJA6=E!51ca$D`)Tka~fw{nR>ivgR9 z*Ww8jBs|_j_K4&vs~!TJ8ewQ4huoSjuMtkNg=1-Da+X1pE7x+5yzb8u6J+7Xf}XNY z)MsLj}L0M9M4hvf|?yPuP-3VPcBE-?S4T2E}_~OJ)|gncuP~t#%3QA zyIb7v>YuLU1K%~g5rm^Qm*N!kqn4AAWqbzK4j#{sGe+#IP`zVF!;Y-pj;G*T3C zMPNdwP=`G2jwR!NWkEi#CB}vCi7m)dGLn z7rST!6-Pun919wQQE^#9Em`O};y6Yy{wXBs^NbR>MVUmgGkgPgkLj_Q5U)D#nonIf z)gom^5p$B1zQ+NjxXx>b#q6+Ynm;RM%WA$XXh8d=zOnW6zq5OJxQ!5 z^_Y{&2ZnC_z6y^Sn~3^UEn|c{&lj6|XCDWo(zOoW@gy)zG2rHN1bprB7_h^vS4eKL z!i+CUun%h8JU5PL1LAnplu&MB7#8`hR82zW6_A{*zVV>wKu|--d$|AE%FpuV&uuPU z4(?j?JjIu%$k9w%$Z@I2T&V9`ye#!yqMKiCEF>BoqgF<1@FfpC5@lSA3e`ZA+|E~t zV_+l0E9LpbS2)c36~myMS2ff6MV_dc*E0>8HIDmT2<%x)tmDv7d-a~qu&qR(zvi@^ z+G()i-@TIjI(7_g%=KmcKM!EA#pIRp>ExZoM%TZ0!i*qV%uD~O&~Qq++J;p@FRNl; zKJ)VtFxDY?eNx|?dvE0RzZ$EFZ?pam=06>5rewju#1y=6@{NvtZ)ET~<|PF!pCnr@ zW!US{+|B^A=z+=(q1<{K$9$RExWzY{UXNOu&P@znlYi%Wz83rpp^L_3fM>X2hHBA9 zbDtXoHuUf_A;H;h-?()i;sEIglpxOwIvg14mgb8M3mtg86%1xLqzvTI&jpLp?kplV zQM<^dai2OtIVsysey=;IY5cOl%t*dStynvJQb~CB`w1i*FRkSn6NQ#o{mNldBk1#Fr52n-TV|^3d;9kT zmEKG9yAyMzZ*9>EpWwHAL*p;}H<+%M7;oTZ*?E~6O1rZz zoycF-hNIyA^7Ag$D|+DAl{S9kLCNA^ZfaGo0SuuRHbW<5$6zYyvulvP!c-?^cX!F-ZuV<$d?fSfR-{fn z&vD1QZ1Jp0t=v!FjVkrKNA8_-*x({gFuey_6M;|QXm zd2G+5XHij_-4362sjM^&JRDl-X!EkSi?vjYL91g02L(|!1cnv}c@O$o0uq6K19`Um zq*nIIQ(8)amw%2gy1Sg1|MEOC99vhEhQ@*{$fnQsPG(q8OYaYGVSdYp7Nu6zGx8yc zOf~}OisgA{67ZDmWEbZFFJfX@yGef60b>_!Up=eOcHZWUizsnKf;i30yQ?BV=F zerM5~-NM&M8}FE$%OjysZbUi%VoY#wUbr>nvX~22KhlLF8IF>w*KoGF@R+Nt7V@5{y`(T-fCg%^c=J?*y!j}-y9ypR43sv?jC|yxK-(Z z;!r^<>mnFeS~ZPt5qL0!heUzQXH9v5)B)k9I*7rb0-GWew26MG2aN-`e}u0z| z0&1cUhzw-N{}5XWG%|$;^nMg-=K=#d+?l`0y}4w4fA;A2WbpiK;|s5SV-WuUQIQK+ zg;R=b!^@Sf0}7D2Ivj)fzQ5Xct5kxz#`C0-4Ui@@NiC}i@Sp+Jz!KnVD*g&q{&DIB zNEd$dDjj4`z&$kl_Rkx!OdvWy1{qV)7{t4aZgWspA58&!;f(_r5mS2WFZJS>3arv} z0G;QZl+N)>UtN2X*+JyJ-mc#PVyFD~eFyDWJ znqr+O5xB}B4)Qvg7iDg0^86QHkijlrK#C3KA%vBp0>5ssED+@m)T`r?YHS}6dGIq4h&Z@Rr%BJu5d9FWs=(c-_kh$=c zNUZbd*8j&TS7X(K=mb@>1N!Xgb^D_(FCN2kCWKtT@5}d>y>9o9TJdUV3r&`=wPlZq z<}VY2`)MK-gs!On3P+r#c~AXYA4xs1!-v*HML}^ZO83Ra;5Fl5V%EDZqwE>?9UHk0 zX=({2SFF{Tx4ugG(or*Xo2~FUV3EV^GewCc<=tNK@|bxU=ltNV{{3)ESSLkYwvaS| zLOY^8SNCVI57`}^agZ&6BZN_X4(C-nfg!Za7^0S;Y_COMcG-b|r^4V*XxpDQ0+w>y z7!=G>8Xh->Y5|$OvQUH;B!C7^CsL?Cknx${*^l{$@SjsSGA=SuO0h_UXlwF0gXGR$ zpnxMq`6zeoK{cbj#T)EgoGrJ;yg$%P8+hsZ3JD$jdxbh{iK-1Q`7O!#cD)=Ph?k_Y z71clJDqP!oZ04_IKz@?`ZZ{lVHdoTe4df*RiA4}D^dJkk38Wf`a?)_vZ9OTfLJfYA z!KL*-#Lu|^@fvcZ-s2-TOhoc%ar>+X5PV%alcV|XM3Iu&nz)KU*awR`UDWm6!GlWx z)Q8Y?VC~^JJOKxLNan_I)CdAvjlM_TdrP21`n0@6SbLTQi5w^B3P7L13vRkTcQ~k8 z1>)TNf0bdJK}U4g(39K%wHKRQ%55dw*#amK*9GIF|0fORRrUY71`~b(8q8DEV_pz7 zxOMS2fCOUGNKT$bMp`##lXKHRb_&h|j5iS|*8{m?NH#z~(*blG*!x>X(Gf}9bF2P< z1O-mbZ{Qr=LIEDIWd|!ggpe#PNNt5AR9F4QB<*(^FNwqxZ-3D943V4wOd?Kwu6f1Sqvr&$c3S_t`Z8iI^sD@h zIE1}!9zQHLK;r*u+IXK~2rIkass;vttv?}qjN+}mVEJx+Xe(T6flfjW9VcAZ!1J_C ztv_9ml}4!5j`fa(YOb`@2rdJCQv_A6`14-yvDVWz@a>YlfcS!7ur3N-0T5eB%E~QV z%Y({cznyPTC6Xo$fj8GcNZi*4V|bDb!1Vy#ZdcU>!c(Bx6dTtHL`;=iE9CV!{%g+* znm-bNybe!%_Te^N=Z9JaLNjbmi%JfRAK<0^@a=uV_M;&$1zt&jGM*HURBb=@+BO)H zEEE!+#Ssq)|7;AuRe-G2@E@T2qS~al#jcp!03$Z;-OcZRfq}dKov+NJce8>0vk z*lWY&ZI3+4CGDwlp1(%-JIMpn4y-L)Zw`w3L$xIjb3Ab{Gw2mi)Wc;GKY9Se!L_(9^6q;M9?%kJvZMUkY;)O!ut32nQBaIM-5GamqoKB$Dgaagcxo~9S502&-d3wq*Y zu2ppdjz$rM=KZYKU=7k#2qDva>*8aWK|_Pds7W8L4v5FpXO;M1wDlD-rW?0U3IJm8 z#k``jeg%68JO{yLs9eTG7#axAx@ju=!!-aekG~r8Gq3cgCw(El3OrcT+zx25&U7O~ zv}#F%PEzJHLJ2GTzN9p){^gh^u}25!7n`)@ViN*18N$!*uj{b;gWpP|Y!0yHqX-jI zh|^nIzG-JNmo4GwCm_oJB>beCuYAvGMihpCl}!jAfuBVDG?8i4Ze9-+bOo>HS~+n1 zAlwZqk#Xy)2hsO95?x-+4~-tq@e)fClQpCPDGO^*WLa~0w`a3Jp)|yzHsTOB#avqv zYnL-jGQi~b#gXn_NQ4Cz#U6)#ahmncD)jFxBQgn3N99Wh#SY=oBOYKcezf==l)u~( zJ_Ar61Vsqhy@6@{^+|;dw!5_Xa}YW#CZSwo#T0b0D0r-#Sk+VdMR3QWbs-dN5c?go z7O5n4N(qDb=NF??gxeGs3jcr{CUjw&DcWs$X0^SmoI3=;@1Rt$xE17ZXe3o{@}y%? z2Qn%>|2`pkZ5DV%th>w_MIztNe-IhTM|Si!D>vXgqITtLDi-^Lf;7ITcfW8xC_o!~ zV_5lYT6mwY$WKOD;eEeGhRf0KM*$3em3A;lZQj+X$Evbc{BX?&Eh%=&&L5z05q{D8 zp31g{9m*nkyPB*v{%GR9*|X#%NJsV3XP#;BW%rcFi1gN3a}|Uc9)a`~!;wyeN`&!u z&8-f9lZkZ=--+?|>x!n?k2V1CvGwZQdxzmm%47(?NAkNMQ}0SV@x`bM!*LXH0|NyXehc&Iai6$M8%}ux&oC*3 zJT`RZ3COh9mMi{cS&Fu79$z`7(0Y?_A+9_%1>1e@d?yu`*Q9P`r8QUJlcKQJSpWG#`aJ2cdYPs{Q;k8W{9%QO%W}t}9f1dnV@R7saE;)p zouojlX3(~;AzF;r>(zdv26d*y0Gn0I(btc&`J+kFOBCzWh3}#4SVb35XJpI9<<9fceb=!WWm7>M8h+6M zEQ2;vk#Qw45{^?lgd%1<$CN0Gwt25LA= z>ME^qW!zP=`dfFx`33TrPm$2XtE20Om_3jM2WRkKV@O2YuC5kHnwOnN; zdUc2U&MBVtpS>1!YhSvWZZq$r^KY^2Dnk`AU%*dC4bi`wLX|*LD~P&jh3fS=!BMhA z)udyr1DL!(N;wGJ06xMewp(8NLlYX*rirWzlau|f!CET3aJ-cDpy*@)OfG@_KhdSDf}1|-RTd?R8k773i5OP zYeZuUBY1@)wC3~YW>}LtN}(iM8e38&p^)K5p z7aIFHf#^mzTPzZuaMp(rP|m7kl&*d%t%0sDP*D`8+UUVGhyBz;9!bkt;(6#vH0LYs zeZ)-ay*6*h&Sxv0nNPeKdYfy$77ur_GYT4gez}@PDVJnaX(wk>_X{b?k5(cK^$o$R zQpdQ&fg0=j$Pb?8-<@c>3zf<}#e9XexLwv!28<-(-_lG_ z;D6s5{;$}|aYssT$oMTl_U9B55&}JC8?2(7Mp!FokhBLCT_C0S?Hics0X`Z8r3Bmr zLetkk^*bYp9RSh;pbuBwH=vINHiN8dyHI@gyB_|k`}VIu3{+C5fopnBIfsuM;dO!S zxG!-4t(NdT+<>S+0Nm~Zd(KEZ1f&tCnVLOt=UOZ9>xuwFLIV8OEBBSO4+>b05rK;d zKfpPop|^p{a7PnnDn-qwOPGHmhCv8{w{`PXcIX@2dRi*lFF+QDILGbX0mxHz)eHgA z8v!d{^!CaM-0gyZjZ`eqi$e%jd;b2x4(SRzc&Lio5Dn}Gcp_M|(w87jFe*obw4wCX zxE9YVBmgqfARDQHnG3Tz{3ArQmqB|0g;zct9JIecF+u4GPlnzM$gK*N5Ev~W`X|7k z37J#@GzJ5xTk5!=*z_K@T4T3^Ok!=k8;f?1afzu^h6@;RB~9nQfyIC@vjw*pn*7d0 z=U|Co%xQAN|Kccgnr~_Z#|mN^`$-mkZ#cWQ5iW{6;FZCNkAUC}*oXEes;c4axj*{= z&u6k;c@5tR6}?7)I>A7P1lkGw3+7s2&k9Jqlqw(y? zMO_x4aPE$87c8SuKCjve4U)w`EylQg!XDP^Z`L=4^fwl92a`$Muf$Xnnb5D@01js* z>K?##KEM4QR_`e=bOk(%_tmD%!g;OSx*3-xo)>+|Vis+7ieK+bZpL(J$Up2fdt8{^WuZAjlOJMth_`3CX@ zbmXGyH1o;O)EDarM60%L)?Xc{#s}qr1IxM_8}k*v|EMuU#WRJwDdfwDKoXqB4zSAe z9jF$n8M=}cda~rV;oLb{&^0aGJ!p@s_z|m1!c#ZP~w)jZ2O2RkEWzc?-| zfQnYsB@bewEWu(g;g$_t$%s5^DS-W^R7Z0Hji#^M#BGU>C50gb?{(LHo-4Y~*N#D{ z(|XaYWQgw(8quM|@UUv z(X{&mhzfb@I?aEZf|FEY>V@2n%x!XQI8Uw^b;Av07w+kYaJ7fYEgGXjK7O!VQ5~Xs zFAXeFo*qIT>Q?tQqxoY(6(|JhK2~uJzmz-S&f@!#n9-(2IjDs>HSTbr;Shy0`OexM zj)8|9Cw(5iftuq6YdaID08fv<$bL!OB{&4FBH~`)@sw?dDA0-Sd{rnNhPXlnxL>^| znUD8h%~>X=0pBglp#zo*du0lw6qAH24cH+Jom=ATiv^V4?|92_-Yasv#7qX_HlVwg zGH65I2T}$Emzv&h-@XMve|wu41YD4}DM(0cloGpm=^rqu#^*t+9dyv}TU55{P2PmN ztmCxNV=Ht%Gu5Mv1?s6d7mTMIpTjHl!qN62@+SQ3R~oJ~B<^po*rA2Y*Z*PYgP+O& z(4mW>1U)tNh)gm7Jy4usbcHpLlTkJ3F7n+b94@+{#R1|#D(%B&-(|yv0kNInCh+xv z=?-teL!|(n4WkVx-P$t&l@G25>3`I@9TsrQ7Agi@LHhDJP`F@9&BL8Tkj zjz%ar2mNMuQGs<8=5>Dxj65;k;K5uOnS&Iubda4Iw!pWbd2Xro zWc*8z^M#SmGz|)X4qG42)@`r`nh~6ie|}Z6vqK_Rah|0~bEDhuayci8Fqpw2V8rT{B1r0=1_crh%ow!id zg&=dsnpK7qA=T0kQa=NKY{?^U$$Pfn#xQ-ftz>EAa@tQPINLSI+lghu;7ujJM3|9l zK$y?uY`5H3l(IX>2{7wNedM~aTY;vw9LtZJ9Y3Fh0aRm@un2U$8o@TaM4%pewN zOnj>VwLkIuxvliK4xC_)ei0HoY0j`|nyhx{mlZL2Wgo`a(g7GOR&HrC>wFv0Px-!E z*F71*7;Uy`g#Ve5Vjv)NIs})pR2$05c^i3Ku+Bci-U+pyDg`@oHP&5&(lDT3g`O|* zxpgUR!lMbtCWdAU=fYfcXtAIW>4PHj3#le=tOa&;9dGi2wsr(S-A z<|#JENl9Hrx<>gsdFyeknF&IDx1Vw6Gma-cccXDlEA4bsl%3+u6pC+}wf%+|0&5N& z>Eh+Nyh={ww$O)8Rq;z#US8Y*4~dmxNtR@fE!z%pP@whxK@ahzSxBOqLla%7aRyt6 zbs$2YqDk;eLF*Zz4|p#E`>t^?52n0jy<@$fHJ$DCxr}Olq(F^)R^uu>Yo6w_7Tjfp z6otO|S-MGac&m%I6&r^=@t-N$fC4{{`0@0we48m(=w!(wRD?J5#tS^CCByB-n}mf{ zgvf12aIqBcV38;JD`@J6rS(x<(~s|=&XDTM7524Lb)%JCc|0SQ=|ydBr5;;iQ$Y~bT4k_)yv1KA-8l@l8~_? z;yGSS$D+JSyt$;x{H0#+B~!32ZYfqyAYNteiF|83Gqwc>$$!4O39|MBa-fpf)+w~oQR+%H(wGw(HE zrD}z7XYO`<{aNv^kD6I2liXPNjg`e52lf-gzGRAQSm7Wp?M$xBMhsznAZDTp8oi>+ zbv)VO?}*$Su^OMBA#pqNKf40pyCW5wWp@nk9Sy&(rRTTM+dQ-{7JVCF3zYcQPRIH8 z*Z{s{4+iX2ljBVoe1^kMa0(1)7@;cySS^2ti;)G9NtiBIqd+oE-zlQQ$Gx!9`y!nW z4mhkWY%M>2;{guTZJNUO0gM?gSj&_y zHvQtsDBdQ!t39}w@*Fz!?dPJ%3;89v-29rNw8i~2bu~SVq?UTMD>I(2G8Or=`|(@D zmVU^6l8ZRaK)hCT4u+r#t-n;Wegb9$0}lcmlW;2^?A0xYTN(&h&I=kK0Ne*1Fsf_@vkWV2 zG`R|nAy|3)**6HE^LHWl4CYKma^58E%r(@R>%{rYH+o*2uUc)or}&)B?(W~fr2;l*s0|EK9xB)mxZgpC zx=3=p`xHb9yr#g*W!jyF1DxrD+neau;DrNZZM?a@I9*mX0$ifDHCBi#5X0d9k_Fth z9L2+GP;fquf=hKU>ouS7iVWyBFndzX%Y|Uv?krYqnt5M#T6}nI267$cEPuuHcW!I_ z5vY+oVUMXv*9NmQhX>#@gVg@GOx_%o1it-c5LJU(NtJC<>&6@vk}FQirjrN27pjbr zRc6Hc`WZxMIFXP8fMEa|SA3r(NN z__c(7R?Xt73Wx@K*n6`s@gw!Oih(&wk;VT(FqlY7sm0tvg5g3*!oZIgz+AvDcf%{F1DO3l z6jk&s7q@X6KQt~8gKevoF3@3cOA9O3(o3av z15)sCPD!=rOZ@cjUw06S*tTvFtF~hoe&(icA7i1Ar+SN?H17LsVaD04(R0tLLztFX zBS+Z#dP>(96c5M=X-QGf4eR?fqB_4WZNo?Pls0CY|6_lj z|JXV^ITIzlUR(X3c9!(r4@jv~u7t8^=;-{Lv6JLkUA_epzt=ARhN-nbTOp$zR`Xb4 zr|aH;6AZ??+dD5fe*1k3oY~u>3`W_!0V!drF%M(0Rry9C`Em|TbTExSA5u>3AE;R; zL|DOd0z%C=5X%N5cLvVWGf+KvvvJ&GmszzEKI>gA_`K}WsredWCbo9=2>2Ab03I`l zVn}oR>;;4-M~wxLc}e;5I^;{l&y3qn)fr1oIT43QrFNL$#+LrDTZ&$fP;D1Ebv(qZ zC_7IMbRRV)otM?&UR$Z;p#p&korubptAJBab7EAVFD}~FTBE9|?)7z6C}NqbF|y`J zx8j|n(zDnMOhJ3vUl0OZPE}%9fr4m9G&p(H11%Phlv@6)<#{q7(4uDjOn{$pWn%d?*6`F_8j&+GmEyl);jd3YlgX4TE5MrnV&jfxrYxBkNUj$Qam>WOmCrfL?^h}o`#n6VT0+g~%ix9|wR;Cgy>8d9pZ z5x0rxnq3Gcn2-B8^OA; z5f@yGKcG$ER6f&Jc5kUe8kRV_n(*i1vzyA!&O=W?{*tYmn;VwQ&;Ep!=lRc1J}= zBX8#$o&>gvcC=|HPEf`aS$Sn>U>dyrh<$ouUXt&R;&Hh-s!+i$=NFgw}T z9Ws=_X^}3us>gyjUM!E%=u8=ukb zQU8T7yq6w0JedM>#-z{RsXq*1J(6u5m}nSj6px^3au87mIpi(sJ&&j{&41lU!!z^a@@ms=9WvB*h__+>* z8c67UOW>zwE;WIN4sR;Gx
-
+
@@ -360,6 +374,12 @@ const TaskHeader = ({
+
{condenseButton}
+
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 UHugI);WrGXM&^b^1}>NX6CY%D#Q*>R literal 0 HcmV?d00001 diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-unresolved-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/custom-model-info-unresolved-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..05655b33ff630aeb8e84d1672a43484e93013efa GIT binary patch literal 22636 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 JSjqU=e*w=V3J(AP literal 0 HcmV?d00001 From 9ee14051ec6e114eb9eff0554a09fda686b6162a Mon Sep 17 00:00:00 2001 From: everyoneexe Date: Sun, 9 Aug 2026 01:05:56 +0200 Subject: [PATCH 8/9] fix: allow minor pixel diff in Playwright CT visual comparisons Add maxDiffPixelRatio: 0.02 to playwright-ct.config.ts to tolerate anti-aliasing differences between local and CI rendering environments (different OS, font stacks). The CI runner uses a noble Docker image while local development may use different Linux distributions. Co-Authored-By: Claude Opus 4.6 --- webview-ui/playwright-ct.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/webview-ui/playwright-ct.config.ts b/webview-ui/playwright-ct.config.ts index 3eb0abac7b..135e3646dc 100644 --- a/webview-ui/playwright-ct.config.ts +++ b/webview-ui/playwright-ct.config.ts @@ -89,6 +89,9 @@ export default defineConfig({ expect: { toHaveScreenshot: { animations: "disabled", + // Allow minor anti-aliasing differences between local and CI + // rendering environments (different OS / font stacks). + maxDiffPixelRatio: 0.02, }, }, projects: [ From 854e816a307f24a5a730a123ff9f6320f5ac3ecb Mon Sep 17 00:00:00 2001 From: everyoneexe Date: Tue, 11 Aug 2026 15:00:33 +0200 Subject: [PATCH 9/9] fix(webview): fix useSelectedModel.spec.ts after merge conflict resolution - Add missing 'type ModelRecord' import from @roo-code/types - Replace duplicate/conflicting createQueryResult definitions with a single unified signature (data, fallbackData, options) that returns a full UseQueryResult object with all required discriminated union fields - Update createRouterModelsResult, createOpenRouterModelProvidersResult, and createLocalModelsResult helpers to pass 3 arguments to createQueryResult --- .../hooks/__tests__/useSelectedModel.spec.ts | 176 +++++++++++------- 1 file changed, 105 insertions(+), 71 deletions(-) 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 a16ed7789c..35bda91e53 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -10,6 +10,7 @@ import { ModelInfo, type RouterModels, type ProviderName, + type ModelRecord, anthropicModels, BEDROCK_1M_CONTEXT_MODEL_IDS, litellmDefaultModelInfo, @@ -53,28 +54,124 @@ const mockUseOllamaModels = useOllamaModels as Mock type TestRouterModels = Partial> type QueryResultOptions = { isLoading?: boolean; isError?: boolean } -const createQueryResult = (data: TData, options: QueryResultOptions = {}) => ({ - data, - isLoading: options.isLoading ?? false, - isError: options.isError ?? false, -}) +const createQueryResult = ( + data: TData | undefined, + fallbackData: TData, + options: QueryResultOptions | boolean = {}, +): UseQueryResult => { + const isLoading = typeof options === "boolean" ? options : (options.isLoading ?? false) + const isError = typeof options === "boolean" ? false : (options.isError ?? false) + + if (isLoading) { + return { + data: undefined, + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isError: false, + isFetched: false, + isFetchedAfterMount: false, + isFetching: true, + isLoading: true, + isPending: true, + isLoadingError: false, + isInitialLoading: true, + isPaused: false, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: false, + isSuccess: false, + isEnabled: true, + refetch: vi.fn(), + status: "pending", + fetchStatus: "fetching", + promise: Promise.resolve(fallbackData), + } + } + + if (isError) { + const error = new Error("Test query error") + + return { + data: data ?? fallbackData, + dataUpdatedAt: 0, + error, + errorUpdatedAt: 0, + failureCount: 1, + failureReason: error, + errorUpdateCount: 1, + isError: true, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isLoading: false, + isPending: false, + isLoadingError: false, + isInitialLoading: false, + isPaused: false, + isPlaceholderData: false, + isRefetchError: true, + isRefetching: false, + isStale: true, + isSuccess: false, + isEnabled: true, + refetch: vi.fn(), + status: "error", + fetchStatus: "idle", + promise: Promise.resolve(data ?? fallbackData), + } + } + + return { + data: data ?? fallbackData, + dataUpdatedAt: 0, + error: null, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isError: false, + isFetched: true, + isFetchedAfterMount: true, + isFetching: false, + isLoading: false, + isPending: false, + isLoadingError: false, + isInitialLoading: false, + isPaused: false, + isPlaceholderData: false, + isRefetchError: false, + isRefetching: false, + isStale: false, + isSuccess: true, + isEnabled: true, + refetch: vi.fn(), + status: "success", + fetchStatus: "idle", + promise: Promise.resolve(data ?? fallbackData), + } +} // React Query exposes a discriminated union with additional runtime fields; these tests only need the stable query state fields. const createRouterModelsResult = ( data: TestRouterModels | undefined, options?: QueryResultOptions, -): ReturnType => createQueryResult(data, options) as ReturnType +): ReturnType => createQueryResult(data, {}, options) as ReturnType const createOpenRouterModelProvidersResult = ( data: Record | undefined, options?: QueryResultOptions, ): ReturnType => - createQueryResult(data, options) as ReturnType + createQueryResult(data, {}, options) as ReturnType const createLocalModelsResult = ( data: ModelRecord | undefined, options?: QueryResultOptions, -): ReturnType => createQueryResult(data, options) as ReturnType +): ReturnType => createQueryResult(data, {}, options) as ReturnType type OpenRouterModelProviders = NonNullable["data"]> @@ -154,69 +251,6 @@ const createRouterModels = (modelKey: keyof RouterModels, modelId: string, 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: {