diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index fcf0ed5e7..bdf3b5889 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -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 ` for the current session only. +- **Saving from the command** — `/set-context-window [model] --save` + applies the session override and also writes + `modelLimits[""].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). diff --git a/src/commands/ctx_viz/ctx-noninteractive.ts b/src/commands/ctx_viz/ctx-noninteractive.ts index d39a7e4e1..ed04de757 100644 --- a/src/commands/ctx_viz/ctx-noninteractive.ts +++ b/src/commands/ctx_viz/ctx-noninteractive.ts @@ -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> -const CONTEXT_WINDOW_SOURCE_LABELS: Record = { - '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, string> = { 'token-threshold': 'token threshold', 'message-count': 'message count', diff --git a/src/commands/set-context-window/index.ts b/src/commands/set-context-window/index.ts index a6763f4a9..f17799342 100644 --- a/src/commands/set-context-window/index.ts +++ b/src/commands/set-context-window/index.ts @@ -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] ', + argumentHint: '[model] [--save]', supportsNonInteractive: true, load: () => import('./set-context-window.js'), } satisfies Command diff --git a/src/commands/set-context-window/set-context-window.test.ts b/src/commands/set-context-window/set-context-window.test.ts index d12c5885f..235ee204e 100644 --- a/src/commands/set-context-window/set-context-window.test.ts +++ b/src/commands/set-context-window/set-context-window.test.ts @@ -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, +): Promise { + 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 { + 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) + }) +}) diff --git a/src/commands/set-context-window/set-context-window.ts b/src/commands/set-context-window/set-context-window.ts index 1cfcfa0af..c825593e7 100644 --- a/src/commands/set-context-window/set-context-window.ts +++ b/src/commands/set-context-window/set-context-window.ts @@ -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] +const HELP = `Usage: /set-context-window [model] [--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)[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}`, } } diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index 818d3015e..fdb7f3fa6 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -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 diff --git a/src/utils/context.ts b/src/utils/context.ts index 4633ce4ed..eb087206c 100644 --- a/src/utils/context.ts +++ b/src/utils/context.ts @@ -236,6 +236,24 @@ export type ContextWindowSource = | 'internal-model-config' | 'default' +export const CONTEXT_WINDOW_SOURCE_LABELS: Record = { + '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' } } diff --git a/web/src/data/commands.ts b/web/src/data/commands.ts index f1b68e973..220c4e989 100644 --- a/web/src/data/commands.ts +++ b/web/src/data/commands.ts @@ -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] ' }, + { name: 'set-context-window', description: 'Set a session-scoped context window override for a model', category: 'models', args: '[model] [--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 |strong ]' }, { name: 'login', description: 'Sign in with your Anthropic account', category: 'models' },