diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 6e9d56018..84e03c2d5 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -408,6 +408,7 @@ already present as dev dependencies, so source/dev builds need no extra steps. | `OPENAI_API_BASE` | No | Compatibility alias for `OPENAI_BASE_URL` | | `OPENCLAUDE_OLLAMA_NUM_CTX` | Ollama only | Request-level Ollama context window. Defaults to `32768`; set a larger value for longer same-session history if your model and hardware can handle it. | | `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` | No | JSON map of OpenAI-compatible model names to context windows, such as `{"custom-model":1000000}`. Use this when a custom provider does not expose context metadata from `/v1/models`. | +| `CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS` | No | JSON map of OpenAI-compatible model names to max output tokens, such as `{"custom-model":32768}`. Use this when a custom provider does not expose output-limit metadata from `/v1/models`. | | `OPENCODE_API_KEY` | OpenCode Zen / Go | Shared API key for OpenCode Zen (pay-as-you-go) and OpenCode Go (subscription); get yours from https://opencode.ai | | `MIMO_API_KEY` | Xiaomi MiMo route | Xiaomi MiMo API key for `https://api.xiaomimimo.com/v1`; mirrored into the OpenAI-compatible auth env when the MiMo route is active | | `CLAUDE_CODE_USE_GEMINI` | Gemini only | Set to `1` to enable the direct Gemini provider path | @@ -433,6 +434,43 @@ Model env vars are provider-scoped: first-party Anthropic sessions read `GEMINI_MODEL`, and Mistral reads `MISTRAL_MODEL`. For manual Bedrock, Vertex, or Foundry launches, select the model with `--model`. +### Per-model limit overrides (`settings.json`) + +When a custom OpenAI-compatible provider does not expose context metadata from +`/v1/models`, you can pin a model's context window and max output tokens. In +addition to the `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` / +`CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS` env vars above, you can set a +`modelLimits` map in your `settings.json` (the same file `/config` writes, e.g. +`~/.openclaude/settings.json`): + +```json +{ + "modelLimits": { + "my-custom-deployment": { "contextWindow": 262144, "maxOutputTokens": 32768 }, + "api.private-llm.test:my-custom-deployment": { "contextWindow": 1000000 } + } +} +``` + +- **Key matching** — keys match the model api-name exactly, or by prefix (e.g. + `my-custom` matches `my-custom-deployment-v2`). An **exact** key always wins + over a **prefix** key. A host-qualified key (`:`) only wins over a + bare key **within the same match kind** — a host-qualified exact key beats a + bare exact key, and a host-qualified prefix beats a bare prefix, but a bare + exact key still beats a host-qualified prefix. So to give the same model + different limits per endpoint, use host-qualified **exact** keys for each + endpoint. `` is the `OPENAI_BASE_URL` host **including the port when the + URL has one** (`new URL(baseUrl).host`): for `http://localhost:4000/v1` the + key is `localhost:4000:my-model`, not `localhost:my-model`. Either field may be + omitted to override only one limit. +- **Precedence** — from highest to lowest: an **exact** env-var override → the + built-in catalog value → the discovery-cache value → a **prefix** env-var + override → `modelLimits` → the descriptor default. (The built-in catalog is + checked before the discovery cache.) So env-var overrides always win over + `modelLimits`, and `modelLimits` mainly fills in models that have no built-in + metadata (a known catalog model keeps its catalog limit unless you set an + *exact* env override for it). + ## Runtime Hardening Use these commands to validate your setup and catch mistakes early: diff --git a/docs/integrations/common-pitfalls.md b/docs/integrations/common-pitfalls.md index 0fee67aff..1fbd8e4c1 100644 --- a/docs/integrations/common-pitfalls.md +++ b/docs/integrations/common-pitfalls.md @@ -138,9 +138,9 @@ Adding built-in context or output limits to Safer rule: Put built-in model metadata in `src/integrations/models/`. Keep -`openaiContextWindows.ts` focused on documented env overrides such as -`CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` and -`CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS`. +`openaiContextWindows.ts` focused on documented user overrides — the +`CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` / `CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS` +env vars and the `settings.json` `modelLimits` map — not a built-in table. ## Pitfall 13: Forgetting the compatibility layer diff --git a/docs/integrations/how-to/add-model.md b/docs/integrations/how-to/add-model.md index bff5bb505..a15bb0a15 100644 --- a/docs/integrations/how-to/add-model.md +++ b/docs/integrations/how-to/add-model.md @@ -179,13 +179,16 @@ Model lookup should prefer: `modelDescriptorId`; 3. global shared model descriptors under `src/integrations/models/` for legacy and custom OpenAI-compatible model names; -4. documented env overrides from `src/utils/model/openaiContextWindows.ts` - (`CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` and - `CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS`). +4. documented user overrides from `src/utils/model/openaiContextWindows.ts` — + the `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` / `CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS` + env vars and the `modelLimits` map in `settings.json` (see the + `modelLimits` section in `docs/advanced-setup.md` for key matching and + precedence). -`openaiContextWindows.ts` is compatibility glue for user-provided env -overrides. It should not grow a second built-in model table. Built-in model -limits belong in model descriptor files. +`openaiContextWindows.ts` is compatibility glue for user-provided overrides +(env vars and the `settings.json` `modelLimits` map). It should not grow a +second built-in model table. Built-in model limits belong in model descriptor +files. ## What not to do diff --git a/src/integrations/runtimeMetadata.modelLimits.test.ts b/src/integrations/runtimeMetadata.modelLimits.test.ts new file mode 100644 index 000000000..284a8180b --- /dev/null +++ b/src/integrations/runtimeMetadata.modelLimits.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' + +// @ts-expect-error -- query-string cache-buster: the `?...` suffix makes Bun +// treat this as a distinct module id, bypassing other suites' +// mock.module('../utils/settings/settings.js') registrations so we capture the +// genuine module. Importing it at module scope (once) keeps the real-module +// load out of beforeEach, where the cold dynamic import sat right at Bun's 5s +// hook-timeout boundary. +import * as realSettingsModule from '../utils/settings/settings.js?modelLimitsRealSettings' + +// Integration coverage for the `modelLimits` settings override flowing through +// the real runtime resolution path (CodeRabbit review on PR #1164/#1234). The +// per-symbol tests in openaiContextWindows.test.ts exercise the lookup helpers +// directly; this drives the full chain via resolveModelRuntimeLimits, which is +// what runtime code actually calls. It also confirms the settings fallback is +// reached (resolveModelRuntimeLimits calls the settings-aware +// getOpenAIContextWindowMatches / getOpenAIMaxOutputTokenMatches, not a +// settings-blind variant) for prefix and host-qualified keys, and that env +// overrides win. + +type SettingsShape = { + modelLimits?: Record< + string, + { contextWindow?: number; maxOutputTokens?: number } + > +} + +let mockSettings: SettingsShape = {} +// Gate the getInitialSettings override so the process-global mock.module is a +// transparent passthrough to the real settings whenever this suite is not the +// one running — otherwise a later integrations test that reads +// getInitialSettings() would see this suite's stub settings leak in. +let settingsOverrideActive = false + +beforeEach(async () => { + await acquireSharedMutationLock('integrations/runtimeMetadata.modelLimits.test.ts') + mock.restore() + mockSettings = {} + mock.module('../utils/settings/settings.js', () => ({ + ...realSettingsModule, + getInitialSettings: () => + settingsOverrideActive + ? mockSettings + : realSettingsModule.getInitialSettings(), + })) + settingsOverrideActive = true +}) + +afterEach(() => { + try { + mock.restore() + settingsOverrideActive = false + } finally { + releaseSharedMutationLock() + } +}) + +async function importFresh() { + const nonce = `${Date.now()}-${Math.random()}` + return import(`./runtimeMetadata.js?ts=${nonce}`) +} + +test('resolveModelRuntimeLimits resolves settings modelLimits for an exact model key', async () => { + mockSettings = { + modelLimits: { + 'my-custom-deployment': { contextWindow: 123_456, maxOutputTokens: 4_096 }, + }, + } + const { resolveModelRuntimeLimits } = await importFresh() + + const limits = resolveModelRuntimeLimits({ + model: 'my-custom-deployment', + processEnv: {}, + }) + + expect(limits.contextWindow).toBe(123_456) + expect(limits.maxOutputTokens).toBe(4_096) +}) + +test('resolveModelRuntimeLimits prefers a host-qualified settings key', async () => { + mockSettings = { + modelLimits: { + 'my-custom-deployment': { contextWindow: 100_000 }, + 'api.private-llm.test:my-custom-deployment': { contextWindow: 262_144 }, + }, + } + const { resolveModelRuntimeLimits } = await importFresh() + + const limits = resolveModelRuntimeLimits({ + model: 'my-custom-deployment', + baseUrl: 'https://api.private-llm.test/v1', + processEnv: {}, + }) + + expect(limits.contextWindow).toBe(262_144) +}) + +test('resolveModelRuntimeLimits resolves a prefix settings key', async () => { + mockSettings = { + modelLimits: { + 'my-custom': { contextWindow: 333_333 }, + }, + } + const { resolveModelRuntimeLimits } = await importFresh() + + const limits = resolveModelRuntimeLimits({ + model: 'my-custom-deployment-v2', + processEnv: {}, + }) + + expect(limits.contextWindow).toBe(333_333) +}) + +test('resolveModelRuntimeLimits lets an env override win over settings modelLimits', async () => { + mockSettings = { + modelLimits: { + 'my-custom-deployment': { contextWindow: 999 }, + }, + } + const { resolveModelRuntimeLimits } = await importFresh() + + const limits = resolveModelRuntimeLimits({ + model: 'my-custom-deployment', + processEnv: { + CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS: JSON.stringify({ + 'my-custom-deployment': 111_111, + }), + }, + }) + + expect(limits.contextWindow).toBe(111_111) +}) + +test('resolveModelRuntimeLimits lets a broad env-prefix override win over an exact settings key', async () => { + // Regression for the env/settings precedence drift: a broad env-prefix + // override (`my-custom`) must not be silently overtaken by a more specific + // settings entry (`my-custom-deployment`). Covers both contextWindow and + // maxOutputTokens. + mockSettings = { + modelLimits: { + 'my-custom-deployment': { contextWindow: 999, maxOutputTokens: 111 }, + }, + } + const { resolveModelRuntimeLimits } = await importFresh() + + const limits = resolveModelRuntimeLimits({ + model: 'my-custom-deployment', + processEnv: { + CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS: JSON.stringify({ + 'my-custom': 111_111, + }), + CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS: JSON.stringify({ + 'my-custom': 4_096, + }), + }, + }) + + expect(limits.contextWindow).toBe(111_111) + expect(limits.maxOutputTokens).toBe(4_096) +}) diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index 52a1f4710..317288fda 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -476,18 +476,28 @@ export function resolveModelRuntimeLimits(options: { runtimeEnv, ) + // Precedence: an exact env override wins outright; then the built-in + // catalog / discovery-cache value (a `:cloud` variant must take its known + // catalog limit rather than inherit a broad base-model env *prefix*); then a + // broad env *prefix* override; then the settings.json `modelLimits` override; + // then the descriptor default. The key fix for the env/settings drift is + // keeping `settings` strictly below `prefix` so a broad env-prefix override is + // never silently overtaken by a settings entry — matching the scalar + // getOpenAIContextWindow, where env (exact or prefix) beats settings. return { contextWindow: externalContextWindow.exact ?? catalogEntry?.contextWindow ?? cachedCatalogEntry?.contextWindow ?? externalContextWindow.prefix ?? + externalContextWindow.settings ?? modelDescriptor?.contextWindow, maxOutputTokens: externalMaxOutputTokens.exact ?? catalogEntry?.maxOutputTokens ?? cachedCatalogEntry?.maxOutputTokens ?? externalMaxOutputTokens.prefix ?? + externalMaxOutputTokens.settings ?? modelDescriptor?.maxOutputTokens, } } diff --git a/src/utils/model/openaiContextWindows.test.ts b/src/utils/model/openaiContextWindows.test.ts new file mode 100644 index 000000000..692382576 --- /dev/null +++ b/src/utils/model/openaiContextWindows.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' + +// @ts-expect-error -- query-string cache-buster: the `?...` suffix makes Bun +// treat this as a distinct module id, bypassing other suites' +// mock.module('../settings/settings.js') registrations so we capture the +// genuine module. Importing it at module scope (once) keeps the real-module +// load out of beforeEach, where the cold dynamic import sat right at Bun's 5s +// hook-timeout boundary and intermittently failed the first test. +import * as realSettingsModule from '../settings/settings.js?openaiContextWindowsRealSettings' + +const originalEnv = { + CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS: + process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS, + CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS: + process.env.CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS, + OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, +} + +type SettingsShape = { + // Deliberately permissive (allows null entries) so the defensive-handling + // test can feed malformed shapes that real settings typing would reject. + modelLimits?: Record< + string, + { contextWindow?: number; maxOutputTokens?: number } | null + > +} + +let mockSettings: SettingsShape = {} +// Gate the getInitialSettings override so the process-global mock.module is a +// transparent passthrough to the real settings whenever this suite is not the +// one running — settings.js is read by many suites, so a stub must not leak. +let settingsOverrideActive = false + +beforeEach(async () => { + await acquireSharedMutationLock('openaiContextWindows.test.ts') + mock.restore() + mockSettings = {} + delete process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS + delete process.env.CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS + delete process.env.OPENAI_BASE_URL + mock.module('../settings/settings.js', () => ({ + ...realSettingsModule, + getInitialSettings: () => + settingsOverrideActive + ? mockSettings + : realSettingsModule.getInitialSettings(), + })) + settingsOverrideActive = true +}) + +afterEach(() => { + try { + mock.restore() + settingsOverrideActive = false + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } finally { + releaseSharedMutationLock() + } +}) + +async function importFresh() { + const nonce = `${Date.now()}-${Math.random()}` + return await import(`./openaiContextWindows.js?ts=${nonce}`) +} + +test('settings modelLimits resolves context window when no env override is set', async () => { + mockSettings = { + modelLimits: { + 'qwen3.6-plus': { contextWindow: 1_048_576, maxOutputTokens: 32_768 }, + }, + } + const { getOpenAIContextWindow, getOpenAIMaxOutputTokens } = await importFresh() + + expect(getOpenAIContextWindow('qwen3.6-plus')).toBe(1_048_576) + expect(getOpenAIMaxOutputTokens('qwen3.6-plus')).toBe(32_768) +}) + +test('env override takes precedence over settings modelLimits', async () => { + process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS = JSON.stringify({ + 'qwen3.6-plus': 524_288, + }) + mockSettings = { + modelLimits: { + 'qwen3.6-plus': { contextWindow: 1_048_576 }, + }, + } + const { getOpenAIContextWindow } = await importFresh() + + expect(getOpenAIContextWindow('qwen3.6-plus')).toBe(524_288) +}) + +test('settings modelLimits supports prefix matching on the model name', async () => { + mockSettings = { + modelLimits: { + 'qwen3': { contextWindow: 262_144 }, + }, + } + const { getOpenAIContextWindow } = await importFresh() + + expect(getOpenAIContextWindow('qwen3.6-plus')).toBe(262_144) +}) + +test('settings modelLimits supports host-qualified keys', async () => { + process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' + mockSettings = { + modelLimits: { + 'qwen3.6-plus': { contextWindow: 200_000 }, + 'openrouter.ai:qwen3.6-plus': { contextWindow: 1_048_576 }, + }, + } + const { getOpenAIContextWindow } = await importFresh() + + expect(getOpenAIContextWindow('qwen3.6-plus')).toBe(1_048_576) +}) + +test('a bare exact key beats a host-qualified prefix for a different model', async () => { + // An exact match wins over any prefix, including a host-qualified one. Here + // the host-qualified `openrouter.ai:qwen3` only prefix-matches, while + // `qwen3.6-plus` is an exact match for the requested model, so the exact + // bare limit must win. To force a per-endpoint override for this model the + // user supplies a host-qualified EXACT key (covered by the test above). + process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' + mockSettings = { + modelLimits: { + 'qwen3.6-plus': { contextWindow: 200_000 }, + 'openrouter.ai:qwen3': { contextWindow: 1_048_576 }, + }, + } + const { getOpenAIContextWindow } = await importFresh() + + expect(getOpenAIContextWindow('qwen3.6-plus')).toBe(200_000) +}) + +test('missing modelLimits returns undefined', async () => { + mockSettings = {} + const { getOpenAIContextWindow, getOpenAIMaxOutputTokens } = await importFresh() + + expect(getOpenAIContextWindow('whatever')).toBeUndefined() + expect(getOpenAIMaxOutputTokens('whatever')).toBeUndefined() +}) + +test('invalid modelLimits entries are skipped without throwing', async () => { + mockSettings = { + modelLimits: { + 'bad-zero': { contextWindow: 0 }, + 'bad-negative': { contextWindow: -1 }, + 'bad-shape': null, + 'good': { contextWindow: 64_000 }, + }, + } + const { getOpenAIContextWindow } = await importFresh() + + expect(getOpenAIContextWindow('bad-zero')).toBeUndefined() + expect(getOpenAIContextWindow('bad-negative')).toBeUndefined() + expect(getOpenAIContextWindow('bad-shape')).toBeUndefined() + expect(getOpenAIContextWindow('good')).toBe(64_000) +}) diff --git a/src/utils/model/openaiContextWindows.ts b/src/utils/model/openaiContextWindows.ts index 9b941d517..e9bea2501 100644 --- a/src/utils/model/openaiContextWindows.ts +++ b/src/utils/model/openaiContextWindows.ts @@ -2,19 +2,39 @@ * Runtime overrides for OpenAI-compatible model limits. * * Built-in model limits, including legacy aliases, live in - * src/integrations/models. These helpers only preserve the documented JSON env - * override path for custom/private deployments. + * src/integrations/models. These helpers preserve the documented JSON env + * override path for custom/private deployments and a `modelLimits` settings + * map for the same effect via settings.json. + * + * This module only produces the *override candidates* (exact/prefix env-var + * matches and the settings `modelLimits` match); it does not decide the overall + * precedence. The authoritative runtime chain — exact env override, then the + * catalog/discovery cache, then the prefix env override, then settings + * `modelLimits`, then the descriptor default — is applied by + * resolveModelRuntimeLimits in integrations/runtimeMetadata.ts. Keep precedence + * changes there, not duplicated here. */ +import { getInitialSettings } from '../settings/settings.js' + type LimitEnvVar = | 'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS' | 'CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS' export type OpenAILimitOverrideMatches = { + // Exact env-var override match. exact?: number + // settings.json `modelLimits` match (exact or prefix). Just a candidate here; + // its position in the overall precedence is decided by resolveModelRuntimeLimits + // (integrations/runtimeMetadata.ts), which applies settings after the exact and + // prefix env overrides and the catalog/discovery cache. + settings?: number + // Prefix env-var override match. prefix?: number } +type SettingsLimitKey = 'contextWindow' | 'maxOutputTokens' + function readExternalLimits( envVarName: LimitEnvVar, processEnv: NodeJS.ProcessEnv, @@ -99,6 +119,14 @@ function lookupByModel( const hostQualifiedModel = baseUrlHost && modelName ? `${baseUrlHost}:${modelName}` : undefined + // Match precedence, high to low: host-qualified exact, bare exact, + // host-qualified prefix, bare prefix. Within each match kind a host-qualified + // key (`:`) beats the bare key, so the same model name can carry + // a different limit per endpoint via a host-qualified EXACT key. An exact + // match always beats a prefix — including a host-qualified prefix — so a + // precise `gpt-4o` entry is not overridden by an `api.foo.com:gpt-4` prefix + // that only matches a different, shorter model name. (Consumers read + // `exact ?? prefix`.) return { exact: lookupExactByKey(entries, hostQualifiedModel) ?? @@ -130,14 +158,49 @@ function lookupExternalLimit( return matches.exact ?? matches.prefix } +function readSettingsLimits(key: SettingsLimitKey): Record { + let limits: unknown + try { + limits = getInitialSettings().modelLimits + } catch { + return {} + } + if (!limits || typeof limits !== 'object' || Array.isArray(limits)) { + return {} + } + const result: Record = {} + for (const [modelName, entry] of Object.entries(limits)) { + if (!entry || typeof entry !== 'object') continue + const value = (entry as Record)[key] + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + const trimmed = modelName.trim() + if (trimmed.length > 0) { + result[trimmed] = value + } + } + } + return result +} + +function lookupSettingsLimit( + key: SettingsLimitKey, + model: string | undefined, + processEnv: NodeJS.ProcessEnv, +): number | undefined { + const matches = lookupByModel(readSettingsLimits(key), model, processEnv) + return matches.exact ?? matches.prefix +} + export function getOpenAIContextWindow( model: string | undefined, processEnv: NodeJS.ProcessEnv = process.env, ): number | undefined { - return lookupExternalLimit( - 'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS', - model, - processEnv, + return ( + lookupExternalLimit( + 'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS', + model, + processEnv, + ) ?? lookupSettingsLimit('contextWindow', model, processEnv) ) } @@ -145,21 +208,26 @@ export function getOpenAIContextWindowMatches( model: string | undefined, processEnv: NodeJS.ProcessEnv = process.env, ): OpenAILimitOverrideMatches { - return lookupExternalLimitMatches( - 'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS', - model, - processEnv, - ) + return { + ...lookupExternalLimitMatches( + 'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS', + model, + processEnv, + ), + settings: lookupSettingsLimit('contextWindow', model, processEnv), + } } export function getOpenAIMaxOutputTokens( model: string | undefined, processEnv: NodeJS.ProcessEnv = process.env, ): number | undefined { - return lookupExternalLimit( - 'CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS', - model, - processEnv, + return ( + lookupExternalLimit( + 'CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS', + model, + processEnv, + ) ?? lookupSettingsLimit('maxOutputTokens', model, processEnv) ) } @@ -167,9 +235,12 @@ export function getOpenAIMaxOutputTokenMatches( model: string | undefined, processEnv: NodeJS.ProcessEnv = process.env, ): OpenAILimitOverrideMatches { - return lookupExternalLimitMatches( - 'CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS', - model, - processEnv, - ) + return { + ...lookupExternalLimitMatches( + 'CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS', + model, + processEnv, + ), + settings: lookupSettingsLimit('maxOutputTokens', model, processEnv), + } } diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index d70e8bc9a..4b7f3e7a9 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -841,6 +841,32 @@ export const SettingsSchema = lazySchema(() => 'the currently-active id) and retries the turn. ' + 'Example: ["provider_anthropic", "provider_openai", "provider_ollama"]', ), + modelLimits: z + .record( + z.string(), + z.object({ + contextWindow: z + .number() + .int() + .positive() + .optional() + .describe('Total context window size in tokens.'), + maxOutputTokens: z + .number() + .int() + .positive() + .optional() + .describe('Maximum output tokens per response.'), + }), + ) + .optional() + .describe( + 'Per-model overrides for context window and max output tokens. ' + + 'Used for OpenAI-compatible models whose limits are not in the built-in catalog. ' + + 'Keys are matched against the model name (exact match preferred, then prefix). ' + + 'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS / CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS env vars take precedence. ' + + 'Example: { "qwen3.6-plus": { "contextWindow": 1048576, "maxOutputTokens": 32768 } }', + ), fastMode: z .boolean() .optional()