From 4d52559c77216c24fc2f25279030ddffcc96022c Mon Sep 17 00:00:00 2001 From: 0xfandom Date: Mon, 18 May 2026 13:17:59 +0530 Subject: [PATCH 01/13] feat(settings): per-model context_window and max_output_tokens overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `modelLimits` settings.json map so users can declare context window and max output tokens for OpenAI-compatible models that are not in the built-in catalog. Resolution order is env var → settings → catalog, so the existing CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS / _MAX_OUTPUT_TOKENS env vars keep priority. Keys are matched exactly, then by prefix, with optional ":" host-qualified forms. Example: "modelLimits": { "qwen3.6-plus": { "contextWindow": 1048576, "maxOutputTokens": 32768 } } Closes #478 --- src/utils/model/openaiContextWindows.test.ts | 131 +++++++++++++++++++ src/utils/model/openaiContextWindows.ts | 64 +++++++-- src/utils/settings/types.ts | 26 ++++ 3 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 src/utils/model/openaiContextWindows.test.ts diff --git a/src/utils/model/openaiContextWindows.test.ts b/src/utils/model/openaiContextWindows.test.ts new file mode 100644 index 0000000000..b816e0f10a --- /dev/null +++ b/src/utils/model/openaiContextWindows.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../../test/sharedMutationLock.js' + +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 ConfigShape = { + modelLimits?: Record< + string, + { contextWindow?: number; maxOutputTokens?: number } | null + > | null +} + +let mockConfig: ConfigShape = {} + +beforeEach(async () => { + await acquireSharedMutationLock('openaiContextWindows.test.ts') + mock.restore() + mockConfig = {} + delete process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS + delete process.env.CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS + delete process.env.OPENAI_BASE_URL + const actualConfig = await import(`../config.js?ts=${Date.now()}-${Math.random()}`) + mock.module('../config.js', () => ({ + ...actualConfig, + getGlobalConfig: () => mockConfig, + })) +}) + +afterEach(() => { + try { + mock.restore() + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } finally { + releaseSharedMutationLock('openaiContextWindows.test.ts') + } +}) + +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 () => { + mockConfig = { + 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, + }) + mockConfig = { + 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 () => { + mockConfig = { + 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' + mockConfig = { + 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('missing modelLimits returns undefined', async () => { + mockConfig = {} + const { getOpenAIContextWindow, getOpenAIMaxOutputTokens } = await importFresh() + + expect(getOpenAIContextWindow('whatever')).toBeUndefined() + expect(getOpenAIMaxOutputTokens('whatever')).toBeUndefined() +}) + +test('invalid modelLimits entries are skipped without throwing', async () => { + mockConfig = { + 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 9b941d5178..effc84c565 100644 --- a/src/utils/model/openaiContextWindows.ts +++ b/src/utils/model/openaiContextWindows.ts @@ -2,10 +2,15 @@ * 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. + * + * Resolution order: env var → settings.json `modelLimits` → built-in catalog. */ +import { getGlobalConfig } from '../config.js' + type LimitEnvVar = | 'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS' | 'CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS' @@ -15,6 +20,8 @@ export type OpenAILimitOverrideMatches = { prefix?: number } +type SettingsLimitKey = 'contextWindow' | 'maxOutputTokens' + function readExternalLimits( envVarName: LimitEnvVar, processEnv: NodeJS.ProcessEnv, @@ -130,14 +137,49 @@ function lookupExternalLimit( return matches.exact ?? matches.prefix } +function readSettingsLimits(key: SettingsLimitKey): Record { + let limits: unknown + try { + limits = getGlobalConfig().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) ) } @@ -156,10 +198,12 @@ 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) ) } diff --git a/src/utils/settings/types.ts b/src/utils/settings/types.ts index 564cdc2337..db8bdfebea 100644 --- a/src/utils/settings/types.ts +++ b/src/utils/settings/types.ts @@ -789,6 +789,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() From f04d4b5473778dba60275cee32658d37519e5f8b Mon Sep 17 00:00:00 2001 From: 0xfandom Date: Wed, 24 Jun 2026 12:33:15 +0530 Subject: [PATCH 02/13] fix(model-limits): apply settings overrides in resolveModelRuntimeLimits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings.json `modelLimits` fallback was only wired into the scalar getOpenAIContextWindow / getOpenAIMaxOutputTokens helpers. The runtime resolution path (resolveModelRuntimeLimits) instead consumes the *Matches variants, which only returned env-var matches — so a configured modelLimits override never reached the model whose limits are actually resolved at runtime, contrary to the documented env → settings → catalog order. Fold the settings lookup into getOpenAIContextWindowMatches / getOpenAIMaxOutputTokenMatches as a dedicated `settings` field, and insert it between the exact env override and the built-in catalog in resolveModelRuntimeLimits. Declare `modelLimits` on GlobalConfig so the lookup is typed. Add an integration test that drives resolveModelRuntimeLimits and asserts settings resolve for exact, host-qualified and prefix keys, and that an env override still wins. --- .../runtimeMetadata.modelLimits.test.ts | 130 ++++++++++++++++++ src/integrations/runtimeMetadata.ts | 2 + src/utils/config.ts | 7 + src/utils/model/openaiContextWindows.test.ts | 2 +- src/utils/model/openaiContextWindows.ts | 32 +++-- 5 files changed, 162 insertions(+), 11 deletions(-) create mode 100644 src/integrations/runtimeMetadata.modelLimits.test.ts diff --git a/src/integrations/runtimeMetadata.modelLimits.test.ts b/src/integrations/runtimeMetadata.modelLimits.test.ts new file mode 100644 index 0000000000..82c4eeab53 --- /dev/null +++ b/src/integrations/runtimeMetadata.modelLimits.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' + +// 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 +// getOpenAIContextWindow / getOpenAIMaxOutputTokens, not a settings-blind +// variant) for prefix and host-qualified keys, and that env overrides win. + +type ConfigShape = { + modelLimits?: Record< + string, + { contextWindow?: number; maxOutputTokens?: number } | null + > | null +} + +let mockConfig: ConfigShape = {} +// Gate the getGlobalConfig override so the process-global mock.module is a +// transparent passthrough to the real config whenever this suite is not the one +// running — otherwise a later integrations test that reads getGlobalConfig() +// would see this suite's stub config leak in. +let configOverrideActive = false +let realConfigModule: typeof import('../utils/config.js') | undefined + +beforeEach(async () => { + await acquireSharedMutationLock('integrations/runtimeMetadata.modelLimits.test.ts') + mock.restore() + mockConfig = {} + realConfigModule ??= (await import( + `../utils/config.js?modelLimitsReal=${Date.now()}-${Math.random()}` + )) as typeof import('../utils/config.js') + const real = realConfigModule + mock.module('../utils/config.js', () => ({ + ...real, + getGlobalConfig: () => + configOverrideActive ? mockConfig : real.getGlobalConfig(), + })) + configOverrideActive = true +}) + +afterEach(() => { + try { + mock.restore() + configOverrideActive = 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 () => { + mockConfig = { + 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 () => { + mockConfig = { + 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 () => { + mockConfig = { + 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 () => { + mockConfig = { + 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) +}) diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index 05446e16b0..3449d9be06 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -465,12 +465,14 @@ export function resolveModelRuntimeLimits(options: { return { contextWindow: externalContextWindow.exact ?? + externalContextWindow.settings ?? catalogEntry?.contextWindow ?? cachedCatalogEntry?.contextWindow ?? externalContextWindow.prefix ?? modelDescriptor?.contextWindow, maxOutputTokens: externalMaxOutputTokens.exact ?? + externalMaxOutputTokens.settings ?? catalogEntry?.maxOutputTokens ?? cachedCatalogEntry?.maxOutputTokens ?? externalMaxOutputTokens.prefix ?? diff --git a/src/utils/config.ts b/src/utils/config.ts index 09b00696e9..243f7cf9a5 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -239,6 +239,13 @@ export type GlobalConfig = { apiKeyHelper?: string projects?: Record numStartups: number + // Per-model context window / max output token overrides, keyed by model name + // (optionally host-qualified as `:`). Resolved between env-var + // overrides and the built-in catalog. See openaiContextWindows.ts. + modelLimits?: Record< + string, + { contextWindow?: number; maxOutputTokens?: number } | null + > installMethod?: InstallMethod autoUpdates?: boolean // Flag to distinguish protection-based disabling from user preference diff --git a/src/utils/model/openaiContextWindows.test.ts b/src/utils/model/openaiContextWindows.test.ts index b816e0f10a..694fc558ff 100644 --- a/src/utils/model/openaiContextWindows.test.ts +++ b/src/utils/model/openaiContextWindows.test.ts @@ -46,7 +46,7 @@ afterEach(() => { } } } finally { - releaseSharedMutationLock('openaiContextWindows.test.ts') + releaseSharedMutationLock() } }) diff --git a/src/utils/model/openaiContextWindows.ts b/src/utils/model/openaiContextWindows.ts index effc84c565..72cfd51a5e 100644 --- a/src/utils/model/openaiContextWindows.ts +++ b/src/utils/model/openaiContextWindows.ts @@ -16,7 +16,13 @@ type LimitEnvVar = | 'CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS' export type OpenAILimitOverrideMatches = { + // Exact env-var override match (highest precedence). exact?: number + // settings.json `modelLimits` match (exact or prefix). Sits between the exact + // env override and the built-in catalog, mirroring the documented resolution + // order env → settings → catalog. + settings?: number + // Prefix env-var override match (lowest precedence among overrides). prefix?: number } @@ -187,11 +193,14 @@ 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( @@ -211,9 +220,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), + } } From 39557a38fd76142013d9f0b7cc532292ad41938f Mon Sep 17 00:00:00 2001 From: 0xfandom Date: Thu, 25 Jun 2026 12:33:11 +0530 Subject: [PATCH 03/13] fix(model-limits): resolve modelLimits from settings.json, not global config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readSettingsLimits read getGlobalConfig().modelLimits, which is ~/.openclaude.json — a different file from the settings.json the feature is documented and schema'd for (SettingsSchema in settings/types.ts). A user adding modelLimits to settings.json saw no effect. Read it from getInitialSettings() (the merged settings snapshot, session-cached) instead, and drop the now-unused modelLimits field from GlobalConfig so the override lives in one place. Rewire both test suites to a gated-passthrough getInitialSettings mock. --- .../runtimeMetadata.modelLimits.test.ts | 50 ++++++++++--------- src/utils/config.ts | 7 --- src/utils/model/openaiContextWindows.test.ts | 43 ++++++++++------ src/utils/model/openaiContextWindows.ts | 4 +- 4 files changed, 57 insertions(+), 47 deletions(-) diff --git a/src/integrations/runtimeMetadata.modelLimits.test.ts b/src/integrations/runtimeMetadata.modelLimits.test.ts index 82c4eeab53..c69b673f4d 100644 --- a/src/integrations/runtimeMetadata.modelLimits.test.ts +++ b/src/integrations/runtimeMetadata.modelLimits.test.ts @@ -13,41 +13,43 @@ import { // getOpenAIContextWindow / getOpenAIMaxOutputTokens, not a settings-blind // variant) for prefix and host-qualified keys, and that env overrides win. -type ConfigShape = { +type SettingsShape = { modelLimits?: Record< string, - { contextWindow?: number; maxOutputTokens?: number } | null - > | null + { contextWindow?: number; maxOutputTokens?: number } + > } -let mockConfig: ConfigShape = {} -// Gate the getGlobalConfig override so the process-global mock.module is a -// transparent passthrough to the real config whenever this suite is not the one -// running — otherwise a later integrations test that reads getGlobalConfig() -// would see this suite's stub config leak in. -let configOverrideActive = false -let realConfigModule: typeof import('../utils/config.js') | undefined +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 +let realSettingsModule: + | typeof import('../utils/settings/settings.js') + | undefined beforeEach(async () => { await acquireSharedMutationLock('integrations/runtimeMetadata.modelLimits.test.ts') mock.restore() - mockConfig = {} - realConfigModule ??= (await import( - `../utils/config.js?modelLimitsReal=${Date.now()}-${Math.random()}` - )) as typeof import('../utils/config.js') - const real = realConfigModule - mock.module('../utils/config.js', () => ({ + mockSettings = {} + realSettingsModule ??= (await import( + `../utils/settings/settings.js?modelLimitsReal=${Date.now()}-${Math.random()}` + )) as typeof import('../utils/settings/settings.js') + const real = realSettingsModule + mock.module('../utils/settings/settings.js', () => ({ ...real, - getGlobalConfig: () => - configOverrideActive ? mockConfig : real.getGlobalConfig(), + getInitialSettings: () => + settingsOverrideActive ? mockSettings : real.getInitialSettings(), })) - configOverrideActive = true + settingsOverrideActive = true }) afterEach(() => { try { mock.restore() - configOverrideActive = false + settingsOverrideActive = false } finally { releaseSharedMutationLock() } @@ -59,7 +61,7 @@ async function importFresh() { } test('resolveModelRuntimeLimits resolves settings modelLimits for an exact model key', async () => { - mockConfig = { + mockSettings = { modelLimits: { 'my-custom-deployment': { contextWindow: 123_456, maxOutputTokens: 4_096 }, }, @@ -76,7 +78,7 @@ test('resolveModelRuntimeLimits resolves settings modelLimits for an exact model }) test('resolveModelRuntimeLimits prefers a host-qualified settings key', async () => { - mockConfig = { + mockSettings = { modelLimits: { 'my-custom-deployment': { contextWindow: 100_000 }, 'api.private-llm.test:my-custom-deployment': { contextWindow: 262_144 }, @@ -94,7 +96,7 @@ test('resolveModelRuntimeLimits prefers a host-qualified settings key', async () }) test('resolveModelRuntimeLimits resolves a prefix settings key', async () => { - mockConfig = { + mockSettings = { modelLimits: { 'my-custom': { contextWindow: 333_333 }, }, @@ -110,7 +112,7 @@ test('resolveModelRuntimeLimits resolves a prefix settings key', async () => { }) test('resolveModelRuntimeLimits lets an env override win over settings modelLimits', async () => { - mockConfig = { + mockSettings = { modelLimits: { 'my-custom-deployment': { contextWindow: 999 }, }, diff --git a/src/utils/config.ts b/src/utils/config.ts index 243f7cf9a5..09b00696e9 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -239,13 +239,6 @@ export type GlobalConfig = { apiKeyHelper?: string projects?: Record numStartups: number - // Per-model context window / max output token overrides, keyed by model name - // (optionally host-qualified as `:`). Resolved between env-var - // overrides and the built-in catalog. See openaiContextWindows.ts. - modelLimits?: Record< - string, - { contextWindow?: number; maxOutputTokens?: number } | null - > installMethod?: InstallMethod autoUpdates?: boolean // Flag to distinguish protection-based disabling from user preference diff --git a/src/utils/model/openaiContextWindows.test.ts b/src/utils/model/openaiContextWindows.test.ts index 694fc558ff..ca080484d1 100644 --- a/src/utils/model/openaiContextWindows.test.ts +++ b/src/utils/model/openaiContextWindows.test.ts @@ -12,32 +12,47 @@ const originalEnv = { OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, } -type ConfigShape = { +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 - > | null + > } -let mockConfig: ConfigShape = {} +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 +let realSettingsModule: + | typeof import('../settings/settings.js') + | undefined beforeEach(async () => { await acquireSharedMutationLock('openaiContextWindows.test.ts') mock.restore() - mockConfig = {} + mockSettings = {} delete process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS delete process.env.CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS delete process.env.OPENAI_BASE_URL - const actualConfig = await import(`../config.js?ts=${Date.now()}-${Math.random()}`) - mock.module('../config.js', () => ({ - ...actualConfig, - getGlobalConfig: () => mockConfig, + realSettingsModule ??= (await import( + `../settings/settings.js?ts=${Date.now()}-${Math.random()}` + )) as typeof import('../settings/settings.js') + const real = realSettingsModule + mock.module('../settings/settings.js', () => ({ + ...real, + getInitialSettings: () => + settingsOverrideActive ? mockSettings : real.getInitialSettings(), })) + settingsOverrideActive = true }) afterEach(() => { try { mock.restore() + settingsOverrideActive = false for (const [key, value] of Object.entries(originalEnv)) { if (value === undefined) { delete process.env[key] @@ -56,7 +71,7 @@ async function importFresh() { } test('settings modelLimits resolves context window when no env override is set', async () => { - mockConfig = { + mockSettings = { modelLimits: { 'qwen3.6-plus': { contextWindow: 1_048_576, maxOutputTokens: 32_768 }, }, @@ -71,7 +86,7 @@ test('env override takes precedence over settings modelLimits', async () => { process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS = JSON.stringify({ 'qwen3.6-plus': 524_288, }) - mockConfig = { + mockSettings = { modelLimits: { 'qwen3.6-plus': { contextWindow: 1_048_576 }, }, @@ -82,7 +97,7 @@ test('env override takes precedence over settings modelLimits', async () => { }) test('settings modelLimits supports prefix matching on the model name', async () => { - mockConfig = { + mockSettings = { modelLimits: { 'qwen3': { contextWindow: 262_144 }, }, @@ -94,7 +109,7 @@ test('settings modelLimits supports prefix matching on the model name', async () test('settings modelLimits supports host-qualified keys', async () => { process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1' - mockConfig = { + mockSettings = { modelLimits: { 'qwen3.6-plus': { contextWindow: 200_000 }, 'openrouter.ai:qwen3.6-plus': { contextWindow: 1_048_576 }, @@ -106,7 +121,7 @@ test('settings modelLimits supports host-qualified keys', async () => { }) test('missing modelLimits returns undefined', async () => { - mockConfig = {} + mockSettings = {} const { getOpenAIContextWindow, getOpenAIMaxOutputTokens } = await importFresh() expect(getOpenAIContextWindow('whatever')).toBeUndefined() @@ -114,7 +129,7 @@ test('missing modelLimits returns undefined', async () => { }) test('invalid modelLimits entries are skipped without throwing', async () => { - mockConfig = { + mockSettings = { modelLimits: { 'bad-zero': { contextWindow: 0 }, 'bad-negative': { contextWindow: -1 }, diff --git a/src/utils/model/openaiContextWindows.ts b/src/utils/model/openaiContextWindows.ts index 72cfd51a5e..8e6057699f 100644 --- a/src/utils/model/openaiContextWindows.ts +++ b/src/utils/model/openaiContextWindows.ts @@ -9,7 +9,7 @@ * Resolution order: env var → settings.json `modelLimits` → built-in catalog. */ -import { getGlobalConfig } from '../config.js' +import { getInitialSettings } from '../settings/settings.js' type LimitEnvVar = | 'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS' @@ -146,7 +146,7 @@ function lookupExternalLimit( function readSettingsLimits(key: SettingsLimitKey): Record { let limits: unknown try { - limits = getGlobalConfig().modelLimits + limits = getInitialSettings().modelLimits } catch { return {} } From 6158205cfeaeb84c2e244815cde1a5f90af69c95 Mon Sep 17 00:00:00 2001 From: 0xfandom Date: Fri, 26 Jun 2026 12:29:00 +0530 Subject: [PATCH 04/13] test(model-limits): capture real settings module at load, not in beforeEach The settings modelLimits suites re-imported the real settings module with a cold dynamic import inside beforeEach. That import sat right at Bun's default 5s hook timeout and intermittently failed the first test. Capture the genuine module once via a top-level query-string-busted static import so the cost moves to module load (no hook timeout) and the gated passthrough still bypasses other suites' settings.js mocks. Also correct the integration-test comment to name the helpers the runtime path actually calls (getOpenAIContextWindowMatches / getOpenAIMaxOutputTokenMatches). --- .../runtimeMetadata.modelLimits.test.ts | 26 +++++++++++-------- src/utils/model/openaiContextWindows.test.ts | 21 ++++++++------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/integrations/runtimeMetadata.modelLimits.test.ts b/src/integrations/runtimeMetadata.modelLimits.test.ts index c69b673f4d..209dfd3419 100644 --- a/src/integrations/runtimeMetadata.modelLimits.test.ts +++ b/src/integrations/runtimeMetadata.modelLimits.test.ts @@ -4,14 +4,23 @@ import { 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 -// getOpenAIContextWindow / getOpenAIMaxOutputTokens, not a settings-blind -// variant) for prefix and host-qualified keys, and that env overrides win. +// getOpenAIContextWindowMatches / getOpenAIMaxOutputTokenMatches, not a +// settings-blind variant) for prefix and host-qualified keys, and that env +// overrides win. type SettingsShape = { modelLimits?: Record< @@ -26,22 +35,17 @@ let mockSettings: SettingsShape = {} // one running — otherwise a later integrations test that reads // getInitialSettings() would see this suite's stub settings leak in. let settingsOverrideActive = false -let realSettingsModule: - | typeof import('../utils/settings/settings.js') - | undefined beforeEach(async () => { await acquireSharedMutationLock('integrations/runtimeMetadata.modelLimits.test.ts') mock.restore() mockSettings = {} - realSettingsModule ??= (await import( - `../utils/settings/settings.js?modelLimitsReal=${Date.now()}-${Math.random()}` - )) as typeof import('../utils/settings/settings.js') - const real = realSettingsModule mock.module('../utils/settings/settings.js', () => ({ - ...real, + ...realSettingsModule, getInitialSettings: () => - settingsOverrideActive ? mockSettings : real.getInitialSettings(), + settingsOverrideActive + ? mockSettings + : realSettingsModule.getInitialSettings(), })) settingsOverrideActive = true }) diff --git a/src/utils/model/openaiContextWindows.test.ts b/src/utils/model/openaiContextWindows.test.ts index ca080484d1..32739e41ab 100644 --- a/src/utils/model/openaiContextWindows.test.ts +++ b/src/utils/model/openaiContextWindows.test.ts @@ -4,6 +4,14 @@ import { 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, @@ -26,9 +34,6 @@ let mockSettings: SettingsShape = {} // 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 -let realSettingsModule: - | typeof import('../settings/settings.js') - | undefined beforeEach(async () => { await acquireSharedMutationLock('openaiContextWindows.test.ts') @@ -37,14 +42,12 @@ beforeEach(async () => { delete process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS delete process.env.CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS delete process.env.OPENAI_BASE_URL - realSettingsModule ??= (await import( - `../settings/settings.js?ts=${Date.now()}-${Math.random()}` - )) as typeof import('../settings/settings.js') - const real = realSettingsModule mock.module('../settings/settings.js', () => ({ - ...real, + ...realSettingsModule, getInitialSettings: () => - settingsOverrideActive ? mockSettings : real.getInitialSettings(), + settingsOverrideActive + ? mockSettings + : realSettingsModule.getInitialSettings(), })) settingsOverrideActive = true }) From e1406440c503e6037866563a8a593a471a5e14c4 Mon Sep 17 00:00:00 2001 From: 0xfandom Date: Mon, 29 Jun 2026 16:08:50 +0530 Subject: [PATCH 05/13] fix(model-limits): keep env-prefix override above settings in runtime resolver resolveModelRuntimeLimits ordered the settings `modelLimits` value above the env-prefix match, so a broad env-prefix override (e.g. `{"my-custom":N}`) was silently overtaken by a more specific settings entry (`my-custom-deployment`). The scalar getOpenAIContextWindow treats env (exact ?? prefix) as strictly higher priority than settings; mirror that in the runtime resolver: env.exact, then env.prefix, then settings, then catalog/cache/descriptor. Regression covers both contextWindow and maxOutputTokens. --- .../runtimeMetadata.modelLimits.test.ts | 28 +++++++++++++++++++ src/integrations/runtimeMetadata.ts | 12 ++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/integrations/runtimeMetadata.modelLimits.test.ts b/src/integrations/runtimeMetadata.modelLimits.test.ts index 209dfd3419..284a8180ba 100644 --- a/src/integrations/runtimeMetadata.modelLimits.test.ts +++ b/src/integrations/runtimeMetadata.modelLimits.test.ts @@ -134,3 +134,31 @@ test('resolveModelRuntimeLimits lets an env override win over settings modelLimi 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 3449d9be06..cf990e2653 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -462,20 +462,28 @@ export function resolveModelRuntimeLimits(options: { runtimeEnv, ) + // Precedence: env overrides (exact, then prefix) are the highest-priority + // user overrides and must win over everything — mirroring the scalar + // getOpenAIContextWindow (`env(exact ?? prefix) ?? settings`). The settings.json + // `modelLimits` override then takes precedence over the built-in catalog / + // discovery-cache values (#478 is an override mechanism), which in turn beat + // the descriptor default. Keeping `settings` below `prefix` is the fix for the + // env/settings drift: a broad env-prefix override must not be silently + // overtaken by a settings entry. return { contextWindow: externalContextWindow.exact ?? + externalContextWindow.prefix ?? externalContextWindow.settings ?? catalogEntry?.contextWindow ?? cachedCatalogEntry?.contextWindow ?? - externalContextWindow.prefix ?? modelDescriptor?.contextWindow, maxOutputTokens: externalMaxOutputTokens.exact ?? + externalMaxOutputTokens.prefix ?? externalMaxOutputTokens.settings ?? catalogEntry?.maxOutputTokens ?? cachedCatalogEntry?.maxOutputTokens ?? - externalMaxOutputTokens.prefix ?? modelDescriptor?.maxOutputTokens, } } From faa2b1faa4f86253351ceff1bf353c725821298e Mon Sep 17 00:00:00 2001 From: 0xfandom Date: Mon, 29 Jun 2026 16:08:50 +0530 Subject: [PATCH 06/13] docs(model-limits): document the settings.json modelLimits override Add a user-facing section near the env-var overrides covering the modelLimits map, JSON shape, exact/prefix/host-qualified key matching, and the env > settings > catalog > descriptor precedence. --- docs/advanced-setup.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index c171055131..4895db5c8f 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -402,6 +402,34 @@ 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`). A host-qualified key + (`:`, where `` is the `OPENAI_BASE_URL` hostname) wins over + a bare model key, so the same model name on two endpoints can carry different + limits. Either field may be omitted to override only one limit. +- **Precedence** — env-var overrides (exact, then prefix) win over `modelLimits`, + which in turn wins over the built-in catalog / discovery-cache values, which + win over the descriptor default. So an env override always takes priority, and + `modelLimits` only applies where no env override is set. + ## Runtime Hardening Use these commands to validate your setup and catch mistakes early: From 11240dba08b49d37ae9246efa1bb331d88e090ff Mon Sep 17 00:00:00 2001 From: 0xfandom Date: Mon, 29 Jun 2026 16:49:13 +0530 Subject: [PATCH 07/13] fix(model-limits): keep catalog above env-prefix in runtime precedence The previous reorder put the env-prefix match above the catalog value, which broke the existing invariant that a `:cloud` catalog variant takes its known catalog limit rather than inheriting a broad base-model env prefix (deepseek-v4-pro:cloud regressed to 262144). Correct order: exact env -> catalog/cache -> env prefix -> settings -> descriptor. This still keeps settings strictly below env-prefix (the original drift fix) while preserving catalog precedence over prefix. Docs precedence updated to match. --- docs/advanced-setup.md | 10 ++++++---- src/integrations/runtimeMetadata.ts | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 4895db5c8f..e7d7c9d67a 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -425,10 +425,12 @@ addition to the `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` / (`:`, where `` is the `OPENAI_BASE_URL` hostname) wins over a bare model key, so the same model name on two endpoints can carry different limits. Either field may be omitted to override only one limit. -- **Precedence** — env-var overrides (exact, then prefix) win over `modelLimits`, - which in turn wins over the built-in catalog / discovery-cache values, which - win over the descriptor default. So an env override always takes priority, and - `modelLimits` only applies where no env override is set. +- **Precedence** — from highest to lowest: an **exact** env-var override → the + built-in catalog / discovery-cache value → a **prefix** env-var override → + `modelLimits` → the descriptor default. 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 diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index cf990e2653..8f4620f71b 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -462,28 +462,28 @@ export function resolveModelRuntimeLimits(options: { runtimeEnv, ) - // Precedence: env overrides (exact, then prefix) are the highest-priority - // user overrides and must win over everything — mirroring the scalar - // getOpenAIContextWindow (`env(exact ?? prefix) ?? settings`). The settings.json - // `modelLimits` override then takes precedence over the built-in catalog / - // discovery-cache values (#478 is an override mechanism), which in turn beat - // the descriptor default. Keeping `settings` below `prefix` is the fix for the - // env/settings drift: a broad env-prefix override must not be silently - // overtaken by a settings entry. + // 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 ?? - externalContextWindow.prefix ?? - externalContextWindow.settings ?? catalogEntry?.contextWindow ?? cachedCatalogEntry?.contextWindow ?? + externalContextWindow.prefix ?? + externalContextWindow.settings ?? modelDescriptor?.contextWindow, maxOutputTokens: externalMaxOutputTokens.exact ?? - externalMaxOutputTokens.prefix ?? - externalMaxOutputTokens.settings ?? catalogEntry?.maxOutputTokens ?? cachedCatalogEntry?.maxOutputTokens ?? + externalMaxOutputTokens.prefix ?? + externalMaxOutputTokens.settings ?? modelDescriptor?.maxOutputTokens, } } From 7cdcb471df9d464f6f7cd1c8fa9537cdcc45dee9 Mon Sep 17 00:00:00 2001 From: 0xfandom Date: Tue, 30 Jun 2026 12:52:25 +0530 Subject: [PATCH 08/13] docs(advanced-setup): document CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS env var The modelLimits section referenced the max-output env var but the Environment Variables table only listed the context-window one, leaving output-only configuration undocumented. Add the matching table row. Refs #478 --- docs/advanced-setup.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index e7d7c9d67a..0395a6e3a8 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -378,6 +378,7 @@ The **OpenClaude VS Code extension** can store the key in Secret Storage and set | `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 | From 28fa72bca59386ce480be5fafe76988ad0653966 Mon Sep 17 00:00:00 2001 From: 0xfandom Date: Thu, 2 Jul 2026 12:12:35 +0530 Subject: [PATCH 09/13] docs(model): align openaiContextWindows precedence comments with the resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module header and the OpenALimitOverrideMatches.settings comment claimed a resolution order of "env → settings → catalog", which contradicts resolveModelRuntimeLimits (exact env → catalog/discovery cache → prefix env → settings modelLimits → descriptor default). Since this module only produces the override candidates and does not own the precedence, narrow the comments to say so and point at runtimeMetadata.ts as the authoritative chain, so this precedence-sensitive code isn't misread when touched again. --- src/utils/model/openaiContextWindows.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/utils/model/openaiContextWindows.ts b/src/utils/model/openaiContextWindows.ts index 8e6057699f..537e075325 100644 --- a/src/utils/model/openaiContextWindows.ts +++ b/src/utils/model/openaiContextWindows.ts @@ -6,7 +6,13 @@ * override path for custom/private deployments and a `modelLimits` settings * map for the same effect via settings.json. * - * Resolution order: env var → settings.json `modelLimits` → built-in catalog. + * 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' @@ -16,13 +22,14 @@ type LimitEnvVar = | 'CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS' export type OpenAILimitOverrideMatches = { - // Exact env-var override match (highest precedence). + // Exact env-var override match. exact?: number - // settings.json `modelLimits` match (exact or prefix). Sits between the exact - // env override and the built-in catalog, mirroring the documented resolution - // order env → settings → catalog. + // 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 (lowest precedence among overrides). + // Prefix env-var override match. prefix?: number } From 67d459de1c5dec9fdbcab710b948dc405ad71e9b Mon Sep 17 00:00:00 2001 From: 0xfandom Date: Mon, 6 Jul 2026 12:29:54 +0530 Subject: [PATCH 10/13] fix(model): rank host-qualified modelLimits keys above bare model keys lookupByModel grouped all exact matches (host-qualified and bare) ahead of all prefix matches, so a bare exact key like `qwen3.6-plus` beat a host-qualified prefix like `openrouter.ai:qwen3`. That defeated the advertised per-endpoint disambiguation for versioned model families. A host-qualified key is strictly more specific than a bare one, so rank both host-qualified forms (exact and prefix) in the high-priority tier ahead of the bare exact match, leaving only the bare prefix in the low-priority tier. --- src/utils/model/openaiContextWindows.test.ts | 15 +++++++++++++++ src/utils/model/openaiContextWindows.ts | 12 +++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/utils/model/openaiContextWindows.test.ts b/src/utils/model/openaiContextWindows.test.ts index 32739e41ab..b3c0dd04c9 100644 --- a/src/utils/model/openaiContextWindows.test.ts +++ b/src/utils/model/openaiContextWindows.test.ts @@ -123,6 +123,21 @@ test('settings modelLimits supports host-qualified keys', async () => { expect(getOpenAIContextWindow('qwen3.6-plus')).toBe(1_048_576) }) +test('host-qualified prefix key wins over a bare exact model key', async () => { + // A host-qualified prefix is more specific than a bare exact match, so the + // per-endpoint limit must win even when a bare exact key also matches. + 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(1_048_576) +}) + test('missing modelLimits returns undefined', async () => { mockSettings = {} const { getOpenAIContextWindow, getOpenAIMaxOutputTokens } = await importFresh() diff --git a/src/utils/model/openaiContextWindows.ts b/src/utils/model/openaiContextWindows.ts index 537e075325..3a5fa4abea 100644 --- a/src/utils/model/openaiContextWindows.ts +++ b/src/utils/model/openaiContextWindows.ts @@ -119,13 +119,19 @@ function lookupByModel( const hostQualifiedModel = baseUrlHost && modelName ? `${baseUrlHost}:${modelName}` : undefined + // A host-qualified key (`:`) is strictly more specific than a + // bare model key, so ANY host-qualified match — exact OR prefix — must + // outrank a bare match. Rank both host-qualified forms in the high-priority + // `exact` tier ahead of the bare exact match; leave only the bare prefix in + // the low-priority `prefix` tier. Otherwise a bare exact (`qwen3.6-plus`) + // would beat a host-qualified prefix (`openrouter.ai:qwen3`) and the + // advertised per-endpoint disambiguation would fail for versioned families. return { exact: lookupExactByKey(entries, hostQualifiedModel) ?? - lookupExactByKey(entries, modelName), - prefix: lookupPrefixByKey(entries, hostQualifiedModel) ?? - lookupPrefixByKey(entries, modelName), + lookupExactByKey(entries, modelName), + prefix: lookupPrefixByKey(entries, modelName), } } From c239b49f5d23cffb37c2c2a2024e86f242f47d3c Mon Sep 17 00:00:00 2001 From: 0xfandom Date: Mon, 6 Jul 2026 12:48:23 +0530 Subject: [PATCH 11/13] fix(model): keep an exact modelLimits key ahead of any host-qualified prefix The previous commit ranked host-qualified PREFIX matches above bare exact matches, which broke the deliberate precedence in context.test.ts: an exact `gpt-4o` limit was overridden by an unrelated `api.foo.com:gpt-4` prefix that only matches a shorter, different model name. Restore the tiering so an exact match (host-qualified or bare) always beats a prefix, and a host-qualified key beats a bare key WITHIN the same match kind. The supported way to set a different limit for the same model per endpoint is a host-qualified EXACT key. Narrow the regression + comment to that behavior. --- src/utils/model/openaiContextWindows.test.ts | 11 +++++++---- src/utils/model/openaiContextWindows.ts | 20 +++++++++++--------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/utils/model/openaiContextWindows.test.ts b/src/utils/model/openaiContextWindows.test.ts index b3c0dd04c9..6923825761 100644 --- a/src/utils/model/openaiContextWindows.test.ts +++ b/src/utils/model/openaiContextWindows.test.ts @@ -123,9 +123,12 @@ test('settings modelLimits supports host-qualified keys', async () => { expect(getOpenAIContextWindow('qwen3.6-plus')).toBe(1_048_576) }) -test('host-qualified prefix key wins over a bare exact model key', async () => { - // A host-qualified prefix is more specific than a bare exact match, so the - // per-endpoint limit must win even when a bare exact key also matches. +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: { @@ -135,7 +138,7 @@ test('host-qualified prefix key wins over a bare exact model key', async () => { } const { getOpenAIContextWindow } = await importFresh() - expect(getOpenAIContextWindow('qwen3.6-plus')).toBe(1_048_576) + expect(getOpenAIContextWindow('qwen3.6-plus')).toBe(200_000) }) test('missing modelLimits returns undefined', async () => { diff --git a/src/utils/model/openaiContextWindows.ts b/src/utils/model/openaiContextWindows.ts index 3a5fa4abea..e9bea25018 100644 --- a/src/utils/model/openaiContextWindows.ts +++ b/src/utils/model/openaiContextWindows.ts @@ -119,19 +119,21 @@ function lookupByModel( const hostQualifiedModel = baseUrlHost && modelName ? `${baseUrlHost}:${modelName}` : undefined - // A host-qualified key (`:`) is strictly more specific than a - // bare model key, so ANY host-qualified match — exact OR prefix — must - // outrank a bare match. Rank both host-qualified forms in the high-priority - // `exact` tier ahead of the bare exact match; leave only the bare prefix in - // the low-priority `prefix` tier. Otherwise a bare exact (`qwen3.6-plus`) - // would beat a host-qualified prefix (`openrouter.ai:qwen3`) and the - // advertised per-endpoint disambiguation would fail for versioned families. + // 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) ?? - lookupPrefixByKey(entries, hostQualifiedModel) ?? lookupExactByKey(entries, modelName), - prefix: lookupPrefixByKey(entries, modelName), + prefix: + lookupPrefixByKey(entries, hostQualifiedModel) ?? + lookupPrefixByKey(entries, modelName), } } From 15feb10e7d730e1706e667e159b19594f908ebd6 Mon Sep 17 00:00:00 2001 From: 0xfandom Date: Tue, 7 Jul 2026 12:57:26 +0530 Subject: [PATCH 12/13] docs(model): align modelLimits matching wording with exact-over-prefix rule Narrow the advanced-setup wording so a host-qualified key only wins over a bare key within the same match kind (a bare exact key still beats a host-qualified prefix), matching lookupByModel's exact ?? prefix behavior; per-endpoint limits for the same model need host-qualified exact keys. Also note modelLimits as part of the documented user-override layer in the integration add-model and common-pitfalls guides. --- docs/advanced-setup.md | 12 ++++++++---- docs/integrations/common-pitfalls.md | 6 +++--- docs/integrations/how-to/add-model.md | 17 ++++++++++------- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 0395a6e3a8..12c7189985 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -422,10 +422,14 @@ addition to the `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` / ``` - **Key matching** — keys match the model api-name exactly, or by prefix (e.g. - `my-custom` matches `my-custom-deployment-v2`). A host-qualified key - (`:`, where `` is the `OPENAI_BASE_URL` hostname) wins over - a bare model key, so the same model name on two endpoints can carry different - limits. Either field may be omitted to override only one limit. + `my-custom` matches `my-custom-deployment-v2`). An **exact** key always wins + over a **prefix** key. A host-qualified key (`:`, where `` + is the `OPENAI_BASE_URL` hostname) 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. Either field may be + omitted to override only one limit. - **Precedence** — from highest to lowest: an **exact** env-var override → the built-in catalog / discovery-cache value → a **prefix** env-var override → `modelLimits` → the descriptor default. So env-var overrides always win over diff --git a/docs/integrations/common-pitfalls.md b/docs/integrations/common-pitfalls.md index 0fee67aff6..1fbd8e4c1e 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 bff5bb505c..a15bb0a151 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`). - -`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. +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 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 From 4f5c5fce3ee11676bfd0c296e066ac8b92aff200 Mon Sep 17 00:00:00 2001 From: 0xfandom Date: Wed, 8 Jul 2026 18:23:00 +0530 Subject: [PATCH 13/13] docs(model): clarify modelLimits host-port key and catalog/cache precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spell out that the host-qualified key uses new URL(baseUrl).host — including the port when present (localhost:4000:my-model, not localhost:my-model) — and split the precedence line so the built-in catalog is shown as checked before the discovery-cache value, matching resolveModelRuntimeLimits. --- docs/advanced-setup.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 12c7189985..301a5ecfaa 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -423,16 +423,19 @@ addition to the `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` / - **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 (`:`, where `` - is the `OPENAI_BASE_URL` hostname) 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. Either field may be + 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 / discovery-cache value → a **prefix** env-var override → - `modelLimits` → the descriptor default. So env-var overrides always win over + 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).