From 3c3d417457a4c3420139488c19718b7415641584 Mon Sep 17 00:00:00 2001 From: Kairos Duan <2394451247@qq.com> Date: Wed, 27 May 2026 11:31:31 +0800 Subject: [PATCH] Enable Codex goals in provider templates (#3089) * Enable Codex goals in provider templates * feat: add Codex goal mode toggle - Remove forced goals=true from Codex provider presets and custom templates. - Add a Codex provider editor switch that updates [features].goals on demand. - Update docs, i18n, and regression coverage for the optional Goal mode flow. --------- Co-authored-by: Jason --- docs/user-manual/en/2-providers/2.1-add.md | 3 + docs/user-manual/ja/2-providers/2.1-add.md | 3 + docs/user-manual/zh/2-providers/2.1-add.md | 3 + .../providers/forms/CodexConfigSections.tsx | 57 +++++++-- src/i18n/locales/en.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/zh-TW.json | 1 + src/i18n/locales/zh.json | 1 + src/utils/providerConfigUtils.ts | 111 ++++++++++++++++++ .../CommonConfigModalBehavior.test.tsx | 51 +++++++- tests/config/codexTemplates.test.ts | 17 +++ tests/config/therouterProviderPresets.test.ts | 17 +-- tests/utils/providerConfigUtils.codex.test.ts | 84 ++++++++++++- 13 files changed, 325 insertions(+), 25 deletions(-) create mode 100644 tests/config/codexTemplates.test.ts diff --git a/docs/user-manual/en/2-providers/2.1-add.md b/docs/user-manual/en/2-providers/2.1-add.md index c4219f200..85bf684fd 100644 --- a/docs/user-manual/en/2-providers/2.1-add.md +++ b/docs/user-manual/en/2-providers/2.1-add.md @@ -268,10 +268,13 @@ requires_openai_auth = true | `model` | Yes | Model to use (e.g., `gpt-5.2`, `gpt-4o`) | | `model_reasoning_effort` | No | Reasoning effort: `low` / `medium` / `high` | | `disable_response_storage` | No | Whether to disable response storage | +| `goals` | No | Enables Codex `/goal` mode when set under `[features]`; the provider editor can toggle this field for providers that need it | | `base_url` | Yes | API endpoint URL | | `wire_api` | No | API protocol type (usually `responses`) | | `requires_openai_auth` | No | Whether to use OpenAI authentication | +If Codex recognizes `/goal` but cannot set a goal after switching to an API-key or custom endpoint provider, enable the provider editor's **Goal mode** switch. CC Switch writes `goals = true` under `[features]` for that provider's `~/.codex/config.toml`; restart Codex afterward. If Goal mode asks for approval more often than expected, refresh the Codex settings page and check that `approval_policy` and `sandbox_mode` still match your intended permission level. + ### Gemini Configuration Format diff --git a/docs/user-manual/ja/2-providers/2.1-add.md b/docs/user-manual/ja/2-providers/2.1-add.md index 4ec428755..8b2017ed6 100644 --- a/docs/user-manual/ja/2-providers/2.1-add.md +++ b/docs/user-manual/ja/2-providers/2.1-add.md @@ -268,10 +268,13 @@ requires_openai_auth = true | `model` | はい | 使用するモデル(例:`gpt-5.2`、`gpt-4o`) | | `model_reasoning_effort` | いいえ | 推論強度:`low` / `medium` / `high` | | `disable_response_storage` | いいえ | レスポンス保存を無効にするかどうか | +| `goals` | いいえ | `[features]` 配下で `true` にすると Codex の `/goal` モードを有効化します。必要なプロバイダーでは、プロバイダー編集画面のスイッチで切り替えられます | | `base_url` | はい | API エンドポイントアドレス | | `wire_api` | いいえ | API プロトコルタイプ(通常 `responses`) | | `requires_openai_auth` | いいえ | OpenAI 認証方式を使用するかどうか | +API Key またはカスタムエンドポイントのプロバイダーへ切り替えた後、Codex が `/goal` を認識しているのに目標を設定できない場合は、プロバイダー編集画面の **Goal mode** スイッチを有効にしてください。CC Switch はそのプロバイダーの `~/.codex/config.toml` の `[features]` に `goals = true` を書き込みます。その後、Codex を再起動してください。Goal mode 有効化後に想定より頻繁に承認を求められる場合は、Codex の設定画面を再読み込みし、`approval_policy` と `sandbox_mode` が意図した権限と一致しているか確認してください。 + ### Gemini 設定形式 diff --git a/docs/user-manual/zh/2-providers/2.1-add.md b/docs/user-manual/zh/2-providers/2.1-add.md index 48ac767d2..674bd2617 100644 --- a/docs/user-manual/zh/2-providers/2.1-add.md +++ b/docs/user-manual/zh/2-providers/2.1-add.md @@ -268,10 +268,13 @@ requires_openai_auth = true | `model` | 是 | 使用的模型(如 `gpt-5.2`、`gpt-4o`) | | `model_reasoning_effort` | 否 | 推理强度:`low` / `medium` / `high` | | `disable_response_storage` | 否 | 是否禁用响应存储 | +| `goals` | 否 | 放在 `[features]` 下并设为 `true` 后启用 Codex `/goal` 模式;需要时可在供应商编辑区域通过开关启用 | | `base_url` | 是 | API 端点地址 | | `wire_api` | 否 | API 协议类型(通常为 `responses`) | | `requires_openai_auth` | 否 | 是否使用 OpenAI 认证方式 | +如果切换到 API Key 或自定义端点供应商后,Codex 能识别 `/goal`,但无法设置目标,请在供应商编辑区域打开 **Goal mode** 开关。CC Switch 会为该供应商在 `~/.codex/config.toml` 的 `[features]` 下写入 `goals = true`,之后请重启 Codex。如果启用 Goal mode 后比预期更频繁地请求批准,请刷新 Codex 设置页,并确认 `approval_policy` 与 `sandbox_mode` 仍符合你的权限预期。 + ### Gemini 配置格式 diff --git a/src/components/providers/forms/CodexConfigSections.tsx b/src/components/providers/forms/CodexConfigSections.tsx index 8ce78eb30..7fbd9fd2f 100644 --- a/src/components/providers/forms/CodexConfigSections.tsx +++ b/src/components/providers/forms/CodexConfigSections.tsx @@ -1,10 +1,19 @@ // NOTE: Codex 1M 上下文 UI 已暂时隐藏(详见下方 CodexConfigSection 内 JSX 注释)。 // 如需恢复,请同时: -// - 在下方 React import 中加回 `useMemo` // - 取消下面 `@/utils/providerConfigUtils` import 的注释 -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import JsonEditor from "@/components/JsonEditor"; +import { + isCodexGoalModeEnabled, + setCodexGoalMode, +} from "@/utils/providerConfigUtils"; /* import { extractCodexTopLevelInt, @@ -144,6 +153,18 @@ export const CodexConfigSection: React.FC = ({ [onChange], ); + const goalModeEnabled = useMemo( + () => isCodexGoalModeEnabled(localValue), + [localValue], + ); + + const handleGoalModeToggle = useCallback( + (checked: boolean) => { + handleLocalChange(setCodexGoalMode(localValueRef.current || "", checked)); + }, + [handleLocalChange], + ); + // Codex 1M 上下文相关状态/回调暂时禁用——见同文件下方 JSX 注释处的恢复说明。 /* // Parse toggle states from TOML text @@ -217,7 +238,7 @@ export const CodexConfigSection: React.FC = ({ return (
-
+
- +
+ + + +
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 97c5e9235..8dfdb5a70 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1102,6 +1102,7 @@ "editCommonConfig": "Edit Common Config", "editCommonConfigTitle": "Edit Codex Common Config Snippet", "commonConfigHint": "This snippet will be appended to the end of config.toml when 'Write Common Config' is checked", + "enableGoalMode": "Enable Goal mode", "apiUrlLabel": "API Request URL", "extractFromCurrent": "Extract from Editor", "extractNoCommonConfig": "No common config available to extract from editor", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 093e284c1..087551def 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1102,6 +1102,7 @@ "editCommonConfig": "共通設定を編集", "editCommonConfigTitle": "Codex 共通設定スニペットを編集", "commonConfigHint": "「共通設定を書き込む」がオンの場合、config.toml の末尾に追記されます", + "enableGoalMode": "Goal mode を有効化", "apiUrlLabel": "API リクエスト URL", "extractFromCurrent": "編集内容から抽出", "extractNoCommonConfig": "編集内容から抽出できる共通設定がありません", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 2fcf1e22c..8b70c0ebe 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1074,6 +1074,7 @@ "editCommonConfig": "編輯通用設定", "editCommonConfigTitle": "編輯 Codex 通用設定片段", "commonConfigHint": "該片段會在勾選「寫入通用設定」時附加到 config.toml 末尾", + "enableGoalMode": "啟用 Goal mode", "apiUrlLabel": "API 請求位址", "extractFromCurrent": "從編輯內容擷取", "extractNoCommonConfig": "目前編輯內容沒有可擷取的通用設定", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 4064af713..ba01d5f56 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1102,6 +1102,7 @@ "editCommonConfig": "编辑通用配置", "editCommonConfigTitle": "编辑 Codex 通用配置片段", "commonConfigHint": "该片段会在勾选'写入通用配置'时追加到 config.toml 末尾", + "enableGoalMode": "启用 Goal mode", "apiUrlLabel": "API 请求地址", "extractFromCurrent": "从编辑内容提取", "extractNoCommonConfig": "当前编辑内容没有可提取的通用配置", diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index 337fe2316..482709472 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -410,6 +410,9 @@ const TOML_WIRE_API_PATTERN = /^\s*wire_api\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/; const TOML_MODEL_PROVIDER_LINE_PATTERN = /^\s*model_provider\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/; +const TOML_GOALS_FEATURE_PATTERN = /^\s*goals\s*=\s*(true|false)\s*(?:#.*)?$/; +const TOML_GOALS_FEATURE_REPLACE_PATTERN = + /^(\s*goals\s*=\s*)(true|false)(\s*(?:#.*)?)$/; const CODEX_RESERVED_MODEL_PROVIDER_IDS = new Set([ "amazon-bedrock", "openai", @@ -422,6 +425,7 @@ const CODEX_RESERVED_MODEL_PROVIDER_IDS = new Set([ interface TomlSectionRange { bodyEndIndex: number; bodyStartIndex: number; + headerLineIndex: number; } interface TomlAssignmentMatch { @@ -458,6 +462,7 @@ const getTomlSectionRange = ( return { bodyStartIndex: headerLineIndex + 1, bodyEndIndex: index, + headerLineIndex, }; } @@ -468,6 +473,7 @@ const getTomlSectionRange = ( return { bodyStartIndex: headerLineIndex + 1, bodyEndIndex: lines.length, + headerLineIndex, }; }; @@ -639,6 +645,14 @@ const getTopLevelModelProviderLineIndex = (lines: string[]): number => { return -1; }; +const hasTomlSectionBodyContent = ( + lines: string[], + sectionRange: TomlSectionRange, +): boolean => + lines + .slice(sectionRange.bodyStartIndex, sectionRange.bodyEndIndex) + .some((line) => line.trim() !== ""); + const TOML_BASIC_STRING_ESCAPES: Record = { '"': '\\"', "\\": "\\\\", @@ -797,6 +811,103 @@ export const setCodexWireApi = ( return finalizeTomlText(lines); }; +export const isCodexGoalModeEnabled = ( + configText: string | undefined | null, +): boolean => { + try { + const raw = typeof configText === "string" ? configText : ""; + const text = normalizeTomlText(raw); + if (!text) return false; + + try { + const parsed = parseToml(text) as Record; + return parsed.features?.goals === true; + } catch { + // Fall back to line scanning while the user is editing invalid TOML. + } + + const lines = text.split("\n"); + const featureRange = getTomlSectionRange(lines, "features"); + if (!featureRange) return false; + + const index = findTomlLineInRange( + lines, + TOML_GOALS_FEATURE_PATTERN, + featureRange.bodyStartIndex, + featureRange.bodyEndIndex, + ); + if (index === -1) return false; + + return lines[index].match(TOML_GOALS_FEATURE_PATTERN)?.[1] === "true"; + } catch { + return false; + } +}; + +export const setCodexGoalMode = ( + configText: string, + enabled: boolean, +): string => { + const normalizedText = normalizeTomlText(configText); + const lines = normalizedText ? normalizedText.split("\n") : []; + let featureRange = getTomlSectionRange(lines, "features"); + + if (featureRange) { + const goalLineIndex = findTomlLineInRange( + lines, + TOML_GOALS_FEATURE_REPLACE_PATTERN, + featureRange.bodyStartIndex, + featureRange.bodyEndIndex, + ); + + if (enabled) { + if (goalLineIndex !== -1) { + lines[goalLineIndex] = lines[goalLineIndex].replace( + TOML_GOALS_FEATURE_REPLACE_PATTERN, + "$1true$3", + ); + } else { + lines.splice( + getTomlSectionInsertIndex(lines, featureRange), + 0, + "goals = true", + ); + } + return finalizeTomlText(lines); + } + + if (goalLineIndex !== -1) { + lines.splice(goalLineIndex, 1); + featureRange = getTomlSectionRange(lines, "features"); + if (featureRange && !hasTomlSectionBodyContent(lines, featureRange)) { + lines.splice( + featureRange.headerLineIndex, + featureRange.bodyEndIndex - featureRange.headerLineIndex, + ); + } + } + return finalizeTomlText(lines); + } + + if (!enabled) return normalizedText; + + const topLevelEndIndex = getTopLevelEndIndex(lines); + const sectionLines: string[] = []; + if (topLevelEndIndex > 0 && lines[topLevelEndIndex - 1].trim() !== "") { + sectionLines.push(""); + } + sectionLines.push("[features]", "goals = true"); + if ( + topLevelEndIndex < lines.length && + lines[topLevelEndIndex]?.trim() !== "" + ) { + sectionLines.push(""); + } + + lines.splice(topLevelEndIndex, 0, ...sectionLines); + return finalizeTomlText(lines); +}; + // 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号) export const extractCodexBaseUrl = ( configText: string | undefined | null, diff --git a/tests/components/CommonConfigModalBehavior.test.tsx b/tests/components/CommonConfigModalBehavior.test.tsx index 76abf6a10..1820571ed 100644 --- a/tests/components/CommonConfigModalBehavior.test.tsx +++ b/tests/components/CommonConfigModalBehavior.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import CodexConfigEditor from "@/components/providers/forms/CodexConfigEditor"; import GeminiConfigEditor from "@/components/providers/forms/GeminiConfigEditor"; +import { isCodexGoalModeEnabled } from "@/utils/providerConfigUtils"; vi.mock("@/components/common/FullScreenPanel", () => ({ FullScreenPanel: ({ @@ -68,7 +69,9 @@ describe("Common config modals", () => { expect(screen.queryByTestId("common-config-panel")).not.toBeInTheDocument(); fireEvent.click( - screen.getByRole("button", { name: /codexConfig.editCommonConfig|编辑通用配置/ }), + screen.getByRole("button", { + name: /codexConfig.editCommonConfig|编辑通用配置/, + }), ); expect(screen.getByTestId("common-config-panel")).toBeInTheDocument(); @@ -82,6 +85,52 @@ describe("Common config modals", () => { ); }); + it("toggles Codex Goal mode in config.toml from the provider editor", () => { + const onConfigChange = vi.fn(); + const configValue = [ + 'model_provider = "custom"', + 'model = "gpt-5.4"', + "", + "[model_providers.custom]", + 'name = "custom"', + "", + ].join("\n"); + + render( + {}} + onConfigChange={onConfigChange} + useCommonConfig={false} + onCommonConfigToggle={() => {}} + commonConfigSnippet="" + onCommonConfigSnippetChange={() => true} + onCommonConfigErrorClear={() => {}} + commonConfigError="" + authError="" + configError="" + />, + ); + + const goalToggle = screen.getByRole("checkbox", { + name: "codexConfig.enableGoalMode", + }); + + expect(goalToggle).not.toBeChecked(); + + fireEvent.click(goalToggle); + + const enabledConfig = onConfigChange.mock.lastCall?.[0] ?? ""; + expect(isCodexGoalModeEnabled(enabledConfig)).toBe(true); + + fireEvent.click(goalToggle); + + const disabledConfig = onConfigChange.mock.lastCall?.[0] ?? ""; + expect(isCodexGoalModeEnabled(disabledConfig)).toBe(false); + expect(disabledConfig).not.toContain("goals = true"); + }); + it("keeps the Gemini common config modal closed after user closes it with an error present", async () => { render( { + it("does not force Codex Goal mode in the custom provider template", () => { + const template = getCodexCustomTemplate(); + const parsed = parseToml(template.config) as { + features?: { goals?: boolean }; + model_providers?: Record; + }; + + expect(template.auth).toEqual({ OPENAI_API_KEY: "" }); + expect(parsed.features?.goals).toBeUndefined(); + expect(parsed.model_providers?.custom).toBeDefined(); + }); +}); diff --git a/tests/config/therouterProviderPresets.test.ts b/tests/config/therouterProviderPresets.test.ts index aec49bc66..aa3a4dbf7 100644 --- a/tests/config/therouterProviderPresets.test.ts +++ b/tests/config/therouterProviderPresets.test.ts @@ -24,26 +24,25 @@ describe("TheRouter provider presets", () => { expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe( "anthropic/claude-sonnet-4.6", ); - expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe( - "anthropic/claude-opus-4.7", - ); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("anthropic/claude-opus-4.7"); }); it("uses the OpenAI-compatible v1 endpoint for Codex", () => { - const preset = codexProviderPresets.find((item) => item.name === "TheRouter"); + const preset = codexProviderPresets.find( + (item) => item.name === "TheRouter", + ); expect(preset).toBeDefined(); expect(preset?.websiteUrl).toBe("https://therouter.ai"); expect(preset?.apiKeyUrl).toBe("https://dashboard.therouter.ai"); expect(preset?.category).toBe("aggregator"); - expect(preset?.endpointCandidates).toEqual([ - "https://api.therouter.ai/v1", - ]); + expect(preset?.endpointCandidates).toEqual(["https://api.therouter.ai/v1"]); expect(preset?.auth).toEqual({ OPENAI_API_KEY: "" }); expect(preset?.config).toContain('model_provider = "custom"'); expect(preset?.config).toContain("[model_providers.custom]"); expect(preset?.config).toContain('name = "therouter"'); expect(preset?.config).toContain('model = "openai/gpt-5.3-codex"'); + expect(preset?.config).not.toContain("goals = true"); expect(preset?.config).toContain( 'base_url = "https://api.therouter.ai/v1"', ); @@ -51,7 +50,9 @@ describe("TheRouter provider presets", () => { }); it("uses the Gemini-native root endpoint for Gemini", () => { - const preset = geminiProviderPresets.find((item) => item.name === "TheRouter"); + const preset = geminiProviderPresets.find( + (item) => item.name === "TheRouter", + ); expect(preset).toBeDefined(); expect(preset?.websiteUrl).toBe("https://therouter.ai"); diff --git a/tests/utils/providerConfigUtils.codex.test.ts b/tests/utils/providerConfigUtils.codex.test.ts index be5915290..31b7af15a 100644 --- a/tests/utils/providerConfigUtils.codex.test.ts +++ b/tests/utils/providerConfigUtils.codex.test.ts @@ -4,8 +4,10 @@ import { extractCodexExperimentalBearerToken, extractCodexModelName, extractCodexTopLevelInt, + isCodexGoalModeEnabled, removeCodexTopLevelField, setCodexBaseUrl, + setCodexGoalMode, setCodexModelName, setCodexTopLevelInt, updateCodexExperimentalBearerToken, @@ -195,6 +197,83 @@ describe("Codex TOML utils", () => { expect(removed).toContain("[model_providers.custom]"); }); + it("adds Goal mode under the top-level features table", () => { + const input = [ + 'model_provider = "custom"', + 'model = "gpt-5.4"', + "", + "[model_providers.custom]", + 'name = "custom"', + "", + ].join("\n"); + + const output = setCodexGoalMode(input, true); + + expect(isCodexGoalModeEnabled(output)).toBe(true); + expect(output).toContain( + 'model = "gpt-5.4"\n\n[features]\ngoals = true\n\n[model_providers.custom]', + ); + }); + + it("removes Goal mode without deleting other feature flags", () => { + const input = [ + 'model_provider = "custom"', + "", + "[features]", + "goals = true", + "experimental_resume = true", + "", + "[model_providers.custom]", + 'name = "custom"', + "", + ].join("\n"); + + const output = setCodexGoalMode(input, false); + + expect(isCodexGoalModeEnabled(output)).toBe(false); + expect(output).toContain("[features]\nexperimental_resume = true"); + expect(output).not.toMatch(/^\s*goals\s*=/m); + }); + + it("removes the features table when disabling the only Goal mode flag", () => { + const input = [ + 'model_provider = "custom"', + "", + "[features]", + "goals = true", + "", + "[model_providers.custom]", + 'name = "custom"', + "", + ].join("\n"); + + const output = setCodexGoalMode(input, false); + + expect(isCodexGoalModeEnabled(output)).toBe(false); + expect(output).not.toContain("[features]"); + expect(output).toContain("[model_providers.custom]"); + }); + + it("preserves feature-section comments when disabling Goal mode", () => { + const input = [ + 'model_provider = "custom"', + "", + "[features]", + "# Keep this note", + "goals = true", + "", + "[model_providers.custom]", + 'name = "custom"', + "", + ].join("\n"); + + const output = setCodexGoalMode(input, false); + + expect(isCodexGoalModeEnabled(output)).toBe(false); + expect(output).toContain("[features]\n# Keep this note"); + expect(output).not.toMatch(/^\s*goals\s*=/m); + }); + // P3 回归: 不能在 config 没用 bearer token 模式时, 误为它新增一行 it("updateCodexExperimentalBearerToken leaves config without the token alone", () => { const input = [ @@ -251,10 +330,7 @@ describe("Codex TOML utils", () => { "", ].join("\n"); - const updated = updateCodexExperimentalBearerToken( - input, - 'abc"def\\ghi', - ); + const updated = updateCodexExperimentalBearerToken(input, 'abc"def\\ghi'); expect(updated).toContain( 'experimental_bearer_token = "abc\\"def\\\\ghi" # vendor token',