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