From 16e44aa1d18edfde159e13aaa3d6a777c8261fb9 Mon Sep 17 00:00:00 2001 From: shenlvkang-collab Date: Wed, 7 Oct 2026 17:34:25 +0800 Subject: [PATCH] feat(codex): add synced model and reasoning defaults --- .changeset/codex-launch-defaults.md | 5 + docs/wiki/Agent-CLIs.md | 8 ++ src/web/codex-launch-defaults.ts | 22 +++++ src/web/public/i18n.js | 5 + src/web/public/index.html | 24 +++++ src/web/public/settings-ui.js | 4 + src/web/routes/session-routes.ts | 17 ++-- src/web/schemas.ts | 6 ++ .../session-routes-codex-defaults.test.ts | 97 +++++++++++++++++++ 9 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 .changeset/codex-launch-defaults.md create mode 100644 src/web/codex-launch-defaults.ts create mode 100644 test/routes/session-routes-codex-defaults.test.ts diff --git a/.changeset/codex-launch-defaults.md b/.changeset/codex-launch-defaults.md new file mode 100644 index 00000000..ea8b61cd --- /dev/null +++ b/.changeset/codex-launch-defaults.md @@ -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. diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index 9e1d335a..8032cd1f 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -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, diff --git a/src/web/codex-launch-defaults.ts b/src/web/codex-launch-defaults.ts new file mode 100644 index 00000000..24f25b40 --- /dev/null +++ b/src/web/codex-launch-defaults.ts @@ -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 { + if (customEndpoint) return config; + const settings = await readJsonConfig>(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, + }; +} diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 538534b2..3fe7e62f 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -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': '会话标签页', diff --git a/src/web/public/index.html b/src/web/public/index.html index 958371f6..a798b020 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2527,6 +2527,30 @@

Codex

synced
+
+
+ Default Codex model + Model ID for new Codex sessions. Leave empty to use Codex configuration. +
+ +
+
+
+ Default Codex reasoning effort + Applies to new sessions; supported levels depend on the model and Codex version. Custom endpoints keep their own settings. +
+ +
Bypass approvals and sandbox diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index e8fed034..2f6c5a1c 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -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 diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index a21b19f4..263a1e58 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -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)?.[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, diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 775b889a..20cb89d7 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -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 diff --git a/test/routes/session-routes-codex-defaults.test.ts b/test/routes/session-routes-codex-defaults.test.ts new file mode 100644 index 00000000..ef317260 --- /dev/null +++ b/test/routes/session-routes-codex-defaults.test.ts @@ -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(); + 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 = {}) { + 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(); + } + }); +});