Skip to content

Commit 0002e97

Browse files
authored
Merge pull request #62 from SurviveM/feat/codex-context-budget-controls
feat: add codex context budget controls
2 parents f3541e0 + c287ceb commit 0002e97

7 files changed

Lines changed: 1289 additions & 29 deletions

File tree

cli.js

Lines changed: 157 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ const RECENT_CONFIGS_FILE = path.join(CONFIG_DIR, 'recent-configs.json');
9393
const WORKFLOW_DEFINITIONS_FILE = path.join(CONFIG_DIR, 'codexmate-workflows.json');
9494
const WORKFLOW_RUNS_FILE = path.join(CONFIG_DIR, 'codexmate-workflow-runs.jsonl');
9595
const DEFAULT_CLAUDE_MODEL = 'glm-4.7';
96+
const DEFAULT_MODEL_CONTEXT_WINDOW = 190000;
97+
const DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT = 185000;
9698
const CODEX_BACKUP_NAME = 'codex-config';
9799

98100
const DEFAULT_MODELS = ['gpt-5.3-codex', 'gpt-5.1-codex-max', 'gpt-4-turbo', 'gpt-4'];
@@ -226,6 +228,8 @@ function resolveWebHost(options = {}) {
226228

227229
const EMPTY_CONFIG_FALLBACK_TEMPLATE = `model = "gpt-5.3-codex"
228230
model_reasoning_effort = "high"
231+
model_context_window = ${DEFAULT_MODEL_CONTEXT_WINDOW}
232+
model_auto_compact_token_limit = ${DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT}
229233
disable_response_storage = true
230234
approval_policy = "never"
231235
sandbox_mode = "danger-full-access"
@@ -3168,6 +3172,8 @@ function buildDefaultConfigContent(initializedAt) {
31683172
31693173
model_provider = "openai"
31703174
model = "${defaultModel}"
3175+
model_context_window = ${DEFAULT_MODEL_CONTEXT_WINDOW}
3176+
model_auto_compact_token_limit = ${DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT}
31713177
31723178
[model_providers.openai]
31733179
name = "openai"
@@ -3333,6 +3339,45 @@ function applyReasoningEffortToTemplate(template, reasoningEffort) {
33333339
return content;
33343340
}
33353341

3342+
function normalizePositiveIntegerParam(value) {
3343+
if (value === undefined || value === null) {
3344+
return null;
3345+
}
3346+
const text = typeof value === 'number'
3347+
? String(value)
3348+
: (typeof value === 'string' ? value.trim() : String(value).trim());
3349+
if (!text) {
3350+
return null;
3351+
}
3352+
if (!/^\d+$/.test(text)) {
3353+
return null;
3354+
}
3355+
const parsed = Number.parseInt(text, 10);
3356+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
3357+
return null;
3358+
}
3359+
return parsed;
3360+
}
3361+
3362+
function applyPositiveIntegerConfigToTemplate(template, key, value) {
3363+
let content = typeof template === 'string' ? template : '';
3364+
const normalized = normalizePositiveIntegerParam(value);
3365+
if (!key || normalized === null) {
3366+
return content;
3367+
}
3368+
3369+
const hasBom = content.charCodeAt(0) === 0xFEFF;
3370+
const lineEnding = content.includes('\r\n') ? '\r\n' : '\n';
3371+
if (hasBom) {
3372+
content = content.slice(1);
3373+
}
3374+
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3375+
const pattern = new RegExp(`^\\s*${escapedKey}\\s*=\\s*[^\\n]*\\n?`, 'gmi');
3376+
content = content.replace(pattern, '');
3377+
content = content.replace(new RegExp(`^(?:[\\t ]*${lineEnding})+`), '');
3378+
return `${hasBom ? '\uFEFF' : ''}${key} = ${normalized}${lineEnding}${content}`;
3379+
}
3380+
33363381
function getConfigTemplate(params = {}) {
33373382
let content = EMPTY_CONFIG_FALLBACK_TEMPLATE;
33383383
if (fs.existsSync(CONFIG_FILE)) {
@@ -3343,6 +3388,20 @@ function getConfigTemplate(params = {}) {
33433388
}
33443389
} catch (e) {}
33453390
}
3391+
if (
3392+
params.modelAutoCompactTokenLimit !== undefined
3393+
&& params.modelAutoCompactTokenLimit !== null
3394+
&& normalizePositiveIntegerParam(params.modelAutoCompactTokenLimit) === null
3395+
) {
3396+
return { error: 'modelAutoCompactTokenLimit must be a positive integer' };
3397+
}
3398+
if (
3399+
params.modelContextWindow !== undefined
3400+
&& params.modelContextWindow !== null
3401+
&& normalizePositiveIntegerParam(params.modelContextWindow) === null
3402+
) {
3403+
return { error: 'modelContextWindow must be a positive integer' };
3404+
}
33463405
const selectedProvider = typeof params.provider === 'string' ? params.provider.trim() : '';
33473406
const selectedModel = typeof params.model === 'string' ? params.model.trim() : '';
33483407
let template = normalizeTopLevelConfigWithTemplate(content, selectedProvider, selectedModel);
@@ -3352,11 +3411,54 @@ function getConfigTemplate(params = {}) {
33523411
if (typeof params.reasoningEffort === 'string') {
33533412
template = applyReasoningEffortToTemplate(template, params.reasoningEffort);
33543413
}
3414+
if (!/^\s*model_auto_compact_token_limit\s*=.*$/m.test(template)) {
3415+
template = applyPositiveIntegerConfigToTemplate(
3416+
template,
3417+
'model_auto_compact_token_limit',
3418+
DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT
3419+
);
3420+
}
3421+
if (!/^\s*model_context_window\s*=.*$/m.test(template)) {
3422+
template = applyPositiveIntegerConfigToTemplate(
3423+
template,
3424+
'model_context_window',
3425+
DEFAULT_MODEL_CONTEXT_WINDOW
3426+
);
3427+
}
3428+
if (params.modelAutoCompactTokenLimit !== undefined) {
3429+
template = applyPositiveIntegerConfigToTemplate(
3430+
template,
3431+
'model_auto_compact_token_limit',
3432+
params.modelAutoCompactTokenLimit
3433+
);
3434+
}
3435+
if (params.modelContextWindow !== undefined) {
3436+
template = applyPositiveIntegerConfigToTemplate(
3437+
template,
3438+
'model_context_window',
3439+
params.modelContextWindow
3440+
);
3441+
}
33553442
return {
33563443
template
33573444
};
33583445
}
33593446

3447+
function readPositiveIntegerConfigValue(config, key) {
3448+
const options = arguments[2] && typeof arguments[2] === 'object' ? arguments[2] : {};
3449+
const useDefaultsWhenMissing = options.useDefaultsWhenMissing !== false;
3450+
if (!config || typeof config !== 'object' || !key) {
3451+
return '';
3452+
}
3453+
const raw = config[key];
3454+
if (raw === undefined && useDefaultsWhenMissing) {
3455+
if (key === 'model_context_window') return DEFAULT_MODEL_CONTEXT_WINDOW;
3456+
if (key === 'model_auto_compact_token_limit') return DEFAULT_MODEL_AUTO_COMPACT_TOKEN_LIMIT;
3457+
}
3458+
const normalized = normalizePositiveIntegerParam(raw);
3459+
return normalized === null ? '' : normalized;
3460+
}
3461+
33603462
function applyConfigTemplate(params = {}) {
33613463
const template = typeof params.template === 'string' ? params.template : '';
33623464
if (!template.trim()) {
@@ -3370,6 +3472,20 @@ function applyConfigTemplate(params = {}) {
33703472
return { error: `模板 TOML 解析失败: ${e.message}` };
33713473
}
33723474

3475+
if (
3476+
Object.prototype.hasOwnProperty.call(parsed, 'model_context_window')
3477+
&& normalizePositiveIntegerParam(parsed.model_context_window) === null
3478+
) {
3479+
return { error: '模板中的 model_context_window 必须是正整数' };
3480+
}
3481+
3482+
if (
3483+
Object.prototype.hasOwnProperty.call(parsed, 'model_auto_compact_token_limit')
3484+
&& normalizePositiveIntegerParam(parsed.model_auto_compact_token_limit) === null
3485+
) {
3486+
return { error: '模板中的 model_auto_compact_token_limit 必须是正整数' };
3487+
}
3488+
33733489
if (!parsed.model_provider || typeof parsed.model_provider !== 'string') {
33743490
return { error: '模板缺少 model_provider' };
33753491
}
@@ -9976,22 +10092,38 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
997610092
let result;
997710093

997810094
switch (action) {
9979-
case 'status':
10095+
case 'status': {
998010096
const statusConfigResult = readConfigOrVirtualDefault();
998110097
const config = statusConfigResult.config;
998210098
const serviceTier = typeof config.service_tier === 'string' ? config.service_tier.trim() : '';
998310099
const modelReasoningEffort = typeof config.model_reasoning_effort === 'string' ? config.model_reasoning_effort.trim() : '';
10100+
const budgetReadOptions = {
10101+
useDefaultsWhenMissing: !hasConfigLoadError(statusConfigResult)
10102+
};
10103+
const modelContextWindow = readPositiveIntegerConfigValue(
10104+
config,
10105+
'model_context_window',
10106+
budgetReadOptions
10107+
);
10108+
const modelAutoCompactTokenLimit = readPositiveIntegerConfigValue(
10109+
config,
10110+
'model_auto_compact_token_limit',
10111+
budgetReadOptions
10112+
);
998410113
result = {
998510114
provider: config.model_provider || '未设置',
998610115
model: config.model || '未设置',
998710116
serviceTier,
998810117
modelReasoningEffort,
10118+
modelContextWindow,
10119+
modelAutoCompactTokenLimit,
998910120
configReady: !statusConfigResult.isVirtual,
999010121
configErrorType: statusConfigResult.errorType || '',
999110122
configNotice: statusConfigResult.reason || '',
999210123
initNotice: consumeInitNotice()
999310124
};
999410125
break;
10126+
}
999510127
case 'install-status':
999610128
result = buildInstallStatusReport();
999710129
break;
@@ -11464,11 +11596,26 @@ function buildMcpStatusPayload() {
1146411596
const config = statusConfigResult.config;
1146511597
const serviceTier = typeof config.service_tier === 'string' ? config.service_tier.trim() : '';
1146611598
const modelReasoningEffort = typeof config.model_reasoning_effort === 'string' ? config.model_reasoning_effort.trim() : '';
11599+
const budgetReadOptions = {
11600+
useDefaultsWhenMissing: !hasConfigLoadError(statusConfigResult)
11601+
};
11602+
const modelContextWindow = readPositiveIntegerConfigValue(
11603+
config,
11604+
'model_context_window',
11605+
budgetReadOptions
11606+
);
11607+
const modelAutoCompactTokenLimit = readPositiveIntegerConfigValue(
11608+
config,
11609+
'model_auto_compact_token_limit',
11610+
budgetReadOptions
11611+
);
1146711612
return {
1146811613
provider: config.model_provider || '未设置',
1146911614
model: config.model || '未设置',
1147011615
serviceTier,
1147111616
modelReasoningEffort,
11617+
modelContextWindow,
11618+
modelAutoCompactTokenLimit,
1147211619
configReady: !statusConfigResult.isVirtual,
1147311620
configErrorType: statusConfigResult.errorType || '',
1147411621
configNotice: statusConfigResult.reason || '',
@@ -11566,6 +11713,8 @@ const BUILTIN_WORKFLOW_DEFINITIONS = Object.freeze({
1156611713
model: { type: 'string' },
1156711714
serviceTier: { type: 'string' },
1156811715
reasoningEffort: { type: 'string' },
11716+
modelContextWindow: { type: ['string', 'number'] },
11717+
modelAutoCompactTokenLimit: { type: ['string', 'number'] },
1156911718
apply: { type: 'boolean' }
1157011719
},
1157111720
required: ['provider'],
@@ -11580,7 +11729,9 @@ const BUILTIN_WORKFLOW_DEFINITIONS = Object.freeze({
1158011729
provider: '{{input.provider}}',
1158111730
model: '{{input.model}}',
1158211731
serviceTier: '{{input.serviceTier}}',
11583-
reasoningEffort: '{{input.reasoningEffort}}'
11732+
reasoningEffort: '{{input.reasoningEffort}}',
11733+
modelContextWindow: '{{input.modelContextWindow}}',
11734+
modelAutoCompactTokenLimit: '{{input.modelAutoCompactTokenLimit}}'
1158411735
}
1158511736
},
1158611737
{
@@ -12149,15 +12300,17 @@ function createMcpTools(options = {}) {
1214912300

1215012301
pushTool({
1215112302
name: 'codexmate.config.template.get',
12152-
description: 'Get Codex config template with optional provider/model/service tier/reasoning effort.',
12303+
description: 'Get Codex config template with optional provider/model/service tier/reasoning effort/context budget.',
1215312304
readOnly: true,
1215412305
inputSchema: {
1215512306
type: 'object',
1215612307
properties: {
1215712308
provider: { type: 'string' },
1215812309
model: { type: 'string' },
1215912310
serviceTier: { type: 'string' },
12160-
reasoningEffort: { type: 'string' }
12311+
reasoningEffort: { type: 'string' },
12312+
modelContextWindow: { type: ['string', 'number'] },
12313+
modelAutoCompactTokenLimit: { type: ['string', 'number'] }
1216112314
},
1216212315
additionalProperties: false
1216312316
},

tests/e2e/test-config.js

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ module.exports = async function testConfig(ctx) {
2222
assert(typeof apiStatus.configReady === 'boolean', 'api status configReady missing');
2323
assert('modelReasoningEffort' in apiStatus, 'api status modelReasoningEffort missing');
2424
assert('serviceTier' in apiStatus, 'api status serviceTier missing');
25+
assert('modelContextWindow' in apiStatus, 'api status modelContextWindow missing');
26+
assert('modelAutoCompactTokenLimit' in apiStatus, 'api status modelAutoCompactTokenLimit missing');
27+
assert(apiStatus.modelContextWindow === 190000, 'api status modelContextWindow mismatch');
28+
assert(apiStatus.modelAutoCompactTokenLimit === 185000, 'api status modelAutoCompactTokenLimit mismatch');
2529

2630
// ========== List API Tests ==========
2731
const apiList = await api('list');
@@ -83,26 +87,78 @@ module.exports = async function testConfig(ctx) {
8387
assert(typeof templateReasoningXhigh.template === 'string', 'get-config-template(reasoning xhigh) missing template');
8488
assert(/^\s*model_reasoning_effort\s*=\s*"xhigh"\s*$/m.test(templateReasoningXhigh.template), 'get-config-template(reasoning xhigh) missing model_reasoning_effort');
8589

90+
// ========== Get Config Template Tests - Context Budget ==========
91+
const templateContextBudget = await api('get-config-template', {
92+
provider: 'shadow',
93+
model: 'shadow-model',
94+
modelContextWindow: 200000,
95+
modelAutoCompactTokenLimit: 195000
96+
});
97+
assert(typeof templateContextBudget.template === 'string', 'get-config-template(context budget) missing template');
98+
assert(templateContextBudget.template.includes('model_provider = "shadow"'), 'get-config-template(context budget) missing provider override');
99+
assert(templateContextBudget.template.includes('model = "shadow-model"'), 'get-config-template(context budget) missing model override');
100+
assert(/^\s*model_context_window\s*=\s*200000\s*$/m.test(templateContextBudget.template), 'get-config-template(context budget) missing model_context_window');
101+
assert(/^\s*model_auto_compact_token_limit\s*=\s*195000\s*$/m.test(templateContextBudget.template), 'get-config-template(context budget) missing model_auto_compact_token_limit');
102+
86103
// ========== Get Config Template Tests - Combined ==========
87104
const templateCombined = await api('get-config-template', {
88105
provider: 'shadow',
89106
model: 'shadow-model',
90107
serviceTier: 'fast',
91-
reasoningEffort: 'high'
108+
reasoningEffort: 'high',
109+
modelContextWindow: 190000,
110+
modelAutoCompactTokenLimit: 185000
92111
});
93112
assert(typeof templateCombined.template === 'string', 'get-config-template(combined) missing template');
94113
assert(/^\s*service_tier\s*=\s*"fast"\s*$/m.test(templateCombined.template), 'get-config-template(combined) missing service_tier');
95114
assert(/^\s*model_reasoning_effort\s*=\s*"high"\s*$/m.test(templateCombined.template), 'get-config-template(combined) missing model_reasoning_effort');
115+
assert(/^\s*model_context_window\s*=\s*190000\s*$/m.test(templateCombined.template), 'get-config-template(combined) missing model_context_window');
116+
assert(/^\s*model_auto_compact_token_limit\s*=\s*185000\s*$/m.test(templateCombined.template), 'get-config-template(combined) missing model_auto_compact_token_limit');
96117

97118
const templateCombinedXhigh = await api('get-config-template', {
98119
provider: 'shadow',
99120
model: 'shadow-model',
100121
serviceTier: 'fast',
101-
reasoningEffort: 'xhigh'
122+
reasoningEffort: 'xhigh',
123+
modelContextWindow: 210000,
124+
modelAutoCompactTokenLimit: 200000
102125
});
103126
assert(typeof templateCombinedXhigh.template === 'string', 'get-config-template(combined xhigh) missing template');
104127
assert(/^\s*service_tier\s*=\s*"fast"\s*$/m.test(templateCombinedXhigh.template), 'get-config-template(combined xhigh) missing service_tier');
105128
assert(/^\s*model_reasoning_effort\s*=\s*"xhigh"\s*$/m.test(templateCombinedXhigh.template), 'get-config-template(combined xhigh) missing model_reasoning_effort');
129+
assert(/^\s*model_context_window\s*=\s*210000\s*$/m.test(templateCombinedXhigh.template), 'get-config-template(combined xhigh) missing model_context_window');
130+
assert(/^\s*model_auto_compact_token_limit\s*=\s*200000\s*$/m.test(templateCombinedXhigh.template), 'get-config-template(combined xhigh) missing model_auto_compact_token_limit');
131+
132+
// ========== Apply Config Template Validation Tests ==========
133+
const invalidContextBudgetApply = await api('apply-config-template', {
134+
template: `model_provider = "shadow"
135+
model = "shadow-model"
136+
model_context_window = 0
137+
138+
[model_providers.shadow]
139+
base_url = "https://example.test/v1"
140+
preferred_auth_method = "shadow-key"
141+
`
142+
});
143+
assert(
144+
invalidContextBudgetApply.error === '模板中的 model_context_window 必须是正整数',
145+
'apply-config-template should reject invalid model_context_window'
146+
);
147+
148+
const invalidAutoCompactApply = await api('apply-config-template', {
149+
template: `model_provider = "shadow"
150+
model = "shadow-model"
151+
model_auto_compact_token_limit = "abc"
152+
153+
[model_providers.shadow]
154+
base_url = "https://example.test/v1"
155+
preferred_auth_method = "shadow-key"
156+
`
157+
});
158+
assert(
159+
invalidAutoCompactApply.error === '模板中的 model_auto_compact_token_limit 必须是正整数',
160+
'apply-config-template should reject invalid model_auto_compact_token_limit'
161+
);
106162

107163
// ========== Export Config Tests ==========
108164
const exportResult = await api('export-config', { includeKeys: true });
@@ -337,6 +393,21 @@ module.exports = async function testConfig(ctx) {
337393
await waitForServer(legacyPort);
338394

339395
const legacyApi = (action, params) => postJson(legacyPort, { action, params }, 2000);
396+
const legacyStatus = await legacyApi('status');
397+
assert(legacyStatus.modelContextWindow === 190000, 'legacy status should default modelContextWindow');
398+
assert(
399+
legacyStatus.modelAutoCompactTokenLimit === 185000,
400+
'legacy status should default modelAutoCompactTokenLimit'
401+
);
402+
const legacyTemplateDefaults = await legacyApi('get-config-template', {});
403+
assert(
404+
/^\s*model_context_window\s*=\s*190000\s*$/m.test(legacyTemplateDefaults.template),
405+
'legacy get-config-template should restore default model_context_window'
406+
);
407+
assert(
408+
/^\s*model_auto_compact_token_limit\s*=\s*185000\s*$/m.test(legacyTemplateDefaults.template),
409+
'legacy get-config-template should restore default model_auto_compact_token_limit'
410+
);
340411
const legacyAddDup = await legacyApi('add-provider', {
341412
name: 'foo.bar',
342413
url: 'https://dup.example.com/v1',

0 commit comments

Comments
 (0)