Skip to content
Open
42 changes: 27 additions & 15 deletions src/components/ProviderManager.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -961,13 +961,13 @@ test('ProviderManager clears hidden Hicap auth fields when editing', async () =>
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Edit provider profile') &&
frame.includes('Step 1 of 6'),
frame.includes('Step 1 of 8'),
)

for (let step = 2; step <= 6; step++) {
for (let step = 2; step <= 8; step++) {
mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes(`Step ${step} of 6`),
frame.includes(`Step ${step} of 8`),
)
}
mounted.stdin.write('\r')
Expand Down Expand Up @@ -1037,25 +1037,25 @@ test('ProviderManager skips advanced fields for legacy Kimi Code profiles', asyn
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Edit provider profile') &&
frame.includes('Provider name') &&
frame.includes('Step 1 of 4'),
frame.includes('Step 1 of 6'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Base URL') &&
frame.includes('Step 2 of 4'),
frame.includes('Step 2 of 6'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('Default model') &&
frame.includes('Step 3 of 4'),
frame.includes('Step 3 of 6'),
)

mounted.stdin.write('\r')
const output = await waitForFrameOutput(mounted.getOutput, frame =>
frame.includes('API key') &&
frame.includes('Step 4 of 4'),
frame.includes('Step 4 of 6'),
)

expect(output).not.toContain('API mode')
Expand Down Expand Up @@ -1798,49 +1798,61 @@ test('ProviderManager editing an active multi-model provider keeps app state on
mounted.getOutput,
frame =>
frame.includes('Edit provider profile') &&
frame.includes('Step 1 of 8'),
frame.includes('Step 1 of 10'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 2 of 10'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 3 of 10'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 2 of 8'),
frame => frame.includes('Step 4 of 10'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 3 of 8'),
frame => frame.includes('Step 5 of 10'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 4 of 8'),
frame => frame.includes('Step 6 of 10'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 5 of 8'),
frame => frame.includes('Step 7 of 10'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 6 of 8'),
frame => frame.includes('Step 8 of 10'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 7 of 8'),
frame => frame.includes('Step 9 of 10'),
)

mounted.stdin.write('\r')
await waitForFrameOutput(
mounted.getOutput,
frame => frame.includes('Step 8 of 8'),
frame => frame.includes('Step 10 of 10'),
)

mounted.stdin.write('\r')
Expand Down
48 changes: 47 additions & 1 deletion src/components/ProviderManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ type DraftField =
| 'authHeader'
| 'authHeaderValue'
| 'customHeaders'
| 'contextWindowSize'
| 'maxOutputTokens'

type ProviderDraft = Record<DraftField, string>

Expand Down Expand Up @@ -194,6 +196,20 @@ const FORM_STEPS: Array<{
helpText: 'Optional. Extra non-auth request headers for providers that support them.',
optional: true,
},
{
key: 'contextWindowSize',
label: 'Context window size',
placeholder: 'e.g. 128000',
helpText: 'Optional. Max tokens for context (input + output + history).',
optional: true,
},
{
key: 'maxOutputTokens',
label: 'Max output tokens',
placeholder: 'e.g. 4096',
helpText: 'Optional. Max tokens the model can generate in a single turn.',
optional: true,
},
]

const GITHUB_PROVIDER_ID = '__github_models__'
Expand All @@ -215,6 +231,8 @@ function toDraft(profile: ProviderProfile): ProviderDraft {
authHeader: profile.authHeader ?? '',
authHeaderValue: profile.authHeaderValue ?? '',
customHeaders: serializeProfileCustomHeaders(profile.customHeaders) ?? '',
contextWindowSize: profile.contextWindowSize ? String(profile.contextWindowSize) : '',
maxOutputTokens: profile.maxOutputTokens ? String(profile.maxOutputTokens) : '',
}
}

Expand Down Expand Up @@ -251,6 +269,8 @@ function presetToDraft(preset: ProviderPreset): ProviderDraft {
authHeader: '',
authHeaderValue: '',
customHeaders: '',
contextWindowSize: '',
maxOutputTokens: '',
}
}

Expand Down Expand Up @@ -282,7 +302,13 @@ function profileSummary(profile: ProviderProfile, isActive: boolean): string {
routeSupportsAuthHeaders(routeId) && profile.authHeader
? ` · ${profile.authHeader} auth`
: ''
return `${providerKind} · ${profile.baseUrl} · ${modelDisplay}${modeInfo}${authInfo} · ${keyInfo}${activeSuffix}`
const contextInfo = profile.contextWindowSize
? ` · ${Math.round(profile.contextWindowSize / 1000)}k ctx`
: ''
const tokensInfo = profile.maxOutputTokens
? ` · ${profile.maxOutputTokens} out`
: ''
return `${providerKind} · ${profile.baseUrl} · ${modelDisplay}${modeInfo}${authInfo}${contextInfo}${tokensInfo} · ${keyInfo}${activeSuffix}`
}

function getGithubCredentialSourceFromEnv(
Expand Down Expand Up @@ -1134,6 +1160,8 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
authHeader: '',
authHeaderValue: '',
customHeaders: '',
contextWindowSize: '',
maxOutputTokens: '',
}
setEditingProfileId(null)
setDraftProvider(provider)
Expand Down Expand Up @@ -1229,6 +1257,12 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
Object.keys(parsedCustomHeaders.headers).length > 0
? parsedCustomHeaders.headers
: undefined,
contextWindowSize: nextDraft.contextWindowSize
? parseInt(nextDraft.contextWindowSize, 10)
: undefined,
maxOutputTokens: nextDraft.maxOutputTokens
? parseInt(nextDraft.maxOutputTokens, 10)
: undefined,
}

const saved = profileId
Expand Down Expand Up @@ -1469,6 +1503,18 @@ export function ProviderManager({ mode, onDone }: Props): React.ReactNode {
return
}

if (
(currentStepKey === 'contextWindowSize' ||
currentStepKey === 'maxOutputTokens') &&
trimmed.length > 0
) {
const parsed = parseInt(trimmed, 10)
if (isNaN(parsed) || parsed <= 0) {
setErrorMessage(`${currentStep.label} must be a positive number.`)
return
}
}

const nextDraft = {
...draft,
[currentStepKey]: trimmed,
Expand Down
6 changes: 5 additions & 1 deletion src/screens/Doctor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { join } from 'path';
import React, { Suspense, use, useCallback, useEffect, useMemo, useState } from 'react';
import { KeybindingWarnings } from 'src/components/KeybindingWarnings.js';
import { McpParsingWarnings } from 'src/components/mcp/McpParsingWarnings.js';
import { getModelMaxOutputTokens } from 'src/utils/context.js';
import { getModelMaxOutputTokens, getContextWindowForModel } from 'src/utils/context.js';
import { getClaudeConfigHomeDir } from 'src/utils/envUtils.js';
import type { SettingSource } from 'src/utils/settings/constants.js';
import { getOriginalCwd } from '../bootstrap/state.js';
Expand Down Expand Up @@ -152,6 +152,10 @@ export function Doctor(t0) {
}, {
name: "CLAUDE_CODE_MAX_OUTPUT_TOKENS",
...getModelMaxOutputTokens("claude-opus-4-6")
}, {
name: "CLAUDE_CODE_MAX_CONTEXT_TOKENS",
default: getContextWindowForModel("claude-opus-4-6"),
upperLimit: 1_000_000
}];
t4 = envVars.map(_temp8).filter(_temp9);
$[5] = t4;
Expand Down
2 changes: 2 additions & 0 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ export type ProviderProfile = {
authScheme?: OpenAICompatibleAuthScheme
authHeaderValue?: string
customHeaders?: Record<string, string>
contextWindowSize?: number
maxOutputTokens?: number
}

export type GlobalConfig = {
Expand Down
30 changes: 20 additions & 10 deletions src/utils/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@ import {
} from '../integrations/routeMetadata.js'
import { getCanonicalName } from './model/model.js'
import { getModelCapability } from './model/modelCapabilities.js'
import { resolveAntModel } from './model/antModels.js'

import { validateBoundedIntEnvVar } from './envValidation.js'

// Model context window size (200k tokens for all models right now)
export const MODEL_CONTEXT_WINDOW_DEFAULT = 200_000
export const MODEL_CONTEXT_WINDOW_UPPER_LIMIT = 1_000_000

// Fallback context window for unknown 3P models. Must be large enough that
// the effective context (this minus output token reservation) stays positive,
Expand Down Expand Up @@ -80,20 +84,26 @@ export function getContextWindowForModel(
model: string,
betas?: string[],
): number {
// Allow override via environment variable (internal-only)
const contextWindowSize = getContextWindowForModelInternal(model, betas)

// Allow override via environment variable
// This takes precedence over all other context window resolution, including 1M detection,
// so users can cap the effective context window for local decisions (auto-compact, etc.)
// while still using a 1M-capable endpoint.
if (
process.env.USER_TYPE === 'ant' &&
process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS
) {
const override = parseInt(process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, 10)
if (!isNaN(override) && override > 0) {
return override
}
}
// Note: CLAUDE_CODE_MAX_CONTEXT_TOKENS now applies to all users.
const result = validateBoundedIntEnvVar(
'CLAUDE_CODE_MAX_CONTEXT_TOKENS',
process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS,
contextWindowSize,
MODEL_CONTEXT_WINDOW_UPPER_LIMIT,
)
return result.effective
}

function getContextWindowForModelInternal(
model: string,
betas?: string[],
): number {
// [1m] suffix — explicit client-side opt-in, respected over all detection
if (has1mContext(model)) {
return 1_000_000
Expand Down
21 changes: 21 additions & 0 deletions src/utils/providerProfile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1521,3 +1521,24 @@ test('atomic-chat launch ignores mismatched persisted openai env', async () => {
assert.equal(env.CODEX_API_KEY, undefined)
assert.equal(env.CHATGPT_ACCOUNT_ID, undefined)
})

test('buildStartupEnvFromProfile preserves persisted token limits', async () => {
const env = await buildStartupEnvFromProfile({
persisted: {
profile: 'openai',
env: {
OPENAI_BASE_URL: 'https://api.openai.com/v1',
OPENAI_MODEL: 'gpt-4o',
CLAUDE_CODE_MAX_CONTEXT_TOKENS: '50000',
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '4096',
},
createdAt: new Date().toISOString(),
},
goal: 'balanced',
processEnv: {},
})

assert.equal(env.OPENAI_MODEL, 'gpt-4o')
assert.equal(env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, '50000')
assert.equal(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS, '4096')
})
Loading