From 771cd28b379044489dbe7d30296b2e9e61d22abc Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:29:05 -0400 Subject: [PATCH 1/5] fix(ctx): prefer known model limits over generic 128k discovery Proxies such as OmniRoute often report context_length 128000 when per-model metadata is missing. That discovery value was beating known descriptors and settings.modelLimits, so 1M models auto-compacted against a 128k budget. Prefer larger known descriptors over the generic 128k discovery default, and let settings/env overrides beat discovery. Document the override path in /set-context-window help. Refs #2081 --- docs/advanced-setup.md | 14 ++- .../set-context-window/set-context-window.ts | 9 +- .../runtimeMetadata.modelLimits.test.ts | 55 ++++++++ src/integrations/runtimeMetadata.test.ts | 118 ++++++++++++++++++ src/integrations/runtimeMetadata.ts | 59 +++++++-- src/utils/model/openaiContextWindows.ts | 5 +- 6 files changed, 241 insertions(+), 19 deletions(-) diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index f0cc2c06e2..4b281ada95 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -522,12 +522,14 @@ addition to the `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` / 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). + built-in catalog value → a **prefix** env-var override → `modelLimits` → the + discovery-cache value (unless it is the generic 128k proxy default and a known + model descriptor is larger) → the descriptor default. So env-var overrides and + `modelLimits` always win over discovery, which matters when a gateway such as + OmniRoute reports `context_length: 128000` for every model. A known catalog + model keeps its catalog limit unless you set an *exact* env override for it. + Session-only: `/set-context-window ` also overrides for the current + session without editing settings. ### Exact-model pricing overrides (`settings.json`) diff --git a/src/commands/set-context-window/set-context-window.ts b/src/commands/set-context-window/set-context-window.ts index b432355f69..25664c5a30 100644 --- a/src/commands/set-context-window/set-context-window.ts +++ b/src/commands/set-context-window/set-context-window.ts @@ -14,8 +14,13 @@ Examples: /set-context-window gpt-4o 200000 /set-context-window status -The override takes precedence over integration catalog and provider -profile defaults. Use /clear-context-window to remove it.` +The override takes precedence over discovery, catalog, and provider defaults +for this session only. Use /clear-context-window to remove it. + +For a permanent override (survives restart), set modelLimits in settings.json +or CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS, for example: + { "modelLimits": { "my-model": { "contextWindow": 1000000 } } } +See docs/advanced-setup.md (Per-model limit overrides).` export const call: LocalCommandCall = async (args, context) => { const trimmed = args.trim() diff --git a/src/integrations/runtimeMetadata.modelLimits.test.ts b/src/integrations/runtimeMetadata.modelLimits.test.ts index 284a8180ba..99979262a8 100644 --- a/src/integrations/runtimeMetadata.modelLimits.test.ts +++ b/src/integrations/runtimeMetadata.modelLimits.test.ts @@ -162,3 +162,58 @@ test('resolveModelRuntimeLimits lets a broad env-prefix override win over an exa expect(limits.contextWindow).toBe(111_111) expect(limits.maxOutputTokens).toBe(4_096) }) + +test('resolveModelRuntimeLimits lets settings modelLimits beat discovery cache', async () => { + const { setCachedModels } = await import(`./discoveryCache.js?ts=${Date.now()}`) + const { getDiscoveryCacheKey } = await import( + `./discoveryService.js?ts=${Date.now()}` + ) + const { mkdtempSync, rmSync } = await import('node:fs') + const { tmpdir } = await import('node:os') + const { join } = await import('node:path') + + const originalConfigDir = process.env.CLAUDE_CONFIG_DIR + const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-model-limits-')) + process.env.CLAUDE_CONFIG_DIR = tempDir + try { + const baseUrl = 'http://localhost:20128/v1' + await setCachedModels( + getDiscoveryCacheKey('custom', { baseUrl }), + { + models: [ + { + id: 'my-codex-combo', + apiName: 'my-codex-combo', + label: 'my-codex-combo', + contextWindow: 128_000, + }, + ], + }, + ) + + mockSettings = { + modelLimits: { + 'my-codex-combo': { contextWindow: 1_000_000, maxOutputTokens: 32_768 }, + }, + } + const { resolveModelRuntimeLimits } = await importFresh() + + const limits = resolveModelRuntimeLimits({ + model: 'my-codex-combo', + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: baseUrl, + }, + }) + + expect(limits.contextWindow).toBe(1_000_000) + expect(limits.maxOutputTokens).toBe(32_768) + } finally { + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } + rmSync(tempDir, { recursive: true, force: true }) + } +}) diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index 99425f9e9b..0a53dd66a1 100644 --- a/src/integrations/runtimeMetadata.test.ts +++ b/src/integrations/runtimeMetadata.test.ts @@ -8,6 +8,7 @@ import { releaseSharedMutationLock, } from '../test/sharedMutationLock' import { + preferDiscoveredOrKnownContextWindow, resolveModelRuntimeLimits, resolveOpenAIShimRuntimeContext, } from '../integrations/runtimeMetadata' @@ -420,6 +421,123 @@ describe('resolveModelRuntimeLimits', () => { ).toBe(2_000_000) }) }) + + it('ignores generic 128k discovery defaults when a known model descriptor is larger', async () => { + // OmniRoute and similar gateways report context_length: 128000 for every + // model when they lack per-model metadata. That must not shadow gpt-5.4's + // 1.05M descriptor (premature auto-compact). + await withTempConfigDir(async () => { + const baseUrl = 'http://localhost:20128/v1' + await setCachedModels( + getDiscoveryCacheKey('custom', { + baseUrl, + }), + { + models: [ + { + id: 'gpt-5.4', + apiName: 'gpt-5.4', + label: 'gpt-5.4', + contextWindow: 128_000, + }, + { + id: 'openai/gpt-5.4', + apiName: 'openai/gpt-5.4', + label: 'openai/gpt-5.4', + contextWindow: 128_000, + }, + ], + }, + ) + + for (const model of ['gpt-5.4', 'openai/gpt-5.4']) { + expect( + resolveModelRuntimeLimits({ + model, + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: baseUrl, + }, + }).contextWindow, + ).toBe(1_050_000) + } + }) + }) + + it('still trusts non-default discovery values over a larger descriptor', async () => { + // If a proxy intentionally advertises a non-128k window (e.g. a real 200k + // cap), keep trusting discovery even when a descriptor is larger. + await withTempConfigDir(async () => { + const baseUrl = 'http://localhost:20128/v1' + await setCachedModels( + getDiscoveryCacheKey('custom', { + baseUrl, + }), + { + models: [ + { + id: 'gpt-5.4', + apiName: 'gpt-5.4', + label: 'gpt-5.4', + contextWindow: 200_000, + }, + ], + }, + ) + + expect( + resolveModelRuntimeLimits({ + model: 'gpt-5.4', + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: baseUrl, + }, + }).contextWindow, + ).toBe(200_000) + }) + }) + + it('lets an exact env override beat a wrong discovery-cache context window', async () => { + await withTempConfigDir(async () => { + const baseUrl = 'http://localhost:20128/v1' + await setCachedModels( + getDiscoveryCacheKey('custom', { + baseUrl, + }), + { + models: [ + { + id: 'my-codex-combo', + apiName: 'my-codex-combo', + label: 'my-codex-combo', + contextWindow: 128_000, + }, + ], + }, + ) + + expect( + resolveModelRuntimeLimits({ + model: 'my-codex-combo', + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: baseUrl, + CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS: JSON.stringify({ + 'my-codex-combo': 1_000_000, + }), + }, + }).contextWindow, + ).toBe(1_000_000) + }) + }) + + it('preferDiscoveredOrKnownContextWindow rescues known models from generic 128k discovery', () => { + expect(preferDiscoveredOrKnownContextWindow(128_000, 1_050_000)).toBe(1_050_000) + expect(preferDiscoveredOrKnownContextWindow(200_000, 1_050_000)).toBe(200_000) + expect(preferDiscoveredOrKnownContextWindow(128_000, 128_000)).toBe(128_000) + expect(preferDiscoveredOrKnownContextWindow(undefined, 1_050_000)).toBe(1_050_000) + expect(preferDiscoveredOrKnownContextWindow(1_000_000, undefined)).toBe(1_000_000) + }) }) describe('LLMTR runtime attribution', () => { diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index 50fa438599..391ac50009 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -486,6 +486,40 @@ function findCachedCatalogEntryForApiName( ) } +// Many OpenAI-compatible proxies (notably OmniRoute and similar gateways) report +// a generic 128k context_length when they lack per-model metadata. That value +// matches OPENAI_FALLBACK_CONTEXT_WINDOW's default in utils/context.ts. Prefer a +// larger known descriptor over that generic discovery default so auto-compact +// does not fire early on 1M-class models. +const GENERIC_DISCOVERY_CONTEXT_FALLBACK = 128_000 + +/** + * Choose between a discovered context window and a known descriptor limit. + * + * Trust discovery when it is present and does not look like the generic 128k + * proxy default. When discovery is that default and a known descriptor is larger, + * prefer the descriptor (wrong 128k from OmniRoute-style gateways was shadowing + * 1M models and triggering premature auto-compact). + */ +export function preferDiscoveredOrKnownContextWindow( + discovered: number | undefined, + known: number | undefined, +): number | undefined { + if (discovered === undefined) { + return known + } + if (known === undefined) { + return discovered + } + if ( + discovered === GENERIC_DISCOVERY_CONTEXT_FALLBACK && + known > GENERIC_DISCOVERY_CONTEXT_FALLBACK + ) { + return known + } + return discovered +} + export function resolveModelRuntimeLimits(options: { model: string processEnv?: NodeJS.ProcessEnv @@ -531,28 +565,35 @@ 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 + // Precedence (high → low): + // 1. exact env override + // 2. built-in route catalog (so `:cloud` variants keep their catalog cap over + // a broad base-model env *prefix*) + // 3. env *prefix* override + // 4. settings.json `modelLimits` (explicit user pin; must beat discovery so + // wrong proxy metadata cannot lock users out of documented overrides) + // 5. discovery cache, unless it is the generic 128k proxy default and a known + // model descriptor is larger + // 6. model descriptor default + // Keep `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, + preferDiscoveredOrKnownContextWindow( + cachedCatalogEntry?.contextWindow, + modelDescriptor?.contextWindow, + ), maxOutputTokens: externalMaxOutputTokens.exact ?? catalogEntry?.maxOutputTokens ?? - cachedCatalogEntry?.maxOutputTokens ?? externalMaxOutputTokens.prefix ?? externalMaxOutputTokens.settings ?? + cachedCatalogEntry?.maxOutputTokens ?? modelDescriptor?.maxOutputTokens, } } diff --git a/src/utils/model/openaiContextWindows.ts b/src/utils/model/openaiContextWindows.ts index e9bea25018..cf502c09dc 100644 --- a/src/utils/model/openaiContextWindows.ts +++ b/src/utils/model/openaiContextWindows.ts @@ -9,8 +9,9 @@ * 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 + * built-in catalog, then the prefix env override, then settings `modelLimits`, + * then discovery (with a known-descriptor rescue for the generic 128k proxy + * default), then the descriptor default — is applied by * resolveModelRuntimeLimits in integrations/runtimeMetadata.ts. Keep precedence * changes there, not duplicated here. */ From 12612c4b60bb08a7b4d0626632e7b23a1b74c59a Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 07:39:28 -0400 Subject: [PATCH 2/5] test(ctx): cover OmniRoute + gpt-5.6-sol (user-reported gpt sol) The Discord report used GPT-5.6 Sol, not gpt-5.4. Regression now pins the catalog-less custom-route rescue to the 272k descriptor for gpt-5.6-sol. Refs #2081 --- src/integrations/runtimeMetadata.test.ts | 47 ++++++++++-------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index 0a53dd66a1..41ad67e053 100644 --- a/src/integrations/runtimeMetadata.test.ts +++ b/src/integrations/runtimeMetadata.test.ts @@ -423,9 +423,10 @@ describe('resolveModelRuntimeLimits', () => { }) it('ignores generic 128k discovery defaults when a known model descriptor is larger', async () => { - // OmniRoute and similar gateways report context_length: 128000 for every - // model when they lack per-model metadata. That must not shadow gpt-5.4's - // 1.05M descriptor (premature auto-compact). + // User-reported path: OmniRoute + GPT-5.6 Sol ("gpt sol"). Gateways often + // report context_length: 128000 when they lack per-model metadata. That must + // not shadow the known gpt-5.6-sol descriptor (272k Codex-safe fallback on + // catalog-less custom routes; premature auto-compact at 128k). await withTempConfigDir(async () => { const baseUrl = 'http://localhost:20128/v1' await setCachedModels( @@ -435,32 +436,24 @@ describe('resolveModelRuntimeLimits', () => { { models: [ { - id: 'gpt-5.4', - apiName: 'gpt-5.4', - label: 'gpt-5.4', - contextWindow: 128_000, - }, - { - id: 'openai/gpt-5.4', - apiName: 'openai/gpt-5.4', - label: 'openai/gpt-5.4', + id: 'gpt-5.6-sol', + apiName: 'gpt-5.6-sol', + label: 'gpt-5.6-sol', contextWindow: 128_000, }, ], }, ) - for (const model of ['gpt-5.4', 'openai/gpt-5.4']) { - expect( - resolveModelRuntimeLimits({ - model, - processEnv: { - CLAUDE_CODE_USE_OPENAI: '1', - OPENAI_BASE_URL: baseUrl, - }, - }).contextWindow, - ).toBe(1_050_000) - } + expect( + resolveModelRuntimeLimits({ + model: 'gpt-5.6-sol', + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: baseUrl, + }, + }).contextWindow, + ).toBe(272_000) }) }) @@ -476,9 +469,9 @@ describe('resolveModelRuntimeLimits', () => { { models: [ { - id: 'gpt-5.4', - apiName: 'gpt-5.4', - label: 'gpt-5.4', + id: 'gpt-5.6-sol', + apiName: 'gpt-5.6-sol', + label: 'gpt-5.6-sol', contextWindow: 200_000, }, ], @@ -487,7 +480,7 @@ describe('resolveModelRuntimeLimits', () => { expect( resolveModelRuntimeLimits({ - model: 'gpt-5.4', + model: 'gpt-5.6-sol', processEnv: { CLAUDE_CODE_USE_OPENAI: '1', OPENAI_BASE_URL: baseUrl, From 218c425362ae4486735f540496740440d2923343 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:12:13 -0400 Subject: [PATCH 3/5] fix(ctx): let explicit overrides beat discovered context windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses both review findings on #2082. P1 — drop the generic-128k rescue from the previous commit. The custom gateway discovery mapper copies an endpoint's own context_length / context_window into ModelCatalogEntry.contextWindow, and nothing synthesizes 128k on our side, so the numeric value carries no provenance: a flat 128000 is indistinguishable from a real per-deployment or tenant cap. Budgeting a known-larger descriptor over that cap traded an early auto-compact for a late context-window API failure. Discovery is authoritative over the model descriptor again. What remains is the safe half of the fix: an env prefix override and settings.json modelLimits now sit above discovery, so a user on a gateway that advertises the wrong window can pin the real one. Docs and /set-context-window help point at that path. Tests cover a gateway-advertised 128k and 200k window staying put for a model the catalog knows is larger, plus env and modelLimits overrides beating it. P3 — isolate the discovery-cache fixtures. getClaudeConfigHomeDir() ignores CLAUDE_CONFIG_DIR by design, so both the new modelLimits test and the pre-existing withTempConfigDir helper were writing fixtures into the caller's real config dir. Both now use setClaudeConfigHomeDirForTesting with save and restore, and clear the discovery cache on teardown so the in-memory sync snapshot cannot leak into a later suite. The modelLimits test now asserts the isolated cache resolves on its own before asserting the settings pin wins. Refs #2081 --- docs/advanced-setup.md | 17 ++- .../runtimeMetadata.modelLimits.test.ts | 56 +++++--- src/integrations/runtimeMetadata.test.ts | 135 +++++++----------- src/integrations/runtimeMetadata.ts | 53 ++----- src/utils/model/openaiContextWindows.ts | 3 +- 5 files changed, 110 insertions(+), 154 deletions(-) diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 4b281ada95..ec1e8ce371 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -523,13 +523,16 @@ addition to the `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` / omitted to override only one limit. - **Precedence** — from highest to lowest: an **exact** env-var override → the built-in catalog value → a **prefix** env-var override → `modelLimits` → the - discovery-cache value (unless it is the generic 128k proxy default and a known - model descriptor is larger) → the descriptor default. So env-var overrides and - `modelLimits` always win over discovery, which matters when a gateway such as - OmniRoute reports `context_length: 128000` for every model. A known catalog - model keeps its catalog limit unless you set an *exact* env override for it. - Session-only: `/set-context-window ` also overrides for the current - session without editing settings. + discovery-cache value → the descriptor default. So env-var overrides and + `modelLimits` both win over discovery. A known catalog model keeps its catalog + limit unless you set an *exact* env override for it. +- **When a gateway advertises the wrong window** — some OpenAI-compatible + gateways report a flat `context_length` (often `128000`) for every model they + proxy. OpenClaude keeps that value, because an advertised window can just as + easily be a real per-deployment cap, and budgeting above the endpoint's true + limit turns early auto-compact into a mid-session API failure. If you know the + real window, pin it with `modelLimits` or `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` + above, or use `/set-context-window ` for the current session only. ### Exact-model pricing overrides (`settings.json`) diff --git a/src/integrations/runtimeMetadata.modelLimits.test.ts b/src/integrations/runtimeMetadata.modelLimits.test.ts index 99979262a8..60b7944674 100644 --- a/src/integrations/runtimeMetadata.modelLimits.test.ts +++ b/src/integrations/runtimeMetadata.modelLimits.test.ts @@ -164,17 +164,26 @@ test('resolveModelRuntimeLimits lets a broad env-prefix override win over an exa }) test('resolveModelRuntimeLimits lets settings modelLimits beat discovery cache', async () => { - const { setCachedModels } = await import(`./discoveryCache.js?ts=${Date.now()}`) + const { clearDiscoveryCache, setCachedModels } = await import( + `./discoveryCache.js?ts=${Date.now()}` + ) const { getDiscoveryCacheKey } = await import( `./discoveryService.js?ts=${Date.now()}` ) + // The discovery cache path comes from getClaudeConfigHomeDir(), which ignores + // CLAUDE_CONFIG_DIR by design. Without this override the fixture below would + // be written into the caller's real ~/.openclaude. + const { + getClaudeConfigHomeDirOverrideForTesting, + setClaudeConfigHomeDirForTesting, + } = await import('../utils/envUtils.js') const { mkdtempSync, rmSync } = await import('node:fs') const { tmpdir } = await import('node:os') const { join } = await import('node:path') - const originalConfigDir = process.env.CLAUDE_CONFIG_DIR + const previousOverride = getClaudeConfigHomeDirOverrideForTesting() const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-model-limits-')) - process.env.CLAUDE_CONFIG_DIR = tempDir + setClaudeConfigHomeDirForTesting(tempDir) try { const baseUrl = 'http://localhost:20128/v1' await setCachedModels( @@ -186,34 +195,43 @@ test('resolveModelRuntimeLimits lets settings modelLimits beat discovery cache', apiName: 'my-codex-combo', label: 'my-codex-combo', contextWindow: 128_000, + maxOutputTokens: 8_192, }, ], }, ) + const { resolveModelRuntimeLimits } = await importFresh() + const resolveWithDiscovery = () => + resolveModelRuntimeLimits({ + model: 'my-codex-combo', + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: baseUrl, + }, + }) + + // Establish that the isolated cache is observable first, so the override + // assertion below proves precedence rather than passing on a missing fixture. + mockSettings = {} + const discovered = resolveWithDiscovery() + expect(discovered.contextWindow).toBe(128_000) + expect(discovered.maxOutputTokens).toBe(8_192) + mockSettings = { modelLimits: { 'my-codex-combo': { contextWindow: 1_000_000, maxOutputTokens: 32_768 }, }, } - const { resolveModelRuntimeLimits } = await importFresh() + const overridden = resolveWithDiscovery() - const limits = resolveModelRuntimeLimits({ - model: 'my-codex-combo', - processEnv: { - CLAUDE_CODE_USE_OPENAI: '1', - OPENAI_BASE_URL: baseUrl, - }, - }) - - expect(limits.contextWindow).toBe(1_000_000) - expect(limits.maxOutputTokens).toBe(32_768) + expect(overridden.contextWindow).toBe(1_000_000) + expect(overridden.maxOutputTokens).toBe(32_768) } finally { - if (originalConfigDir === undefined) { - delete process.env.CLAUDE_CONFIG_DIR - } else { - process.env.CLAUDE_CONFIG_DIR = originalConfigDir - } + // Clears the module-level sync snapshot as well, so the fixture cannot leak + // into a later suite once the temp dir is removed. + await clearDiscoveryCache() + setClaudeConfigHomeDirForTesting(previousOverride) rmSync(tempDir, { recursive: true, force: true }) } }) diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index 41ad67e053..16235de492 100644 --- a/src/integrations/runtimeMetadata.test.ts +++ b/src/integrations/runtimeMetadata.test.ts @@ -8,23 +8,23 @@ import { releaseSharedMutationLock, } from '../test/sharedMutationLock' import { - preferDiscoveredOrKnownContextWindow, resolveModelRuntimeLimits, resolveOpenAIShimRuntimeContext, } from '../integrations/runtimeMetadata' -import { setCachedModels } from './discoveryCache' +import { clearDiscoveryCache, setCachedModels } from './discoveryCache' +import { + getClaudeConfigHomeDirOverrideForTesting, + setClaudeConfigHomeDirForTesting, +} from '../utils/envUtils.js' import { getDiscoveryCacheKey, getRouteDiscoveryHeaders, } from './discoveryService' import { resolveActiveRouteIdFromEnv } from './routeMetadata.js' -import { setClaudeConfigHomeDirForTesting } from '../utils/envUtils.js' import glmBrand from './brands/glm.js' import glmModels from './models/glm.js' import zaiVendor from './vendors/zai.js' -const originalConfigDir = process.env.CLAUDE_CONFIG_DIR - describe('Z.AI GLM-5.3 descriptor contract', () => { it('wires the verified GLM-5.3-Flash descriptor and direct catalog contract', () => { const model = glmModels.find(candidate => candidate.id === 'glm-5.3-flash') @@ -115,25 +115,27 @@ describe('Z.AI GLM-5.3 descriptor contract', () => { }) }) +// The discovery cache resolves its path through getClaudeConfigHomeDir(), which +// deliberately ignores CLAUDE_CONFIG_DIR (OpenClaude config is independent of +// Claude Code config). Use the explicit test override so these fixtures land in +// the temp dir instead of the developer's real ~/.openclaude. async function withTempConfigDir(fn: () => Promise): Promise { await acquireSharedMutationLock('integrations/runtimeMetadata.test.ts') + const previousOverride = getClaudeConfigHomeDirOverrideForTesting() let tempDir: string | null = null try { tempDir = mkdtempSync(join(tmpdir(), 'openclaude-runtime-metadata-test-')) setClaudeConfigHomeDirForTesting(tempDir) - process.env.CLAUDE_CONFIG_DIR = tempDir return await fn() } finally { try { - if (originalConfigDir === undefined) { - delete process.env.CLAUDE_CONFIG_DIR - } else { - process.env.CLAUDE_CONFIG_DIR = originalConfigDir - } + // Drops the module-level sync snapshot so a later suite cannot read this + // suite's fixture back out of memory after the temp dir is gone. + await clearDiscoveryCache() + setClaudeConfigHomeDirForTesting(previousOverride) if (tempDir) { rmSync(tempDir, { recursive: true, force: true }) } - setClaudeConfigHomeDirForTesting(undefined) } finally { releaseSharedMutationLock() } @@ -422,73 +424,46 @@ describe('resolveModelRuntimeLimits', () => { }) }) - it('ignores generic 128k discovery defaults when a known model descriptor is larger', async () => { - // User-reported path: OmniRoute + GPT-5.6 Sol ("gpt sol"). Gateways often - // report context_length: 128000 when they lack per-model metadata. That must - // not shadow the known gpt-5.6-sol descriptor (272k Codex-safe fallback on - // catalog-less custom routes; premature auto-compact at 128k). - await withTempConfigDir(async () => { - const baseUrl = 'http://localhost:20128/v1' - await setCachedModels( - getDiscoveryCacheKey('custom', { - baseUrl, - }), - { - models: [ - { - id: 'gpt-5.6-sol', - apiName: 'gpt-5.6-sol', - label: 'gpt-5.6-sol', - contextWindow: 128_000, - }, - ], - }, - ) - - expect( - resolveModelRuntimeLimits({ - model: 'gpt-5.6-sol', - processEnv: { - CLAUDE_CODE_USE_OPENAI: '1', - OPENAI_BASE_URL: baseUrl, + it.each([128_000, 200_000])( + 'keeps a gateway-advertised %i window for a globally larger known model', + async advertised => { + // User-reported path: OmniRoute + GPT-5.6 Sol ("gpt sol"), whose gateway + // advertised a smaller window than the known descriptor. An advertised + // `context_length` is indistinguishable from a real per-deployment cap, so + // discovery stays authoritative — budgeting past the endpoint's real limit + // would trade an early compact for a mid-session API failure. 128k is + // covered explicitly because it is the value gateways most often report as + // a flat default. + await withTempConfigDir(async () => { + const baseUrl = 'http://localhost:20128/v1' + await setCachedModels( + getDiscoveryCacheKey('custom', { + baseUrl, + }), + { + models: [ + { + id: 'gpt-5.6-sol', + apiName: 'gpt-5.6-sol', + label: 'gpt-5.6-sol', + contextWindow: advertised, + }, + ], }, - }).contextWindow, - ).toBe(272_000) - }) - }) + ) - it('still trusts non-default discovery values over a larger descriptor', async () => { - // If a proxy intentionally advertises a non-128k window (e.g. a real 200k - // cap), keep trusting discovery even when a descriptor is larger. - await withTempConfigDir(async () => { - const baseUrl = 'http://localhost:20128/v1' - await setCachedModels( - getDiscoveryCacheKey('custom', { - baseUrl, - }), - { - models: [ - { - id: 'gpt-5.6-sol', - apiName: 'gpt-5.6-sol', - label: 'gpt-5.6-sol', - contextWindow: 200_000, + expect( + resolveModelRuntimeLimits({ + model: 'gpt-5.6-sol', + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: baseUrl, }, - ], - }, - ) - - expect( - resolveModelRuntimeLimits({ - model: 'gpt-5.6-sol', - processEnv: { - CLAUDE_CODE_USE_OPENAI: '1', - OPENAI_BASE_URL: baseUrl, - }, - }).contextWindow, - ).toBe(200_000) - }) - }) + }).contextWindow, + ).toBe(advertised) + }) + }, + ) it('lets an exact env override beat a wrong discovery-cache context window', async () => { await withTempConfigDir(async () => { @@ -523,14 +498,6 @@ describe('resolveModelRuntimeLimits', () => { ).toBe(1_000_000) }) }) - - it('preferDiscoveredOrKnownContextWindow rescues known models from generic 128k discovery', () => { - expect(preferDiscoveredOrKnownContextWindow(128_000, 1_050_000)).toBe(1_050_000) - expect(preferDiscoveredOrKnownContextWindow(200_000, 1_050_000)).toBe(200_000) - expect(preferDiscoveredOrKnownContextWindow(128_000, 128_000)).toBe(128_000) - expect(preferDiscoveredOrKnownContextWindow(undefined, 1_050_000)).toBe(1_050_000) - expect(preferDiscoveredOrKnownContextWindow(1_000_000, undefined)).toBe(1_000_000) - }) }) describe('LLMTR runtime attribution', () => { diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index 391ac50009..6b5403a2eb 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -486,40 +486,6 @@ function findCachedCatalogEntryForApiName( ) } -// Many OpenAI-compatible proxies (notably OmniRoute and similar gateways) report -// a generic 128k context_length when they lack per-model metadata. That value -// matches OPENAI_FALLBACK_CONTEXT_WINDOW's default in utils/context.ts. Prefer a -// larger known descriptor over that generic discovery default so auto-compact -// does not fire early on 1M-class models. -const GENERIC_DISCOVERY_CONTEXT_FALLBACK = 128_000 - -/** - * Choose between a discovered context window and a known descriptor limit. - * - * Trust discovery when it is present and does not look like the generic 128k - * proxy default. When discovery is that default and a known descriptor is larger, - * prefer the descriptor (wrong 128k from OmniRoute-style gateways was shadowing - * 1M models and triggering premature auto-compact). - */ -export function preferDiscoveredOrKnownContextWindow( - discovered: number | undefined, - known: number | undefined, -): number | undefined { - if (discovered === undefined) { - return known - } - if (known === undefined) { - return discovered - } - if ( - discovered === GENERIC_DISCOVERY_CONTEXT_FALLBACK && - known > GENERIC_DISCOVERY_CONTEXT_FALLBACK - ) { - return known - } - return discovered -} - export function resolveModelRuntimeLimits(options: { model: string processEnv?: NodeJS.ProcessEnv @@ -570,11 +536,16 @@ export function resolveModelRuntimeLimits(options: { // 2. built-in route catalog (so `:cloud` variants keep their catalog cap over // a broad base-model env *prefix*) // 3. env *prefix* override - // 4. settings.json `modelLimits` (explicit user pin; must beat discovery so - // wrong proxy metadata cannot lock users out of documented overrides) - // 5. discovery cache, unless it is the generic 128k proxy default and a known - // model descriptor is larger + // 4. settings.json `modelLimits` (explicit user pin) + // 5. discovery cache // 6. model descriptor default + // Discovery stays authoritative over the descriptor: a gateway's advertised + // `context_length` is the endpoint's real cap (it may legitimately be a + // smaller deployment/tenant limit for a globally larger model), and the + // OpenAI-compatible response carries no signal that would let us tell a + // synthetic gateway default apart from a real cap. Users whose gateway + // advertises a wrong window pin it explicitly via `modelLimits`, an env + // override, or `/set-context-window`; those all sit above discovery here. // Keep `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. @@ -584,10 +555,8 @@ export function resolveModelRuntimeLimits(options: { catalogEntry?.contextWindow ?? externalContextWindow.prefix ?? externalContextWindow.settings ?? - preferDiscoveredOrKnownContextWindow( - cachedCatalogEntry?.contextWindow, - modelDescriptor?.contextWindow, - ), + cachedCatalogEntry?.contextWindow ?? + modelDescriptor?.contextWindow, maxOutputTokens: externalMaxOutputTokens.exact ?? catalogEntry?.maxOutputTokens ?? diff --git a/src/utils/model/openaiContextWindows.ts b/src/utils/model/openaiContextWindows.ts index cf502c09dc..90dc311681 100644 --- a/src/utils/model/openaiContextWindows.ts +++ b/src/utils/model/openaiContextWindows.ts @@ -10,8 +10,7 @@ * matches and the settings `modelLimits` match); it does not decide the overall * precedence. The authoritative runtime chain — exact env override, then the * built-in catalog, then the prefix env override, then settings `modelLimits`, - * then discovery (with a known-descriptor rescue for the generic 128k proxy - * default), then the descriptor default — is applied by + * then the discovery cache, then the descriptor default — is applied by * resolveModelRuntimeLimits in integrations/runtimeMetadata.ts. Keep precedence * changes there, not duplicated here. */ From b3cd96d609b13725aea0178104f0d7bcf50f4fa1 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:27:22 -0400 Subject: [PATCH 4/5] test(ctx): cover env prefix overrides over discovery cache CodeRabbit on #2082 asked for a regression where CLAUDE_CODE_OPENAI_* prefix keys beat a seeded discovery cache for both contextWindow and maxOutputTokens, not just exact keys and settings.modelLimits. Refs #2081 --- src/integrations/runtimeMetadata.test.ts | 42 ++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/integrations/runtimeMetadata.test.ts b/src/integrations/runtimeMetadata.test.ts index 16235de492..713a3bde2e 100644 --- a/src/integrations/runtimeMetadata.test.ts +++ b/src/integrations/runtimeMetadata.test.ts @@ -498,6 +498,48 @@ describe('resolveModelRuntimeLimits', () => { ).toBe(1_000_000) }) }) + + it('lets env prefix overrides beat discovery for both context and max-output limits', async () => { + // CodeRabbit on #2082: exact-key and settings.modelLimits vs discovery are + // covered, but not CLAUDE_CODE_OPENAI_* prefix keys. A prefix pin is the + // documented way to cover a family of gateway model ids without listing + // each one, and must sit above the discovery cache for both limits. + await withTempConfigDir(async () => { + const baseUrl = 'http://localhost:20128/v1' + await setCachedModels( + getDiscoveryCacheKey('custom', { + baseUrl, + }), + { + models: [ + { + id: 'my-codex-combo-v2', + apiName: 'my-codex-combo-v2', + label: 'my-codex-combo-v2', + contextWindow: 128_000, + maxOutputTokens: 8_192, + }, + ], + }, + ) + + expect( + resolveModelRuntimeLimits({ + model: 'my-codex-combo-v2', + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: baseUrl, + CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS: JSON.stringify({ + 'my-codex-combo': 1_000_000, + }), + CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS: JSON.stringify({ + 'my-codex-combo': 32_768, + }), + }, + }), + ).toEqual({ contextWindow: 1_000_000, maxOutputTokens: 32_768 }) + }) + }) }) describe('LLMTR runtime attribution', () => { From 859d4c1ef4bbe6163cba0000a9040c7d145a5ab0 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:00:49 -0400 Subject: [PATCH 5/5] docs(ctx): Clarify permanent overrides for catalogued models Keep the documented workaround aligned with resolver precedence so users do not expect modelLimits to override built-in catalog entries. Refs #2081 --- docs/advanced-setup.md | 6 ++++-- src/commands/set-context-window/set-context-window.ts | 6 ++++-- src/integrations/runtimeMetadata.ts | 5 +++-- src/utils/model/openaiContextWindows.ts | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index ec1e8ce371..4428a63e47 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -531,8 +531,10 @@ addition to the `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` / proxy. OpenClaude keeps that value, because an advertised window can just as easily be a real per-deployment cap, and budgeting above the endpoint's true limit turns early auto-compact into a mid-session API failure. If you know the - real window, pin it with `modelLimits` or `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` - above, or use `/set-context-window ` for the current session only. + real window, use an **exact** `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` entry for a + catalogued model. For a custom or discovered model, use `modelLimits` or an + exact env entry. Use `/set-context-window ` for the current session + only. ### Exact-model pricing overrides (`settings.json`) diff --git a/src/commands/set-context-window/set-context-window.ts b/src/commands/set-context-window/set-context-window.ts index 25664c5a30..1cfcfa0af9 100644 --- a/src/commands/set-context-window/set-context-window.ts +++ b/src/commands/set-context-window/set-context-window.ts @@ -17,9 +17,11 @@ Examples: The override takes precedence over discovery, catalog, and provider defaults for this session only. Use /clear-context-window to remove it. -For a permanent override (survives restart), set modelLimits in settings.json -or CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS, for example: +For a permanent override (survives restart), use modelLimits in settings.json +for custom/discovered models, for example: { "modelLimits": { "my-model": { "contextWindow": 1000000 } } } +For a catalogued model, use an exact CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS entry; +catalog values take precedence over modelLimits and prefix env entries. See docs/advanced-setup.md (Per-model limit overrides).` export const call: LocalCommandCall = async (args, context) => { diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index 6b5403a2eb..bf4d79f453 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -544,8 +544,9 @@ export function resolveModelRuntimeLimits(options: { // smaller deployment/tenant limit for a globally larger model), and the // OpenAI-compatible response carries no signal that would let us tell a // synthetic gateway default apart from a real cap. Users whose gateway - // advertises a wrong window pin it explicitly via `modelLimits`, an env - // override, or `/set-context-window`; those all sit above discovery here. + // advertises a wrong window pin it via an exact env override, `modelLimits` + // for an uncatalogued model, or `/set-context-window`; each applicable + // override sits above discovery here. // Keep `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. diff --git a/src/utils/model/openaiContextWindows.ts b/src/utils/model/openaiContextWindows.ts index 90dc311681..16cc354042 100644 --- a/src/utils/model/openaiContextWindows.ts +++ b/src/utils/model/openaiContextWindows.ts @@ -27,7 +27,7 @@ export type OpenAILimitOverrideMatches = { // 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. + // prefix env overrides and catalog, but before the discovery cache. settings?: number // Prefix env-var override match. prefix?: number