Compare commits

..

24 Commits

Author SHA1 Message Date
Jason 25951d8132 chore(release): bump version to 3.16.1 and add release notes 2026-06-01 17:55:55 +08:00
Jason d66030bee6 Stream Codex custom tools with native input events
Custom (freeform) Codex tools are bridged through Chat Completions as
JSON `{"input": "..."}` functions, but the Chat->Responses stream still
re-emitted them via `response.function_call_arguments.*`, leaking the
JSON wrapper and using event types the Codex client does not route for
freeform tools.

Emit `response.custom_tool_call_input.delta`/`.done` (with the unwrapped
input text) for custom tool calls instead, suppressing the intermediate
function-argument deltas since a partial `{"input":` fragment cannot be
safely unwrapped mid-stream. Custom tool call items now use a `ctc_`
item-id prefix (matching the input events' item_id) consistently across
the streaming and non-streaming paths via a shared
response_tool_call_item_id_from_chat_name helper.

Covered by new streaming and non-streaming custom-tool tests.
2026-06-01 17:01:00 +08:00
Jason 5968336364 Restore Codex tool plugins over Chat Completions third-party proxy
When proxying Codex Responses requests through the Chat Completions
format (api_format=openai_chat), the request transform only forwarded
plain `function` tools and silently dropped `tool_search`, `namespace`
(MCP), and `custom` tools, so third-party APIs never saw the official
Codex plugins and could not call them.

Introduce CodexToolContext, built from the original Responses request,
which flattens all four tool kinds into Chat functions and keeps a
chat_name -> CodexToolSpec map. The response path (streaming and
non-streaming) looks names up in this map to restore the original
tool_search_call / custom_tool_call / namespaced function_call items
instead of reparsing the flattened name. Long namespaced names are
truncated with a sha256 suffix to fit the 64-char Chat tool-name limit.

Covered by new round-trip tests for all four tool kinds across both
streaming and non-streaming paths.
2026-06-01 16:29:23 +08:00
Jason b7499fc871 Refresh Codex provider label on proxy takeover hot-switch
During proxy takeover, switching third-party Codex providers left the
client-visible provider name stale: sync_codex_live_from_provider_while_proxy_active
based the live config on the existing live file and only patched
base_url/wire_api/model, never refreshing model_provider or
model_providers.<id>.name. The Codex app kept showing the previous
provider in its bottom-right label.

Rebuild the effective settings from the DB for the selected provider so
the live config carries the correct provider key and display name, then
merge MCP servers back from the existing live config. base_url stays
pointed at the local proxy, and official OAuth in auth.json is untouched
(takeover writes config.toml only when auth preservation is enabled).

Generalize preserve_codex_mcp_servers_in_backup ->
preserve_codex_mcp_servers_from_existing_config since it now serves both
the backup and live-sync paths.
2026-06-01 15:16:13 +08:00
Jason aeaa016cae Simplify Codex takeover notice copy and match hint styling
Restyle the proxy-takeover notice in the Codex editor from the boxed
Alert to the amber inline-hint style used by the endpoint hints, and drop
implementation jargon (127.0.0.1 / PROXY_MANAGED) that users could not
interpret. The notice and the auth/config hints now simply state that the
form shows the stored provider config rather than the proxy-managed live
config. i18n synced across en/zh/ja/zh-TW.
2026-06-01 11:41:05 +08:00
Jason 2a131a5572 Harden Codex takeover ownership signaling and serialize switch/takeover
Gate provider sync and switching on the restore backup / live placeholder
("is this live file owned by takeover?") instead of the lagging
proxy_config.enabled and proxy-running flags. The backup is created
before enabled=true is committed, so during that activation window the
old guards were blind and a concurrent sync/switch could rewrite the
taken-over live file, clearing Codex auth.json for a mis-categorized
provider.

Acquire a per-app switch lock around both set_takeover_for_app and
provider switching so the two cannot interleave, splitting the locking
entry points into outer (lock) / inner (no-lock) pairs to stay
deadlock-free. Preserve the official OAuth auth in provider-rebuilt
restore backups by routing the provider token into config.toml. Refine
takeover idempotency to require the live config to point at the current
proxy URL, rebuilding from backup when it does not.

Add unit and integration tests covering the official -> DeepSeek ->
takeover on/off lifecycle and the stopped-proxy switch path.
2026-06-01 11:41:05 +08:00
Jason a04e72a267 Fix Codex edit dialog masking live OAuth during proxy takeover
The reported "OAuth access token disappears when enabling Codex proxy
takeover" was a display artifact, not data loss: auth.json on disk kept
the OAuth login the whole time. During takeover the edit dialog falls
back to the stored provider config (so it does not surface the proxy
placeholder), which for a third-party provider shows that provider's own
key instead of the live auth.json, making the OAuth token look gone.

Thread an isProxyTakeover flag from App through ProviderForm into the
Codex editor and show an explicit notice plus storage-aware auth/config
hints clarifying that the form displays the stored provider config while
the live config is temporarily managed by the proxy. Drop the
proxy-running condition so the notice shows whenever takeover is active,
even with the proxy stopped.

Add a regression test asserting the dialog does not read live settings
during takeover and renders the database config. i18n synced across
en/zh/ja/zh-TW.
2026-06-01 11:41:05 +08:00
Jason ce993baefa Fix Codex OAuth auth cleared on takeover when provider mis-categorized
Preserve-mode takeover routes the PROXY_MANAGED placeholder into
config.toml and must leave auth.json (the ChatGPT OAuth login) intact.
c9cadd6e covered only the None-provider write branch; the
Some(provider) branch still ran the category-based auth decision in
codex_config::write_codex_live_for_provider, whose first clause
(category == "official" && has_login_material) ignores the preserve
flag entirely. Because takeover stamps OPENAI_API_KEY = PROXY_MANAGED
into auth, codex_auth_has_login_material returns true, so a provider
stored with a stale/mis-classified "official" category (e.g. DeepSeek)
had its real auth.json overwritten with the placeholder — the access
token vanished on takeover and reappeared on cleanup.

Fix at the takeover entry instead of patching the fragile category
logic: add write_codex_takeover_live_for_provider, which under
preservation detects the PROXY_MANAGED placeholder in auth and writes
only config.toml (bearer token + catalog projection), never touching
auth.json. Gating on the placeholder is orthogonal to category, so any
mis-classification is handled. All four takeover sites
(sync-while-active, takeover_live_configs, _strict, _best_effort) now
route through it; restore (verbatim) and non-takeover provider writes
are unchanged.

Also projects the model catalog via
prepare_codex_live_config_text_with_optional_catalog, so the
model_catalog_json pointer survives takeover too.

Add a regression test: official-category provider + preserve enabled +
proxy takeover must keep auth.json byte-identical while moving the
placeholder into config.toml.
2026-06-01 11:41:05 +08:00
Jason d5328e5290 Fix Codex model catalog lost on proxy takeover-off restore
Turning proxy takeover off restores Live from a stored backup via
write_codex_live_verbatim. That path mishandled the Codex model catalog
for two backup shapes that need opposite treatment:

- Snapshot backup (read_codex_live_settings -> {auth, config}): no inline
  modelCatalog, but the config.toml text already carries the live
  model_catalog_json pointer. The old code ran catalog projection, which
  saw no specs and stripped the pointer.
- Provider-rebuilt backup (update_live_backup_from_provider): inline
  modelCatalog (DB SSOT) with a pointer-less config text. A pure verbatim
  write ignored the inline catalog and never regenerated the pointer.

The projection decision is orthogonal to auth: a provider-rebuilt backup
can pair an inline modelCatalog with empty auth.json ({}) when the API key
lives in the config's experimental_bearer_token. The empty-auth branch
raw-wrote config and skipped projection entirely, so that shape lost its
mapping too.

Decide the config.toml text once, before splitting on auth, via the new
prepare_codex_live_config_text_with_optional_catalog helper: project only
when an inline modelCatalog is present, else keep the text raw (preserving
any existing pointer). Every config-writing branch (write-auth,
delete-auth, no-auth) now applies it consistently. Add regression tests
covering all three shapes.
2026-06-01 11:41:05 +08:00
Jason 0fbba4267c Fix Codex model catalog being wiped by live-config backfill
`modelCatalog` is a cc-switch-private field whose SSOT is the database; Live's
config.toml only carries a lossy `model_catalog_json` projection. Proxy
takeover/restore cycles and the official Codex.app rewriting config.toml can
drop that projection, so `read_live_settings` reconstructs an empty catalog.
Two paths then overwrote the stored mapping with that empty Live snapshot:

- Switch-away backfill (`switch_normal` -> `restore_live_settings_for_provider_backfill`):
  now overlays the DB provider's `modelCatalog`, falling back to the
  Live-reconstructed one only when the DB has none.
- Edit dialog (`EditProviderDialog`): when editing the active Codex provider it
  preferred Live over the DB SSOT; now keeps the DB `modelCatalog` so opening +
  saving no longer clears the mapping table.

Add Rust backfill tests (preserve DB catalog when Live lacks it; keep Live
catalog when DB has none) and a frontend regression test for the edit dialog.
2026-06-01 11:39:22 +08:00
oasis 8bf1660237 fix(codex): always update model catalog JSON on provider switch (#3360)
* fix(codex): always update model catalog JSON on provider switch

Without this fix, the cc-switch-model-catalog.json file and the
model_catalog_json field in config.toml were only regenerated when
should_sync_backup was true (proxy active or backup exists). Switching
providers while the proxy was idle left the catalog pointing at the
previous provider's models, requiring a full restart to take effect.

After the existing should_sync_backup block, unconditionally call
write_codex_provider_live_with_catalog when switching a Codex provider
and the proxy is not currently active (live_taken_over == false). This
mirrors what live.rs already does during a normal provider apply.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(codex): refresh catalog on restored hot switch

---------

Co-authored-by: yueqi.guo <guo_yueqi@qq.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-06-01 11:38:07 +08:00
Siskon afa09e127e fix(usage): resolve per-app credentials for native balance/coding-plan queries (#3355)
* fix(usage): resolve per-app credentials for native balance/coding-plan queries

The native usage-query paths (balance + coding_plan) in
`query_provider_usage_inner` read credentials only from `env.ANTHROPIC_*`.
That matches Claude providers, but Codex stores its key in
`auth.OPENAI_API_KEY` with the base URL inside a TOML `config` string,
Hermes/OpenClaw flatten them at the top level, and OpenCode nests them under
`options`. So the card "refresh usage" / auto-query returned empty
credentials and failed ("查询失败") for those apps, even though the
config-page "Test" button worked (the frontend extracts per-app correctly).

Introduce a single per-app resolver `Provider::resolve_usage_credentials`
that mirrors the frontend `getProviderCredentials`, and route both native
branches through it. Add `extract_codex_base_url` to `codex_config` as the
canonical Codex TOML base-URL parser and make the proxy adapter delegate to
it (removing a duplicate copy). Align the frontend `getProviderCredentials`
to cover OpenCode and Claude Desktop and to use the same OpenRouter/Google
key fallbacks as the backend.

Fixes #3158
Fixes #3100
Fixes #2625

* refactor(usage): explicit AppType arms + frontend trailing-slash trim

Address two review nits on the per-app credential resolver:

- provider.rs: replace the catch-all `_` arm in resolve_usage_credentials with an explicit `AppType::Claude | AppType::ClaudeDesktop` arm, so a new AppType variant fails to compile here instead of silently defaulting to the Anthropic env shape.
- UsageScriptModal.tsx: normalize getProviderCredentials baseUrl through a single trailing-slash trim so the frontend 'Test' path matches the backend resolver (trim_end_matches), keeping front/back truly in lockstep.

No behavior change for well-formed configs; tests/typecheck/fmt/clippy clean.

* fix(usage): skip empty primary credential fields in fallback chain

`obj.get(key)` returns `Some` for a present-but-empty field, so the
`.or_else()` fallback chains for the Claude/ClaudeDesktop and Gemini api-key
lookups only skipped *absent* keys, not empty ones. Presets seed fields like
`ANTHROPIC_AUTH_TOKEN` as present-but-empty placeholders, so a provider whose
real key lives in a fallback field (`ANTHROPIC_API_KEY` / `OPENROUTER_API_KEY`
/ `GOOGLE_API_KEY`, or `GOOGLE_API_KEY` for Gemini) resolved to an empty key on
the native balance/coding-plan path — while the frontend `a || b` (which skips
empty strings) still found it, reproducing the same Test-works / refresh-fails
divergence this PR removes.

Add a `first_non_empty` helper that skips present-but-empty values, matching
the frontend `||` semantics, and use it for both fallback chains. Tests cover
empty primary + populated fallback for Claude and Gemini.
2026-06-01 08:43:40 +08:00
Eunknight 0960fd7179 fix: Claude Desktop 官方供应商添加报错 #3402 (#3405)
* fix: Claude Desktop 官方供应商添加时缺少 ANTHROPIC_BASE_URL 报错

根因:前端 mutation 为 claude-desktop 生成随机 UUID 作为 provider id,
后端 is_official_provider 通过 id 匹配跳过校验,随机 UUID 不匹配导致
走入普通 direct 模式校验并要求 ANTHROPIC_BASE_URL。

修复:
- 前端:claude-desktop + category=official 时使用固定 id "claude-desktop-official"
- 后端:validate_provider / validate_direct_provider / validate_proxy_provider /
  apply_provider_to_paths 增加 category=="official" 兜底检查

Fixes #3402

* fix: restrict Claude Desktop official provider detection

* fix: add Claude Desktop official provider via seed

---------

Co-authored-by: 金恩光 <enguang.jin@gmail.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-05-31 23:37:31 +08:00
Zhiqi Yao 5ef72a2030 fix(codex): add multi-platform CLI discovery and static gpt-5.5 template fallback (#3382)
* fix(codex): add multi-platform CLI discovery and static gpt-5.5 template fallback for model catalog generation

When cc-switch generates a Codex model catalog for third-party providers,
it needs the gpt-5.5 model definition as a template. Previously it relied
on either ~/.codex/models_cache.json (only exists when Codex has connected
to OpenAI) or running 'codex debug models --bundled' (fails when codex
is not on the Tauri app's PATH, common in macOS GUI environments).

This commit adds a three-tier fallback:
1. Try 'codex' from PATH, then platform-specific common paths
   (/opt/homebrew/bin/codex, /usr/local/bin/codex, etc.)
2. If all CLI attempts fail, use a compile-time embedded gpt-5.5
   template (extracted from codex 0.135.0 bundled models)

Also adds tests for the static template validity and CLI candidates.

* fix(codex): discover user node codex installs

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-05-31 22:48:27 +08:00
Haoyu Wang e02a2763c2 fix: add kimi/moonshot to Anthropic tool thinking history normalizer (#3377)
* fix: add kimi/moonshot to Anthropic tool thinking history normalizer

Kimi's Anthropic-compatible endpoint requires reasoning_content in
assistant tool call messages when thinking is enabled, same as DeepSeek.
The is_reasoning_content_compatible_identifier already included kimi/moonshot
but is_anthropic_tool_thinking_history_identifier was missing them, causing
400 errors on multi-turn conversations with tool calls.

Closes #3351

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: unify reasoning vendor hints into a single SSOT list

Replace the two separately-maintained vendor identifier lists with one
shared REASONING_VENDOR_HINTS constant and a single
is_reasoning_vendor_identifier predicate. The Anthropic
tool-thinking-history matcher and the openai_chat reasoning_content
matcher previously kept independent lists, which is exactly how
kimi/moonshot ended up in one but not the other (#3351). A single source
of truth keeps the two from drifting apart again.

Also add a Kimi regression test for the Anthropic tool-history
normalization path.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-05-31 20:10:02 +08:00
Jason c9cadd6e09 Fix Codex OAuth auth being cleared during preserve-mode takeover
When "preserve official auth on switch" is enabled, proxy takeover routes
the PROXY_MANAGED placeholder into config.toml's experimental_bearer_token
and leaves auth.json (the ChatGPT OAuth login) untouched. Takeover detection
only inspected auth.json's OPENAI_API_KEY, so it never recognized this state
and returned a false negative, which led downstream paths to clobber the
preserved OAuth login.

- Detection: is_codex_live_taken_over now also matches a config.toml
  experimental_bearer_token equal to the placeholder, fixing detect/cleanup/
  restore/startup-recovery in one place.
- Cleanup: remove the config.toml bearer token only when it equals the
  placeholder (new remove_codex_experimental_bearer_token_if predicate), so a
  real third-party key is never stripped.
- Write: under preservation, the None-provider takeover path writes only
  config.toml and keeps auth.json intact, matching the provider path.
- Settings: rename the section to "Codex App Enhancements" and reword the
  description across all four locales.
- Add tests covering OAuth preservation on takeover and placeholder-only
  cleanup.
2026-05-30 22:56:36 +08:00
Jason 60a9b330e5 Refactor Codex live-write routing and cover default auth overwrite
Collapse the two duplicated write_codex_live_atomic branches in
write_codex_live_for_provider into a single should_write_auth guard.
This is behavior-preserving: `if A {X} else if B {X} else {Y}` becomes
`if A || B {X} else {Y}`.

Adapt the Codex switch tests to the new opt-in default for
preserve_codex_official_auth_on_switch (flipped off in 3f59ab37):
add an enable_codex_official_auth_preservation() test helper for the
cases that assert the auth-preserving path, and tag the official login
provider with category="official" so it routes through the official
branch rather than relying on the global preservation flag.

Add a regression test locking the default (preservation off) behavior:
switching to a third-party provider rewrites auth.json with the new
API key and discards the existing ChatGPT OAuth login. This is the
dual of the existing preserve-and-backfill test, which only covered
the opt-in path.
2026-05-30 22:09:28 +08:00
Jason f4e2c28a2b Enrich Codex proxy forwarding-error responses with context
When forward_with_retry fails for Codex endpoints (/chat/completions, /responses, /responses/compact), the handlers previously returned the bare ProxyError, surfacing a terse body to the client. Build a richer JSON error instead: the message embeds provider, model, endpoint and upstream status, and the error object carries those as structured fields alongside a stable cc_switch_* code.

Align map_proxy_error_to_status with ProxyError::into_response so the manually built responses use the canonical status codes (AuthError->401, Config/InvalidRequest->400, TransformError->422, StreamIdleTimeout->504, AlreadyRunning->409, NotRunning->503); previously these forwarder-level errors collapsed to 500.
2026-05-30 21:42:16 +08:00
Jason 0e6f2b395f Swap Shengsuanyun and AICodeMirror sponsor ads 2026-05-30 21:23:50 +08:00
Jason 41433cfab2 Add Codex restart hint after provider switch 2026-05-30 21:19:30 +08:00
Jason 3f59ab3746 Default Codex auth preservation to off (opt-in)
Flip preserve_codex_official_auth_on_switch from true to false so
third-party Codex switches overwrite auth.json by default, matching the
expectation that switching providers also swaps credentials. Users who
rely on keeping the ChatGPT login in auth.json while on a third-party
provider (for official plugins / remote login) can enable it in
Settings -> Codex Authentication.

The toggle field ships for the first time here (it is not in v3.16.0),
so no existing settings.json holds an explicit value -- every user lands
on the new default and no migration is required.

Also set the flag explicitly in the preservation unit test instead of
relying on the global default, keeping it valid now that the default is
false.
2026-05-30 21:04:23 +08:00
Jason ee69c83687 Fix garbled output and false "not runnable" in Windows version probe
The version probe ran `cmd /C "\"{path}\" --version"` on Windows. Rust's
own arg quoting plus the manual quotes produced nested quotes that cmd
(without /S) misparsed, so it reported even working tools as an
unrecognized command -> non-zero exit -> shown as "installed but not
runnable". cmd's error is emitted in the OEM code page (e.g. GBK on
zh-CN) and was decoded as UTF-8, turning it into mojibake.

- Add decode_command_output: try strict UTF-8, then OEM (GetOEMCP), then
  ANSI (GetACP) code page via MultiByteToWideChar; route all command
  output decoding through it (probes, lifecycle output, osascript,
  terminal launchers).
- Add run_windows_tool_version_command: run .exe directly (no cmd);
  invoke .cmd/.bat via `cmd /D /S /C call <quoted> --version` with
  raw_arg to bypass Rust's quoting and keep deterministic quote handling.
- Add windows-sys Win32_Globalization dependency.
2026-05-30 17:46:35 +08:00
Jason 2683af57cb Add Codex auth preservation setting 2026-05-30 14:13:58 +08:00
Jason 8f83fa2063 docs: add Codex DeepSeek routing guides 2026-05-30 11:49:50 +08:00
67 changed files with 6230 additions and 1028 deletions
+32 -1
View File
@@ -5,7 +5,38 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [3.16.1] - 2026-06-01
Development since v3.16.0 focuses on hardening Codex provider switching and Local Routing takeover: preserving official OAuth auth and model catalogs across normal switches, hot-switches, backup restore, and edit flows; restoring Codex Chat tool/plugin compatibility over Chat Completions upstreams; improving Codex proxy diagnostics and CLI discovery; and documenting DeepSeek routing.
**Stats**: 23 commits | 62 files changed | +5,603 insertions | -1,113 deletions
### Added
- **Codex Official Auth Preservation Setting**: Added an opt-in setting that keeps the official ChatGPT / Codex OAuth login in `auth.json` when switching third-party Codex providers, while moving third-party provider tokens into `config.toml` when enabled.
- **Codex DeepSeek Routing Guides**: Added localized DeepSeek routing guides for Codex in English, Chinese, and Japanese, with screenshots covering provider routing requirements, Codex provider setup, and Local Routing takeover.
### Changed
- **Codex Auth Preservation Is Opt-In**: The new official-auth preservation setting now defaults to off, so third-party Codex switches keep the legacy behavior of writing the active provider auth unless users explicitly enable preservation.
- **Codex Provider Switch Restart Hint**: Successful Codex provider switches now tell users to restart the Codex client so catalog and config changes take effect.
- **Codex Proxy Takeover Switching Is Serialized**: Provider switches and takeover toggles now share a per-app lock and use backup / live placeholder ownership signals instead of lagging `enabled` or server-running flags, preventing normal live writes from racing a just-activated or temporarily stopped takeover.
- **Codex Takeover Hot-Switch Display Refresh**: Hot-switching a Codex provider while Local Routing owns Live now refreshes the proxy-safe live provider id, model, and display name while keeping endpoints pointed at the local proxy.
- **Sponsor Ordering**: Swapped the Shengsuanyun and AICodeMirror sponsor blocks across README locales.
- **Docs Organization**: Moved the Chinese proxy guide under `docs/guides/` and removed the obsolete working-directory plan document.
### Fixed
- **Codex Provider Edit Dialog Under Takeover**: The Codex provider edit form now shows an explicit notice and storage-aware auth / config hints clarifying that it displays the stored provider config (not the proxy-managed live `auth.json` / `config.toml`), so the official OAuth token is no longer mistaken as lost while takeover is active. The dialog also treats takeover as active regardless of whether the proxy server is currently running.
- **Codex OAuth Auth During Proxy Takeover**: Fixed multiple preserve-mode takeover paths that could clear or overwrite the official ChatGPT / Codex OAuth `auth.json`. Takeover detection now recognizes `PROXY_MANAGED` in `config.toml`, cleanup only removes placeholder bearer tokens, config-only takeover writes are used consistently, and mis-categorized third-party providers no longer trigger the official-provider auth overwrite path. Provider sync and switching now treat the restore backup and live placeholders as the takeover-ownership signal (instead of the lagging `enabled` / proxy-running flags) and serialize switch/takeover per app, so a just-activated or proxy-stopped takeover can no longer be overwritten by a normal live write.
- **Codex Model Catalog Data Loss**: Fixed cases where Codex `modelCatalog` could be wiped by live-config backfill, active-provider edit dialogs, provider switches, or proxy takeover-off restore. Snapshot backups now keep existing `model_catalog_json` pointers, provider-rebuilt backups regenerate the catalog projection from the database source of truth, active edit dialogs prefer the DB catalog over lossy Live reconstruction, and provider switches always refresh the generated catalog JSON.
- **Codex Chat Tools Over Chat Completions Routing**: Restored Codex `tool_search`, loaded namespace tools, custom tools, and tool outputs when third-party Codex providers are routed through Chat Completions. Non-streaming and streaming Chat responses now map back to the right Responses item types, including native `response.custom_tool_call_input.*` events for custom-tool streaming.
- **Codex Proxy Error Diagnostics**: Codex forwarding failures now return richer JSON errors with provider, model, endpoint, upstream status, stable `cc_switch_*` codes, and HTTP statuses aligned with the canonical `ProxyError` response mapping.
- **Codex Native Balance / Coding-Plan Queries**: Fixed native usage and plan lookups so each app resolves the correct provider credentials instead of leaking assumptions from another app surface.
- **Codex CLI Discovery and Catalog Projection**: Fixed third-party Codex catalog projection that could fail when the Codex CLI was not reachable through one narrow PATH lookup, by adding multi-platform CLI discovery plus a bundled GPT-5.5 model-catalog template fallback.
- **Claude Desktop Official Provider Creation**: Fixed adding the Claude Desktop Official provider when the official category/config path was selected.
- **Anthropic Tool Thinking History for Kimi / Moonshot**: Added Kimi and Moonshot to the Anthropic-compatible tool-thinking history normalizer so later turns can replay reasoning and tool-call context correctly.
- **Windows Tool Version Probing**: Fixed Windows version checks that could misquote `.cmd` / `.bat` commands and decode localized command output as mojibake, causing working tools to appear as "installed but not runnable".
## [3.16.0] - 2026-05-29
+5 -5
View File
@@ -43,17 +43,17 @@ MiniMax-M2.7 is a next-generation large language model designed for autonomous e
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
<td>Thanks to Shengsuanyun for sponsoring this project! Shengsuanyun is a super factory serving AI Native Teams — an industrial-grade AI task parallel execution platform. Its model marketplace aggregates Claude, ChatGPT, Gemini, and other domestic and international LLM and multimedia model capabilities with direct supply. Absolutely no reverse engineering or dilution — platform-wide model SLA availability reaches 99.7%, with <a href="https://watch.shengsuanyun.com/status/shengsuanyun">monitoring dashboards</a> showing green across the board. It also offers enterprise-grade custom gateways for fine-grained team cost and permission management, smart routing, security protection, and BYOK (Bring Your Own Key) hosting. The platform charges on a pay-per-use and tokens plan (coming soon) basis, with invoicing available. Register via <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">this link</a> as a new user to receive ¥10 in credits plus a 10% bonus on your first top-up.</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support.
Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CC Switch users: register via <a href="https://www.aicodemirror.com/register?invitecode=9915W3">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
<td>Thanks to Shengsuanyun for sponsoring this project! Shengsuanyun is a super factory serving AI Native Teams — an industrial-grade AI task parallel execution platform. Its model marketplace aggregates Claude, ChatGPT, Gemini, and other domestic and international LLM and multimedia model capabilities with direct supply. Absolutely no reverse engineering or dilution — platform-wide model SLA availability reaches 99.7%, with <a href="https://watch.shengsuanyun.com/status/shengsuanyun">monitoring dashboards</a> showing green across the board. It also offers enterprise-grade custom gateways for fine-grained team cost and permission management, smart routing, security protection, and BYOK (Bring Your Own Key) hosting. The platform charges on a pay-per-use and tokens plan (coming soon) basis, with invoicing available. Register via <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">this link</a> as a new user to receive ¥10 in credits plus a 10% bonus on your first top-up.</td>
</tr>
<tr>
<td width="180"><a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/"><img src="assets/partners/logos/pateway.png" alt="PatewayAI" width="150"></a></td>
<td>Thanks to PatewayAI for sponsoring this project! PatewayAI is an API relay service provider built for heavy AI developers, focused on directly relaying official high-quality model APIs. It offers the full Claude lineup and the Codex series, 100% sourced from official channels — no dilution, no fakes, verification welcome. Billing is transparent and every token-level invoice can be audited line by line.
+5 -5
View File
@@ -43,17 +43,17 @@ MiniMax-M2.7 ist ein großes Sprachmodell der nächsten Generation, das auf auto
<td>Danke an AIGoCode für die Unterstützung dieses Projekts! AIGoCode ist eine All-in-One-Plattform, die Claude Code, Codex und die neuesten Gemini-Modelle integriert und Ihnen stabile, effiziente und äußerst kostengünstige KI-Coding-Dienste bietet. Die Plattform stellt flexible Abonnementpläne bereit, birgt kein Risiko einer Kontosperrung, ermöglicht Direktzugriff ohne VPN und reagiert blitzschnell. AIGoCode hat ein besonderes Angebot für CC-Switch-Nutzer vorbereitet: Wenn Sie sich über <a href="https://aigocode.com/invite/CC-SWITCH">diesen Link</a> registrieren, erhalten Sie bei Ihrer ersten Aufladung zusätzliche 10 % Bonusguthaben!</td>
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
<td>Danke an Shengsuanyun für die Unterstützung dieses Projekts! Shengsuanyun ist eine Superfabrik für KI-native Teams — eine Plattform zur parallelen Ausführung von KI-Aufgaben in industrieller Qualität. Ihr Modellmarktplatz bündelt die Fähigkeiten von Claude, ChatGPT, Gemini und weiteren in- und ausländischen LLM- und Multimedia-Modellen mit Direktbezug. Absolut kein Reverse Engineering und keine Verwässerung — die plattformweite Modell-SLA-Verfügbarkeit erreicht 99,7 %, und die <a href="https://watch.shengsuanyun.com/status/shengsuanyun">Monitoring-Dashboards</a> zeigen durchgehend grün an. Es bietet außerdem unternehmensgerechte, anpassbare Gateways für fein abgestufte Kosten- und Berechtigungsverwaltung im Team, intelligentes Routing, Sicherheitsschutz und BYOK-Hosting (Bring Your Own Key). Die Plattform rechnet nach Nutzung sowie über einen Token-Plan (in Kürze verfügbar) ab, und Rechnungsstellung ist möglich. Registrieren Sie sich über <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">diesen Link</a> als Neukunde und erhalten Sie ein Guthaben von ¥10 sowie 10 % Bonus auf Ihre erste Aufladung.</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>Danke an AICodeMirror für die Unterstützung dieses Projekts! AICodeMirror stellt offizielle, hochstabile Relay-Dienste für Claude Code / Codex / Gemini CLI bereit, mit unternehmensgerechter Nebenläufigkeit, schneller Rechnungsstellung und rund um die Uhr verfügbarem dediziertem technischem Support.
Offizielle Kanäle von Claude Code / Codex / Gemini zu 38 % / 2 % / 9 % des Originalpreises, mit zusätzlichen Rabatten beim Aufladen! AICodeMirror bietet besondere Vorteile für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://www.aicodemirror.com/register?invitecode=9915W3">diesen Link</a> und erhalten Sie 20 % Rabatt auf Ihre erste Aufladung; Unternehmenskunden erhalten bis zu 25 % Rabatt!</td>
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
<td>Danke an Shengsuanyun für die Unterstützung dieses Projekts! Shengsuanyun ist eine Superfabrik für KI-native Teams — eine Plattform zur parallelen Ausführung von KI-Aufgaben in industrieller Qualität. Ihr Modellmarktplatz bündelt die Fähigkeiten von Claude, ChatGPT, Gemini und weiteren in- und ausländischen LLM- und Multimedia-Modellen mit Direktbezug. Absolut kein Reverse Engineering und keine Verwässerung — die plattformweite Modell-SLA-Verfügbarkeit erreicht 99,7 %, und die <a href="https://watch.shengsuanyun.com/status/shengsuanyun">Monitoring-Dashboards</a> zeigen durchgehend grün an. Es bietet außerdem unternehmensgerechte, anpassbare Gateways für fein abgestufte Kosten- und Berechtigungsverwaltung im Team, intelligentes Routing, Sicherheitsschutz und BYOK-Hosting (Bring Your Own Key). Die Plattform rechnet nach Nutzung sowie über einen Token-Plan (in Kürze verfügbar) ab, und Rechnungsstellung ist möglich. Registrieren Sie sich über <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">diesen Link</a> als Neukunde und erhalten Sie ein Guthaben von ¥10 sowie 10 % Bonus auf Ihre erste Aufladung.</td>
</tr>
<tr>
<td width="180"><a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/"><img src="assets/partners/logos/pateway.png" alt="PatewayAI" width="150"></a></td>
<td>Danke an PatewayAI für die Unterstützung dieses Projekts! PatewayAI ist ein API-Relay-Anbieter für anspruchsvolle KI-Entwickler, der sich auf das direkte Relayen offizieller hochwertiger Modell-APIs konzentriert. Er bietet die komplette Claude-Reihe und die Codex-Serie, zu 100 % aus offiziellen Kanälen bezogen — keine Verwässerung, keine Fälschungen, Überprüfung ausdrücklich erwünscht. Die Abrechnung ist transparent, und jede Rechnung auf Token-Ebene lässt sich Zeile für Zeile prüfen.
+5 -5
View File
@@ -43,17 +43,17 @@ MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
<td>胜算雲(Shengsuanyun)のご支援に感謝します!胜算雲は AI ネイティブチーム向けのスーパーファクトリーであり、産業グレードの AI タスク並列実行プラットフォームです。モデルマーケットプレイスでは Claude、ChatGPT、Gemini をはじめとする国内外の LLM およびマルチメディアモデルの計算リソースを集約・直接提供。リバースエンジニアリングや品質低下は一切なく、プラットフォーム全体のモデル SLA 可用性は 99.7% に達し、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">監視ダッシュボード</a>は常時グリーン表示です。さらにエンタープライズ向けカスタムゲートウェイを提供し、チームのきめ細かなコスト・権限管理、スマートルーティング、セキュリティ保護、BYOK(自社キー持ち込み)ホスティングを実現します。従量課金およびトークンプラン(近日公開)対応で、請求書発行にも対応。<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">このリンク</a>から新規登録すると 10 元分のクレジットと初回チャージ 10% ボーナスが付与されます。</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定リレーサービスを提供しており、エンタープライズ級の同時接続、迅速な請求書発行、24時間年中無休の専用テクニカルサポートを備えています。
Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / 2% / 9%、チャージ時にはさらに割引!AICodeMirror は CC Switch ユーザー向けに特別特典を用意:<a href="https://www.aicodemirror.com/register?invitecode=9915W3">このリンク</a>から登録すると初回チャージ 20% オフ、法人のお客様は最大 25% オフ!</td>
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
<td>胜算雲(Shengsuanyun)のご支援に感謝します!胜算雲は AI ネイティブチーム向けのスーパーファクトリーであり、産業グレードの AI タスク並列実行プラットフォームです。モデルマーケットプレイスでは Claude、ChatGPT、Gemini をはじめとする国内外の LLM およびマルチメディアモデルの計算リソースを集約・直接提供。リバースエンジニアリングや品質低下は一切なく、プラットフォーム全体のモデル SLA 可用性は 99.7% に達し、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">監視ダッシュボード</a>は常時グリーン表示です。さらにエンタープライズ向けカスタムゲートウェイを提供し、チームのきめ細かなコスト・権限管理、スマートルーティング、セキュリティ保護、BYOK(自社キー持ち込み)ホスティングを実現します。従量課金およびトークンプラン(近日公開)対応で、請求書発行にも対応。<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">このリンク</a>から新規登録すると 10 元分のクレジットと初回チャージ 10% ボーナスが付与されます。</td>
</tr>
<tr>
<td width="180"><a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/"><img src="assets/partners/logos/pateway.png" alt="PatewayAI" width="150"></a></td>
<td>PatewayAI のご支援に感謝します!PatewayAI はヘビーな AI 開発者向けに、公式直結の高品質モデル API 中継サービスを専門に提供するプロバイダーです。Claude シリーズ全モデルおよび Codex シリーズに対応し、100% 公式ソースから直接提供。混ぜ物・水増しは一切なく、検証も歓迎します。課金は透明で、トークン単位の請求書を 1 件ずつ照合可能です。
+5 -5
View File
@@ -43,17 +43,17 @@ MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
<td>感谢胜算云赞助了本项目!胜算云是专为AI Native Teams服务的超级工厂,工业级AI任务并行执行平台,模型商城集采直供聚合接入了Claude、Chatgpt、Gemini等海内外LLM及图片视频多媒体模型算力,绝无逆向掺水、全站模型SLA可用性高达99.7%、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">监测接口</a>日常全绿。更有企业级专属定制网关,实现团队精细化成本与权限管控,智能路由+安全防护+BYOK企业自带密钥托管。平台按量及tokens plan(即将上线)计费,可开票,使用<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">此链接</a>注册新用户可获10元模力及首充10%赠送。</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。
Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CCSwitch 的用户提供了特别福利,通过<a href="https://www.aicodemirror.com/register?invitecode=9915W3">此链接</a>注册的用户,可享受首充8折,企业客户最高可享 7.5 折!</td>
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
<td>感谢胜算云赞助了本项目!胜算云是专为AI Native Teams服务的超级工厂,工业级AI任务并行执行平台,模型商城集采直供聚合接入了Claude、Chatgpt、Gemini等海内外LLM及图片视频多媒体模型算力,绝无逆向掺水、全站模型SLA可用性高达99.7%、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">监测接口</a>日常全绿。更有企业级专属定制网关,实现团队精细化成本与权限管控,智能路由+安全防护+BYOK企业自带密钥托管。平台按量及tokens plan(即将上线)计费,可开票,使用<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">此链接</a>注册新用户可获10元模力及首充10%赠送。</td>
</tr>
<tr>
<td width="180"><a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/"><img src="assets/partners/logos/pateway.png" alt="PatewayAI" width="150"></a></td>
<td>感谢 PatewayAI 赞助了本项目!PatewayAI 是一家面向重度 AI 开发者、专注官方直连高品质模型 API 中转服务商。提供 Claude 全系列与 Codex 系列模型,100% 官方源直供,不掺假不注水,欢迎检验。计费透明,Token 级账单可逐笔核验。
@@ -0,0 +1,101 @@
# Using DeepSeek-Style Chat APIs in Codex: CC Switch Local Routing Guide
> Applies to CC Switch 3.16.0 and nearby versions. This guide is based on the repository documentation and code, and uses DeepSeek as an example of an OpenAI Chat Completions-compatible API. Screenshots are generated from the current frontend UI with de-identified sample data to avoid exposing a real API key or account balance.
## Why local routing is needed
The newer Codex CLI targets the OpenAI Responses API, while DeepSeek, Kimi, MiniMax, SiliconFlow, and many other providers expose the OpenAI Chat Completions shape, usually `/chat/completions`. These two protocols use different request bodies, streaming events, and response structures. If you put a Chat endpoint directly into Codex configuration, common results include an incorrect model list, 404/400 requests, or streaming responses that Codex cannot parse correctly.
CC Switch solves this by making Codex always talk to a local route and continue sending Responses API requests. The route detects whether the active provider is Chat-format, rewrites the request into Chat Completions for the upstream provider, and finally converts the Chat response back into the Responses shape that Codex understands.
![Needs routing marker in the Codex provider list](../images/codex-deepseek-routing/01-codex-providers-require-routing.png)
The chain has four main steps:
1. When Codex routing is enabled, the local configuration is written as `http://127.0.0.1:15721/v1`, while `wire_api = "responses"` is kept in place.
2. The provider's `meta.apiFormat = "openai_chat"` tells the route that the real upstream is Chat Completions.
3. The route rewrites `/responses` or `/v1/responses` to `/chat/completions`, and converts the Responses request body into a Chat request body.
4. After the upstream responds, the route converts the Chat JSON or SSE stream back into Responses JSON/SSE.
## Prerequisites
Prepare these three things first:
- CC Switch installed and able to start.
- Codex CLI installed and run at least once, so the `~/.codex/config.toml` directory structure exists.
- An API key from DeepSeek or another Chat Completions provider.
DeepSeek's official documentation currently lists the OpenAI-compatible base URL as `https://api.deepseek.com` (other providers often use a base URL with a `/v1` suffix), and the Chat API path as `/chat/completions`. CC Switch's DeepSeek preset already contains these details, so prefer the preset and do not manually assemble the endpoint path.
## Step 1: Add a Codex provider
Open CC Switch, switch to the top-level `Codex` tab, and click the plus button in the upper-right corner to add a provider.
Choose the built-in `DeepSeek` preset. You only need to do two things:
- Enter your DeepSeek API key.
- Save the provider.
![Local routing mapping in the DeepSeek Codex provider form](../images/codex-deepseek-routing/02-deepseek-codex-routing-form.png)
The preset already includes DeepSeek's request base URL, default model, model menu, thinking/reasoning parameters, and automatically enables `Needs Local Routing`. You can adjust the default model or model display names if needed; the protocol conversion is handled by the routing layer.
## Step 2: Enable local routing and route Codex
Go to the `Routing` page in Settings, expand `Local Routing`, and complete two toggles:
1. Turn on the main routing switch to start the local service. The default address is `127.0.0.1:15721`.
2. Turn on `Codex` under `Routing Enabled`. If you only want Codex to use local routing, you can leave Claude and Gemini off.
![Enabling Codex routing on the local routing page](../images/codex-deepseek-routing/03-local-route-codex-takeover.png)
After routing is enabled, CC Switch points Codex's live configuration to the local route and manages authentication with a placeholder. The real DeepSeek key stays in the CC Switch provider configuration and is injected by the local route while forwarding requests, so you do not need to expose the key in Codex's live configuration.
## Step 3: Switch providers and restart Codex
Return to the Codex provider list and click `Enable` on the DeepSeek provider. If you see the `Needs Routing` marker, that provider must be used while routing is running; when the route is not started, CC Switch shows a prompt saying the routing service is required.
After switching, restart the current Codex terminal session. This is recommended because:
- The Codex process may already have read the old `config.toml`.
- After `model_catalog_json` is generated, the `/model` menu usually needs a fresh process before it refreshes.
Inside Codex, use `/model` to check whether the current model comes from the DeepSeek preset, such as `DeepSeek V4 Flash`. The Codex app currently does not support multi-model selection, so it defaults to the first configured model. Then send a small test prompt and confirm that the request count increases in the routing panel, or that a Codex request appears in usage/request logs.
## How to handle other Chat providers
DeepSeek, Kimi, MiniMax, SiliconFlow, and other common Chat-format providers already have presets in CC Switch, so use presets first. Only choose custom configuration for providers that are not covered by presets; in that case, fill in the API key, base URL, and models according to the provider's documentation, and set `API Format` to `OpenAI Chat Completions (requires routing)`.
If the upstream provider directly supports the OpenAI Responses API, you do not need to enable `Needs Local Routing`; CC Switch can connect through Responses directly without Chat conversion.
## FAQ
**Codex reports 404 or cannot find `/responses`**
Usually Codex routing is not enabled, or the upstream Chat base URL was written directly into Codex manually. Check whether `~/.codex/config.toml` points to `http://127.0.0.1:15721/v1`.
**DeepSeek upstream reports 404**
If you are using the built-in DeepSeek preset, first confirm that the active provider really comes from the preset and that Codex routing is enabled. Only custom providers require extra base URL checks: the base URL should be the service root, not the full endpoint path with `/chat/completions`.
**`/model` does not show DeepSeek models**
Restart Codex after saving the provider. CC Switch generates `cc-switch-model-catalog.json` and writes its path to `model_catalog_json`, but a running Codex process may not hot-load the model catalog.
The Codex app currently does not support multi-model selection, so it uses the first configured model by default.
**Routing is enabled, but requests still go to the wrong provider**
Confirm that all three states match: the current provider under the Codex tab is DeepSeek; the local routing service is running; and the Codex toggle is enabled under `Routing Enabled`.
**Can I use an official OpenAI Codex account through local routing?**
Not recommended. CC Switch blocks switching to official providers while local routing takeover is enabled, because accessing official APIs through a proxy may create account risk. Routing is mainly intended for third-party, aggregator, or protocol-conversion scenarios.
## References
- [CC Switch User Manual: Add Provider](../user-manual/en/2-providers/2.1-add.md)
- [CC Switch User Manual: Proxy Service](../user-manual/en/4-proxy/4.1-service.md)
- [CC Switch User Manual: App Routing](../user-manual/en/4-proxy/4.2-routing.md)
- [DeepSeek API Docs: Your First API Call](https://api-docs.deepseek.com/)
- [DeepSeek API Docs: Create Chat Completion](https://api-docs.deepseek.com/api/create-chat-completion)
- [DeepSeek API Docs: Multi-round Conversation](https://api-docs.deepseek.com/guides/multi_round_chat)
@@ -0,0 +1,101 @@
# Codex で DeepSeek などの Chat 形式 API を使う: CC Switch ローカルルーティングガイド
> 対象バージョン: CC Switch 3.16.0 およびその前後のバージョン。本記事はリポジトリ内のドキュメントとコードをもとに整理し、OpenAI Chat Completions 互換 API の例として DeepSeek を使用します。スクリーンショットは現在のフロントエンド UI から、実際の API Key やアカウント残高が漏れないよう匿名化したサンプルデータで生成しています。
## ローカルルーティングが必要な理由
新しい Codex CLI は OpenAI Responses API を前提にしています。一方で DeepSeek、Kimi、MiniMax、SiliconFlow など多くのプロバイダーが実際に公開しているのは OpenAI Chat Completions 形式、つまり `/chat/completions` です。この 2 つのプロトコルは、リクエストボディ、ストリーミングイベント、レスポンス構造が異なります。Chat エンドポイントをそのまま Codex 設定に入れると、モデル一覧が合わない、リクエストが 404/400 になる、ストリーミングレスポンスを Codex が正しく解析できない、といった問題が起きがちです。
CC Switch では、Codex が常にローカルルートへ接続し、Responses API のままリクエストを送るようにします。ルート内部で現在のプロバイダーが Chat 形式かどうかを判定し、必要ならリクエストを Chat Completions に書き換えて上流へ送り、最後に Chat レスポンスを Codex が理解できる Responses 形式へ戻します。
![Codex プロバイダー一覧のローカルルーティング必須マーク](../images/codex-deepseek-routing/01-codex-providers-require-routing.png)
この経路は主に 4 つのステップに分かれます:
1. Codex ルーティングを有効にすると、ローカル設定は `http://127.0.0.1:15721/v1` に書き換えられ、`wire_api = "responses"` は維持されます。
2. Provider の `meta.apiFormat = "openai_chat"` が、実際の上流は Chat Completions だとルートに伝えます。
3. ルートは `/responses` または `/v1/responses``/chat/completions` に書き換え、Responses のリクエストボディを Chat のリクエストボディへ変換します。
4. 上流から返ってきた後、ルートは Chat の JSON または SSE ストリームを Responses JSON/SSE へ変換して返します。
## 事前準備
先に次の 3 つを用意してください:
- インストール済みで起動できる CC Switch。
- インストール済みの Codex CLI。少なくとも 1 回は実行し、`~/.codex/config.toml` のディレクトリ構造が存在していること。
- DeepSeek または同種の Chat Completions プロバイダーの API Key。
DeepSeek 公式ドキュメントでは、OpenAI 互換 base URL は現在 `https://api.deepseek.com`(他のプロバイダーでは `/v1` 付きの base URL もよくあります)、Chat API のパスは `/chat/completions` と記載されています。CC Switch の DeepSeek プリセットにはこれらの情報がすでに入っているため、まずはプリセットを使い、エンドポイントパスを手で組み立てる必要はありません。
## Step 1: Codex プロバイダーを追加する
CC Switch を開き、上部の `Codex` タブへ切り替え、右上のプラスボタンからプロバイダーを追加します。
内蔵プリセットの `DeepSeek` を選びます。必要なのは次の 2 つだけです:
- DeepSeek API Key を入力する。
- プロバイダーを保存する。
![DeepSeek Codex プロバイダーフォームのローカルルーティング設定](../images/codex-deepseek-routing/02-deepseek-codex-routing-form.png)
プリセットには DeepSeek のリクエスト先、デフォルトモデル、モデルメニュー、thinking/reasoning パラメータがすでに含まれており、`ローカルルーティングが必要` も自動的に有効になります。必要に応じてデフォルトモデルやモデル表示名を調整できますが、プロトコル変換はルーティング層に任せれば十分です。
## Step 2: ローカルルーティングを有効にして Codex をルーティングする
設定の `ルーティング` ページに入り、`ローカルルーティング` を展開して、次の 2 つのスイッチを設定します:
1. `ルーティング総スイッチ` をオンにしてローカルサービスを起動します。デフォルトアドレスは `127.0.0.1:15721` です。
2. `ルーティング有効``Codex` をオンにします。Codex だけをルーティングしたい場合は、Claude と Gemini はオフのままで構いません。
![ローカルルーティング画面で Codex ルーティングを有効化](../images/codex-deepseek-routing/03-local-route-codex-takeover.png)
ルーティングを有効にすると、CC Switch は Codex の live 設定をローカルルートへ向け、認証はプレースホルダーで管理します。実際の DeepSeek Key は CC Switch の Provider 設定内に残り、ローカルルートが転送時に注入します。そのため、Codex の live 設定に Key を露出させる必要はありません。
## Step 3: プロバイダーを切り替えて Codex を再起動する
Codex プロバイダー一覧に戻り、DeepSeek プロバイダーの `有効化` をクリックします。`ルーティングが必要` の表示が見える場合、そのプロバイダーはルーティング実行中に使う必要があります。ルーティングが起動していない場合、CC Switch は「ルーティングサービスが必要」という趣旨のメッセージを表示します。
切り替え後は、現在の Codex ターミナルセッションを再起動することをおすすめします。理由は次のとおりです:
- Codex プロセスがすでに古い `config.toml` を読み込んでいる可能性があります。
- `model_catalog_json` の生成後、`/model` メニューの更新には通常、新しいプロセスが必要です。
Codex に入ったら、`/model` で現在のモデルが DeepSeek プリセット由来かどうかを確認します。たとえば `DeepSeek V4 Flash` などです。現在の Codex app は複数モデル選択に対応していないため、設定内の最初のモデルをデフォルトで使用します。その後、小さな質問を 1 つ送って、ルーティングパネルのリクエスト数が増えるか、usage / リクエストログに Codex リクエストが出るかを確認します。
## 他の Chat プロバイダーの場合
DeepSeek、Kimi、MiniMax、SiliconFlow など一般的な Chat 形式プロバイダーは CC Switch にプリセットがあるため、まずはプリセットを使ってください。プリセットにないプロバイダーだけ、カスタム設定を選びます。その場合は相手側のドキュメントに従って API Key、base URL、モデルを入力し、`API 形式``OpenAI Chat Completions (ルーティングが必要)` に設定します。
上流が OpenAI Responses API を直接サポートしている場合は、`ローカルルーティングが必要` を有効にする必要はありません。その場合、CC Switch は Responses のまま直結でき、Chat 変換は行いません。
## よくある質問
**Codex が 404 を返す、または `/responses` が見つからない**
多くの場合、Codex ルーティングが有効になっていないか、上流 Chat base URL を手動で Codex に直接書いています。`~/.codex/config.toml``http://127.0.0.1:15721/v1` を指しているか確認してください。
**DeepSeek 上流が 404 を返す**
内蔵 DeepSeek プリセットを使っている場合は、まず現在のプロバイダーが本当にプリセット由来であること、そして Codex ルーティングが有効であることを確認してください。カスタムプロバイダーを使っている場合だけ、base URL を追加で確認します。base URL はサービスのルートであり、`/chat/completions` 付きの完全なエンドポイントパスではありません。
**`/model` に DeepSeek モデルが表示されない**
プロバイダーを保存した後、Codex を再起動してください。CC Switch は `cc-switch-model-catalog.json` を生成し、そのパスを `model_catalog_json` に書き込みますが、実行中の Codex プロセスがモデルカタログをホットロードするとは限りません。
現在の Codex app は複数モデル選択に対応していないため、設定内の最初のモデルをデフォルトで使用します。
**ルーティングを有効にしたのに、リクエストが別のプロバイダーへ行く**
次の 3 つの状態が一致しているか確認してください:Codex タブの現在のプロバイダーが DeepSeek であること、ローカルルーティングサービスが実行中であること、`ルーティング有効` で Codex スイッチがオンであること。
**公式 OpenAI Codex アカウントをローカルルーティング経由で使えますか**
おすすめしません。CC Switch はローカルルーティング有効中、公式プロバイダーへの切り替えをブロックします。プロキシ経由で公式 API にアクセスすると、アカウントリスクが発生する可能性があるためです。ルーティングは主にサードパーティ、集約サービス、またはプロトコル変換のための機能です。
## 参考リンク
- [CC Switch ユーザーマニュアル: プロバイダーの追加](../user-manual/ja/2-providers/2.1-add.md)
- [CC Switch ユーザーマニュアル: プロキシサービス](../user-manual/ja/4-proxy/4.1-service.md)
- [CC Switch ユーザーマニュアル: アプリケーションルーティング](../user-manual/ja/4-proxy/4.2-routing.md)
- [DeepSeek API Docs: Your First API Call](https://api-docs.deepseek.com/)
- [DeepSeek API Docs: Create Chat Completion](https://api-docs.deepseek.com/api/create-chat-completion)
- [DeepSeek API Docs: Multi-round Conversation](https://api-docs.deepseek.com/guides/multi_round_chat)
@@ -0,0 +1,101 @@
# 在 Codex 中用 DeepSeek 这类 Chat 格式 APICC Switch 本地路由攻略
> 适用版本:CC Switch 3.16.0 及附近版本。本文根据仓库内文档与代码整理,并用 DeepSeek 作为 OpenAI Chat Completions 兼容接口的示例。截图来自当前前端界面,使用去敏示例数据生成,避免泄露真实 API Key 或账户余额。
## 为什么需要本地路由
新版 Codex CLI 面向的是 OpenAI Responses API,而 DeepSeek、Kimi、MiniMax、SiliconFlow 等很多供应商实际暴露的是 OpenAI Chat Completions 形态,也就是 `/chat/completions`。这两种协议的请求体、流式事件和返回结构不同,直接把 Chat 接口填进 Codex 配置里,常见结果就是模型列表不对、请求 404/400,或者流式响应无法被 Codex 正确解析。
CC Switch 的做法是让 Codex 始终连本机路由,仍以 Responses API 发送请求;路由在内部识别当前供应商是否是 Chat 格式,再把请求改写成 Chat Completions 发给上游,最后把 Chat 响应转换回 Responses 形态返回给 Codex。
![Codex 供应商列表里的需要路由标记](../images/codex-deepseek-routing/01-codex-providers-require-routing.png)
这条链路主要分成四步:
1. Codex 接管时,本地配置会被写成 `http://127.0.0.1:15721/v1`,并强制保持 `wire_api = "responses"`
2. Provider 的 `meta.apiFormat = "openai_chat"` 会告诉路由:真实上游是 Chat Completions。
3. 路由把 `/responses``/v1/responses` 改写到 `/chat/completions`,并把 Responses 请求体转换成 Chat 请求体。
4. 上游返回后,路由再把 Chat 的 JSON 或 SSE 转回 Codex 能理解的 Responses JSON/SSE。
## 准备工作
你需要先准备好三样东西:
- 已安装并能启动的 CC Switch。
- 已安装 Codex CLI,并至少运行过一次,让 `~/.codex/config.toml` 目录结构存在。
- DeepSeek 或同类 Chat Completions 供应商的 API Key。
DeepSeek 官方文档目前写明 OpenAI 兼容 base URL 是 `https://api.deepseek.com`(其他供应商常见的是带 `/v1` 后缀的 base URL),Chat API 路径是 `/chat/completions`CC Switch 的 DeepSeek 预设已经按这些信息配好,请优先使用预设,不需要手动拼接口路径。
## 第一步:添加 Codex 供应商
打开 CC Switch,切到顶部的 `Codex` 标签,点击右上角的加号添加供应商。
选择内置预设里的 `DeepSeek`,只需要做两件事:
- 填入 DeepSeek API Key。
- 保存供应商。
![DeepSeek Codex 供应商表单中的本地路由映射](../images/codex-deepseek-routing/02-deepseek-codex-routing-form.png)
预设已经内置 DeepSeek 的请求地址、默认模型、模型菜单、thinking/reasoning 参数,并会自动打开 `需要本地路由映射`。你可以按需调整默认模型或模型显示名;协议转换交给路由层完成即可。
## 第二步:开启本地路由并接管 Codex
进入设置里的 `路由` 页面,展开 `本地路由`,完成两个开关:
1. 打开 `路由总开关`,启动本地服务。默认地址是 `127.0.0.1:15721`
2.`路由启用` 中打开 `Codex`。如果只想让 Codex 走路由,可以保持 Claude、Gemini 关闭。
![本地路由页面中启用 Codex 接管](../images/codex-deepseek-routing/03-local-route-codex-takeover.png)
接管后,CC Switch 会把 Codex 的 live 配置指向本机路由,并用占位符管理认证。真实 DeepSeek Key 仍保存在 CC Switch 的 Provider 配置里,由本地路由在转发时注入,不需要你把 Key 暴露给 Codex live 配置。
## 第三步:切换供应商并重启 Codex
回到 Codex 供应商列表,点击 DeepSeek 供应商的 `启用`。如果看到 `需要路由` 标记,说明这个供应商必须在路由运行时使用;没有启动路由时,CC Switch 会弹出“需要路由服务才能正常使用”的提示。
切换后建议重启当前 Codex 终端会话。原因是:
- Codex 进程可能已经读取过旧的 `config.toml`
- `model_catalog_json` 生成后,`/model` 菜单通常需要新进程才能刷新。
进入 Codex 后,可以用 `/model` 查看当前模型是否来自 DeepSeek 预设,例如 `DeepSeek V4 Flash`。目前 Codex app 不支持多模型选择时,会默认使用配置里的第一个模型。随后发一个小问题,确认路由面板的请求数增长,或者在用量/请求日志里看到 Codex 请求即可。
## 其它 Chat 供应商怎么处理
DeepSeek、Kimi、MiniMax、SiliconFlow 等常见 Chat 格式供应商在 CC Switch 里已有预设,优先用预设即可。只有预设里没有的供应商,才需要选择自定义配置;这时按对方文档填 API Key、base URL 和模型,并把 `API 格式` 选为 `OpenAI Chat Completions (需开启路由)`
如果上游直接支持 OpenAI Responses API,就不需要打开 `需要本地路由映射`;这时 CC Switch 可以按 Responses 直连,不做 Chat 转换。
## 常见问题
**Codex 报 404 或找不到 `/responses`**
通常是没有开启 Codex 接管,或者你手动把上游 Chat base URL 直接写给了 Codex。检查 `~/.codex/config.toml` 是否指向 `http://127.0.0.1:15721/v1`
**DeepSeek 上游报 404**
如果用的是内置 DeepSeek 预设,先确认当前供应商确实来自预设,并且 Codex 路由已启用。只有在使用自定义供应商时,才需要额外检查 base URL:它应该是服务根地址,而不是带 `/chat/completions` 的完整接口路径。
**`/model` 看不到 DeepSeek 模型**
保存供应商后重启 Codex。CC Switch 会生成 `cc-switch-model-catalog.json` 并把路径写入 `model_catalog_json`,但正在运行的 Codex 进程不一定会热加载模型目录。
目前 Codex app 不支持多模型选择,默认使用配置的第一个模型。
**开了路由但请求仍走错供应商**
确认三处状态一致:Codex 标签下当前供应商是 DeepSeek;本地路由服务正在运行;`路由启用` 里 Codex 开关已打开。
**可以用官方 OpenAI Codex 账号走本地路由吗**
不建议。CC Switch 会在本地路由接管模式下阻止切到官方供应商,因为用代理访问官方 API 可能带来账号风险。路由主要用于第三方、聚合或协议转换场景。
## 参考链接
- [CC Switch 用户手册:添加供应商](../user-manual/zh/2-providers/2.1-add.md)
- [CC Switch 用户手册:代理服务](../user-manual/zh/4-proxy/4.1-service.md)
- [CC Switch 用户手册:应用路由](../user-manual/zh/4-proxy/4.2-routing.md)
- [DeepSeek API 文档:Your First API Call](https://api-docs.deepseek.com/)
- [DeepSeek API 文档:Create Chat Completion](https://api-docs.deepseek.com/api/create-chat-completion)
- [DeepSeek API 文档:Multi-round Conversation](https://api-docs.deepseek.com/guides/multi_round_chat)
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

+10 -9
View File
@@ -6,6 +6,16 @@
---
## Usage Guide
The two headline capabilities in this release are **Codex third-party provider Chat Completions routing** and **in-app managed CLI tool management**. If you want providers that only speak the OpenAI Chat protocol (DeepSeek, Kimi, MiniMax, etc.) to work directly in Codex, or want to install / upgrade CLI tools from one place inside the app, start with these guides:
- **[Using DeepSeek in Codex: local routing hands-on guide](../guides/codex-deepseek-routing-guide-en.md)** — uses the built-in DeepSeek preset to walk through adding a Codex provider, enabling local routing, and verifying request forwarding.
- **[Add a Codex provider: Chat Completions routing and model mapping](../user-manual/en/2-providers/2.1-add.md)** — covers the "Needs Local Routing" toggle, the model mapping table, and reasoning (thinking) auto-detection.
- **[Settings → About: managed CLI tool management](../user-manual/en/1-getting-started/1.5-settings.md)** — covers version detection, per-tool / update-all upgrades, conflict diagnostics, and source-anchored upgrade commands.
---
> [!WARNING]
>
> ## Only Official Channels (Please Read)
@@ -24,15 +34,6 @@
---
## Usage Guide
The two headline capabilities in this release are **Codex third-party provider Chat Completions routing** and **in-app managed CLI tool management**. If you want providers that only speak the OpenAI Chat protocol (DeepSeek, Kimi, MiniMax, etc.) to work directly in Codex, or want to install / upgrade CLI tools from one place inside the app, start with these two:
- **[Add a Codex provider: Chat Completions routing and model mapping](../user-manual/en/2-providers/2.1-add.md)** — covers the "Needs Local Routing" toggle, the model mapping table, and reasoning (thinking) auto-detection.
- **[Settings → About: managed CLI tool management](../user-manual/en/1-getting-started/1.5-settings.md)** — covers version detection, per-tool / update-all upgrades, conflict diagnostics, and source-anchored upgrade commands.
---
## Overview
CC Switch v3.16.0's development since v3.15.0 centers on **promoting third-party Codex providers to first-class citizens through Chat Completions routing**. Codex natively only speaks the OpenAI Responses API and GPT-family models; this release lets CC Switch's local proxy convert Codex's outgoing Responses requests into Chat Completions and rebuild the JSON and SSE streaming responses back into Responses shape, preserving `reasoning_content` / inline `<think>` blocks / streamed reasoning summaries / tool calls / `previous_response_id` follow-ups along the way, normalizing error envelopes, and probing Chat-format providers correctly in Stream Check. It ships 22 Chat-routing presets with explicit model catalogs (DeepSeek, Zhipu GLM, Kimi, MiniMax, StepFun, Baidu Qianfan, Bailian, ModelScope, Longcat, BaiLing, Xiaomi MiMo, Volcengine Agentplan, BytePlus, DouBao Seed, SiliconFlow, Novita AI, Nvidia, and more).
+10 -9
View File
@@ -6,6 +6,16 @@
---
## 利用ガイド
本リリースの主役となる 2 つの機能は、**Codex サードパーティプロバイダーの Chat Completions ルーティング**と**アプリ内の管理対象 CLI ツール管理**です。OpenAI Chat プロトコルにしか対応していないプロバイダー(DeepSeek、Kimi、MiniMax など)を Codex で直接使いたい場合、またはアプリ内で CLI ツールの一括インストール / アップグレードをしたい場合は、まずこちらをご覧ください:
- **[Codex で DeepSeek を使う: ローカルルーティング実践ガイド](../guides/codex-deepseek-routing-guide-ja.md)** —— DeepSeek 内蔵プリセットを例に、Codex プロバイダーの追加、ローカルルーティングの有効化、リクエスト転送の確認までを説明します。
- **[Codex プロバイダーの追加: Chat Completions ルーティングとモデルマッピング](../user-manual/ja/2-providers/2.1-add.md)** —— 「ローカルルーティングが必要」トグル、モデルマッピングテーブル、思考能力(reasoning)の自動判別までの一連の流れを説明しています。
- **[設定 → バージョン情報: 管理対象 CLI ツール管理](../user-manual/ja/1-getting-started/1.5-settings.md)** —— バージョン検出、個別アップグレード / 全体アップグレード、競合診断、インストール元にアンカーされたアップグレードコマンドを説明しています。
---
> [!WARNING]
>
> ## 唯一の公式チャネル(必ずお読みください)
@@ -24,15 +34,6 @@
---
## 利用ガイド
本リリースの主役となる 2 つの機能は、**Codex サードパーティプロバイダーの Chat Completions ルーティング**と**アプリ内蔵の受託 CLI ツール管理**です。OpenAI Chat プロトコルにしか対応していないプロバイダー(DeepSeek、Kimi、MiniMax など)を Codex で直接使いたい場合、またはアプリ内で CLI ツールの一括インストール / アップグレードをしたい場合は、まずこの 2 つをご覧ください:
- **[Codex プロバイダーの追加: Chat Completions ルーティングとモデルマッピング](../user-manual/ja/2-providers/2.1-add.md)** —— 「ローカルルーティングが必要」トグル、モデルマッピングテーブル、思考能力(reasoning)の自動判別までの一連の流れを説明しています。
- **[設定 → バージョン情報: 受託 CLI ツール管理](../user-manual/ja/1-getting-started/1.5-settings.md)** —— バージョン検出、個別アップグレード / 全体アップグレード、競合診断、インストール元にアンカーされたアップグレードコマンドを説明しています。
---
## 概要
CC Switch v3.16.0 の v3.15.0 以降の開発のコアは、**サードパーティ Codex プロバイダーを Chat Completions ルーティングによって一等市民へ昇格させること**です。Codex はネイティブには OpenAI Responses API と GPT 系モデルしか認識しませんが、本リリースでは CC Switch のローカルプロキシが Codex の送出する Responses リクエストを Chat Completions に変換し、JSON と SSE のストリーミングレスポンスを Responses 形態へ再構築します。その道中で `reasoning_content` / インライン `<think>` ブロック / ストリーミング推論サマリー / ツール呼び出し / `previous_response_id` の継続を保持し、エラーエンベロープを正規化し、Stream Check で Chat 形式プロバイダーを正しくプローブします。あわせて明示的なモデルカタログ付きの 22 個の Chat ルーティングプリセット(DeepSeek、Zhipu GLM、Kimi、MiniMax、StepFun、Baidu Qianfan、Bailian、ModelScope、Longcat、BaiLing、Xiaomi MiMo、Volcengine Agentplan、BytePlus、DouBao Seed、SiliconFlow、Novita AI、Nvidia など)を出荷します。
+10 -9
View File
@@ -6,6 +6,16 @@
---
## 使用攻略
本版本最主打的两块能力是 **Codex 第三方供应商 Chat Completions 路由**与**应用内受管 CLI 工具管理**。如果你想让 DeepSeek、Kimi、MiniMax 这类只支持 OpenAI Chat 协议的供应商在 Codex 里直接可用,或者想在应用内一站式安装 / 升级 CLI 工具,建议先读这几篇:
- **[在 Codex 中使用 DeepSeek:本地路由实战攻略](../guides/codex-deepseek-routing-guide-zh.md)** —— 以 DeepSeek 内置预设为例,演示从添加 Codex 供应商、开启本地路由到验证请求转发的完整路径。
- **[添加 Codex 供应商:Chat Completions 路由与模型映射](../user-manual/zh/2-providers/2.1-add.md)** —— 覆盖「需要本地路由映射」开关、模型映射表、思考能力(reasoning)自适应识别的完整流程。
- **[设置 → 关于:受管 CLI 工具管理](../user-manual/zh/1-getting-started/1.5-settings.md)** —— 覆盖版本检测、单独升级 / 全部升级、冲突诊断、按安装来源锚定的升级命令。
---
> [!WARNING]
>
> ## 唯一官方渠道声明(请务必阅读)
@@ -24,15 +34,6 @@
---
## 使用攻略
本版本最主打的两块能力是 **Codex 第三方供应商 Chat Completions 路由**与**应用内受管 CLI 工具管理**。如果你想让 DeepSeek、Kimi、MiniMax 这类只支持 OpenAI Chat 协议的供应商在 Codex 里直接可用,或者想在应用内一站式安装 / 升级 CLI 工具,建议先读这两篇:
- **[添加 Codex 供应商:Chat Completions 路由与模型映射](../user-manual/zh/2-providers/2.1-add.md)** —— 覆盖「需要本地路由映射」开关、模型映射表、思考能力(reasoning)自适应识别的完整流程。
- **[设置 → 关于:受管 CLI 工具管理](../user-manual/zh/1-getting-started/1.5-settings.md)** —— 覆盖版本检测、单独升级 / 全部升级、冲突诊断、按安装来源锚定的升级命令。
---
## 概览
CC Switch v3.16.0 自 v3.15.0 以来的开发核心,是把**第三方 Codex 供应商通过 Chat Completions 路由升级为一等公民**。Codex 原生只认 OpenAI Responses API 与 GPT 系列模型,本版本让 CC Switch 的本地代理把 Codex 发出的 Responses 请求转换为上游的 Chat Completions,再把 JSON 与 SSE 流式响应重建回 Responses 形态,沿途保留 `reasoning_content` / 内联 `<think>` 块 / 流式推理摘要 / 工具调用 / `previous_response_id` 续接状态,并把错误信封规范化、在 Stream Check 中正确探测 Chat 格式供应商。配套上货 22 个带显式模型目录的 Chat 路由预设(DeepSeek、智谱 GLM、Kimi、MiniMax、StepFun、百度千帆、百炼、ModelScope、Longcat、百灵、小米 MiMo、火山 Agentplan、BytePlus、豆包 Seed、SiliconFlow、Novita AI、Nvidia 等)。
+235
View File
@@ -0,0 +1,235 @@
# CC Switch v3.16.1
> Codex stability patch: because some users did not want CC Switch to change how Codex config files are written, Codex App Enhancements now has a switch and is off by default. After enabling it, you can keep using Codex mobile remote control, official plugins, and other official-app features while using third-party APIs; this release also includes a series of stability fixes.
**[中文版 →](v3.16.1-zh.md) | [日本語版 →](v3.16.1-ja.md)**
---
## Usage Guides
If you use Codex third-party providers, local routing takeover, or want to use DeepSeek / Kimi / GLM / MiniMax and other Chat Completions upstreams in Codex, start with these docs:
- **[Using DeepSeek in Codex: local routing hands-on guide](../guides/codex-deepseek-routing-guide-en.md)**: walks through adding a Codex provider, enabling local routing, and verifying request forwarding.
- **[Add a Codex provider: Chat Completions routing and model mapping](../user-manual/en/2-providers/2.1-add.md)**: covers the "Needs Local Routing" option, model mapping table, and reasoning capability configuration.
- **[Local Proxy Service](../user-manual/en/4-proxy/4.1-service.md)** and **[Local Routing](../user-manual/en/4-proxy/4.2-routing.md)**: explain the proxy service, live-config takeover, and related risk notes.
---
> [!WARNING]
>
> ## Only Official Channels (Please Read)
>
> CC Switch is a **fully free and open-source** desktop app, and we **do not charge users any fees**. Please only obtain the software through the official channels listed below:
>
> | Channel | Only Official |
> | ------------------ | ------------------------------------------------------------------------------ |
> | Website | **[ccswitch.io](https://ccswitch.io)** |
> | Source | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
> | Downloads | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
> | Author | **[@farion1231](https://github.com/farion1231)** |
> | Report an Imposter | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
>
> **Any "CC Switch" website or client that asks you for payment, top-ups, or login credentials is fake.** If you have been tricked into paying, stop the transaction immediately and file a report through GitHub Issues.
---
## Overview
CC Switch v3.16.1 is a Codex stability patch following v3.16.0. v3.16.0 promoted third-party Codex providers to first-class citizens through Chat Completions routing; this release focuses on several high-risk edges discovered in real use: official ChatGPT / Codex OAuth login state could be overwritten while switching third-party providers or during local routing takeover, the Codex model catalog could be cleared during live backfill, hot switching, takeover shutdown restore, or editing the active provider, and Codex `tool_search`, plugin / connector namespace tools, and custom tools were not fully restored back into Responses events on the Chat Completions upstream path.
This release also hardens local routing takeover ownership checks. Provider switching and takeover toggles now run serially per app. When deciding whether the live files are proxy-managed, CC Switch no longer relies only on stale `enabled` state or whether the proxy service is currently running; it also checks backups and proxy placeholders in the live files. This prevents ordinary live writes from overwriting proxy-managed config immediately after takeover is enabled, while the proxy is temporarily stopped, or during hot switching.
**Release date**: 2026-06-01
**Stats**: 23 commits | 62 files changed | +5,603 insertions | -1,113 deletions
---
## Highlights
- **Safer Codex OAuth and third-party provider switching**: added an optional official-auth preservation setting. When enabled, third-party provider tokens are written to `config.toml`, while official ChatGPT / Codex OAuth login stays in `auth.json`.
- **Codex model catalogs are no longer silently wiped**: `modelCatalog` now treats the database as the source of truth, avoiding overwrites from live configs with missing catalog projections during live backfill, provider switching, takeover shutdown restore, and provider editing.
- **Codex Chat tools / plugin routing restored**: `tool_search`, loaded namespace tools, and custom tools from Chat Completions upstreams are remapped back to Codex Responses shape; streaming custom tools now emit native `response.custom_tool_call_input.*` events.
- **More stable local routing takeover and hot switching**: provider switching and takeover toggles are serialized per app. Hot switching refreshes provider display information in Codex live config while keeping the endpoint pointed at the local proxy.
- **Diagnostics and platform compatibility fixes**: Codex proxy errors now include richer context; Codex CLI model-template discovery supports more platforms and falls back to a static GPT-5.5 template; Windows tool version detection fixes localized output and command quoting issues.
---
## Added
### Codex Official Auth Preservation Setting
Added an optional setting for preserving official ChatGPT / Codex OAuth login state when switching to third-party Codex providers. When enabled, CC Switch stores third-party provider API keys in the provider-scoped `experimental_bearer_token` inside Codex `config.toml` instead of overwriting the official login cache in `auth.json`.
Because some users do not want this feature to change how config files are written, the setting is off by default and keeps the compatibility behavior from before v3.16.0. Users who need both official Codex login and third-party providers can manually enable it under Settings -> Codex App Enhancements.
### Codex DeepSeek Routing Guide
Added Codex DeepSeek routing guides in Chinese, English, and Japanese, covering provider routing requirements, the DeepSeek Codex provider form, and screenshot-based local routing takeover instructions.
---
## Changed
### Codex Auth Preservation Is Now Opt-In
Official auth preservation is off by default. Third-party Codex provider switching therefore keeps the old behavior unless the user opts in, avoiding surprise changes to how `auth.json` / `config.toml` are written.
### Codex Restart Prompt After Provider Switching
Codex loads the model catalog and part of its config at startup. After successfully switching a Codex provider, the UI now reminds the user to restart Codex so model catalog and config changes actually take effect.
### Provider Switching and Takeover Toggles Are Serialized
Codex / Claude / Gemini provider switching and local routing takeover toggles now share a per-app lock, avoiding concurrent writes to live config and backups. Ownership checks also prioritize live backups and the `PROXY_MANAGED` placeholder instead of relying only on whether the proxy service is running.
### Codex Hot Switching Refreshes Display Info
When hot switching Codex providers during local routing takeover, CC Switch refreshes the provider id, model, and display name in the live config so the Codex client menu follows the active provider. The base URL still stays pointed at the local proxy, preventing the real upstream endpoint from leaking back into the live file.
---
## Fixed
### Codex Provider Editor Showing Live OAuth During Takeover
When Codex is under local routing takeover, live `auth.json` / `config.toml` are temporarily rewritten by the proxy. Editing the active provider from those live files could incorrectly show proxy placeholders or official OAuth login as provider config. The editor now explicitly explains that it is showing the provider config stored in the database, not the proxy-managed live files; even if the proxy service is temporarily stopped, CC Switch still treats the app as under takeover when the takeover state indicates so.
### Codex OAuth Cleared or Overwritten During Takeover
Fixed multiple preserve-mode takeover paths that could clear or overwrite official ChatGPT / Codex OAuth `auth.json`. Takeover detection now recognizes `PROXY_MANAGED` in `config.toml`, cleanup only removes proxy placeholder tokens, and third-party providers misclassified as official no longer enter the official-auth overwrite path. Provider sync and switching also treat live backups and placeholders as takeover ownership signals, preventing normal live writes from overwriting proxy config right after takeover or while the proxy is paused.
### Codex Model Catalog Data Loss
Fixed cases where `modelCatalog` could be cleared during live backfill, active-provider editing, provider switching, and takeover shutdown restore. Snapshot backups preserve existing `model_catalog_json` pointers; backups rebuilt from providers regenerate catalog projections from the database source of truth; editing the active provider now prefers the database model catalog instead of trusting a live reverse-parse result that may have lost its projection.
Provider switching also now always refreshes the generated Codex model catalog JSON ([#3360](https://github.com/farion1231/cc-switch/pull/3360), thanks [@Postroggy](https://github.com/Postroggy)).
### Codex Chat Tools, Plugins, and Custom Tools Restored
Fixed Chat Completions routing for third-party Codex providers so `tool_search`, loaded MCP / connector namespace tools, and custom tools are fully restored back into Codex Responses shape. Non-streaming and streaming Chat responses now recover the correct tool type, namespace, call id, and arguments from the original Responses request; custom-tool streaming now emits native `response.custom_tool_call_input.delta` and `response.custom_tool_call_input.done` events.
### Fuller Codex Proxy Error Diagnostics
When Codex forwarding fails, CC Switch now returns JSON errors that include provider, model, endpoint, upstream HTTP status, stable `cc_switch_*` error codes, and normalized HTTP status. This makes it much clearer which provider, endpoint, and upstream error caused the failure.
### Codex Native Balance / Coding Plan Credential Lookup
Fixed native balance and Coding Plan queries using credentials from the wrong app. Each app now resolves its own provider credentials instead of carrying authentication assumptions from another app surface into the query flow ([#3355](https://github.com/farion1231/cc-switch/pull/3355), thanks [@SiskonEmilia](https://github.com/SiskonEmilia)).
### Codex CLI Discovery and Model Catalog Template Fallback
Fixed a too-narrow Codex CLI discovery path for third-party Codex model catalog projection. The backend now searches common Codex CLI install locations across platforms, and falls back to a built-in GPT-5.5 model catalog template if no template can be found ([#3382](https://github.com/farion1231/cc-switch/pull/3382), thanks [@chofuhoyu](https://github.com/chofuhoyu)).
### Claude Desktop Official Provider Add Failure
Fixed an error when adding the Claude Desktop Official provider ([#3405](https://github.com/farion1231/cc-switch/pull/3405), thanks [@Eunknight](https://github.com/Eunknight)).
### Kimi / Moonshot Tool-Thinking History Normalization
Added Kimi / Moonshot to the Anthropic-compatible tool-thinking history normalizer. Later turns can now correctly replay reasoning and tool-call context, avoiding failures caused by history messages that do not match upstream requirements ([#3377](https://github.com/farion1231/cc-switch/pull/3377), thanks [@Neon-Wang](https://github.com/Neon-Wang)).
### Windows Tool Version Detection
Fixed incorrect quoting for `.cmd` / `.bat` version commands on Windows, and fixed localized command output being decoded as mojibake. Previously, these issues could make runnable tools appear as "installed but not runnable."
---
## Upgrade Notes
### Official OAuth Preservation Must Be Enabled Manually
If you want official ChatGPT / Codex OAuth login to stay in `auth.json` while you frequently switch third-party Codex providers, enable Codex official auth preservation in Settings. It is off by default to keep compatibility for existing users.
### Restart Codex After Editing Model Mappings
Codex reads `model_catalog_json` at startup. Even though v3.16.1 fixes model catalog wiping, Codex still needs to be restarted after you edit the model mapping table so the `/model` menu refreshes.
### During Takeover, the Editor Shows Stored Config, Not Live Files
When local routing takeover is enabled, live `auth.json` / `config.toml` temporarily point to the CC Switch proxy. The provider editor therefore shows the provider config saved in the database. This is expected; after takeover is disabled, CC Switch restores live config from backups or the database source of truth.
---
## Risk Notice
This release continues the risk notices from previous versions for reverse-proxy-style features.
**Codex OAuth reverse proxy**: using a ChatGPT subscription's Codex OAuth through a reverse proxy may violate OpenAI's terms of service. See the [v3.13.0 release notes](v3.13.0-en.md#-risk-notice) for details.
**Codex third-party provider Chat routing**: when CC Switch local proxy converts and forwards Codex requests to third-party providers, each provider may have different requirements for billing, compliance, and data retention. Read the target provider's terms before use.
**Claude Desktop third-party provider proxy switching**: when CC Switch's built-in proxy gateway forwards Claude Desktop requests to third-party providers, you must also follow the target provider's billing, compliance, and data-retention terms.
By enabling these features, users accept the related risks. CC Switch is not responsible for account restrictions, warnings, or service suspensions caused by using these features.
---
## Thanks
Thanks to the following contributors for fixes in v3.16.1:
- [#3360](https://github.com/farion1231/cc-switch/pull/3360): always update Codex model catalog JSON when switching providers, thanks [@Postroggy](https://github.com/Postroggy).
- [#3355](https://github.com/farion1231/cc-switch/pull/3355): resolve native balance / Coding Plan credentials per app, thanks [@SiskonEmilia](https://github.com/SiskonEmilia).
- [#3405](https://github.com/farion1231/cc-switch/pull/3405): fix Claude Desktop Official provider add failure, thanks [@Eunknight](https://github.com/Eunknight).
- [#3382](https://github.com/farion1231/cc-switch/pull/3382): Codex CLI multi-platform discovery and GPT-5.5 model template fallback, thanks [@chofuhoyu](https://github.com/chofuhoyu).
- [#3377](https://github.com/farion1231/cc-switch/pull/3377): Kimi / Moonshot tool-thinking history normalization, thanks [@Neon-Wang](https://github.com/Neon-Wang).
Thanks also to everyone who reported Codex OAuth, model catalog, local routing takeover, and Chat Completions tool-call issues after v3.16.0. Many of these fixes came directly from real-world reproduction details.
---
## Download & Install
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) and download the build for your system.
### System Requirements
| System | Minimum Version | Architecture |
| ------- | ------------------------ | ----------------------------------- |
| Windows | Windows 10 and later | x64 |
| macOS | macOS 12 (Monterey)+ | Intel (x64) / Apple Silicon (arm64) |
| Linux | See table below | x64 / ARM64 |
### Windows
| File | Description |
| ---------------------------------------- | ----------------------------------------------- |
| `CC-Switch-v3.16.1-Windows.msi` | **Recommended** - MSI installer with auto-update |
| `CC-Switch-v3.16.1-Windows-Portable.zip` | Portable build, unzip and run |
### macOS
| File | Description |
| -------------------------------- | ----------------------------------------------------- |
| `CC-Switch-v3.16.1-macOS.dmg` | **Recommended** - DMG installer, drag to Applications |
| `CC-Switch-v3.16.1-macOS.zip` | Unzip and drag to Applications, Universal Binary |
| `CC-Switch-v3.16.1-macOS.tar.gz` | For Homebrew install and auto-update |
Homebrew install:
```bash
brew install --cask cc-switch
```
Upgrade:
```bash
brew upgrade --cask cc-switch
```
### Linux
Linux assets are available for both **x86_64** and **ARM64** (`aarch64`). Choose the file whose architecture tag matches your machine's `uname -m` output:
- `CC-Switch-v3.16.1-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- `CC-Switch-v3.16.1-Linux-arm64.AppImage` / `.deb` / `.rpm`
| Distribution | Recommended Format | Install Command |
| ---------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | Make executable and run directly, or use AUR |
| Other distributions / unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+235
View File
@@ -0,0 +1,235 @@
# CC Switch v3.16.1
> Codex 安定性パッチ: 一部のユーザーから「設定ファイルの書き込み方式を変えたくない」というフィードバックがあったため、Codex アプリ拡張にスイッチを追加し、デフォルトではオフにしました。有効化すると、サードパーティ API を使いながら Codex のモバイルリモート操作、公式プラグインなどの公式アプリ機能を引き続き利用できます。本リリースには一連の安定性修正も含まれます。
**[English →](v3.16.1-en.md) | [中文 →](v3.16.1-zh.md)**
---
## 利用ガイド
Codex のサードパーティプロバイダー、ローカルルーティングのテイクオーバー、または DeepSeek / Kimi / GLM / MiniMax などの Chat Completions 上流を Codex で使う場合は、まず以下のドキュメントをご覧ください:
- **[Codex で DeepSeek を使う: ローカルルーティング実践ガイド](../guides/codex-deepseek-routing-guide-ja.md)**: Codex プロバイダーの追加、ローカルルーティングの有効化、リクエスト転送の確認までを説明します。
- **[Codex プロバイダーの追加: Chat Completions ルーティングとモデルマッピング](../user-manual/ja/2-providers/2.1-add.md)**: 「ローカルルーティングが必要」設定、モデルマッピングテーブル、思考能力の設定を説明します。
- **[ローカルプロキシサービス](../user-manual/ja/4-proxy/4.1-service.md)** と **[ローカルルーティング](../user-manual/ja/4-proxy/4.2-routing.md)**: プロキシサービス、live 設定のテイクオーバー、関連するリスク注意事項を説明します。
---
> [!WARNING]
>
> ## 唯一の公式チャネル(必ずお読みください)
>
> CC Switch は**完全に無料・オープンソース**のデスクトップアプリで、**ユーザーから料金を徴収することはありません**。本ソフトウェアは下記の公式チャネルからのみ入手してください:
>
> | チャネル | 唯一の公式 |
> | ------------ | ------------------------------------------------------------------------------ |
> | 公式サイト | **[ccswitch.io](https://ccswitch.io)** |
> | ソースコード | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
> | ダウンロード | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
> | 偽サイト通報 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
>
> **料金請求・チャージ・認証情報の提供を求める「CC Switch」サイトやクライアントはすべて偽物です。** 支払いを誘導された場合は直ちに操作を中止し、GitHub Issues からご報告ください。
---
## 概要
CC Switch v3.16.1 は v3.16.0 に続く Codex 安定性パッチです。v3.16.0 では Chat Completions ルーティングによってサードパーティ Codex プロバイダーを一等市民にしました。本リリースでは、実際の利用で見つかったいくつかの高リスクなエッジケースを中心に修正しています。具体的には、サードパーティプロバイダーの切り替えやローカルルーティングのテイクオーバー中に公式 ChatGPT / Codex OAuth ログイン状態が上書きされる問題、live バックフィル・ホットスイッチ・テイクオーバー解除時の復元・現在のプロバイダー編集で Codex モデルカタログが空になる問題、そして Chat Completions 上流パスで Codex の `tool_search`、プラグイン / コネクタの namespace ツール、カスタムツールが Responses イベントへ完全には復元されない問題です。
本リリースではローカルルーティングのテイクオーバー所有権判定も強化しました。プロバイダー切り替えとテイクオーバートグルはアプリごとに直列実行されます。live ファイルがプロキシ管理下にあるかを判定するとき、遅延しがちな `enabled` 状態やプロキシサービスの起動状態だけに頼らず、バックアップと live 内のプロキシプレースホルダーも確認します。これにより、テイクオーバー直後、プロキシの一時停止中、ホットスイッチ中に、通常の live 書き込みがプロキシ管理設定を上書きしてしまうことを防ぎます。
**リリース日**: 2026-06-01
**Stats**: 23 commits | 62 files changed | +5,603 insertions | -1,113 deletions
---
## ハイライト
- **Codex OAuth とサードパーティプロバイダー切り替えをより安全に**: 公式認証を保持する任意設定を追加しました。有効化すると、サードパーティプロバイダーの token は `config.toml` に書き込まれ、公式 ChatGPT / Codex OAuth ログインは `auth.json` に残ります。
- **Codex モデルカタログが静かに消えないように**: `modelCatalog` はデータベースを信頼できる情報源として扱い、live バックフィル、プロバイダー切り替え、テイクオーバー解除時の復元、プロバイダー編集で、カタログ投影を失った live 設定によりデータベースが上書きされることを避けます。
- **Codex Chat ツール / プラグインルーティングを復元**: Chat Completions 上流から返る `tool_search`、読み込み済み namespace ツール、カスタムツールを Codex Responses 形態へ再マッピングします。ストリーミングのカスタムツールはネイティブの `response.custom_tool_call_input.*` イベントを出力します。
- **ローカルルーティングのテイクオーバーとホットスイッチがより安定**: プロバイダー切り替えとテイクオーバートグルはアプリごとに直列化されます。ホットスイッチ時は Codex live 内のプロバイダー表示情報を更新しつつ、endpoint は引き続きローカルプロキシを指します。
- **診断とプラットフォーム互換性の修正**: Codex プロキシエラーがより豊富な文脈を返すようになりました。Codex CLI のモデルテンプレート探索はより多くのプラットフォームに対応し、静的な GPT-5.5 テンプレートのフォールバックを備えます。Windows のツールバージョン検出では、ローカライズ出力とコマンド引用符の問題を修正しました。
---
## 追加機能
### Codex 公式認証保持設定
サードパーティ Codex プロバイダーへ切り替えるときに、公式 ChatGPT / Codex OAuth ログイン状態を保持する任意設定を追加しました。有効化すると、CC Switch はサードパーティプロバイダーの API key を Codex `config.toml` の provider-scoped `experimental_bearer_token` に保存し、`auth.json` 内の公式ログインキャッシュを上書きしません。
一部のユーザーはこの機能によって設定ファイルの書き込み方式が変わることを望んでいないため、この設定はデフォルトでオフです。v3.16.0 以前の互換動作を維持します。公式 Codex ログインとサードパーティプロバイダーを同時に使いたい場合は、「設定 → Codex アプリ拡張」で手動で有効化できます。
### Codex DeepSeek ルーティングガイド
Codex DeepSeek ルーティングガイドを中国語 / 英語 / 日本語で追加しました。プロバイダーのルーティング要件、DeepSeek Codex プロバイダーフォームの設定、ローカルルーティングのテイクオーバー手順をスクリーンショット付きで説明します。
---
## 変更
### Codex 認証保持は opt-in に変更
公式認証保持設定はデフォルトでオフです。これにより、サードパーティ Codex プロバイダーの切り替えは従来の動作を維持し、ユーザーが気づかないうちに `auth.json` / `config.toml` の書き込み方式が変わることを避けます。
### Codex プロバイダー切り替え後に再起動を案内
Codex はモデルカタログと一部の設定をクライアント起動時に読み込みます。Codex プロバイダーの切り替えに成功した後、UI は Codex の再起動を案内し、モデルカタログと設定変更が実際に反映されるようにします。
### プロバイダー切り替えとテイクオーバートグルを直列化
Codex / Claude / Gemini のプロバイダー切り替えとローカルルーティングのテイクオーバートグルは、アプリごとのロックを共有するようになりました。これにより、2 つの処理が同時に live 設定とバックアップを書き換えることを避けます。live がプロキシ管理下にあるかの判定も、プロキシサービスが起動しているかだけでなく、live バックアップと `PROXY_MANAGED` プレースホルダーを優先して確認します。
### Codex ホットスイッチで表示情報を更新
ローカルルーティングのテイクオーバー中に Codex プロバイダーをホットスイッチすると、CC Switch は live 設定内の provider id、モデル、表示名を更新し、Codex クライアントのメニューが現在のプロバイダーに追従するようにします。同時に base URL はローカルプロキシアドレスのまま維持し、実際の上流 endpoint が live ファイルへ戻ってしまうことを防ぎます。
---
## 修正
### Codex テイクオーバー中の編集ダイアログが live OAuth を表示する問題
Codex がローカルルーティングのテイクオーバー状態にあるとき、live の `auth.json` / `config.toml` はプロキシによって一時的に書き換えられています。この live を読み続けると、現在のプロバイダー編集時にプロキシプレースホルダーや公式 OAuth ログインをプロバイダー設定として誤表示してしまいます。現在の編集ダイアログは、ここに表示されるのがプロキシ管理下の live ファイルではなく、データベースに保存されたプロバイダー設定であることを明示します。プロキシサービスが一時停止していても、そのアプリがテイクオーバー状態であればテイクオーバーとして扱います。
### Codex OAuth がテイクオーバー中に消去または上書きされる問題
公式 ChatGPT / Codex OAuth `auth.json` を消去または上書きする可能性があった複数の preserve-mode テイクオーバーパスを修正しました。テイクオーバー判定は `config.toml` 内の `PROXY_MANAGED` を認識し、クリーンアップはプロキシプレースホルダー token だけを削除します。サードパーティプロバイダーが official と誤分類されても、公式 auth の上書きパスには入りません。プロバイダー同期と切り替えでは、live バックアップとプレースホルダーをテイクオーバー所有権のシグナルとして扱い、テイクオーバー直後やプロキシ一時停止中に通常の live 書き込みがプロキシ設定を上書きすることを防ぎます。
### Codex モデルカタログのデータ消失
live バックフィル、現在のプロバイダー編集、プロバイダー切り替え、テイクオーバー解除時の復元などで `modelCatalog` が空になる問題を修正しました。スナップショットバックアップは既存の `model_catalog_json` ポインターを保持します。プロバイダーから再構築されるバックアップは、データベースの信頼できる情報源からカタログ投影を再生成します。現在のプロバイダー編集時は、投影を失っている可能性のある live の逆解析結果ではなく、データベース内のモデルカタログを優先します。
また、プロバイダー切り替え時には生成済みの Codex モデルカタログ JSON を常に更新するようになりました([#3360](https://github.com/farion1231/cc-switch/pull/3360)、[@Postroggy](https://github.com/Postroggy) に感謝)。
### Codex Chat ツール、プラグイン、カスタムツールの復元
サードパーティ Codex プロバイダーが Chat Completions ルーティングを通るとき、`tool_search`、読み込み済みの MCP / connector namespace ツール、カスタムツールを Codex Responses 形態へ完全に復元できない問題を修正しました。非ストリーミングとストリーミングの Chat レスポンスは、元の Responses リクエストに基づいて正しいツール種別、namespace、call id、引数を復元します。カスタムツールのストリーミング出力は、ネイティブの `response.custom_tool_call_input.delta``response.custom_tool_call_input.done` イベントを発行します。
### Codex プロキシエラー診断の拡充
Codex の転送に失敗したとき、provider、model、endpoint、上流 HTTP ステータス、安定した `cc_switch_*` エラーコード、正規化された HTTP ステータスを含む JSON エラーを返すようになりました。これにより、どのプロバイダー、どの endpoint、どの上流エラーが原因なのかを追いやすくなります。
### Codex ネイティブ残高 / Coding Plan の認証情報検索
ネイティブ残高と Coding Plan の照会時に、別アプリの認証情報を誤って使う問題を修正しました。各 app は自分自身のプロバイダー認証情報を解析し、別のアプリ面の認証前提を照会フローへ持ち込まなくなりました([#3355](https://github.com/farion1231/cc-switch/pull/3355)、[@SiskonEmilia](https://github.com/SiskonEmilia) に感謝)。
### Codex CLI 探索とモデルカタログテンプレートのフォールバック
サードパーティ Codex モデルカタログ投影における Codex CLI の探索パスが狭すぎる問題を修正しました。バックエンドは複数プラットフォームの一般的な Codex CLI インストール場所を探し、それでもテンプレートが見つからない場合は内蔵の GPT-5.5 モデルカタログテンプレートへフォールバックします([#3382](https://github.com/farion1231/cc-switch/pull/3382)、[@chofuhoyu](https://github.com/chofuhoyu) に感謝)。
### Claude Desktop Official プロバイダー追加失敗
Claude Desktop Official プロバイダー追加時のエラーを修正しました([#3405](https://github.com/farion1231/cc-switch/pull/3405)、[@Eunknight](https://github.com/Eunknight) に感謝)。
### Kimi / Moonshot ツール思考履歴の正規化
Kimi / Moonshot を Anthropic 互換ツール思考履歴 normalizer に追加しました。後続ターンで reasoning と tool-call コンテキストを正しく再生できるようになり、履歴メッセージの形が上流要件に合わず失敗する問題を避けます([#3377](https://github.com/farion1231/cc-switch/pull/3377)、[@Neon-Wang](https://github.com/Neon-Wang) に感謝)。
### Windows ツールバージョン検出
Windows で `.cmd` / `.bat` のバージョンコマンドに誤って引用符が付く問題と、ローカライズされたコマンド出力が文字化けしてデコードされる問題を修正しました。以前は、実行可能なツールが「インストール済みだが実行できない」と表示されることがありました。
---
## アップグレード時の注意
### 公式 OAuth 保持は手動で有効化が必要
公式 ChatGPT / Codex OAuth ログインを `auth.json` に長期保持しつつ、サードパーティ Codex プロバイダーを頻繁に切り替える場合は、設定で Codex 公式認証保持を有効化してください。既存ユーザーの互換動作を維持するため、デフォルトではオフです。
### モデルマッピング変更後は Codex の再起動が必要
Codex は起動時に `model_catalog_json` を読み込みます。v3.16.1 でモデルカタログが空になる問題は修正されていますが、モデルマッピングテーブルを変更した後は、`/model` メニューを更新するために Codex の再起動が必要です。
### テイクオーバー中に編集するのは保存済み設定であり live ファイルではありません
ローカルルーティングのテイクオーバーを有効化すると、live の `auth.json` / `config.toml` は一時的に CC Switch プロキシを指します。このときプロバイダー編集で表示されるのは、データベースに保存されたプロバイダー設定です。これは期待される動作です。テイクオーバーを無効化すると、CC Switch はバックアップまたはデータベースの信頼できる情報源から live 設定を復元します。
---
## リスク通知
本リリースは、リバースプロキシ系機能に関する以前のリスク通知を引き続き適用します。
**Codex OAuth リバースプロキシ**: ChatGPT サブスクリプションの Codex OAuth をリバースプロキシ経由で使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 release notes](v3.13.0-ja.md#-リスクに関する注意事項) を参照してください。
**Codex サードパーティプロバイダー Chat ルーティング**: CC Switch ローカルプロキシで Codex リクエストを変換し、サードパーティプロバイダーへ転送する場合、課金、コンプライアンス、データ保持に関する制約はプロバイダーごとに異なります。利用前に対象プロバイダーの利用規約を確認してください。
**Claude Desktop サードパーティプロバイダープロキシ切り替え**: CC Switch 内蔵のプロキシゲートウェイで Claude Desktop のリクエストをサードパーティプロバイダーへ転送する場合も、対象プロバイダーの課金、コンプライアンス、データ保持に関する規約に従う必要があります。
上記機能を有効化したユーザーは、関連するリスクを自ら負うものとします。CC Switch は、これらの機能の利用によって発生したアカウント制限、警告、サービス停止について責任を負いません。
---
## 謝辞
v3.16.1 で修正を届けてくださった以下のコントリビューターに感謝します:
- [#3360](https://github.com/farion1231/cc-switch/pull/3360): Codex プロバイダー切り替え時にモデルカタログ JSON を常に更新、[@Postroggy](https://github.com/Postroggy) に感謝。
- [#3355](https://github.com/farion1231/cc-switch/pull/3355): ネイティブ残高 / Coding Plan 照会の認証情報を app ごとに解析、[@SiskonEmilia](https://github.com/SiskonEmilia) に感謝。
- [#3405](https://github.com/farion1231/cc-switch/pull/3405): Claude Desktop Official プロバイダー追加エラーを修正、[@Eunknight](https://github.com/Eunknight) に感謝。
- [#3382](https://github.com/farion1231/cc-switch/pull/3382): Codex CLI の複数プラットフォーム探索と GPT-5.5 モデルテンプレートフォールバック、[@chofuhoyu](https://github.com/chofuhoyu) に感謝。
- [#3377](https://github.com/farion1231/cc-switch/pull/3377): Kimi / Moonshot ツール思考履歴の正規化、[@Neon-Wang](https://github.com/Neon-Wang) に感謝。
v3.16.0 リリース後に Codex OAuth、モデルカタログ、ローカルルーティングのテイクオーバー、Chat Completions ツール呼び出しの問題を報告してくださったすべてのユーザーにも感謝します。今回の多くの修正は、実際の利用シーンから得られた再現情報に基づいています。
---
## ダウンロードとインストール
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から、お使いのシステムに対応するビルドをダウンロードしてください。
### システム要件
| システム | 最低バージョン | アーキテクチャ |
| -------- | ------------------------ | --------------------------------- |
| Windows | Windows 10 以降 | x64 |
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 下表を参照 | x64 / ARM64 |
### Windows
| ファイル | 説明 |
| ---------------------------------------- | --------------------------------------------------- |
| `CC-Switch-v3.16.1-Windows.msi` | **推奨** - 自動更新対応の MSI インストーラー |
| `CC-Switch-v3.16.1-Windows-Portable.zip` | ポータブル版、展開してそのまま実行できます |
### macOS
| ファイル | 説明 |
| -------------------------------- | ------------------------------------------------------- |
| `CC-Switch-v3.16.1-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ |
| `CC-Switch-v3.16.1-macOS.zip` | 展開して Applications へドラッグ、Universal Binary |
| `CC-Switch-v3.16.1-macOS.tar.gz` | Homebrew インストールと自動更新用 |
Homebrew インストール:
```bash
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
Linux アセットは **x86_64****ARM64**`aarch64`)の両方を提供します。ファイル名にアーキテクチャ識別子が含まれているため、マシンの `uname -m` 出力に合わせて選択してください:
- `CC-Switch-v3.16.1-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- `CC-Switch-v3.16.1-Linux-arm64.AppImage` / `.deb` / `.rpm`
| ディストリビューション | 推奨形式 | インストール方法 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して直接起動、または AUR を使用 |
| その他 / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+235
View File
@@ -0,0 +1,235 @@
# CC Switch v3.16.1
> Codex 稳定性补丁:由于部分用户反映不希望改变配置文件的写入方式,因此为 Codex 增强模式添加开关并默认关闭。开启此开关后,你可以在使用第三方 API 的情况下继续使用 Codex 的手机远程操作、官方插件等功能;本版本也包含一系列稳定性修复。
**[English →](v3.16.1-en.md) | [日本語版 →](v3.16.1-ja.md)**
---
## 使用攻略
如果你正在使用 Codex 第三方供应商、本地路由接管,或希望在 Codex 中使用 DeepSeek / Kimi / GLM / MiniMax 等 Chat Completions 上游,建议先看这些文档:
- **[在 Codex 中使用 DeepSeek:本地路由实战攻略](../guides/codex-deepseek-routing-guide-zh.md)**:从添加 Codex 供应商、开启本地路由,到验证请求转发的完整路径。
- **[添加 Codex 供应商:Chat Completions 路由与模型映射](../user-manual/zh/2-providers/2.1-add.md)**:覆盖「需要本地路由映射」、模型映射表与思考能力配置。
- **[本地代理服务](../user-manual/zh/4-proxy/4.1-service.md)** 与 **[本地路由](../user-manual/zh/4-proxy/4.2-routing.md)**:了解代理服务、接管 live 配置、以及相关风险提示。
---
> [!WARNING]
>
> ## 唯一官方渠道声明(请务必阅读)
>
> CC Switch 是**完全免费、开源**的桌面应用,**不会向用户收取任何费用**。请仅通过下列官方渠道获取本软件:
>
> | 类别 | 唯一官方 |
> | -------- | ------------------------------------------------------------------------------ |
> | 官网 | **[ccswitch.io](https://ccswitch.io)** |
> | 源码 | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
> | 下载 | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
> | 举报山寨 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
>
> **任何向你收费、要求充值、或索取登录凭据的"CC Switch"网站或客户端均为假冒**。如果你被诱导支付了费用,请立即停止操作并通过 GitHub Issues 反馈。
---
## 概览
CC Switch v3.16.1 是 v3.16.0 之后的一版 Codex 稳定性补丁。v3.16.0 让第三方 Codex 供应商通过 Chat Completions 路由成为一等公民;这一版则主要处理真实使用中暴露出的几个高风险边角:官方 ChatGPT / Codex OAuth 登录态在第三方供应商切换或本地路由接管期间被覆盖,Codex 模型目录在 live 回填、热切换、关闭接管恢复或编辑当前供应商时被清空,以及 Codex 的 `tool_search`、插件 / 连接器命名空间、自定义工具在 Chat Completions 上游路径中没有完整恢复为 Responses 事件。
这版也加固了本地路由接管的所有权判断:切换供应商和开启 / 关闭接管现在按应用串行执行,判断 live 文件是否由代理接管时不再只看滞后的 `enabled` 或代理服务是否正在运行,而是结合备份和 live 中的代理占位符。这样可以避免刚开启接管、代理临时停止,或热切换时的普通 live 写入把代理托管配置覆盖掉。
**发布日期**2026-06-01
**更新规模**23 commits | 62 files changed | +5,603 / -1,113 lines
---
## 重点内容
- **Codex OAuth 与第三方供应商切换更安全**:新增可选的官方认证保留设置;开启后,第三方供应商 token 写入 `config.toml`,官方 ChatGPT / Codex OAuth 登录继续留在 `auth.json`
- **Codex 模型目录不再被静默清空**`modelCatalog` 以数据库为真相来源,live 回填、供应商切换、接管关闭恢复、编辑弹窗都会避免用丢失投影的 live 配置覆盖数据库。
- **Codex Chat 工具 / 插件路由恢复**Chat Completions 上游返回的 `tool_search`、已加载命名空间工具、自定义工具会重新映射回 Codex Responses 形态;流式自定义工具现在发出原生 `response.custom_tool_call_input.*` 事件。
- **本地路由接管与热切换更稳**:供应商切换和接管开关按 app 串行,热切换会刷新 Codex live 中的供应商显示信息,但 endpoint 仍保持指向本地代理。
- **诊断与平台兼容性修复**:Codex 代理错误返回更丰富上下文;Codex CLI 模型模板发现支持更多平台并提供 GPT-5.5 静态兜底;Windows 工具版本探测修复乱码与误判。
---
## 新功能
### Codex 官方认证保留设置
新增一个可选设置,用于在切换第三方 Codex 供应商时保留官方 ChatGPT / Codex OAuth 登录态。开启后,CC Switch 会把第三方供应商的 API key 放进 Codex `config.toml` 的 provider-scoped `experimental_bearer_token`,而不是覆盖 `auth.json` 里的官方登录缓存。
由于部分用户不希望此功能改变配置文件的写入方式,因此该设置默认关闭,保持 v3.16.0 之前的兼容行为。需要同时使用官方 Codex 登录和第三方供应商的用户,可以在“设置 → Codex 应用增强”里手动开启。
### Codex DeepSeek 路由指南
新增中 / 英 / 日三语的 Codex DeepSeek 路由指南,包含供应商路由要求、DeepSeek Codex 供应商表单配置,以及本地路由接管的截图说明。
---
## 变更
### Codex 认证保留默认改为 opt-in
官方认证保留设置默认关闭。这样第三方 Codex 供应商切换继续沿用旧行为,避免已有用户在不知情的情况下改变 `auth.json` / `config.toml` 的写入方式。
### Codex 切换供应商后提示重启
Codex 的模型目录与部分配置在客户端启动时加载。现在成功切换 Codex 供应商后,界面会提示用户重启 Codex,让模型目录和配置变化真正生效。
### 供应商切换与接管开关串行化
Codex / Claude / Gemini 的供应商切换与本地路由接管开关现在共享 per-app 锁,避免两个流程同时修改 live 配置和备份。判断 live 是否由代理接管时,也会优先看 live 备份与 `PROXY_MANAGED` 占位符,而不是只看代理服务是否正在运行。
### Codex 热切换刷新显示信息
在本地路由接管期间热切换 Codex 供应商时,CC Switch 会刷新 live 配置中的 provider id、模型和显示名称,让 Codex 客户端菜单能跟随当前供应商;同时 base URL 仍保持本地代理地址,避免真实上游 endpoint 泄回 live 文件。
---
## 修复
### Codex 接管期间编辑弹窗误显示 live OAuth
当 Codex 处于本地路由接管状态时,live `auth.json` / `config.toml` 已被代理临时改写。编辑当前供应商如果继续读取 live,就会把代理占位符或官方 OAuth 登录误显示成供应商配置。现在编辑弹窗会明确提示:此处显示的是数据库中存储的供应商配置,而不是代理托管的 live 文件;即使代理服务暂时停止,只要该 app 仍处于接管状态,也会按接管逻辑处理。
### Codex OAuth 在接管期间被清空或覆盖
修复多条 preserve-mode 接管路径,它们此前可能清空或覆盖官方 ChatGPT / Codex OAuth `auth.json`。现在接管检测会识别 `config.toml` 里的 `PROXY_MANAGED`,清理流程只移除代理占位符 token,第三方供应商错误归类为 official 时也不会再走官方 auth 覆盖路径。供应商同步与切换会把 live 备份和占位符视为接管所有权信号,避免正常 live 写入覆盖刚接管或代理暂停时的代理配置。
### Codex 模型目录数据丢失
修复 `modelCatalog` 在 live 回填、当前供应商编辑弹窗、供应商切换、关闭接管恢复等场景被清空的问题。快照备份会保留已有 `model_catalog_json` 指针;由供应商重建的备份会从数据库真相来源重新生成目录投影;编辑当前供应商时会优先使用数据库里的模型目录,而不是信任可能已经丢失投影的 live 反解结果。
同时,供应商切换现在会始终刷新生成的 Codex 模型目录 JSON[#3360](https://github.com/farion1231/cc-switch/pull/3360),感谢 [@Postroggy](https://github.com/Postroggy))。
### Codex Chat 工具、插件和自定义工具恢复
修复第三方 Codex 供应商走 Chat Completions 路由时,`tool_search`、已加载的 MCP / connector 命名空间工具、自定义工具无法完整恢复为 Codex Responses 形态的问题。非流式与流式 Chat 响应现在都会根据原始 Responses 请求恢复正确的工具类型、namespace、call id 与参数;自定义工具流式输出会发出原生的 `response.custom_tool_call_input.delta``response.custom_tool_call_input.done` 事件。
### Codex 代理错误诊断更完整
Codex 转发失败时,现在返回包含 provider、model、endpoint、上游 HTTP 状态、稳定 `cc_switch_*` 错误码和规范 HTTP 状态的 JSON 错误。这样排查「到底是哪个供应商、哪个 endpoint、哪种上游错误」会清楚很多。
### Codex 原生余额 / Coding Plan 查询凭据
修复原生余额与 Coding Plan 查询时跨 app 错用凭据的问题。现在每个 app 会解析自己的供应商凭据,不再把其他应用面的认证假设带进查询流程([#3355](https://github.com/farion1231/cc-switch/pull/3355),感谢 [@SiskonEmilia](https://github.com/SiskonEmilia))。
### Codex CLI 发现与模型目录模板兜底
修复第三方 Codex 模型目录投影对 Codex CLI 发现路径过窄的问题。现在后端会在多平台常见安装位置寻找 Codex CLI,并在仍找不到模板时使用内置 GPT-5.5 模型目录模板兜底([#3382](https://github.com/farion1231/cc-switch/pull/3382),感谢 [@chofuhoyu](https://github.com/chofuhoyu))。
### Claude Desktop 官方供应商添加失败
修复添加 Claude Desktop 官方供应商时报错的问题([#3405](https://github.com/farion1231/cc-switch/pull/3405),感谢 [@Eunknight](https://github.com/Eunknight))。
### Kimi / Moonshot 工具思考历史规范化
把 Kimi / Moonshot 加入 Anthropic 兼容工具思考历史 normalizer。后续轮次现在能正确重放 reasoning 与 tool-call 上下文,避免因为历史消息形态不符合上游要求而失败([#3377](https://github.com/farion1231/cc-switch/pull/3377),感谢 [@Neon-Wang](https://github.com/Neon-Wang))。
### Windows 工具版本探测
修复 Windows 上 `.cmd` / `.bat` 版本命令被错误加引号,以及本地化命令输出被解码成乱码的问题。此前这些问题会让可运行的工具显示为「已安装但无法运行」。
---
## 升级提醒
### 官方 OAuth 保留需要手动开启
如果你希望官方 ChatGPT / Codex OAuth 登录长期保留在 `auth.json`,同时又频繁切换第三方 Codex 供应商,请在设置中开启 Codex 官方认证保留。默认关闭是为了保持老用户的兼容行为。
### 修改模型映射后仍需重启 Codex
Codex 在启动时读取 `model_catalog_json`。因此即使 v3.16.1 已修复模型目录被清空的问题,只要你修改了模型映射表,仍然需要重启 Codex 才能让 `/model` 菜单刷新。
### 接管期间编辑的是存储配置,不是 live 文件
本地路由接管开启后,live `auth.json` / `config.toml` 会临时指向 CC Switch 代理。此时编辑供应商时看到的是数据库里保存的供应商配置,属于预期行为;关闭接管后,CC Switch 会按备份或数据库真相来源恢复 live 配置。
---
## 风险提示
本版本继续沿用此前版本对反向代理类功能的风险提示。
**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#-风险提示)。
**Codex 第三方供应商 Chat 路由**:通过 CC Switch 本地代理把 Codex 请求转换并转发到第三方供应商时,各供应商对计费、合规与数据留存的约束不同,请在使用前阅读目标供应商的服务条款。
**Claude Desktop 第三方供应商代理切换**:通过 CC Switch 内置代理网关把 Claude Desktop 的请求转到第三方供应商时,同样需要遵守目标供应商的计费、合规与数据留存约束。
用户启用上述功能即表示自行承担相关风险。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
---
## 致谢
感谢以下贡献者在 v3.16.1 中提交修复:
- [#3360](https://github.com/farion1231/cc-switch/pull/3360):Codex 供应商切换时始终更新模型目录 JSON,感谢 [@Postroggy](https://github.com/Postroggy)。
- [#3355](https://github.com/farion1231/cc-switch/pull/3355):原生余额 / Coding Plan 查询按 app 解析凭据,感谢 [@SiskonEmilia](https://github.com/SiskonEmilia)。
- [#3405](https://github.com/farion1231/cc-switch/pull/3405):修复 Claude Desktop 官方供应商添加报错,感谢 [@Eunknight](https://github.com/Eunknight)。
- [#3382](https://github.com/farion1231/cc-switch/pull/3382)Codex CLI 多平台发现与 GPT-5.5 模型模板兜底,感谢 [@chofuhoyu](https://github.com/chofuhoyu)。
- [#3377](https://github.com/farion1231/cc-switch/pull/3377)Kimi / Moonshot 工具思考历史规范化,感谢 [@Neon-Wang](https://github.com/Neon-Wang)。
也感谢所有在 v3.16.0 发布后反馈 Codex OAuth、模型目录、本地路由接管和 Chat Completions 工具调用问题的用户。很多补丁都来自这些真实使用场景里的复现线索。
---
## 下载与安装
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
### 系统要求
| 系统 | 最低版本 | 架构 |
| ------- | -------------------------- | ----------------------------------- |
| Windows | Windows 10 及以上 | x64 |
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 见下表 | x64 / ARM64 |
### Windows
| 文件 | 说明 |
| ---------------------------------------- | ----------------------------------- |
| `CC-Switch-v3.16.1-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.16.1-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
### macOS
| 文件 | 说明 |
| -------------------------------- | --------------------------------------------- |
| `CC-Switch-v3.16.1-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
| `CC-Switch-v3.16.1-macOS.zip` | 解压后拖入 ApplicationsUniversal Binary |
| `CC-Switch-v3.16.1-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
Homebrew 安装:
```bash
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
Linux 资产同时提供 **x86_64****ARM64**`aarch64`)两种架构。资产文件名中包含架构标识,请按你机器的 `uname -m` 输出选择对应版本:
- `CC-Switch-v3.16.1-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- `CC-Switch-v3.16.1-Linux-arm64.AppImage` / `.deb` / `.rpm`
| 发行版 | 推荐格式 | 安装方式 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb``sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm``sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
-597
View File
@@ -1,597 +0,0 @@
# CC-Switch "工作目录" 功能 — 实施方案
## Context
CC-Switch 管理 5 个 CLI 工具(Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw)的供应商、MCP 服务器、Skills、提示词配置。当前所有启用状态是全局的——用户在不同项目间切换时需要手动 toggle。
本功能允许用户注册多个工作目录(项目文件夹),切换目录时自动保存/恢复各实体的启用状态。**不做数据隔离**——所有实体共享全局池,仅 "谁是激活的" 按目录区分。
---
## 一、需要按目录区分的实体(完整清单)
| 实体 | 当前状态字段 | 存储方式 | 需要区分? | 理由 |
|------|-------------|---------|-----------|------|
| **Provider** | `is_current` | per `(id, app_type)` | **YES** | 不同项目用不同供应商 |
| **Provider (Failover)** | `in_failover_queue` | per `(id, app_type)` | **YES** | 备用供应商队列跟随主供应商配置 |
| **MCP Server** | `enabled_claude/codex/gemini/opencode` | per `id`, 4列 | **YES** | 不同项目需要不同 MCP 工具 |
| **Skill** | `enabled_claude/codex/gemini/opencode` | per `id`, 4列 | **YES** | 不同项目需要不同 Skills |
| **Prompt** | `enabled` | per `(id, app_type)`, 单选 | **YES** | 不同项目用不同系统提示词 |
| Proxy Config | `enabled`, thresholds | per `app_type` | NO | 基础设施级别,非项目相关 |
| Settings | key-value | flat table | NO | 全局用户偏好 |
| Provider Health | failures, errors | runtime | **CLEAR** | 切换时清除,重新计算 |
| Common Config | `common_config_{app}` | settings table | NO | 全局模板,非项目相关 |
| Usage/Logs | historical | various tables | NO | 历史数据,不应分区 |
> 原计划遗漏了 **Failover Queue****Provider Health 清除**
---
## 二、数据库变更(Schema v8 → v9
### 新增 5 张表
```sql
-- 1. 工作目录注册表
CREATE TABLE IF NOT EXISTS working_directories (
id TEXT PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
name TEXT,
is_current BOOLEAN NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT 0
);
-- 2. Provider 状态快照 (is_current + in_failover_queue)
-- 每个目录保存所有 provider 的两个状态标志
CREATE TABLE IF NOT EXISTS dir_provider_state (
dir_id TEXT NOT NULL,
app_type TEXT NOT NULL,
provider_id TEXT NOT NULL,
is_current BOOLEAN NOT NULL DEFAULT 0,
in_failover_queue BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (dir_id, app_type, provider_id)
);
-- 3. MCP 启用状态快照 (直接镜像 4 列,不做行展开)
CREATE TABLE IF NOT EXISTS dir_mcp_state (
dir_id TEXT NOT NULL,
mcp_id TEXT NOT NULL,
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (dir_id, mcp_id)
);
-- 4. Skill 启用状态快照 (直接镜像 4 列)
CREATE TABLE IF NOT EXISTS dir_skill_state (
dir_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (dir_id, skill_id)
);
-- 5. Prompt 启用状态快照 (每个 app_type 只存激活的 prompt_id)
CREATE TABLE IF NOT EXISTS dir_prompt_state (
dir_id TEXT NOT NULL,
app_type TEXT NOT NULL,
prompt_id TEXT NOT NULL,
PRIMARY KEY (dir_id, app_type)
);
```
### 设计决策说明
**MCP/Skill 用 4 列镜像而非 `(entity_id, app_type, enabled)` 行展开**
- 与主表 `mcp_servers` / `skills` 结构一致,snapshot/apply 代码直接 copy 4 列
- 避免 4 倍行膨胀(每个 MCP 服务器 1 行 vs 4 行)
- 未来增加新 app 时,两边同步加列即可
**Prompt 只存 `(dir_id, app_type, prompt_id)`**
- 每个 app_type 最多一个 enabled prompt,不需要存 boolean
- 无记录 = 该 app 无激活 prompt
**Provider 合并 `is_current` + `in_failover_queue`**
- 两个标志都是 per `(app_type, provider_id)` 的状态
- 存在同一表中避免多表 JOIN
### 迁移脚本
`schema.rs` 中:
- `create_tables_on_conn()` 添加 5 个 CREATE TABLE
- 新增 `migrate_v8_to_v9(conn)`: 创建 5 张表 + 插入 `__default__`
- `SCHEMA_VERSION` 升至 9
- 迁移循环添加 `7 => ...` 后加 `8 => { Self::migrate_v8_to_v9(conn)?; Self::set_user_version(conn, 9)?; }`
```rust
fn migrate_v8_to_v9(conn: &Connection) -> Result<(), AppError> {
// 创建 5 张表(使用 IF NOT EXISTS,幂等)
// ...
// 插入 __default__ 虚拟目录,代表"全局默认"状态
conn.execute(
"INSERT OR IGNORE INTO working_directories (id, path, name, is_current, created_at) \
VALUES ('__default__', '__default__', NULL, 0, ?1)",
[crate::database::get_unix_timestamp()?],
)?;
Ok(())
}
```
---
## 三、后端实现
### 3.1 DAO 层 — `src-tauri/src/database/dao/working_dir.rs`
所有方法都是 `impl Database` 块,遵循现有 DAO 模式。
**关键方法签名**(需要 `_on_conn` 变体支持事务):
```rust
// ═══ 工作目录 CRUD ═══
pub fn list_working_directories(&self) -> Result<Vec<WorkingDirectory>, AppError>
pub fn add_working_directory(&self, id: &str, path: &str, name: Option<&str>) -> Result<(), AppError>
pub fn delete_working_directory(&self, id: &str) -> Result<(), AppError>
pub fn rename_working_directory(&self, id: &str, name: &str) -> Result<(), AppError>
pub fn get_current_working_directory(&self) -> Result<Option<WorkingDirectory>, AppError>
// 使用 _on_conn 变体,在 Service 层的事务中调用
fn set_current_working_directory_on_conn(conn: &Connection, id: &str) -> Result<(), AppError>
// ═══ 快照写入 ═══ (都有 _on_conn 变体)
fn snapshot_providers_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError>
fn snapshot_mcp_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError>
fn snapshot_skills_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError>
fn snapshot_prompts_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError>
// ═══ 快照恢复 ═══ (都有 _on_conn 变体, 返回 bool = 是否有快照)
fn apply_provider_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result<bool, AppError>
fn apply_mcp_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result<bool, AppError>
fn apply_skill_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result<bool, AppError>
fn apply_prompt_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result<bool, AppError>
```
**snapshot_providers 实现思路**
```sql
-- 先清除旧快照
DELETE FROM dir_provider_state WHERE dir_id = ?1;
-- 从主表复制当前状态
INSERT INTO dir_provider_state (dir_id, app_type, provider_id, is_current, in_failover_queue)
SELECT ?1, app_type, id, is_current, in_failover_queue
FROM providers
WHERE is_current = 1 OR in_failover_queue = 1;
```
**apply_provider_snapshot 实现思路**
```sql
-- 检查是否有快照
SELECT COUNT(*) FROM dir_provider_state WHERE dir_id = ?1; -- 如果 0,返回 false
-- 在事务中:先清除主表所有 is_current 和 in_failover_queue
UPDATE providers SET is_current = 0;
UPDATE providers SET in_failover_queue = 0;
-- 从快照恢复
UPDATE providers SET is_current = 1
WHERE (id, app_type) IN (SELECT provider_id, app_type FROM dir_provider_state WHERE dir_id = ?1 AND is_current = 1);
UPDATE providers SET in_failover_queue = 1
WHERE (id, app_type) IN (SELECT provider_id, app_type FROM dir_provider_state WHERE dir_id = ?1 AND in_failover_queue = 1);
```
**snapshot_mcp / snapshot_skills 实现思路**(直接镜像 4 列):
```sql
DELETE FROM dir_mcp_state WHERE dir_id = ?1;
INSERT INTO dir_mcp_state (dir_id, mcp_id, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode)
SELECT ?1, id, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
FROM mcp_servers;
```
**apply_mcp_snapshot 实现思路**
```sql
-- 先全部禁用
UPDATE mcp_servers SET enabled_claude = 0, enabled_codex = 0, enabled_gemini = 0, enabled_opencode = 0;
-- 从快照恢复
UPDATE mcp_servers SET
enabled_claude = (SELECT enabled_claude FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id),
enabled_codex = (SELECT enabled_codex FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id),
enabled_gemini = (SELECT enabled_gemini FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id),
enabled_opencode = (SELECT enabled_opencode FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id)
WHERE id IN (SELECT mcp_id FROM dir_mcp_state WHERE dir_id = ?1);
```
### 3.2 Service 层 — `src-tauri/src/services/working_dir.rs`
```rust
use crate::store::AppState;
use crate::error::AppError;
use crate::database::lock_conn;
use crate::app_config::AppType;
use crate::services::{McpService, ProviderService, SkillService};
use crate::config::write_text_file;
use crate::prompt_files::prompt_file_path;
pub struct WorkingDirService;
impl WorkingDirService {
/// 核心切换逻辑
pub fn switch(state: &AppState, target_dir_id: &str) -> Result<(), AppError> {
// ═══ 前置检查 ═══
// 1. 检查代理接管状态,若活跃则拒绝切换
// 使用 db.is_live_takeover_active() 或同步检查 proxy_config.live_takeover_active
// (因为 ProxyService::is_running() 是 async,而此函数是 sync
Self::check_proxy_not_active(state)?;
// ═══ Phase 1: 回填 Prompt ═══
// 在 snapshot 之前,将 live 文件内容回填到当前 enabled prompt
// 这样即使用户手动编辑了 live 文件,内容也不会丢失
Self::backfill_prompt_content(state)?;
// ═══ Phase 2: 数据库操作(事务) ═══
{
let conn = lock_conn!(state.db.conn);
conn.execute("BEGIN IMMEDIATE", [])?;
let result = (|| -> Result<(), AppError> {
// 获取当前工作目录
let current = Self::get_current_dir_id_on_conn(&conn)?;
// 保存当前状态到旧目录
if let Some(old_id) = &current {
Database::snapshot_providers_on_conn(&conn, old_id)?;
Database::snapshot_mcp_on_conn(&conn, old_id)?;
Database::snapshot_skills_on_conn(&conn, old_id)?;
Database::snapshot_prompts_on_conn(&conn, old_id)?;
} else {
// 无当前目录 = 全局模式,保存到 __default__
Database::snapshot_providers_on_conn(&conn, "__default__")?;
Database::snapshot_mcp_on_conn(&conn, "__default__")?;
Database::snapshot_skills_on_conn(&conn, "__default__")?;
Database::snapshot_prompts_on_conn(&conn, "__default__")?;
}
// 加载目标目录快照(如果有的话)
// 如果无快照(首次进入),保持主表不变
Database::apply_provider_snapshot_on_conn(&conn, target_dir_id)?;
Database::apply_mcp_snapshot_on_conn(&conn, target_dir_id)?;
Database::apply_skill_snapshot_on_conn(&conn, target_dir_id)?;
Database::apply_prompt_snapshot_on_conn(&conn, target_dir_id)?;
// 更新 is_current 标记
Database::set_current_working_directory_on_conn(&conn, target_dir_id)?;
Ok(())
})();
match result {
Ok(()) => conn.execute("COMMIT", [])?,
Err(e) => {
let _ = conn.execute("ROLLBACK", []);
return Err(e);
}
};
}
// conn 锁在此处释放
// ═══ Phase 3: 同步 live 配置文件 ═══
Self::sync_all_live(state)?;
// ═══ Phase 4: 清除 Provider Health ═══
state.db.clear_all_provider_health()?;
Ok(())
}
/// 回填 live prompt 文件内容到 DB(切换前调用)
fn backfill_prompt_content(state: &AppState) -> Result<(), AppError> {
for app in AppType::all() {
let path = prompt_file_path(&app)?;
if !path.exists() { continue; }
let live_content = std::fs::read_to_string(&path).unwrap_or_default();
if live_content.trim().is_empty() { continue; }
let mut prompts = state.db.get_prompts(app.as_str())?;
if let Some((_, prompt)) = prompts.iter_mut().find(|(_, p)| p.enabled) {
prompt.content = live_content;
prompt.updated_at = Some(get_unix_timestamp()?);
state.db.save_prompt(app.as_str(), prompt)?;
}
}
Ok(())
}
/// 将 DB 中的 enabled prompt 内容写入 live 文件(切换后调用)
/// 注意:不做回填!只写入。区别于 PromptService::enable_prompt()
fn write_prompts_to_live(state: &AppState) -> Result<(), AppError> {
for app in AppType::all() {
let path = prompt_file_path(&app)?;
let prompts = state.db.get_prompts(app.as_str())?;
if let Some(prompt) = prompts.values().find(|p| p.enabled) {
write_text_file(&path, &prompt.content)?;
}
// 无 enabled prompt 时不清空文件(保留现状)
}
Ok(())
}
/// 同步所有 live 配置(Provider + MCP + Skill + Prompt
fn sync_all_live(state: &AppState) -> Result<(), AppError> {
// 1. Provider → live files
ProviderService::sync_current_to_live(state)?;
// sync_current_to_live 内部已调用 McpService::sync_all_enabled()
// 2. Skills → app dirs (循环每个 app)
for app in AppType::all() {
let _ = SkillService::sync_to_app(&state.db, &app);
}
// 3. Prompts → live files
Self::write_prompts_to_live(state)?;
Ok(())
}
/// 检查代理是否活跃(同步检查数据库标志)
fn check_proxy_not_active(state: &AppState) -> Result<(), AppError> {
// 检查 proxy_config 表中 live_takeover_active 列
// 如果有任何 app 的 live_takeover_active = 1,拒绝切换
let conn = lock_conn!(state.db.conn);
let active: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM proxy_config WHERE live_takeover_active = 1)",
[], |r| r.get(0)
).unwrap_or(false);
if active {
return Err(AppError::Message(
"代理接管模式运行中,请先停止代理再切换工作目录".into()
));
}
Ok(())
}
}
```
### 3.3 Command 层 — `src-tauri/src/commands/working_dir.rs`
遵循现有模式:`State<'_, AppState>` + `Result<T, String>` + `.map_err(|e| e.to_string())`
```rust
#[tauri::command]
pub fn list_working_directories(state: State<'_, AppState>) -> Result<Vec<WorkingDirectory>, String>
#[tauri::command]
pub fn add_working_directory(state: State<'_, AppState>, path: String, name: Option<String>) -> Result<WorkingDirectory, String>
#[tauri::command]
pub fn delete_working_directory(state: State<'_, AppState>, id: String) -> Result<(), String>
#[tauri::command]
pub fn rename_working_directory(state: State<'_, AppState>, id: String, name: String) -> Result<(), String>
#[tauri::command]
pub fn switch_working_directory(state: State<'_, AppState>, id: String) -> Result<(), String>
// 调用 WorkingDirService::switch()
#[tauri::command]
pub fn get_current_working_directory(state: State<'_, AppState>) -> Result<Option<WorkingDirectory>, String>
```
### 3.4 需修改的现有文件
| 文件 | 修改内容 |
|------|---------|
| `src-tauri/src/database/schema.rs` | 添加 5 个 CREATE TABLE + `migrate_v8_to_v9()` |
| `src-tauri/src/database/mod.rs` | `SCHEMA_VERSION = 9` + 迁移循环加 `8 => ...` + `pub mod working_dir` in dao |
| `src-tauri/src/database/dao/mod.rs` | 添加 `pub mod working_dir;` |
| `src-tauri/src/services/mod.rs` | 添加 `pub mod working_dir;` + `pub use working_dir::WorkingDirService;` |
| `src-tauri/src/commands/mod.rs` | 添加 `mod working_dir;` + `pub use working_dir::*;` |
| `src-tauri/src/lib.rs` | invoke_handler 注册 6 个新命令 |
### 3.5 可能需要新增的 DAO 辅助方法
`src-tauri/src/database/dao/failover.rs`
```rust
/// 清除所有 provider_health 记录(切换目录时调用)
pub fn clear_all_provider_health(&self) -> Result<(), AppError>
```
---
## 四、前端实现
### 4.1 API — `src/lib/api/workingDir.ts`
```typescript
import { invoke } from "@tauri-apps/api/core";
export interface WorkingDirectory {
id: string;
path: string;
name?: string;
isCurrent: boolean;
createdAt: number;
}
export const workingDirApi = {
list: () => invoke<WorkingDirectory[]>("list_working_directories"),
add: (path: string, name?: string) =>
invoke<WorkingDirectory>("add_working_directory", { path, name }),
delete: (id: string) => invoke<void>("delete_working_directory", { id }),
rename: (id: string, name: string) =>
invoke<void>("rename_working_directory", { id, name }),
switch: (id: string) => invoke<void>("switch_working_directory", { id }),
getCurrent: () =>
invoke<WorkingDirectory | null>("get_current_working_directory"),
};
```
### 4.2 组件 — `src/components/WorkingDirSwitcher.tsx`
**位置**Header toolbar,靠近 AppSwitcher。
**功能**
- 下拉菜单显示已注册目录列表
- 当前目录高亮
- "浏览…" 按钮调用 Tauri 文件夹选择对话框
- 右键菜单:重命名、删除
- "__default__(全局)" 选项恢复到全局状态
- 切换后 invalidate 所有相关 React Query
**切换后的 Query Invalidation**
```typescript
// 需要验证实际的 queryKey 名称
queryClient.invalidateQueries({ queryKey: ["providers"] });
queryClient.invalidateQueries({ queryKey: ["mcp-servers"] });
queryClient.invalidateQueries({ queryKey: ["installed-skills"] });
queryClient.invalidateQueries({ queryKey: ["prompts"] });
queryClient.invalidateQueries({ queryKey: ["workingDirectories"] });
```
### 4.3 i18n
三个文件都需更新:
- `src/i18n/locales/zh.json`
- `src/i18n/locales/en.json`
- `src/i18n/locales/ja.json`
---
## 五、切换流程时序
```
用户选择目录 B
├── 1. check_proxy_not_active()
│ → 如果代理接管中,返回错误,终止
├── 2. backfill_prompt_content()
│ → 读 live prompt 文件 → 更新 DB 中已启用 prompt 的 content
│ → 保护用户手动编辑的 prompt 不丢失
├── 3. BEGIN TRANSACTION
│ ├── snapshot(old_dir / __default__)
│ │ ├── providers → dir_provider_state (is_current + in_failover_queue)
│ │ ├── mcp_servers → dir_mcp_state (4 列直接复制)
│ │ ├── skills → dir_skill_state (4 列直接复制)
│ │ └── prompts → dir_prompt_state (enabled prompt_id)
│ │
│ ├── apply(target_dir)
│ │ ├── dir_provider_state → providers
│ │ ├── dir_mcp_state → mcp_servers
│ │ ├── dir_skill_state → skills
│ │ └── dir_prompt_state → prompts
│ │
│ └── set_current_working_directory(target_dir)
├── COMMIT
├── 4. sync_all_live()
│ ├── ProviderService::sync_current_to_live(state)
│ │ └── 内部已调用 McpService::sync_all_enabled()
│ ├── for app in AppType::all() { SkillService::sync_to_app(&db, &app) }
│ └── write_prompts_to_live() ← 无回填,直接写
└── 5. clear_all_provider_health()
→ 清除运行时熔断器状态
```
---
## 六、边界情况处理
| 场景 | 处理方式 |
|------|---------|
| **首次进入目录(无快照)** | `apply_*_snapshot()` 返回 false,主表保持不变。用户调整后,下次切走时自动保存。 |
| **全局模式 → 目录** | 自动将当前状态 snapshot 到 `__default__` 虚拟目录。`__default__` 在 v9 迁移中预创建。 |
| **目录 → 全局模式** | 用户选择 `__default__`,恢复全局状态。 |
| **新增 MCP/Skill/Provider** | 新实体在 dir_*_state 中无记录。apply 时只更新有记录的实体,新增的保持 DB 默认值。 |
| **删除 MCP/Skill/Provider** | dir_*_state 中对应记录在 apply 时找不到主表行,UPDATE 影响 0 行,静默跳过。 |
| **删除工作目录** | 级联删除 dir_*_state 中所有 `dir_id` 匹配的行。若为当前目录,回退到 `__default__`。 |
| **代理接管中切换** | `check_proxy_not_active()` 检测到 `live_takeover_active = 1`,拒绝切换并提示用户先停止代理。 |
| **切换中途崩溃** | 事务保护 DB 操作的原子性。最坏情况:DB 已更新但 live 文件未同步。下次启动可添加恢复检查(Phase 2 优化)。 |
| **用户手动编辑了 prompt 文件** | `backfill_prompt_content()` 在切换前读取 live 文件回填到 DB,保护手动修改。 |
---
## 七、实施顺序
### Phase 1: 数据库
1. `database/schema.rs` — 5 个 CREATE TABLE + `migrate_v8_to_v9()`
2. `database/mod.rs``SCHEMA_VERSION = 9` + 迁移分支
3. `database/dao/working_dir.rs` — 全部 DAO 方法(`_on_conn` 变体)
4. `database/dao/failover.rs` — 新增 `clear_all_provider_health()`
5. `database/dao/mod.rs` — 注册模块
### Phase 2: 服务 + 命令
6. `services/working_dir.rs``WorkingDirService::switch()`
7. `commands/working_dir.rs` — 6 个 Tauri 命令
8. `services/mod.rs` — 注册模块
9. `commands/mod.rs` — 注册模块
10. `lib.rs` — invoke_handler 注册
### Phase 3: 前端
11. `src/lib/api/workingDir.ts` — API 封装
12. `src/types.ts` — WorkingDirectory 类型
13. `src/components/WorkingDirSwitcher.tsx` — UI 组件
14. `src/App.tsx` — 集成到 header toolbar
15. `src/i18n/locales/{zh,en,ja}.json` — 国际化
### Phase 4: 优化(可选)
16. 启动恢复检查(DB 状态 vs live 文件一致性)
17. 托盘菜单显示当前工作目录
---
## 八、关键文件索引
### 新增文件(5 个)
- `src-tauri/src/database/dao/working_dir.rs`
- `src-tauri/src/services/working_dir.rs`
- `src-tauri/src/commands/working_dir.rs`
- `src/lib/api/workingDir.ts`
- `src/components/WorkingDirSwitcher.tsx`
### 必须修改的文件(7 个)
- `src-tauri/src/database/schema.rs` — CREATE TABLE + 迁移
- `src-tauri/src/database/mod.rs` — 版本号 + 迁移循环
- `src-tauri/src/database/dao/mod.rs` — 模块注册
- `src-tauri/src/database/dao/failover.rs` — clear_all_provider_health
- `src-tauri/src/services/mod.rs` — 模块注册
- `src-tauri/src/commands/mod.rs` — 模块注册
- `src-tauri/src/lib.rs` — invoke_handler
### 必须修改的前端文件(4 个)
- `src/App.tsx` — 集成 WorkingDirSwitcher
- `src/types.ts` — WorkingDirectory 接口
- `src/i18n/locales/zh.json` — 中文
- `src/i18n/locales/en.json` — 英文
- `src/i18n/locales/ja.json` — 日文
### 参考文件(理解现有模式)
- `src-tauri/src/services/mcp.rs``sync_all_enabled()` (line 165)
- `src-tauri/src/services/skill.rs``sync_to_app()` (line 1707)
- `src-tauri/src/services/provider/mod.rs``sync_current_to_live()` (line 1552)
- `src-tauri/src/services/prompt.rs``enable_prompt()` (line 73) — 理解回填逻辑
- `src-tauri/src/prompt_files.rs` — prompt 文件路径
- `src-tauri/src/config.rs``write_text_file()` (line 176)
---
## 九、验证计划
### 后端验证
1. `cargo test` — DAO 层单元测试(使用 `Database::memory()`
- 快照/恢复往返一致性
- 新增/删除实体后的 apply 行为
- `__default__` 全局状态保护
- 事务回滚测试
2. 手动测试 — 启动应用,创建两个目录,切换并验证 live 文件变化
### 前端验证
1. `pnpm typecheck` — TypeScript 类型检查
2. `pnpm lint` — ESLint 检查
3. 手动 UI 测试 — 工作目录切换器交互、query invalidation 后数据刷新
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.16.0",
"version": "3.16.1",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
+2 -1
View File
@@ -735,7 +735,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.16.0"
version = "3.16.1"
dependencies = [
"anyhow",
"arboard",
@@ -798,6 +798,7 @@ dependencies = [
"uuid",
"webkit2gtk",
"webpki-roots 0.26.11",
"windows-sys 0.61.2",
"winreg 0.52.0",
"zip 2.4.2",
]
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.16.0"
version = "3.16.1"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
@@ -88,6 +88,7 @@ webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.52"
windows-sys = { version = "0.61", features = ["Win32_Globalization"] }
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.5"
+2 -1
View File
@@ -1947,12 +1947,13 @@ mod tests {
let direct = direct_provider("direct");
assert!(is_compatible_direct_provider(&direct));
let claude_official = Provider::with_id(
let mut claude_official = Provider::with_id(
"claude-official".to_string(),
"Claude Official".to_string(),
json!({"env": {}}),
Some("https://www.anthropic.com/claude-code".to_string()),
);
claude_official.category = Some("official".to_string());
assert!(!is_compatible_direct_provider(&claude_official));
let mut openai_format = direct_provider("openai");
+387 -38
View File
@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::config::{
@@ -204,6 +205,29 @@ pub fn extract_codex_api_key(auth: Option<&Value>, config_text: Option<&str>) ->
.or_else(|| config_text.and_then(extract_codex_experimental_bearer_token))
}
/// Extract the upstream base URL from a Codex `config.toml` string.
///
/// Prefers the active `[model_providers.<model_provider>].base_url`, falling
/// back to a top-level `base_url` when no model provider is selected.
pub fn extract_codex_base_url(config_text: &str) -> Option<String> {
let doc = config_text.parse::<toml::Value>().ok()?;
if let Some(active_provider) = doc.get("model_provider").and_then(|v| v.as_str()) {
if let Some(base_url) = doc
.get("model_providers")
.and_then(|providers| providers.get(active_provider))
.and_then(|provider| provider.get("base_url"))
.and_then(|v| v.as_str())
{
return Some(base_url.to_string());
}
}
doc.get("base_url")
.and_then(|v| v.as_str())
.map(ToString::to_string)
}
pub fn codex_auth_has_login_material(auth: &Value) -> bool {
let Some(obj) = auth.as_object() else {
return false;
@@ -393,39 +417,227 @@ fn load_codex_model_template_from_cache() -> Result<Option<Value>, AppError> {
Ok(find_codex_model_template(&catalog))
}
fn load_codex_model_template_from_bundled() -> Result<Option<Value>, AppError> {
let output = match Command::new("codex")
.args(["debug", "models", "--bundled"])
.output()
{
Ok(output) => output,
Err(err) => {
log::debug!("failed to run `codex debug models --bundled`: {err}");
return Ok(None);
}
/// Fixed candidates for locating the `codex` CLI when it is not on the process
/// PATH (common in GUI apps launched outside a terminal).
const CODEX_CLI_FIXED_CANDIDATES: &[&str] = &[
"codex", // PATH (all platforms)
"/opt/homebrew/bin/codex", // macOS Apple Silicon Homebrew
"/usr/local/bin/codex", // macOS Intel Homebrew / Linux
"/home/linuxbrew/.linuxbrew/bin/codex", // Linux Homebrew
];
fn push_codex_cli_candidate(
candidates: &mut Vec<PathBuf>,
seen: &mut HashSet<String>,
candidate: PathBuf,
) {
let key = candidate.to_string_lossy().into_owned();
if seen.insert(key) {
candidates.push(candidate);
}
}
fn push_existing_codex_cli_candidate(
candidates: &mut Vec<PathBuf>,
seen: &mut HashSet<String>,
candidate: PathBuf,
) {
if candidate.exists() {
push_codex_cli_candidate(candidates, seen, candidate);
}
}
fn push_codex_cli_candidates_from_version_dirs(
candidates: &mut Vec<PathBuf>,
seen: &mut HashSet<String>,
versions_dir: PathBuf,
suffix: &[&str],
) {
let Ok(entries) = fs::read_dir(versions_dir) else {
return;
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::debug!("`codex debug models --bundled` failed: {stderr}");
return Ok(None);
let mut discovered = entries
.filter_map(Result::ok)
.map(|entry| {
let mut candidate = entry.path();
for component in suffix {
candidate.push(component);
}
candidate
})
.filter(|candidate| candidate.exists())
.collect::<Vec<_>>();
// Prefer newer-looking version directories before older global installs.
discovered.sort_by(|a, b| b.cmp(a));
for candidate in discovered {
push_codex_cli_candidate(candidates, seen, candidate);
}
}
fn push_home_codex_cli_candidates(
candidates: &mut Vec<PathBuf>,
seen: &mut HashSet<String>,
home: &Path,
) {
for relative in [
".nvm/current/bin/codex",
".volta/bin/codex",
".asdf/shims/codex",
".local/share/mise/shims/codex",
".config/mise/shims/codex",
".local/bin/codex",
".npm-global/bin/codex",
".npm-packages/bin/codex",
".local/share/pnpm/codex",
"Library/pnpm/codex",
] {
push_existing_codex_cli_candidate(candidates, seen, home.join(relative));
}
let catalog: Value = serde_json::from_slice(&output.stdout).map_err(|e| {
AppError::Message(format!(
"Failed to parse `codex debug models --bundled` output: {e}"
))
})?;
Ok(find_codex_model_template(&catalog))
push_codex_cli_candidates_from_version_dirs(
candidates,
seen,
home.join(".nvm/versions/node"),
&["bin", "codex"],
);
push_codex_cli_candidates_from_version_dirs(
candidates,
seen,
home.join(".local/share/fnm/node-versions"),
&["installation", "bin", "codex"],
);
push_codex_cli_candidates_from_version_dirs(
candidates,
seen,
home.join("Library/Application Support/fnm/node-versions"),
&["installation", "bin", "codex"],
);
}
fn push_env_codex_cli_candidates(candidates: &mut Vec<PathBuf>, seen: &mut HashSet<String>) {
for (env_key, suffix) in [
("NPM_CONFIG_PREFIX", &["bin", "codex"][..]),
("VOLTA_HOME", &["bin", "codex"][..]),
("ASDF_DATA_DIR", &["shims", "codex"][..]),
("MISE_DATA_DIR", &["shims", "codex"][..]),
("PNPM_HOME", &["codex"][..]),
] {
let Some(prefix) = std::env::var_os(env_key) else {
continue;
};
let mut candidate = PathBuf::from(prefix);
for component in suffix {
candidate.push(component);
}
push_existing_codex_cli_candidate(candidates, seen, candidate);
}
if let Some(nvm_dir) = std::env::var_os("NVM_DIR") {
push_codex_cli_candidates_from_version_dirs(
candidates,
seen,
PathBuf::from(nvm_dir).join("versions/node"),
&["bin", "codex"],
);
}
if let Some(fnm_dir) = std::env::var_os("FNM_DIR") {
push_codex_cli_candidates_from_version_dirs(
candidates,
seen,
PathBuf::from(fnm_dir).join("node-versions"),
&["installation", "bin", "codex"],
);
}
#[cfg(windows)]
{
if let Some(appdata) = std::env::var_os("APPDATA") {
let npm_dir = PathBuf::from(appdata).join("npm");
for name in ["codex.cmd", "codex.exe", "codex"] {
push_existing_codex_cli_candidate(candidates, seen, npm_dir.join(name));
}
}
}
}
fn codex_cli_candidates() -> Vec<PathBuf> {
let mut candidates = Vec::new();
let mut seen = HashSet::new();
for candidate in CODEX_CLI_FIXED_CANDIDATES {
push_codex_cli_candidate(&mut candidates, &mut seen, PathBuf::from(candidate));
}
push_env_codex_cli_candidates(&mut candidates, &mut seen);
push_home_codex_cli_candidates(&mut candidates, &mut seen, &get_home_dir());
candidates
}
fn load_codex_model_template_from_bundled() -> Result<Option<Value>, AppError> {
for candidate in codex_cli_candidates() {
let candidate_label = candidate.to_string_lossy();
let output = match Command::new(&candidate)
.args(["debug", "models", "--bundled"])
.output()
{
Ok(output) => output,
Err(err) => {
log::debug!("failed to run `{candidate_label} debug models --bundled`: {err}");
continue;
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::debug!("`{candidate_label} debug models --bundled` failed: {stderr}");
continue;
}
let catalog: Value = match serde_json::from_slice(&output.stdout) {
Ok(catalog) => catalog,
Err(e) => {
log::debug!(
"Failed to parse `{candidate_label} debug models --bundled` output: {e}"
);
continue;
}
};
if let Some(template) = find_codex_model_template(&catalog) {
return Ok(Some(template));
}
}
Ok(None)
}
fn load_codex_model_template_static() -> Option<Value> {
let text = include_str!("resources/gpt5_5_template.json");
match serde_json::from_str(text) {
Ok(template) => Some(template),
Err(e) => {
log::warn!("Failed to parse bundled gpt-5.5 template: {e}");
None
}
}
}
fn load_codex_model_catalog_template() -> Result<Value, AppError> {
// ① models_cache.json (created by Codex when it connects to OpenAI)
if let Some(template) = load_codex_model_template_from_cache()? {
return Ok(template);
}
// ② codex CLI (PATH + platform-specific common paths)
if let Some(template) = load_codex_model_template_from_bundled()? {
return Ok(template);
}
// ③ Static fallback bundled at compile time
if let Some(template) = load_codex_model_template_static() {
return Ok(template);
}
Err(AppError::Message(format!(
"Codex model catalog template `{CODEX_MODEL_CATALOG_TEMPLATE_SLUG}` not found. Please start Codex once so models_cache.json is available, or ensure the `codex` CLI is on PATH."
@@ -634,18 +846,39 @@ fn build_simplified_catalog_from_texts(config_text: &str, catalog_text: &str) ->
Some(json!({ "models": entries }))
}
/// Unified helper: write Codex live config with model catalog preparation.
/// Replaces scattered `prepare_codex_config_text_with_model_catalog` calls.
pub fn write_codex_live_with_catalog(
/// Decide the `config.toml` text to write during a takeover-off restore,
/// projecting the model catalog **only when `settings` carries an inline
/// `modelCatalog`**.
///
/// Restore feeds back a stored backup, and Codex backups come in two shapes that
/// need opposite handling:
///
/// - **Snapshot backup** (`read_codex_live_settings`): `{ auth, config }` with no
/// inline `modelCatalog`. Its `config.toml` text already carries whatever
/// `model_catalog_json` pointer existed at backup time, and the generated
/// catalog file on disk is untouched. Here we must keep the config **raw** —
/// running catalog projection would see "no specs" and strip the live pointer.
/// - **Provider-rebuilt backup** (`update_live_backup_from_provider`): the DB
/// provider's settings, i.e. `{ auth, config (no pointer), modelCatalog
/// (inline DB SSOT) }`. Here the pointer/catalog file must be (re)generated
/// from the inline `modelCatalog`, or the mapping is lost on restore.
///
/// Gating on the presence of the inline `modelCatalog` key routes each shape
/// correctly; an empty inline catalog still projects (and so correctly drops a
/// now-stale pointer), while an absent key leaves the text untouched. This is
/// **orthogonal to auth** — a provider-rebuilt backup can pair an inline
/// `modelCatalog` with empty `auth.json` (the API key living in the config's
/// `experimental_bearer_token`), so the caller must decide config projection
/// independently of whether it writes or deletes `auth.json`.
pub fn prepare_codex_live_config_text_with_optional_catalog(
settings: &Value,
auth: &Value,
config_text: Option<&str>,
) -> Result<(), AppError> {
let prepared_config = config_text
.map(|text| prepare_codex_config_text_with_model_catalog(settings, text))
.transpose()?;
write_codex_live_atomic(auth, prepared_config.as_deref())
config_text: &str,
) -> Result<String, AppError> {
if settings.get("modelCatalog").is_some() {
prepare_codex_config_text_with_model_catalog(settings, config_text)
} else {
Ok(config_text.to_string())
}
}
pub fn write_codex_provider_live_with_catalog(
@@ -739,7 +972,10 @@ fn set_codex_experimental_bearer_token(config_text: &str, token: &str) -> Result
Ok(doc.to_string())
}
fn remove_codex_experimental_bearer_token(config_text: &str) -> Result<String, AppError> {
pub fn remove_codex_experimental_bearer_token_if(
config_text: &str,
predicate: impl Fn(&str) -> bool,
) -> Result<String, AppError> {
if config_text.trim().is_empty() || !config_text.contains("experimental_bearer_token") {
return Ok(config_text.to_string());
}
@@ -755,14 +991,32 @@ fn remove_codex_experimental_bearer_token(config_text: &str) -> Result<String, A
.and_then(|table| table.get_mut(provider_id.as_str()))
.and_then(|item| item.as_table_mut())
{
provider_table.remove("experimental_bearer_token");
let should_remove = provider_table
.get("experimental_bearer_token")
.and_then(|item| item.as_str())
.map(str::trim)
.is_some_and(&predicate);
if should_remove {
provider_table.remove("experimental_bearer_token");
}
}
}
doc.as_table_mut().remove("experimental_bearer_token");
let should_remove_top_level = doc
.get("experimental_bearer_token")
.and_then(|item| item.as_str())
.map(str::trim)
.is_some_and(&predicate);
if should_remove_top_level {
doc.as_table_mut().remove("experimental_bearer_token");
}
Ok(doc.to_string())
}
fn remove_codex_experimental_bearer_token(config_text: &str) -> Result<String, AppError> {
remove_codex_experimental_bearer_token_if(config_text, |_| true)
}
/// Read the current Codex live settings as a `{ auth, config }` object.
///
/// Missing `auth.json` collapses to `{}` so a config-only third-party install
@@ -788,15 +1042,19 @@ pub fn read_codex_live_settings() -> Result<Value, AppError> {
/// Route a Codex live write between full auth+config or config-only.
///
/// Official providers with usable login material own `auth.json`; everyone
/// else only touches `config.toml` so the user's ChatGPT login cache survives
/// third-party switches.
/// Official providers with usable login material own `auth.json`. Third-party
/// providers only touch `config.toml` when the compatibility setting is enabled
/// so the user's ChatGPT login cache survives provider switches.
pub fn write_codex_live_for_provider(
category: Option<&str>,
auth: &Value,
config_text: Option<&str>,
) -> Result<(), AppError> {
if category == Some("official") && codex_auth_has_login_material(auth) {
let should_write_auth = (category == Some("official") && codex_auth_has_login_material(auth))
|| (category != Some("official")
&& !crate::settings::preserve_codex_official_auth_on_switch());
if should_write_auth {
write_codex_live_atomic(auth, config_text)
} else {
let live_config = prepare_codex_provider_live_config(auth, config_text.unwrap_or(""))?;
@@ -1677,4 +1935,95 @@ name = "any"
"entries lacking slug are skipped; a fully-skipped catalog yields None"
);
}
#[test]
fn codex_cli_candidates_are_non_empty() {
let candidates = codex_cli_candidates();
assert!(
candidates
.iter()
.any(|candidate| candidate == Path::new("codex")),
"codex CLI candidates must include the PATH entry"
);
}
#[test]
fn codex_cli_candidates_include_user_node_manager_bins() {
let temp_home = tempfile::tempdir().expect("create temp home");
let home = temp_home.path();
let expected = [
home.join(".nvm/versions/node/v22.14.0/bin/codex"),
home.join(".volta/bin/codex"),
home.join(".asdf/shims/codex"),
home.join(".local/share/mise/shims/codex"),
home.join(".local/share/fnm/node-versions/v22.14.0/installation/bin/codex"),
];
for candidate in &expected {
std::fs::create_dir_all(candidate.parent().expect("candidate parent"))
.expect("create candidate parent");
std::fs::write(candidate, "").expect("create candidate");
}
let mut candidates = Vec::new();
let mut seen = HashSet::new();
push_home_codex_cli_candidates(&mut candidates, &mut seen, home);
for candidate in expected {
assert!(
candidates.contains(&candidate),
"user-level Codex CLI candidate should be discovered: {}",
candidate.display()
);
}
}
#[test]
fn codex_cli_candidates_deduplicate_entries() {
let temp_home = tempfile::tempdir().expect("create temp home");
let home = temp_home.path();
let candidate = home.join(".volta/bin/codex");
std::fs::create_dir_all(candidate.parent().expect("candidate parent"))
.expect("create candidate parent");
std::fs::write(&candidate, "").expect("create candidate");
let mut candidates = Vec::new();
let mut seen = HashSet::new();
push_existing_codex_cli_candidate(&mut candidates, &mut seen, candidate.clone());
push_home_codex_cli_candidates(&mut candidates, &mut seen, home);
assert_eq!(
candidates.iter().filter(|path| **path == candidate).count(),
1,
"duplicate candidates should be removed"
);
}
#[test]
fn static_template_is_valid_json_with_slug() {
let template =
load_codex_model_template_static().expect("static template must parse as valid JSON");
assert_eq!(
template.get("slug").and_then(|v| v.as_str()),
Some("gpt-5.5"),
"static template slug must be gpt-5.5"
);
}
#[test]
fn static_template_has_required_keys() {
let template =
load_codex_model_template_static().expect("static template must parse as valid JSON");
for key in &[
"model_messages",
"base_instructions",
"context_window",
"display_name",
] {
assert!(
template.get(key).is_some(),
"static template must contain key '{key}'"
);
}
}
}
+133 -35
View File
@@ -249,8 +249,8 @@ fn finish_lifecycle_output(output: &std::process::Output) -> Result<(), String>
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = decode_command_output(&output.stderr);
let stdout = decode_command_output(&output.stdout);
let raw = if stderr.trim().is_empty() {
stdout.trim()
} else {
@@ -271,6 +271,81 @@ fn last_lines(text: &str, n: usize) -> String {
lines[start..].join("\n")
}
fn decode_command_output(bytes: &[u8]) -> String {
#[cfg(target_os = "windows")]
{
decode_windows_command_output(bytes)
}
#[cfg(not(target_os = "windows"))]
{
String::from_utf8_lossy(bytes).into_owned()
}
}
#[cfg(target_os = "windows")]
fn decode_windows_command_output(bytes: &[u8]) -> String {
if bytes.is_empty() {
return String::new();
}
if let Ok(text) = std::str::from_utf8(bytes) {
return text.to_string();
}
use windows_sys::Win32::Globalization::{GetACP, GetOEMCP, MultiByteToWideChar};
fn decode_codepage(bytes: &[u8], codepage: u32) -> Option<String> {
if codepage == 0 {
return None;
}
let input_len = i32::try_from(bytes.len()).ok()?;
unsafe {
let wide_len = MultiByteToWideChar(
codepage,
0,
bytes.as_ptr(),
input_len,
std::ptr::null_mut(),
0,
);
if wide_len <= 0 {
return None;
}
let mut wide = vec![0u16; wide_len as usize];
let written = MultiByteToWideChar(
codepage,
0,
bytes.as_ptr(),
input_len,
wide.as_mut_ptr(),
wide_len,
);
if written <= 0 {
return None;
}
Some(String::from_utf16_lossy(&wide[..written as usize]))
}
}
let oem_cp = unsafe { GetOEMCP() };
if let Some(decoded) = decode_codepage(bytes, oem_cp) {
return decoded;
}
let ansi_cp = unsafe { GetACP() };
if ansi_cp != oem_cp {
if let Some(decoded) = decode_codepage(bytes, ansi_cp) {
return decoded;
}
}
String::from_utf8_lossy(bytes).into_owned()
}
fn normalize_requested_tools(tools: &[String]) -> Vec<&'static str> {
let set: std::collections::HashSet<&str> = tools.iter().map(|s| s.as_str()).collect();
VALID_TOOLS
@@ -936,8 +1011,8 @@ fn try_get_version(tool: &str) -> ShellProbe {
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
let stdout = decode_command_output(&out.stdout).trim().to_string();
let stderr = decode_command_output(&out.stderr).trim().to_string();
if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if raw.is_empty() {
@@ -1051,8 +1126,8 @@ fn try_get_version_wsl(
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
let stdout = decode_command_output(&out.stdout).trim().to_string();
let stderr = decode_command_output(&out.stderr).trim().to_string();
if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if raw.is_empty() {
@@ -1431,8 +1506,43 @@ fn build_tool_search_paths(tool: &str) -> Vec<std::path::PathBuf> {
search_paths
}
#[cfg(target_os = "windows")]
fn is_windows_command_script(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat"))
.unwrap_or(false)
}
#[cfg(target_os = "windows")]
fn run_windows_tool_version_command(
tool_path: &Path,
new_path: &str,
) -> std::io::Result<std::process::Output> {
use std::process::Command;
if is_windows_command_script(tool_path) {
let path = tool_path.to_string_lossy();
let command = format!("call {} --version", win_quote_path_for_batch(&path));
let mut cmd = Command::new("cmd");
return cmd
.args(["/D", "/S", "/C"])
.raw_arg(&command)
.env("PATH", new_path)
.creation_flags(CREATE_NO_WINDOW)
.output();
}
Command::new(tool_path)
.arg("--version")
.env("PATH", new_path)
.creation_flags(CREATE_NO_WINDOW)
.output()
}
/// 扫描常见路径查找 CLI(PATH 主命令未命中时的兜底单探)。
fn scan_cli_version(tool: &str) -> ShellProbe {
#[cfg(not(target_os = "windows"))]
use std::process::Command;
let search_paths = build_tool_search_paths(tool);
@@ -1458,13 +1568,7 @@ fn scan_cli_version(tool: &str) -> ShellProbe {
}
#[cfg(target_os = "windows")]
let output = {
Command::new("cmd")
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
.env("PATH", &new_path)
.creation_flags(CREATE_NO_WINDOW)
.output()
};
let output = run_windows_tool_version_command(&tool_path, &new_path);
#[cfg(not(target_os = "windows"))]
let output = {
@@ -1475,8 +1579,8 @@ fn scan_cli_version(tool: &str) -> ShellProbe {
};
if let Ok(out) = output {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
let stdout = decode_command_output(&out.stdout).trim().to_string();
let stderr = decode_command_output(&out.stderr).trim().to_string();
if out.status.success() {
let raw = if stdout.is_empty() { &stderr } else { &stdout };
if !raw.is_empty() {
@@ -1588,7 +1692,7 @@ fn resolve_path_default(tool: &str) -> Option<std::path::PathBuf> {
if !out.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&out.stdout);
let raw = decode_command_output(&out.stdout);
// 不能死取第一行:交互式 .zshrc 可能先打印欢迎语(如 "🚀 Welcome back"),
// command -v 的真实路径在其后;取第一个 `/` 开头的行才稳。
let first = first_abs_path_line(&raw)?;
@@ -1607,7 +1711,7 @@ fn resolve_path_default(tool: &str) -> Option<std::path::PathBuf> {
if !out.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&out.stdout);
let raw = decode_command_output(&out.stdout);
let first = raw.lines().next()?.trim();
if first.is_empty() {
return None;
@@ -1619,6 +1723,7 @@ fn resolve_path_default(tool: &str) -> Option<std::path::PathBuf> {
/// `build_tool_search_paths`,但不在首个命中处停止——而是对每个去重后的真实
/// 可执行文件都跑一次 `--version`,从而能发现"升级写入 A 处、PATH 实际用 B 处"。
fn enumerate_tool_installations(tool: &str) -> Vec<ToolInstallation> {
#[cfg(not(target_os = "windows"))]
use std::process::Command;
let search_paths = build_tool_search_paths(tool);
@@ -1648,14 +1753,7 @@ fn enumerate_tool_installations(tool: &str) -> Vec<ToolInstallation> {
}
#[cfg(target_os = "windows")]
let output = {
use std::os::windows::process::CommandExt;
Command::new("cmd")
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
.env("PATH", &new_path)
.creation_flags(CREATE_NO_WINDOW)
.output()
};
let output = run_windows_tool_version_command(&tool_path, &new_path);
#[cfg(not(target_os = "windows"))]
let output = Command::new(&tool_path)
.arg("--version")
@@ -1664,14 +1762,14 @@ fn enumerate_tool_installations(tool: &str) -> Vec<ToolInstallation> {
let (version, runnable, error) = match output {
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
let stdout = decode_command_output(&out.stdout).trim().to_string();
let stderr = decode_command_output(&out.stderr).trim().to_string();
let raw = if stdout.is_empty() { stderr } else { stdout };
(Some(extract_version(&raw)), true, None)
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
let stderr = decode_command_output(&out.stderr).trim().to_string();
let stdout = decode_command_output(&out.stdout).trim().to_string();
let detail = if stderr.is_empty() { stdout } else { stderr };
let detail = detail.trim();
let error = if detail.is_empty() {
@@ -2542,7 +2640,7 @@ end tell"#,
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = decode_command_output(&output.stderr);
return Err(format!(
"Terminal.app 执行失败 (exit code: {:?}): {}",
output.status.code(),
@@ -2603,7 +2701,7 @@ fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = decode_command_output(&output.stderr);
return Err(format!(
"iTerm2 执行失败 (exit code: {:?}): {}",
output.status.code(),
@@ -2633,7 +2731,7 @@ fn launch_macos_ghostty(script_file: &std::path::Path) -> Result<(), String> {
.map_err(|e| format!("启动 Ghostty 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = decode_command_output(&output.stderr);
return Err(format!(
"Ghostty 启动失败 (exit code: {:?}): {}",
output.status.code(),
@@ -2666,7 +2764,7 @@ fn launch_macos_open_app(
.map_err(|e| format!("启动 {app_name} 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = decode_command_output(&output.stderr);
return Err(format!(
"{} 启动失败 (exit code: {:?}): {}",
app_name,
@@ -2718,7 +2816,7 @@ fn launch_macos_warp(script_file: &std::path::Path) -> Result<(), String> {
let output = cmd.output().map_err(|e| format!("启动 Warp 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = decode_command_output(&output.stderr);
return Err(format!(
"Warp 启动失败 (exit code: {:?}): {}",
output.status.code(),
@@ -2961,7 +3059,7 @@ fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), S
.map_err(|e| format!("启动 {} 失败: {e}", terminal_name))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = decode_command_output(&output.stderr);
return Err(format!(
"{} 启动失败 (exit code: {:?}): {}",
terminal_name,
+58 -35
View File
@@ -219,6 +219,17 @@ pub fn import_claude_desktop_providers_from_claude(
Ok(imported)
}
#[tauri::command]
pub fn ensure_claude_desktop_official_provider(state: State<'_, AppState>) -> Result<bool, String> {
state
.db
.ensure_official_seed_by_id(
crate::database::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID,
AppType::ClaudeDesktop,
)
.map_err(|e| e.to_string())
}
fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool {
let Some(env) = provider
.settings_config
@@ -398,6 +409,14 @@ pub async fn queryProviderUsage(
inner
}
/// Resolve `(base_url, api_key)` for native usage queries, delegating to the
/// per-app resolver on `Provider`. Missing provider → empty credentials.
fn resolve_native_credentials(app_type: &AppType, provider: Option<&Provider>) -> (String, String) {
provider
.map(|p| p.resolve_usage_credentials(app_type))
.unwrap_or_default()
}
async fn query_provider_usage_inner(
state: &AppState,
copilot_state: &CopilotAuthState,
@@ -455,25 +474,10 @@ async fn query_provider_usage_inner(
// ── Coding Plan 专用路径 ──
if template_type == TEMPLATE_TYPE_TOKEN_PLAN {
// 从供应商配置中提取 API Key 和 Base URL
let settings_config = provider
.map(|p| &p.settings_config)
.cloned()
.unwrap_or_default();
let env = settings_config.get("env");
let base_url = env
.and_then(|e| e.get("ANTHROPIC_BASE_URL"))
.and_then(|v| v.as_str())
.unwrap_or("");
let api_key = env
.and_then(|e| {
e.get("ANTHROPIC_AUTH_TOKEN")
.or_else(|| e.get("ANTHROPIC_API_KEY"))
})
.and_then(|v| v.as_str())
.unwrap_or("");
// 从供应商配置中提取 API Key 和 Base URL(按 app 区分存储格式)
let (base_url, api_key) = resolve_native_credentials(&app_type, provider);
let quota = crate::services::coding_plan::get_coding_plan_quota(base_url, api_key)
let quota = crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key)
.await
.map_err(|e| format!("Failed to query coding plan: {e}"))?;
@@ -515,24 +519,10 @@ async fn query_provider_usage_inner(
// ── 官方余额查询路径 ──
if template_type == TEMPLATE_TYPE_BALANCE {
let settings_config = provider
.map(|p| &p.settings_config)
.cloned()
.unwrap_or_default();
let env = settings_config.get("env");
let base_url = env
.and_then(|e| e.get("ANTHROPIC_BASE_URL"))
.and_then(|v| v.as_str())
.unwrap_or("");
let api_key = env
.and_then(|e| {
e.get("ANTHROPIC_AUTH_TOKEN")
.or_else(|| e.get("ANTHROPIC_API_KEY"))
})
.and_then(|v| v.as_str())
.unwrap_or("");
// 按 app 区分的凭据存储格式提取 Base URL 与 API Key
let (base_url, api_key) = resolve_native_credentials(&app_type, provider);
return crate::services::balance::get_balance(base_url, api_key)
return crate::services::balance::get_balance(&base_url, &api_key)
.await
.map_err(|e| format!("Failed to query balance: {e}"));
}
@@ -957,3 +947,36 @@ mod import_claude_desktop_tests {
);
}
}
#[cfg(test)]
mod native_query_credentials_tests {
use super::resolve_native_credentials;
use crate::app_config::AppType;
use crate::provider::Provider;
use serde_json::json;
#[test]
fn delegates_to_provider_for_codex() {
let provider = Provider::with_id(
"test".to_string(),
"Test".to_string(),
json!({
"auth": { "OPENAI_API_KEY": "sk-codex" },
"config": "model_provider = \"deepseek\"\n\
[model_providers.deepseek]\n\
base_url = \"https://api.deepseek.com\"\n",
}),
None,
);
let (base_url, api_key) = resolve_native_credentials(&AppType::Codex, Some(&provider));
assert_eq!(base_url, "https://api.deepseek.com");
assert_eq!(api_key, "sk-codex");
}
#[test]
fn missing_provider_yields_empty() {
let (base_url, api_key) = resolve_native_credentials(&AppType::Codex, None);
assert!(base_url.is_empty());
assert!(api_key.is_empty());
}
}
+1
View File
@@ -1105,6 +1105,7 @@ pub fn run() {
commands::get_claude_desktop_status,
commands::get_claude_desktop_default_routes,
commands::import_claude_desktop_providers_from_claude,
commands::ensure_claude_desktop_official_provider,
commands::get_claude_config_status,
commands::get_config_status,
commands::get_claude_code_config_path,
+294
View File
@@ -107,6 +107,108 @@ impl Provider {
.map(|s| s.enabled)
.unwrap_or(false)
}
/// Resolve `(base_url, api_key)` for native usage queries (balance /
/// coding-plan) from the stored provider config.
///
/// Each app persists credentials in a different shape, so callers must pass
/// the owning app type. This mirrors the frontend `getProviderCredentials`
/// in `UsageScriptModal.tsx`.
///
/// TODO: the env-only helpers in `services/provider/usage.rs`
/// (`extract_api_key_from_provider` / `extract_base_url_from_provider`)
/// duplicate this per-app logic on the JS-script path and could delegate
/// here in a follow-up to remove the remaining copy.
pub fn resolve_usage_credentials(
&self,
app_type: &crate::app_config::AppType,
) -> (String, String) {
use crate::app_config::AppType;
let settings = &self.settings_config;
let str_at =
|value: Option<&Value>| value.and_then(|v| v.as_str()).unwrap_or("").to_string();
// First present, non-empty string among `keys`, mirroring the frontend's
// `a || b || c` — JS `||` skips empty strings, and presets seed fields like
// `ANTHROPIC_AUTH_TOKEN` as present-but-empty placeholders, so a plain
// `.get().or_else()` chain (which only skips *absent* keys) would stop short.
fn first_non_empty(env: Option<&Value>, keys: &[&str]) -> String {
let Some(env) = env else {
return String::new();
};
for key in keys {
if let Some(s) = env.get(key).and_then(|v| v.as_str()) {
if !s.is_empty() {
return s.to_string();
}
}
}
String::new()
}
let (base_url, api_key) = match app_type {
// Codex keeps its key in `auth.OPENAI_API_KEY` and its base URL
// inside a TOML `config` string, not in an `env` map.
AppType::Codex => {
let auth = settings.get("auth");
let config_text = settings.get("config").and_then(|v| v.as_str());
let api_key = crate::codex_config::extract_codex_api_key(auth, config_text)
.unwrap_or_default();
let base_url = config_text
.and_then(crate::codex_config::extract_codex_base_url)
.unwrap_or_default();
(base_url, api_key)
}
// Gemini uses Google-specific env keys (with a legacy GOOGLE_API_KEY fallback).
AppType::Gemini => {
let env = settings.get("env");
let base_url = str_at(env.and_then(|e| e.get("GOOGLE_GEMINI_BASE_URL")));
let api_key = first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
(base_url, api_key)
}
// Hermes (config.yaml) flattens credentials at the top level, snake_case.
AppType::Hermes => (
str_at(settings.get("base_url")),
str_at(settings.get("api_key")),
),
// OpenClaw (openclaw.json) flattens credentials at the top level, camelCase.
AppType::OpenClaw => (
str_at(settings.get("baseUrl")),
str_at(settings.get("apiKey")),
),
// OpenCode (OMO) nests credentials under `options` (the SDK options object).
AppType::OpenCode => {
let options = settings.get("options");
(
str_at(options.and_then(|o| o.get("baseURL"))),
str_at(options.and_then(|o| o.get("apiKey"))),
)
}
// Claude and Claude Desktop both use the Anthropic-style env map, keeping
// the OpenRouter/Google key fallbacks the JS-script path relies on.
// Listed explicitly (not `_`) so a new AppType fails to compile here.
AppType::Claude | AppType::ClaudeDesktop => {
let env = settings.get("env");
let base_url = str_at(env.and_then(|e| e.get("ANTHROPIC_BASE_URL")));
let api_key = first_non_empty(
env,
&[
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
"OPENROUTER_API_KEY",
"GOOGLE_API_KEY",
],
);
(base_url, api_key)
}
};
// Normalize like the JS-script path (extract_base_url_from_provider) so a
// future delegation from services/provider/usage.rs is behavior-preserving
// and `{{baseUrl}}/path` concatenation never produces a double slash.
(base_url.trim_end_matches('/').to_string(), api_key)
}
}
/// 供应商管理器
@@ -1150,4 +1252,196 @@ mod tests {
assert!(toml.contains("base_url = \"https://example.com/openai\""));
assert!(!toml.contains("https://example.com/openai/v1"));
}
// ── resolve_usage_credentials (per-app credential extraction) ──
use crate::app_config::AppType;
fn provider_with(settings_config: serde_json::Value) -> Provider {
Provider::with_id("p".to_string(), "P".to_string(), settings_config, None)
}
#[test]
fn resolve_credentials_claude_env() {
let p = provider_with(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
"ANTHROPIC_AUTH_TOKEN": "sk-claude",
}
}));
assert_eq!(
p.resolve_usage_credentials(&AppType::Claude),
(
"https://api.deepseek.com/anthropic".to_string(),
"sk-claude".to_string()
)
);
}
#[test]
fn resolve_credentials_claude_openrouter_fallback() {
// OpenRouter-on-Claude keeps its key in OPENROUTER_API_KEY; the superset
// fallback must still find it (regression guard for the per-app refactor).
let p = provider_with(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1",
"OPENROUTER_API_KEY": "sk-or",
}
}));
let (base_url, api_key) = p.resolve_usage_credentials(&AppType::Claude);
assert_eq!(base_url, "https://openrouter.ai/api/v1");
assert_eq!(api_key, "sk-or");
}
#[test]
fn resolve_credentials_codex_auth_and_toml() {
let p = provider_with(json!({
"auth": { "OPENAI_API_KEY": "sk-codex" },
"config": "model_provider = \"deepseek\"\n\
[model_providers.deepseek]\n\
base_url = \"https://api.deepseek.com\"\n",
}));
assert_eq!(
p.resolve_usage_credentials(&AppType::Codex),
(
"https://api.deepseek.com".to_string(),
"sk-codex".to_string()
)
);
}
#[test]
fn resolve_credentials_gemini_env_with_google_fallback() {
let p = provider_with(json!({
"env": {
"GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com",
"GOOGLE_API_KEY": "g-legacy",
}
}));
let (base_url, api_key) = p.resolve_usage_credentials(&AppType::Gemini);
assert_eq!(base_url, "https://generativelanguage.googleapis.com");
assert_eq!(api_key, "g-legacy");
}
#[test]
fn resolve_credentials_claude_skips_empty_primary_key() {
// Presets seed ANTHROPIC_AUTH_TOKEN as a present-but-empty placeholder.
// The fallback chain must skip empty values (matching the frontend's
// `a || b` semantics), not just absent keys.
let p = provider_with(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1",
"ANTHROPIC_AUTH_TOKEN": "",
"ANTHROPIC_API_KEY": "",
"OPENROUTER_API_KEY": "sk-or",
}
}));
let (_, api_key) = p.resolve_usage_credentials(&AppType::Claude);
assert_eq!(api_key, "sk-or");
}
#[test]
fn resolve_credentials_gemini_skips_empty_primary_key() {
let p = provider_with(json!({
"env": {
"GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com",
"GEMINI_API_KEY": "",
"GOOGLE_API_KEY": "g-real",
}
}));
let (_, api_key) = p.resolve_usage_credentials(&AppType::Gemini);
assert_eq!(api_key, "g-real");
}
#[test]
fn resolve_credentials_hermes_snake_case() {
let p = provider_with(json!({
"base_url": "https://api.deepseek.com",
"api_key": "sk-hermes",
}));
assert_eq!(
p.resolve_usage_credentials(&AppType::Hermes),
(
"https://api.deepseek.com".to_string(),
"sk-hermes".to_string()
)
);
}
#[test]
fn resolve_credentials_openclaw_camel_case() {
let p = provider_with(json!({
"baseUrl": "https://api.deepseek.com",
"apiKey": "sk-openclaw",
}));
assert_eq!(
p.resolve_usage_credentials(&AppType::OpenClaw),
(
"https://api.deepseek.com".to_string(),
"sk-openclaw".to_string()
)
);
}
#[test]
fn resolve_credentials_opencode_options() {
// OpenCode (OMO) nests creds under options.{baseURL,apiKey}; useOpencodeFormState
// writes config.options.apiKey, so the stored provider keeps them there.
let p = provider_with(json!({
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "https://api.deepseek.com/v1",
"apiKey": "sk-opencode",
"setCacheKey": true,
}
}));
assert_eq!(
p.resolve_usage_credentials(&AppType::OpenCode),
(
"https://api.deepseek.com/v1".to_string(),
"sk-opencode".to_string()
)
);
}
#[test]
fn resolve_credentials_claude_desktop_uses_env() {
// ClaudeDesktop persists the Anthropic env shape (ClaudeDesktopProviderForm
// reads env.ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN), so it resolves via
// the default env branch — it is NOT unsupported.
let p = provider_with(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
"ANTHROPIC_AUTH_TOKEN": "sk-desktop",
}
}));
assert_eq!(
p.resolve_usage_credentials(&AppType::ClaudeDesktop),
(
"https://api.deepseek.com/anthropic".to_string(),
"sk-desktop".to_string()
)
);
}
#[test]
fn resolve_credentials_trims_trailing_slash_on_base_url() {
let p = provider_with(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic/",
"ANTHROPIC_AUTH_TOKEN": "sk-claude",
}
}));
let (base_url, _) = p.resolve_usage_credentials(&AppType::Claude);
assert_eq!(base_url, "https://api.deepseek.com/anthropic");
}
#[test]
fn resolve_credentials_missing_fields_yield_empty() {
let p = provider_with(json!({}));
assert_eq!(
p.resolve_usage_credentials(&AppType::Claude),
(String::new(), String::new())
);
}
}
+41 -4
View File
@@ -1,6 +1,6 @@
//! 错误类型到 HTTP 状态码的映射
//!
//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录
//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录和手动构建错误响应
use super::ProxyError;
@@ -12,14 +12,21 @@ use super::ProxyError;
/// - 连接失败:502 Bad Gateway
/// - 无可用 Provider503 Service Unavailable
/// - 重试耗尽:503 Service Unavailable
/// - 认证错误:401 Unauthorized
/// - 配置/请求错误:400 Bad Request
/// - 转换错误:422 Unprocessable Entity
/// - 其他错误:500 Internal Server Error
pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
match error {
// 服务状态错误:与 IntoResponse 保持一致
ProxyError::AlreadyRunning => 409,
ProxyError::NotRunning => 503,
// 上游错误:使用实际状态码
ProxyError::UpstreamError { status, .. } => *status,
// 超时错误:504 Gateway Timeout
ProxyError::Timeout(_) => 504,
ProxyError::Timeout(_) | ProxyError::StreamIdleTimeout(_) => 504,
// 转发失败/连接失败:502 Bad Gateway
ProxyError::ForwardFailed(_) => 502,
@@ -39,11 +46,17 @@ pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
// Provider 不健康:503 Service Unavailable
ProxyError::ProviderUnhealthy(_) => 503,
// 配置错误/无效请求:400 Bad Request
ProxyError::ConfigError(_) | ProxyError::InvalidRequest(_) => 400,
// 认证错误:401 Unauthorized
ProxyError::AuthError(_) => 401,
// 数据库错误:500 Internal Server Error
ProxyError::DatabaseError(_) => 500,
// 转换错误:500 Internal Server Error
ProxyError::TransformError(_) => 500,
// 转换错误:422 Unprocessable Entity
ProxyError::TransformError(_) => 422,
// 其他未知错误:500 Internal Server Error
_ => 500,
@@ -104,6 +117,30 @@ mod tests {
assert_eq!(map_proxy_error_to_status(&error), 503);
}
#[test]
fn test_map_status_matches_proxy_error_response_semantics() {
assert_eq!(
map_proxy_error_to_status(&ProxyError::AuthError("bad token".to_string())),
401
);
assert_eq!(
map_proxy_error_to_status(&ProxyError::ConfigError("bad config".to_string())),
400
);
assert_eq!(
map_proxy_error_to_status(&ProxyError::InvalidRequest("bad request".to_string())),
400
);
assert_eq!(
map_proxy_error_to_status(&ProxyError::TransformError("bad transform".to_string())),
422
);
assert_eq!(
map_proxy_error_to_status(&ProxyError::StreamIdleTimeout(30)),
504
);
}
#[test]
fn test_get_error_message() {
let error = ProxyError::UpstreamError {
+225 -11
View File
@@ -18,7 +18,7 @@ use super::{
providers::{
codex_chat_history::record_responses_sse_stream, get_adapter, get_claude_api_format,
streaming::create_anthropic_sse_stream,
streaming_codex_chat::create_responses_sse_stream_from_chat,
streaming_codex_chat::create_responses_sse_stream_from_chat_with_context,
streaming_gemini::create_anthropic_sse_stream_from_gemini,
streaming_responses::create_anthropic_sse_stream_from_responses, transform,
transform_codex_chat, transform_gemini, transform_responses,
@@ -522,7 +522,7 @@ pub async fn handle_chat_completions(
ctx.provider = provider;
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
return build_codex_proxy_error_response(&ctx, &endpoint, &err.error);
}
};
@@ -566,6 +566,7 @@ pub async fn handle_responses(
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let codex_tool_context = transform_codex_chat::build_codex_tool_context_from_request(&body);
let forwarder = ctx.create_forwarder(&state);
let mut result = match forwarder
@@ -586,7 +587,7 @@ pub async fn handle_responses(
ctx.provider = provider;
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
return build_codex_proxy_error_response(&ctx, &endpoint, &err.error);
}
};
@@ -601,6 +602,7 @@ pub async fn handle_responses(
&state,
is_stream,
connection_guard,
codex_tool_context,
)
.await;
}
@@ -641,6 +643,7 @@ pub async fn handle_responses_compact(
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let codex_tool_context = transform_codex_chat::build_codex_tool_context_from_request(&body);
let forwarder = ctx.create_forwarder(&state);
let mut result = match forwarder
@@ -661,7 +664,7 @@ pub async fn handle_responses_compact(
ctx.provider = provider;
}
log_forward_error(&state, &ctx, is_stream, &err.error);
return Err(err.error);
return build_codex_proxy_error_response(&ctx, &endpoint, &err.error);
}
};
@@ -676,6 +679,7 @@ pub async fn handle_responses_compact(
&state,
is_stream,
connection_guard,
codex_tool_context,
)
.await;
}
@@ -696,6 +700,7 @@ async fn handle_codex_chat_to_responses_transform(
state: &ProxyState,
is_stream: bool,
connection_guard: Option<ActiveConnectionGuard>,
tool_context: transform_codex_chat::CodexToolContext,
) -> Result<axum::response::Response, ProxyError> {
let status = response.status();
@@ -708,7 +713,7 @@ async fn handle_codex_chat_to_responses_transform(
if is_stream || response.is_sse() {
let stream = response.bytes_stream();
let sse_stream = create_responses_sse_stream_from_chat(stream);
let sse_stream = create_responses_sse_stream_from_chat_with_context(stream, tool_context);
let sse_stream = record_responses_sse_stream(sse_stream, state.codex_chat_history.clone());
let usage_collector = if usage_logging_enabled(state) {
@@ -790,11 +795,14 @@ async fn handle_codex_chat_to_responses_transform(
log::error!("[Codex] 解析 Chat 上游响应失败: {e}, body: {body_str}");
ProxyError::TransformError(format!("Failed to parse upstream chat response: {e}"))
})?;
let responses_response = transform_codex_chat::chat_completion_to_response(chat_response)
.map_err(|e| {
log::error!("[Codex] Chat → Responses 响应转换失败: {e}");
e
})?;
let responses_response = transform_codex_chat::chat_completion_to_response_with_context(
chat_response,
&tool_context,
)
.map_err(|e| {
log::error!("[Codex] Chat → Responses 响应转换失败: {e}");
e
})?;
state
.codex_chat_history
.record_response(&responses_response)
@@ -926,6 +934,175 @@ async fn handle_codex_chat_error_response(
})
}
/// 把转发层(非上游响应)的失败构造成富化的 Codex 错误响应。
///
/// 与 `handle_codex_chat_error_response`(处理上游真实错误响应、复制上游头)不同,
/// 这里没有上游响应可参照,只产出一个 `application/json` 错误体。状态码走
/// `map_proxy_error_to_status`,该函数已与 `ProxyError::into_response` 对齐。
///
/// 注意:`endpoint` 经 `endpoint_with_query` 可能携带 query(如 `?beta=true`)并被
/// 原样写入错误体。当前 Codex 端点不在 query 里放凭证,故安全;若将来复用到
/// query 携带密钥的端点(如 Gemini 的 `?key=`),需先脱敏再回显。
fn build_codex_proxy_error_response(
ctx: &RequestContext,
endpoint: &str,
error: &ProxyError,
) -> Result<axum::response::Response, ProxyError> {
let status = axum::http::StatusCode::from_u16(map_proxy_error_to_status(error))
.unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
let body = codex_proxy_error_json(&ctx.provider.name, &ctx.request_model, endpoint, error);
let body = serde_json::to_vec(&body).map_err(|e| {
log::error!("[Codex] 序列化代理错误体失败: {e}");
ProxyError::Internal(format!("Failed to serialize proxy error: {e}"))
})?;
axum::response::Response::builder()
.status(status)
.header(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/json"),
)
.body(axum::body::Body::from(body))
.map_err(|e| {
log::error!("[Codex] 构建代理错误响应失败: {e}");
ProxyError::Internal(format!("Failed to build proxy error response: {e}"))
})
}
fn codex_proxy_error_json(
provider_name: &str,
request_model: &str,
endpoint: &str,
error: &ProxyError,
) -> Value {
let (mut body, upstream_status) = match error {
ProxyError::UpstreamError { status, body } => {
let parsed_body = body
.as_deref()
.map(|body| serde_json::from_str::<Value>(body).unwrap_or_else(|_| json!(body)));
(
transform_codex_chat::chat_error_to_response_error(parsed_body.as_ref()),
Some(*status),
)
}
_ => (
json!({
"error": {
"message": get_error_message(error),
"type": "proxy_error",
"code": codex_proxy_error_code(error),
"param": Value::Null,
}
}),
None,
),
};
let Some(error_obj) = body
.get_mut("error")
.and_then(|value| value.as_object_mut())
else {
return body;
};
let cause = error_obj
.get("message")
.and_then(|value| value.as_str())
.map(ToString::to_string)
.filter(|message| !message.trim().is_empty())
.unwrap_or_else(|| get_error_message(error));
let status_fragment = upstream_status
.map(|status| format!("; upstream_status: HTTP {status}"))
.unwrap_or_default();
let message = format!(
"CC Switch local proxy failed while handling Codex endpoint {endpoint}. Provider: {provider_name}; model: {request_model}{status_fragment}; cause: {cause}"
);
error_obj.insert(
"message".to_string(),
Value::String(compact_error_message(&message, 1800)),
);
if error_obj
.get("type")
.and_then(|value| value.as_str())
.map(|value| value.trim().is_empty())
.unwrap_or(true)
{
error_obj.insert("type".to_string(), Value::String("proxy_error".to_string()));
}
if error_obj.get("code").map(Value::is_null).unwrap_or(true) {
error_obj.insert(
"code".to_string(),
Value::String(codex_proxy_error_code(error).to_string()),
);
}
if !error_obj.contains_key("param") {
error_obj.insert("param".to_string(), Value::Null);
}
error_obj.insert(
"provider".to_string(),
Value::String(provider_name.to_string()),
);
error_obj.insert(
"model".to_string(),
Value::String(request_model.to_string()),
);
// 仅用于 Codex 本地路由;不要复用到 query 可能携带凭证的端点。
error_obj.insert("endpoint".to_string(), Value::String(endpoint.to_string()));
if let Some(status) = upstream_status {
error_obj.insert(
"upstream_status".to_string(),
Value::Number(serde_json::Number::from(status)),
);
}
body
}
fn codex_proxy_error_code(error: &ProxyError) -> &'static str {
match error {
ProxyError::ForwardFailed(_) => "cc_switch_forward_failed",
ProxyError::Timeout(_) | ProxyError::StreamIdleTimeout(_) => "cc_switch_timeout",
ProxyError::NoAvailableProvider => "cc_switch_no_available_provider",
ProxyError::AllProvidersCircuitOpen => "cc_switch_all_providers_circuit_open",
ProxyError::NoProvidersConfigured => "cc_switch_no_providers_configured",
ProxyError::MaxRetriesExceeded => "cc_switch_max_retries_exceeded",
ProxyError::ProviderUnhealthy(_) => "cc_switch_provider_unhealthy",
ProxyError::ConfigError(_) => "cc_switch_config_error",
ProxyError::TransformError(_) => "cc_switch_transform_error",
ProxyError::InvalidRequest(_) => "cc_switch_invalid_request",
ProxyError::AuthError(_) => "cc_switch_auth_error",
ProxyError::UpstreamError { .. } => "cc_switch_upstream_error",
ProxyError::DatabaseError(_) => "cc_switch_database_error",
ProxyError::Internal(_) => "cc_switch_internal_error",
ProxyError::AlreadyRunning
| ProxyError::NotRunning
| ProxyError::BindFailed(_)
| ProxyError::StopTimeout
| ProxyError::StopFailed(_) => "cc_switch_proxy_error",
}
}
fn compact_error_message(message: &str, max_chars: usize) -> String {
let normalized = message.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.chars().count() <= max_chars {
return normalized;
}
let truncated = normalized
.chars()
.take(max_chars)
.collect::<String>()
.trim_end()
.to_string();
format!("{truncated}…(truncated)")
}
// ============================================================================
// Gemini API 处理器
// ============================================================================
@@ -1176,7 +1353,10 @@ async fn log_usage(
#[cfg(test)]
mod tests {
use super::{responses_sse_to_response_value, should_use_claude_transform_streaming};
use super::{
codex_proxy_error_json, responses_sse_to_response_value,
should_use_claude_transform_streaming,
};
use crate::proxy::ProxyError;
#[test]
@@ -1263,4 +1443,38 @@ data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\"}}\n
assert!(responses_sse_to_response_value(sse).is_err());
}
#[test]
fn codex_proxy_forward_error_includes_context_and_cause() {
let error = ProxyError::ForwardFailed("连接失败: dns lookup failed".to_string());
let body = codex_proxy_error_json("DeepSeek", "deepseek-chat", "/responses", &error);
let message = body["error"]["message"].as_str().unwrap();
assert!(message.contains("CC Switch local proxy failed"));
assert!(message.contains("DeepSeek"));
assert!(message.contains("deepseek-chat"));
assert!(message.contains("/responses"));
assert!(message.contains("dns lookup failed"));
assert_eq!(body["error"]["code"], "cc_switch_forward_failed");
assert_eq!(body["error"]["provider"], "DeepSeek");
assert_eq!(body["error"]["model"], "deepseek-chat");
}
#[test]
fn codex_proxy_upstream_error_normalizes_nonstandard_body() {
let error = ProxyError::UpstreamError {
status: 502,
body: Some(
r#"{"base_resp":{"status_code":2013,"status_msg":"upstream gateway failed"}}"#
.to_string(),
),
};
let body = codex_proxy_error_json("MiniMax", "abab6.5s", "/responses", &error);
let message = body["error"]["message"].as_str().unwrap();
assert!(message.contains("upstream_status: HTTP 502"));
assert!(message.contains("upstream gateway failed"));
assert_eq!(body["error"]["code"], 2013);
assert_eq!(body["error"]["upstream_status"], 502);
}
}
+41 -15
View File
@@ -21,6 +21,8 @@ use serde_json::{json, Value};
const ANTHROPIC_THINKING_PLACEHOLDER: &str = "tool call";
const ANTHROPIC_REDACTED_THINKING_PLACEHOLDER: &str = "[redacted thinking]";
// Keep hints lowercase; matching lowercases only the input value.
const REASONING_VENDOR_HINTS: &[&str] = &["moonshot", "kimi", "deepseek", "mimo", "xiaomimimo"];
/// 获取 Claude 供应商的 API 格式
///
@@ -86,18 +88,11 @@ pub fn claude_api_format_needs_transform(api_format: &str) -> bool {
)
}
fn is_reasoning_content_compatible_identifier(value: &str) -> bool {
fn is_reasoning_vendor_identifier(value: &str) -> bool {
let value = value.to_ascii_lowercase();
value.contains("moonshot")
|| value.contains("kimi")
|| value.contains("deepseek")
|| value.contains("mimo")
|| value.contains("xiaomimimo")
}
fn is_anthropic_tool_thinking_history_identifier(value: &str) -> bool {
let value = value.to_ascii_lowercase();
value.contains("deepseek") || value.contains("mimo") || value.contains("xiaomimimo")
REASONING_VENDOR_HINTS
.iter()
.any(|hint| value.contains(hint))
}
fn should_normalize_anthropic_tool_thinking_history(
@@ -112,7 +107,7 @@ fn should_normalize_anthropic_tool_thinking_history(
if body
.get("model")
.and_then(|m| m.as_str())
.is_some_and(is_anthropic_tool_thinking_history_identifier)
.is_some_and(is_reasoning_vendor_identifier)
{
return true;
}
@@ -129,7 +124,7 @@ fn should_normalize_anthropic_tool_thinking_history(
]
.into_iter()
.flatten()
.any(is_anthropic_tool_thinking_history_identifier)
.any(is_reasoning_vendor_identifier)
}
/// DeepSeek's Anthropic-compatible endpoint requires thinking history to be
@@ -224,7 +219,7 @@ fn should_preserve_reasoning_content_for_openai_chat(provider: &Provider, body:
if body
.get("model")
.and_then(|m| m.as_str())
.is_some_and(is_reasoning_content_compatible_identifier)
.is_some_and(is_reasoning_vendor_identifier)
{
return true;
}
@@ -243,7 +238,7 @@ fn should_preserve_reasoning_content_for_openai_chat(provider: &Provider, body:
base_urls
.into_iter()
.flatten()
.any(is_reasoning_content_compatible_identifier)
.any(is_reasoning_vendor_identifier)
}
pub fn transform_claude_request_for_api_format(
@@ -2000,6 +1995,37 @@ mod tests {
assert_eq!(content[2]["type"], "tool_use");
}
#[test]
fn test_kimi_anthropic_tool_history_injects_missing_thinking() {
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.kimi.com/coding",
"ANTHROPIC_API_KEY": "test-key"
}
}));
let mut body = json!({
"model": "kimi-for-coding",
"messages": [{
"role": "assistant",
"content": [
{"type": "tool_use", "id": "call_123", "name": "read_file", "input": {"path": "README.md"}}
]
}]
});
let changed = normalize_anthropic_tool_thinking_history_for_provider(
&mut body,
&provider,
"anthropic",
);
assert!(changed);
let content = body["messages"][0]["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "thinking");
assert_eq!(content[0]["thinking"], ANTHROPIC_THINKING_PLACEHOLDER);
assert_eq!(content[1]["type"], "tool_use");
}
#[test]
fn test_deepseek_anthropic_tool_history_rewrites_redacted_thinking() {
let provider = create_provider(json!({
+3 -16
View File
@@ -392,22 +392,9 @@ fn extract_codex_model_from_toml(config_text: &str) -> Option<String> {
}
fn extract_codex_base_url_from_toml(config_text: &str) -> Option<String> {
let doc = config_text.parse::<TomlValue>().ok()?;
if let Some(active_provider) = doc.get("model_provider").and_then(|v| v.as_str()) {
if let Some(base_url) = doc
.get("model_providers")
.and_then(|providers| providers.get(active_provider))
.and_then(|provider| provider.get("base_url"))
.and_then(|v| v.as_str())
{
return Some(base_url.to_string());
}
}
doc.get("base_url")
.and_then(|v| v.as_str())
.map(ToString::to_string)
// Canonical parser lives in codex_config; keep this thin alias so the
// proxy hot path and the usage-credential resolver share one implementation.
crate::codex_config::extract_codex_base_url(config_text)
}
impl CodexAdapter {
@@ -170,6 +170,25 @@ pub(crate) fn response_function_call_item(
item
}
pub(crate) fn response_function_call_item_with_namespace(
item_id: &str,
status: &str,
call_id: &str,
name: &str,
namespace: Option<&str>,
arguments: &str,
reasoning: Option<&str>,
) -> Value {
let mut item =
response_function_call_item(item_id, status, call_id, name, arguments, reasoning);
if let Some(namespace) = namespace.filter(|value| !value.is_empty()) {
if let Some(obj) = item.as_object_mut() {
obj.insert("namespace".to_string(), json!(namespace));
}
}
item
}
pub(crate) fn response_item_call_id(item: &Value) -> Option<String> {
item.get("call_id")
.or_else(|| item.get("id"))
@@ -2,11 +2,13 @@
use super::{
codex_chat_common::{
extract_reasoning_field_text, response_function_call_item, split_leading_think_block,
strip_leading_think_open_tag,
extract_reasoning_field_text, split_leading_think_block, strip_leading_think_open_tag,
},
transform_codex_chat::{
chat_usage_to_responses_usage, response_id_from_chat_id, response_status_from_finish_reason,
chat_usage_to_responses_usage, custom_tool_input_from_chat_arguments,
response_id_from_chat_id, response_status_from_finish_reason,
response_tool_call_item_from_chat_name, response_tool_call_item_id_from_chat_name,
CodexToolContext,
},
};
use crate::proxy::json_canonical::canonicalize_tool_arguments_str;
@@ -75,6 +77,7 @@ struct ChatToResponsesState {
output_items: Vec<(u32, Value)>,
latest_usage: Option<Value>,
finish_reason: Option<String>,
tool_context: CodexToolContext,
}
impl Default for ChatToResponsesState {
@@ -93,11 +96,19 @@ impl Default for ChatToResponsesState {
output_items: Vec::new(),
latest_usage: None,
finish_reason: None,
tool_context: CodexToolContext::default(),
}
}
}
impl ChatToResponsesState {
fn with_tool_context(tool_context: CodexToolContext) -> Self {
Self {
tool_context,
..Self::default()
}
}
fn handle_chat_chunk(&mut self, chunk: &Value) -> Vec<Bytes> {
let mut events = Vec::new();
@@ -410,6 +421,7 @@ impl ChatToResponsesState {
let mut output_index = None;
let mut item_id = String::new();
let mut pending_arguments = String::new();
let current_name: String;
{
let state = self.tools.entry(chat_index).or_default();
@@ -436,8 +448,10 @@ impl ChatToResponsesState {
output_index = state.output_index;
item_id = state.item_id.clone();
}
current_name = state.name.clone();
}
let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&current_name);
let mut events = Vec::new();
if should_add {
@@ -453,16 +467,22 @@ impl ChatToResponsesState {
state.name = "unknown_tool".to_string();
}
state.output_index = Some(assigned);
state.item_id = format!("fc_{}", state.call_id);
let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&state.name);
state.item_id = response_tool_call_item_id_from_chat_name(
&state.call_id,
&state.name,
&self.tool_context,
);
item_id = state.item_id.clone();
let item = response_function_call_item(
let item = response_tool_call_item_from_chat_name(
&item_id,
"in_progress",
&state.call_id,
&state.name,
"",
Some(&state.reasoning_content),
&self.tool_context,
);
events.push(sse_event(
@@ -474,7 +494,7 @@ impl ChatToResponsesState {
}),
));
if !pending_arguments.is_empty() {
if !pending_arguments.is_empty() && !is_custom_tool {
events.push(sse_event(
"response.function_call_arguments.delta",
json!({
@@ -485,7 +505,7 @@ impl ChatToResponsesState {
}),
));
}
} else if !args_delta.is_empty() {
} else if !args_delta.is_empty() && !is_custom_tool {
if let Some(output_index) = output_index {
events.push(sse_event(
"response.function_call_arguments.delta",
@@ -668,14 +688,19 @@ impl ChatToResponsesState {
state.name = "unknown_tool".to_string();
}
state.output_index = Some(assigned);
state.item_id = format!("fc_{}", state.call_id);
let item = response_function_call_item(
state.item_id = response_tool_call_item_id_from_chat_name(
&state.call_id,
&state.name,
&self.tool_context,
);
let item = response_tool_call_item_from_chat_name(
&state.item_id,
"in_progress",
&state.call_id,
&state.name,
"",
Some(&state.reasoning_content),
&self.tool_context,
);
add_event = Some(sse_event(
"response.output_item.added",
@@ -696,26 +721,52 @@ impl ChatToResponsesState {
};
let output_index = state.output_index.unwrap_or(0);
let arguments = canonicalize_tool_arguments_str(&state.arguments);
let item = response_function_call_item(
let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&state.name);
let item = response_tool_call_item_from_chat_name(
&state.item_id,
"completed",
&state.call_id,
&state.name,
&arguments,
Some(&state.reasoning_content),
&self.tool_context,
);
state.done = true;
self.output_items.push((output_index, item.clone()));
events.push(sse_event(
"response.function_call_arguments.done",
json!({
"type": "response.function_call_arguments.done",
"item_id": state.item_id,
"output_index": output_index,
"arguments": arguments
}),
));
if is_custom_tool {
let input = custom_tool_input_from_chat_arguments(&arguments);
if !input.is_empty() {
events.push(sse_event(
"response.custom_tool_call_input.delta",
json!({
"type": "response.custom_tool_call_input.delta",
"item_id": state.item_id,
"output_index": output_index,
"delta": input.clone()
}),
));
}
events.push(sse_event(
"response.custom_tool_call_input.done",
json!({
"type": "response.custom_tool_call_input.done",
"item_id": state.item_id,
"output_index": output_index,
"input": input
}),
));
} else {
events.push(sse_event(
"response.function_call_arguments.done",
json!({
"type": "response.function_call_arguments.done",
"item_id": state.item_id,
"output_index": output_index,
"arguments": arguments
}),
));
}
events.push(sse_event(
"response.output_item.done",
json!({
@@ -810,13 +861,23 @@ fn leading_think_prefix_decision(buffer: &str) -> ThinkPrefixDecision {
}
/// Create a stream that converts Chat Completions SSE chunks into Responses SSE events.
#[allow(dead_code)]
pub fn create_responses_sse_stream_from_chat<E: std::error::Error + Send + 'static>(
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
create_responses_sse_stream_from_chat_with_context(stream, CodexToolContext::default())
}
/// Create a stream that converts Chat Completions SSE chunks into Responses SSE
/// events while restoring Codex tool namespace/custom/tool_search metadata.
pub fn create_responses_sse_stream_from_chat_with_context<E: std::error::Error + Send + 'static>(
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
tool_context: CodexToolContext,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
async_stream::stream! {
let mut buffer = String::new();
let mut utf8_remainder: Vec<u8> = Vec::new();
let mut state = ChatToResponsesState::default();
let mut state = ChatToResponsesState::with_tool_context(tool_context);
let mut stream_failed = false;
tokio::pin!(stream);
@@ -929,12 +990,16 @@ mod tests {
use futures::{stream, StreamExt};
async fn collect(chunks: Vec<&str>) -> String {
collect_with_context(chunks, CodexToolContext::default()).await
}
async fn collect_with_context(chunks: Vec<&str>, tool_context: CodexToolContext) -> String {
let chunks: Vec<Result<Bytes, std::io::Error>> = chunks
.into_iter()
.map(|chunk| Ok(Bytes::copy_from_slice(chunk.as_bytes())))
.collect();
let upstream = stream::iter(chunks);
let converted = create_responses_sse_stream_from_chat(upstream);
let converted = create_responses_sse_stream_from_chat_with_context(upstream, tool_context);
let bytes: Vec<Bytes> = converted.map(|item| item.unwrap()).collect().await;
String::from_utf8(bytes.concat()).unwrap()
}
@@ -1011,6 +1076,35 @@ mod tests {
assert!(output.contains("\"call_id\":\"call_1\""));
}
#[tokio::test]
async fn restores_custom_tool_input_stream_events() {
let request = json!({
"model": "gpt-5.4",
"tools": [{ "type": "custom", "name": "exec" }]
});
let context =
super::super::transform_codex_chat::build_codex_tool_context_from_request(&request);
let output = collect_with_context(
vec![
"data: {\"id\":\"chatcmpl_custom\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_custom\",\"type\":\"function\",\"function\":{\"name\":\"exec\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_custom\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"input\\\":\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_custom\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"ls -la\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
],
context,
)
.await;
assert!(output.contains("event: response.custom_tool_call_input.delta"));
assert!(output.contains("event: response.custom_tool_call_input.done"));
assert!(!output.contains("event: response.function_call_arguments.delta"));
assert!(!output.contains("event: response.function_call_arguments.done"));
assert!(output.contains("\"id\":\"ctc_call_custom\""));
assert!(output.contains("\"type\":\"custom_tool_call\""));
assert!(output.contains("\"name\":\"exec\""));
assert!(output.contains("\"input\":\"ls -la\""));
}
#[tokio::test]
async fn canonicalizes_streamed_tool_call_arguments_on_done_events() {
let output = collect(vec![
@@ -1039,6 +1133,68 @@ mod tests {
assert!(output.contains("\"reasoning_content\":\"Need file.\""));
}
#[tokio::test]
async fn restores_namespace_on_streamed_tool_call_items() {
let request = json!({
"model": "gpt-5.4",
"input": [{
"type": "tool_search_output",
"call_id": "call_tool_search_1",
"tools": [{
"type": "namespace",
"name": "mcp__codex_apps__gmail",
"tools": [{
"type": "function",
"name": "_search_emails",
"description": "Search Gmail.",
"parameters": {"type": "object"}
}]
}]
}]
});
let context =
super::super::transform_codex_chat::build_codex_tool_context_from_request(&request);
let output = collect_with_context(
vec![
"data: {\"id\":\"chatcmpl_gmail\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_gmail\",\"type\":\"function\",\"function\":{\"name\":\"mcp__codex_apps__gmail___search_emails\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_gmail\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"query\\\":\\\"in:inbox\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
],
context,
)
.await;
assert!(output.contains("\"type\":\"function_call\""));
assert!(output.contains("\"namespace\":\"mcp__codex_apps__gmail\""));
assert!(output.contains("\"name\":\"_search_emails\""));
assert!(output.contains(r#""arguments":"{\"query\":\"in:inbox\"}""#));
}
#[tokio::test]
async fn restores_tool_search_on_streamed_tool_call_items() {
let request = json!({
"model": "gpt-5.4",
"tools": [{"type": "tool_search"}],
"input": "Search for Gmail tools."
});
let context =
super::super::transform_codex_chat::build_codex_tool_context_from_request(&request);
let output = collect_with_context(
vec![
"data: {\"id\":\"chatcmpl_tool_search\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_tool_search_1\",\"type\":\"function\",\"function\":{\"name\":\"tool_search\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_tool_search\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"query\\\":\\\"Gmail search emails\\\",\\\"limit\\\":10}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
],
context,
)
.await;
assert!(output.contains("\"type\":\"tool_search_call\""));
assert!(output.contains("\"execution\":\"client\""));
assert!(output.contains("\"call_id\":\"call_tool_search_1\""));
assert!(output.contains("\"query\":\"Gmail search emails\""));
}
#[tokio::test]
async fn stream_error_emits_failed_without_completed() {
let upstream = stream::iter(vec![Err::<Bytes, std::io::Error>(std::io::Error::other(
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+133 -13
View File
@@ -596,6 +596,19 @@ fn restore_live_settings_for_provider_backfill(
);
}
// `modelCatalog` is a cc-switchprivate field whose SSOT is the DB. Live's
// `config.toml` only carries a lossy projection (`model_catalog_json` →
// generated catalog file) that proxy takeover/restore cycles and Codex.app
// config rewrites can drop, so `read_live_settings` may reconstruct it as
// absent. Never let a switch-away backfill from Live erase the stored
// mapping: prefer the DB provider's `modelCatalog`, falling back to whatever
// Live reconstructed only when the DB has none.
if let Some(stored_catalog) = provider.settings_config.get("modelCatalog") {
if let Some(obj) = settings.as_object_mut() {
obj.insert("modelCatalog".to_string(), stored_catalog.clone());
}
}
settings
}
@@ -910,6 +923,48 @@ pub(crate) fn sync_current_provider_for_app_to_live(
Ok(())
}
fn sync_current_provider_for_app_respecting_takeover(
state: &AppState,
app_type: &AppType,
) -> Result<(), AppError> {
let current_id = match crate::settings::get_effective_current_provider(&state.db, app_type)? {
Some(id) => id,
None => return Ok(()),
};
let providers = state.db.get_all_providers(app_type.as_str())?;
let Some(provider) = providers.get(&current_id) else {
return Ok(());
};
let has_live_backup = futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
.ok()
.flatten()
.is_some();
let live_taken_over = state
.proxy_service
.detect_takeover_in_live_config_for_app(app_type);
// `enabled` is set only after takeover writes complete. During that
// activation window, backup/live placeholders are the authoritative signal
// that normal provider sync must not rewrite the managed live file.
if has_live_backup || live_taken_over {
if matches!(app_type, AppType::ClaudeDesktop) {
write_live_with_common_config(state.db.as_ref(), app_type, provider)?;
} else {
futures::executor::block_on(
state
.proxy_service
.update_live_backup_from_provider(app_type.as_str(), provider),
)
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
}
return Ok(());
}
write_live_with_common_config(state.db.as_ref(), app_type, provider)
}
/// Sync current provider to live configuration
///
/// 使用有效的当前供应商 ID(验证过存在性)。
@@ -924,19 +979,10 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// Additive mode: sync ALL providers
sync_all_providers_to_live(state, &app_type)?;
} else {
// Switch mode: sync only current provider
let current_id =
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
Some(id) => id,
None => continue,
};
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
// Switch mode: sync only current provider. During proxy takeover,
// update the restore backup instead of rewriting the taken-over
// live file.
sync_current_provider_for_app_respecting_takeover(state, &app_type)?;
}
}
@@ -1647,4 +1693,78 @@ mod tests {
.collect();
assert_eq!(values, vec!["tool2"]);
}
#[test]
fn codex_switch_backfill_preserves_stored_model_catalog_when_live_lacks_it() {
// Reproduces the data-loss bug: switching away from a Codex provider
// backfills the outgoing provider from Live, but Live's config.toml had
// already lost its `model_catalog_json` projection (proxy cycle /
// Codex.app rewrite), so `read_live_settings` reconstructs no catalog.
// The stored mapping must survive the backfill.
let mut provider = Provider::with_id(
"deepseek".to_string(),
"DeepSeek".to_string(),
json!({
"auth": { "OPENAI_API_KEY": "sk-deepseek" },
"config": "model_provider = \"custom\"\nmodel = \"deepseek-v4-pro\"\n",
"modelCatalog": {
"models": [
{ "model": "deepseek-v4-pro", "contextWindow": 1_000_000 }
]
}
}),
None,
);
provider.category = Some("cn_official".to_string());
// Live snapshot as captured during switch: no `modelCatalog` field.
let live_settings = json!({
"auth": { "OPENAI_API_KEY": "sk-deepseek" },
"config": "model_provider = \"custom\"\nmodel = \"deepseek-v4-pro\"\n"
});
let result =
restore_live_settings_for_provider_backfill(&AppType::Codex, &provider, live_settings);
assert_eq!(
result.get("modelCatalog"),
provider.settings_config.get("modelCatalog"),
"switch-away backfill must keep the DB-stored modelCatalog when Live has none"
);
}
#[test]
fn codex_switch_backfill_keeps_live_catalog_when_db_has_none() {
// When the DB provider has no stored catalog, a catalog reconstructed
// from Live (if any) should be left intact — the DB-preference overlay
// must not wipe it.
let mut provider = Provider::with_id(
"deepseek".to_string(),
"DeepSeek".to_string(),
json!({
"auth": { "OPENAI_API_KEY": "sk-deepseek" },
"config": "model_provider = \"custom\"\nmodel = \"deepseek-v4-pro\"\n"
}),
None,
);
provider.category = Some("cn_official".to_string());
let live_settings = json!({
"auth": { "OPENAI_API_KEY": "sk-deepseek" },
"config": "model_provider = \"custom\"\nmodel = \"deepseek-v4-pro\"\n",
"modelCatalog": { "models": [ { "model": "deepseek-v4-pro" } ] }
});
let result = restore_live_settings_for_provider_backfill(
&AppType::Codex,
&provider,
live_settings.clone(),
);
assert_eq!(
result.get("modelCatalog"),
live_settings.get("modelCatalog"),
"backfill must keep the Live-reconstructed catalog when the DB has none"
);
}
}
+34 -20
View File
@@ -1391,11 +1391,13 @@ impl ProviderService {
.ok()
.flatten()
.is_some();
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
let live_taken_over = state
.proxy_service
.detect_takeover_in_live_config_for_app(&app_type);
let should_sync_via_proxy = is_proxy_running && (has_live_backup || live_taken_over);
// Backup or live placeholders mean the live file is currently owned
// by proxy takeover, including the short activation window before
// proxy_config.enabled is committed.
let should_sync_via_proxy = has_live_backup || live_taken_over;
if should_sync_via_proxy {
if matches!(app_type, AppType::ClaudeDesktop) {
@@ -1409,7 +1411,9 @@ impl ProviderService {
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
}
if matches!(app_type, AppType::Claude) {
if matches!(app_type, AppType::Claude)
&& futures::executor::block_on(state.proxy_service.is_running())
{
futures::executor::block_on(
state
.proxy_service
@@ -1559,7 +1563,7 @@ impl ProviderService {
/// Switch flow:
/// 1. Validate target provider exists
/// 2. Check if proxy takeover mode is active AND proxy server is running
/// 3. If takeover mode active: hot-switch proxy target only (no Live config write)
/// 3. If takeover mode active: hot-switch proxy target and refresh proxy-safe Live labels
/// 4. If normal mode:
/// a. **Backfill mechanism**: Backfill current live config to current provider
/// b. Update local settings current_provider_xxx (device-level)
@@ -1589,21 +1593,32 @@ impl ProviderService {
return Self::switch_normal(state, app_type, id, &providers);
}
// Check if proxy takeover mode is active AND proxy server is actually running
// Both conditions must be true to use hot-switch mode
// Use blocking wait since this is a sync function
// Provider switches and takeover toggles both mutate live config and the
// restore backup. Serialize them per app, then decide from the locked
// current state so a just-started takeover cannot be overwritten by a
// normal live write.
let _switch_guard =
if matches!(app_type, AppType::Claude | AppType::Codex | AppType::Gemini) {
Some(futures::executor::block_on(
state.proxy_service.lock_switch_for_app(app_type.as_str()),
))
} else {
None
};
// Backup or live placeholders mean the live file is owned by proxy
// takeover, even if the proxy server is temporarily stopped or is in the
// activation window before enabled=true is committed.
let is_app_taken_over =
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
.ok()
.flatten()
.is_some();
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running());
let live_taken_over = state
.proxy_service
.detect_takeover_in_live_config_for_app(&app_type);
// Hot-switch only when BOTH: this app is taken over AND proxy server is actually running
let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running;
let should_hot_switch = is_app_taken_over || live_taken_over;
// Block switching to official providers when proxy takeover is active.
// Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans.
@@ -1616,7 +1631,9 @@ impl ProviderService {
}
if should_hot_switch {
// Proxy takeover mode: hot-switch only, don't write Live config
// Proxy takeover mode: hot-switch without restoring upstream Live config.
// The proxy layer may still refresh proxy-safe Live fields so client labels
// follow the selected provider while endpoints remain local.
log::info!(
"代理接管模式:热切换 {} 的目标供应商为 {}",
app_type.as_str(),
@@ -1626,12 +1643,12 @@ impl ProviderService {
futures::executor::block_on(
state
.proxy_service
.hot_switch_provider(app_type.as_str(), id),
.hot_switch_provider_inner(app_type.as_str(), id),
)
.map_err(|e| AppError::Message(format!("热切换失败: {e}")))?;
// Note: No Live config write, no MCP sync
// The proxy server will route requests to the new provider via is_current
// The proxy server will route requests to the new provider via is_current.
// MCP sync is intentionally skipped while Live config is owned by takeover.
return Ok(SwitchResult::default());
}
@@ -1800,11 +1817,6 @@ impl ProviderService {
return Ok(());
};
let takeover_enabled =
futures::executor::block_on(state.db.get_proxy_config_for_app(app_type.as_str()))
.map(|config| config.enabled)
.unwrap_or(false);
let has_live_backup =
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
.ok()
@@ -1815,7 +1827,9 @@ impl ProviderService {
.proxy_service
.detect_takeover_in_live_config_for_app(&app_type);
if takeover_enabled && (has_live_backup || live_taken_over) {
// See the save path above: backup/placeholders are the ownership signal
// here, not just proxy_config.enabled.
if has_live_backup || live_taken_over {
if matches!(app_type, AppType::ClaudeDesktop) {
write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;
return Ok(());
File diff suppressed because it is too large Load Diff
+15
View File
@@ -253,6 +253,10 @@ pub struct AppSettings {
/// Whether to show the failover toggle independently on the main page
#[serde(default)]
pub enable_failover_toggle: bool,
/// Keep Codex ChatGPT login material in auth.json when switching to third-party providers.
/// Opt-in: defaults to false so third-party switches cleanly overwrite auth.json.
#[serde(default)]
pub preserve_codex_official_auth_on_switch: bool,
/// User has confirmed the failover toggle first-run notice
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failover_confirmed: Option<bool>,
@@ -366,6 +370,7 @@ impl Default for AppSettings {
usage_confirmed: None,
stream_check_confirmed: None,
enable_failover_toggle: false,
preserve_codex_official_auth_on_switch: false,
failover_confirmed: None,
first_run_notice_confirmed: None,
common_config_confirmed: None,
@@ -699,6 +704,16 @@ pub fn get_hermes_override_dir() -> Option<PathBuf> {
.map(|p| resolve_override_path(p))
}
pub fn preserve_codex_official_auth_on_switch() -> bool {
settings_store()
.read()
.unwrap_or_else(|e| {
log::warn!("设置锁已毒化,使用恢复值: {e}");
e.into_inner()
})
.preserve_codex_official_auth_on_switch
}
// ===== 当前供应商管理函数 =====
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.16.0",
"version": "3.16.1",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
+3 -1
View File
@@ -10,7 +10,8 @@ use cc_switch_lib::{
#[path = "support.rs"]
mod support;
use support::{
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,
ensure_test_home, reset_test_fs, test_mutex,
};
#[test]
@@ -73,6 +74,7 @@ fn sync_claude_provider_writes_live_settings() {
fn sync_codex_provider_writes_config_without_touching_auth() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
enable_codex_official_auth_preservation();
let mut config = MultiAppConfig::default();
+3 -1
View File
@@ -11,7 +11,8 @@ use cc_switch_lib::{
mod support;
use std::collections::HashMap;
use support::{
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,
ensure_test_home, reset_test_fs, test_mutex,
};
fn settings_path(home: &Path) -> PathBuf {
@@ -238,6 +239,7 @@ fn codex_startup_import_skips_when_only_official_seed_exists() {
fn switch_provider_updates_codex_live_and_state() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
enable_codex_official_auth_preservation();
let _home = ensure_test_home();
let legacy_auth = json!({"OPENAI_API_KEY": "legacy-key"});
+422 -11
View File
@@ -8,7 +8,8 @@ use cc_switch_lib::{
#[path = "support.rs"]
mod support;
use support::{
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,
ensure_test_home, reset_test_fs, test_mutex,
};
fn sanitize_provider_name(name: &str) -> String {
@@ -99,6 +100,7 @@ fn migrate_legacy_common_config_usage_marks_historical_provider_enabled() {
fn provider_service_switch_codex_updates_live_and_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
enable_codex_official_auth_preservation();
let _home = ensure_test_home();
let legacy_auth = json!({ "OPENAI_API_KEY": "legacy-key" });
@@ -355,6 +357,7 @@ requires_openai_auth = true
fn provider_service_switch_codex_preserves_oauth_and_backfills_api_key_from_live_token() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
enable_codex_official_auth_preservation();
let _home = ensure_test_home();
let live_auth = json!({
@@ -520,6 +523,261 @@ requires_openai_auth = true
);
}
#[tokio::test(flavor = "current_thread")]
#[allow(
clippy::await_holding_lock,
reason = "this integration-style test must serialize global test HOME and settings mutations across async takeover calls"
)]
async fn codex_official_to_deepseek_then_takeover_enters_and_restores_proxy_managed_live_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
enable_codex_official_auth_preservation();
let _home = ensure_test_home();
let oauth_auth = json!({
"auth_mode": "chatgpt",
"tokens": {
"access_token": "oauth-access",
"id_token": "oauth-id"
}
});
let official_config = r#"model_provider = "openai"
model = "gpt-5"
[model_providers.openai]
name = "OpenAI"
wire_api = "responses"
"#;
write_codex_live_atomic(&oauth_auth, Some(official_config))
.expect("seed official Codex OAuth live config");
let deepseek_provider_config = r#"model_provider = "deepseek"
model = "deepseek-chat"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com/v1"
wire_api = "responses"
"#;
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "official-provider".to_string();
let mut official_provider = Provider::with_id(
"official-provider".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": oauth_auth,
"config": official_config
}),
None,
);
official_provider.category = Some("official".to_string());
manager
.providers
.insert("official-provider".to_string(), official_provider);
let mut deepseek_provider = Provider::with_id(
"deepseek-provider".to_string(),
"DeepSeek".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "deepseek-key"},
"config": deepseek_provider_config
}),
None,
);
deepseek_provider.category = Some("custom".to_string());
manager
.providers
.insert("deepseek-provider".to_string(), deepseek_provider);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "deepseek-provider")
.expect("switch from official subscription to DeepSeek");
let auth_after_switch: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth after switch");
assert_eq!(
auth_after_switch, oauth_auth,
"normal provider switch with Codex preservation enabled must keep OAuth auth.json"
);
let config_after_switch =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config");
assert!(
config_after_switch.contains("https://api.deepseek.com/v1"),
"normal switch should write the DeepSeek endpoint before takeover"
);
assert!(
config_after_switch.contains("deepseek-key"),
"normal switch should inject the DeepSeek key into config.toml"
);
state
.proxy_service
.set_takeover_for_app("codex", true)
.await
.expect("enable Codex takeover");
let auth_after_takeover: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth after takeover");
assert_eq!(
auth_after_takeover, oauth_auth,
"enabling takeover must not rewrite Codex OAuth auth.json"
);
let config_after_takeover =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config");
assert!(
config_after_takeover.contains("http://127.0.0.1:15721/v1"),
"enabling takeover should point Codex config.toml at the local proxy"
);
assert!(
config_after_takeover.contains("PROXY_MANAGED"),
"enabling takeover should move the proxy placeholder into config.toml"
);
assert!(
!config_after_takeover.contains("https://api.deepseek.com/v1"),
"takeover live config should not keep the upstream DeepSeek endpoint"
);
let backup = state
.db
.get_live_backup("codex")
.await
.expect("read Codex backup")
.expect("backup exists after takeover");
let backup_value: serde_json::Value =
serde_json::from_str(&backup.original_config).expect("parse backup");
let backup_config = backup_value
.get("config")
.and_then(|value| value.as_str())
.unwrap_or_default();
assert!(
backup_config.contains("https://api.deepseek.com/v1")
&& backup_config.contains("deepseek-key"),
"takeover backup should remain the restorable DeepSeek config"
);
state
.proxy_service
.set_takeover_for_app("codex", false)
.await
.expect("disable Codex takeover");
let restored_auth: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read restored auth");
assert_eq!(
restored_auth, oauth_auth,
"disabling takeover should restore without replacing OAuth auth.json"
);
let restored_config = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())
.expect("read restored config");
assert!(
restored_config.contains("https://api.deepseek.com/v1")
&& restored_config.contains("deepseek-key"),
"disabling takeover should restore the selected DeepSeek live config"
);
assert!(
!restored_config.contains("PROXY_MANAGED"),
"restored live config must not keep the proxy placeholder"
);
}
#[test]
fn provider_service_switch_codex_default_overwrites_official_auth_when_preservation_off() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
// Intentionally do NOT enable preservation: this locks the default opt-out
// behavior where switching to a third-party provider rewrites auth.json,
// discarding the user's ChatGPT OAuth login. It is the dual of
// `provider_service_switch_codex_preserves_oauth_and_backfills_api_key_from_live_token`.
let _home = ensure_test_home();
let live_auth = json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": {
"access_token": "official-oauth-token",
"account_id": "acct-1"
}
});
let legacy_config = r#"model_provider = "rightcode"
model = "gpt-5.4"
[model_providers.rightcode]
name = "RightCode"
base_url = "https://rightcode.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#;
write_codex_live_atomic(&live_auth, Some(legacy_config))
.expect("seed existing Codex OAuth live config");
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "legacy-provider".to_string();
manager.providers.insert(
"legacy-provider".to_string(),
Provider::with_id(
"legacy-provider".to_string(),
"RightCode".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "rightcode-key"},
"config": legacy_config
}),
None,
),
);
manager.providers.insert(
"third-party".to_string(),
Provider::with_id(
"third-party".to_string(),
"AiHubMix".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "third-party-key"},
"config": r#"model_provider = "aihubmix"
model = "gpt-5.4"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
requires_openai_auth = true
"#
}),
None,
),
);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "third-party")
.expect("switch to third-party provider should succeed");
let auth_value: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json");
assert_eq!(
auth_value.get("OPENAI_API_KEY").and_then(|v| v.as_str()),
Some("third-party-key"),
"default (preservation off) should overwrite auth.json with the third-party API key"
);
assert!(
auth_value.pointer("/tokens/access_token").is_none(),
"default switch must clear the official ChatGPT OAuth token from live auth.json"
);
}
#[test]
fn provider_service_switch_codex_supports_official_login_provider_without_auth_write() {
let _guard = test_mutex().lock().expect("acquire test mutex");
@@ -561,18 +819,19 @@ requires_openai_auth = true
None,
),
);
manager.providers.insert(
let mut official_provider = Provider::with_id(
"official-provider".to_string(),
Provider::with_id(
"official-provider".to_string(),
"OpenAI Official".to_string(),
json!({
"auth": {},
"config": ""
}),
None,
),
"OpenAI Official".to_string(),
json!({
"auth": {},
"config": ""
}),
None,
);
official_provider.category = Some("official".to_string());
manager
.providers
.insert("official-provider".to_string(), official_provider);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
@@ -927,6 +1186,158 @@ fn sync_current_provider_for_app_keeps_live_takeover_and_updates_restore_backup(
);
}
#[test]
fn switch_codex_provider_with_takeover_live_but_stopped_proxy_keeps_proxy_live_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
enable_codex_official_auth_preservation();
let _home = ensure_test_home();
let oauth_auth = json!({
"auth_mode": "chatgpt",
"tokens": {
"access_token": "oauth-access",
"id_token": "oauth-id"
}
});
let old_provider_config = r#"model_provider = "deepseek"
model = "deepseek-chat"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "https://api.deepseek.com/v1"
wire_api = "responses"
experimental_bearer_token = "old-key"
"#;
let proxy_live_config = r#"model_provider = "deepseek"
model = "deepseek-chat"
[model_providers.deepseek]
name = "DeepSeek"
base_url = "http://127.0.0.1:15721/v1"
wire_api = "responses"
experimental_bearer_token = "PROXY_MANAGED"
"#;
write_codex_live_atomic(&oauth_auth, Some(proxy_live_config))
.expect("seed taken-over Codex live config");
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "old-provider".to_string();
let mut old_provider = Provider::with_id(
"old-provider".to_string(),
"DeepSeek Old".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "old-key"},
"config": old_provider_config
}),
None,
);
old_provider.category = Some("custom".to_string());
manager
.providers
.insert("old-provider".to_string(), old_provider);
let mut new_provider = Provider::with_id(
"new-provider".to_string(),
"DeepSeek New".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "new-key"},
"config": r#"model_provider = "deepseek-new"
model = "deepseek-reasoner"
[model_providers.deepseek-new]
name = "DeepSeek New"
base_url = "https://new.deepseek.example/v1"
wire_api = "responses"
"#
}),
None,
);
new_provider.category = Some("custom".to_string());
manager
.providers
.insert("new-provider".to_string(), new_provider);
}
let state = create_test_state_with_config(&config).expect("create test state");
futures::executor::block_on(
state.db.save_live_backup(
"codex",
&serde_json::to_string(&json!({
"auth": oauth_auth,
"config": old_provider_config
}))
.expect("serialize backup"),
),
)
.expect("seed Codex live backup");
assert!(
!futures::executor::block_on(state.proxy_service.is_running()),
"fixture keeps the proxy server stopped"
);
ProviderService::switch(&state, AppType::Codex, "new-provider")
.expect("switch should update takeover backup instead of writing normal live config");
let auth_after: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("read auth.json");
assert_eq!(
auth_after, oauth_auth,
"provider switch during takeover ownership must not rewrite Codex OAuth auth"
);
let live_config =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
assert!(
live_config.contains("http://127.0.0.1:15721/v1"),
"live config should remain pointed at the local proxy"
);
assert!(
live_config.contains("PROXY_MANAGED"),
"live config should keep the proxy bearer placeholder"
);
assert!(
live_config.contains(r#"model_provider = "deepseek-new""#)
&& live_config.contains(r#"name = "DeepSeek New""#),
"live config should update the Codex-visible provider label during takeover"
);
assert!(
!live_config.contains("https://new.deepseek.example/v1"),
"normal provider base_url must not overwrite taken-over live config"
);
let backup = futures::executor::block_on(state.db.get_live_backup("codex"))
.expect("get Codex backup")
.expect("backup exists");
let backup_value: serde_json::Value =
serde_json::from_str(&backup.original_config).expect("parse backup");
assert_eq!(
backup_value.get("auth"),
Some(&auth_after),
"restore backup should preserve the official OAuth auth"
);
let backup_config = backup_value
.get("config")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert!(
backup_config.contains("new-key") && backup_config.contains("deepseek-new"),
"restore backup should be rebuilt from the newly selected provider"
);
let current = state
.db
.get_current_provider(AppType::Codex.as_str())
.expect("get current provider");
assert_eq!(current.as_deref(), Some("new-provider"));
}
#[test]
fn explicitly_cleared_common_snippet_is_not_auto_extracted() {
let _guard = test_mutex().lock().expect("acquire test mutex");
+9
View File
@@ -50,6 +50,15 @@ pub fn reset_test_fs() {
let _ = update_settings(AppSettings::default());
}
#[allow(dead_code)]
pub fn enable_codex_official_auth_preservation() {
update_settings(AppSettings {
preserve_codex_official_auth_on_switch: true,
..Default::default()
})
.expect("enable Codex official auth preservation");
}
/// 全局互斥锁,避免多测试并发写入相同的 HOME 目录。
pub fn test_mutex() -> &'static Mutex<()> {
static MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
+1 -1
View File
@@ -1533,7 +1533,7 @@ function App() {
}}
onSubmit={handleEditProvider}
appId={activeApp}
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
isProxyTakeover={isCurrentAppTakeoverActive}
/>
{effectiveUsageProvider && (
+72 -47
View File
@@ -161,55 +161,80 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
apiKey: string | undefined;
baseUrl: string | undefined;
} => {
try {
const config = provider.settingsConfig;
if (!config) return { apiKey: undefined, baseUrl: undefined };
const trimTrailingSlash = (url: string | undefined) =>
typeof url === "string" ? url.replace(/\/+$/, "") : url;
const raw = ((): {
apiKey: string | undefined;
baseUrl: string | undefined;
} => {
try {
const config = provider.settingsConfig;
if (!config) return { apiKey: undefined, baseUrl: undefined };
// 处理不同应用的配置格式
if (appId === "claude") {
// Claude: { env: { ANTHROPIC_AUTH_TOKEN | ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL } }
const env = (config as any).env || {};
return {
apiKey: env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY,
baseUrl: env.ANTHROPIC_BASE_URL,
};
} else if (appId === "codex") {
// Codex: { auth: { OPENAI_API_KEY }, config: TOML string with base_url }
const auth = (config as any).auth || {};
const configToml = (config as any).config || "";
const apiKey =
typeof auth.OPENAI_API_KEY === "string" && auth.OPENAI_API_KEY.trim()
? auth.OPENAI_API_KEY
: extractCodexExperimentalBearerToken(configToml);
return {
apiKey,
baseUrl: extractCodexBaseUrl(configToml),
};
} else if (appId === "gemini") {
// Gemini: { env: { GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL } }
const env = (config as any).env || {};
return {
apiKey: env.GEMINI_API_KEY,
baseUrl: env.GOOGLE_GEMINI_BASE_URL,
};
} else if (appId === "hermes") {
// Hermes: settingsConfig 顶层扁平(snake_case,对应 config.yaml
return {
apiKey: (config as any).api_key,
baseUrl: (config as any).base_url,
};
} else if (appId === "openclaw") {
// OpenClaw: settingsConfig 顶层扁平(camelCase,对应 openclaw.json
return {
apiKey: (config as any).apiKey,
baseUrl: (config as any).baseUrl,
};
// 处理不同应用的配置格式
if (appId === "claude" || appId === "claude-desktop") {
// Claude / Claude Desktop: { env: { ANTHROPIC_AUTH_TOKEN | ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL } }
// Key fallbacks mirror the backend resolver (Provider::resolve_usage_credentials).
const env = (config as any).env || {};
return {
apiKey:
env.ANTHROPIC_AUTH_TOKEN ||
env.ANTHROPIC_API_KEY ||
env.OPENROUTER_API_KEY ||
env.GOOGLE_API_KEY,
baseUrl: env.ANTHROPIC_BASE_URL,
};
} else if (appId === "codex") {
// Codex: { auth: { OPENAI_API_KEY }, config: TOML string with base_url }
const auth = (config as any).auth || {};
const configToml = (config as any).config || "";
const apiKey =
typeof auth.OPENAI_API_KEY === "string" &&
auth.OPENAI_API_KEY.trim()
? auth.OPENAI_API_KEY
: extractCodexExperimentalBearerToken(configToml);
return {
apiKey,
baseUrl: extractCodexBaseUrl(configToml),
};
} else if (appId === "gemini") {
// Gemini: { env: { GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL } }
// Key fallback mirrors the backend resolver (Provider::resolve_usage_credentials).
const env = (config as any).env || {};
return {
apiKey: env.GEMINI_API_KEY || env.GOOGLE_API_KEY,
baseUrl: env.GOOGLE_GEMINI_BASE_URL,
};
} else if (appId === "hermes") {
// Hermes: settingsConfig 顶层扁平(snake_case,对应 config.yaml
return {
apiKey: (config as any).api_key,
baseUrl: (config as any).base_url,
};
} else if (appId === "openclaw") {
// OpenClaw: settingsConfig 顶层扁平(camelCase,对应 openclaw.json
return {
apiKey: (config as any).apiKey,
baseUrl: (config as any).baseUrl,
};
} else if (appId === "opencode") {
// OpenCode (OMO): 凭据嵌在 options.{baseURL, apiKey}SDK options 对象)
const options = (config as any).options || {};
return {
apiKey: options.apiKey,
baseUrl: options.baseURL,
};
}
return { apiKey: undefined, baseUrl: undefined };
} catch (error) {
console.error("Failed to extract provider credentials:", error);
return { apiKey: undefined, baseUrl: undefined };
}
return { apiKey: undefined, baseUrl: undefined };
} catch (error) {
console.error("Failed to extract provider credentials:", error);
return { apiKey: undefined, baseUrl: undefined };
}
})();
// Trim the trailing slash to mirror the backend resolver
// (Provider::resolve_usage_credentials), so `{{baseUrl}}/path` never
// produces a double slash regardless of which path runs the query.
return { apiKey: raw.apiKey, baseUrl: trimTrailingSlash(raw.baseUrl) };
};
const providerCredentials = getProviderCredentials();
@@ -30,6 +30,7 @@ interface AddProviderDialogProps {
provider: Omit<Provider, "id"> & {
providerKey?: string;
suggestedDefaults?: OpenClawSuggestedDefaults;
ensureClaudeDesktopOfficialSeed?: boolean;
},
) => Promise<void> | void;
}
@@ -98,6 +99,7 @@ export function AddProviderDialog({
const providerData: Omit<Provider, "id"> & {
providerKey?: string;
suggestedDefaults?: OpenClawSuggestedDefaults;
ensureClaudeDesktopOfficialSeed?: boolean;
} = {
name: values.name.trim(),
notes: values.notes?.trim() || undefined,
@@ -109,6 +111,16 @@ export function AddProviderDialog({
...(values.meta ? { meta: values.meta } : {}),
};
if (appId === "claude-desktop" && values.presetId) {
const presetIndex = parseInt(
values.presetId.replace("claude-desktop-", ""),
);
const preset = claudeDesktopProviderPresets[presetIndex];
providerData.ensureClaudeDesktopOfficialSeed =
values.presetCategory === "official" &&
preset?.category === "official";
}
// OpenCode/OpenClaw: pass providerKey for ID generation
if (
(appId === "opencode" || appId === "openclaw" || appId === "hermes") &&
@@ -132,11 +132,31 @@ export function EditProviderDialog({
}, [open, provider?.id, appId, hasLoadedLive, isProxyTakeover]); // 只依赖 provider.id,不依赖整个 provider 对象
const initialSettingsConfig = useMemo(() => {
return (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
const base = (liveSettings ?? provider?.settingsConfig ?? {}) as Record<
string,
unknown
>;
}, [liveSettings, provider?.settingsConfig]); // 只依赖 settingsConfig,不依赖整个 provider
// Codex 的 modelCatalog 是 cc-switch 私有字段,SSOT 在数据库。Live 的 config.toml
// 仅在写入时投影出 model_catalog_json 指针;Codex.app 改写配置、代理接管/恢复周期、
// 来回切换供应商都可能让 Live 丢失该投影,从而 read_live_settings 反解为空。
// 若放任 Live 覆盖,编辑界面会显示空映射表,保存后连同数据库里的映射一起清空(数据丢失)。
// 因此始终以数据库 SSOT 的 modelCatalog 为准,仅在数据库确实没有时才回退到 Live 反解结果。
if (
appId === "codex" &&
liveSettings &&
provider?.settingsConfig &&
typeof provider.settingsConfig === "object"
) {
const dbCatalog = (provider.settingsConfig as Record<string, unknown>)
.modelCatalog;
if (dbCatalog !== undefined) {
return { ...base, modelCatalog: dbCatalog };
}
}
return base;
}, [liveSettings, provider?.settingsConfig, appId]); // 只依赖 settingsConfig,不依赖整个 provider
// 固定 initialData,防止 provider 对象更新时重置表单
const initialData = useMemo(() => {
@@ -227,6 +247,7 @@ export function EditProviderDialog({
onSubmittingChange={setIsFormSubmitting}
initialData={initialData}
showButtons={false}
isProxyTakeover={isProxyTakeover}
/>
</FullScreenPanel>
);
@@ -1,4 +1,5 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { CodexAuthSection, CodexConfigSection } from "./CodexConfigSections";
import { CodexCommonConfigModal } from "./CodexCommonConfigModal";
@@ -11,6 +12,8 @@ interface CodexConfigEditorProps {
showRemoteCompaction?: boolean;
isProxyTakeover?: boolean;
onAuthChange: (value: string) => void;
onConfigChange: (value: string) => void;
@@ -43,6 +46,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
configValue,
providerName,
showRemoteCompaction,
isProxyTakeover = false,
onAuthChange,
onConfigChange,
onAuthBlur,
@@ -57,6 +61,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
onExtract,
isExtracting,
}) => {
const { t } = useTranslation();
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
const handleCloseCommonConfigModal = () => {
@@ -66,12 +71,21 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
return (
<div className="space-y-6">
{isProxyTakeover && (
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
<p className="text-xs text-amber-600 dark:text-amber-400">
{t("codexConfig.proxyTakeoverStorageNotice")}
</p>
</div>
)}
{/* Auth JSON Section */}
<CodexAuthSection
value={authValue}
onChange={onAuthChange}
onBlur={onAuthBlur}
error={authError}
isProxyTakeover={isProxyTakeover}
/>
{/* Config TOML Section */}
@@ -85,6 +99,7 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
commonConfigError={commonConfigError}
configError={configError}
isProxyTakeover={isProxyTakeover}
/>
{/* Common Config Modal */}
@@ -29,6 +29,7 @@ interface CodexAuthSectionProps {
onChange: (value: string) => void;
onBlur?: () => void;
error?: string;
isProxyTakeover?: boolean;
}
/**
@@ -39,6 +40,7 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
onChange,
onBlur,
error,
isProxyTakeover = false,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
@@ -90,7 +92,11 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
{!error && (
<p className="text-xs text-muted-foreground">
{t("codexConfig.authJsonHint")}
{t(
isProxyTakeover
? "codexConfig.authJsonStorageHint"
: "codexConfig.authJsonHint",
)}
</p>
)}
</div>
@@ -107,6 +113,7 @@ interface CodexConfigSectionProps {
onEditCommonConfig: () => void;
commonConfigError?: string;
configError?: string;
isProxyTakeover?: boolean;
}
/**
@@ -122,6 +129,7 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
onEditCommonConfig,
commonConfigError,
configError,
isProxyTakeover = false,
}) => {
const { t } = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(false);
@@ -367,7 +375,11 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
{!configError && (
<p className="text-xs text-muted-foreground">
{t("codexConfig.configTomlHint")}
{t(
isProxyTakeover
? "codexConfig.configTomlStorageHint"
: "codexConfig.configTomlHint",
)}
</p>
)}
</div>
@@ -230,6 +230,7 @@ export interface ProviderFormProps {
iconColor?: string;
};
showButtons?: boolean;
isProxyTakeover?: boolean;
}
export function ProviderForm(props: ProviderFormProps) {
@@ -251,6 +252,7 @@ function ProviderFormFull({
onSubmittingChange,
initialData,
showButtons = true,
isProxyTakeover = false,
}: ProviderFormProps) {
if (appId === "claude-desktop") {
throw new Error("ProviderFormFull should not receive claude-desktop");
@@ -2165,6 +2167,7 @@ function ProviderFormFull({
configValue={codexConfig}
providerName={form.watch("name")}
showRemoteCompaction={category !== "official"}
isProxyTakeover={isProxyTakeover}
onAuthChange={setCodexAuth}
onConfigChange={handleCodexConfigChange}
useCommonConfig={useCodexCommonConfigFlag}
@@ -0,0 +1,35 @@
import { useTranslation } from "react-i18next";
import { KeyRound } from "lucide-react";
import type { SettingsFormState } from "@/hooks/useSettings";
import { ToggleRow } from "@/components/ui/toggle-row";
interface CodexAuthSettingsProps {
settings: SettingsFormState;
onChange: (updates: Partial<SettingsFormState>) => void;
}
export function CodexAuthSettings({
settings,
onChange,
}: CodexAuthSettingsProps) {
const { t } = useTranslation();
return (
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-border/40">
<KeyRound className="h-4 w-4 text-primary" />
<h3 className="text-sm font-medium">{t("settings.codexAuth")}</h3>
</div>
<ToggleRow
icon={<KeyRound className="h-4 w-4 text-emerald-500" />}
title={t("settings.preserveCodexOfficialAuthOnSwitch")}
description={t("settings.preserveCodexOfficialAuthOnSwitchDescription")}
checked={settings.preserveCodexOfficialAuthOnSwitch ?? false}
onCheckedChange={(value) =>
onChange({ preserveCodexOfficialAuthOnSwitch: value })
}
/>
</section>
);
}
+5
View File
@@ -44,6 +44,7 @@ import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
import { UsageDashboard } from "@/components/usage/UsageDashboard";
import { LogConfigPanel } from "@/components/settings/LogConfigPanel";
import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel";
import { CodexAuthSettings } from "@/components/settings/CodexAuthSettings";
import { useInstalledSkills } from "@/hooks/useSkills";
import { useSettings } from "@/hooks/useSettings";
import { useImportExport } from "@/hooks/useImportExport";
@@ -241,6 +242,10 @@ export function SettingsPage({
handleAutoSave({ skillSyncMethod: method })
}
/>
<CodexAuthSettings
settings={settings}
onChange={handleAutoSave}
/>
<WindowSettings
settings={settings}
onChange={handleAutoSave}
+5 -1
View File
@@ -75,6 +75,7 @@ export function useProviderActions(
providerKey?: string;
suggestedDefaults?: OpenClawSuggestedDefaults;
addToLive?: boolean;
ensureClaudeDesktopOfficialSeed?: boolean;
},
) => {
const enhanced = injectCodingPlanUsageScript(activeApp, provider);
@@ -247,7 +248,10 @@ export function useProviderActions(
if (!proxyRequiredReason) {
let messageKey = "notifications.switchSuccess";
let defaultMessage = "切换成功!";
if (activeApp === "claude-desktop") {
if (activeApp === "codex") {
messageKey = "notifications.codexRestartRequired";
defaultMessage = "切换成功,请重启客户端以生效";
} else if (activeApp === "claude-desktop") {
if (provider.meta?.claudeDesktopMode === "proxy") {
messageKey = "notifications.claudeDesktopProxyRestartRequired";
defaultMessage =
+5
View File
@@ -116,6 +116,8 @@ export function useSettingsForm(): UseSettingsFormResult {
data.enableClaudePluginIntegration ?? false,
silentStartup: data.silentStartup ?? false,
skipClaudeOnboarding: data.skipClaudeOnboarding ?? false,
preserveCodexOfficialAuthOnSwitch:
data.preserveCodexOfficialAuthOnSwitch ?? false,
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
codexConfigDir: sanitizeDir(data.codexConfigDir),
geminiConfigDir: sanitizeDir(data.geminiConfigDir),
@@ -140,6 +142,7 @@ export function useSettingsForm(): UseSettingsFormResult {
useAppWindowControls: false,
enableClaudePluginIntegration: false,
skipClaudeOnboarding: false,
preserveCodexOfficialAuthOnSwitch: false,
language: readPersistedLanguage(),
} as SettingsFormState);
@@ -177,6 +180,8 @@ export function useSettingsForm(): UseSettingsFormResult {
serverData.enableClaudePluginIntegration ?? false,
silentStartup: serverData.silentStartup ?? false,
skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? false,
preserveCodexOfficialAuthOnSwitch:
serverData.preserveCodexOfficialAuthOnSwitch ?? false,
claudeConfigDir: sanitizeDir(serverData.claudeConfigDir),
codexConfigDir: sanitizeDir(serverData.codexConfigDir),
geminiConfigDir: sanitizeDir(serverData.geminiConfigDir),
+7
View File
@@ -225,6 +225,7 @@
"providerSaved": "Provider configuration saved",
"providerDeleted": "Provider deleted successfully",
"switchSuccess": "Switch successful!",
"codexRestartRequired": "Switched successfully. Restart the client to apply changes.",
"claudeDesktopRestartRequired": "Switched successfully. Restart Claude Desktop to apply changes.",
"claudeDesktopProxyRestartRequired": "Switched successfully. Keep CC Switch running and restart Claude Desktop to apply changes.",
"addToConfigSuccess": "Added to config",
@@ -561,6 +562,9 @@
"enableClaudePluginIntegrationDescription": "When enabled, the VS Code Claude Code extension provider will switch with this app",
"skipClaudeOnboarding": "Skip Claude Code first-run confirmation",
"skipClaudeOnboardingDescription": "When enabled, Claude Code will skip the first-run confirmation",
"codexAuth": "Codex App Enhancements",
"preserveCodexOfficialAuthOnSwitch": "Keep official login when switching third-party providers",
"preserveCodexOfficialAuthOnSwitchDescription": "When enabled, you can use the Codex app's official plugins, mobile remote control, and other features while using third-party APIs.",
"appVisibility": {
"title": "Homepage Display",
"description": "Choose which apps to show on the homepage",
@@ -1100,8 +1104,11 @@
"authJson": "auth.json (JSON) *",
"authJsonPlaceholder": "{\n \"OPENAI_API_KEY\": \"sk-your-api-key-here\"\n}",
"authJsonHint": "Codex auth.json configuration content",
"authJsonStorageHint": "Proxy takeover is showing the stored auth, not the live auth.json.",
"configToml": "config.toml (TOML)",
"configTomlHint": "Codex config.toml configuration content",
"configTomlStorageHint": "Proxy takeover is showing the stored config, not the live config.toml.",
"proxyTakeoverStorageNotice": "💡 This app is under proxy takeover. You are editing the stored provider config; the live config is already temporarily managed by the proxy.",
"writeCommonConfig": "Write Common Config",
"editCommonConfig": "Edit Common Config",
"editCommonConfigTitle": "Edit Codex Common Config Snippet",
+7
View File
@@ -225,6 +225,7 @@
"providerSaved": "プロバイダー設定を保存しました",
"providerDeleted": "プロバイダーを削除しました",
"switchSuccess": "切り替え成功!",
"codexRestartRequired": "切り替えました。反映するにはクライアントを再起動してください",
"claudeDesktopRestartRequired": "切り替えました。反映するには Claude Desktop を再起動してください",
"claudeDesktopProxyRestartRequired": "切り替えました。CC Switch を起動したまま Claude Desktop を再起動してください",
"addToConfigSuccess": "設定に追加しました",
@@ -561,6 +562,9 @@
"enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します",
"skipClaudeOnboarding": "Claude Code の初回確認をスキップ",
"skipClaudeOnboardingDescription": "オンにすると Claude Code の初回インストール確認をスキップします",
"codexAuth": "Codex アプリ拡張",
"preserveCodexOfficialAuthOnSwitch": "サードパーティ切替時に公式ログインを保持",
"preserveCodexOfficialAuthOnSwitchDescription": "オンにすると、サードパーティ API を使用する際に Codex アプリの公式プラグインやスマホからのリモート操作などの機能を利用できます。",
"appVisibility": {
"title": "ホームページ表示",
"description": "ホームページに表示するアプリを選択",
@@ -1100,8 +1104,11 @@
"authJson": "auth.json (JSON) *",
"authJsonPlaceholder": "{\n \"OPENAI_API_KEY\": \"sk-your-api-key-here\"\n}",
"authJsonHint": "Codex の auth.json 設定内容",
"authJsonStorageHint": "プロキシ引き継ぎ中は保存済みの auth を表示しており、実際の auth.json ではありません。",
"configToml": "config.toml (TOML)",
"configTomlHint": "Codex の config.toml 設定内容",
"configTomlStorageHint": "プロキシ引き継ぎ中は保存済みの config を表示しており、実際の config.toml ではありません。",
"proxyTakeoverStorageNotice": "💡 このアプリはプロキシ引き継ぎ中です。ここで編集しているのは保存済みのプロバイダー設定で、実際の live 設定はすでにプロキシが一時的に引き継いでいます。",
"writeCommonConfig": "共通設定を書き込む",
"editCommonConfig": "共通設定を編集",
"editCommonConfigTitle": "Codex 共通設定スニペットを編集",
+7
View File
@@ -225,6 +225,7 @@
"providerSaved": "供應商設定已儲存",
"providerDeleted": "供應商刪除成功",
"switchSuccess": "切換成功!",
"codexRestartRequired": "切換成功,請重新啟動客戶端以套用",
"claudeDesktopRestartRequired": "切換成功,重新啟動 Claude Desktop 後生效",
"claudeDesktopProxyRestartRequired": "切換成功,請保持 CC Switch 執行,並重新啟動 Claude Desktop 後生效",
"addToConfigSuccess": "已新增至設定",
@@ -561,6 +562,9 @@
"enableClaudePluginIntegrationDescription": "開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換",
"skipClaudeOnboarding": "跳過 Claude Code 初次安裝確認",
"skipClaudeOnboardingDescription": "開啟後跳過 Claude Code 初次安裝確認",
"codexAuth": "Codex 應用增強",
"preserveCodexOfficialAuthOnSwitch": "切換第三方時保留官方登入",
"preserveCodexOfficialAuthOnSwitchDescription": "開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能",
"appVisibility": {
"title": "主頁面顯示",
"description": "選擇在主頁面顯示的應用程式",
@@ -1072,8 +1076,11 @@
"authJson": "auth.json (JSON) *",
"authJsonPlaceholder": "{\n \"OPENAI_API_KEY\": \"sk-your-api-key-here\"\n}",
"authJsonHint": "Codex auth.json 設定內容",
"authJsonStorageHint": "代理接管中顯示的是儲存的 auth 配置,不是即時 auth.json",
"configToml": "config.toml (TOML)",
"configTomlHint": "Codex config.toml 設定內容",
"configTomlStorageHint": "代理接管中顯示的是儲存的 config 配置,不是即時 config.toml",
"proxyTakeoverStorageNotice": "💡 目前應用處於代理接管狀態。這裡編輯的是儲存的供應商配置,真實 live 配置已由代理暫時接管",
"writeCommonConfig": "寫入通用設定",
"editCommonConfig": "編輯通用設定",
"editCommonConfigTitle": "編輯 Codex 通用設定片段",
+7
View File
@@ -225,6 +225,7 @@
"providerSaved": "供应商配置已保存",
"providerDeleted": "供应商删除成功",
"switchSuccess": "切换成功!",
"codexRestartRequired": "切换成功,请重启客户端以生效",
"claudeDesktopRestartRequired": "切换成功,重启 Claude Desktop 后生效",
"claudeDesktopProxyRestartRequired": "切换成功,请保持 CC Switch 运行,并重启 Claude Desktop 后生效",
"addToConfigSuccess": "已添加到配置",
@@ -561,6 +562,9 @@
"enableClaudePluginIntegrationDescription": "开启后 Vscode Claude Code 插件的供应商将随本软件切换",
"skipClaudeOnboarding": "跳过 Claude Code 初次安装确认",
"skipClaudeOnboardingDescription": "开启后跳过 Claude Code 初次安装确认",
"codexAuth": "Codex 应用增强",
"preserveCodexOfficialAuthOnSwitch": "切换第三方时保留官方登录",
"preserveCodexOfficialAuthOnSwitchDescription": "开启后可以在使用第三方 API 的时候使用 Codex 应用的官方插件、手机远程操作等功能",
"appVisibility": {
"title": "主页面显示",
"description": "选择在主页面显示的应用",
@@ -1100,8 +1104,11 @@
"authJson": "auth.json (JSON) *",
"authJsonPlaceholder": "{\n \"OPENAI_API_KEY\": \"sk-your-api-key-here\"\n}",
"authJsonHint": "Codex auth.json 配置内容",
"authJsonStorageHint": "代理接管中显示的是存储的 auth 配置,不是实时 auth.json",
"configToml": "config.toml (TOML)",
"configTomlHint": "Codex config.toml 配置内容",
"configTomlStorageHint": "代理接管中显示的是存储的 config 配置,不是实时 config.toml",
"proxyTakeoverStorageNotice": "💡 当前应用处于代理接管状态。这里编辑的是存储的供应商配置,真实 live 配置已由代理临时接管",
"writeCommonConfig": "写入通用配置",
"editCommonConfig": "编辑通用配置",
"editCommonConfigTitle": "编辑 Codex 通用配置片段",
+4
View File
@@ -99,6 +99,10 @@ export const providersApi = {
return await invoke("import_claude_desktop_providers_from_claude");
},
async ensureClaudeDesktopOfficialProvider(): Promise<boolean> {
return await invoke("ensure_claude_desktop_official_provider");
},
async getClaudeDesktopStatus(): Promise<ClaudeDesktopStatus> {
return await invoke("get_claude_desktop_status");
},
+18 -2
View File
@@ -19,8 +19,26 @@ export const useAddProviderMutation = (appId: AppId) => {
providerInput: Omit<Provider, "id"> & {
providerKey?: string;
addToLive?: boolean;
ensureClaudeDesktopOfficialSeed?: boolean;
},
) => {
const {
providerKey: _providerKey,
addToLive,
ensureClaudeDesktopOfficialSeed,
...rest
} = providerInput;
if (appId === "claude-desktop" && ensureClaudeDesktopOfficialSeed) {
await providersApi.ensureClaudeDesktopOfficialProvider();
const providers = await providersApi.getAll(appId);
const officialProvider = providers["claude-desktop-official"];
if (!officialProvider) {
throw new Error("Claude Desktop official provider was not created");
}
return officialProvider;
}
let id: string;
if (appId === "opencode" || appId === "openclaw" || appId === "hermes") {
@@ -40,8 +58,6 @@ export const useAddProviderMutation = (appId: AppId) => {
id = generateUUID();
}
const { providerKey: _providerKey, addToLive, ...rest } = providerInput;
const newProvider: Provider = {
...rest,
id,
+1
View File
@@ -15,6 +15,7 @@ export const settingsSchema = z.object({
skipClaudeOnboarding: z.boolean().optional(),
launchOnStartup: z.boolean().optional(),
enableLocalProxy: z.boolean().optional(),
preserveCodexOfficialAuthOnSwitch: z.boolean().optional(),
language: z.enum(["en", "zh", "zh-TW", "ja"]).optional(),
// 设备级目录覆盖
+2
View File
@@ -333,6 +333,8 @@ export interface Settings {
streamCheckConfirmed?: boolean;
// Whether to show the failover toggle independently on the main page
enableFailoverToggle?: boolean;
// Preserve Codex ChatGPT login in auth.json when switching third-party providers
preserveCodexOfficialAuthOnSwitch?: boolean;
// User has confirmed the failover toggle first-run notice
failoverConfirmed?: boolean;
// User has confirmed the first-run welcome notice
@@ -0,0 +1,205 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Provider } from "@/types";
const apiMocks = vi.hoisted(() => ({
getCurrent: vi.fn(),
getLiveProviderSettings: vi.fn(),
getOpenClawLiveProvider: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
providersApi: {
getCurrent: apiMocks.getCurrent,
},
vscodeApi: {
getLiveProviderSettings: apiMocks.getLiveProviderSettings,
},
openclawApi: {
getLiveProvider: apiMocks.getOpenClawLiveProvider,
},
}));
vi.mock("@/components/common/FullScreenPanel", () => ({
FullScreenPanel: ({
isOpen,
children,
footer,
}: {
isOpen: boolean;
children: React.ReactNode;
footer?: React.ReactNode;
}) =>
isOpen ? (
<div>
<div>{children}</div>
<div>{footer}</div>
</div>
) : null,
}));
vi.mock("@/components/providers/forms/ProviderForm", () => ({
ProviderForm: ({
initialData,
onSubmit,
isProxyTakeover,
}: {
initialData: {
name?: string;
websiteUrl?: string;
notes?: string;
settingsConfig?: Record<string, unknown>;
meta?: Record<string, unknown>;
icon?: string;
iconColor?: string;
};
onSubmit: (values: {
name: string;
websiteUrl: string;
notes?: string;
settingsConfig: string;
meta?: Record<string, unknown>;
icon?: string;
iconColor?: string;
}) => void;
isProxyTakeover?: boolean;
}) => (
<form
id="provider-form"
onSubmit={(event) => {
event.preventDefault();
onSubmit({
name: initialData.name ?? "",
websiteUrl: initialData.websiteUrl ?? "",
notes: initialData.notes,
settingsConfig: JSON.stringify(initialData.settingsConfig ?? {}),
meta: initialData.meta,
icon: initialData.icon,
iconColor: initialData.iconColor,
});
}}
>
<output data-testid="settings-config">
{JSON.stringify(initialData.settingsConfig ?? {})}
</output>
<output data-testid="is-proxy-takeover">
{isProxyTakeover ? "true" : "false"}
</output>
</form>
),
}));
import { EditProviderDialog } from "@/components/providers/EditProviderDialog";
describe("EditProviderDialog", () => {
beforeEach(() => {
apiMocks.getCurrent.mockReset();
apiMocks.getLiveProviderSettings.mockReset();
apiMocks.getOpenClawLiveProvider.mockReset();
});
it("保留 Codex 数据库中的 modelCatalog,避免 live 配置缺字段时清空模型映射", async () => {
const dbModelCatalog = {
models: [
{
model: "deepseek-v4-flash",
displayName: "DeepSeek V4 Flash",
contextWindow: 1000000,
},
],
};
const provider: Provider = {
id: "deepseek",
name: "DeepSeek",
category: "aggregator",
settingsConfig: {
auth: {
OPENAI_API_KEY: "db-key",
},
config: 'model_provider = "custom"\nmodel = "deepseek-v4-flash"\n',
modelCatalog: dbModelCatalog,
},
};
const liveSettings = {
auth: {
OPENAI_API_KEY: "live-key",
},
config: 'model_provider = "custom"\nmodel = "deepseek-v4-pro"\n',
};
const handleSubmit = vi.fn().mockResolvedValue(undefined);
apiMocks.getCurrent.mockResolvedValue(provider.id);
apiMocks.getLiveProviderSettings.mockResolvedValue(liveSettings);
render(
<EditProviderDialog
open
provider={provider}
onOpenChange={vi.fn()}
onSubmit={handleSubmit}
appId="codex"
/>,
);
await waitFor(() => {
expect(
JSON.parse(screen.getByTestId("settings-config").textContent ?? "{}"),
).toEqual({
...liveSettings,
modelCatalog: dbModelCatalog,
});
});
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
expect(handleSubmit.mock.calls[0][0].provider.settingsConfig).toEqual({
...liveSettings,
modelCatalog: dbModelCatalog,
});
});
it("代理接管中编辑 Codex 供应商时展示数据库配置而不是读取 live 代理配置", async () => {
const provider: Provider = {
id: "deepseek",
name: "DeepSeek",
category: "custom",
settingsConfig: {
auth: {
OPENAI_API_KEY: "db-key",
},
config:
'model_provider = "custom"\n[model_providers.custom]\nbase_url = "https://api.deepseek.com/v1"\n',
},
};
apiMocks.getCurrent.mockResolvedValue(provider.id);
apiMocks.getLiveProviderSettings.mockResolvedValue({
auth: {
OPENAI_API_KEY: "PROXY_MANAGED",
},
config:
'model_provider = "custom"\n[model_providers.custom]\nbase_url = "http://127.0.0.1:15721/v1"\nexperimental_bearer_token = "PROXY_MANAGED"\n',
});
render(
<EditProviderDialog
open
provider={provider}
onOpenChange={vi.fn()}
onSubmit={vi.fn()}
appId="codex"
isProxyTakeover
/>,
);
await waitFor(() => {
expect(screen.getByTestId("is-proxy-takeover").textContent).toBe("true");
});
expect(apiMocks.getLiveProviderSettings).not.toHaveBeenCalled();
expect(
JSON.parse(screen.getByTestId("settings-config").textContent ?? "{}"),
).toEqual(provider.settingsConfig);
});
});
+136
View File
@@ -0,0 +1,136 @@
import type { ReactNode } from "react";
import { act, renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useAddProviderMutation } from "@/lib/query/mutations";
import type { Provider } from "@/types";
const apiMocks = vi.hoisted(() => ({
add: vi.fn(),
ensureClaudeDesktopOfficialProvider: vi.fn(),
getAll: vi.fn(),
updateTrayMenu: vi.fn(),
}));
const uuidMocks = vi.hoisted(() => ({
generateUUID: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
providersApi: {
add: (...args: unknown[]) => apiMocks.add(...args),
ensureClaudeDesktopOfficialProvider: (...args: unknown[]) =>
apiMocks.ensureClaudeDesktopOfficialProvider(...args),
getAll: (...args: unknown[]) => apiMocks.getAll(...args),
updateTrayMenu: (...args: unknown[]) => apiMocks.updateTrayMenu(...args),
},
sessionsApi: {},
settingsApi: {},
}));
vi.mock("@/utils/uuid", () => ({
generateUUID: () => uuidMocks.generateUUID(),
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return { wrapper };
}
beforeEach(() => {
apiMocks.add.mockReset().mockResolvedValue(true);
apiMocks.ensureClaudeDesktopOfficialProvider
.mockReset()
.mockResolvedValue(true);
apiMocks.getAll.mockReset().mockResolvedValue({});
apiMocks.updateTrayMenu.mockReset().mockResolvedValue(true);
uuidMocks.generateUUID.mockReset().mockReturnValue("generated-uuid");
});
describe("useAddProviderMutation", () => {
it("duplicates Claude Desktop official providers with a fresh id", async () => {
const { wrapper } = createWrapper();
const { result } = renderHook(
() => useAddProviderMutation("claude-desktop"),
{ wrapper },
);
const duplicatedProvider = await act(async () =>
result.current.mutateAsync({
name: "Claude Desktop Official copy",
settingsConfig: { env: {} },
category: "official",
}),
);
expect(apiMocks.ensureClaudeDesktopOfficialProvider).not.toHaveBeenCalled();
expect(apiMocks.add).toHaveBeenCalledTimes(1);
expect(apiMocks.add).toHaveBeenCalledWith(
expect.objectContaining({
id: "generated-uuid",
name: "Claude Desktop Official copy",
category: "official",
}),
"claude-desktop",
undefined,
);
expect(duplicatedProvider.id).toBe("generated-uuid");
expect(duplicatedProvider.id).not.toBe("claude-desktop-official");
});
it("returns the persisted seed row for the Claude Desktop official preset", async () => {
const seedProvider: Provider = {
id: "claude-desktop-official",
name: "Claude Desktop Official",
settingsConfig: { env: {} },
websiteUrl: "https://claude.ai/download",
category: "official",
icon: "anthropic",
iconColor: "#D4915D",
createdAt: 123,
};
apiMocks.getAll.mockResolvedValueOnce({
"claude-desktop-official": seedProvider,
});
const { wrapper } = createWrapper();
const { result } = renderHook(
() => useAddProviderMutation("claude-desktop"),
{ wrapper },
);
const persistedProvider = await act(async () =>
result.current.mutateAsync({
name: "Renamed by form",
settingsConfig: { env: { ignored: true } },
websiteUrl: "https://example.invalid",
category: "official",
icon: "custom-icon",
ensureClaudeDesktopOfficialSeed: true,
}),
);
expect(apiMocks.ensureClaudeDesktopOfficialProvider).toHaveBeenCalledTimes(
1,
);
expect(apiMocks.getAll).toHaveBeenCalledWith("claude-desktop");
expect(apiMocks.add).not.toHaveBeenCalled();
expect(persistedProvider).toEqual(seedProvider);
});
});
+4
View File
@@ -192,6 +192,10 @@ describe("useProviderActions", () => {
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
expect(settingsApiGetMock).not.toHaveBeenCalled();
expect(settingsApiApplyMock).not.toHaveBeenCalled();
expect(toastSuccessMock).toHaveBeenCalledWith(
"切换成功,请重启客户端以生效",
{ closeButton: true },
);
});
it("warns but still switches providers that require proxy when proxy is not running", async () => {