-
Notifications
You must be signed in to change notification settings - Fork 240
feat: support custom model info overrides #1142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
everyoneexe
wants to merge
21
commits into
Zoo-Code-Org:main
Choose a base branch
from
everyoneexe:feat/custom-model-info-override
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 2 commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
61bca0d
docs: add design spec for custom model settings override
everyoneexe 3f7641f
feat: support custom model info overrides
everyoneexe e382bfe
fix: address custom model metadata review feedback
everyoneexe ee085a2
chore: remove internal design spec from PR
everyoneexe 546ddf4
fix: harden tests and improve code clarity across custom model info
everyoneexe b1ea608
merge: integrate main into feat/custom-model-info-override
everyoneexe 13ce7f0
fix: address maintainer review feedback on custom model info
everyoneexe eac42d6
test: add Playwright CT baseline screenshots for CustomModelInfoSettings
everyoneexe 9ee1405
fix: allow minor pixel diff in Playwright CT visual comparisons
everyoneexe fabea64
Merge branch 'main' into feat/custom-model-info-override
everyoneexe 63e6ce4
merge: resolve conflict with main (clearAllMocks helper migration)
everyoneexe 879e403
Merge branch 'feat/custom-model-info-override' of github.com:everyone…
everyoneexe 667fd5e
Merge branch 'main' into feat/custom-model-info-override
everyoneexe 505c50a
Merge branch 'main' into feat/custom-model-info-override
everyoneexe 854e816
fix(webview): fix useSelectedModel.spec.ts after merge conflict resol…
everyoneexe 3b33fe0
Merge branch 'main' into feat/custom-model-info-override
everyoneexe ac8416f
fix: address custom model info review feedback
everyoneexe bbe3146
Merge branch 'main' into feat/custom-model-info-override
everyoneexe e9d404f
test: mock custom model info controls
everyoneexe 8683068
Merge remote-tracking branch 'origin/main' into feat/custom-model-inf…
everyoneexe 5080762
Merge branch 'main' into feat/custom-model-info-override
everyoneexe File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
301 changes: 301 additions & 0 deletions
301
docs/superpowers/specs/2026-08-04-custom-model-settings-design.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <X>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<ModelInfo> | 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<ModelInfo> | ||
| ``` | ||
|
|
||
| `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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| }) | ||
| }) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.