feat(codex): add synced model and reasoning defaults

This commit is contained in:
shenlvkang-collab
2026-10-07 17:34:25 +08:00
parent ac94f339ac
commit 16e44aa1d1
9 changed files with 182 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'aicodeman': minor
---
Add synced Codex default model and reasoning effort controls to App Settings. Apply defaults to new local and Docker sessions while preserving explicit launch settings, custom endpoints, remote commands, and Codex configuration when defaults are empty.
+8
View File
@@ -125,6 +125,14 @@ Integration detail: [`docs/opencode-integration.md`](https://github.com/Ark0N/Co
### Codex
App Settings has synced **Default Codex model** and **Default Codex reasoning effort**
controls. Enter a model ID supported by your Codex provider; available reasoning levels
depend on the model and CLI version. Empty defaults use Codex's own configuration.
The defaults apply when creating local or Docker sessions, including HTTP API launches.
Explicit `codexConfig.model` / `codexConfig.reasoningEffort` values take precedence.
Custom model endpoints and remote host command overrides keep their own settings.
Changing a default affects new sessions and does not edit Codex configuration files.
Two behaviours that are deliberate and worth knowing:
- **Predictive echo instead of buffered echo.** Codex's composer reacts to every keystroke,
+22
View File
@@ -0,0 +1,22 @@
import type { CodexConfig } from '../types.js';
import { SettingsUpdateSchema } from './schemas.js';
import { readJsonConfig, SETTINGS_PATH } from './route-helpers.js';
/** Resolve launch-only defaults without changing Codex's own configuration files. */
export async function resolveCodexLaunchDefaults(
config: CodexConfig | undefined,
customEndpoint = false
): Promise<CodexConfig | undefined> {
if (customEndpoint) return config;
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'Codex launch defaults', {});
const model = SettingsUpdateSchema.shape.codexModel.safeParse(settings.codexModel);
const effort = SettingsUpdateSchema.shape.codexReasoningEffort.safeParse(settings.codexReasoningEffort);
const defaultModel = model.success ? model.data || undefined : undefined;
const defaultEffort = effort.success ? effort.data || undefined : undefined;
if (!defaultModel && !defaultEffort) return config;
return {
...config,
model: config?.model ?? defaultModel,
reasoningEffort: config?.reasoningEffort ?? defaultEffort,
};
}
+5
View File
@@ -45,6 +45,11 @@
// Exact English-source translations. Technical names, command examples, model
// names, keyboard chords, and user-authored content intentionally stay unchanged.
const ZH_CN = Object.freeze({
'Default Codex model': 'Codex 默认模型',
'Default Codex reasoning effort': 'Codex 默认思考强度',
'Use Codex configuration': '使用 Codex 配置',
'Model ID for new Codex sessions. Leave empty to use Codex configuration.': '新 Codex 会话使用的模型 ID。留空时使用 Codex 配置。',
'Applies to new sessions; supported levels depend on the model and Codex version. Custom endpoints keep their own settings.': '应用于新会话;可用强度取决于模型和 Codex 版本。自定义端点保留自己的设置。',
'Skip to terminal': '跳转到终端',
'Go to main page': '返回主页',
'Session tabs': '会话标签页',
+24
View File
@@ -2527,6 +2527,30 @@
<div class="set-group" id="appSettingsCodexGroup">
<div class="set-group-head"><h4>Codex</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
<div class="set-row" data-search="codex default model">
<div class="set-row-text">
<span class="set-row-label">Default Codex model</span>
<span class="set-row-desc">Model ID for new Codex sessions. Leave empty to use Codex configuration.</span>
</div>
<input id="appSettingsCodexModel" class="set-input" type="text" maxlength="100" aria-label="Default Codex model" placeholder="Use Codex configuration" autocomplete="off" spellcheck="false">
</div>
<div class="set-row" data-search="codex default reasoning effort thinking">
<div class="set-row-text">
<span class="set-row-label">Default Codex reasoning effort</span>
<span class="set-row-desc">Applies to new sessions; supported levels depend on the model and Codex version. Custom endpoints keep their own settings.</span>
</div>
<select id="appSettingsCodexReasoningEffort" class="set-select" aria-label="Default Codex reasoning effort">
<option value="">Use Codex configuration</option>
<option value="none">none</option>
<option value="minimal">minimal</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="xhigh">xhigh</option>
<option value="max">max</option>
<option value="ultra">ultra</option>
</select>
</div>
<div class="set-row" data-search="codex bypass approvals sandbox">
<div class="set-row-text">
<span class="set-row-label">Bypass approvals and sandbox</span>
+4
View File
@@ -526,6 +526,8 @@ Object.assign(CodemanApp.prototype, {
settings.codexDangerouslyBypassApprovals ?? false;
document.getElementById('appSettingsCodexAnimations').checked =
settings.codexAnimationsEnabled ?? false;
document.getElementById('appSettingsCodexModel').value = settings.codexModel ?? '';
document.getElementById('appSettingsCodexReasoningEffort').value = settings.codexReasoningEffort ?? '';
this._applyCodexSettingsVisibility();
// Claude Permissions settings
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
@@ -2534,6 +2536,8 @@ Object.assign(CodemanApp.prototype, {
claudeMode: document.getElementById('appSettingsClaudeMode').value,
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
// Codex CLI settings
codexModel: document.getElementById('appSettingsCodexModel').value.trim(),
codexReasoningEffort: document.getElementById('appSettingsCodexReasoningEffort').value,
codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked,
codexAnimationsEnabled: document.getElementById('appSettingsCodexAnimations').checked,
// Claude Permissions settings
+11 -6
View File
@@ -115,6 +115,7 @@ import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
import type { NewlineSequence } from '../../config/cli-registry/types.js';
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
import { resolveCodexLaunchDefaults } from '../codex-launch-defaults.js';
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
@@ -1103,6 +1104,7 @@ export function registerSessionRoutes(
const globalNice = await ctx.getGlobalNiceConfig();
const modelConfig = await ctx.getModelConfig();
const mode = body.mode || 'claude';
const launchCodexConfig = mode === 'codex' ? await resolveCodexLaunchDefaults(body.codexConfig) : body.codexConfig;
// Where a model override comes from is a capability, and the three answers are
// genuinely different mechanisms:
// 'flag' — the CLI takes --model, so read the value the caller sent
@@ -1117,9 +1119,10 @@ export function registerSessionRoutes(
const modelSource = getCli(mode)?.capabilities.model;
const model =
modelSource?.source === 'flag'
? (legacyConfigForMode(mode, body as unknown as Record<string, unknown>)?.[modelSource.param ?? 'model'] as
| string
| undefined)
? (legacyConfigForMode(mode, { ...body, codexConfig: launchCodexConfig } as unknown as Record<
string,
unknown
>)?.[modelSource.param ?? 'model'] as string | undefined)
: modelSource?.source === 'claude-settings-file'
? body.model || modelConfig?.defaultModel || undefined
: undefined;
@@ -1136,7 +1139,7 @@ export function registerSessionRoutes(
deepSeekConfig: gatedDeepSeekConfig,
} = await _clampExternalCliBypassForOwner(
owner,
body.codexConfig,
launchCodexConfig,
body.geminiConfig,
body.antigravityConfig,
body.piConfig,
@@ -3831,13 +3834,15 @@ export function registerSessionRoutes(
// Apply global Nice priority config and model config from settings
const niceConfig = await ctx.getGlobalNiceConfig();
const qsModelConfig = await ctx.getModelConfig();
const qsLaunchCodexConfig =
mode === 'codex' && !remote ? await resolveCodexLaunchDefaults(codexConfig, !!customModel) : codexConfig;
// See the create path for why this is a capability rather than a mode ladder.
const qsModelSource = getCli(mode)?.capabilities.model;
const qsModel =
qsModelSource?.source === 'flag'
? (legacyConfigForMode(mode, {
openCodeConfig,
codexConfig,
codexConfig: qsLaunchCodexConfig,
geminiConfig,
antigravityConfig,
piConfig,
@@ -3859,7 +3864,7 @@ export function registerSessionRoutes(
deepSeekConfig: qsGatedDeepSeekConfig,
} = await _clampExternalCliBypassForOwner(
owner,
codexConfig,
qsLaunchCodexConfig,
geminiConfig,
antigravityConfig,
piConfig,
+6
View File
@@ -1457,6 +1457,12 @@ export const SettingsUpdateSchema = z
claudeMode: z.string().max(50).optional(),
allowedTools: z.string().max(2000).optional(),
// Codex CLI settings
codexModel: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._\-/]*$/)
.optional(),
codexReasoningEffort: z.enum(['', ...CODEX_REASONING_EFFORTS]).optional(),
codexDangerouslyBypassApprovals: z.boolean().optional(),
codexAnimationsEnabled: z.boolean().optional(),
// Terminal history and retention
@@ -0,0 +1,97 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
import { CASES_DIR, SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { resolveCodexLaunchDefaults } from '../../src/web/codex-launch-defaults.js';
import { buildCodexCommand } from '../../src/tmux-manager.js';
import { Session } from '../../src/session.js';
import { safeRmHomeTree } from '../mocks/index.js';
vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>();
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
});
describe('Codex launch defaults', () => {
let harness: RouteTestHarness;
const workingDir = join(homedir(), 'codex-default-test');
beforeEach(async () => {
await mkdir(workingDir, { recursive: true });
await mkdir(dirname(SETTINGS_PATH), { recursive: true });
await writeFile(SETTINGS_PATH, JSON.stringify({ codexModel: 'gpt-6.1', codexReasoningEffort: 'high' }));
harness = await createRouteTestHarness(registerSessionRoutes);
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
});
afterEach(async () => {
await harness.app.close();
safeRmHomeTree(CASES_DIR);
safeRmHomeTree(workingDir);
vi.restoreAllMocks();
});
async function createdConfig(url: string, overrides: Record<string, unknown> = {}) {
const payload =
url === '/api/sessions'
? { workingDir, mode: 'codex', ...overrides }
: { caseName: 'codex-default-test', mode: 'codex', ...overrides };
const res = await harness.app.inject({ method: 'POST', url, payload });
expect(res.statusCode).toBe(200);
const session = [...harness.ctx.sessions.values()].at(-1) as Session | undefined;
expect(session).toBeDefined();
return session!.codexConfig;
}
for (const url of ['/api/sessions', '/api/quick-start']) {
it(`applies persisted defaults at ${url} and emits the CLI options`, async () => {
const config = await createdConfig(url);
expect(config).toMatchObject({ model: 'gpt-6.1', reasoningEffort: 'high' });
expect(buildCodexCommand(config)).toContain('--model gpt-6.1');
expect(buildCodexCommand(config)).toContain('--config model_reasoning_effort=high');
});
it(`preserves explicit model and effort at ${url}`, async () => {
expect(await createdConfig(url, { codexConfig: { model: 'gpt-other', reasoningEffort: 'low' } })).toMatchObject({
model: 'gpt-other',
reasoningEffort: 'low',
});
});
}
it('leaves empty defaults to Codex and ignores malformed persisted values', async () => {
for (const settings of [
{ codexModel: '', codexReasoningEffort: '' },
{ codexModel: 'bad;command', codexReasoningEffort: 'invalid' },
]) {
await writeFile(SETTINGS_PATH, JSON.stringify(settings));
expect(await resolveCodexLaunchDefaults(undefined)).toBeUndefined();
expect(buildCodexCommand(await resolveCodexLaunchDefaults(undefined))).toBe('codex');
}
});
it('keeps defaults out of custom endpoint launches', async () => {
const config = { model: 'local-model', animations: false };
expect(await resolveCodexLaunchDefaults(config, true)).toBe(config);
});
it('accepts, persists, clears and validates synced settings via HTTP', async () => {
const system = await createRouteTestHarness(registerSystemRoutes);
try {
const put = (payload: unknown) => system.app.inject({ method: 'PUT', url: '/api/settings', payload });
expect((await put({ codexModel: 'gpt-6.1', codexReasoningEffort: 'xhigh' })).statusCode).toBe(200);
const read = await system.app.inject({ method: 'GET', url: '/api/settings' });
expect(read.json()).toMatchObject({ codexModel: 'gpt-6.1', codexReasoningEffort: 'xhigh' });
expect((await put({ codexReasoningEffort: 'bogus' })).statusCode).toBe(400);
expect((await put({ codexModel: 'bad;command' })).statusCode).toBe(400);
expect((await put({ codexModel: '', codexReasoningEffort: '' })).statusCode).toBe(200);
expect(await resolveCodexLaunchDefaults(undefined)).toBeUndefined();
} finally {
await system.app.close();
}
});
});