mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 12:50:54 -04:00
feat(ctx): add --save to /set-context-window to persist overrides (#2475)
* feat(ctx): add --save to /set-context-window to persist overrides `/set-context-window [model] <tokens> --save` applies the session override and also writes modelLimits["<model>"].contextWindow to user settings.json through the existing settings writer, keeping unrelated settings and the entry's maxOutputTokens. The output distinguishes session-only changes, successful saves, and failed saves (the session override still applies). After saving, the command resolves the window a restarted session would use and reports when an env override, the route catalog, or another modelLimits entry will take precedence over the saved value. Precedence is unchanged. Closes #2298 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ctx): check --save values against the settings schema before writing The session override accepts any finite integer of at least 33000 tokens, but the settings schema only accepts safe integers for modelLimits.*.contextWindow. Saving a larger value reported success and wrote an entry that made the whole user settings file fail to load on the next read. Validate the modelLimits patch with SettingsSchema before writing it. A rejected value now prints "Not saved: <path>: <reason>", leaves the file unchanged, and keeps the session override. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ctx): report when invalid settings keep a saved window from loading The settings writer keeps fields the schema rejects that are already in the file, and the loader then ignores the whole file. --save still printed "Saved" and blamed whichever source won without the file, for example "fallback for unknown model ... instead of the saved value". After writing, check whether the user settings file loads. If it does not, say that the saved value will not apply after a restart and list the invalid settings. Update the help text and setup docs to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ctx): report env settings that a --save repair makes loadable Startup ignores an invalid user settings file, including its env block, and the settings watcher skips --save's own write. When --save repaired such a file, the restart check still read this session's environment and printed a plain "Saved", although after a restart the file's env (for example CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS) applies and can take precedence over the saved value. Read the file's state before writing. If it did not load before and loads now, name its env settings that this session did not load and say the restart check does not account for them. Qualify the resolver comment and update the help text and setup docs to match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ctx): report a failed save for a model name settings cannot store The settings schema drops a "__proto__" record key without an error, and the writer's merge and the loader drop it the same way. --save checked only that the patch validated, then printed "Saved to modelLimits["__proto__"]" while the file kept no entry, and the restart check reported "[object Object] tokens". Check that the validated patch still holds the model's entry before writing. If it does not, report "Not saved" and keep the session override, as other failed saves do. Cover both command forms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ctx): report a failed save when the window does not reach settings --save printed "Saved to modelLimits[...]" whenever the settings writer returned no error. For an invalid settings file the writer merges into the raw JSON, and when modelLimits or the model's entry is an array the merge keeps the array and sets the new field as a named array property, which JSON drops. The file then held no contextWindow for the model. Read the file back after writing and report a save only when it holds the requested contextWindow. Otherwise report "Not saved", naming the array that blocked it, and keep the session override, as other failed saves do. Cover both array levels in both command forms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -661,6 +661,18 @@ addition to the `CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS` /
|
||||
catalogued model. For a custom or discovered model, use `modelLimits` or an
|
||||
exact env entry. Use `/set-context-window <tokens>` for the current session
|
||||
only.
|
||||
- **Saving from the command** — `/set-context-window [model] <tokens> --save`
|
||||
applies the session override and also writes
|
||||
`modelLimits["<model>"].contextWindow` to your user `settings.json`, keeping
|
||||
other settings and the entry's `maxOutputTokens`. The saved value follows the
|
||||
precedence above after a restart; if an env override, the catalog, or another
|
||||
`modelLimits` entry would take precedence, the command says so. It also
|
||||
lists any other invalid settings in the file, because they make the whole
|
||||
file ignored after a restart. If the save makes a previously invalid file
|
||||
valid, the command names the file's `env` settings that the current session
|
||||
did not load: they can apply after a restart, and the precedence check does
|
||||
not account for them. If the save fails, the override still applies to the
|
||||
current session.
|
||||
- **Checking the active source** — `/ctx` shows a **Window source** line naming
|
||||
the step that supplied the current window (for example an env override,
|
||||
`modelLimits`, provider discovery, or the fallback for an unknown model).
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { CompactMetadata, Message } from '../../types/message.js'
|
||||
import { analyzeContextUsage, type ContextData } from '../../utils/analyzeContext.js'
|
||||
import { getGlobalConfig } from '../../utils/config.js'
|
||||
import {
|
||||
CONTEXT_WINDOW_SOURCE_LABELS,
|
||||
type ContextWindowSource,
|
||||
getModelMaxOutputTokens,
|
||||
resolveContextWindowForModel,
|
||||
@@ -166,24 +167,6 @@ export async function collectCtxData(context: CtxDataInput): Promise<{
|
||||
/** Shape returned by collectCtxData, accepted by renderCtxReport. */
|
||||
export type RenderInput = Awaited<ReturnType<typeof collectCtxData>>
|
||||
|
||||
const CONTEXT_WINDOW_SOURCE_LABELS: Record<ContextWindowSource, string> = {
|
||||
'internal-env-override': 'CLAUDE_CODE_MAX_CONTEXT_TOKENS',
|
||||
'session-override': 'session override (/set-context-window)',
|
||||
'1m-suffix': '[1m] model suffix',
|
||||
'env-exact': 'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS (exact match)',
|
||||
'route-catalog': 'provider route catalog',
|
||||
'env-prefix': 'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS (prefix match)',
|
||||
'model-limits': 'modelLimits in settings',
|
||||
discovery: 'provider model discovery',
|
||||
'model-descriptor': 'built-in model metadata',
|
||||
'runtime-limits': 'provider runtime limits',
|
||||
fallback: 'fallback for unknown model',
|
||||
'1m-beta': '1M context beta',
|
||||
'1m-experiment': '1M context experiment',
|
||||
'internal-model-config': 'internal model config',
|
||||
default: 'default',
|
||||
}
|
||||
|
||||
const AUTO_COMPACT_REASON_LABELS: Record<NonNullable<CompactMetadata['autoCompactReason']>, string> = {
|
||||
'token-threshold': 'token threshold',
|
||||
'message-count': 'message count',
|
||||
|
||||
@@ -4,7 +4,7 @@ export default {
|
||||
type: 'local',
|
||||
name: 'set-context-window',
|
||||
description: 'Set a session-scoped context window override for a model',
|
||||
argumentHint: '[model] <tokens>',
|
||||
argumentHint: '[model] <tokens> [--save]',
|
||||
supportsNonInteractive: true,
|
||||
load: () => import('./set-context-window.js'),
|
||||
} satisfies Command
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
@@ -8,7 +11,14 @@ import { call as setContextWindow } from './set-context-window.js'
|
||||
import {
|
||||
clearSessionContextWindowOverride,
|
||||
getContextWindowForModel,
|
||||
resolveContextWindowForModel,
|
||||
} from '../../utils/context.js'
|
||||
import {
|
||||
getClaudeConfigHomeDirOverrideForTesting,
|
||||
setClaudeConfigHomeDirForTesting,
|
||||
} from '../../utils/envUtils.js'
|
||||
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
|
||||
import { snapshotEnv } from '../../test/envSnapshot.js'
|
||||
import {
|
||||
getAutoCompactThreshold,
|
||||
getEffectiveContextWindowSize,
|
||||
@@ -63,3 +73,247 @@ test('/set-context-window updates tokens and compaction immediately; clear resto
|
||||
expect(getEffectiveContextWindowSize('claude-sonnet-4')).toBe(80_000)
|
||||
expect(getAutoCompactThreshold('claude-sonnet-4')).toBe(50_000)
|
||||
})
|
||||
|
||||
const SAVE_MODEL = 'my-private-model'
|
||||
|
||||
async function withCustomProviderSettings(
|
||||
initialSettings: string | undefined,
|
||||
run: (settingsPath: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const root = mkdtempSync(join(tmpdir(), 'openclaude-set-context-window-'))
|
||||
const settingsPath = join(root, 'settings.json')
|
||||
if (initialSettings !== undefined) {
|
||||
writeFileSync(settingsPath, initialSettings)
|
||||
}
|
||||
const restoreEnv = snapshotEnv(
|
||||
[
|
||||
'CLAUDE_CODE_USE_OPENAI',
|
||||
'OPENAI_BASE_URL',
|
||||
'OPENAI_API_BASE',
|
||||
'OPENAI_MODEL',
|
||||
'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS',
|
||||
],
|
||||
{ clear: true },
|
||||
)
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
process.env.OPENAI_BASE_URL = 'https://llm.example.test/v1'
|
||||
const previousConfigDir = getClaudeConfigHomeDirOverrideForTesting()
|
||||
setClaudeConfigHomeDirForTesting(root)
|
||||
resetSettingsCache()
|
||||
try {
|
||||
await run(settingsPath)
|
||||
} finally {
|
||||
clearSessionContextWindowOverride(SAVE_MODEL)
|
||||
setClaudeConfigHomeDirForTesting(previousConfigDir)
|
||||
resetSettingsCache()
|
||||
restoreEnv()
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
const saveContext = { options: { mainLoopModel: SAVE_MODEL } } as never
|
||||
|
||||
async function runSetContextWindow(args: string): Promise<string> {
|
||||
const result = await setContextWindow(args, saveContext)
|
||||
if (result.type !== 'text') throw new Error(`unexpected result type ${result.type}`)
|
||||
return result.value
|
||||
}
|
||||
|
||||
test('/set-context-window --save persists the active model window for the next session', async () => {
|
||||
await withCustomProviderSettings(
|
||||
JSON.stringify({
|
||||
env: { KEEP_ME: '1' },
|
||||
modelLimits: { [SAVE_MODEL]: { maxOutputTokens: 32_768 } },
|
||||
}),
|
||||
async settingsPath => {
|
||||
expect(await runSetContextWindow('500000')).toContain('(session only)')
|
||||
expect(JSON.parse(readFileSync(settingsPath, 'utf8')).modelLimits).toEqual({
|
||||
[SAVE_MODEL]: { maxOutputTokens: 32_768 },
|
||||
})
|
||||
|
||||
const output = await runSetContextWindow('1000000 --save')
|
||||
expect(output).toContain(`Saved to modelLimits["${SAVE_MODEL}"] in ${settingsPath}.`)
|
||||
expect(output).not.toContain('After a restart')
|
||||
expect(JSON.parse(readFileSync(settingsPath, 'utf8'))).toEqual({
|
||||
env: { KEEP_ME: '1' },
|
||||
modelLimits: {
|
||||
[SAVE_MODEL]: { maxOutputTokens: 32_768, contextWindow: 1_000_000 },
|
||||
},
|
||||
})
|
||||
|
||||
// A new session starts without session overrides and re-reads settings.
|
||||
clearSessionContextWindowOverride()
|
||||
resetSettingsCache()
|
||||
expect(resolveContextWindowForModel(SAVE_MODEL)).toEqual({
|
||||
contextWindow: 1_000_000,
|
||||
source: 'model-limits',
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('/set-context-window --save reports when a higher-precedence source will shadow the saved value', async () => {
|
||||
await withCustomProviderSettings(undefined, async settingsPath => {
|
||||
process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS = JSON.stringify({ [SAVE_MODEL]: 200_000 })
|
||||
|
||||
const output = await runSetContextWindow(`${SAVE_MODEL} 1000000 --save`)
|
||||
|
||||
expect(output).toContain(`Saved to modelLimits["${SAVE_MODEL}"] in ${settingsPath}.`)
|
||||
expect(output).toContain(
|
||||
'After a restart, CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS (exact match) will set 200,000 tokens instead of the saved value.',
|
||||
)
|
||||
expect(JSON.parse(readFileSync(settingsPath, 'utf8')).modelLimits).toEqual({
|
||||
[SAVE_MODEL]: { contextWindow: 1_000_000 },
|
||||
})
|
||||
expect(getContextWindowForModel(SAVE_MODEL)).toBe(1_000_000)
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
['the active model', '9007199254740992 --save'],
|
||||
['an explicit model', `${SAVE_MODEL} 9007199254740992 --save`],
|
||||
])('/set-context-window --save does not write a window the settings schema rejects (%s)', async (_form, args) => {
|
||||
const initialSettings = JSON.stringify({
|
||||
env: { KEEP_ME: '1' },
|
||||
modelLimits: { [SAVE_MODEL]: { maxOutputTokens: 32_768 } },
|
||||
})
|
||||
await withCustomProviderSettings(initialSettings, async settingsPath => {
|
||||
const output = await runSetContextWindow(args)
|
||||
|
||||
expect(output).toContain(
|
||||
`Not saved: modelLimits.${SAVE_MODEL}.contextWindow: Too big: expected int to be <=9007199254740991`,
|
||||
)
|
||||
expect(output).toContain('The override applies to this session only.')
|
||||
expect(output).not.toContain('Saved to modelLimits')
|
||||
expect(readFileSync(settingsPath, 'utf8')).toBe(initialSettings)
|
||||
expect(getContextWindowForModel(SAVE_MODEL)).toBe(9_007_199_254_740_992)
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
['the active model', '1000000 --save'],
|
||||
['an explicit model', '__proto__ 1000000 --save'],
|
||||
])('/set-context-window --save does not report a save for a model name settings cannot store (%s)', async (_form, args) => {
|
||||
const initialSettings = JSON.stringify({ env: { KEEP_ME: '1' } })
|
||||
await withCustomProviderSettings(initialSettings, async settingsPath => {
|
||||
try {
|
||||
const result = await setContextWindow(args, {
|
||||
options: { mainLoopModel: '__proto__' },
|
||||
} as never)
|
||||
|
||||
expect(result).toMatchObject({ type: 'text' })
|
||||
const output = result.type === 'text' ? result.value : ''
|
||||
expect(output).toContain(
|
||||
'Not saved: modelLimits["__proto__"]: settings cannot store this model name',
|
||||
)
|
||||
expect(output).toContain('The override applies to this session only.')
|
||||
expect(output).not.toContain('Saved to modelLimits')
|
||||
expect(readFileSync(settingsPath, 'utf8')).toBe(initialSettings)
|
||||
expect(getContextWindowForModel('__proto__')).toBe(1_000_000)
|
||||
} finally {
|
||||
clearSessionContextWindowOverride('__proto__')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('/set-context-window --save reports when other invalid settings stop the saved file from loading', async () => {
|
||||
await withCustomProviderSettings(
|
||||
JSON.stringify({
|
||||
env: { KEEP_ME: '1' },
|
||||
modelLimits: { [SAVE_MODEL]: { maxOutputTokens: -1 } },
|
||||
}),
|
||||
async settingsPath => {
|
||||
const output = await runSetContextWindow('1000000 --save')
|
||||
|
||||
expect(output).toContain(`Saved to modelLimits["${SAVE_MODEL}"] in ${settingsPath}.`)
|
||||
expect(output).toContain(
|
||||
`After a restart, the saved value will not apply: ${settingsPath} has invalid settings, so the whole file is ignored until they are fixed.\n modelLimits.${SAVE_MODEL}.maxOutputTokens: Number must be greater than or equal to 0`,
|
||||
)
|
||||
expect(output).not.toContain('instead of the saved value')
|
||||
expect(JSON.parse(readFileSync(settingsPath, 'utf8'))).toEqual({
|
||||
env: { KEEP_ME: '1' },
|
||||
modelLimits: {
|
||||
[SAVE_MODEL]: { maxOutputTokens: -1, contextWindow: 1_000_000 },
|
||||
},
|
||||
})
|
||||
expect(getContextWindowForModel(SAVE_MODEL)).toBe(1_000_000)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('/set-context-window --save reports a plain save when it replaces an invalid saved window', async () => {
|
||||
await withCustomProviderSettings(
|
||||
JSON.stringify({ modelLimits: { [SAVE_MODEL]: { contextWindow: -5 } } }),
|
||||
async settingsPath => {
|
||||
const output = await runSetContextWindow('1000000 --save')
|
||||
|
||||
expect(output).toContain(`Saved to modelLimits["${SAVE_MODEL}"] in ${settingsPath}.`)
|
||||
expect(output).not.toContain('After a restart')
|
||||
clearSessionContextWindowOverride()
|
||||
resetSettingsCache()
|
||||
expect(resolveContextWindowForModel(SAVE_MODEL)).toEqual({
|
||||
contextWindow: 1_000_000,
|
||||
source: 'model-limits',
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test.each([
|
||||
['the active model', '1000000 --save'],
|
||||
['an explicit model', `${SAVE_MODEL} 1000000 --save`],
|
||||
])('/set-context-window --save reports env settings that a repair makes loadable (%s)', async (_form, args) => {
|
||||
const contextWindows = JSON.stringify({ [SAVE_MODEL]: 200_000 })
|
||||
await withCustomProviderSettings(
|
||||
JSON.stringify({
|
||||
env: { CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS: contextWindows },
|
||||
modelLimits: { [SAVE_MODEL]: { contextWindow: -5 } },
|
||||
}),
|
||||
async settingsPath => {
|
||||
// Startup ignored the invalid file, so its env was never applied.
|
||||
expect(process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS).toBeUndefined()
|
||||
|
||||
const output = await runSetContextWindow(args)
|
||||
|
||||
expect(output).toContain(`Saved to modelLimits["${SAVE_MODEL}"] in ${settingsPath}.`)
|
||||
expect(output).toContain(
|
||||
`${settingsPath} had invalid settings before this save, so this session did not load its env settings (CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS). They can apply after a restart and change which window is used; this report does not account for them.`,
|
||||
)
|
||||
expect(JSON.parse(readFileSync(settingsPath, 'utf8'))).toEqual({
|
||||
env: { CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS: contextWindows },
|
||||
modelLimits: { [SAVE_MODEL]: { contextWindow: 1_000_000 } },
|
||||
})
|
||||
expect(process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS).toBeUndefined()
|
||||
expect(getContextWindowForModel(SAVE_MODEL)).toBe(1_000_000)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test.each([
|
||||
['modelLimits', 'the active model', '1000000 --save', { modelLimits: [] }],
|
||||
['modelLimits', 'an explicit model', `${SAVE_MODEL} 1000000 --save`, { modelLimits: [] }],
|
||||
[`modelLimits["${SAVE_MODEL}"]`, 'the active model', '1000000 --save', { modelLimits: { [SAVE_MODEL]: [] } }],
|
||||
[`modelLimits["${SAVE_MODEL}"]`, 'an explicit model', `${SAVE_MODEL} 1000000 --save`, { modelLimits: { [SAVE_MODEL]: [] } }],
|
||||
])('/set-context-window --save does not report a save when %s is an array (%s)', async (container, _form, args, initial) => {
|
||||
const initialSettings = { env: { KEEP_ME: '1' }, ...initial }
|
||||
await withCustomProviderSettings(JSON.stringify(initialSettings), async settingsPath => {
|
||||
const output = await runSetContextWindow(args)
|
||||
|
||||
expect(output).toContain(`Not saved: ${container} in ${settingsPath} is an array`)
|
||||
expect(output).toContain('The override applies to this session only.')
|
||||
expect(output).not.toContain('Saved to modelLimits')
|
||||
expect(JSON.parse(readFileSync(settingsPath, 'utf8'))).toEqual(initialSettings)
|
||||
expect(getContextWindowForModel(SAVE_MODEL)).toBe(1_000_000)
|
||||
})
|
||||
})
|
||||
|
||||
test('/set-context-window --save keeps the session override when saving fails', async () => {
|
||||
await withCustomProviderSettings('{ not json', async settingsPath => {
|
||||
const output = await runSetContextWindow('1000000 --save')
|
||||
|
||||
expect(output).toContain(`Not saved: Invalid JSON syntax in settings file at ${settingsPath}`)
|
||||
expect(output).toContain('The override applies to this session only.')
|
||||
expect(readFileSync(settingsPath, 'utf8')).toBe('{ not json')
|
||||
expect(getContextWindowForModel(SAVE_MODEL)).toBe(1_000_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,45 @@
|
||||
import { getSdkBetas } from '../../bootstrap/state.js'
|
||||
import { getBaseModelApiName } from '../../integrations/runtimeMetadata.js'
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
import {
|
||||
CONTEXT_WINDOW_SOURCE_LABELS,
|
||||
resolveContextWindowForModel,
|
||||
setSessionContextWindowOverride,
|
||||
getSessionContextWindowOverride,
|
||||
getSessionContextWindowOverrides,
|
||||
} from '../../utils/context.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import { readFileSync } from '../../utils/fileRead.js'
|
||||
import { safeParseJSON } from '../../utils/json.js'
|
||||
import {
|
||||
getSettingsFilePathForSource,
|
||||
parseSettingsFile,
|
||||
updateSettingsForSource,
|
||||
} from '../../utils/settings/settings.js'
|
||||
import { SettingsSchema } from '../../utils/settings/types.js'
|
||||
import { formatZodError } from '../../utils/settings/validation.js'
|
||||
|
||||
const HELP = `Usage: /set-context-window [model] <tokens>
|
||||
const HELP = `Usage: /set-context-window [model] <tokens> [--save]
|
||||
|
||||
Set a session-scoped context window override. Defaults to the active model.
|
||||
|
||||
Examples:
|
||||
/set-context-window 256000
|
||||
/set-context-window gpt-4o 200000
|
||||
/set-context-window my-model 1000000 --save
|
||||
/set-context-window status
|
||||
|
||||
The override takes precedence over discovery, catalog, and provider defaults
|
||||
for this session only. Use /clear-context-window to remove it.
|
||||
|
||||
Add --save to also write the value to modelLimits in your user settings.json
|
||||
so it survives restarts. Other settings, including the model's
|
||||
maxOutputTokens, are kept. After a restart the saved value follows the
|
||||
modelLimits precedence below, and --save reports when another source will
|
||||
take precedence over it, or when other invalid settings in the file stop it
|
||||
from loading. If the save fixes a file this session could not load, it also
|
||||
names the env settings in that file, which this check cannot account for.
|
||||
|
||||
For a permanent override (survives restart), use modelLimits in settings.json
|
||||
for custom/discovered models, for example:
|
||||
{ "modelLimits": { "my-model": { "contextWindow": 1000000 } } }
|
||||
@@ -24,6 +47,127 @@ 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).`
|
||||
|
||||
function ownProperty(value: unknown, key: string): unknown {
|
||||
return value !== null && typeof value === 'object' && Object.hasOwn(value, key)
|
||||
? (value as Record<string, unknown>)[key]
|
||||
: undefined
|
||||
}
|
||||
|
||||
// The writer reports success once it has written the file, not once the value
|
||||
// is in it. For an invalid file it merges into the raw JSON, where an array in
|
||||
// place of modelLimits or the model's entry keeps the new field only as a named
|
||||
// array property, which JSON drops. Read the file back before reporting a save.
|
||||
function checkContextWindowWritten(
|
||||
settingsPath: string | undefined,
|
||||
key: string,
|
||||
tokens: number,
|
||||
): Error | null {
|
||||
if (!settingsPath) {
|
||||
return new Error('there is no user settings file to write to')
|
||||
}
|
||||
let data: unknown
|
||||
try {
|
||||
data = safeParseJSON(readFileSync(settingsPath), false)
|
||||
} catch (e) {
|
||||
return new Error(`could not read ${settingsPath} back: ${errorMessage(e)}`)
|
||||
}
|
||||
const modelLimits = ownProperty(data, 'modelLimits')
|
||||
if (Array.isArray(modelLimits)) {
|
||||
return new Error(
|
||||
`modelLimits in ${settingsPath} is an array, so it cannot hold model entries`,
|
||||
)
|
||||
}
|
||||
const entry = ownProperty(modelLimits, key)
|
||||
if (Array.isArray(entry)) {
|
||||
return new Error(
|
||||
`modelLimits["${key}"] in ${settingsPath} is an array, so it cannot hold contextWindow`,
|
||||
)
|
||||
}
|
||||
if (ownProperty(entry, 'contextWindow') !== tokens) {
|
||||
return new Error(
|
||||
`modelLimits["${key}"].contextWindow was not written to ${settingsPath}`,
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function saveContextWindow(model: string, tokens: number): string {
|
||||
const settingsPath = getSettingsFilePathForSource('userSettings')
|
||||
const key = getBaseModelApiName(model) ?? model
|
||||
const patch = { modelLimits: { [key]: { contextWindow: tokens } } }
|
||||
// Startup ignores an invalid file, including its env settings, and the
|
||||
// settings watcher skips this command's own write. Keep the state from
|
||||
// before the write so a repair that makes the env load can be reported.
|
||||
const before = settingsPath ? parseSettingsFile(settingsPath) : null
|
||||
// The session override accepts values the settings schema rejects (such as
|
||||
// integers above Number.MAX_SAFE_INTEGER). Writing one would stop the whole
|
||||
// user settings file from loading, so check the entry before writing it.
|
||||
const validation = SettingsSchema().safeParse(patch)
|
||||
let error: Error | null
|
||||
if (!validation.success) {
|
||||
error = new Error(
|
||||
formatZodError(validation.error, settingsPath ?? 'settings')
|
||||
.map(issue => `${issue.path}: ${issue.message}`)
|
||||
.join('; '),
|
||||
)
|
||||
} else if (!Object.hasOwn(validation.data.modelLimits ?? {}, key)) {
|
||||
// Parsing also drops keys a settings object cannot hold, such as
|
||||
// "__proto__", without an error. The writer and the loader drop the entry
|
||||
// the same way, so it would never be saved or loaded.
|
||||
error = new Error(
|
||||
`modelLimits["${key}"]: settings cannot store this model name`,
|
||||
)
|
||||
} else {
|
||||
error =
|
||||
updateSettingsForSource('userSettings', patch).error ??
|
||||
checkContextWindowWritten(settingsPath, key, tokens)
|
||||
}
|
||||
if (error) {
|
||||
return `Not saved: ${error.message}\nThe override applies to this session only.`
|
||||
}
|
||||
|
||||
const saved = `Saved to modelLimits["${key}"] in ${settingsPath}.`
|
||||
// The writer keeps invalid fields already in the file, and the loader then
|
||||
// ignores the whole file. Report that rather than naming whichever source
|
||||
// wins without it as the one taking precedence.
|
||||
const written = settingsPath ? parseSettingsFile(settingsPath) : null
|
||||
if (written?.settings === null) {
|
||||
const details = written.errors
|
||||
.map(issue => `\n ${issue.path}: ${issue.message}`)
|
||||
.join('')
|
||||
return `${saved}\nAfter a restart, the saved value will not apply: ${settingsPath} has invalid settings, so the whole file is ignored until they are fixed.${details}`
|
||||
}
|
||||
// The restart check below reads this session's environment, which lacks
|
||||
// env settings from a file that only became valid with this save.
|
||||
const unloadedEnvKeys =
|
||||
before?.settings === null
|
||||
? Object.entries(written?.settings?.env ?? {})
|
||||
.filter(([name, value]) => process.env[name] !== value)
|
||||
.map(([name]) => name)
|
||||
: []
|
||||
const unloadedEnvNote =
|
||||
unloadedEnvKeys.length > 0
|
||||
? `\n${settingsPath} had invalid settings before this save, so this session did not load its env settings (${unloadedEnvKeys.join(', ')}). They can apply after a restart and change which window is used; this report does not account for them.`
|
||||
: ''
|
||||
const afterRestart = resolveContextWindowForModel(
|
||||
model,
|
||||
getSdkBetas(),
|
||||
undefined,
|
||||
{ ignoreSessionOverride: true },
|
||||
)
|
||||
if (
|
||||
afterRestart.source === 'model-limits' &&
|
||||
afterRestart.contextWindow === tokens
|
||||
) {
|
||||
return `${saved}${unloadedEnvNote}`
|
||||
}
|
||||
const shadowingSource =
|
||||
afterRestart.source === 'model-limits'
|
||||
? 'a higher-priority modelLimits entry'
|
||||
: CONTEXT_WINDOW_SOURCE_LABELS[afterRestart.source]
|
||||
return `${saved}\nAfter a restart, ${shadowingSource} will set ${afterRestart.contextWindow.toLocaleString()} tokens instead of the saved value.${unloadedEnvNote}`
|
||||
}
|
||||
|
||||
export const call: LocalCommandCall = async (args, context) => {
|
||||
const trimmed = args.trim()
|
||||
|
||||
@@ -42,7 +186,9 @@ export const call: LocalCommandCall = async (args, context) => {
|
||||
return { type: 'text', value: `Active context window overrides:\n${lines}` }
|
||||
}
|
||||
|
||||
const parts = trimmed.split(/\s+/)
|
||||
const allParts = trimmed.split(/\s+/)
|
||||
const save = allParts.includes('--save')
|
||||
const parts = allParts.filter(part => part !== '--save')
|
||||
|
||||
let model: string
|
||||
let tokensStr: string
|
||||
@@ -72,8 +218,11 @@ export const call: LocalCommandCall = async (args, context) => {
|
||||
const prevDisplay = previous !== undefined
|
||||
? `${previous.toLocaleString()} tokens`
|
||||
: 'none (using default)'
|
||||
const summary = save
|
||||
? `Context window for "${result.normalizedModel}" set to ${tokens.toLocaleString()} tokens for this session.\n${saveContextWindow(model, tokens)}`
|
||||
: `Context window for "${result.normalizedModel}" set to ${tokens.toLocaleString()} tokens (session only).`
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Context window for "${result.normalizedModel}" set to ${tokens.toLocaleString()} tokens (session only).\nPrevious: ${prevDisplay}`,
|
||||
value: `${summary}\nPrevious: ${prevDisplay}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ function normalizeModelApiName(
|
||||
return baseModel ? baseModel.toLowerCase() : null
|
||||
}
|
||||
|
||||
function getBaseModelApiName(value: string | undefined): string | null {
|
||||
export function getBaseModelApiName(value: string | undefined): string | null {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
|
||||
+24
-2
@@ -236,6 +236,24 @@ export type ContextWindowSource =
|
||||
| 'internal-model-config'
|
||||
| 'default'
|
||||
|
||||
export const CONTEXT_WINDOW_SOURCE_LABELS: Record<ContextWindowSource, string> = {
|
||||
'internal-env-override': 'CLAUDE_CODE_MAX_CONTEXT_TOKENS',
|
||||
'session-override': 'session override (/set-context-window)',
|
||||
'1m-suffix': '[1m] model suffix',
|
||||
'env-exact': 'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS (exact match)',
|
||||
'route-catalog': 'provider route catalog',
|
||||
'env-prefix': 'CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS (prefix match)',
|
||||
'model-limits': 'modelLimits in settings',
|
||||
discovery: 'provider model discovery',
|
||||
'model-descriptor': 'built-in model metadata',
|
||||
'runtime-limits': 'provider runtime limits',
|
||||
fallback: 'fallback for unknown model',
|
||||
'1m-beta': '1M context beta',
|
||||
'1m-experiment': '1M context experiment',
|
||||
'internal-model-config': 'internal model config',
|
||||
default: 'default',
|
||||
}
|
||||
|
||||
export function getContextWindowForModel(
|
||||
model: string,
|
||||
betas?: string[],
|
||||
@@ -247,12 +265,14 @@ export function getContextWindowForModel(
|
||||
/**
|
||||
* Resolve the context window together with the precedence step that supplied
|
||||
* it, so diagnostics can report the real source instead of inferring it from
|
||||
* the value.
|
||||
* the value. `ignoreSessionOverride` skips the session override; the result
|
||||
* still uses this process's environment and settings.
|
||||
*/
|
||||
export function resolveContextWindowForModel(
|
||||
model: string,
|
||||
betas?: string[],
|
||||
runtimeLimits?: { contextWindow?: number },
|
||||
options?: { ignoreSessionOverride?: boolean },
|
||||
): { contextWindow: number; source: ContextWindowSource } {
|
||||
// Allow override via environment variable (internal-only)
|
||||
// This takes precedence over all other context window resolution, including 1M detection,
|
||||
@@ -270,7 +290,9 @@ export function resolveContextWindowForModel(
|
||||
|
||||
// Session-scoped override — set via /set_context_window command.
|
||||
// Takes precedence over all resolution except the internal env var above.
|
||||
const sessionOverride = getSessionContextWindowOverride(model)
|
||||
const sessionOverride = options?.ignoreSessionOverride
|
||||
? undefined
|
||||
: getSessionContextWindowOverride(model)
|
||||
if (sessionOverride !== undefined) {
|
||||
return { contextWindow: sessionOverride, source: 'session-override' }
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export const commands: SlashCommand[] = [
|
||||
{ name: 'model', description: 'Set the AI model for the session', category: 'models', args: '[model]' },
|
||||
{ name: 'provider', description: 'Manage API provider profiles', category: 'models' },
|
||||
{ name: 'effort', description: 'Set effort level for model usage', category: 'models', args: '[low|medium|high|xhigh|max|ultracode|auto]' },
|
||||
{ name: 'set-context-window', description: 'Set a session-scoped context window override for a model', category: 'models', args: '[model] <tokens>' },
|
||||
{ name: 'set-context-window', description: 'Set a session-scoped context window override for a model', category: 'models', args: '[model] <tokens> [--save]' },
|
||||
{ name: 'clear-context-window', description: 'Clear session-scoped context window overrides', category: 'models', args: '[model]' },
|
||||
{ name: 'smartroute', description: 'Configure smart auto-routing (experimental): route simple turns to your configured simple model', category: 'models', args: '[on|off|simple <key>|strong <key>]' },
|
||||
{ name: 'login', description: 'Sign in with your Anthropic account', category: 'models' },
|
||||
|
||||
Reference in New Issue
Block a user