Compare commits

..

1 Commits

Author SHA1 Message Date
SaladDay 483256ad92 refactor: remove redundant proxy query paths 2026-07-30 15:08:42 +00:00
43 changed files with 245 additions and 3389 deletions
-51
View File
@@ -5,57 +5,6 @@ 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).
## [3.19.1] - 2026-07-31
Development since v3.19.0 is a maintenance pass rather than a feature wave — and the first release in this project's history that deletes more than it adds. Three Chinese Codex gateways move onto native Responses and no longer need local routing takeover: DeepSeek connects straight to `api.deepseek.com` and brings with it a general mechanism for mirroring a vendor's own model catalog verbatim, so the GPT-5 harness and the freeform `apply_patch` registration stay self-consistent instead of collapsing into the neutral template; Volcengine's Ark Coding Plan follows now that the official documentation confirms `/api/coding/v3` serves Responses; and Tencent Hunyuan's TokenHub joins as a new preset. The correctness sweep covers four failures visible in the field: Claude Desktop usage has been counted twice since v3.18.0, once as a proxy row and once as an imported session row (#5938); switching back to the built-in official Codex provider left a third-party key in `auth.json`, so Codex authenticated to the official endpoint with the wrong credential and returned 401 without ever falling through to its login screen; `grok update` failed from Settings with a bare `os error 2` because a GUI-launched app cannot see node; and Grok Build's proxy takeover returned 404 on any non-Responses backend while every request minted a fresh session id (#5677). Rounding it out, nine interface strings that rendered as Simplified Chinese in every language are localized and the Traditional Chinese About page gains the thirty tool-manager strings it was missing, eight models that silently billed at zero gain built-in prices, deep-link import confirmations mask more and truncate less, and 3,166 lines of superseded code plus four npm dependencies are removed.
**Stats**: 12 commits | 71 files changed | +2,324 insertions | -3,680 deletions
### Added
- **Official Vendor Model Catalogs Mirrored for Native Responses Providers**: Codex reads model capabilities from a catalog file, and CC Switch generated every provider's catalog from a neutral template — which is right for an aggregator, but strips capabilities a vendor's own integration depends on. A vendor whose published catalog is bundled with the app is now mirrored verbatim instead. DeepSeek is the first: `src-tauri/src/resources/codex_deepseek_catalog_template.json` carries its `deepseek-v4-flash` and `deepseek-v4-pro` entries with `apply_patch_tool_type: "freeform"`, `web_search_tool_type: "text"`, `supports_search_tool: true`, the low/high/max reasoning levels, and the 17,644-character GPT-5 harness in `base_instructions` and `model_messages` — which has to travel together with the freeform tool registration, because the harness instructs the model to use `apply_patch`. The gate is deliberately narrow: the provider must resolve to the native-Responses profile **and** its `base_url` must be on `deepseek.com`. Matching is by host only and never by model brand, because these entries grant capabilities that an aggregator reselling the same model may not implement. Explicit per-model overrides in the provider's own catalog still win, and an unrecognised model id clones the flagship entry while keeping its own slug. Every other provider profile emits exactly the catalog it did before.
- **Tencent Hunyuan (TokenHub) Codex Preset**: A "Tencent Hunyuan" entry joins the Codex preset picker under the Opensource Official category, between Bailian and StepFun. It writes `https://tokenhub.tencentmaas.com/v1` with `wire_api = "responses"` and the `disable_response_storage = true` that TokenHub requires, declares `hy3` and `hy3-preview` at a 256K context window (rather than accepting Codex's 128K default), and marks both text-only so `view_image` payloads are never sent to a model that cannot read them. Because it is a native Responses provider, Codex talks to the gateway directly and no local routing is needed; the generated catalog uses the neutral native template, which pins `shell_type = "shell_command"` and drops the freeform `apply_patch` registration that native gateways reject. The address manager and latency test see two candidates from the start — the primary `.com` host and the official `.cn` backup. The region-scoped international site is deliberately excluded, since API keys do not carry across sites. Note that the API key must be a TokenHub key created with the Hy3 scope; Coding Plan and Token Plan subscription keys do not work against this endpoint.
- **Built-In Pricing for Eight Models That Billed at Zero**: `gpt-5.3-codex-spark`, `gemini-3.5-flash-lite`, `kimi-k2.7-code-highspeed` (2x its `kimi-k2.7-code` base, matching Kimi's Turbo pattern), `glm-5-turbo`, `glm-5v-turbo` and `qwen3.6-flash` had no row in the built-in pricing table and could not be reached by the prefix fallback, so every request against them was recorded at zero cost. Two more rows, bare `claude-opus-4-6` and `claude-sonnet-4-6`, close a subtler gap: model-id resolution strips a date suffix but never adds one, so a log row carrying the undated id matched nothing at all. All eight are seeded insert-if-absent, so a price you already customized is untouched.
- **Grok Build Joins the Failover Tabs and the Environment-Conflict Check**: The failover settings gain a fourth tab for Grok Build alongside Claude Code, Codex and Gemini, and the startup environment-conflict banner now detects a shell-exported `XAI_API_KEY` or `GROK_DEFAULT_MODEL` — variables that silently override whatever provider is selected. Detection distinguishes exact names from prefixes, so CC Switch's own `GROK_BIN_DIR` and `GROK_HOME` are not reported.
### Changed
- **DeepSeek and Volcengine Ark Coding Plan Connect to Codex Directly, Without Local Routing**: Both presets were declared OpenAI Chat, which made them takeover-required: the provider card carried the "needs routing" badge, switching with the proxy off raised the routing prompt, and every request travelled Codex → local proxy → Responses-to-Chat conversion → upstream. Both vendors now publish official Codex integrations confirming their endpoints serve the Responses API — DeepSeek's `api.deepseek.com` and Volcengine's `/api/coding/v3` — so both presets declare native Responses, the badge and the prompt disappear, and Codex connects straight to the gateway. The generated `config.toml` is unchanged in both cases, since it already emitted `wire_api = "responses"`; what changes is the catalog profile and, for DeepSeek, the context window, which moves from 1,000,000 to the vendor's own 1,048,576. BytePlus deliberately stays on Chat routing until the international site's documentation is verified separately. The Volcengine preset also gained a comment recording a billing rule worth knowing: the pay-as-you-go `/api/v3` endpoint must never be added to this preset's backup addresses, because it bills separately instead of drawing down plan quota.
- **Catalog `displayName` and `contextWindow` Are Now Explicit-Only**: Both fields previously carried local defaults — the model id and a 128,000-token window — applied before a vendor value could be consulted, so a mirrored catalog would have had its 1M window overwritten with 128K. They are now optional, with the fallbacks applied one level down at entry construction, which makes "left blank" mean "keep whatever the vendor declared". Providers that set the fields explicitly, and every non-mirrored profile, emit identical catalogs to before.
### Fixed
- **Claude Desktop Usage Was Counted Twice**: Claude Desktop traffic through the local gateway landed in the usage dashboard once as a proxy row and once more as an imported transcript row, so its tokens, cost and request counts read roughly double. The regression shipped in v3.18.0: proxy dedup ids were scoped as `session:{app_type}:{provider_id}:{message_id}` for every app except `claude`, which put `claude-desktop` in its own namespace while the transcript importer kept writing the same Claude message under the bare `session:{message_id}` shape with `app_type = 'claude'`. All three dedup defenses failed at once — the primary-key convergence that lets a proxy row absorb an existing session row, the write-side fingerprint probe, and the read-side filter, the latter two both comparing `app_type` with strict equality. The two apps now share the bare namespace again, and the two comparisons are widened by a one-way rule where a `claude` session row can be absorbed by a `claude-desktop` proxy row but never the reverse. Because the read-side filter is also what daily rollups aggregate through, already-stored duplicates stop being counted without any row being rewritten or deleted — see Upgrade notes for the retention limit on that. For Codex, Gemini and OpenCode the widened comparison collapses to the previous exact match, and quota checks keep strict app matching. (#5938, #5951)
- **Switching Back to the Official Codex Provider Stranded You on a 401 With No Login Screen**: With Codex API-key preservation off (the default), switching to a third-party provider writes that vendor's key into `~/.codex/auth.json`. Switching afterwards to a built-in official provider — whose stored credentials are empty — took the config-only write path, so `config.toml` was replaced while the third-party `OPENAI_API_KEY` stayed on disk. Codex then authenticated to the official endpoint with a foreign key and got 401, and because `auth.json` existed it never fell through to its own login screen, leaving no way out from inside the app. After a successful switch to an official Codex provider, CC Switch now deletes an `auth.json` that contains only an `OPENAI_API_KEY` with no first-class credential beside it — OAuth tokens, a personal access token, an agent identity or a Bedrock key all mark the file as real and leave it untouched, while metadata such as `auth_mode`, `last_refresh` or an account id can no longer shield a stale key. Deleting rather than writing `{}` is deliberate: an empty object resolves to ChatGPT mode without tokens and errors at bootstrap, whereas a missing file yields the login screen. The cleanup runs only after the outgoing provider has been backfilled into the database, so the removed key is preserved and comes back when that provider is selected again. Live-config reads were relaxed in the same change so the post-cleanup state — no `auth.json`, an existing `config.toml` — is no longer reported as "Codex is not installed".
- **Grok Build Upgrades From Settings Failed With a Bare `os error 2`**: Upgrading Grok Build from Settings → About failed with `Error: No such file or directory (os error 2)` and nothing else. The cause is an asymmetry between how CC Switch probes tools and how it runs them: probing goes through a login shell, which sources the user's rc files and therefore sees nvm, Homebrew and Volta, while lifecycle scripts ran under a non-login shell inheriting the narrow PATH a GUI app is launched with. That normally does not matter, because anchored commands invoke binaries by absolute path — but grok 0.2.112 moved self-update onto npm distribution, so `grok update` now spawns `npm view` and `npm i -g` internally, and npm resolves node through its own shebang. The inner spawn returned ENOENT, which grok surfaced as the bare error. Lifecycle commands on macOS and Linux now run with the login shell's real PATH merged ahead of the inherited one, read through `/usr/bin/env` rather than by echoing the variable, because fish stores PATH as a list and would emit space-separated segments. Native Grok's update additionally chains the official xAI installer as a fallback — deliberately not `npm i -g`, which shares both of the primary's failure modes; the installer is the only node-free path, lands in the same location, and rewrites the CLI's own `installer` setting back to `internal`, healing users whom an earlier npm fallback had moved onto npm distribution.
- **Grok Build Proxy Takeover Returned 404, and Every Request Looked Like a New Session**: Enabling takeover on a Grok Build provider whose API format was OpenAI Chat or Anthropic produced an immediate 404 with no failover and no usage record — takeover rewrote the base URL and key but left the backend field alone, so the CLI posted to a route the proxy does not register. Takeover now also pins the backend to Responses; the per-provider downgrade to Chat Completions still happens inside the forwarder, and the forced value reverts with the whole live config when the proxy stops. Separately, proxy session extraction recognised only Codex and OpenAI clients, so every Grok Build turn minted a fresh session id marked as not client-provided, which suppressed prompt-cache key injection and per-session grouping in the usage dashboard. Grok's own headers are now read — the conversation id first, then the session id, ignoring the per-request id — under a distinct `grokbuild_` prefix so its rows cannot collide with Codex's. (#5677)
- **Nine Interface Strings Rendered as Simplified Chinese in Every Language**: Nine strings appeared in Simplified Chinese regardless of the selected language, English and Japanese included. Each call site used the inline-default form with a Chinese literal, but the key existed in none of the four locale files — and i18next resolves a key through the language chain before it considers an inline default, so the English fallback never engaged and the Chinese literal won everywhere. The affected strings cover the Grok Build provider form's validation toast, the failover tooltip shown when an app is not yet taken over, the warning raised when stopping Claude Desktop routing while another app holds takeover and the reason line that explains why routing must start, the duplicate-provider id read failure, the empty Codex common-config error, the routing service's stop and stop-failed toasts, and the "unpriced" cost label the usage tables show for a request that carries tokens but computes to zero. All nine now exist in Chinese, English, Japanese and Traditional Chinese. (#5960)
- **The Traditional Chinese About Page Fell Back to English in the Tool Manager**: With the interface set to Traditional Chinese, the tool management section of the About page rendered in English — version rows, install and update buttons, result toasts, the install-conflict diagnosis and the entire upgrade-confirmation dialog. The panel was built out across three earlier changes that added labels to Chinese, English and Japanese only, and because i18next falls back to English rather than failing, thirty missing keys were invisible in testing. This gap shipped in every release from v3.16.0 through v3.19.0. All thirty strings are now translated, the install hint was brought in line with the other locales, and a new locale test asserts that every tool-management label exists in all four languages with matching interpolation variables, so this class of drift fails the suite instead of shipping. (#5943)
- **Seeded Model Prices Disagreed With Vendor List Prices**: Costs are frozen at log time from the built-in pricing table, so a stale seed quietly mis-bills every subsequent request. Four rows were corrected: `deepseek-chat` and `deepseek-reasoner` are now legacy aliases of V4 Flash at $0.14/$0.28 per million input/output with $0.0028 cache read (from $0.27/$1.10 and $0.55/$2.19); `minimax-m3` halves to $0.30/$1.20, the official standard tier; and `gpt-5.6-luna` drops 80% to $0.20/$1.20 while `gpt-5.6-terra` drops 20% to $2/$12, following OpenAI's 2026-07-30 price cut, with `gpt-5.6-sol` deliberately unchanged and the family's cache-write ratio preserved. The repairs rewrite a row only when all four of its cost columns still hold the exact previous built-in values, so a price you edited yourself — or one set by models.dev sync — is never touched.
### Security
- **Deep-Link Import Confirmations Mask More and Truncate Less**: A continuation of the `ccswitch://` confirmation hardening in v3.19.0. Config previews are now built by one shared module that masks secrets recursively through nested TOML tables and JSON objects, which fixes two opposite defects: a Grok Build import rendered no configuration preview at all, while a Codex import printed embedded `api_key` values in the clear. The 300-character truncation on the configuration preview is gone — the full document now renders inside a scrollable box, closing the last place the confirmation could hide part of what it was about to write. Masking itself is stricter everywhere it is used, including the MCP import confirmation: the sensitive-name matcher gained `AUTHORIZATION`, `COOKIE` and `CREDENTIAL` alongside exact matches for `AUTH` and `BEARER`, masked values reveal four leading characters instead of eight, and a value of eight characters or fewer is now replaced entirely rather than shown. Finally, the frontend Base64 decoder no longer trims surrounding whitespace, which could discard a `+` that URL decoding had turned into a space — the same class of frontend/backend decoder divergence fixed in v3.19.0, where the dialog shows one thing and the importer writes another.
### Internal
- **Superseded Code Removed: 3,166 Lines, Fourteen Modules and Four Dependencies**: A deletion pass over code that had no caller. On the Rust side it drops the provider icon inference table, a placeholder health checker, a second never-wired SSE implementation with its own stream and non-stream handlers, two unused proxy session types, and four unreferenced usage parsers along with a dead cost-calculation entry point — the live billing path, its auto-detecting parsers and session id extraction are untouched. Twenty-two `#[allow(dead_code)]` suppressions, which are why the compiler never flagged any of it, go with them. On the front end, fourteen modules with no importer are deleted, including a prompt form modal and a repository manager both superseded by panel rewrites, a duplicate proxy config hook, a circuit-breaker panel that never had an importer in the project's history, and three schema files; their strings are removed identically from all four locales. The Tauri commands behind them are deliberately kept. Four unused npm dependencies are dropped. Two icon-extraction scripts that regenerated the hand-curated icon index are removed, and the index header now states that automatic regeneration is intentionally unsupported. A companion change consolidates proxy status and takeover state onto a single React Query layer — a second, parallel set of hooks over the same commands had zero call sites, so its query keys never had an observer and the invalidations aimed at them were no-ops. Query key strings are byte-identical and the surviving hook keeps its existing poll behavior. (#5916, #5928)
### Upgrade notes
- No database schema migration in this release — `SCHEMA_VERSION` stays at 16, so no pre-migration backup is triggered.
- Claude Desktop's double-counted usage corrects itself retroactively, but only within the detail-row retention window. The fix suppresses duplicate rows at query time rather than rewriting or deleting anything, so every affected day whose detail rows are still present recovers its correct total on next launch. Detail rows older than 30 days are aggregated into daily rollups and pruned, and a day already rolled up by a build without this fix keeps its inflated figure permanently. The regression entered with v3.18.0 (2026-07-21).
- The eight newly priced models are re-costed retroactively where possible: startup backfills requests that were logged with zero cost, so dashboard totals for those models will rise. Rows already rolled up and pruned stay at zero. The four repriced models work the other way — backfill only touches zero-cost rows, so requests already logged keep the old rate and only new requests bill at the new one; historical and current spend for the same model will differ. Manual price edits, models.dev sync values and deletion tombstones are re-applied after seeding and always win.
- Preset changes apply only to newly created providers. A DeepSeek or Volcengine Ark Coding Plan provider already saved keeps its stored API format, keeps requiring local routing, and keeps its old catalog; re-create it from the preset, or switch the API format in the provider form, to get the direct connection. A provider that is already on native Responses with a `deepseek.com` base URL picks up the mirrored official catalog on its next switch without any re-save, because the gate reads the live configuration.
- `deepseek-v4-pro` cannot be used over the direct connection yet. It stays listed in the preset, and in the mirrored vendor catalog, but DeepSeek has not opened its Codex integration for that model — early August 2026 by their own estimate — so selecting it while the provider is on native Responses fails upstream. Use `deepseek-v4-flash`, which is the preset default and unaffected. To use pro today, switch that provider's API format back to OpenAI Chat and enable local routing takeover: that is the path DeepSeek took before this release, and the proxy's Responses-to-Chat conversion serves pro exactly as it did.
- The mirrored DeepSeek catalog declares a minimum Codex client version of 0.144.0, which CC Switch does not verify — the freeform `apply_patch` registration it carries needs that release or newer. The generated catalog file also grows to roughly 75 KB for the two mirrored models, since each carries the full harness text.
- Because DeepSeek, Volcengine Ark Coding Plan and Tencent Hunyuan no longer require takeover, their traffic can bypass the local proxy entirely, and per-request proxy usage accounting does not see it. The usage itself is neither lost nor indistinguishable — Codex session-log import still records it — but that path carries no provider identity: all Codex usage that did not go through the local proxy is grouped under a single `Codex (Session)` entry, official-subscription consumption included. So a provider moving from routed to direct also moves its usage out from under its own name. Model attribution is unaffected: every row keeps its model id, and the usage panel's per-model table still separates `deepseek-v4-flash`, `hy3`, `ark-code-latest` and the official GPT models into their own rows with their own token and cost figures. Keep local routing takeover enabled only if you need the provider dimension specifically — for instance to compare the same model across several aggregators.
- The stale Codex `auth.json` cleanup is gated on the incoming provider carrying the explicit official category and on the outgoing provider being backfilled successfully. An official entry created by hand without that category, or a switch whose backfill failed, still leaves the residue behind.
- Enabling proxy takeover on a Grok Build provider now rewrites its backend to Responses in the live configuration. The stored provider row is untouched and the live file is restored from its backup when the proxy stops.
- On macOS and Linux, every tool install and update triggered from Settings → About now runs with the login shell's PATH merged ahead of the inherited one, so a binary a lifecycle script resolves by name may resolve differently than before; each action also spawns one extra shell to read that PATH, which executes the user's interactive startup files. Windows is unchanged. Users on grok 0.2.112 or later may see two enumerated installations — the native one plus a global npm package that `grok update` created itself; they are kept in sync upstream and report the same version.
- Environment-conflict detection for Claude Code, Codex and Gemini moved from substring to prefix matching, so variables that merely contain an app's name — `MY_ANTHROPIC_API_KEY`, `OLD_GEMINI_API_KEY` — are no longer reported as conflicts.
- Deep-link import confirmations now reveal less of each secret than before: four leading characters instead of eight, and values of eight characters or fewer are masked entirely.
## [3.19.0] - 2026-07-30
Development since v3.18.0 is headlined by a security-hardening wave and a proxy correctness fix with immediate field impact. On the security side (#5811 and follow-ups): skill installs from GitHub repositories are hardened against zip-slip and repository-coordinate path traversal with hard archive limits; a Gemini common-config credential leak is closed and a one-time startup cleanup scrubs keys that already leaked into other providers' configs; imported SQL backups now run under a SQLite authorizer that denies `ATTACH` and every other statement that can reach outside the import database; common-config snippet handling no longer follows `__proto__` into the global prototype; terminal launch escapes project paths with POSIX single quotes so a directory name can no longer inject commands; and `ccswitch://` import confirmations show every argument, env var, URL, and the complete usage-script body — nothing hidden by truncation, nor by a Base64 variant the dialog could not decode, credential-shaped values masked — flag risky values, and import usage scripts disabled by default. On the proxy side, tool-result images are lifted out of stringified tool text and re-emitted as native media on every conversion bridge, ending the ~9,000× token inflation that pushed Codex sessions past their context window after a few screenshots (#4465, #5663). Usage statistics gain opt-in automatic price sync from models.dev with a persistent local override file (#5734), plus coverage of Grok CLI's official OAuth mode imported from session logs and SuperGrok subscription quota on provider cards. Rounding it out: in-app updates are served from a Cloudflare R2 mirror at `dl.ccswitch.io` with GitHub as fallback, Codex usage import reuses parsed parent rollouts across forked sessions (#5626), preset defaults advance to Claude Opus 5, GPT-5.6 Sol, and Gemini 3.6 Flash, the OpenClaw Kimi For Coding base URL is corrected, and the GPT-in-Claude-Code routing guide is now available in English and Japanese.
-369
View File
@@ -1,369 +0,0 @@
# CC Switch v3.19.1
> The through-line of this release is **tying off the loose ends of the last one**: three Chinese Codex gateways have been confirmed to support the Responses API natively, so **local routing takeover is no longer required** — the DeepSeek and Volcengine Ark Coding Plan presets move from routed to direct, and the newly added Tencent Hunyuan TokenHub is direct from the start. Four failures you could hit in daily use are fixed: **Claude Desktop usage has been counted twice since v3.18.0** (the historical numbers correct themselves after upgrading, but there is a 30-day window — see "Upgrade Notes"), switching back to the official Codex provider left you stuck on a 401 with no login screen, upgrading Grok Build from Settings failed with nothing but `os error 2`, and enabling takeover on Grok Build returned a straight 404. On top of that, eight models that had been billed at $0 gain built-in pricing, and 39 interface strings have their language problem fixed. This release has **no database migration**, and it is the first release in this project's history that deletes more than it adds.
**[中文版 →](v3.19.1-zh.md) | [日本語版 →](v3.19.1-ja.md)**
---
## Highlights: What You Can Do Now
- **Connect DeepSeek, Volcengine Ark Coding Plan, and Tencent Hunyuan directly inside Codex**: all three vendors' official Codex documentation now confirms their endpoints serve the Responses API natively. The existing DeepSeek and Volcengine Ark Coding Plan presets move from Chat format to native format, so the "needs routing" badge on the provider card and the prompt on switching both disappear and requests no longer pass through the local proxy's protocol conversion; Tencent Hunyuan TokenHub is new in this release and is native from the start. **Note that DeepSeek V4 Pro cannot use the direct connection yet** — the vendor has not opened its Codex integration for that model. Use V4 Flash for direct connections (it is the preset default); see [Upgrade Notes](#deepseek-v4-pro-cannot-use-the-direct-connection-yet).
- **Let DeepSeek use the model catalog DeepSeek itself publishes**: the new "official vendor catalog mirroring" mechanism serves a vendor's published `models.json` verbatim to that vendor's own endpoint, so the freeform `apply_patch` registration and its companion GPT-5 harness stay together instead of being flattened into the neutral template. The match is on host only, never on model name — the same model resold by an aggregator may not implement the same capabilities.
- **Get correct Claude Desktop usage numbers**: since v3.18.0, Claude Desktop traffic through the local gateway has been recorded twice in the dashboard — once from the proxy and once from session-log import — roughly doubling its tokens, cost, and request counts. After this fix, every day whose detail rows are still present returns to the correct number automatically, **with no rebuild required**.
- **Sign in normally after switching back to the official Codex provider**: previously, switching from a third-party provider back to the built-in official Codex entry left the third-party key in `~/.codex/auth.json`. Codex would use it against the official endpoint and get a reliable 401 — and because the file existed, it never fell back to its own login screen, leaving no way out from inside the app.
- **Upgrade Grok Build from the Settings page**: since 0.2.112, `grok update` performs its distribution by calling npm internally, but an app launched from the GUI cannot see node, so the upgrade only ever reported `Error: No such file or directory (os error 2)`.
- **Enable takeover on Grok Build without hitting a 404**: a Grok Build provider whose API format had been changed by hand to OpenAI Chat or Anthropic would, once takeover was enabled, send requests to a route the proxy does not register — a straight 404, with no failover and no usage record. On top of that, every Grok Build request used to be treated as a new session, which disabled both cache-key injection and per-session grouping.
- **See the real cost of eight models that had been billed at $0**: `gpt-5.3-codex-spark`, `gemini-3.5-flash-lite`, `kimi-k2.7-code-highspeed`, `glm-5-turbo`, `glm-5v-turbo`, `qwen3.6-flash`, plus the undated `claude-opus-4-6` / `claude-sonnet-4-6`.
- **Read the About page's tool manager in Traditional Chinese**: 30 strings had only been added for Simplified Chinese, English, and Japanese and were missing for Traditional Chinese; because i18next silently falls back to English, that panel had been half-English since v3.16.0. A further 9 strings showed Simplified Chinese **in every language**.
- **Move back and forth between the official subscription and DeepSeek instead of choosing one**: `auth.json` and `config.toml` are single-slot files — Codex itself cannot hold a second set of credentials. A vendor's one-click script rewrites that configuration to be its own, whereas CC Switch snapshots and restores it per provider — which is the most practical difference between the two approaches; see [the comparison below](#importing-through-cc-switch-vs-running-the-official-script).
---
## Usage Guides
The changes in this release center on how Codex connects and on how usage is counted. The following docs are worth reading alongside it:
- **[Local Routing](../user-manual/en/4-proxy/4.2-routing.md)**: which providers need takeover enabled, and what takeover does. After this release, DeepSeek, Volcengine Ark Coding Plan, and Tencent Hunyuan no longer need it.
- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: the usage dashboard's data sources and how the statistics are counted — useful for understanding how the Claude Desktop double count happened and why some historical days cannot be corrected.
- **[Using Chat-Format APIs Like DeepSeek in Codex](../guides/codex-deepseek-routing-guide-en.md)**: this guide explains how local routing converts Responses into Chat Completions. The mechanism still applies to Kimi, MiniMax, SiliconFlow, and other providers that remain Chat-shaped, but **the parts that use DeepSeek as the example no longer apply to this release** — DeepSeek now connects directly and needs no routing.
---
> [!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.19.1 is a maintenance release along three lines. The first is Chinese Codex gateways moving to native Responses as a group: DeepSeek connects directly to `api.deepseek.com` and brings a reusable mechanism with it — mirroring the model catalog a vendor publishes itself, so the freeform `apply_patch` registration and its companion GPT-5 harness stay self-consistent rather than being folded into the neutral template; Volcengine's Ark Coding Plan endpoint `/api/coding/v3` follows now that the official documentation confirms it; and Tencent Hunyuan's TokenHub joins as a new preset. None of the three needs local routing takeover any more.
The second is four failures visible in the field: Claude Desktop usage has been recorded twice since v3.18.0 ([#5938](https://github.com/farion1231/cc-switch/issues/5938)); switching back to the built-in official Codex provider left a third-party `auth.json` behind, producing a 401 with no login screen; `grok update` reported nothing but `os error 2` when run from the GUI; and Grok Build's proxy takeover returned 404 on non-Responses backends while every request was treated as a new session ([#5677](https://github.com/farion1231/cc-switch/pull/5677)). The third is weight loss: 3,166 lines of code with no remaining caller and 4 unused npm dependencies are deleted — this is the first release in the project's history that deletes more than it adds. Beyond that, deep-link import confirmations mask more and truncate less, eight models that had been billed at $0 gain pricing, and four built-in prices are realigned with vendor list prices. This release has **no database schema migration** (the version stays at v16), so upgrading is light.
**Release date**: 2026-07-31
**Change size**: 12 commits | 71 files changed | +2,324 / -3,680 lines
---
## Added
### Official Vendor Model Catalog Mirroring (DeepSeek First)
Codex reads model capabilities from a catalog file, and CC Switch previously generated that catalog from a neutral template for every provider — which is right for an aggregator, but strips the capabilities a vendor's own integration depends on. Now, for any vendor whose official catalog is bundled with the app, that vendor's own file is mirrored directly.
DeepSeek is the first: the bundled file carries the `deepseek-v4-flash` and `deepseek-v4-pro` entries, preserving `apply_patch_tool_type: "freeform"`, `web_search_tool_type: "text"`, `supports_search_tool: true`, the low / high / max reasoning tiers, and the 17,644-character GPT-5 harness in `base_instructions` and `model_messages`**that harness has to travel together with the freeform tool registration**, because the harness itself instructs the model to use `apply_patch`; splitting either half leaves the pair inconsistent.
The gate is deliberately narrow: the provider must resolve to the native Responses profile **and** its `base_url` must be on `deepseek.com`. **Matching is by host, not by model brand** — the same model resold by an aggregator may not implement the same capabilities, and granting by brand would hand capabilities to a service that never implemented them. Entries a provider pins in its own catalog still win; an unrecognized model ID clones the flagship entry but keeps its own name. Catalogs generated for every other profile are byte-identical to before.
### Tencent Hunyuan (TokenHub) Codex Preset
The Codex preset picker gains "Tencent Hunyuan" under the Opensource Official category, between Bailian and StepFun. Selecting it writes `https://tokenhub.tencentmaas.com/v1`, `wire_api = "responses"`, and the `disable_response_storage = true` that TokenHub requires; it declares the two models `hy3` and `hy3-preview` with a **256K** context window (rather than accepting Codex's 128K default), and marks them text-only — Codex will no longer send `view_image` payloads to a model that cannot read them.
Because it is a native Responses provider, Codex connects to the gateway directly with no local routing; the generated catalog uses the neutral native template, which pins `shell_type = "shell_command"` and drops the freeform `apply_patch` registration that native gateways reject. The address manager and latency test have two candidates from the start: the primary domain and the official `.cn` backup. The regionally separate international site is deliberately excluded, because API keys do not carry across sites.
Note that the **API key must be a TokenHub key with Hy3 access enabled**; Coding Plan and Token Plan subscription keys do not work against this endpoint.
### Built-In Pricing for Eight Models That Had Been Billed at $0
`gpt-5.3-codex-spark`, `gemini-3.5-flash-lite`, `kimi-k2.7-code-highspeed` (2x the `kimi-k2.7-code` baseline, following Kimi's Turbo convention), `glm-5-turbo`, `glm-5v-turbo`, and `qwen3.6-flash` had no row at all in the built-in pricing table, and the prefix fallback could not reach them either, so every request against them was recorded at zero cost.
Two more rows — the undated `claude-opus-4-6` and `claude-sonnet-4-6` — close a subtler gap: model ID resolution only **strips** a date suffix, it never **adds** one, so a log carrying an undated ID matched nothing. All eight rows are seeded insert-if-absent, so any price you edited is untouched.
### Grok Build Joins the Failover Tabs and Environment-Conflict Detection
The failover section in Settings gains a fourth Grok Build tab alongside Claude Code, Codex, and Gemini. The environment-conflict banner shown at startup also begins detecting `XAI_API_KEY` and `GROK_DEFAULT_MODEL` — two variables that silently override whichever provider you selected in the app. Detection distinguishes exact names from prefixes, so CC Switch's own `GROK_BIN_DIR` and `GROK_HOME` are not falsely reported.
---
## Changed
### DeepSeek and Volcengine Ark Coding Plan Connect to Codex Directly, With No Local Routing
Both presets were previously marked as OpenAI Chat format, which made both of them "needs takeover": the provider card carried the "needs routing" badge, switching without the proxy running raised a prompt, and every request travelled Codex → local proxy → Responses-to-Chat → upstream.
Both vendors' official Codex integration docs now confirm their endpoints serve the Responses API — DeepSeek's `api.deepseek.com` and Volcengine's `/api/coding/v3` — so both presets are declared native Responses, the badge and the prompt disappear, and Codex connects to the gateway directly. Neither vendor's generated `config.toml` changes (it was already `wire_api = "responses"`); what changes is the catalog generation profile, and for DeepSeek the context window, which is realigned from 1,000,000 to the vendor's own 1,048,576.
BytePlus's international site deliberately stays on Chat routing until its documentation is verified separately. The Volcengine preset also carries a billing note worth knowing: the pay-as-you-go `/api/v3` endpoint **must never** be added to this preset's backup addresses — it bills separately and does not draw down plan quota.
### Catalog Display Name and Context Window Are Now Explicit-Only
Both fields previously carried local defaults — the model ID and a 128,000-token window — applied before the vendor's value had a chance to participate, so a mirrored catalog's 1M window would have been overwritten with 128K. They are now optional, with the fallbacks moved down to entry construction, so "left blank" genuinely means "keep the value the vendor declared". Providers that set the two fields explicitly, and every non-mirrored profile, generate exactly the catalog they did before.
---
## Importing Through CC Switch vs. Running the Official Script
DeepSeek publishes a one-click Codex setup script. It works, it takes backups, and it comes with a restore menu. **If this machine is only ever going to use DeepSeek, running the official script is perfectly fine.** What CC Switch addresses is a different situation: you want to move back and forth between several providers.
### Switching Providers Swaps Login State and Configuration as a Set, With No Manual Backup
`~/.codex/auth.json` and `~/.codex/config.toml` are both **single-slot files** — Codex itself has no multi-credential storage, and one configuration can only describe one provider. When you switch away from a provider, CC Switch snapshots the contents of both files into that provider's record; when you switch back, it writes them back whole. So "ChatGPT subscription → DeepSeek → back to the subscription" normally does not require another `codex login`, and moving between third-party providers requires no manual step at all. Doing the same thing by hand means copying both files before and after every switch; miss it once, and the overwritten OAuth credentials can only be recovered by signing in again.
The official script makes a different trade-off: it rewrites `config.toml` into a DeepSeek-specific configuration — pinning `preferred_auth_method = "apikey"` and `forced_login_method = "api"` at the top level to fix authentication to API key, and **deleting any existing `[profiles.*]` from `config.toml`** (Codex's own built-in mechanism for switching between providers). Your ChatGPT credentials themselves are not deleted — `auth.json` is untouched — but they cannot be used under that configuration; returning to the subscription means running the script's restore menu for a whole-file rollback, and that rollback also discards any hand edits you made to `config.toml` after installation. The script itself can only switch between flash and pro; there is no "move to a third provider" option.
### After Switching Providers, Your Old Sessions Are Still in `codex resume`
Codex sorts its resume list into drawers by the `model_provider` recorded in each session. Every third-party Codex provider CC Switch creates — DeepSeek, Kimi, an aggregator, it makes no difference — writes the same identifier `custom`, so however you switch among them, `codex resume` keeps showing the full history. On its first launch, CC Switch also performs a one-time migration that folds known per-vendor buckets (including the `deepseek` one the official script writes) into this shared bucket, backing the original files up to `~/.cc-switch/backups/` first.
**There is a clear boundary here**: that migration runs once, on CC Switch's first launch. **If you install CC Switch first and only later run the official script**, those `deepseek`-tagged sessions will not be folded in — they stay in their own drawer. Separately, a provider identifier you wrote by hand that is not on the known list is deliberately left alone.
### Official-Subscription Sessions Are Already Interleaved With Third-Party Ones in CC Switch
CC Switch's **Sessions panel scans the session directory directly and does not read `model_provider`**, so Codex sessions produced during official-subscription use have always been in the same list as third-party ones — searchable, resumable, deletable — **with no toggle required**.
If you additionally want **Codex's own `codex resume` list** to merge official and third-party sessions, that is a separate matter: Settings → General → Codex App Enhancements → **"Unified Codex session history"**, off by default. Enabling it affects new sessions only; moving existing official sessions across as well requires ticking "Also migrate existing official session history" in the enable dialog (also unchecked by default). Both are pre-existing features, not new in this release; for the edge cases see the [Unified Codex Session History guide](../guides/codex-unified-session-history-guide-en.md).
> **Two shared prerequisites, stated up front so they do not confuse you later:**
>
> **First, everything above applies to the Codex directory CC Switch points at.** That is `~/.codex` by default and can be changed in Settings. **CC Switch does not read the `CODEX_HOME` environment variable** — if you use that variable to point Codex somewhere else, CC Switch cannot see those sessions, and provider switches will be written into a directory the CLI is not using. To change the directory, use CC Switch's own config-directory setting.
>
> **Second, appearing in the same list does not mean a session can be resumed.** Codex's reasoning content (`encrypted_content`) can only be decrypted by the backend that produced it, so continuing an old session on a different provider may fail — that is upstream's design, not something CC Switch can work around.
---
## Fixed
### Claude Desktop Usage Was Counted Twice
Claude Desktop traffic through the local gateway landed twice in the usage dashboard — once as a proxy row and once as a session-import row — so its tokens, cost, and request counts were roughly doubled.
This is a regression introduced in v3.18.0: the proxy-side dedup ID carried a scope prefix for every app except `claude`, written as `session:{app}:{provider}:{message_id}`, which put `claude-desktop` in its own namespace; meanwhile the session importer kept writing the same Claude message in the bare `session:{message_id}` form with `app_type = 'claude'`. Three dedup defenses failed at once: the primary-key convergence that lets a proxy row absorb an existing session row, the write-side fingerprint probe, and the read-side filter — the latter two both comparing app type with strict equality.
The two apps now share the bare namespace again, and the two comparisons are widened by a one-way rule: a `claude` session row can be absorbed by a `claude-desktop` proxy row, **but not the reverse**. Because the read-side filter is exactly the one daily rollups aggregate through, duplicate rows already in the database stop being counted, **with no row rewritten or deleted** — this self-healing has a retention limit, see "Upgrade Notes". For Codex, Gemini, and OpenCode the widened comparison degenerates to the previous exact match, and quota checks still use strict matching. ([#5938](https://github.com/farion1231/cc-switch/issues/5938), [#5951](https://github.com/farion1231/cc-switch/pull/5951))
### Switching Back to the Official Codex Provider Left You Stuck on a 401 With No Login Screen
With the Codex API-key preservation toggle off (the default), switching to a third-party provider writes that vendor's key into `~/.codex/auth.json`. Switching afterwards to the built-in official provider — whose stored credentials are empty — took the config-only branch, so `config.toml` was replaced while the third-party `OPENAI_API_KEY` stayed on disk as it was. Codex then used that foreign key against the official endpoint and got a reliable 401; and because `auth.json` existed, it never fell back to its own login screen, leaving no way out from inside the app.
Now, after a successful switch to an official Codex provider, if `auth.json` contains only an `OPENAI_API_KEY` with no first-class credential beside it, the file is deleted — an OAuth token, a personal access token, an agent identity, or a Bedrock key all mark the file as genuine and leave it fully intact, while metadata such as `auth_mode`, `last_refresh`, or an account ID can no longer "shield" a stale key.
**Deleting the file rather than writing `{}`** is deliberate: an empty object is read by Codex as ChatGPT mode without tokens and errors at startup, whereas a missing file is equivalent to being signed out and goes straight to the login flow. The cleanup runs only after the previous provider has been successfully backfilled into the database, so the deleted key is not lost — it is stored in that provider's record and comes back when you select it again. The same change also relaxed live-config reads: the post-cleanup state (no `auth.json`, a `config.toml` present) is no longer reported as "Codex is not installed".
### Upgrading Grok Build From the Settings Page Reported Nothing but `os error 2`
Upgrading Grok Build under Settings → About failed with `Error: No such file or directory (os error 2)` and no further information.
The root cause is an asymmetry between the probe path and the execution path: probing goes through a login shell, which reads the user's rc files and therefore sees nvm, Homebrew, and Volta, while lifecycle scripts ran under a non-login shell inheriting the very narrow PATH a GUI-launched app starts with. That would normally not matter, because anchored commands invoke their target by absolute path — but grok 0.2.112 moved self-update onto npm distribution, so `grok update` internally invokes `npm view` and `npm i -g`, and npm in turn resolves node through its shebang. The inner call returned ENOENT, and grok surfaced it verbatim as that `os error 2`.
Lifecycle commands on macOS and Linux now merge the login shell's real PATH ahead of the inherited one, read by executing `/usr/bin/env` rather than echoing the variable — because fish stores PATH as a list, and echoing would yield space-separated segments. For a natively installed Grok, the upgrade chain additionally falls back to the official xAI installer, **deliberately not `npm i -g`**: npm shares both of the primary path's failure modes (no node, a mirror missing the package) and would fail alongside it; the official installer is the only route that does not depend on node, lands in the same place, and rewrites the CLI's own `installer` setting back to `internal`, incidentally healing users an earlier npm fallback had moved onto npm distribution.
### Grok Build Returned 404 With Takeover Enabled, and Every Request Looked Like a New Session
Enabling takeover on a Grok Build provider whose API format had been changed to OpenAI Chat or Anthropic produced an immediate 404, with no failover and no usage record — takeover rewrote the address and the key but left the backend field alone, so the CLI sent requests to a route the proxy does not register. Takeover now pins the backend to Responses as well; the per-provider downgrade to Chat Completions still happens in the forwarding layer, and the forced value is restored along with the whole live-config backup when the proxy stops.
The other problem was that the proxy's session detection recognized only Codex and OpenAI clients, so every Grok Build turn generated a new session ID marked "not client-provided", which suppressed both cache-key injection and per-session grouping in the dashboard. Grok's own headers are now read — the conversation ID first, then the session ID, ignoring the one that changes per request — under a separate prefix, so the records cannot collide with Codex's. ([#5677](https://github.com/farion1231/cc-switch/pull/5677))
### Nine Interface Strings Showed Simplified Chinese in Every Language
Nine strings displayed Simplified Chinese regardless of the interface language, in English and Japanese interfaces alike. Every call site used the "inline default value" form with a Chinese literal as the default, but the corresponding key was in **none** of the four locale files — and i18next walks the whole language chain before it considers an inline default, so the English fallback never got a chance and the Chinese literal won in every language.
The affected strings cover the Grok Build provider form's required-field validation, the failover hover tooltip shown when an app is not yet under takeover, the warning raised when stopping Claude Desktop routing while another app holds takeover along with its reason line, the provider-identifier read failure, the empty Codex common-config error, the routing service's stopped and stop-failed toasts, and the "unpriced" label the usage tables put on requests that carry tokens but compute to zero cost. All nine keys now exist in Simplified Chinese, English, Japanese, and Traditional Chinese. ([#5960](https://github.com/farion1231/cc-switch/pull/5960))
### The Traditional Chinese About Page's Tool Manager Fell Back to English
With the interface language set to Traditional Chinese, the tool management section of the About page displayed English — the version rows, the install and update buttons, the result toasts, the install-conflict diagnosis, and the entire upgrade confirmation dialog. That panel was built out across three separate changes, each adding strings for Simplified Chinese, English, and Japanese only; and because i18next's strategy is to fall back to English rather than error, the 30 missing keys were completely invisible in testing. **This gap had shipped in every release from v3.16.0 through v3.19.0.**
All 30 strings are now translated and the install hint is aligned with the other languages; a new locale test requires every tool-management string to exist in all four languages with matching interpolation variables, so this kind of drift will fail the test suite instead of shipping. ([#5943](https://github.com/farion1231/cc-switch/pull/5943))
### Built-In Pricing Had Drifted From Vendor List Prices
Cost is frozen against the built-in pricing table at the moment a request is logged, so a stale seeded price silently miscounts every subsequent request. Four rows are corrected in this release: `deepseek-chat` and `deepseek-reasoner` are now legacy aliases of V4 Flash at $0.14 input / $0.28 output per million tokens with $0.0028 cache read (previously $0.27/$1.10 and $0.55/$2.19); `minimax-m3` is halved to $0.30/$1.20 per the official standard tier; `gpt-5.6-luna` drops 80% to $0.20/$1.20 and `gpt-5.6-terra` drops 20% to $2/$12 following OpenAI's 2026-07-30 price cut, with `gpt-5.6-sol` deliberately unchanged and the family's cache-write ratio preserved.
The repair only rewrites a row when all four of its price columns **still equal the previous built-in values exactly**, so a price you edited yourself — or one written by models.dev sync — is never touched.
---
## Security Hardening
### Deep-Link Import Confirmations: Stricter Masking, Less Truncation
This continues the `ccswitch://` confirmation hardening from v3.19.0. Config previews are now built by a single shared module that recursively masks secrets inside nested TOML tables and JSON objects, fixing two defects that pointed in opposite directions at once: a Grok Build import **rendered no config preview at all**, while a Codex import **printed embedded `api_key` values in the clear**.
The 300-character truncation on the config preview is gone; the full content now renders in a scrollable box — closing the last place the confirmation could hide part of what it was about to write. The masking itself is stricter everywhere it is used, **including the MCP import confirmation**: sensitive-name matching adds `AUTHORIZATION`, `COOKIE`, and `CREDENTIAL`, plus exact matches for `AUTH` and `BEARER`; the plaintext prefix shown after masking shrinks from 8 characters to 4; and values of 8 characters or fewer are now replaced entirely rather than displayed as they are.
Finally, the frontend's Base64 decoder no longer trims leading and trailing whitespace — which may well be a `+` that URL decoding turned into a space. This is the same class of frontend/backend decoder divergence fixed in v3.19.0: what the confirmation displays is one thing, what the importer writes is another.
---
## Internal
### 3,166 Lines With No Caller, 14 Modules, and 4 Dependencies Removed
A pass over code with no remaining caller. On the backend it deletes the provider icon inference table, a placeholder health checker, a never-wired SSE implementation (with its own streaming and non-streaming handlers), two unused proxy session types, four unreferenced usage parsers, and a dead cost-calculation entry point — **the live billing path, its auto-detecting parsers, and session ID extraction are all untouched**. The 22 `#[allow(dead_code)]` suppressions that had kept the compiler quiet about all of it go too.
On the frontend, 14 modules with no importer are deleted, including a prompt form modal and a repository manager both superseded by panel rewrites, a duplicate proxy config hook, a circuit-breaker panel that never had an importer in the project's history, and three schema files; their strings are removed from all four languages in step. The Tauri commands behind these modules are deliberately kept. Four unused npm dependencies are also dropped. Two scripts that regenerated the hand-curated icon index are removed, and the index file's header now states that automatic regeneration is intentionally unsupported.
A companion change consolidates proxy state and takeover state onto a single query layer — there had been a second parallel set of hooks over the same commands with zero callers, whose query keys never had an observer, so every invalidation aimed at them was a no-op. The query key strings are unchanged character for character, and the surviving hook keeps its existing polling behavior. ([#5916](https://github.com/farion1231/cc-switch/pull/5916), [#5928](https://github.com/farion1231/cc-switch/pull/5928))
---
## Upgrade Notes
### No Database Migration in This Release
v3.19.1 contains no schema migration (the version stays at v16), so no pre-upgrade backup is triggered and the upgrade is ready to use immediately.
### The Claude Desktop Double-Count Self-Heal Has a 30-Day Window (Please Read)
The fix suppresses duplicate rows at query time rather than rewriting or deleting data, so **every day whose detail rows are still present returns to the correct total on the next launch**, with no rebuild needed.
But detail rows older than 30 days are aggregated into daily rollups and pruned, and a rollup is computed once, under whatever rules were in effect at aggregation time. **Any day already aggregated by a build without this fix keeps its inflated number permanently.** The regression entered with v3.18.0 (2026-07-21), so the sooner you upgrade, the more of the historical range is recovered.
### The New Pricing Affects Historical Data in Two Different Ways
The eight newly priced models are **recalculated retroactively**: at startup, requests recorded with zero cost have their cost filled in, so the dashboard numbers for those models will **go up**. Detail rows that were already aggregated and pruned cannot be recalculated and stay at zero.
The four repriced models work in the opposite direction: the backfill only touches zero-cost rows, so requests already recorded keep the old price and only new requests bill at the new one — historical and new spend for the same model will not match. Both paths protect your own pricing: the repair only changes rows still at the original built-in value, while manual edits, models.dev sync values, and deletion tombstones in `~/.cc-switch/model-pricing.json` are replayed after seeding and repair and always win.
### Preset Changes Only Affect Newly Created Providers
An already saved DeepSeek or Volcengine Ark Coding Plan provider keeps the API format it stored, still needs local routing, and still uses the old catalog. To use the direct connection, re-create the provider from the preset, or change the API format to native Responses in the provider form's advanced section.
That said, **a provider already on native Responses whose address is on `deepseek.com` picks up the mirrored official catalog on its next switch**, with no re-save required — because the check reads the live configuration.
### DeepSeek V4 Pro Cannot Use the Direct Connection Yet
The preset still lists `deepseek-v4-pro`, and the vendor's own published catalog carries it too, but **DeepSeek has not opened its Codex integration for pro**; their stated timing is early August 2026. Until then, selecting pro in direct-connection mode fails upstream — use `deepseek-v4-flash`, which is also the preset's default model.
If you need pro right now, change that provider's API format back to "OpenAI Chat" and enable local routing takeover. This is exactly the path DeepSeek used before v3.19.1: the local proxy converts the Responses requests Codex sends into Chat Completions, and pro is unaffected on that route.
### Two Prerequisites for the Official DeepSeek Catalog
The mirrored catalog declares a minimum Codex client version of 0.144.0, and **CC Switch does not verify this itself** — the freeform `apply_patch` registration it carries requires that version or newer. Separately, the generated catalog file grows to roughly 75 KB (for the two mirrored models), because each entry carries the full harness text.
### After Going Direct, Usage Attribution Moves From the Provider Name to `Codex (Session)`
DeepSeek, Volcengine Ark Coding Plan, and Tencent Hunyuan no longer need takeover, so their traffic can bypass the local proxy entirely and the proxy's per-request records no longer see them.
**The usage itself is neither lost nor indistinguishable** — Codex's session-log import records it as before, only that path does not carry provider identity: all Codex usage that did not go through the local proxy is grouped under an entry named `Codex (Session)`, official-subscription consumption included. In other words, once DeepSeek moves from routed to direct, its usage moves out from under the name "DeepSeek" and into `Codex (Session)`.
**To tell them apart, look at the model**: every usage record carries its own model ID, and the usage panel's per-model statistics list them row by row — `deepseek-v4-flash`, `hy3`, `ark-code-latest`, and the official subscription's GPT models each get their own row, with separate cost and token figures. Only when the dimension you actually need is **per provider** (comparing the same model across several aggregators, for instance) do you need to keep using local routing takeover — that route records the real provider name.
### Two Prerequisites for the Stale Codex Credential Cleanup
The cleanup runs only when the incoming provider carries the explicit official category **and** the outgoing provider was backfilled successfully. An entry created by hand without the official category, or a switch whose backfill failed, still leaves the residue on disk.
### Enabling Takeover on Grok Build Rewrites the Backend Field
Enabling takeover on a Grok Build provider now rewrites the backend field in the live configuration to Responses. The provider record stored in the database is unaffected, and the live file is restored in full from its backup when the proxy stops.
### PATH Changes for Tool Installs and Upgrades (macOS / Linux Only)
Every tool install and upgrade triggered from Settings → About now merges the login shell's PATH ahead of the inherited one, so a program a lifecycle script resolves by name may resolve differently than before; each action also starts one extra shell to read that PATH, which executes your interactive startup files. **Windows is unaffected.**
Users on grok 0.2.112 or later may see two installation records — the native one, plus the global npm package `grok update` created itself; upstream keeps them in sync and they report the same version.
### Environment-Conflict Detection Now Matches Differently
Detection for Claude Code, Codex, and Gemini has been tightened from "contains" to "prefix", so variables that merely contain an app name — `MY_ANTHROPIC_API_KEY`, `OLD_GEMINI_API_KEY` — are **no longer reported as conflicts**. Detection for Grok Build was added at the same time.
### Deep-Link Import Confirmations Show Less of Each Secret
The plaintext prefix shown after masking shrinks from 8 characters to 4, and values of 8 characters or fewer are masked entirely. This affects the MCP import confirmation as well.
---
## Risk Notice
### Carried-Over Notices
**xAI Grok OAuth sign-in**: reuses the public OAuth client identity of the official Grok CLI; using it could lead to account restriction or suspension — see the [v3.18.0 release notes](v3.18.0-en.md#risk-notice) for details.
**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.
**SuperGrok quota queries**: the quota display on provider cards depends on a non-public billing endpoint at grok.com and may stop working once xAI changes the interface — see the [v3.19.0 release notes](v3.19.0-en.md#risk-notice) for details.
**Third-party provider routing**: when the CC Switch local proxy converts and forwards Codex, Claude Desktop, or Grok Build requests to a third-party provider, each provider has different constraints on billing, compliance, and data retention. Please read the target provider's terms of service before use.
By enabling these features, users accept the associated risks. CC Switch is not responsible for any account restriction, warning, or service suspension resulting from their use.
---
## Thanks
Most of the fixes in this release came from outside contributors — five of the six PRs are not mine.
### Code Contributions
- [#5677](https://github.com/farion1231/cc-switch/pull/5677): finishing off Grok Build's proxy takeover and deep-link integration — the backend field, session identity, the failover tab, and environment-variable detection, plus a fix for the credential leak in config previews along the way. Thanks to @YUZHEthefool. This is the broadest single piece of work in the release.
- [#5951](https://github.com/farion1231/cc-switch/pull/5951): the Claude Desktop double-count fix. Thanks to @Komikawayi. Pinpointing which change in v3.18.0 made all three dedup defenses fail at once was the most patient piece of investigation in this release.
- [#5916](https://github.com/farion1231/cc-switch/pull/5916), [#5928](https://github.com/farion1231/cc-switch/pull/5928): removing 3,166 lines of code with no callers and the duplicate proxy query layer. Thanks to @SaladDay.
- [#5943](https://github.com/farion1231/cc-switch/pull/5943): completing the Traditional Chinese tool-management strings and adding a test that prevents locale drift. Thanks to @yovinchen.
- [#5960](https://github.com/farion1231/cc-switch/pull/5960): completing the 9 strings that showed Simplified Chinese in every language. Thanks to @mhy1227.
### Issue Reports
Thanks to @Alaric-L for reporting in [#5938](https://github.com/farion1231/cc-switch/issues/5938) that every Claude Desktop request produced an extra log row sourced from `session_log`, causing tokens to be counted twice — that report pinpointed the data source, and the most important usage fix in this release was located directly from it.
---
## Download & Install
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) and download the build for your system, or get it from the official site [ccswitch.io](https://ccswitch.io) (downloads are distributed through Cloudflare edge nodes and do not depend on GitHub being reachable).
### System Requirements
| System | Minimum Version | Architecture |
| ------- | -------------------- | ----------------------------------- |
| Windows | Windows 10 and later | x64 / ARM64 |
| macOS | macOS 12 (Monterey)+ | Intel (x64) / Apple Silicon (arm64) |
| Linux | See table below | x64 / ARM64 |
### Windows
| File | Description |
| ---------------------------------------- | ------------------------------------------------ |
| `CC-Switch-v3.19.1-Windows.msi` | **Recommended** - MSI installer with auto-update |
| `CC-Switch-v3.19.1-Windows-Portable.zip` | Portable build, unzip and run |
Windows ARM64 devices should pick the artifact whose file name carries the `arm64` tag.
### macOS
| File | Description |
| -------------------------------- | ----------------------------------------------------- |
| `CC-Switch-v3.19.1-macOS.dmg` | **Recommended** - DMG installer, drag to Applications |
| `CC-Switch-v3.19.1-macOS.zip` | Unzip and drag to Applications, Universal Binary |
| `CC-Switch-v3.19.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.19.1-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- `CC-Switch-v3.19.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` |
-369
View File
@@ -1,369 +0,0 @@
# CC Switch v3.19.1
> 本リリースの主軸は**前バージョンの積み残しを片付けること**です。中国系 Codex ゲートウェイ 3 社が Responses API のネイティブ対応を確認できたため、**ローカルルーティングの引き継ぎが不要**になりました——DeepSeek と火山方舟 Coding Plan のプリセットはローカルルーティング経由から直接接続へ変更、新規追加の Tencent Hunyuan TokenHub は最初から直接接続です。日常的に遭遇しうる 4 つの不具合も修正しました——**Claude Desktop の使用量が v3.18.0 以降 2 重に計上されていた問題**(アップグレード後に過去の数値も自動で正常化しますが 30 日の期限があります。「アップグレード時の注意」を参照)、公式 Codex プロバイダに戻すと 401 のままログイン画面も表示されなくなる問題、設定ページからの Grok Build アップグレードが `os error 2` の一言で失敗する問題、Grok Build で引き継ぎを有効にすると 404 になる問題です。さらに、これまで $0 で計上されていた 8 モデルに組み込み価格を追加し、UI 文言 39 件の言語の問題を修正しました。本リリースに**データベースマイグレーションはなく**、また本プロジェクト史上初めて削除行数が追加行数を上回ったリリースでもあります。
**[English →](v3.19.1-en.md) | [中文版 →](v3.19.1-zh.md)**
---
## ハイライト:本リリースでできること
- **DeepSeek・火山方舟 Coding Plan・Tencent Hunyuan を Codex から直接接続する**:3 社の公式 Codex ドキュメントで、エンドポイントが Responses API をネイティブ提供していることが確認できました。DeepSeek と火山方舟 Coding Plan の既存プリセットは Chat 形式からネイティブ形式に変わり、プロバイダカードの「ルーティングが必要」バッジと切り替え時の確認メッセージがなくなり、リクエストはローカルプロキシのプロトコル変換を通らなくなります。Tencent Hunyuan TokenHub は本リリースで追加されたプリセットで、最初からネイティブ形式です。**DeepSeek V4 Pro はまだ直接接続で使えない点にご注意ください**——ベンダー側で Codex 連携がまだ開放されていません。直接接続では V4 Flash(プリセットの既定モデル)をご利用ください。詳細は[アップグレード時の注意](#deepseek-v4-pro-はまだ直接接続で使えません)を参照してください。
- **DeepSeek に DeepSeek 自身が公開したモデルカタログを使わせる**:新しい「公式ベンダーカタログのミラーリング」機構は、ベンダーが公開している `models.json` をそのまま当該ベンダーのエンドポイント向けに配信します。これにより freeform な `apply_patch` の登録と、対になる GPT-5 ハーネスがセットのまま保持され、中立テンプレートに押し込められることがなくなります。判定はホスト名のみで行い、モデル名では行いません——同じモデルでも、再販するアグリゲータが同じ機能を実装しているとは限らないためです。
- **Claude Desktop の正しい使用量を得る**:v3.18.0 以降、ローカルゲートウェイ経由の Claude Desktop のトラフィックはダッシュボードに 2 回記録されていました——1 回はプロキシから、もう 1 回はセッションログの取り込みから——token・費用・リクエスト数がおよそ 2 倍になっていました。本リリースの修正後、明細行が残っている日は自動的に正しい数値に戻ります。**再構築の操作は不要です。**
- **公式 Codex プロバイダに戻した後に正常にログインする**:これまでサードパーティのプロバイダから組み込みの公式 Codex エントリに戻すと、サードパーティのキーが `~/.codex/auth.json` に残っていました。Codex はそのキーで公式エンドポイントにリクエストし、確実に 401 になります——しかもファイルが存在するため Codex 自身のログイン画面にも戻らず、アプリ内から抜け出す手段がありませんでした。
- **設定ページから Grok Build をアップグレードする**:`grok update` は 0.2.112 以降、内部で npm を呼び出して配布を行うようになりましたが、GUI から起動したアプリからは node が見えないため、アップグレードは `Error: No such file or directory (os error 2)` の一言で失敗していました。
- **Grok Build で 404 にならずに引き継ぎを有効にする**:API 形式を手動で OpenAI Chat または Anthropic に変更した Grok Build プロバイダで引き継ぎを有効にすると、プロキシが登録していないルートにリクエストが送られ、そのまま 404 になっていました。フェイルオーバーも使用量の記録もありません。さらに Grok Build のリクエストはこれまで毎回新しいセッションとして扱われ、キャッシュキーの注入とセッション単位の集計がどちらも効かなくなっていました。
- **これまで $0 で計上されていた 8 モデルの実際のコストを見る**:`gpt-5.3-codex-spark``gemini-3.5-flash-lite``kimi-k2.7-code-highspeed``glm-5-turbo``glm-5v-turbo``qwen3.6-flash`、および日付サフィックスのない `claude-opus-4-6` / `claude-sonnet-4-6` です。
- **繁体字中国語の UI で「バージョン情報」ページのツール管理を読む**:30 件の文言が簡体字中国語・英語・日本語にしか追加されておらず、繁体字中国語が漏れていました。i18next は黙って英語にフォールバックするため、このパネルは v3.16.0 以降ずっと半分英語のままでした。さらに 9 件の文言は**すべての言語で**簡体字中国語が表示されていました。
- **公式サブスクリプションと DeepSeek を二者択一ではなく行き来する**:`auth.json``config.toml` はどちらもスロットが 1 つしかないファイルで、Codex 自身は 2 組目の認証情報を保持できません。ベンダーのワンクリックスクリプトはこの設定を自分専用に書き換えますが、CC Switch はプロバイダごとにまとめてスナップショットして復元します——これが公式スクリプトとの最も実際的な違いです。詳細は[後述の比較](#cc-switch-経由の導入と公式スクリプトの違い)を参照してください。
---
## 利用ガイド
本リリースの変更は Codex の接続方式と使用量の集計基準に集中しています。以下のドキュメントとあわせてお読みください:
- **[ローカルルーティング](../user-manual/ja/4-proxy/4.2-routing.md)**:どのプロバイダで引き継ぎが必要か、引き継ぎが何をするか。本リリース以降、DeepSeek・火山方舟 Coding Plan・Tencent Hunyuan には不要になります。
- **[使用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**:使用量ダッシュボードのデータソースと集計基準。Claude Desktop の 2 重計上がどう起きたか、修正後もなぜ一部の過去日を戻せないかを理解する助けになります。
- **[Codex で DeepSeek のような Chat 形式 API を使う](../guides/codex-deepseek-routing-guide-ja.md)**:このガイドはローカルルーティングが Responses を Chat Completions に変換する仕組みを説明したものです。仕組みの部分は Kimi・MiniMax・SiliconFlow など Chat 形式のままのプロバイダには引き続き当てはまりますが、**DeepSeek を例として扱っている部分は本リリースには当てはまりません**——DeepSeek は直接接続になり、ルーティングは不要です。
---
> [!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.19.1 はメンテナンスリリースで、軸は 3 つあります。1 つ目は中国系 Codex ゲートウェイがまとめてネイティブ Responses に移行したことです。DeepSeek は `api.deepseek.com` に直接接続し、あわせて再利用可能な機構をもたらしました——ベンダー自身が公開するモデルカタログをそのままミラーリングして配信し、freeform な `apply_patch` の登録と対になる GPT-5 ハーネスが中立テンプレートに畳み込まれず、整合したまま保たれます。火山方舟の Coding Plan エンドポイント `/api/coding/v3` も公式ドキュメントでの確認を経て追随し、Tencent Hunyuan の TokenHub が新しいプリセットとして加わりました。3 つとも、もうローカルルーティングの引き継ぎを有効にする必要はありません。
2 つ目は実際に遭遇しうる 4 つの不具合の修正です。Claude Desktop の使用量が v3.18.0 以降 2 回記録されていた問題([#5938](https://github.com/farion1231/cc-switch/issues/5938))、組み込みの公式 Codex プロバイダに戻すとサードパーティの `auth.json` が残り、401 になったうえログイン画面も出なくなる問題、`grok update` が GUI 環境では `os error 2` としか報告しない問題、そして Grok Build のプロキシ引き継ぎが Responses 以外のバックエンドで 404 になり、かつ毎回のリクエストが新規セッション扱いになっていた問題([#5677](https://github.com/farion1231/cc-switch/pull/5677))です。3 つ目は減量です。呼び出し元が 1 つもないコード 3,166 行と、未使用の npm 依存 4 件を削除しました——本プロジェクト史上初めて、削除行数が追加行数を上回ったリリースです。このほか、ディープリンクのインポート確認ダイアログはマスクをより厳しく、切り詰めをより少なくし、$0 で計上されていた 8 モデルに価格を追加し、組み込み価格 4 件をベンダーの定価に合わせ直しました。本リリースに**データベーススキーマのマイグレーションはなく**(バージョンは v16 のまま)、アップグレードは軽量です。
**リリース日**2026-07-31
**変更規模**12 commits | 71 files changed | +2,324 / -3,680 lines
---
## 追加機能
### 公式ベンダーモデルカタログのミラーリング(DeepSeek が第 1 号)
Codex はカタログファイルからモデルの機能を読み取りますが、CC Switch はこれまでどのプロバイダに対しても中立テンプレートからカタログを生成していました。アグリゲータにはこれが正しいものの、ベンダー自身の連携が前提としている機能が落ちてしまいます。本リリースからは、公式カタログをアプリに同梱しているベンダーについては、そのベンダー自身のファイルをそのまま配信します。
DeepSeek が第 1 号です。同梱ファイルは `deepseek-v4-flash``deepseek-v4-pro` の 2 エントリを持ち、`apply_patch_tool_type: "freeform"``web_search_tool_type: "text"``supports_search_tool: true`、low / high / max の 3 段階の推論レベル、そして `base_instructions``model_messages` に入っている 17,644 文字の GPT-5 ハーネスを保持します——**このハーネスは freeform なツール登録とセットで扱う必要があります**。ハーネス自体がモデルに `apply_patch` の使用を指示しているため、どちらか一方だけを取り出すと整合しなくなるからです。
判定条件は意図的に狭くしてあります。プロバイダがネイティブ Responses の区分に該当し、**かつ** `base_url``deepseek.com` 上にあることが条件です。**判定はホスト名で行い、モデルのブランド名では行いません**——同じモデルを再販するアグリゲータが同じ機能を実装しているとは限らず、ブランド名で付与することは、実装していないサービスに機能を与えることになるからです。プロバイダが自身のカタログで明示した項目は引き続き優先されます。未知のモデル ID の場合はフラッグシップのエントリを複製したうえで、そのモデル自身の名称を保ちます。他のすべての区分で生成されるカタログは、変更前とバイト単位で同一です。
### Tencent HunyuanTokenHubCodex プリセット
Codex のプリセット選択に「Tencent Hunyuan」が追加され、「オープンソース公式」カテゴリの Bailian と StepFun の間に入ります。選択すると `https://tokenhub.tencentmaas.com/v1``wire_api = "responses"`、および TokenHub が必須としている `disable_response_storage = true` が書き込まれます。モデルは `hy3``hy3-preview` の 2 つを宣言し、コンテキストウィンドウは **256K**Codex の既定である 128K を受け入れるのではなく)、さらにテキスト専用として印を付けます——Codex が `view_image` の画像ペイロードを、画像を読めないモデルに送ることはなくなります。
ネイティブ Responses のプロバイダであるため、Codex はローカルルーティングなしでゲートウェイに直接接続します。生成されるカタログは中立のネイティブテンプレートを使い、`shell_type = "shell_command"` を固定し、ネイティブゲートウェイが受け付けない freeform な `apply_patch` の登録を外します。アドレス管理と速度計測には最初から 2 つの候補があります——メインドメインと、公式のバックアップである `.cn` ドメインです。地域が独立している国際サイトは、API キーがサイト間で共通ではないため意図的に除外しています。
**API キーは Hy3 の権限が有効な TokenHub のキーである必要がある**点にご注意ください。Coding Plan や Token Plan のサブスクリプションキーは、このエンドポイントでは利用できません。
### $0 で計上されていた 8 モデルに組み込み価格を追加
`gpt-5.3-codex-spark``gemini-3.5-flash-lite``kimi-k2.7-code-highspeed`Kimi の Turbo の慣例に従い `kimi-k2.7-code` の基準価格の 2 倍)、`glm-5-turbo``glm-5v-turbo``qwen3.6-flash` は組み込み価格テーブルに行そのものが存在せず、プレフィックスによるフォールバックでも届かなかったため、これらへのリクエストはすべてコスト 0 として記録されていました。
さらに 2 行——日付サフィックスのない `claude-opus-4-6``claude-sonnet-4-6`——は、より分かりにくい隙間を塞ぐものです。モデル ID の解決は日付サフィックスを**取り除く**ことしかせず、**付け足す**ことはないため、日付なしの ID を持つログはどれにも一致しませんでした。8 行はいずれも「存在しない場合のみ挿入」で投入されるため、変更済みの価格は影響を受けません。
### Grok Build がフェイルオーバータブと環境変数の競合検出に加わりました
設定ページのフェイルオーバーに、Claude Code・Codex・Gemini に続く 4 つ目の Grok Build タブが追加されました。起動時の環境変数競合バナーも `XAI_API_KEY``GROK_DEFAULT_MODEL` の検出を開始します——この 2 つはアプリで選んだプロバイダを黙って上書きしてしまう変数です。検出は完全一致とプレフィックスを区別するため、CC Switch 自身が使う `GROK_BIN_DIR``GROK_HOME` が誤検出されることはありません。
---
## 変更
### DeepSeek と火山方舟 Coding Plan が Codex に直接接続、ローカルルーティング不要に
両プリセットはこれまで OpenAI Chat 形式として扱われていたため、どちらも「引き継ぎが必要」でした。プロバイダカードには「ルーティングが必要」バッジが付き、プロキシを起動せずに切り替えると確認が出て、リクエストは毎回 Codex → ローカルプロキシ → Responses から Chat への変換 → 上流、という経路をたどっていました。
両社の公式 Codex 連携ドキュメントで、エンドポイントが Responses API を提供していることが確認できました——DeepSeek の `api.deepseek.com` と火山方舟の `/api/coding/v3` です。これに伴い両プリセットはネイティブ Responses として宣言され、バッジと確認メッセージはなくなり、Codex はゲートウェイに直接接続します。両社とも生成される `config.toml` に変化はありません(もともと `wire_api = "responses"` でした)。変わるのはカタログの生成区分と、DeepSeek についてはコンテキストウィンドウで、1,000,000 からベンダー自身の 1,048,576 に合わせ直しました。
BytePlus の国際サイトは、ドキュメントを個別に確認するまで意図的に Chat ルーティングのままとします。火山方舟のプリセットには、知っておく価値のある課金上の注記も残しました。従量課金の `/api/v3` エンドポイントは、このプリセットのバックアップアドレスに**絶対に追加してはいけません**——別勘定で課金され、プランの残量を消費しないためです。
### カタログの表示名とコンテキストウィンドウが「明示した場合のみ有効」に
この 2 つのフィールドには、これまでローカルの既定値——モデル ID と 128,000 のウィンドウ——が付いており、しかもベンダーの値が関与する前に適用されていたため、ミラーリングしたカタログの 1M のウィンドウが 128K に上書きされてしまう状態でした。現在この 2 つは省略可能になり、フォールバックはエントリ構築の層まで下ろされたため、「空のままにする」ことが本当に「ベンダーが宣言した値を使う」を意味するようになりました。この 2 つを明示したプロバイダ、およびミラーリング以外のすべての区分では、生成されるカタログは以前とまったく同じです。
---
## CC Switch 経由の導入と公式スクリプトの違い
DeepSeek は Codex のワンクリック導入スクリプトを公開しています。これはきちんと動き、バックアップも取り、復元メニューも備えています。**このマシンで DeepSeek だけを使うつもりなら、公式スクリプトを実行して何の問題もありません。** CC Switch が扱うのは別の場面です——複数のプロバイダを行き来したい場合です。
### プロバイダを切り替えるとき、ログイン状態と設定がまとめて入れ替わる(自分でバックアップする必要はありません)
`~/.codex/auth.json``~/.codex/config.toml` はどちらも**スロットが 1 つしかないファイル**です——Codex 自身に複数の認証情報を保持する仕組みはなく、1 つの設定は 1 つのプロバイダにしか対応できません。CC Switch はあるプロバイダから切り替えるとき、この 2 ファイルの内容をまとめてそのプロバイダのレコードにスナップショットし、戻すときにまとめて書き戻します。そのため「ChatGPT サブスクリプション → DeepSeek → サブスクリプションに戻る」で通常 `codex login` をやり直す必要はなく、サードパーティ間の行き来には手作業がまったく不要です。同じことを手作業で行う場合は、切り替えのたびに前後でこの 2 ファイルをコピーする必要があり、一度でも漏らせば、上書きされた OAuth の認証情報はログインし直す以外に取り戻せません。
公式スクリプトのトレードオフは異なります。`config.toml` を DeepSeek 専用の設定に作り替え——トップレベルに `preferred_auth_method = "apikey"``forced_login_method = "api"` を固定して認証方式を API キーに固定し、さらに **`config.toml` にすでにある `[profiles.*]` を削除します**(Codex 自身が持つ、複数プロバイダを切り替える仕組みです)。ChatGPT のログイン認証情報そのものは削除されず、`auth.json` はそのまま残りますが、その設定のもとでは使えません。サブスクリプションに戻すにはスクリプトの復元メニューで全体をロールバックする必要があり、そのロールバックではインストール後に `config.toml` へ加えた手動の変更もあわせて失われます。スクリプト自体も flash と pro の切り替えしかできず、「3 つ目のプロバイダに移る」という選択肢はありません。
### プロバイダを切り替えた後も、`codex resume` に以前のセッションが残る
Codex は各セッションに記録された `model_provider` によって、再開一覧を引き出しのように分けています。CC Switch が作成するサードパーティの Codex プロバイダは——DeepSeek でも Kimi でもアグリゲータでも——すべて同じ識別子 `custom` を書き込むため、それらの間をどう切り替えても `codex resume` からは常に全履歴が見えます。CC Switch は初回起動時に一度だけ移行処理も行い、ベンダーごとに分かれていた既知の古いセッション(公式スクリプトが書き込む `deepseek` もこれに含まれます)をこの共有の引き出しへまとめます。その際、元のファイルは先に `~/.cc-switch/backups/` へバックアップされます。
**ここには明確な境界があります**。この移行は CC Switch の初回起動時に一度だけ実行されます。**先に CC Switch をインストールし、その後で公式スクリプトを実行した場合**、`deepseek` の識別子を持つそれらのセッションはもうまとめられず、自分の引き出しに残ったままになります。また、手書きで指定した、既知の一覧にないプロバイダ識別子は、CC Switch が意図的に変更しません。
### 公式サブスクリプションのセッションは、CC Switch ではもともとサードパーティと同じ一覧に並んでいます
CC Switch の**セッション管理パネルはセッションディレクトリを直接スキャンし、`model_provider` を読みません**。そのため公式サブスクリプションの利用中に生まれた Codex セッションは、以前からサードパーティのセッションと同じ一覧にあり、検索・再開・削除ができます——**どのスイッチも有効にする必要はありません**。
さらに **Codex 自身の `codex resume` の一覧**でも公式とサードパーティをまとめたい場合は、それは別の話になります。設定 → 一般 → Codex アプリ拡張 → **「Codex セッション履歴を統一」**で、既定はオフです。有効にすると新しいセッションのみが対象になります。既存の公式セッションもあわせて移すには、有効化の確認ダイアログで「既存の公式セッション履歴もあわせて移行する」にチェックを入れる必要があります(こちらも既定ではチェックなしです)。この 2 つはどちらも既存機能で、本リリースでの新規追加ではありません。境界となるケースについては[「Codex セッション履歴の統一」ガイド](../guides/codex-unified-session-history-guide-ja.md)を参照してください。
> **共通の前提が 2 つあります。後で戸惑わないよう先に明記します:**
>
> **1. すべては CC Switch が参照している Codex ディレクトリが基準です。** 既定は `~/.codex` で、設定から変更できます。**CC Switch は `CODEX_HOME` 環境変数を読みません**——この変数で Codex を別の場所に向けている場合、そちらのセッションは CC Switch からは見えず、プロバイダの切り替えも CLI が使っていないディレクトリに書き込まれます。ディレクトリを変更するときは、CC Switch 自身の「設定ファイルのディレクトリ」設定をお使いください。
>
> **2. 同じ一覧に並ぶことは、必ず再開できることを意味しません。** Codex の推論内容(`encrypted_content`)は、それを生成したバックエンドでしか復号できないため、別のプロバイダで以前のセッションを続けようとすると失敗することがあります——これは上流の設計であり、CC Switch が回避できるものではありません。
---
## 修正
### Claude Desktop の使用量が 2 回計上されていた問題
ローカルゲートウェイ経由の Claude Desktop のトラフィックは、使用量ダッシュボードに 2 回入っていました——1 回はプロキシの行、もう 1 回はセッション記録の取り込みの行です——その結果、token・費用・リクエスト数がおよそ 2 倍になっていました。
これは v3.18.0 で入った回帰です。プロキシ側の重複排除 ID は `claude` 以外のすべてのアプリにスコープのプレフィックスを付け、`session:{アプリ}:{プロバイダ}:{メッセージID}` の形にしていました。これにより `claude-desktop` が独立した名前空間に入る一方、セッションの取り込み側は同じ Claude のメッセージを、素の `session:{メッセージID}` の形かつ `app_type = 'claude'` で書き続けていました。その結果、3 つの重複排除の防御が同時に破られました——プロキシの行が既存のセッション行を吸収するための主キーの収束、書き込み側のフィンガープリント照合、読み取り側のフィルタです。後の 2 つはどちらもアプリ種別を厳密な等価比較で判定していました。
現在は 2 つのアプリが再び素の名前空間を共有し、2 か所の比較は一方向の規則で緩和されています。`claude` のセッション行は `claude-desktop` のプロキシ行に吸収されうる一方、**その逆は成立しません**。読み取り側のフィルタは日次ロールアップの集計が通るものと同一であるため、すでに保存済みの重複行も計上されなくなります。**行の書き換えも削除も行いません**——この自動修復には保持期間の制限があります。「アップグレード時の注意」を参照してください。Codex・Gemini・OpenCode では緩和後の比較はもとの厳密一致に退化し、上限チェックは引き続き厳密一致を使います。([#5938](https://github.com/farion1231/cc-switch/issues/5938)、[#5951](https://github.com/farion1231/cc-switch/pull/5951)
### 公式 Codex プロバイダに戻すと 401 のまま、ログイン画面も出ない問題
Codex の API キー保持スイッチがオフのとき(既定はオフです)、サードパーティのプロバイダに切り替えると相手のキーが `~/.codex/auth.json` に書き込まれます。その後、組み込みの公式プロバイダ——保存されている認証情報は空です——に切り替えると「設定だけを書く」分岐を通るため、`config.toml` は置き換えられる一方、サードパーティの `OPENAI_API_KEY` はそのままディスクに残りました。Codex はその外部のキーで公式エンドポイントにリクエストして確実に 401 になり、さらに `auth.json` が存在するため自身のログイン画面にも戻らず、アプリ内には出口がありませんでした。
現在は、公式 Codex プロバイダへの切り替えが成功した後、`auth.json` の中身が `OPENAI_API_KEY` のみで、その横に第一級の認証情報が何もない場合、このファイルを削除します——OAuth トークン、パーソナルアクセストークン、agent の識別情報、Bedrock のキーのいずれかがあれば本物の認証情報であることを示すため、完全に保持されます。一方、`auth_mode``last_refresh`・アカウント ID のような単なるメタデータは、古くなったキーを「かばう」ことがもうできません。
**`{}` を書き込むのではなくファイルを削除する**のは意図的な選択です。空のオブジェクトは Codex にトークンのない ChatGPT モードと判定され、起動時にエラーになります。ファイルがない状態こそが未ログインと等価で、そのままログインの流れに入ります。このクリーンアップは、以前のプロバイダがデータベースへ正常に書き戻された後にのみ実行されるため、削除されたキーは失われていません——そのプロバイダのレコードに保存されており、再び選択すれば戻ってきます。同じ変更でライブ設定の読み取りも緩和され、クリーンアップ後の状態(`auth.json` がなく `config.toml` がある)が「Codex がインストールされていません」と報告されることはなくなりました。
### 設定ページからの Grok Build アップグレードが `os error 2` の一言で失敗する問題
設定 → バージョン情報から Grok Build をアップグレードすると、`Error: No such file or directory (os error 2)` で失敗し、それ以外の情報は何も出ませんでした。
根本原因は、検出の経路と実行の経路の非対称性です。検出はログインシェルを通るためユーザーの rc ファイルを読み、nvm・Homebrew・Volta が見えます。一方、ライフサイクルのスクリプトは非ログインシェルで実行され、GUI アプリの起動時に受け継がれる非常に狭い PATH を引き継ぎます。本来これは問題になりません。アンカーされたコマンドは対象のプログラムを絶対パスで呼び出すからです——しかし grok 0.2.112 は自己更新を npm 配布に移し、`grok update` は内部で `npm view``npm i -g` を呼び出すようになりました。そして npm 自身は shebang を通じて node を解決します。内側の呼び出しが ENOENT を返し、grok はそれをそのまま `os error 2` として投げていました。
現在、macOS と Linux のライフサイクルコマンドは、ログインシェルの実際の PATH を、引き継いだ PATH の前に結合します。読み取りは変数をエコーするのではなく `/usr/bin/env` を実行して行います——fish は PATH をリストとして保持しており、エコーするとスペース区切りの断片になってしまうためです。ネイティブインストールの Grok については、アップグレードの連鎖に公式 xAI インストーラーをフォールバックとして追加しました。**あえて `npm i -g` は使いません**:npm は主経路と同じ 2 つの失敗モード(node がない、ミラーにパッケージがない)を共有しており、一緒に失敗するからです。公式インストーラーは node に依存しない唯一の経路で、配置先も同じであり、さらに CLI 自身の `installer` 設定を `internal` に書き戻すため、初期の npm フォールバックによって npm 配布に切り替わってしまったユーザーも、ついでに元に戻ります。
### Grok Build で引き継ぎを有効にすると 404 になり、毎回のリクエストが新規セッションのように扱われる問題
API 形式を OpenAI Chat または Anthropic に変更した Grok Build プロバイダで引き継ぎを有効にすると、ただちに 404 になり、フェイルオーバーも使用量の記録もありませんでした——引き継ぎはアドレスとキーを書き換える一方、バックエンドのフィールドには手を付けなかったため、CLI がプロキシの登録していないルートにリクエストを送っていたのです。現在は引き継ぎがバックエンドも Responses に固定します。個別のプロバイダに応じて Chat Completions へ落とす処理は引き続き転送層で行われ、この強制された値はプロキシ停止時にライブ設定のバックアップとともに元へ戻ります。
もう 1 つの問題は、プロキシのセッション判定が Codex と OpenAI のクライアントしか認識していなかったことです。そのため Grok Build のリクエストは毎回新しいセッション ID を生成して「クライアント提供ではない」と印を付けられ、キャッシュキーの注入とダッシュボードのセッション単位の集計がどちらも効かなくなっていました。現在は Grok 自身のヘッダを読みます——まずセッションが属する会話の ID、次にセッション ID を見て、リクエストごとに変わる ID は無視します——さらに専用のプレフィックスを使い、Codex の記録と衝突しないようにしています。([#5677](https://github.com/farion1231/cc-switch/pull/5677)
### 9 件の UI 文言がすべての言語で簡体字中国語を表示していた問題
9 件の文字列が、UI の言語にかかわらず簡体字中国語で表示されていました。英語や日本語の UI でも同様です。各呼び出し箇所は「インラインの既定値」の書き方を使い、既定値には中国語のリテラルが入っていましたが、対応するキーは 4 つの言語ファイルの**どれにも存在しませんでした**——そして i18next は言語チェーンをすべてたどってからインラインの既定値を検討するため、英語へのフォールバックが働く機会がそもそもなく、中国語のリテラルがすべての言語で勝っていました。
対象の文言は、Grok Build プロバイダフォームの必須項目チェック、アプリがまだ引き継がれていないときのフェイルオーバーのホバー説明、別のアプリが引き継ぎを保持している状態で Claude Desktop のルーティングを停止しようとしたときの警告とその理由の説明、プロバイダ識別子の読み取り失敗、Codex 共通設定が空のときのエラー、ルーティングサービスの停止と停止失敗の 2 つの通知、そして使用量テーブルで「token はあるがコストが 0 と算出された」リクエストに付く「未計算」のラベルにわたります。9 件のキーは現在、簡体字中国語・英語・日本語・繁体字中国語のすべてに存在します。([#5960](https://github.com/farion1231/cc-switch/pull/5960)
### 繁体字中国語の「バージョン情報」ページでツール管理が英語にフォールバックしていた問題
UI の言語を繁体字中国語に設定すると、「バージョン情報」ページのツール管理の領域が英語で表示されていました——バージョンの行、インストールと更新のボタン、結果の通知、インストール競合の診断、そして更新確認ダイアログ全体です。このパネルは 3 回の変更を経て段階的に作られましたが、そのたびに簡体字中国語・英語・日本語だけが追加されていました。i18next の方針はエラーにせず英語へフォールバックすることなので、欠けていた 30 件のキーはテストからまったく見えませんでした。**この欠落は v3.16.0 から v3.19.0 までずっと出荷され続けていました。**
30 件の文言はすべて翻訳され、インストールの説明も他の言語と揃えました。新しく追加した言語テストは、ツール管理のすべての文言が 4 言語に存在し、かつ補間変数が一致することを要求するため、この種のずれは今後リリースされる前にテストで失敗します。([#5943](https://github.com/farion1231/cc-switch/pull/5943)
### 組み込み価格がベンダーの定価とずれていた問題
コストはログに書き込む時点で組み込み価格テーブルに従って確定するため、古い初期値は以後のすべてのリクエストを黙って誤って計算します。本リリースでは 4 行を修正しました。`deepseek-chat``deepseek-reasoner` は V4 Flash の旧称エイリアスとなり、100 万 token あたり入力 $0.14 / 出力 $0.28、キャッシュ読み取り $0.0028 になりました(従来は $0.27/$1.10 と $0.55/$2.19)。`minimax-m3` は公式の標準区分に合わせて半額の $0.30/$1.20 に。`gpt-5.6-luna` は OpenAI の 2026-07-30 の値下げに合わせて 80% 引き下げて $0.20/$1.20、`gpt-5.6-terra` は 20% 引き下げて $2/$12 とし、`gpt-5.6-sol` は意図的に据え置き、このシリーズのキャッシュ書き込みの比率も維持しています。
この修正は、行の 4 つの価格列が**すべて従来の組み込み値と等しいままである場合にのみ**書き換えます。そのため、自分で変更した価格——あるいは models.dev の同期によって書き込まれた価格——が触られることはありません。
---
## セキュリティ強化
### ディープリンクのインポート確認:マスクをより厳しく、切り詰めをより少なく
これは v3.19.0 の `ccswitch://` 確認ダイアログ強化の続きです。設定のプレビューは共有モジュールで一元的に構築するようになり、入れ子になった TOML テーブルや JSON オブジェクトの中の秘匿値も再帰的にマスクします。これにより、方向の異なる 2 つの欠陥が同時に解消されました。Grok Build のインポートはこれまで設定のプレビューが**まったく描画されず**、Codex のインポートは埋め込まれた `api_key` を**平文で表示**していました。
設定プレビューにあった 300 文字の切り詰めは撤廃され、全文がスクロール可能な枠内に描画されるようになりました——確認ダイアログが「これから何を書き込むか」を隠しうる最後の箇所が塞がれます。マスク自体も、使われているすべての箇所で厳しくなりました。**MCP のインポート確認ダイアログも含みます**:秘匿キー名の照合に `AUTHORIZATION``COOKIE``CREDENTIAL` と、完全一致の `AUTH``BEARER` を追加し、マスク後に表示される平文のプレフィックスは 8 文字から 4 文字に縮小、長さが 8 文字以下の値は、そのまま表示するのではなく全体を置き換えるようになりました。
最後に、フロントエンドの Base64 デコーダーは前後の空白を切り落とさなくなりました——それは URL デコードによって `+` が空白になったものである可能性があるためです。これは v3.19.0 で修正したのと同じ種類の、フロントエンドとバックエンドのデコード基準のずれです。確認ダイアログが表示するものと、インポーターが書き込むものが食い違ってしまいます。
---
## 内部変更
### 呼び出し元のないコード 3,166 行、14 モジュール、依存 4 件を削除
「呼び出し元が 1 つもない」コードを対象にした整理です。バックエンドではプロバイダのアイコン推定テーブル、プレースホルダーのヘルスチェッカー、一度も接続されていない SSE 実装(ストリーミングと非ストリーミングのハンドラを含みます)、未使用のプロキシセッション型 2 つ、参照のない使用量パーサー 4 つ、そして使われていないコスト計算の入口を削除しました——**本番の課金経路、その自動判別パーサー、セッション ID の抽出はすべて手つかずです**。コンパイラが今まで警告しなかった原因である 22 か所の `#[allow(dead_code)]` も、あわせて削除しました。
フロントエンドではインポート元のない 14 モジュールを削除しました。パネルへの作り直しで置き換えられたプロンプトフォームのモーダルとリポジトリマネージャ、重複したプロキシ設定の hook、プロジェクトの歴史上一度もインポート元を持たなかったサーキットブレーカーのパネル、そして 3 つの schema ファイルが含まれます。これらの文言も 4 言語から同時に削除しました。これらのモジュールの背後にある Tauri コマンドは意図的に残しています。未使用の npm 依存 4 件も削除しました。手動管理のアイコンインデックスを再生成する 2 つのスクリプトを削除し、インデックスファイルのヘッダには「自動再生成は意図的にサポートしない」と明記しました。
あわせて、プロキシの状態と引き継ぎの状態を単一のクエリ層に統合しました——これまで同じコマンドを対象とする 2 つ目の hook 群が並存していましたが、呼び出し元はゼロで、そのクエリキーには観測者が一度も付かず、それらを対象とした無効化はすべて空振りでした。クエリキーの文字列は一字も変わっておらず、残した hook は従来のポーリング動作を維持しています。([#5916](https://github.com/farion1231/cc-switch/pull/5916)、[#5928](https://github.com/farion1231/cc-switch/pull/5928)
---
## アップグレード時の注意
### 本リリースにデータベースマイグレーションはありません
v3.19.1 にスキーマのマイグレーションは含まれず(バージョンは v16 のまま)、アップグレード前のバックアップも発生しないため、そのまますぐに利用できます。
### Claude Desktop の 2 重計上の自動修復には 30 日の期限があります(要確認)
この修正はデータを書き換えたり削除したりするのではなく、クエリ時に重複行を抑制します。そのため**明細行が残っている日は、次回起動時に正しい合計へ戻ります**。再構築の操作は必要ありません。
ただし明細行は 30 日を超えると日次ロールアップに集計され、削除されます。そしてロールアップは集計時点で有効だった基準で一度計算され、そのまま固定されます。**この修正を含まないバージョンですでに集計されてしまった日は、水増しされた数値が永久に残ります。** この回帰は v3.18.0(2026-07-21)で入ったため、アップグレードが早いほど、取り戻せる過去の範囲は広くなります。
### 新しい価格が過去のデータに与える 2 通りの影響
新たに価格を追加した 8 モデルは**遡って再計算されます**。起動時にコストが 0 と記録されているリクエストへコストが補われるため、これらのモデルのダッシュボードの数値は**上がります**。すでに集計・削除された明細行は再計算できず、0 のままです。
価格を変更した 4 モデルは逆方向です。補正は 0 コストの行しか扱わないため、すでに記録済みのリクエストは旧価格のままで、新しいリクエストのみが新価格で計上されます——同じモデルでも、過去の費用と今後の費用は一致しません。どちらの経路でもご自身の価格は保護されます。修正は元の組み込み値のままの行しか変更せず、`~/.cc-switch/model-pricing.json` にある手動での価格変更・models.dev の同期値・削除の記録は、投入と修正の後に再適用され、常に優先されます。
### プリセットの変更は新規作成のプロバイダにのみ影響します
すでに保存済みの DeepSeek や火山方舟 Coding Plan のプロバイダは、保存されている API 形式を保ち、引き続きローカルルーティングを必要とし、従来のカタログを使います。直接接続にするには、プリセットからプロバイダを作り直すか、プロバイダフォームの詳細設定で API 形式をネイティブ Responses に変更してください。
ただし、**すでにネイティブ Responses で、アドレスが `deepseek.com` 上にあるプロバイダは、次回の切り替え時に自動でミラーリングされた公式カタログを使うようになります**。保存し直す必要はありません——判定はライブ設定を読むためです。
### DeepSeek V4 Pro はまだ直接接続で使えません
プリセットには引き続き `deepseek-v4-pro` が並んでおり、ベンダー自身が公開しているカタログにも含まれていますが、**DeepSeek 側で pro 向けの Codex 連携がまだ開放されていません**。公式に示されている時期は 2026 年 8 月上旬です。それまでは直接接続モードで pro を選ぶと上流でエラーになります——プリセットの既定モデルでもある `deepseek-v4-flash` をご利用ください。
いま pro をどうしても使いたい場合は、そのプロバイダの API 形式を「OpenAI Chat」に戻し、ローカルルーティングの引き継ぎを有効にしてください。これは v3.19.1 より前に DeepSeek がずっと通っていた経路そのものです。ローカルプロキシが Codex の送る Responses リクエストを Chat Completions に変換するため、pro はこの経路では影響を受けません。
### DeepSeek 公式カタログの 2 つの前提
ミラーリングされたカタログは Codex クライアントの最低バージョンとして 0.144.0 を宣言していますが、**CC Switch 自身は検証を行いません**——カタログが持つ freeform な `apply_patch` の登録には、このバージョン以降が必要です。また、生成されるカタログファイルは(ミラーリングされた 2 モデルで)約 75 KB まで大きくなります。各エントリがハーネスの全文を含むためです。
### 直接接続の後、使用量の帰属はプロバイダ名から `Codex (Session)` に変わります
DeepSeek・火山方舟 Coding Plan・Tencent Hunyuan は引き継ぎを必要としなくなったため、そのトラフィックはローカルプロキシを完全に迂回でき、プロキシ側のリクエスト単位の記録からは見えなくなります。
**使用量そのものは失われませんし、区別もできます**——Codex のセッションログの取り込みが従来どおり記録します。ただしこの経路はプロバイダの識別情報を持ちません。ローカルプロキシを通らなかった Codex の使用量はすべて `Codex (Session)` という名前の項目にまとめられ、公式サブスクリプションの消費もこの行に入ります。つまり DeepSeek がルーティング経由から直接接続に変わると、その使用量は「DeepSeek」という名前の下から `Codex (Session)` に移ります。
**区別するにはモデルを見てください**:使用量の各レコードはそれぞれのモデル ID を保持しており、使用量パネルの「モデル統計」がモデルごとに 1 行ずつ表示します——`deepseek-v4-flash``hy3``ark-code-latest` と公式サブスクリプションの GPT 系は、それぞれ別の行になり、費用も token も分かれています。**プロバイダ単位**という軸そのものが必要な場合(たとえば同じモデルを複数のアグリゲータ間で比較したい場合)にのみ、引き続きローカルルーティングの引き継ぎを使う必要があります——この経路は実際のプロバイダ名を記録します。
### Codex の古い認証情報のクリーンアップには 2 つの前提があります
クリーンアップは「切り替え先のプロバイダに明示的な公式カテゴリが付いている」**かつ**「切り替え元のプロバイダが正常に書き戻された」場合にのみ実行されます。手動で作成し公式カテゴリを付けていないエントリや、書き戻しに失敗した切り替えでは、古い認証情報はディスクに残ったままです。
### Grok Build で引き継ぎを有効にするとバックエンドのフィールドが書き換わります
Grok Build のプロバイダで引き継ぎを有効にすると、ライブ設定のバックエンドのフィールドが Responses に書き換えられるようになりました。データベースに保存されているプロバイダのレコードは影響を受けず、プロキシ停止時にライブファイルはバックアップから丸ごと復元されます。
### ツールのインストールと更新における PATH の変化(macOS / Linux のみ)
設定 → バージョン情報から実行するツールのインストールと更新はすべて、ログインシェルの PATH を、引き継いだ PATH の前に結合するようになりました。そのためライフサイクルのスクリプトが名前で解決するプログラムが、以前と異なるものになる可能性があります。また各操作でこの PATH を読むためのシェルが 1 つ余分に起動され、対話用の起動ファイルが実行されます。**Windows は影響を受けません。**
grok 0.2.112 以降を使っているユーザーは、インストールの記録が 2 つ見えることがあります——ネイティブのものと、`grok update` 自身が作成したグローバルの npm パッケージです。これらは上流によって同期が保たれ、同じバージョンを報告します。
### 環境変数の競合検出の照合基準が変わりました
Claude Code・Codex・Gemini の検出は「部分一致」から「プレフィックス」に引き締められました。そのため、単に名前にアプリ名を含むだけの変数——`MY_ANTHROPIC_API_KEY``OLD_GEMINI_API_KEY`——は**競合として報告されなくなります**。あわせて Grok Build の検出を追加しました。
### ディープリンクのインポート確認で表示される秘匿値が減ります
マスク後に表示される平文のプレフィックスは 8 文字から 4 文字に縮小され、長さが 8 文字以下の値は全体がマスクされます。これは MCP のインポート確認ダイアログにも影響します。
---
## リスク通知
### 継続してお伝えしている注意事項
**xAI Grok OAuth サインイン**:公式 Grok CLI の公開 OAuth クライアント識別情報を再利用しており、利用によってアカウントの制限や停止につながる恐れがあります——詳細は [v3.18.0 release notes](v3.18.0-ja.md#リスク通知) を参照してください。
**Codex OAuth リバースプロキシ**:ChatGPT サブスクリプションの Codex OAuth をリバースプロキシ経由で使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 release notes](v3.13.0-ja.md#-リスクに関する注意事項) を参照してください。
**SuperGrok の残量照会**:プロバイダカードの残量表示は grok.com の非公開の課金エンドポイントに依存しており、xAI がインターフェースを変更すると機能しなくなる可能性があります——詳細は [v3.19.0 release notes](v3.19.0-ja.md#リスク通知) を参照してください。
**サードパーティプロバイダへのルーティング**:CC Switch のローカルプロキシで Codex・Claude Desktop・Grok Build のリクエストを変換してサードパーティのプロバイダへ転送する場合、課金・コンプライアンス・データ保持に関する制約はプロバイダごとに異なります。利用前に対象プロバイダの利用規約をお読みください。
上記の機能を有効にした時点で、ユーザーは関連するリスクを自ら引き受けることになります。CC Switch は、これらの機能の利用に起因するアカウントの制限・警告・サービス停止について、一切の責任を負いません。
---
## 謝辞
本リリースの修正は、その大半が外部のコントリビューターによるものです——6 つの PR のうち 5 つは私が書いたものではありません。
### コード貢献
- [#5677](https://github.com/farion1231/cc-switch/pull/5677)Grok Build のプロキシ引き継ぎとディープリンク連携の仕上げ——バックエンドのフィールド、セッションの識別、フェイルオーバータブ、環境変数の検出、さらに設定プレビューでの秘匿値の漏れの修正まで。@YUZHEthefool さんに感謝します。本リリースで最も広い範囲をカバーした仕事です。
- [#5951](https://github.com/farion1231/cc-switch/pull/5951)Claude Desktop の使用量 2 重計上の修正。@Komikawayi さんに感謝します。v3.18.0 のどの変更が 3 つの重複排除の防御を同時に破ったのかを突き止めたのは、本リリースで最も根気を要した調査でした。
- [#5916](https://github.com/farion1231/cc-switch/pull/5916)、[#5928](https://github.com/farion1231/cc-switch/pull/5928):呼び出し元のないコード 3,166 行と、重複したプロキシのクエリ層の削除。@SaladDay さんに感謝します。
- [#5943](https://github.com/farion1231/cc-switch/pull/5943):繁体字中国語のツール管理の文言を補い、言語のずれを防ぐテストを追加。@yovinchen さんに感謝します。
- [#5960](https://github.com/farion1231/cc-switch/pull/5960):すべての言語で簡体字中国語が表示されていた 9 件の文言の補完。@mhy1227 さんに感謝します。
### 問題報告
[#5938](https://github.com/farion1231/cc-switch/issues/5938) で、Claude Desktop のリクエストごとに `session_log` を出所とするログが 1 行余分に生まれ、token が 2 回集計されていることを報告してくださった @Alaric-L さんに感謝します——データソースまで特定された報告で、本リリースで最も重要な使用量の修正はこの報告から直接たどり着いたものです。
---
## ダウンロードとインストール
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から、お使いのシステムに対応するビルドをダウンロードするか、公式サイト [ccswitch.io](https://ccswitch.io) から入手してください(ダウンロードは Cloudflare のエッジノード経由で配信され、GitHub への到達性に依存しません)。
### システム要件
| システム | 最低バージョン | アーキテクチャ |
| -------- | ------------------------ | ----------------------------------- |
| Windows | Windows 10 以降 | x64 / ARM64 |
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 下表を参照 | x64 / ARM64 |
### Windows
| ファイル | 説明 |
| ---------------------------------------- | -------------------------------------------- |
| `CC-Switch-v3.19.1-Windows.msi` | **推奨** - 自動更新対応の MSI インストーラー |
| `CC-Switch-v3.19.1-Windows-Portable.zip` | ポータブル版、展開してそのまま実行できます |
Windows ARM64 デバイスでは、ファイル名に `arm64` が含まれる対応する成果物を選択してください。
### macOS
| ファイル | 説明 |
| -------------------------------- | ------------------------------------------------------ |
| `CC-Switch-v3.19.1-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ |
| `CC-Switch-v3.19.1-macOS.zip` | 展開して Applications へドラッグ、Universal Binary |
| `CC-Switch-v3.19.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.19.1-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- `CC-Switch-v3.19.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` |
-369
View File
@@ -1,369 +0,0 @@
# CC Switch v3.19.1
> 这一版的主线是**把上一版的尾巴收干净**:三家国产 Codex 网关经确认原生支持 Responses API,**不用再开本地路由接管**——DeepSeek 与火山方舟 Coding Plan 的预设从走本地路由改为直连,新加入的腾讯混元 TokenHub 一上来就是直连;四个能在日常里撞上的故障被修掉——**Claude Desktop 用量自 v3.18.0 起被算了两遍**(升级后历史数字会自动回正,但有 30 天窗口,见「升级提醒」)、切回官方 Codex 会卡在 401 且看不到登录界面、从设置页升级 Grok Build 只报一句 `os error 2`、Grok Build 开启接管后直接 404。另有 8 个此前一直按 $0 记账的模型补上内置定价,39 个界面文案的语言问题被修正。本版**没有数据库迁移**,并且是本项目第一个删除量超过新增量的版本。
**[English →](v3.19.1-en.md) | [日本語版 →](v3.19.1-ja.md)**
---
## 重点内容:你现在可以
- **让 DeepSeek、火山方舟 Coding Plan、腾讯混元在 Codex 里直连**:三家的官方 Codex 文档都已确认端点原生提供 Responses API。DeepSeek 与火山方舟 Coding Plan 的既有预设从 Chat 格式改为原生格式,供应商卡片上的「需要路由」标记与切换时的提示随之消失,请求不再经过本地代理的协议转换;腾讯混元 TokenHub 是本版新增的预设,从一开始就是原生格式。**注意 DeepSeek V4 Pro 暂时还不能直连**——厂商侧尚未开通它的 Codex 集成,直连请用 V4 Flash(预设默认),详见[升级提醒](#deepseek-v4-pro-暂时还不能直连)。
- **让 DeepSeek 用上 DeepSeek 自己发布的模型目录**:新的「官方厂商目录镜像」机制把厂商公布的 `models.json` 原样下发给该厂商自己的端点,freeform `apply_patch` 与配套的 GPT-5 提示词框架成套保留,不再被压成中性模板。判定只认域名、不认模型名——同一个模型在聚合站上未必实现同样的能力。
- **拿到正确的 Claude Desktop 用量数字**:自 v3.18.0 起,经本地网关的 Claude Desktop 流量在看板里被记了两遍——一遍来自代理、一遍来自会话日志导入,token、费用与请求数约翻倍。本版修好后,明细行还在的日子会自动回到正确数字,**不需要重建**。
- **切回官方 Codex 之后能正常登录**:此前从第三方供应商切回内置的官方 Codex 条目时,第三方的 key 会留在 `~/.codex/auth.json` 里,Codex 拿着它去请求官方端点,稳定 401——又因为文件存在,它不会退回自己的登录界面,在应用里没有出路。
- **从设置页把 Grok Build 升上去**`grok update` 自 0.2.112 起改为内部调用 npm 完成分发,而图形界面启动的应用看不到 node,升级只会报一句 `Error: No such file or directory (os error 2)`
- **给 Grok Build 开启接管而不是撞上 404**API 格式被手动改成 OpenAI Chat 或 Anthropic 的 Grok Build 供应商,开启接管后请求会打到一个代理没有注册的路由上,直接 404,且没有故障转移、没有用量记录。同时,Grok Build 的每次请求此前都被当成新会话,缓存键注入与按会话聚合都失效了。
- **看到 8 个此前一直按 $0 记账的模型的真实成本**:`gpt-5.3-codex-spark``gemini-3.5-flash-lite``kimi-k2.7-code-highspeed``glm-5-turbo``glm-5v-turbo``qwen3.6-flash`,以及不带日期后缀的 `claude-opus-4-6` / `claude-sonnet-4-6`
- **在繁体中文界面里看懂「关于」页的工具管理**:30 个只补了简中 / 英文 / 日文的文案漏了繁体中文,因为 i18next 会静默回落英文,这块面板自 v3.16.0 起一直是半英文的。另有 9 个文案在**所有语言下**都显示简体中文。
- **在官方订阅与 DeepSeek 之间来回切,而不是二选一**:`auth.json``config.toml` 都是单槽文件,Codex 自己存不下第二份凭据。厂商的一键脚本会把这份配置改造成自己专用的,而 CC Switch 是按供应商整段快照与还原——这也是它和官方脚本最实际的区别,详见[下文对照](#用-cc-switch-接入和直接跑官方脚本有什么不同)。
---
## 使用攻略
本版的改动集中在 Codex 的连接方式与用量统计口径上,建议结合以下文档了解:
- **[本地路由](../user-manual/zh/4-proxy/4.2-routing.md)**:哪些供应商需要开启接管、接管做了什么。本版之后 DeepSeek、火山方舟 Coding Plan 与腾讯混元都不再需要它。
- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:用量看板的数据来源与统计口径,理解 Claude Desktop 双算是怎么发生的、修复后为什么部分历史日期无法回正。
- **[在 Codex 中用 DeepSeek 这类 Chat 格式 API](../guides/codex-deepseek-routing-guide-zh.md)**:这篇攻略讲的是本地路由如何把 Responses 转换成 Chat Completions,机制部分对 Kimi、MiniMax、SiliconFlow 等仍是 Chat 形态的供应商依旧适用;但**其中以 DeepSeek 作为示例的部分已不适用于本版**——DeepSeek 现在走直连,不需要路由。
---
> [!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.19.1 是一次维护性发布,主线有三条。第一条是国产 Codex 网关集体转向原生 ResponsesDeepSeek 直连 `api.deepseek.com`,并带来一个可复用的机制——把厂商自己发布的模型目录原样镜像下发,让 freeform `apply_patch` 与配套的 GPT-5 提示词框架保持自洽,而不是被折叠成中性模板;火山方舟的 Coding Plan 端点 `/api/coding/v3` 在官方文档确认后跟进;腾讯混元的 TokenHub 作为新预设加入。三者都不再需要开启本地路由接管。
第二条是四个现场可见的故障修复:Claude Desktop 的用量自 v3.18.0 起被记两遍([#5938](https://github.com/farion1231/cc-switch/issues/5938));切回内置官方 Codex 供应商会留下第三方的 `auth.json`,导致 401 且看不到登录界面;`grok update` 在图形界面下只报 `os error 2`Grok Build 的代理接管在非 Responses 后端上 404,且每次请求都被当作新会话([#5677](https://github.com/farion1231/cc-switch/pull/5677))。第三条是减重:3,166 行已无任何调用方的代码与 4 个未使用的 npm 依赖被删除——本版是本项目第一个删除量超过新增量的版本。此外,深链导入确认框的脱敏更严、截断更少,8 个此前按 $0 记账的模型补上定价,4 个内置定价与厂商牌价重新对齐。本版**没有数据库 schema 迁移**(版本号保持 v16),升级轻量。
**发布日期**2026-07-31
**更新规模**12 commits | 71 files changed | +2,324 / -3,680 lines
---
## 新功能
### 官方厂商模型目录镜像(DeepSeek 首发)
Codex 从一个目录文件读取模型能力,而 CC Switch 此前对所有供应商都用中性模板生成这个目录——对聚合站这是对的,但会剥掉厂商自家集成所依赖的能力。现在,凡是随应用内置了官方目录的厂商,直接镜像下发它自己的那一份。
DeepSeek 是第一家:内置文件带着 `deepseek-v4-flash``deepseek-v4-pro` 两个条目,保留 `apply_patch_tool_type: "freeform"``web_search_tool_type: "text"``supports_search_tool: true`、low / high / max 三档思考强度,以及 `base_instructions``model_messages` 里那份 17,644 字符的 GPT-5 提示词框架——**这份框架必须与 freeform 工具注册一起走**,因为框架本身就在指导模型使用 `apply_patch`,拆开任何一半都会不自洽。
判定条件刻意收得很窄:供应商必须落在原生 Responses 档**并且** `base_url``deepseek.com` 上。**只认域名、不认模型品牌**——同一个模型在转售它的聚合站上未必实现同样的能力,按品牌授予等于把能力凭空发给了没有实现它的服务。供应商自己在目录里写死的条目仍然优先;遇到不认识的模型 ID 会克隆旗舰条目,但保留它自己的名称。其它所有档位生成的目录与改动前逐字节一致。
### 腾讯混元(TokenHubCodex 预设
Codex 的预设选择器里新增「Tencent Hunyuan」,归入「开源官方」分类,位于百炼与阶跃之间。选中即写好 `https://tokenhub.tencentmaas.com/v1``wire_api = "responses"` 与 TokenHub 强制要求的 `disable_response_storage = true`;声明 `hy3``hy3-preview` 两个模型,上下文窗口 **256K**(而不是接受 Codex 的 128K 默认值),并标记为纯文本——Codex 不会再把 `view_image` 的图片载荷发给读不了图的模型。
因为是原生 Responses 供应商,Codex 直连网关、无需本地路由;生成的目录走中性原生模板,会固定 `shell_type = "shell_command"` 并去掉原生网关拒收的 freeform `apply_patch` 注册。地址管理器与测速里从一开始就有两个候选:主域名与官方备用的 `.cn` 域名;区域独立的国际站刻意排除在外,因为 API Key 不跨站通用。
注意 **API Key 需要是开通了 Hy3 权限的 TokenHub key**Coding Plan 与 Token Plan 的订阅 key 在这个端点上用不了。
### 8 个此前按 $0 记账的模型补上内置定价
`gpt-5.3-codex-spark``gemini-3.5-flash-lite``kimi-k2.7-code-highspeed`(按 Kimi 的 Turbo 惯例,取 `kimi-k2.7-code` 基准价的 2 倍)、`glm-5-turbo``glm-5v-turbo``qwen3.6-flash` 在内置定价表里根本没有行,前缀回退也够不着,因此每一次请求都被记成零成本。
另外两行 —— 不带日期后缀的 `claude-opus-4-6``claude-sonnet-4-6` —— 补的是一个更隐蔽的缺口:模型 ID 解析只会**剥掉**日期后缀、从不**补上**,所以一条带着无日期 ID 的日志谁也匹配不到。八行全部按「不存在才插入」播种,你改过的价格不受影响。
### Grok Build 加入故障转移页签与环境变量冲突检测
设置页的故障转移在 Claude Code、Codex、Gemini 之外新增第四个 Grok Build 页签。启动时的环境变量冲突横幅也开始检测 `XAI_API_KEY``GROK_DEFAULT_MODEL`——这两个变量会静默盖掉你在应用里选的供应商。检测区分了精确名与前缀,所以 CC Switch 自己用的 `GROK_BIN_DIR``GROK_HOME` 不会被误报。
---
## 变更
### DeepSeek 与火山方舟 Coding Plan 改为直连 Codex,不再需要本地路由
两家的预设此前都标记为 OpenAI Chat 格式,因此都是「需要接管」的:供应商卡片带着「需要路由」标记,未开代理就切换会弹提示,每个请求都要走 Codex → 本地代理 → Responses 转 Chat → 上游这条链路。
现在两家的官方 Codex 集成文档都已确认端点提供 Responses API——DeepSeek 的 `api.deepseek.com` 与火山方舟的 `/api/coding/v3`——两个预设随之声明为原生 Responses,标记与提示消失,Codex 直连网关。两家写出的 `config.toml` 都没有变化(本来就是 `wire_api = "responses"`),变的是目录生成档位;DeepSeek 另外把上下文窗口从 1,000,000 对齐到厂商自己的 1,048,576。
BytePlus 国际站刻意保持 Chat 路由不变,等国际站文档单独核实后再说。火山预设里还留了一条值得知道的计费注记:按量计费的 `/api/v3` 端点**绝不能**加进这个预设的备用地址——它单独计费,不走套餐额度。
### 目录的显示名与上下文窗口改为「显式才生效」
这两个字段此前带着本地默认值——模型 ID 与 128,000 的窗口——并且在厂商值有机会参与之前就应用了,镜像目录里 1M 的窗口会被 128K 覆盖掉。现在它们是可选的,回退挪到条目构造那一层,于是「留空」才真正等于「沿用厂商声明的值」。显式写了这两个字段的供应商,以及所有非镜像档位,生成的目录与之前完全一致。
---
## 用 CC Switch 接入,和直接跑官方脚本有什么不同
DeepSeek 官方提供了一条 Codex 一键接入脚本,它能用、会备份、也带恢复菜单。**如果你这台机器就打算专心用 DeepSeek,跑官方脚本没有任何问题。** CC Switch 解决的是另一个场景:你要在多个供应商之间来回切。
### 换供应商时,登录态与配置整套换,不用自己备份
`~/.codex/auth.json``~/.codex/config.toml` 都是**单槽文件**——Codex 本身没有多凭据存储,一份配置只能对应一个供应商。CC Switch 在你切走某个供应商时,把这一对文件的内容整段快照进那个供应商的记录里;切回来时再整段写回。所以「ChatGPT 订阅 → DeepSeek → 切回订阅」通常不需要重新 `codex login`,第三方之间来回切则完全无需手工动作。手工做同一件事,你得在每次切换前后各拷贝一次这两个文件,漏一次,被覆盖的 OAuth 凭据就只能重新登录找回。
官方脚本的取舍不同:它把 `config.toml` 改造成 DeepSeek 专用配置——顶层写死 `preferred_auth_method = "apikey"``forced_login_method = "api"`,把认证方式固定为 API Key,并且**删除 `config.toml` 里已有的 `[profiles.*]`**(Codex 自带的多供应商切换机制)。你的 ChatGPT 登录凭据本身没有被删,`auth.json` 原封不动;但在这份配置下用不上,想回订阅需要跑脚本的恢复菜单整体回滚——回滚会连带丢掉安装之后你对 `config.toml` 的任何手改。脚本本身也只能在 flash 与 pro 之间切换,没有「换到第三个供应商」这一档。
### 换供应商之后,`codex resume` 里的旧会话还在
Codex 的续聊列表按会话里记录的 `model_provider` 分抽屉。CC Switch 创建的所有第三方 Codex 供应商——不管是 DeepSeek、Kimi 还是聚合站——都写同一个标识 `custom`,所以在它们之间怎么换,`codex resume` 一直能看到全部历史。CC Switch 首次启动时还会做一次性迁移,把已知的按厂商分桶的旧会话(官方脚本写入的 `deepseek` 也在其中)折进这个共享桶,原文件先备份到 `~/.cc-switch/backups/`
**这里有一条明确边界**:这个迁移只在 CC Switch 首次启动时跑一次。**如果你先装了 CC Switch、之后才去跑官方脚本**,那批带 `deepseek` 标识的会话不会再被折进来,它们会留在自己的抽屉里。另外,你手写的、不在已知名单里的供应商标识,CC Switch 刻意不去改动它。
### 官方订阅的会话,在 CC Switch 里本来就和第三方混排
CC Switch 的**会话管理面板直接扫描会话目录、不读 `model_provider`**,所以官方订阅期间产生的 Codex 会话一直和第三方会话在同一个列表里,可搜索、可续聊、可删除——**不需要开任何开关**。
如果你还希望 **Codex 自己的 `codex resume` 列表**也把官方与第三方合并,那是另一件事:设置 → 通用 → Codex 应用增强 → **「统一 Codex 会话历史」**,默认关闭。开启后只影响新会话;已有的官方会话要一并迁入,需要在开启确认框里再勾选「同时迁入现有官方会话历史」(同样默认不勾)。这两项都是既有功能、不是本版新增,边界场景见[《统一 Codex 会话历史》攻略](../guides/codex-unified-session-history-guide-zh.md)。
> **两个共同前提,先说清楚免得你事后困惑:**
>
> **一、以 CC Switch 指向的 Codex 目录为准。** 默认是 `~/.codex`,可在设置里改。**CC Switch 不读 `CODEX_HOME` 环境变量**——如果你用这个变量把 Codex 指到别处,那边的会话它看不见,供应商切换也会写进 CLI 没在用的目录里。要换目录请用 CC Switch 自己的「配置文件目录」设置。
>
> **二、出现在同一个列表里,不等于一定能续聊。** Codex 的推理内容(`encrypted_content`)只有产生它的后端能解密,跨供应商继续一段旧会话可能失败——这是上游的设计,不是 CC Switch 能绕过的。
---
## 修复
### Claude Desktop 的用量被算了两遍
经本地网关的 Claude Desktop 流量在用量看板里落两次——一次是代理行,一次是会话记录导入行——于是它的 token、费用与请求数大约翻倍。
这是 v3.18.0 引入的回归:代理侧的去重 ID 对除 `claude` 之外的所有应用都带上作用域前缀,写成 `session:{应用}:{供应商}:{消息ID}`,这就把 `claude-desktop` 放进了独立命名空间;而会话导入器仍然以裸的 `session:{消息ID}` 形态、`app_type = 'claude'` 写同一条 Claude 消息。三道去重防线因此同时失守:让代理行吸收已有会话行的主键收敛、写入侧的指纹探测、读取侧的过滤器——后两者都在用严格相等比较应用类型。
现在两个应用重新共用裸命名空间,两处比较则按单向规则放宽:`claude` 的会话行可以被 `claude-desktop` 的代理行吸收,**反过来不成立**。由于读取侧的过滤器也正是日报聚合所使用的那一个,已经入库的重复行会停止被计入,**不改写、不删除任何一行**——这条自愈有保留期限制,见「升级提醒」。对 Codex、Gemini、OpenCode 而言放宽后的比较退化为原来的精确匹配,额度检查仍使用严格匹配。([#5938](https://github.com/farion1231/cc-switch/issues/5938)、[#5951](https://github.com/farion1231/cc-switch/pull/5951)
### 切回官方 Codex 供应商会卡在 401、看不到登录界面
在 Codex API Key 保留开关关闭时(默认如此),切换到第三方供应商会把对方的 key 写进 `~/.codex/auth.json`。之后再切到内置的官方供应商——它存的凭据是空的——会走「只写配置」这条分支,于是 `config.toml` 被替换,而第三方的 `OPENAI_API_KEY` 原样留在盘上。Codex 随后拿着这把外来的 key 去请求官方端点,稳定 401;又因为 `auth.json` 存在,它不会退回自己的登录界面,在应用里找不到出路。
现在,成功切到官方 Codex 供应商之后,如果 `auth.json` 里只有一个 `OPENAI_API_KEY`、旁边没有任何一等凭据,这个文件会被删除——OAuth 令牌、个人访问令牌、agent 身份、Bedrock key 中的任何一个都标志着这是一份真实凭据,会被完整保留;而 `auth_mode``last_refresh`、账号 ID 这类纯元数据不再能「挡住」一把过期的 key。
**选择删除文件而不是写入 `{}`**:空对象会被 Codex 判定为没有令牌的 ChatGPT 模式并在启动时报错,而文件缺失才等价于未登录、直接进登录流程。清理只在旧供应商已成功回填进数据库之后执行,所以被删掉的 key 并没有丢——它存进了那个供应商的记录里,再次选中它就会回来。同一处改动还放宽了 live 配置读取:清理之后的状态(没有 `auth.json`、有 `config.toml`)不再被报成「Codex 未安装」。
### 从设置页升级 Grok Build 只报一句 `os error 2`
在设置 → 关于里升级 Grok Build 会失败于 `Error: No such file or directory (os error 2)`,没有任何其它信息。
根因是探测与执行两条路径的不对称:探测走登录 shell,会读取用户的 rc 文件,因此看得见 nvm、Homebrew、Volta;而生命周期脚本跑在非登录 shell 下,继承的是图形界面应用启动时那份很窄的 PATH。这本来无所谓,因为锚定命令都用绝对路径调用目标程序——但 grok 0.2.112 把自更新改到了 npm 分发上,`grok update` 内部会调起 `npm view``npm i -g`,而 npm 自己又要通过 shebang 解析 node。内层调用返回 ENOENT,grok 就把它原样抛成了那句 `os error 2`
现在 macOS 与 Linux 上的生命周期命令会把登录 shell 的真实 PATH 并到继承的那份前面,读取方式是执行 `/usr/bin/env` 而不是回显变量——因为 fish 把 PATH 存成列表,回显会得到空格分隔的片段。原生安装的 Grok 还给升级链追加了官方 xAI 安装脚本作为兜底,**刻意不用 `npm i -g`**:npm 与主路径共享同样两种失败模式(没有 node、镜像源缺包),会一起失败;官方安装脚本是唯一不依赖 node 的路径,落点相同,并且会把 CLI 自己的 `installer` 设置改回 `internal`,顺带治好被早期 npm 兜底切到 npm 分发上的用户。
### Grok Build 开启接管后 404,且每次请求都像新会话
在 API 格式被改成 OpenAI Chat 或 Anthropic 的 Grok Build 供应商上开启接管,会立刻得到 404,没有故障转移也没有用量记录——接管改写了地址与 key,却没有动后端字段,于是 CLI 把请求发到了代理没有注册的路由上。现在接管会同时把后端固定为 Responses;针对具体供应商降级到 Chat Completions 的动作仍然发生在转发层,而这个被强制的值会随整份 live 配置的备份在代理停止时还原。
另一个问题是代理的会话识别此前只认 Codex 与 OpenAI 客户端,因此 Grok Build 的每一轮都会生成一个新的会话 ID 并标记为「非客户端提供」,这同时压掉了缓存键注入与看板里的按会话聚合。现在会读取 Grok 自己的头——先会话所属的对话 ID,再会话 ID,忽略每请求变化的那个——并使用独立前缀,避免与 Codex 的记录撞车。([#5677](https://github.com/farion1231/cc-switch/pull/5677)
### 9 个界面文案在所有语言下都显示简体中文
有 9 个字符串无论界面语言是什么都显示简体中文,英文与日文界面同样如此。每个调用点都用了「内联默认值」的写法、默认值是中文字面量,但对应的键在四个语言文件里**一个都没有**——而 i18next 会先走完语言链才考虑内联默认值,于是英文回退根本没有机会生效,中文字面量在所有语言下都赢了。
受影响的文案覆盖 Grok Build 供应商表单的必填校验提示、应用尚未接管时的故障转移悬停提示、另一个应用持有接管时停止 Claude Desktop 路由的警告及其原因说明、供应商标识读取失败提示、Codex 通用配置为空的错误、路由服务的停止与停止失败两个提示,以及用量表格里给「有 token 但算出来是零成本」的请求打的「未定价」标签。9 个键现在在简中、英文、日文、繁中里都有了。([#5960](https://github.com/farion1231/cc-switch/pull/5960)
### 繁体中文的「关于」页工具管理回落成英文
界面语言设为繁体中文时,「关于」页的工具管理区块显示英文——版本行、安装与更新按钮、结果提示、安装冲突诊断,以及整个升级确认弹窗。这块面板由三次改动逐步建成,每次都只补了简中、英文、日文;而 i18next 的策略是回落英文而不是报错,于是 30 个缺失的键在测试里完全不可见。**这个缺口自 v3.16.0 起一直带到 v3.19.0。**
30 个文案现在全部译好,安装提示也与其它语言对齐;新增的语言测试要求每一个工具管理文案在四种语言下都存在、且插值变量一致,这类漂移以后会在测试里失败而不是发出去。([#5943](https://github.com/farion1231/cc-switch/pull/5943)
### 内置定价与厂商牌价脱节
成本在写入日志时就按内置定价表冻结,所以一个过期的播种价会静默地把之后每一次请求都算错。本版修正四行:`deepseek-chat``deepseek-reasoner` 现在是 V4 Flash 的旧称别名,每百万 token $0.14 输入 / $0.28 输出、缓存读 $0.0028(原为 $0.27/$1.10 与 $0.55/$2.19);`minimax-m3` 按官方标准档减半到 $0.30/$1.20`gpt-5.6-luna` 按 OpenAI 2026-07-30 的降价下调 80% 到 $0.20/$1.20`gpt-5.6-terra` 下调 20% 到 $2/$12`gpt-5.6-sol` 刻意不动,该系列的缓存写入比例保持不变。
修复只在一行的四个价格列**仍然全等于此前的内置值**时才改写它,所以你自己改过的价格——或者由 models.dev 同步写入的价格——绝不会被动到。
---
## 安全加固
### 深链导入确认框:脱敏更严,截断更少
这是 v3.19.0 那轮 `ccswitch://` 确认框加固的延续。配置预览改由一个共享模块统一构建,会递归地对嵌套 TOML 表与 JSON 对象里的密钥脱敏,一次修好两个方向相反的缺陷:Grok Build 的导入此前**完全不渲染**配置预览,而 Codex 的导入会把内嵌的 `api_key` **明文打印**出来。
配置预览上 300 字符的截断被移除,完整内容现在渲染在可滚动的框里——补上了确认框最后一处可能隐藏「即将写入什么」的地方。脱敏本身在所有使用它的位置都更严了,**包括 MCP 导入确认框**:敏感键名匹配新增 `AUTHORIZATION``COOKIE``CREDENTIAL`,以及精确匹配的 `AUTH``BEARER`;脱敏后显示的明文前缀从 8 个字符缩到 4 个;长度不超过 8 个字符的值现在整体替换,而不是原样显示。
最后,前端的 Base64 解码器不再裁掉首尾空白——那有可能是 URL 解码把 `+` 变成的空格。这与 v3.19.0 修过的是同一类前后端解码口径分歧:确认框显示的是一回事,导入器写进去的是另一回事。
---
## 内部
### 删掉 3,166 行已无调用方的代码、14 个模块与 4 个依赖
一轮针对「没有任何调用方」的清理。后端删除了供应商图标推断表、一个占位的健康检查器、一套从未接线的 SSE 实现(含它自己的流式与非流式处理器)、两个未使用的代理会话类型,以及四个无引用的用量解析器与一个死的成本计算入口——**线上计费路径、它的自动识别解析器与会话 ID 提取全部原封不动**。22 处 `#[allow(dead_code)]` 抑制(正是它们让编译器一直没报警)随之删除。
前端删除 14 个无导入方的模块,包括已被面板改版取代的提示词表单弹窗与仓库管理器、一个重复的代理配置 hook、一个在项目历史上从未有过导入方的熔断器面板,以及三个 schema 文件;它们的文案在四种语言里同步删除。这些模块背后的 Tauri 命令刻意保留。另外删除 4 个未使用的 npm 依赖。两个会重新生成手工维护的图标索引的脚本被移除,索引文件头改为写明「刻意不支持自动重生成」。
配套的一处改动把代理状态与接管状态合并到单一的查询层——此前有第二套并行的 hook 覆盖同样的命令,但零调用方,它的查询键从来没有观察者,针对它们的失效调用全是空转。查询键字符串逐字未变,保留下来的 hook 维持原有的轮询行为。([#5916](https://github.com/farion1231/cc-switch/pull/5916)、[#5928](https://github.com/farion1231/cc-switch/pull/5928)
---
## 升级提醒
### 本版没有数据库迁移
v3.19.1 不含 schema 迁移(版本号保持 v16),不会触发升级前备份,升级即用。
### Claude Desktop 双算的自愈有 30 天窗口(请读)
修复是在查询时抑制重复行,而不是改写或删除数据,所以**明细行还在的每一天都会在下次启动后恢复正确总数**,不需要任何重建操作。
但明细行超过 30 天会被聚合进日报并清理,而日报是按聚合当时生效的口径算一次就固定下来的。**已经被没有此修复的版本聚合掉的日期,会永久保留虚高的数字。** 这个回归自 v3.18.0(2026-07-21)进入,所以越早升级、能救回的历史区间越完整。
### 新定价对历史数据的两种不同影响
八个新补定价的模型会被**回溯补算**:启动时会给成本记为零的请求补上成本,因此这些模型的看板数字会**上升**。已经聚合并清理掉的明细行无法补算,保持为零。
四个改价的模型方向相反:补算只处理零成本行,所以已经记录的请求保持旧价,只有新请求按新价计费——同一个模型的历史花费与新增花费会不一致。两条路径都保护你自己的定价:修复只改仍是原内置值的行,而 `~/.cc-switch/model-pricing.json` 里的手工改价、models.dev 同步值与删除墓碑会在播种与修复之后重放,始终优先。
### 预设变更只影响新建供应商
已经保存的 DeepSeek 或火山方舟 Coding Plan 供应商保持它存的 API 格式,仍然需要本地路由,也仍用旧目录。想用直连,请从预设重新创建供应商,或在供应商表单的高级区把 API 格式改为原生 Responses。
不过,**已经是原生 Responses、且地址在 `deepseek.com` 上的供应商,下次切换时就会自动用上镜像的官方目录**,不需要重新保存——因为判定读的是 live 配置。
### DeepSeek V4 Pro 暂时还不能直连
预设里仍然列着 `deepseek-v4-pro`,厂商自己发布的目录也带着它,但 **DeepSeek 侧针对 pro 的 Codex 集成尚未开通**,官方给出的时间是 2026 年 8 月初。在那之前,直连模式下选 pro 会在上游报错——请用 `deepseek-v4-flash`,它也是预设的默认模型。
如果你现在就要用 pro,把这个供应商的 API 格式改回「OpenAI Chat」并开启本地路由接管即可。这正是 v3.19.1 之前 DeepSeek 一直走的那条路:本地代理会把 Codex 发出的 Responses 请求转换成 Chat Completionspro 在这条路上不受影响。
### DeepSeek 官方目录的两个前提
镜像的目录声明了 Codex 客户端最低版本 0.144.0**CC Switch 自己不做校验**——它携带的 freeform `apply_patch` 注册需要这个版本或更新。另外,生成的目录文件会涨到约 75 KB(两个镜像模型),因为每个条目都带着完整的提示词框架文本。
### 直连之后,用量的归属会从供应商名变成 `Codex (Session)`
DeepSeek、火山方舟 Coding Plan 与腾讯混元不再需要接管,它们的流量可以完全绕过本地代理,代理侧的逐请求记录因此看不到它们。
**用量本身不会丢,也仍然分得清**——Codex 的会话日志导入照常记录,只是这条路径不携带供应商身份:所有没走本地代理的 Codex 用量会一起归入名为 `Codex (Session)` 的条目,官方订阅的消耗也在这一行里。也就是说,DeepSeek 从走路由改为直连之后,它的用量会从「DeepSeek」这个名字下移到 `Codex (Session)`
**要区分它们,看模型**:每条用量记录都带着自己的模型 ID,用量面板的「模型统计」按模型逐行列出——`deepseek-v4-flash``hy3``ark-code-latest` 与官方订阅的 GPT 系列各归各行,费用与 token 都是分开的。只有当你需要的正是**按供应商**这个维度(比如同一个模型在多家聚合站之间比价),才需要继续用本地路由接管——这条路会记录真实的供应商名。
### Codex 残留凭据清理的两个前提
清理只在「切入的供应商带有显式的官方分类」**且**「切出的供应商已成功回填」时执行。手工创建、没有标记官方分类的条目,或者回填失败的那次切换,残留仍会留在盘上。
### Grok Build 开启接管会改写后端字段
在 Grok Build 供应商上开启接管,现在会把 live 配置里的后端字段改写为 Responses。数据库里存的供应商记录不受影响,代理停止时 live 文件会从备份整体还原。
### 工具安装与升级的 PATH 变化(仅 macOS / Linux
设置 → 关于里触发的每一次工具安装与升级,现在都会把登录 shell 的 PATH 并到继承的那份前面,因此生命周期脚本按名称解析到的程序有可能与之前不同;每次操作还会多启动一个 shell 来读取这份 PATH,这会执行你的交互式启动文件。**Windows 不受影响。**
使用 grok 0.2.112 及以上版本的用户可能会看到两份安装记录——原生的那份,加上 `grok update` 自己创建的全局 npm 包;它们由上游保持同步,版本号一致。
### 环境变量冲突检测的匹配口径变了
Claude Code、Codex、Gemini 的检测从「包含」收紧为「前缀」,因此仅仅名字里含有应用名的变量——`MY_ANTHROPIC_API_KEY``OLD_GEMINI_API_KEY`——**不再被报为冲突**。同时新增了 Grok Build 的检测。
### 深链导入确认框显示的密钥更少
脱敏后显示的明文前缀从 8 个字符缩到 4 个,长度不超过 8 个字符的值整体脱敏。这也影响 MCP 导入确认框。
---
## 风险提示
### 沿用的提示
**xAI Grok OAuth 登录**:复用官方 Grok CLI 的公开 OAuth 客户端身份,使用可能导致账号被限制或封禁——详见 [v3.18.0 release notes](v3.18.0-zh.md#风险提示)。
**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#-风险提示)。
**SuperGrok 配额查询**:供应商卡片的配额展示依赖 grok.com 的非公开计费端点,xAI 调整接口后可能失效——详见 [v3.19.0 release notes](v3.19.0-zh.md#风险提示)。
**第三方供应商路由**:通过 CC Switch 本地代理把 Codex、Claude Desktop 或 Grok Build 的请求转换并转发到第三方供应商时,各供应商对计费、合规与数据留存的约束不同,请在使用前阅读目标供应商的服务条款。
用户启用上述功能即表示自行承担相关风险。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
---
## 致谢
这一版的修复大半来自外部贡献者——六个 PR 里有五个不是我写的。
### 代码贡献
- [#5677](https://github.com/farion1231/cc-switch/pull/5677)Grok Build 的代理接管与深链集成收尾——补齐后端字段、会话身份识别、故障转移页签与环境变量检测,并顺带修好了配置预览里的密钥泄漏,感谢 @YUZHEthefool。这是本版覆盖面最广的一份工作。
- [#5951](https://github.com/farion1231/cc-switch/pull/5951)Claude Desktop 用量双算修复,感谢 @Komikawayi。定位到 v3.18.0 的哪一处改动让三道去重防线同时失守,是本版最需要耐心的一次排查。
- [#5916](https://github.com/farion1231/cc-switch/pull/5916)、[#5928](https://github.com/farion1231/cc-switch/pull/5928):删除 3,166 行无调用方代码与重复的代理查询层,感谢 @SaladDay
- [#5943](https://github.com/farion1231/cc-switch/pull/5943):补齐繁体中文的工具管理文案,并新增防止语言漂移的测试,感谢 @yovinchen
- [#5960](https://github.com/farion1231/cc-switch/pull/5960):补齐 9 个在所有语言下都显示简体中文的文案,感谢 @mhy1227
### 问题反馈
感谢 @Alaric-L 在 [#5938](https://github.com/farion1231/cc-switch/issues/5938) 中报告 Claude Desktop 的每次请求都多出一条 `session_log` 来源的日志、导致 token 被统计两遍——这条反馈精确到了数据来源,本版最重要的用量修复直接由它定位。
---
## 下载与安装
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本,或从官网 [ccswitch.io](https://ccswitch.io) 获取(下载经 Cloudflare 边缘节点分发,不依赖 GitHub 可达)。
### 系统要求
| 系统 | 最低版本 | 架构 |
| ------- | -------------------------- | ----------------------------------- |
| Windows | Windows 10 及以上 | x64 / ARM64 |
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 见下表 | x64 / ARM64 |
### Windows
| 文件 | 说明 |
| ---------------------------------------- | ----------------------------------- |
| `CC-Switch-v3.19.1-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.19.1-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
Windows ARM64 设备请选择文件名中带 `arm64` 标识的对应制品。
### macOS
| 文件 | 说明 |
| -------------------------------- | --------------------------------------------- |
| `CC-Switch-v3.19.1-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
| `CC-Switch-v3.19.1-macOS.zip` | 解压后拖入 ApplicationsUniversal Binary |
| `CC-Switch-v3.19.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.19.1-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- `CC-Switch-v3.19.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` |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.19.1",
"version": "3.19.0",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
+1 -1
View File
@@ -758,7 +758,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.19.1"
version = "3.19.0"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.19.1"
version = "3.19.0"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
+38 -538
View File
@@ -417,93 +417,6 @@ pub fn codex_auth_has_oauth_login_material(auth: &Value) -> bool {
})
}
/// True only when the auth carries material Codex itself authenticates with
/// ahead of the API-key fallback: OAuth tokens or another first-class login
/// carrier. Unlike `codex_auth_has_oauth_login_material`, pure metadata such
/// as `last_refresh` or `tokens.account_id` does NOT count — metadata must not
/// shield a stale third-party `OPENAI_API_KEY` from post-switch cleanup.
pub fn codex_auth_has_credential_login_material(auth: &Value) -> bool {
let Some(obj) = auth.as_object() else {
return false;
};
let value_present = |value: &Value| match value {
Value::Null => false,
Value::String(text) => !text.trim().is_empty(),
Value::Array(items) => !items.is_empty(),
Value::Object(map) => !map.is_empty(),
_ => true,
};
if ["personal_access_token", "agent_identity", "bedrock_api_key"]
.iter()
.any(|key| obj.get(*key).is_some_and(value_present))
{
return true;
}
obj.get("tokens")
.and_then(Value::as_object)
.is_some_and(|tokens| {
["id_token", "access_token", "refresh_token"]
.iter()
.any(|key| tokens.get(*key).is_some_and(value_present))
})
}
/// True when live `auth.json` is the shape a preserve-off third-party switch
/// leaves behind: an `OPENAI_API_KEY` (possibly alongside metadata like
/// `auth_mode` / `last_refresh`) with no real login credential next to it.
pub fn codex_live_auth_is_stale_third_party_residue(live_auth: &Value) -> bool {
if codex_auth_has_credential_login_material(live_auth) {
return false;
}
live_auth
.get("OPENAI_API_KEY")
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|key| !key.is_empty())
}
/// After a normal switch to an official provider that carries no login
/// material of its own, delete a live `auth.json` that only holds a stale
/// third-party API key, so Codex shows its login screen instead of sending
/// the wrong key to the official endpoint (401 with no way to re-login).
///
/// Deleting the file — not writing `{}` — is deliberate: Codex resolves an
/// empty object to ChatGPT mode without tokens and errors at bootstrap,
/// while a missing file yields NotAuthenticated and the login screen,
/// matching Codex's own logout.
///
/// Callers must only invoke this after the outgoing provider was
/// successfully backfilled into the DB — that backfill holds the only other
/// copy of the third-party key. The switch backfill intentionally lacks the
/// proxy-side "no credentials in the builtin official row" guard
/// (`services/proxy.rs` `sync_live_config_to_provider`): that asymmetry is
/// what heals official API-key logins into the DB row, and this cleanup's
/// safety depends on it — do not align the two guards.
///
/// Returns Ok(true) when the file was deleted.
pub fn clear_stale_codex_live_auth_after_official_switch(
db_auth: &Value,
) -> Result<bool, AppError> {
if codex_auth_has_login_material(db_auth) {
// A material-carrying official provider gets a full auth write;
// nothing stale can remain.
return Ok(false);
}
let auth_path = get_codex_auth_path();
if !auth_path.exists() {
return Ok(false);
}
let live_auth: Value = read_json_file(&auth_path)?;
if !codex_live_auth_is_stale_third_party_residue(&live_auth) {
return Ok(false);
}
delete_file(&auth_path)?;
Ok(true)
}
pub fn should_restore_codex_provider_token_for_backfill(
category: Option<&str>,
template_settings: &Value,
@@ -553,20 +466,17 @@ fn codex_catalog_model_entry(
spec: &CodexCatalogModelSpec,
priority: usize,
profile: CodexCatalogToolProfile,
default_context_window: u64,
) -> Value {
let mut entry = template.clone();
let Some(entry_obj) = entry.as_object_mut() else {
return json!({});
};
let display_name = spec.display_name.as_deref().unwrap_or(&spec.model);
let context_window = spec.context_window.unwrap_or(default_context_window);
entry_obj.insert("slug".to_string(), json!(spec.model));
entry_obj.insert("display_name".to_string(), json!(display_name));
entry_obj.insert("description".to_string(), json!(display_name));
entry_obj.insert("context_window".to_string(), json!(context_window));
entry_obj.insert("max_context_window".to_string(), json!(context_window));
entry_obj.insert("display_name".to_string(), json!(spec.display_name));
entry_obj.insert("description".to_string(), json!(spec.display_name));
entry_obj.insert("context_window".to_string(), json!(spec.context_window));
entry_obj.insert("max_context_window".to_string(), json!(spec.context_window));
entry_obj.insert("priority".to_string(), json!(1000 + priority));
entry_obj.insert("additional_speed_tiers".to_string(), json!([]));
entry_obj.insert("service_tiers".to_string(), json!([]));
@@ -625,13 +535,8 @@ fn codex_catalog_model_entry(
#[derive(Debug, Clone, PartialEq, Eq)]
struct CodexCatalogModelSpec {
model: String,
/// Explicit user value only. Entries fall back to the model id — except
/// official vendor catalog entries, which keep the vendor's display name.
display_name: Option<String>,
/// Explicit user value only. Entries fall back to the config's
/// `model_context_window` (or 128k) — except official vendor catalog
/// entries, which keep the vendor's declared window.
context_window: Option<u64>,
display_name: String,
context_window: u64,
/// Per-row override for the native template's `supports_parallel_tool_calls`
/// (e.g. MiniMax=true, MiMo=false). Only consulted for `NativeResponses`.
supports_parallel_tool_calls: Option<bool>,
@@ -647,7 +552,7 @@ struct CodexCatalogModelSpec {
base_instructions: Option<String>,
}
fn codex_catalog_model_specs(settings: &Value) -> Vec<CodexCatalogModelSpec> {
fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCatalogModelSpec> {
let Some(models) = settings
.get("modelCatalog")
.and_then(|catalog| catalog.get("models"))
@@ -656,6 +561,8 @@ fn codex_catalog_model_specs(settings: &Value) -> Vec<CodexCatalogModelSpec> {
return Vec::new();
};
let default_context_window =
extract_codex_top_level_u64(config_text, "model_context_window").unwrap_or(128_000);
let mut seen = std::collections::HashSet::new();
let mut specs = Vec::new();
@@ -679,12 +586,13 @@ fn codex_catalog_model_specs(settings: &Value) -> Vec<CodexCatalogModelSpec> {
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string);
.unwrap_or(model);
let context_window = parse_codex_positive_u64(
model_config
.get("contextWindow")
.or_else(|| model_config.get("context_window")),
);
)
.unwrap_or(default_context_window);
let supports_parallel_tool_calls = model_config
.get("supportsParallelToolCalls")
@@ -713,7 +621,7 @@ fn codex_catalog_model_specs(settings: &Value) -> Vec<CodexCatalogModelSpec> {
specs.push(CodexCatalogModelSpec {
model: model.to_string(),
display_name,
display_name: display_name.to_string(),
context_window,
supports_parallel_tool_calls,
input_modalities,
@@ -983,122 +891,6 @@ fn load_codex_native_responses_template() -> Value {
serde_json::from_str(text).expect("bundled codex native responses template must be valid JSON")
}
/// Hosts whose native `/responses` gateway publishes an OFFICIAL Codex model
/// catalog (models.json) that cc-switch mirrors verbatim. Matched against
/// `base_url` ONLY — deliberately NOT by model brand, unlike
/// `CODEX_WEB_SEARCH_REJECT_MODEL_PREFIXES`: the official entries GRANT
/// capabilities (freeform `apply_patch`, vendor harness), and an aggregator
/// merely hosting the same model may not honor them. The safe failure
/// direction for aggregators is the neutral template (degraded but working);
/// wrongly granting freeform apply_patch would reintroduce the custom-tool
/// rejection bug.
const CODEX_DEEPSEEK_OFFICIAL_CATALOG_HOSTS: &[&str] = &["deepseek.com"];
/// Bundled copy of DeepSeek's official Codex models.json — the exact file
/// their one-click integration script writes (api-docs.deepseek.com →
/// quick_start/agent_integrations/codex): freeform apply_patch, GPT-5 harness
/// base_instructions, low/high/max reasoning levels, web_search supported,
/// 1m context. Declares `minimal_client_version` 0.144.0.
fn load_codex_deepseek_official_catalog_models() -> Vec<Value> {
let text = include_str!("resources/codex_deepseek_catalog_template.json");
let catalog: Value =
serde_json::from_str(text).expect("bundled DeepSeek official catalog must be valid JSON");
catalog
.get("models")
.and_then(|models| models.as_array())
.cloned()
.unwrap_or_default()
}
/// Official vendor catalog entries for the provider in `config_text`, if its
/// gateway ships one. Only the `NativeResponses` profile qualifies: ProxyChat
/// runs through cc-switch's converter (gpt-5.5 template contract) and the
/// Anthropic transform drops custom tools, so both must keep their existing
/// templates. Host-driven like the web_search blacklist, so existing providers
/// pick it up on their next switch without a re-save.
fn codex_official_vendor_catalog_models(
config_text: &str,
profile: CodexCatalogToolProfile,
) -> Option<Vec<Value>> {
if profile != CodexCatalogToolProfile::NativeResponses {
return None;
}
let base_url = extract_codex_base_url(config_text)?.to_ascii_lowercase();
if CODEX_DEEPSEEK_OFFICIAL_CATALOG_HOSTS
.iter()
.any(|host| base_url.contains(host))
{
let models = load_codex_deepseek_official_catalog_models();
if !models.is_empty() {
return Some(models);
}
}
None
}
/// Build one catalog entry from an official vendor catalog: match the user's
/// model id against the vendor entries by slug; an unknown id clones the
/// vendor's first (flagship) entry so it keeps the gateway's capability
/// profile without impersonating the flagship. The official entry is
/// authoritative — no tool-profile stripping — but explicit per-row user
/// overrides still win.
fn codex_vendor_catalog_model_entry(
vendor_models: &[Value],
spec: &CodexCatalogModelSpec,
priority: usize,
) -> Value {
let matched = vendor_models.iter().find(|entry| {
entry
.get("slug")
.and_then(|slug| slug.as_str())
.is_some_and(|slug| slug.eq_ignore_ascii_case(&spec.model))
});
let mut entry = match matched {
Some(found) => found.clone(),
None => vendor_models.first().cloned().unwrap_or_else(|| json!({})),
};
let Some(entry_obj) = entry.as_object_mut() else {
return json!({});
};
if matched.is_none() {
let display_name = spec.display_name.as_deref().unwrap_or(&spec.model);
entry_obj.insert("slug".to_string(), json!(spec.model));
entry_obj.insert("display_name".to_string(), json!(display_name));
entry_obj.insert("description".to_string(), json!(display_name));
entry_obj.insert("priority".to_string(), json!(1000 + priority));
}
// Explicit user overrides win over the official entry; absent values keep
// the vendor's declarations (context window, modalities, harness, ...).
if let Some(display_name) = spec.display_name.as_deref() {
entry_obj.insert("display_name".to_string(), json!(display_name));
}
if let Some(context_window) = spec.context_window {
entry_obj.insert("context_window".to_string(), json!(context_window));
entry_obj.insert("max_context_window".to_string(), json!(context_window));
}
if let Some(parallel) = spec.supports_parallel_tool_calls {
entry_obj.insert("supports_parallel_tool_calls".to_string(), json!(parallel));
}
if let Some(modalities) = spec.input_modalities.as_deref() {
entry_obj.insert("input_modalities".to_string(), json!(modalities));
}
if let Some(base_instructions) = spec
.base_instructions
.as_deref()
.map(str::trim)
.filter(|text| !text.is_empty())
{
entry_obj.insert("base_instructions".to_string(), json!(base_instructions));
}
// Defensive: if a future codex parser requires a field the vendor file
// predates, backfill only whitelisted parser-required keys.
fill_template_fields_from_static(&mut entry);
entry
}
/// Fields Codex's external-catalog parser REQUIRES (no serde default): when
/// one is missing Codex rejects the whole catalog file at startup ("missing
/// field ..."). `base_instructions` is the other known required field; the
@@ -1181,14 +973,11 @@ fn codex_model_catalog_from_specs(
specs: &[CodexCatalogModelSpec],
template: &Value,
profile: CodexCatalogToolProfile,
default_context_window: u64,
) -> Value {
let entries: Vec<Value> = specs
.iter()
.enumerate()
.map(|(index, spec)| {
codex_catalog_model_entry(template, spec, index, profile, default_context_window)
})
.map(|(index, spec)| codex_catalog_model_entry(template, spec, index, profile))
.collect();
json!({ "models": entries })
@@ -1199,28 +988,11 @@ fn codex_model_catalog_from_settings(
config_text: &str,
profile: CodexCatalogToolProfile,
) -> Result<Option<Value>, AppError> {
let specs = codex_catalog_model_specs(settings);
let specs = codex_catalog_model_specs(settings, config_text);
if specs.is_empty() {
return Ok(None);
}
// Vendors that publish an OFFICIAL Codex models.json for their native
// `/responses` gateway get it mirrored verbatim instead of the neutral
// template: its freeform apply_patch, vendor harness base_instructions and
// reasoning levels are load-bearing (the harness tells the model to use
// apply_patch, so catalog and harness must stay consistent).
if let Some(vendor_models) = codex_official_vendor_catalog_models(config_text, profile) {
let entries: Vec<Value> = specs
.iter()
.enumerate()
.map(|(index, spec)| codex_vendor_catalog_model_entry(&vendor_models, spec, index))
.collect();
return Ok(Some(json!({ "models": entries })));
}
let default_context_window =
extract_codex_top_level_u64(config_text, "model_context_window").unwrap_or(128_000);
// Native providers use the bundled clean template (no freeform apply_patch,
// no cache dependency); proxy-chat providers keep cloning Codex's gpt-5.5
// entry so the proxy can rewrite custom<->function tools as before.
@@ -1231,10 +1003,7 @@ fn codex_model_catalog_from_settings(
CodexCatalogToolProfile::ProxyChat => load_codex_model_catalog_template()?,
};
Ok(Some(codex_model_catalog_from_specs(
&specs,
&template,
profile,
default_context_window,
&specs, &template, profile,
)))
}
@@ -1655,9 +1424,7 @@ fn remove_codex_experimental_bearer_token(config_text: &str) -> Result<String, A
/// Read the current Codex live settings as a `{ auth, config }` object.
///
/// Missing `auth.json` collapses to `{}` so a config-only third-party install
/// is still importable; both files missing is treated as "no live install".
/// A `config.toml` that exists but is empty is a valid state — e.g. the
/// official seed after stale-auth cleanup — and must stay readable.
/// is still importable; both files empty is treated as "no live install".
pub fn read_codex_live_settings() -> Result<Value, AppError> {
let auth_path = get_codex_auth_path();
let auth_present = auth_path.exists();
@@ -1667,7 +1434,7 @@ pub fn read_codex_live_settings() -> Result<Value, AppError> {
json!({})
};
let cfg_text = read_and_validate_codex_config_text()?;
if !auth_present && !get_codex_config_path().exists() {
if !auth_present && cfg_text.trim().is_empty() {
return Err(AppError::localized(
"codex.live.missing",
"Codex 配置文件不存在",
@@ -2636,65 +2403,6 @@ experimental_bearer_token = "stale-table-key"
);
}
#[test]
fn credential_login_material_only_counts_real_credentials() {
assert!(codex_auth_has_credential_login_material(&json!({
"tokens": { "access_token": "t" }
})));
assert!(codex_auth_has_credential_login_material(&json!({
"tokens": { "refresh_token": "r" }
})));
assert!(codex_auth_has_credential_login_material(&json!({
"personal_access_token": "pat"
})));
// API key and pure metadata are not credentials in this predicate's
// sense — they must not shield a stale key from cleanup.
assert!(!codex_auth_has_credential_login_material(&json!({
"OPENAI_API_KEY": "sk-x"
})));
assert!(!codex_auth_has_credential_login_material(&json!({
"OPENAI_API_KEY": "sk-x",
"last_refresh": "2026-01-01T00:00:00Z",
"tokens": { "account_id": "acct-meta-only" }
})));
assert!(!codex_auth_has_credential_login_material(&json!({})));
}
#[test]
fn stale_third_party_residue_detection() {
// Shapes a preserve-off third-party switch leaves behind: cleared.
assert!(codex_live_auth_is_stale_third_party_residue(&json!({
"OPENAI_API_KEY": "sk-third-party"
})));
assert!(codex_live_auth_is_stale_third_party_residue(&json!({
"auth_mode": "apikey",
"OPENAI_API_KEY": "sk-third-party"
})));
assert!(codex_live_auth_is_stale_third_party_residue(&json!({
"OPENAI_API_KEY": "sk-third-party",
"last_refresh": "2026-01-01T00:00:00Z",
"tokens": { "account_id": "acct-meta-only" }
})));
// Anything carrying a real credential must survive untouched.
assert!(!codex_live_auth_is_stale_third_party_residue(&json!({
"OPENAI_API_KEY": "sk-x",
"tokens": { "access_token": "t" }
})));
assert!(!codex_live_auth_is_stale_third_party_residue(&json!({
"auth_mode": "chatgpt",
"OPENAI_API_KEY": null,
"tokens": { "access_token": "official-oauth-token" }
})));
// Nothing to clear.
assert!(!codex_live_auth_is_stale_third_party_residue(&json!({})));
assert!(!codex_live_auth_is_stale_third_party_residue(&json!({
"OPENAI_API_KEY": ""
})));
}
#[test]
fn prepare_provider_live_config_does_not_create_incomplete_provider_table() {
let input = r#"model_provider = "vendor_x"
@@ -3127,18 +2835,14 @@ base_url = "https://production.api/v1"
fill_template_fields_from_static(&mut template);
let specs = vec![CodexCatalogModelSpec {
model: "k3".to_string(),
display_name: Some("Kimi K3".to_string()),
context_window: Some(262_144),
display_name: "Kimi K3".to_string(),
context_window: 262_144,
supports_parallel_tool_calls: None,
input_modalities: None,
base_instructions: None,
}];
let catalog = codex_model_catalog_from_specs(
&specs,
&template,
CodexCatalogToolProfile::ProxyChat,
128_000,
);
let catalog =
codex_model_catalog_from_specs(&specs, &template, CodexCatalogToolProfile::ProxyChat);
assert_eq!(
catalog["models"][0]
.get("supports_reasoning_summaries")
@@ -3194,13 +2898,9 @@ base_url = "https://production.api/v1"
]
}
});
let specs = codex_catalog_model_specs(&settings);
let catalog = codex_model_catalog_from_specs(
&specs,
&template,
CodexCatalogToolProfile::ProxyChat,
128_000,
);
let specs = codex_catalog_model_specs(&settings, r#"model_context_window = 128000"#);
let catalog =
codex_model_catalog_from_specs(&specs, &template, CodexCatalogToolProfile::ProxyChat);
let models = catalog
.get("models")
.and_then(|value| value.as_array())
@@ -3333,40 +3033,40 @@ base_url = "https://production.api/v1"
let specs = vec![
CodexCatalogModelSpec {
model: "gpt-5.4".to_string(),
display_name: Some("GPT 5.4".to_string()),
context_window: Some(128_000),
display_name: "GPT 5.4".to_string(),
context_window: 128_000,
supports_parallel_tool_calls: None,
input_modalities: None,
base_instructions: None,
},
CodexCatalogModelSpec {
model: "deepseek/deepseek-v4-pro".to_string(),
display_name: Some("DeepSeek V4 Pro".to_string()),
context_window: Some(128_000),
display_name: "DeepSeek V4 Pro".to_string(),
context_window: 128_000,
supports_parallel_tool_calls: None,
input_modalities: None,
base_instructions: None,
},
CodexCatalogModelSpec {
model: "glm-5.2v".to_string(),
display_name: Some("GLM 5.2V".to_string()),
context_window: Some(128_000),
display_name: "GLM 5.2V".to_string(),
context_window: 128_000,
supports_parallel_tool_calls: None,
input_modalities: None,
base_instructions: None,
},
CodexCatalogModelSpec {
model: "deepseek-v4-flash".to_string(),
display_name: Some("Explicit Visual Override".to_string()),
context_window: Some(128_000),
display_name: "Explicit Visual Override".to_string(),
context_window: 128_000,
supports_parallel_tool_calls: None,
input_modalities: Some(vec!["text".to_string(), "image".to_string()]),
base_instructions: None,
},
CodexCatalogModelSpec {
model: "custom-text-alias".to_string(),
display_name: Some("Explicit Text Override".to_string()),
context_window: Some(128_000),
display_name: "Explicit Text Override".to_string(),
context_window: 128_000,
supports_parallel_tool_calls: None,
input_modalities: Some(vec!["text".to_string()]),
base_instructions: None,
@@ -3378,7 +3078,7 @@ base_url = "https://production.api/v1"
CodexCatalogToolProfile::NativeResponses,
CodexCatalogToolProfile::Anthropic,
] {
let catalog = codex_model_catalog_from_specs(&specs, &template, profile, 128_000);
let catalog = codex_model_catalog_from_specs(&specs, &template, profile);
let models = catalog["models"].as_array().expect("models array");
let modalities = |slug: &str| {
models
@@ -3429,205 +3129,6 @@ base_url = "https://production.api/v1"
);
}
const DEEPSEEK_NATIVE_CONFIG: &str = r#"model = "deepseek-v4-flash"
model_provider = "custom"
[model_providers.custom]
name = "deepseek"
base_url = "https://api.deepseek.com"
wire_api = "responses"
"#;
#[test]
fn deepseek_host_native_catalog_mirrors_official_entries() {
// DeepSeek publishes an official Codex models.json (freeform
// apply_patch + GPT-5 harness + low/high/max reasoning levels). For a
// deepseek.com native provider the generated catalog must mirror it
// verbatim instead of the stripped neutral template — the harness
// tells the model to use apply_patch, so stripping the tool while
// keeping the harness would be self-inconsistent.
let settings = json!({
"modelCatalog": {
"models": [
{ "model": "deepseek-v4-flash", "displayName": "DeepSeek V4 Flash" },
{ "model": "deepseek-v4-pro", "contextWindow": 500_000 }
]
}
});
let catalog = codex_model_catalog_from_settings(
&settings,
DEEPSEEK_NATIVE_CONFIG,
CodexCatalogToolProfile::NativeResponses,
)
.expect("vendor catalog generation should not error")
.expect("non-empty modelCatalog must yield a catalog");
let flash = &catalog["models"][0];
assert_eq!(
flash.get("slug").and_then(|v| v.as_str()),
Some("deepseek-v4-flash")
);
assert_eq!(
flash.get("apply_patch_tool_type").and_then(|v| v.as_str()),
Some("freeform"),
"official DeepSeek entries keep the freeform apply_patch grant"
);
assert!(
flash
.get("base_instructions")
.and_then(|v| v.as_str())
.is_some_and(|s| s.starts_with("You are Codex, an agent based on GPT-5")),
"official GPT-5 harness must survive verbatim"
);
let efforts: Vec<&str> = flash["supported_reasoning_levels"]
.as_array()
.expect("official reasoning levels array")
.iter()
.filter_map(|level| level.get("effort").and_then(|v| v.as_str()))
.collect();
assert_eq!(efforts, vec!["low", "high", "max"]);
assert_eq!(flash.get("supports_search_tool"), Some(&json!(true)));
assert_eq!(
flash.get("web_search_tool_type").and_then(|v| v.as_str()),
Some("text")
);
assert_eq!(
flash.get("supports_reasoning_summaries"),
Some(&json!(true))
);
assert_eq!(flash.get("input_modalities"), Some(&json!(["text"])));
assert!(
flash.get("model_messages").is_some(),
"official entries are mirrored verbatim, incl. model_messages"
);
// No explicit contextWindow on the row: the official 1m window must
// survive instead of being clobbered by the 128k default.
assert_eq!(
flash.get("context_window").and_then(|v| v.as_u64()),
Some(1_048_576)
);
// Explicit user display name still wins over the official one.
assert_eq!(
flash.get("display_name").and_then(|v| v.as_str()),
Some("DeepSeek V4 Flash")
);
let pro = &catalog["models"][1];
assert_eq!(
pro.get("slug").and_then(|v| v.as_str()),
Some("deepseek-v4-pro")
);
// Explicit user context window override wins…
assert_eq!(
pro.get("context_window").and_then(|v| v.as_u64()),
Some(500_000)
);
assert_eq!(
pro.get("max_context_window").and_then(|v| v.as_u64()),
Some(500_000)
);
// …while the untouched official display name is kept.
assert_eq!(
pro.get("display_name").and_then(|v| v.as_str()),
Some("DeepSeek-V4-Pro")
);
}
#[test]
fn deepseek_official_catalog_unknown_model_clones_flagship() {
// A user-added model id the official file doesn't know keeps the
// gateway's capability profile (clone of the flagship entry) without
// impersonating it: own slug/name, demoted priority, and the official
// context window rather than the 128k synthetic default.
let settings = json!({
"modelCatalog": { "models": [{ "model": "deepseek-v4-lite" }] }
});
let catalog = codex_model_catalog_from_settings(
&settings,
DEEPSEEK_NATIVE_CONFIG,
CodexCatalogToolProfile::NativeResponses,
)
.expect("vendor catalog generation should not error")
.expect("non-empty modelCatalog must yield a catalog");
let entry = &catalog["models"][0];
assert_eq!(
entry.get("slug").and_then(|v| v.as_str()),
Some("deepseek-v4-lite")
);
assert_eq!(
entry.get("display_name").and_then(|v| v.as_str()),
Some("deepseek-v4-lite")
);
assert!(
entry
.get("priority")
.and_then(|v| v.as_u64())
.is_some_and(|p| p >= 1000),
"clones must sort after official entries"
);
assert_eq!(
entry.get("apply_patch_tool_type").and_then(|v| v.as_str()),
Some("freeform")
);
assert_eq!(
entry.get("context_window").and_then(|v| v.as_u64()),
Some(1_048_576),
"absent contextWindow keeps the flagship's official window"
);
assert!(entry
.get("base_instructions")
.and_then(|v| v.as_str())
.is_some_and(|s| !s.trim().is_empty()));
}
#[test]
fn official_vendor_catalog_gated_by_native_profile_and_host() {
// The official mirror is a capability GRANT, so the gate must be
// narrow: native `/responses` profile AND the vendor's own host. Chat
// runs through the proxy converter (gpt-5.5 contract), the Anthropic
// transform drops custom tools, and aggregators hosting the same
// model may reject freeform tools — all of them keep their templates.
assert!(codex_official_vendor_catalog_models(
DEEPSEEK_NATIVE_CONFIG,
CodexCatalogToolProfile::NativeResponses
)
.is_some_and(|models| !models.is_empty()));
for profile in [
CodexCatalogToolProfile::ProxyChat,
CodexCatalogToolProfile::Anthropic,
] {
assert!(
codex_official_vendor_catalog_models(DEEPSEEK_NATIVE_CONFIG, profile).is_none(),
"only the NativeResponses profile may mirror the official catalog"
);
}
let minimax_config = r#"model = "MiniMax-M3"
model_provider = "custom"
[model_providers.custom]
name = "minimax"
base_url = "https://api.minimaxi.com/v1"
wire_api = "responses"
"#;
assert!(
codex_official_vendor_catalog_models(
minimax_config,
CodexCatalogToolProfile::NativeResponses
)
.is_none(),
"non-DeepSeek native hosts keep the neutral template"
);
assert!(
codex_official_vendor_catalog_models("", CodexCatalogToolProfile::NativeResponses)
.is_none()
);
}
#[test]
fn proxy_chat_profile_still_keeps_apply_patch() {
// Regression guard for Mode A: the proxy-chat profile must keep the
@@ -3635,8 +3136,8 @@ wire_api = "responses"
let template = load_codex_native_responses_template();
let specs = vec![CodexCatalogModelSpec {
model: "x".to_string(),
display_name: Some("x".to_string()),
context_window: Some(128_000),
display_name: "x".to_string(),
context_window: 128_000,
supports_parallel_tool_calls: None,
input_modalities: None,
base_instructions: None,
@@ -3650,7 +3151,6 @@ wire_api = "responses"
&specs,
&proxy_template,
CodexCatalogToolProfile::ProxyChat,
128_000,
);
assert_eq!(
catalog["models"][0]
+14 -216
View File
@@ -214,16 +214,11 @@ fn run_tool_lifecycle_silently(command_line: &str, _label: &str) -> Result<(), S
use std::process::Command;
// command_line 是 bash 风格脚本(含 `set -e` 与多行命令);强制用 bash 执行,
// 避免用户默认 shell 为 fish/zsh 时 `set -e` 等语义不一致。
let mut cmd = Command::new("bash");
cmd.arg("-c").arg(command_line);
// GUI App 继承的是 launchd 的窄 PATH,而锚定探测用的是登录 shell —— 见
// `login_shell_path` doc。命令自身用绝对路径不受影响,但工具**内部** spawn 的
// npm/node 等子进程、以及 install 链里裸的 npm fallback 都需要真实 PATH。
if let Some(login_path) = login_shell_path() {
let inherited = std::env::var("PATH").unwrap_or_default();
cmd.env("PATH", merge_path_segments(&login_path, &inherited));
}
let output = cmd.output().map_err(|e| format!("启动安装进程失败: {e}"))?;
let output = Command::new("bash")
.arg("-c")
.arg(command_line)
.output()
.map_err(|e| format!("启动安装进程失败: {e}"))?;
finish_lifecycle_output(&output)
}
@@ -1860,73 +1855,6 @@ fn first_abs_path_line(raw: &str) -> Option<&str> {
raw.lines().map(str::trim).find(|l| l.starts_with('/'))
}
/// 从 `env` 输出里取 `PATH=` 那行的值。要求值以 `/` 开头——PATH 首段必是绝对路径,
/// 这条约束顺带跳过"某个多行值的环境变量恰好有一行以 `PATH=` 开头"的污染,
/// 与 `first_abs_path_line` 对交互式 shell 噪音的容错同理。
#[cfg(not(target_os = "windows"))]
fn path_line_from_env_output(raw: &str) -> Option<&str> {
raw.lines()
.filter_map(|line| line.strip_prefix("PATH="))
.find(|value| value.starts_with('/'))
}
/// 合并两段 PATH`primary` 全部保留在前,`extra` 中未出现过的段按序追加。
/// 空段直接丢弃(`a::b` 里的空段在 POSIX 下语义是"当前目录",注入时不该带上)。
#[cfg(not(target_os = "windows"))]
fn merge_path_segments(primary: &str, extra: &str) -> String {
let mut seen = std::collections::HashSet::new();
let mut merged: Vec<&str> = Vec::new();
for segment in primary.split(':').chain(extra.split(':')) {
if segment.is_empty() || !seen.insert(segment) {
continue;
}
merged.push(segment);
}
merged.join(":")
}
/// 用与 `resolve_path_default` 相同的登录 shell 解析用户的真实 PATH
/// 供 `run_tool_lifecycle_silently` 注入给安装/升级脚本。
///
/// **要解决的不对称**:探测阶段(`try_get_version` / `resolve_path_default`)跑的是
/// `$SHELL -lic`,会读 `.zshrc`/`.zprofile`,看得到 nvm / homebrew / volta;而执行阶段
/// 是非登录 `bash -c`,继承的是 launchd 给 GUI App 的 PATH,通常只有
/// `/usr/bin:/bin:/usr/sbin:/sbin`。锚定命令自己用绝对路径调用执行体,本不受这条影响
/// ——但有两类漏网:
/// 1. **执行体在内部再 spawn 第三方 CLI**`grok update` 靠 `npm view` 查最新版本
/// (见 `grok_native_update_command`),npm 又是 `#!/usr/bin/env node` 脚本;
/// 未来任何 self-update 内部调 node/git/python 同理。
/// 2. **install 分支的 `<官方 installer> || npm i -g <pkg>@latest`**`||` 右侧是裸命令,
/// 窄 PATH 下必然 exit 127,等于没有兜底。
///
/// 把执行阶段的 PATH 拉平到探测阶段的水平,一次消除这两类。
///
/// **用 `/usr/bin/env` 而不是 `echo $PATH`**`$PATH` 在 fish 里是 list 类型,
/// `"$PATH"` 展开成空格分隔而非冒号分隔;`env` 打印的则是子进程的真实环境,
/// 任何 shell 下格式都正确。写绝对路径又绕过了 alias / function / PATH 三重不确定性
/// (交互式 shell 会加载用户 alias)。
///
/// 解析不到时返回 `None`,调用方保持原有行为(不注入),不引入新的失败模式。
#[cfg(not(target_os = "windows"))]
fn login_shell_path() -> Option<String> {
use std::process::Command;
let shell = std::env::var("SHELL")
.ok()
.filter(|s| is_valid_shell(s))
.unwrap_or_else(|| "sh".to_string());
let flag = default_flag_for_shell(&shell);
let out = Command::new(shell)
.arg(flag)
.arg("/usr/bin/env")
.output()
.ok()?;
if !out.status.success() {
return None;
}
let raw = decode_command_output(&out.stdout);
Some(path_line_from_env_output(&raw)?.to_string())
}
/// 用与 `try_get_version` 相同的登录 shell 解析 PATH 默认命中的可执行文件路径,
/// canonicalize 后作为"命令行默认 / 升级目标"的锚点(与升级会作用的那处对齐)。
#[cfg(not(target_os = "windows"))]
@@ -2277,64 +2205,6 @@ fn anchored_official_update_command(tool: &str, bin_path: &str) -> Option<String
official_update_args(tool).map(|args| format!("{} {args}", win_quote_path_for_batch(bin_path)))
}
/// Grok Build 原生安装的升级命令 = `<bin 绝对> update || <官方 installer>`。
///
/// **为什么唯独 native Grok 的 self-update 需要 fallback**claude native / hermes 都没有):
/// `grok update` 虽然是自包含 Rust 二进制的子命令,**却把 npm 当成自己的分发管道**——
/// 先 spawn `npm view @xai-official/grok version --json` 查最新版,再 spawn
/// `npm i -g @xai-official/grok@<version>` 安装,由该包的 `postinstall.js` 从平台 optional
/// 依赖里解出二进制、安置成 `~/.grok/bin/grok-<version>` 并 relink `grok`。而 `npm` 自身是
/// `#!/usr/bin/env node` 脚本 → **native 安装也隐式硬依赖 PATH 里同时有 `npm` + `node`**。
/// GUI 进程 PATH 由 launchd 给、`run_tool_lifecycle_silently` 又是非登录 `bash -c`
/// nvm/homebrew 下的 node+npm 均不可见 → grok 内部 spawn 得到 ENOENT,只向用户抛出
/// 费解的 `Error: No such file or directory (os error 2)`(实测复现)。
///
/// **这是上游换了机制**:0.2.111 及更早版本自更新是直接下载二进制到 `~/.grok/downloads/`
/// (彼时 `is_grok_native_install` 假设的 "native = 不碰 npm" 成立),0.2.112 起改走上述 npm
/// 管道(落点随之从 `downloads/` 变为 `bin/`)。**副作用**:npm 全局包那一处安装是
/// `grok update` 自己装出来的,非用户手动所为,故 native 用户也会被 enumerate 到两处;
/// 两处由同一次 postinstall 同步,版本恒等。
///
/// 这正是 `anchored_command_from_paths` 那条"绝对路径 + 必要时把解释器目录放 PATH 首位"
/// 不变量没覆盖的第三类:**执行体自身既不需要解释器、也已用绝对路径,却在内部再 spawn
/// 第三方 CLI**。`login_shell_path` 的 PATH 注入已让绝大多数机器上的 primary 直接成功;
/// 这条 fallback 覆盖的是"这台机器根本没装 node"——用官方 installer 装的用户完全可能如此。
///
/// **fallback 必须是官方 installer,不能是 `npm i -g`**:后者与 primary **同源**——primary
/// 失败的两种现实原因(本机无 node;npm registry 指向未同步该 tarball 的镜像,见
/// npmmirror dist-tag 事故)都会让 npm fallback 一并失败,`||` 形同虚设。官方 installer
/// 是唯一 node-free 的独立路径(只需 `curl`,在 `/usr/bin`,窄 PATH 下可用),且落点同为
/// `~/.grok/bin`,锚定语义分毫不动 —— 真正的降级冗余要求 fallback 与 primary **失败模式不相关**。
///
/// **这条 fallback 还兼具第三重作用:修复上游锚点(勿在重构时丢掉)**。
/// grok 的更新路径由 `~/.grok/config.toml` 的 `[cli] installer` 决定(`npm` / `internal` /
/// `gh-release`),而官方 install.sh 会**无条件把该字段覆写为 `internal`**(其 awk 段落先插入
/// `installer = "internal"`、再跳过 `[cli]` 段里已有的 `installer`/`channel` 行)。于是:
/// 用户一旦因 install 端的 npm fallback 被切进 npm 模式(postinstall.js 会写 `installer = "npm"`
/// 并在每次 npm 更新时重写,自我巩固),只要 npm 路径出任何问题,这里的 `||` 就会把他拉回
/// node-free 的 internal 模式,并顺带修好 npm 模式漏更新的 `~/.grok/bin/agent` launcher。
/// **实测验证**2026-07-30,窄 PATH + `installer = "npm"`):primary 抛 `os error 2` → fallback
/// 接管 → 装上最新版 → config 写回 `internal` → agent 对齐 → `.zshrc` 幂等更新不重复。
/// ⇒ install 端保留 npm fallback 是安全的(它是 x.ai 不可达时的唯一退路,防火墙场景需要),
/// 其副作用由本链自愈;**把这里换成 npm fallback 会同时废掉降级冗余和这条自愈路径**。
#[cfg(not(target_os = "windows"))]
fn grok_native_update_command(update: String) -> String {
chain_update_commands(
update,
GROK_INSTALL_UNIX.to_string(),
LifecycleCommandShell::Posix,
)
}
/// Windows 版同上,fallback 换成官方 PowerShell installer。
/// **不走 `chain_update_commands`**:它会给 `||` 右侧加 `call`,而这里的 fallback 是
/// `powershell.exe`(不是 `.cmd`/`.bat`),不需要 `call`——与 `hermes_update_windows_command`
/// 同一理由。
#[cfg(target_os = "windows")]
fn grok_native_update_command(update: String) -> String {
format!("{update} || {}", grok_install_windows_command())
}
/// 哪些工具的"官方 self-update"优先于包管理器升级(生成 `<tool> update || <pkg-mgr>`)。
///
/// **codex 刻意不在此列**`codex update` 在 npm 安装上只是裸 `npm install -g
@@ -2489,9 +2359,7 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) ->
return anchored_official_update_command(tool, bin_path);
}
if tool == "grok" && is_grok_native_install(bin_path, real_target) {
return Some(grok_native_update_command(
anchored_official_update_command(tool, bin_path)?,
));
return anchored_official_update_command(tool, bin_path);
}
let package_command = package_manager_anchored_command_from_paths(tool, bin_path, real_target);
if brew_formula_from_path(real_target).is_some() {
@@ -2570,9 +2438,7 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) ->
return anchored_official_update_command(tool, bin_path);
}
if tool == "grok" && is_grok_native_install(bin_path, real_target) {
return Some(grok_native_update_command(
anchored_official_update_command(tool, bin_path)?,
));
return anchored_official_update_command(tool, bin_path);
}
let package_command = package_manager_anchored_command_from_paths(tool, bin_path);
if prefers_official_update(tool, LifecycleCommandShell::WindowsBatch) {
@@ -2669,11 +2535,6 @@ fn installer_with_npm_fallback(installer: &str, tool: &str) -> String {
fn posix_install_command_for(tool: &str) -> String {
match tool {
"claude" => installer_with_npm_fallback(CLAUDE_INSTALL_UNIX, tool),
// Grok 的 npm fallback **会切换用户的分发模式**(该包 postinstall 把
// `~/.grok/config.toml` 的 `[cli] installer` 写成 `npm`,此后 `grok update` 一律走
// npm、隐式依赖 node)。仍然保留它:官方 installer 不可达(防火墙 / x.ai 被拦)时
// 这是唯一退路,而副作用可自愈——`grok_native_update_command` 的 `||` 官方 installer
// 会在 npm 路径出问题时把 `installer` 覆写回 `internal`(见该函数 doc 的实测记录)。
"grok" => installer_with_npm_fallback(GROK_INSTALL_UNIX, tool),
"opencode" => installer_with_npm_fallback(OPENCODE_INSTALL_UNIX, tool),
"hermes" => HERMES_INSTALL_UNIX.to_string(),
@@ -4182,24 +4043,11 @@ mod tests {
}
#[test]
fn grok_native_windows_uses_self_update_with_installer_fallback() {
// sibling 有 npm.cmd 也**不能**拿它当 fallback:`grok update` 本身就是靠 npm
// 分发的(见 grok_native_update_command doc),npm fallback 与 primary 同源、
// 会一起失败。fallback 必须是官方 PowerShell installer —— 唯一不经 npm 的路径。
fn grok_native_windows_uses_self_update() {
let (_dir, _sub, bin_path) = setup_sibling(".grok/bin", "grok.exe", &["npm.cmd"]);
let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path).unwrap();
let expected = format!(
"{} update || {}",
expect_quoted_path(&bin_path),
grok_install_windows_command()
);
assert_eq!(cmd, expected);
// fallback 是 powershell.exe 不是 .cmd/.bat —— `||` 右侧不该有 `call`。
assert!(
!cmd.contains("|| call"),
"powershell needs no `call`: {cmd}"
);
assert!(!cmd.contains("npm"), "npm must not be the fallback: {cmd}");
let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path);
let expected = format!("{} update", expect_quoted_path(&bin_path));
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
#[test]
@@ -4669,7 +4517,7 @@ mod tests {
}
#[test]
fn grok_native_installer_uses_self_update_with_installer_fallback() {
fn grok_native_installer_uses_self_update() {
// ~/.grok/bin/grok is a launcher symlink into ~/.grok/downloads.
// Updating it through npm would create or mutate a different install.
let cmd = anchored_command_from_paths(
@@ -4677,25 +4525,7 @@ mod tests {
"/Users/me/.grok/bin/grok",
"/Users/me/.grok/downloads/grok-macos-aarch64",
);
assert_eq!(
cmd.as_deref(),
Some(format!("/Users/me/.grok/bin/grok update || {GROK_INSTALL_UNIX}").as_str())
);
}
#[test]
fn grok_native_update_falls_back_to_installer_not_npm() {
// 反向锁定:`grok update` 内部靠 `npm view` + `npm i -g` 完成升级,GUI 的窄
// PATH 下会 ENOENT。fallback 必须是官方 installer —— 换成 `npm i -g` 就与
// primary 同源(无 node / 镜像缺 tarball 时一起失败),`||` 形同虚设。
let cmd = anchored_command_from_paths(
"grok",
"/Users/me/.grok/bin/grok",
"/Users/me/.grok/downloads/grok-macos-aarch64",
)
.expect("native grok should anchor");
assert!(cmd.contains("x.ai/cli/install.sh"), "{cmd}");
assert!(!cmd.contains("npm"), "npm must not be the fallback: {cmd}");
assert_eq!(cmd.as_deref(), Some("/Users/me/.grok/bin/grok update"));
}
#[test]
@@ -4705,10 +4535,7 @@ mod tests {
"/Users/me/bin/grok",
"/Users/me/.grok/downloads/grok-macos-aarch64",
);
assert_eq!(
cmd.as_deref(),
Some(format!("/Users/me/bin/grok update || {GROK_INSTALL_UNIX}").as_str())
);
assert_eq!(cmd.as_deref(), Some("/Users/me/bin/grok update"));
}
#[test]
@@ -5179,35 +5006,6 @@ mod tests {
assert_eq!(first_abs_path_line("welcome\nbye\n"), None);
}
#[test]
fn path_line_from_env_output_survives_shell_noise() {
// `$SHELL -lic /usr/bin/env` 的 stdout 前面可能有交互式 rc 的欢迎语。
let raw = "🚀 Welcome back, Jason!\nSHELL=/bin/zsh\nPATH=/opt/homebrew/bin:/usr/bin\nHOME=/Users/me\n";
assert_eq!(
path_line_from_env_output(raw),
Some("/opt/homebrew/bin:/usr/bin")
);
// 多行值的环境变量里恰好有一行以 `PATH=` 开头时,「值须以 / 开头」把它筛掉。
let poisoned = "SOME_SCRIPT=line1\nPATH=not-a-path\nPATH=/usr/bin:/bin\n";
assert_eq!(path_line_from_env_output(poisoned), Some("/usr/bin:/bin"));
// 完全没有 PATH 行 → None,调用方保持不注入。
assert_eq!(path_line_from_env_output("HOME=/Users/me\n"), None);
}
#[test]
fn merge_path_segments_dedupes_preserving_login_order() {
// 登录 shell 的段全部在前且保序;继承 PATH 里的新段追加在后。
assert_eq!(
merge_path_segments(
"/Users/me/.nvm/versions/node/v22/bin:/usr/bin:/bin",
"/usr/bin:/bin:/usr/sbin"
),
"/Users/me/.nvm/versions/node/v22/bin:/usr/bin:/bin:/usr/sbin"
);
// 空段(`a::b` 在 POSIX 下意为当前目录)不该被注入。
assert_eq!(merge_path_segments("/usr/bin::/bin", ""), "/usr/bin:/bin");
}
#[test]
fn is_conflicting_thresholds() {
let make = |version: Option<&str>, runnable: bool| ToolInstallation {
+13 -155
View File
@@ -1574,23 +1574,7 @@ impl Database {
"0.50",
"6.25",
),
// Claude 4.6 系列(裸 id 行覆盖无日期后缀的日志变体,与 dated 行同价)
(
"claude-opus-4-6",
"Claude Opus 4.6",
"5",
"25",
"0.50",
"6.25",
),
(
"claude-sonnet-4-6",
"Claude Sonnet 4.6",
"3",
"15",
"0.30",
"3.75",
),
// Claude 4.6 系列
(
"claude-opus-4-6-20260206",
"Claude Opus 4.6",
@@ -1677,16 +1661,15 @@ impl Database {
// GPT-5.6 系列(Sol / Terra / Luna2026-06 发布)
// 5.6 家族起 cache write 收 1.25× 输入价(此前 GPT 模型写缓存免费,勿回填旧系列)
("gpt-5.6-sol", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"),
// 2026-07-30 OpenAI 降价:luna -80%、terra -20%sol 不变(Fast mode 2× 价不入表)
("gpt-5.6-terra", "GPT-5.6 Terra", "2", "12", "0.20", "2.50"),
(
"gpt-5.6-luna",
"GPT-5.6 Luna",
"0.20",
"1.20",
"0.02",
"gpt-5.6-terra",
"GPT-5.6 Terra",
"2.50",
"15",
"0.25",
"3.125",
),
("gpt-5.6-luna", "GPT-5.6 Luna", "1", "6", "0.10", "1.25"),
// 裸名 gpt-5.6 是 sol 的官方别名;effort 后缀对齐 gpt-5.5 系列的记账形态
("gpt-5.6", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"),
("gpt-5.6-low", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"),
@@ -1746,14 +1729,6 @@ impl Database {
),
// GPT-5.3 Codex 系列
("gpt-5.3-codex", "GPT-5.3 Codex", "1.75", "14", "0.175", "0"),
(
"gpt-5.3-codex-spark",
"GPT-5.3 Codex Spark",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.3-codex-low",
"GPT-5.3 Codex",
@@ -1898,14 +1873,6 @@ impl Database {
"0.15",
"0",
),
(
"gemini-3.5-flash-lite",
"Gemini 3.5 Flash Lite",
"0.30",
"2.50",
"0.03",
"0",
),
// Gemini 3.1 系列
(
"gemini-3.1-pro-preview",
@@ -2095,21 +2062,20 @@ impl Database {
"0",
),
("deepseek-v3", "DeepSeek V3", "0.28", "1.11", "0.028", "0"),
// deepseek-chat / deepseek-reasoner 自 2026-07 起为 V4 Flash 的 legacy 别名(同价)
(
"deepseek-chat",
"DeepSeek Chat",
"0.14",
"0.28",
"0.0028",
"0.27",
"1.10",
"0.07",
"0",
),
(
"deepseek-reasoner",
"DeepSeek Reasoner",
"0.55",
"2.19",
"0.14",
"0.28",
"0.0028",
"0",
),
// DeepSeek V4 系列(官方 CNY 按 1 USD ≈ 7.14 折算)
@@ -2157,15 +2123,6 @@ impl Database {
"0.19",
"0",
),
// HighSpeed 加速档=本体 2 倍价(Kimi 官方一贯模式,同 K2 Turbo
(
"kimi-k2.7-code-highspeed",
"Kimi K2.7 Code HighSpeed",
"1.90",
"8.00",
"0.38",
"0",
),
("kimi-k3", "Kimi K3", "3.00", "15.00", "0.30", "0"),
// Kimi For Coding 套餐里 K3 的裸名(无 kimi- 前缀),同标准 list 价
("k3", "Kimi K3", "3.00", "15.00", "0.30", "0"),
@@ -2208,15 +2165,13 @@ impl Database {
"0.06",
"0.375",
),
("minimax-m3", "MiniMax M3", "0.30", "1.20", "0.06", "0"),
("minimax-m3", "MiniMax M3", "0.60", "2.40", "0.12", "0"),
// GLM (智谱)
("glm-4.7", "GLM-4.7", "0.6", "2.2", "0.11", "0"),
("glm-4.6", "GLM-4.6", "0.6", "2.2", "0.11", "0"),
("glm-5", "GLM-5", "1", "3.2", "0.2", "0"),
("glm-5.1", "GLM-5.1", "1.4", "4.4", "0.26", "0"),
("glm-5.2", "GLM-5.2", "1.4", "4.4", "0.26", "0"),
("glm-5-turbo", "GLM-5-Turbo", "1.2", "4", "0.24", "0"),
("glm-5v-turbo", "GLM-5V-Turbo", "1.2", "4", "0.24", "0"),
// MiMo (小米)
(
"mimo-v2-flash",
@@ -2247,14 +2202,6 @@ impl Database {
"0.065",
"0",
),
(
"qwen3.6-flash",
"Qwen3.6 Flash",
"0.1875",
"1.125",
"0.0375",
"0",
),
("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0.052", "0"),
("qwen3-max", "Qwen3 Max", "0.78", "3.90", "0", "0"),
(
@@ -2482,95 +2429,6 @@ impl Database {
fn repair_current_model_pricing(conn: &Connection) -> Result<(), AppError> {
let pricing_fixes = [
// 2026-07-30 OpenAI GPT-5.6 降价:luna -80%、terra -20%sol 不变)。
// 每档两条守卫:主守卫匹配 ≥v3.19(已跑过 07-12 cache_write 修正),
// 0 态守卫匹配 <v3.19 直升用户(cache_write 仍为旧 seed 的 0
(
"gpt-5.6-luna",
"GPT-5.6 Luna",
"0.20",
"1.20",
"0.02",
"0.25",
"1",
"6",
"0.10",
"1.25",
),
(
"gpt-5.6-luna",
"GPT-5.6 Luna",
"0.20",
"1.20",
"0.02",
"0.25",
"1",
"6",
"0.10",
"0",
),
(
"gpt-5.6-terra",
"GPT-5.6 Terra",
"2",
"12",
"0.20",
"2.50",
"2.50",
"15",
"0.25",
"3.125",
),
(
"gpt-5.6-terra",
"GPT-5.6 Terra",
"2",
"12",
"0.20",
"2.50",
"2.50",
"15",
"0.25",
"0",
),
// 2026-07-31 models.dev 审计核价:DeepSeek V4 发布后 chat/reasoner 降为 V4 Flash
// 别名价;MiniMax M3 官方 standard 档 0.3/1.2(旧值疑似录了加速档)
(
"deepseek-chat",
"DeepSeek Chat",
"0.14",
"0.28",
"0.0028",
"0",
"0.27",
"1.10",
"0.07",
"0",
),
(
"deepseek-reasoner",
"DeepSeek Reasoner",
"0.14",
"0.28",
"0.0028",
"0",
"0.55",
"2.19",
"0.14",
"0",
),
(
"minimax-m3",
"MiniMax M3",
"0.30",
"1.20",
"0.06",
"0",
"0.60",
"2.40",
"0.12",
"0",
),
// 2026-07-12 GPT-5.6 家族 cache write=1.25× 输入价(OpenAI 5.6 起的新规),
// 修正早期 seed 的 0 值;只匹配未被用户改过的行
(
+2 -8
View File
@@ -287,8 +287,7 @@ pub fn apply_proxy_takeover(
token_placeholder: &str,
) -> Result<String, AppError> {
let updated = update_selected_model_string(config_toml, "base_url", proxy_base_url)?;
let updated = update_selected_model_string(&updated, "api_key", token_placeholder)?;
update_selected_model_string(&updated, "api_backend", DEFAULT_API_BACKEND)
update_selected_model_string(&updated, "api_key", token_placeholder)
}
pub fn update_api_key(config_toml: &str, api_key: &str) -> Result<String, AppError> {
@@ -513,12 +512,8 @@ context_window = 500000
#[test]
fn takeover_preserves_env_key_profile_and_injects_inline_placeholder() {
let direct_config = valid_env_key_config().replace(
"api_backend = \"responses\"",
"api_backend = \"chat_completions\"",
);
let updated = apply_proxy_takeover(
&direct_config,
valid_env_key_config(),
"http://127.0.0.1:15721/grokbuild/v1",
"PROXY_MANAGED",
)
@@ -528,7 +523,6 @@ context_window = 500000
assert_eq!(selected.profile, "grok-env");
assert_eq!(selected.env_key.as_deref(), Some("GROK_TEST_API_KEY"));
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
assert_eq!(selected.api_backend, DEFAULT_API_BACKEND);
}
#[test]
+1 -3
View File
@@ -39,9 +39,7 @@ mod usage_events;
mod usage_script;
pub use app_config::{AppType, InstalledSkill, McpApps, McpServer, MultiAppConfig, SkillApps};
pub use codex_config::{
get_codex_auth_path, get_codex_config_path, read_codex_live_settings, write_codex_live_atomic,
};
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
pub use commands::open_provider_terminal;
pub use commands::*;
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
+1 -1
View File
@@ -2630,7 +2630,7 @@ async fn log_usage(
model
};
let dedup_scope = super::usage::parser::dedup_scope_for_app(app_type, provider_id);
let dedup_scope = (app_type != "claude").then_some((app_type, provider_id));
let request_id = usage.dedup_request_id(dedup_scope);
if let Err(e) = logger.log_with_calculation(
+1 -1
View File
@@ -642,7 +642,7 @@ async fn log_usage_internal(
model
};
let dedup_scope = super::usage::parser::dedup_scope_for_app(app_type, provider_id);
let dedup_scope = (app_type != "claude").then_some((app_type, provider_id));
let request_id = usage.dedup_request_id(dedup_scope);
log::debug!(
+10 -130
View File
@@ -7,7 +7,6 @@
//! 支持从客户端请求中提取 Session ID,用于关联同一对话的多个请求:
//! - Claude: 从 `metadata.user_id` (格式: `user_xxx_session_yyy`) 或 `metadata.session_id` 提取
//! - Codex: 从 headers 中的 `session_id` / `x-session-id` 或 `metadata.session_id` 提取
//! - Grok Build: 从 headers 中的 `x-grok-conv-id` / `x-grok-session-id` 提取
//! - 其他: 生成新的 UUID
use axum::http::HeaderMap;
@@ -24,7 +23,7 @@ pub enum SessionIdSource {
MetadataUserId,
/// 从 metadata.session_id 提取
MetadataSessionId,
/// 从 headers 提取
/// 从 headers 提取 (Codex)
Header,
/// 新生成
Generated,
@@ -57,11 +56,6 @@ pub struct SessionIdResult {
/// 2. `metadata.session_id`
/// 3. 生成新 UUID
///
/// ### Grok Build 请求
/// 1. Headers: `x-grok-conv-id` 或 `x-grok-session-id`
/// 2. `metadata.session_id`
/// 3. 生成新 UUID
///
/// ## 示例
///
/// ```ignore
@@ -79,15 +73,9 @@ pub fn extract_session_id(
}
}
// Responses 请求特殊处理。Grok Build 使用与 Codex 相同的客户端协议,
// 但保留独立前缀,避免统计和缓存键跨应用碰撞。
if matches!(client_format, "codex" | "openai" | "grokbuild") {
let prefix = if client_format == "grokbuild" {
"grokbuild"
} else {
"codex"
};
if let Some(result) = extract_responses_session(headers, body, prefix) {
// Codex 请求特殊处理
if client_format == "codex" || client_format == "openai" {
if let Some(result) = extract_codex_session(headers, body) {
return result;
}
}
@@ -123,28 +111,16 @@ fn extract_claude_session(
extract_from_metadata(body)
}
/// 提取 Responses 客户端的 Session ID
fn extract_responses_session(
headers: &HeaderMap,
body: &serde_json::Value,
prefix: &str,
) -> Option<SessionIdResult> {
/// 提取 Codex Session ID
fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option<SessionIdResult> {
// 1. 从 headers 提取
let header_names: &[&str] = if prefix == "grokbuild" {
// Conversation ID 跨多轮请求保持稳定;session ID 作为客户端缺少
// conversation ID 时的回退。x-grok-req-id 是逐请求 ID,不能用于聚合。
&["x-grok-conv-id", "x-grok-session-id"]
} else {
&["session_id", "x-session-id"]
};
for header_name in header_names {
for header_name in &["session_id", "x-session-id"] {
if let Some(value) = headers.get(*header_name) {
if let Ok(session_id) = value.to_str() {
let session_id = session_id.trim();
// Responses 客户端的 Session ID 通常较长(UUID 格式)
// Codex Session ID 通常较长(UUID 格式)
if session_id.len() > 20 {
return Some(SessionIdResult {
session_id: format!("{prefix}_{session_id}"),
session_id: format!("codex_{session_id}"),
source: SessionIdSource::Header,
client_provided: true,
});
@@ -159,10 +135,9 @@ fn extract_responses_session(
.and_then(|m| m.get("session_id"))
.and_then(|v| v.as_str())
{
let session_id = session_id.trim();
if session_id.len() > 10 {
return Some(SessionIdResult {
session_id: format!("{prefix}_{session_id}"),
session_id: format!("codex_{session_id}"),
source: SessionIdSource::MetadataSessionId,
client_provided: true,
});
@@ -327,101 +302,6 @@ mod tests {
assert!(!result.client_provided);
}
#[test]
fn test_codex_keeps_existing_response_session_headers() {
let body = json!({ "input": "Write a function" });
for header_name in ["session_id", "x-session-id"] {
let mut headers = HeaderMap::new();
headers.insert(
header_name,
"d937243f-2702-4f20-97b6-c9682235ab81".parse().unwrap(),
);
let result = extract_session_id(&headers, &body, "codex");
assert_eq!(
result.session_id,
"codex_d937243f-2702-4f20-97b6-c9682235ab81"
);
assert_eq!(result.source, SessionIdSource::Header);
assert!(result.client_provided);
}
}
#[test]
fn test_grokbuild_prefers_conversation_header() {
let mut headers = HeaderMap::new();
headers.insert(
"x-grok-conv-id",
"conv-724f4275-584e-43af-ad46-b5e7509a3ca2".parse().unwrap(),
);
headers.insert(
"x-grok-session-id",
"session-d937243f-2702-4f20-97b6-c9682235ab81"
.parse()
.unwrap(),
);
let body = json!({ "input": "Write a function" });
let result = extract_session_id(&headers, &body, "grokbuild");
assert_eq!(
result.session_id,
"grokbuild_conv-724f4275-584e-43af-ad46-b5e7509a3ca2"
);
assert_eq!(result.source, SessionIdSource::Header);
assert!(result.client_provided);
}
#[test]
fn test_grokbuild_falls_back_to_session_header() {
let body = json!({ "input": "Write a function" });
for conversation_id in ["", " "] {
let mut headers = HeaderMap::new();
headers.insert("x-grok-conv-id", conversation_id.parse().unwrap());
headers.insert(
"x-grok-session-id",
"session-d937243f-2702-4f20-97b6-c9682235ab81"
.parse()
.unwrap(),
);
let result = extract_session_id(&headers, &body, "grokbuild");
assert_eq!(
result.session_id,
"grokbuild_session-d937243f-2702-4f20-97b6-c9682235ab81"
);
assert_eq!(result.source, SessionIdSource::Header);
assert!(result.client_provided);
}
}
#[test]
fn test_grokbuild_ignores_request_and_codex_session_headers() {
let mut headers = HeaderMap::new();
headers.insert(
"x-grok-req-id",
"request-724f4275-584e-43af-ad46-b5e7509a3ca2"
.parse()
.unwrap(),
);
headers.insert(
"x-session-id",
"codex-d937243f-2702-4f20-97b6-c9682235ab81"
.parse()
.unwrap(),
);
let body = json!({ "input": "Write a function" });
let result = extract_session_id(&headers, &body, "grokbuild");
assert_eq!(result.source, SessionIdSource::Generated);
assert!(!result.client_provided);
}
#[test]
fn test_extract_session_generates_new_when_not_found() {
let headers = HeaderMap::new();
-51
View File
@@ -687,57 +687,6 @@ mod tests {
Ok(())
}
#[test]
fn claude_desktop_proxy_replaces_matching_session_log_row() -> Result<(), AppError> {
let db = Database::memory()?;
{
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model, input_tokens,
output_tokens, cache_read_tokens, cache_creation_tokens,
latency_ms, status_code, created_at, data_source
) VALUES ('session:msg_desktop', '_session', 'claude',
'claude-sonnet-4-5', 10, 5, 2, 1, 0, 200, 1, 'session_log')",
[],
)?;
}
let usage = TokenUsage {
input_tokens: 10,
output_tokens: 5,
cache_read_tokens: 2,
cache_creation_tokens: 1,
model: Some("claude-sonnet-4-5".to_string()),
message_id: Some("msg_desktop".to_string()),
};
let request_id = usage.dedup_request_id(crate::proxy::usage::parser::dedup_scope_for_app(
"claude-desktop",
"desktop-provider",
));
let mut proxy_log = request_log(&request_id, 10);
proxy_log.provider_id = "desktop-provider".to_string();
proxy_log.app_type = "claude-desktop".to_string();
proxy_log.model = "claude-sonnet-4-5".to_string();
proxy_log.request_model = "claude-sonnet-4-5".to_string();
proxy_log.pricing_model = "claude-sonnet-4-5".to_string();
proxy_log.usage = usage;
UsageLogger::new(&db).log_request(&proxy_log)?;
let conn = crate::database::lock_conn!(db.conn);
let (count, source, app_type): (i64, String, String) = conn.query_row(
"SELECT COUNT(*), data_source, app_type FROM proxy_request_logs
WHERE request_id = 'session:msg_desktop'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)?;
assert_eq!(count, 1);
assert_eq!(source, "proxy");
assert_eq!(app_type, "claude-desktop");
Ok(())
}
#[test]
fn test_log_error() -> Result<(), AppError> {
let db = Database::memory()?;
-29
View File
@@ -30,16 +30,6 @@ fn openai_cache_write_tokens(usage: &Value) -> u32 {
/// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致
pub const SESSION_REQUEST_ID_PREFIX: &str = "session:";
/// Claude Code and Claude Desktop share Claude message ids with the session
/// importer, so both use the bare `session:{message_id}` namespace. Other
/// apps retain app/provider scoping to avoid collisions between upstreams.
pub fn dedup_scope_for_app<'a>(
app_type: &'a str,
provider_id: &'a str,
) -> Option<(&'a str, &'a str)> {
(!matches!(app_type, "claude" | "claude-desktop")).then_some((app_type, provider_id))
}
fn response_id(body: &Value, field: &str) -> Option<String> {
body.get(field)
.and_then(Value::as_str)
@@ -489,25 +479,6 @@ mod tests {
.starts_with("session:"));
}
#[test]
fn claude_apps_share_the_session_request_id_namespace() {
let usage = TokenUsage {
message_id: Some("msg_123".to_string()),
..Default::default()
};
for app_type in ["claude", "claude-desktop"] {
assert_eq!(
usage.dedup_request_id(dedup_scope_for_app(app_type, "provider-a")),
"session:msg_123"
);
}
assert_eq!(
usage.dedup_request_id(dedup_scope_for_app("codex", "provider-a")),
"session:codex:provider-a:msg_123"
);
}
#[test]
fn stream_parsers_recover_ids_from_envelope_chunks() {
let openai = vec![
File diff suppressed because one or more lines are too long
+15 -82
View File
@@ -31,46 +31,25 @@ pub fn check_env_conflicts(app: &str) -> Result<Vec<EnvConflict>, String> {
Ok(conflicts)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EnvKeyword {
Exact(&'static str),
Prefix(&'static str),
}
/// Get relevant keywords for each app
fn get_keywords_for_app(app: &str) -> Vec<EnvKeyword> {
fn get_keywords_for_app(app: &str) -> Vec<&str> {
match app.to_lowercase().as_str() {
"claude" => vec![EnvKeyword::Prefix("ANTHROPIC")],
"codex" => vec![EnvKeyword::Prefix("OPENAI")],
"gemini" => vec![
EnvKeyword::Prefix("GEMINI"),
EnvKeyword::Prefix("GOOGLE_GEMINI"),
],
"grokbuild" | "grok" => vec![
EnvKeyword::Exact("XAI_API_KEY"),
EnvKeyword::Exact("GROK_DEFAULT_MODEL"),
],
"claude" => vec!["ANTHROPIC"],
"codex" => vec!["OPENAI"],
"gemini" => vec!["GEMINI", "GOOGLE_GEMINI"],
_ => vec![],
}
}
fn matches_env_keyword(name: &str, keywords: &[EnvKeyword]) -> bool {
let upper_name = name.to_uppercase();
keywords.iter().any(|keyword| match keyword {
EnvKeyword::Exact(name) => upper_name == *name,
EnvKeyword::Prefix(prefix) => upper_name.starts_with(prefix),
})
}
/// Check system environment variables (Windows Registry or Unix env)
#[cfg(target_os = "windows")]
fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
let mut conflicts = Vec::new();
// Check HKEY_CURRENT_USER\Environment
if let Ok(hkcu) = RegKey::predef(HKEY_CURRENT_USER).open_subkey("Environment") {
for (name, value) in hkcu.enum_values().filter_map(Result::ok) {
if matches_env_keyword(&name, keywords) {
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
conflicts.push(EnvConflict {
var_name: name.clone(),
var_value: value.to_string(),
@@ -86,7 +65,7 @@ fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String>
.open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment")
{
for (name, value) in hklm.enum_values().filter_map(Result::ok) {
if matches_env_keyword(&name, keywords) {
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
conflicts.push(EnvConflict {
var_name: name.clone(),
var_value: value.to_string(),
@@ -101,12 +80,12 @@ fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String>
}
#[cfg(not(target_os = "windows"))]
fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
let mut conflicts = Vec::new();
// Check current process environment
for (key, value) in std::env::vars() {
if matches_env_keyword(&key, keywords) {
if keywords.iter().any(|k| key.to_uppercase().contains(k)) {
conflicts.push(EnvConflict {
var_name: key,
var_value: value,
@@ -121,7 +100,7 @@ fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String>
/// Check shell configuration files for environment variable exports (Unix only)
#[cfg(not(target_os = "windows"))]
fn check_shell_configs(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
let mut conflicts = Vec::new();
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
@@ -152,7 +131,7 @@ fn check_shell_configs(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, Stri
let var_value = export_line[eq_pos + 1..].trim();
// Check if variable name contains any keyword
if matches_env_keyword(var_name, keywords) {
if keywords.iter().any(|k| var_name.to_uppercase().contains(k)) {
conflicts.push(EnvConflict {
var_name: var_name.to_string(),
var_value: var_value
@@ -178,58 +157,12 @@ mod tests {
#[test]
fn test_get_keywords() {
assert_eq!(
get_keywords_for_app("claude"),
vec![EnvKeyword::Prefix("ANTHROPIC")]
);
assert_eq!(
get_keywords_for_app("codex"),
vec![EnvKeyword::Prefix("OPENAI")]
);
assert_eq!(get_keywords_for_app("claude"), vec!["ANTHROPIC"]);
assert_eq!(get_keywords_for_app("codex"), vec!["OPENAI"]);
assert_eq!(
get_keywords_for_app("gemini"),
vec![
EnvKeyword::Prefix("GEMINI"),
EnvKeyword::Prefix("GOOGLE_GEMINI")
]
vec!["GEMINI", "GOOGLE_GEMINI"]
);
assert_eq!(
get_keywords_for_app("grokbuild"),
vec![
EnvKeyword::Exact("XAI_API_KEY"),
EnvKeyword::Exact("GROK_DEFAULT_MODEL")
]
);
assert_eq!(
get_keywords_for_app("grok"),
get_keywords_for_app("grokbuild")
);
assert_eq!(get_keywords_for_app("unknown"), Vec::<EnvKeyword>::new());
}
#[test]
fn grok_keywords_only_match_credentials() {
let keywords = get_keywords_for_app("grokbuild");
assert!(matches_env_keyword("XAI_API_KEY", &keywords));
assert!(matches_env_keyword("xai_api_key", &keywords));
assert!(matches_env_keyword("GROK_DEFAULT_MODEL", &keywords));
assert!(matches_env_keyword("grok_default_model", &keywords));
assert!(!matches_env_keyword("MY_XAI_API_KEY", &keywords));
assert!(!matches_env_keyword("XAI_API_KEY_BACKUP", &keywords));
assert!(!matches_env_keyword("MY_GROK_DEFAULT_MODEL", &keywords));
assert!(!matches_env_keyword("GROK_DEFAULT_MODEL_BACKUP", &keywords));
assert!(!matches_env_keyword("GROK_BIN_DIR", &keywords));
assert!(!matches_env_keyword("GROK_HOME", &keywords));
}
#[test]
fn broad_app_keywords_match_only_at_the_start() {
let keywords = get_keywords_for_app("claude");
assert!(matches_env_keyword("ANTHROPIC_API_KEY", &keywords));
assert!(matches_env_keyword("anthropic_base_url", &keywords));
assert!(!matches_env_keyword("MY_ANTHROPIC_API_KEY", &keywords));
assert!(!matches_env_keyword("NOT_ANTHROPIC", &keywords));
assert_eq!(get_keywords_for_app("unknown"), Vec::<&str>::new());
}
}
-27
View File
@@ -3090,7 +3090,6 @@ impl ProviderService {
// Use effective current provider (validated existence) to ensure backfill targets valid provider
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
let mut backfill_completed = false;
if let Some(current_id) = current_id {
if current_id != id {
// Additive mode apps - all providers coexist in the same file,
@@ -3124,8 +3123,6 @@ impl ProviderService {
result
.warnings
.push(format!("backfill_failed:{current_id}"));
} else {
backfill_completed = true;
}
}
}
@@ -3145,30 +3142,6 @@ impl ProviderService {
// Sync to live (write_gemini_live handles security flag internally for Gemini)
write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;
// A material-less official Codex provider gets a config-only live
// write, which can leave the previous third-party key in
// ~/.codex/auth.json and strand the user on a 401 with no login
// screen. Only clean up after a successful backfill — the DB copy
// made above is what keeps that key recoverable. Failures degrade to
// a log entry: config.toml and is_current are already committed, so
// failing the switch here would report a switch that in fact happened.
if matches!(app_type, AppType::Codex)
&& backfill_completed
&& provider.category.as_deref() == Some("official")
{
let db_auth = provider.settings_config.get("auth");
match crate::codex_config::clear_stale_codex_live_auth_after_official_switch(
db_auth.unwrap_or(&serde_json::Value::Null),
) {
Ok(true) => log::info!(
"Removed stale third-party auth.json after switching to official Codex provider '{}'",
provider.id
),
Ok(false) => {}
Err(e) => log::warn!("Failed to clean stale Codex auth.json: {e}"),
}
}
// Hermes is additive, so "switching" doesn't overwrite a live config file
// — we instead update the top-level `model:` section to point at this
// provider's first declared model. Without this, clicking "switch" would
+4 -82
View File
@@ -230,12 +230,6 @@ fn data_source_expr(log_alias: &str) -> String {
format!("COALESCE({log_alias}.data_source, 'proxy')")
}
fn dedup_app_type_match_sql(left: &str, right: &str) -> String {
format!(
"{left} IN ({right}, CASE WHEN {right} = 'claude' THEN 'claude-desktop' ELSE {right} END)"
)
}
/// SQL 标量表达式:把 Claude Desktop 网关的 `claude-desktop` app_type 在“展示口径”
/// 上折叠进 `claude`,其余 app_type 原样返回。
///
@@ -249,8 +243,8 @@ fn dedup_app_type_match_sql(left: &str, right: &str) -> String {
/// 而不改动任何已存储的行(详情面板仍读原始 `app_type`)。
///
/// 注意:包裹后该列上的索引在此比较中失效,但这些都是已带时间过滤的聚合扫描,
/// app_type 本就不是主访问路径,可接受。仅用于读侧;跨源去重使用更窄的
/// [`dedup_app_type_match_sql`]额度检查(`check_provider_limits`保留原始精确比较。
/// app_type 本就不是主访问路径,可接受。仅用于读侧;去重匹配(`has_matching_
/// proxy_usage_log`)与额度检查(`check_provider_limits`必须保留原始精确比较。
fn folded_app_type_sql(column: &str) -> String {
format!("CASE WHEN {column} = 'claude-desktop' THEN 'claude' ELSE {column} END")
}
@@ -304,8 +298,6 @@ fn push_provider_model_filters(
pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String {
let data_source = data_source_expr(log_alias);
let proxy_data_source = data_source_expr("proxy_dedup");
let app_type_match =
dedup_app_type_match_sql("proxy_dedup.app_type", &format!("{log_alias}.app_type"));
format!(
"NOT (
{data_source} IN ('session_log', 'codex_session', 'gemini_session', 'opencode_session')
@@ -313,7 +305,7 @@ pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String {
SELECT 1
FROM proxy_request_logs proxy_dedup
WHERE {proxy_data_source} = 'proxy'
AND {app_type_match}
AND proxy_dedup.app_type = {log_alias}.app_type
AND proxy_dedup.status_code >= 200
AND proxy_dedup.status_code < 300
AND proxy_dedup.input_tokens = {log_alias}.input_tokens
@@ -386,13 +378,12 @@ pub(crate) fn has_matching_proxy_usage_log(
matches!(key.app_type, "codex" | "gemini" | "opencode") && key.cache_creation_tokens == 0;
let l_data_source = data_source_expr("l");
let app_type_match = dedup_app_type_match_sql("l.app_type", "?1");
let sql = format!(
"SELECT EXISTS (
SELECT 1
FROM proxy_request_logs l
WHERE {l_data_source} = 'proxy'
AND {app_type_match}
AND l.app_type = ?1
AND l.status_code >= 200
AND l.status_code < 300
AND l.input_tokens = ?3
@@ -2472,75 +2463,6 @@ mod tests {
Ok(())
}
#[test]
fn test_matching_proxy_log_matches_claude_desktop_for_claude_session() -> Result<(), AppError> {
let conn = Connection::open_in_memory()?;
create_legacy_nullable_logs_table(&conn)?;
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, app_type, model, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, status_code, created_at, data_source
) VALUES ('desktop-proxy', 'claude-desktop', 'claude-sonnet-4-5', 100, 20, 10, 5, 200, 1000, 'proxy')",
[],
)?;
let key = DedupKey {
app_type: "claude",
model: "claude-sonnet-4-5",
input_tokens: 100,
output_tokens: 20,
cache_read_tokens: 10,
cache_creation_tokens: 5,
created_at: 1060,
};
assert!(has_matching_proxy_usage_log(&conn, &key)?);
let mut outside_window = key;
outside_window.created_at = 1_601;
assert!(!has_matching_proxy_usage_log(&conn, &outside_window)?);
let mut different_model = key;
different_model.model = "claude-opus-4-5";
assert!(!has_matching_proxy_usage_log(&conn, &different_model)?);
let mut different_input = key;
different_input.input_tokens += 1;
assert!(!has_matching_proxy_usage_log(&conn, &different_input)?);
let mut different_cache_creation = key;
different_cache_creation.cache_creation_tokens += 1;
assert!(!has_matching_proxy_usage_log(
&conn,
&different_cache_creation
)?);
Ok(())
}
#[test]
fn test_effective_filter_dedups_claude_session_against_desktop_proxy() -> Result<(), AppError> {
let conn = Connection::open_in_memory()?;
create_legacy_nullable_logs_table(&conn)?;
conn.execute_batch(
"INSERT INTO proxy_request_logs (
request_id, app_type, model, input_tokens, output_tokens,
cache_read_tokens, cache_creation_tokens, status_code, created_at, data_source
) VALUES
('desktop-proxy', 'claude-desktop', 'claude-sonnet-4-5', 100, 20, 10, 5, 200, 1000, 'proxy'),
('claude-session', 'claude', 'claude-sonnet-4-5', 100, 20, 10, 5, 200, 1060, 'session_log');",
)?;
let filter = effective_usage_log_filter("l");
let sql = format!("SELECT request_id FROM proxy_request_logs l WHERE {filter}");
let request_ids = conn
.prepare(&sql)?
.query_map([], |row| row.get::<_, String>(0))?
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(request_ids, vec!["desktop-proxy"]);
Ok(())
}
#[test]
fn test_claude_desktop_folds_into_claude_for_display() -> Result<(), AppError> {
let db = Database::memory()?;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.19.1",
"version": "3.19.0",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
-162
View File
@@ -882,168 +882,6 @@ requires_openai_auth = true
);
}
#[test]
fn provider_service_switch_codex_official_clears_stale_third_party_auth() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
// preservation stays OFF (default): switching to the third-party provider
// wrote its key into live auth.json, and that residue is what this test
// expects the official switch to clean up.
let _home = ensure_test_home();
let third_party_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
"#;
// Live key intentionally differs from the DB row so the assertion below
// proves the backfill preserved the live copy before it was deleted.
let live_auth = json!({ "OPENAI_API_KEY": "stale-live-key" });
write_codex_live_atomic(&live_auth, Some(third_party_config))
.expect("seed third-party live config");
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "third-party".to_string();
manager.providers.insert(
"third-party".to_string(),
Provider::with_id(
"third-party".to_string(),
"AiHubMix".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "old-db-key"},
"config": third_party_config
}),
None,
),
);
let mut official_provider = Provider::with_id(
"official-provider".to_string(),
"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");
ProviderService::switch(&state, AppType::Codex, "official-provider")
.expect("switch to official provider should succeed");
assert!(
!cc_switch_lib::get_codex_auth_path().exists(),
"switching to a material-less official provider must delete the stale \
third-party auth.json so Codex shows its login screen"
);
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("read providers after switch");
assert_eq!(
providers
.get("third-party")
.expect("third-party provider exists")
.settings_config
.pointer("/auth/OPENAI_API_KEY")
.and_then(|v| v.as_str()),
Some("stale-live-key"),
"the live key must be backfilled into the outgoing provider before deletion"
);
let live_config =
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
assert!(
!live_config.contains("experimental_bearer_token"),
"official provider has no API key to inject"
);
}
#[test]
fn provider_service_reswitch_current_official_keeps_live_auth() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
// Re-selecting the already-current provider performs no backfill, so the
// cleanup must not run either: without a fresh DB copy of whatever sits
// in live auth.json, deleting it would destroy the only copy.
let live_auth = json!({ "OPENAI_API_KEY": "residue-key" });
write_codex_live_atomic(&live_auth, Some("")).expect("seed live config");
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": {},
"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");
ProviderService::switch(&state, AppType::Codex, "official-provider")
.expect("re-switch to current official provider should succeed");
let auth_value: serde_json::Value =
read_json_file(&cc_switch_lib::get_codex_auth_path()).expect("auth.json must survive");
assert_eq!(
auth_value.get("OPENAI_API_KEY").and_then(|v| v.as_str()),
Some("residue-key"),
"no backfill happened, so live auth.json must be left untouched"
);
}
#[test]
fn read_codex_live_settings_tolerates_missing_auth_when_config_file_exists() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
assert!(
cc_switch_lib::read_codex_live_settings().is_err(),
"both files missing is still 'no live install'"
);
// auth.json deleted + empty config.toml is the exact state the official
// switch cleanup leaves behind; it must stay readable or the next
// backfill / hot switch would treat Codex as uninstalled.
let config_path = cc_switch_lib::get_codex_config_path();
std::fs::create_dir_all(config_path.parent().expect("codex dir")).expect("create codex dir");
std::fs::write(&config_path, "").expect("write empty config.toml");
let live = cc_switch_lib::read_codex_live_settings()
.expect("config file present but empty must be readable");
assert_eq!(live.get("auth"), Some(&json!({})));
assert_eq!(live.get("config").and_then(|v| v.as_str()), Some(""));
}
#[test]
fn reapply_codex_official_live_resyncs_mcp_servers() {
let _guard = test_mutex().lock().expect("acquire test mutex");
+62 -11
View File
@@ -1,7 +1,6 @@
import { useState, useEffect, useMemo } from "react";
import { listen } from "@tauri-apps/api/event";
import { DeepLinkImportRequest, deeplinkApi } from "@/lib/api/deeplink";
import { parseDeepLinkConfigPreview } from "@/utils/deepLinkConfigPreview";
import {
Dialog,
DialogContent,
@@ -229,10 +228,62 @@ export function DeepLinkImportDialog() {
? "url"
: null;
const parsedConfig = useMemo(
() => (request ? parseDeepLinkConfigPreview(request) : null),
[request],
);
// Parse config file content for display
interface ParsedConfig {
type: "claude" | "codex" | "gemini";
env?: Record<string, string>;
auth?: Record<string, string>;
tomlConfig?: string;
raw: Record<string, unknown>;
}
// Helper to decode base64 with UTF-8 support
const b64ToUtf8 = (str: string): string => {
try {
const binString = atob(str);
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0) || 0);
return new TextDecoder().decode(bytes);
} catch (e) {
console.error("Failed to decode base64:", e);
return atob(str);
}
};
const parsedConfig = useMemo((): ParsedConfig | null => {
if (!request?.config) return null;
try {
const decoded = b64ToUtf8(request.config);
const parsed = JSON.parse(decoded) as Record<string, unknown>;
if (request.app === "claude") {
// Claude 格式: { env: { ANTHROPIC_AUTH_TOKEN: ..., ... } }
return {
type: "claude",
env: (parsed.env as Record<string, string>) || {},
raw: parsed,
};
} else if (request.app === "codex") {
// Codex 格式: { auth: { OPENAI_API_KEY: ... }, config: "TOML string" }
return {
type: "codex",
auth: (parsed.auth as Record<string, string>) || {},
tomlConfig: (parsed.config as string) || "",
raw: parsed,
};
} else if (request.app === "gemini") {
// Gemini 格式: 扁平结构 { GEMINI_API_KEY: ..., GEMINI_BASE_URL: ... }
return {
type: "gemini",
env: parsed as Record<string, string>,
raw: parsed,
};
}
return null;
} catch (e) {
console.error("Failed to parse config:", e);
return null;
}
}, [request?.config, request?.app]);
/**
* env `maskValue`
@@ -522,11 +573,9 @@ export function DeepLinkImportDialog() {
)}
{/* Codex config */}
{(parsedConfig.type === "codex" ||
parsedConfig.type === "grokbuild") && (
{parsedConfig.type === "codex" && (
<div className="space-y-2">
{parsedConfig.type === "codex" &&
parsedConfig.auth &&
{parsedConfig.auth &&
Object.keys(parsedConfig.auth).length > 0 && (
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">
@@ -550,8 +599,10 @@ export function DeepLinkImportDialog() {
<div className="text-xs text-muted-foreground">
TOML Config:
</div>
<pre className="text-xs font-mono bg-background p-2 rounded overflow-auto max-h-24 whitespace-pre-wrap break-all">
{parsedConfig.tomlConfig}
<pre className="text-xs font-mono bg-background p-2 rounded overflow-x-auto max-h-24 whitespace-pre-wrap">
{parsedConfig.tomlConfig.substring(0, 300)}
{parsedConfig.tomlConfig.length > 300 &&
"..."}
</pre>
</div>
)}
+5 -14
View File
@@ -25,13 +25,6 @@ interface ProxyTabContentProps {
onAutoSave: (updates: Partial<SettingsFormState>) => Promise<boolean | void>;
}
export const FAILOVER_APPS = [
{ id: "claude", label: "Claude" },
{ id: "codex", label: "Codex" },
{ id: "gemini", label: "Gemini" },
{ id: "grokbuild", label: "Grok Build" },
] as const;
export function ProxyTabContent({
settings,
onAutoSave,
@@ -179,14 +172,12 @@ export function ProxyTabContent({
)}
<Tabs defaultValue="claude" className="w-full">
<TabsList className="grid w-full grid-cols-4">
{FAILOVER_APPS.map(({ id, label }) => (
<TabsTrigger key={id} value={id}>
{label}
</TabsTrigger>
))}
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="claude">Claude</TabsTrigger>
<TabsTrigger value="codex">Codex</TabsTrigger>
<TabsTrigger value="gemini">Gemini</TabsTrigger>
</TabsList>
{FAILOVER_APPS.map(({ id: appType }) => {
{(["claude", "codex", "gemini"] as const).map((appType) => {
const failoverDisabled =
!isRunning || !(takeoverStatus?.[appType] ?? false);
return (
+12 -60
View File
@@ -487,12 +487,8 @@ requires_openai_auth = true`,
"https://ark.cn-beijing.volces.com/api/coding/v3",
"ark-code-latest",
),
// ⚠️ 计费红线(官方 warning):Coding Plan 必须走 /api/coding/v3
// 填按量端点 /api/v3 不消耗套餐额度、按量另计费,绝不能混入候选
endpointCandidates: ["https://ark.cn-beijing.volces.com/api/coding/v3"],
// 官方 Codex 文档(volcengine.com/docs/82379/25560562026-07 更新):
// Coding Plan /api/coding/v3 已支持 Responses APIwire_api=responses),无需路由接管转换
apiFormat: "openai_responses",
apiFormat: "openai_chat",
modelCatalog: modelCatalog([
{
model: "ark-code-latest",
@@ -521,8 +517,6 @@ requires_openai_auth = true`,
endpointCandidates: [
"https://ark.ap-southeast.bytepluses.com/api/coding/v3",
],
// 国内站 coding/v3 已切原生 Responses(见 火山Agentplan),但 BytePlus
// 国际站(bytepluses.com)文档未单独核实,暂保持 Chat 路由
apiFormat: "openai_chat",
modelCatalog: modelCatalog([
{
@@ -975,25 +969,27 @@ requires_openai_auth = true`,
"deepseek-v4-flash",
),
endpointCandidates: ["https://api.deepseek.com"],
// DeepSeek 官方 Codex 文档(api-docs.deepseek.com → agent_integrations/codex):
// deepseek-v4-flash 原生 Responseswire_api=responses 对自家 base_url),无需路由接管转换。
// 后端按 deepseek.com host 直接镜像官方 models.jsonfreeform apply_patch +
// GPT-5 harness + low/high/max 思考档,需 codex >= 0.144.0),这里只保留行清单与展示名。
apiFormat: "openai_responses",
apiFormat: "openai_chat",
modelCatalog: modelCatalog([
{
model: "deepseek-v4-flash",
displayName: "DeepSeek V4 Flash",
contextWindow: 1048576,
contextWindow: 1000000,
},
// 官方预计 2026-08 初开通 pro 的 Codex 集成(官方 models.json 已含该条目),
// 在那之前切到 pro 会上游报错
{
model: "deepseek-v4-pro",
displayName: "DeepSeek V4 Pro",
contextWindow: 1048576,
contextWindow: 1000000,
},
]),
codexChatReasoning: {
supportsThinking: true,
supportsEffort: true,
thinkingParam: "thinking",
effortParam: "reasoning_effort",
effortValueMode: "deepseek",
outputFormat: "reasoning_content",
},
category: "cn_official",
icon: "deepseek",
iconColor: "#1E88E5",
@@ -1099,50 +1095,6 @@ requires_openai_auth = true`,
icon: "bailian",
iconColor: "#624AFF",
},
{
name: "Tencent Hunyuan",
websiteUrl: "https://cloud.tencent.com/product/tokenhub",
apiKeyUrl: "https://console.cloud.tencent.com/tokenhub/apikey",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"hy3_tokenhub",
"https://tokenhub.tencentmaas.com/v1",
"hy3",
),
// 官方备用域名 tencentmaas.cn(文档 1823/130078);国际站 tokenhub-intl
// 属不同地域,API Key 不跨站通用,不作候选
endpointCandidates: [
"https://tokenhub.tencentmaas.com/v1",
"https://tokenhub.tencentmaas.cn/v1",
],
// 腾讯 TokenHub 官方 Codex 文档(cloud.tencent.com/document/product/1823/133532):
// hy3 原生 Responseswire_api=responses;官方硬性要求的
// disable_response_storage=true 已由 generateThirdPartyConfig 输出)。
// ⚠️ 须用 TokenHub API Key(创建时范围需勾选 Hy3);Coding Plan / Token Plan
// 订阅 Key 只能走各自 chat 端点,对本预设的 /v1 不通。
// hy3 在带 tools 的请求里会把 reasoning_effort=low 服务端自动升为 high
// Codex 恒带 tools),默认 high 即真实行为。
apiFormat: "openai_responses",
// 无官方 catalog:合成 MiMo 式(shell_command 编辑、不发 freeform apply_patch
modelCatalog: modelCatalog([
{
model: "hy3",
displayName: "Hy3",
contextWindow: 256000,
// hy3 不在官方多模态理解模型名单(1823/130988),纯文本
inputModalities: ["text"],
},
{
model: "hy3-preview",
displayName: "Hy3 Preview",
contextWindow: 256000,
inputModalities: ["text"],
},
]),
category: "cn_official",
icon: "hunyuan",
iconColor: "#0055E9",
},
{
name: "StepFun",
websiteUrl: "https://platform.stepfun.com/step-plan",
+7 -16
View File
@@ -172,8 +172,7 @@
"oauthHint": "Google official uses OAuth personal authentication, no need to fill in API Key. The browser will automatically open for login on first use.",
"apiKeyPlaceholder": "Enter Gemini API Key"
}
},
"duplicateLiveIdsLoadFailed": "Failed to read provider identifiers from config, please fix the config and try again"
}
},
"claudeCode": {
"needsRouting": "Needs Routing",
@@ -222,8 +221,7 @@
"tooltip": {
"active": "Claude Desktop local routing is running - {{address}}:{{port}}",
"inactive": "Turn on Claude Desktop local routing for providers that need model mapping or format conversion. Configured address: {{address}}:{{port}}"
},
"stopBlockedByTakeover": "Another app is using proxy takeover. Please disable that app's takeover in settings before stopping local routing."
}
}
},
"notifications": {
@@ -272,8 +270,7 @@
"backfillWarning": "Switched successfully, but failed to save changes back to the previous provider",
"windowControlFailed": "Window control failed: {{error}}",
"officialBlockedByProxy": "Cannot switch to official provider while local routing is active. Using routing with official APIs may cause account bans.",
"proxyOfficialWarning": "Current provider {{name}} is official. Consider switching to a third-party provider before using local routing.",
"proxyReasonClaudeDesktop": "Using Claude Desktop local routing mode"
"proxyOfficialWarning": "Current provider {{name}} is official. Consider switching to a third-party provider before using local routing."
},
"confirm": {
"deleteProvider": "Delete Provider",
@@ -1171,8 +1168,7 @@
"fetchModelsAuthFailed": "API Key is invalid or lacks permission",
"fetchModelsNotSupported": "This provider does not support fetching model list",
"fetchModelsEndpointNotFound": "No reachable models endpoint found. Please check the Base URL or confirm whether the provider exposes this API.",
"fetchModelsTimeout": "Request timed out, please check network connection",
"requiredFields": "Please fill in provider name, API endpoint, API Key and model"
"fetchModelsTimeout": "Request timed out, please check network connection"
},
"copilot": {
"authSection": "GitHub Copilot Authentication",
@@ -1369,8 +1365,7 @@
"reasoningEffortHint": "Enable when the upstream supports thinking-depth control such as low/high/max. Enabling this also turns on thinking mode and converts Codex's reasoning.effort into the upstream Chat parameter.",
"reasoningGroupTitle": "Reasoning Capability",
"reasoningSectionHint": "Preset providers are configured automatically; custom providers are inferred from name/URL. Override manually only when auto-detection is wrong.",
"advancedSectionHint": "Includes upstream format, model mapping, reasoning overrides and custom User-Agent. Providers using the Chat Completions protocol require routing takeover to be enabled.",
"noCommonConfigToApply": "The common config snippet is empty or contains nothing to write"
"advancedSectionHint": "Includes upstream format, model mapping, reasoning overrides and custom User-Agent. Providers using the Chat Completions protocol require routing takeover to be enabled."
},
"geminiConfig": {
"envFile": "Environment Variables (.env)",
@@ -1469,7 +1464,6 @@
"totalRequests": "Total Requests",
"totalCost": "Total Cost",
"cost": "Cost",
"unpriced": "Unpriced",
"perMillion": "(per million)",
"trends": "Usage Trends",
"rangeToday": "Last 24 hours (hourly)",
@@ -2555,8 +2549,7 @@
},
"tooltip": {
"enabled": "{{app}} failover enabled\nRequests follow queue priority (P1→P2→...)",
"disabled": "Enable {{app}} failover\nSwitches to queue P1 immediately, then falls back on failures",
"takeoverRequired": "Take over {{app}} first, then enable failover"
"disabled": "Enable {{app}} failover\nSwitches to queue P1 immediately, then falls back on failures"
}
},
"proxy": {
@@ -2734,9 +2727,7 @@
},
"server": {
"started": "Routing service started - {{address}}:{{port}}",
"startFailed": "Failed to start routing service: {{detail}}",
"stopped": "Routing service stopped",
"stopFailed": "Failed to stop routing service: {{detail}}"
"startFailed": "Failed to start routing service: {{detail}}"
},
"stoppedWithRestore": "Routing service stopped, all routing configs restored",
"stopWithRestoreFailed": "Stop failed: {{detail}}"
+7 -16
View File
@@ -172,8 +172,7 @@
"oauthHint": "Google 公式は OAuth 個人認証を使用するため API Key は不要です。初回利用時にブラウザが開きます。",
"apiKeyPlaceholder": "Gemini API Key を入力"
}
},
"duplicateLiveIdsLoadFailed": "設定内のプロバイダー識別子を読み込めませんでした。設定を修正してから再度お試しください"
}
},
"claudeCode": {
"needsRouting": "ルーティングが必要",
@@ -222,8 +221,7 @@
"tooltip": {
"active": "Claude Desktop ローカルルーティングは起動中です - {{address}}:{{port}}",
"inactive": "モデルマッピングや形式変換が必要なプロバイダー向けに Claude Desktop ローカルルーティングを起動します。設定アドレス: {{address}}:{{port}}"
},
"stopBlockedByTakeover": "別のアプリがプロキシテイクオーバーを使用しています。設定で該当アプリのテイクオーバーを無効にしてから、ローカルルーティングを停止してください。"
}
}
},
"notifications": {
@@ -272,8 +270,7 @@
"backfillWarning": "切り替え成功しましたが、前のプロバイダーへの設定保存に失敗しました",
"windowControlFailed": "ウィンドウ操作に失敗しました: {{error}}",
"officialBlockedByProxy": "ローカルルーティングモード中は公式プロバイダーに切り替えできません。ルーティング経由で公式 API にアクセスするとアカウントが停止される可能性があります。",
"proxyOfficialWarning": "現在のプロバイダー {{name}} は公式です。ローカルルーティングを使用する前にサードパーティプロバイダーに切り替えてください。",
"proxyReasonClaudeDesktop": "Claude Desktop ローカルルーティングモードを使用中"
"proxyOfficialWarning": "現在のプロバイダー {{name}} は公式です。ローカルルーティングを使用する前にサードパーティプロバイダーに切り替えてください。"
},
"confirm": {
"deleteProvider": "プロバイダーを削除",
@@ -1171,8 +1168,7 @@
"fetchModelsAuthFailed": "API Key が無効か、権限がありません",
"fetchModelsNotSupported": "このプロバイダーはモデル一覧の取得に対応していません",
"fetchModelsEndpointNotFound": "利用可能なモデル一覧エンドポイントが見つかりません。Base URL を確認するか、プロバイダーが該当 API を公開しているかご確認ください",
"fetchModelsTimeout": "リクエストがタイムアウトしました。ネットワーク接続を確認してください",
"requiredFields": "プロバイダー名、API エンドポイント、API Key、モデルを入力してください"
"fetchModelsTimeout": "リクエストがタイムアウトしました。ネットワーク接続を確認してください"
},
"copilot": {
"authSection": "GitHub Copilot 認証",
@@ -1369,8 +1365,7 @@
"reasoningEffortHint": "上流が low/high/max などの思考の深さの制御に対応している場合に有効化します。有効にすると思考モードも自動的にオンになり、Codex の reasoning.effort を上流の Chat パラメータに変換します。",
"reasoningGroupTitle": "思考能力",
"reasoningSectionHint": "プリセットの供給元は自動的に設定され、カスタム供給元は名前/URL から自動推論されます。自動識別が正しくない場合のみ手動で上書きしてください。",
"advancedSectionHint": "上流フォーマット、モデルマッピング、思考能力、カスタム User-Agent の設定を含みます。Chat Completions プロトコルを使用するプロバイダーはルーティング引き継ぎの有効化が必要です。",
"noCommonConfigToApply": "共通設定スニペットが空か、書き込む内容がありません"
"advancedSectionHint": "上流フォーマット、モデルマッピング、思考能力、カスタム User-Agent の設定を含みます。Chat Completions プロトコルを使用するプロバイダーはルーティング引き継ぎの有効化が必要です。"
},
"geminiConfig": {
"envFile": "環境変数 (.env)",
@@ -1469,7 +1464,6 @@
"totalRequests": "総リクエスト数",
"totalCost": "総コスト",
"cost": "コスト",
"unpriced": "価格未設定",
"perMillion": "(100万あたり)",
"trends": "利用トレンド",
"rangeToday": "直近24時間 (時間別)",
@@ -2555,8 +2549,7 @@
},
"tooltip": {
"enabled": "{{app}} フェイルオーバーが有効\nキューの優先度(P1→P2→...)で使用します",
"disabled": "{{app}} フェイルオーバーを有効にする\nキューの P1 に即時切替し、失敗時は次を順に試行します",
"takeoverRequired": "先に {{app}} を引き継いでから、フェイルオーバーを有効にしてください"
"disabled": "{{app}} フェイルオーバーを有効にする\nキューの P1 に即時切替し、失敗時は次を順に試行します"
}
},
"proxy": {
@@ -2734,9 +2727,7 @@
},
"server": {
"started": "ルーティングサービスが開始されました - {{address}}:{{port}}",
"startFailed": "ルーティングサービスの開始に失敗しました: {{detail}}",
"stopped": "ルーティングサービスが停止しました",
"stopFailed": "ルーティングサービスの停止に失敗しました: {{detail}}"
"startFailed": "ルーティングサービスの開始に失敗しました: {{detail}}"
},
"stoppedWithRestore": "ルーティングサービスが停止し、すべてのルーティング設定が復元されました",
"stopWithRestoreFailed": "停止に失敗しました: {{detail}}"
+8 -47
View File
@@ -172,8 +172,7 @@
"oauthHint": "Google 官方使用 OAuth 個人驗證,無需填寫 API Key。首次使用時會自動開啟瀏覽器進行登入。",
"apiKeyPlaceholder": "請輸入 Gemini API Key"
}
},
"duplicateLiveIdsLoadFailed": "讀取設定中的供應商識別碼失敗,請先修復設定後再試"
}
},
"claudeCode": {
"needsRouting": "需要路由",
@@ -222,8 +221,7 @@
"tooltip": {
"active": "Claude Desktop 本地路由已開啟 - {{address}}:{{port}}",
"inactive": "開啟 Claude Desktop 本地路由,用於需要模型對應或格式轉換的供應商。目前設定位址:{{address}}:{{port}}"
},
"stopBlockedByTakeover": "其他應用正在使用代理接管。請先在設定中關閉對應應用接管,再停止本機路由。"
}
}
},
"notifications": {
@@ -272,8 +270,7 @@
"backfillWarning": "切換成功,但舊供應商設定回填失敗,您手動修改的設定可能未儲存",
"windowControlFailed": "視窗控制失敗:{{error}}",
"officialBlockedByProxy": "本地路由模式下不能切換至官方供應商,使用路由存取官方 API 可能導致帳號被封鎖",
"proxyOfficialWarning": "目前供應商 {{name}} 是官方供應商,建議切換至第三方供應商後再使用本地路由",
"proxyReasonClaudeDesktop": "使用 Claude Desktop 本機路由模式"
"proxyOfficialWarning": "目前供應商 {{name}} 是官方供應商,建議切換至第三方供應商後再使用本地路由"
},
"confirm": {
"deleteProvider": "刪除供應商",
@@ -796,39 +793,9 @@
"viewCurrentReleaseNotes": "檢視目前版本更新日誌",
"officialWebsite": "官方網站",
"github": "GitHub",
"manualInstallCommands": "手動安裝指令",
"oneClickInstall": "一鍵安裝",
"oneClickInstallHint": "安裝或更新 Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw / Hermes",
"oneClickInstallHint": "安裝 Claude Code / Codex / Gemini CLI / OpenCode",
"localEnvCheck": "本地環境檢查",
"updateAllTools": "全部更新({{count}}",
"currentVersion": "目前版本",
"latestVersion": "最新版本",
"updateAvailableShort": "可更新",
"toolInstall": "安裝",
"toolUpdate": "更新",
"toolReady": "已就緒",
"toolActionDone": "{{count}} 個工具{{action}}完成",
"toolActionPartial": "{{succeeded}} 個{{action}}成功,{{failed}} 個失敗",
"toolActionFailed": "安裝/更新指令執行失敗",
"toolNotRunnable": "已安裝但無法執行(未偵測到版本)",
"toolActionVersionUnchangedTitle": "版本未變更",
"toolActionVersionUnchanged": "仍為 {{version}}(最新版本:{{latest}})。上游更新程式可能回報成功,但實際上並未套用更新。",
"toolActionInstalledNotRunnable": "已安裝,但目前環境無法執行,請檢查",
"installedNotRunnable": "已安裝 · 無法執行",
"toolCheckEnv": "請檢查執行環境",
"toolDiagnose": "診斷安裝衝突",
"toolDiagnosing": "診斷中…",
"toolConflictTitle": "偵測到多處安裝",
"toolConflictHint": "命令列實際使用標示為「預設」的項目;更新可能已寫入其他位置。",
"toolConflictDefault": "預設",
"toolConflictNotRunnable": "無法執行",
"toolDiagnoseNoConflict": "未發現安裝衝突",
"toolDiagnoseFailed": "診斷失敗",
"toolUpgradeConfirmTitle": "確認更新位置",
"toolUpgradeConfirmHint": "偵測到多處安裝。本次更新不會更新所有安裝;請以下方各工具顯示的實際更新位置為準。",
"toolUpgradeWillRun": "將執行:",
"toolUpgradeConfirmBtn": "確認更新",
"toolUpgradeUnanchoredHint": "無法判定命令列實際使用的安裝位置;將執行預設更新指令(可能安裝至 PATH 中第一個 npm)。",
"envBadge": {
"wsl": "WSL",
"windows": "Win",
@@ -1172,8 +1139,7 @@
"fetchModelsAuthFailed": "API Key 無效或無權限",
"fetchModelsNotSupported": "該供應商不支援取得模型清單",
"fetchModelsEndpointNotFound": "未找到可用的模型清單端點,請檢查 Base URL 或確認供應商是否開放該 API",
"fetchModelsTimeout": "請求逾時,請檢查網路連線",
"requiredFields": "請填寫供應商名稱、API 位址、API Key 和模型"
"fetchModelsTimeout": "請求逾時,請檢查網路連線"
},
"copilot": {
"authSection": "GitHub Copilot 驗證",
@@ -1370,8 +1336,7 @@
"reasoningEffortHint": "上游支援 low/high/max 等思考深度控制時啟用。啟用後會自動啟用思考模式,並把 Codex 的 reasoning.effort 轉成上游 Chat 參數。",
"reasoningGroupTitle": "思考能力",
"reasoningSectionHint": "預設供應商已自動設定;自訂供應商會按名稱/位址自動推斷。僅當自動識別不準時才需手動覆寫。",
"advancedSectionHint": "包含上游格式、模型映射、思考能力與自訂 User-Agent。使用 Chat Completions 協定的供應商需開啟路由接管才能使用。",
"noCommonConfigToApply": "通用設定片段為空或沒有可寫入的內容"
"advancedSectionHint": "包含上游格式、模型映射、思考能力與自訂 User-Agent。使用 Chat Completions 協定的供應商需開啟路由接管才能使用。"
},
"geminiConfig": {
"envFile": "環境變數 (.env)",
@@ -1470,7 +1435,6 @@
"totalRequests": "總請求數",
"totalCost": "總成本",
"cost": "成本",
"unpriced": "未定價",
"perMillion": "(每百萬)",
"trends": "使用趨勢",
"rangeToday": "過去 24 小時 (按小時)",
@@ -2556,8 +2520,7 @@
},
"tooltip": {
"enabled": "{{app}} 故障轉移已啟用\n按佇列優先順序(P1→P2→...)選擇供應商",
"disabled": "啟用 {{app}} 故障轉移\n將立即切換至佇列 P1,並在失敗時自動切換至下一個",
"takeoverRequired": "請先接管 {{app}},再啟用故障轉移"
"disabled": "啟用 {{app}} 故障轉移\n將立即切換至佇列 P1,並在失敗時自動切換至下一個"
}
},
"proxy": {
@@ -2735,9 +2698,7 @@
},
"server": {
"started": "路由服務已啟動 - {{address}}:{{port}}",
"startFailed": "啟動路由服務失敗:{{detail}}",
"stopped": "路由服務已停止",
"stopFailed": "停止路由服務失敗:{{detail}}"
"startFailed": "啟動路由服務失敗:{{detail}}"
},
"stoppedWithRestore": "路由服務已關閉,已還原所有路由設定",
"stopWithRestoreFailed": "停止失敗:{{detail}}"
+7 -16
View File
@@ -172,8 +172,7 @@
"oauthHint": "Google 官方使用 OAuth 个人认证,无需填写 API Key。首次使用时会自动打开浏览器进行登录。",
"apiKeyPlaceholder": "请输入 Gemini API Key"
}
},
"duplicateLiveIdsLoadFailed": "读取配置中的供应商标识失败,请先修复配置后再试"
}
},
"claudeCode": {
"needsRouting": "需要路由",
@@ -222,8 +221,7 @@
"tooltip": {
"active": "Claude Desktop 本地路由已开启 - {{address}}:{{port}}",
"inactive": "开启 Claude Desktop 本地路由,用于需要模型映射或格式转换的供应商。当前配置地址:{{address}}:{{port}}"
},
"stopBlockedByTakeover": "其它应用正在使用代理接管。请先在设置中关闭对应应用接管,再停止本地路由。"
}
}
},
"notifications": {
@@ -272,8 +270,7 @@
"backfillWarning": "切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存",
"windowControlFailed": "窗口控制失败:{{error}}",
"officialBlockedByProxy": "本地路由模式下不能切换到官方供应商,使用路由访问官方 API 可能导致账号被封禁",
"proxyOfficialWarning": "当前供应商 {{name}} 是官方供应商,建议切换到第三方供应商后再使用本地路由",
"proxyReasonClaudeDesktop": "使用 Claude Desktop 本地路由模式"
"proxyOfficialWarning": "当前供应商 {{name}} 是官方供应商,建议切换到第三方供应商后再使用本地路由"
},
"confirm": {
"deleteProvider": "删除供应商",
@@ -1171,8 +1168,7 @@
"fetchModelsAuthFailed": "API Key 无效或无权限",
"fetchModelsNotSupported": "该供应商不支持获取模型列表",
"fetchModelsEndpointNotFound": "未找到可用的模型列表端点,请检查 Base URL 或确认供应商是否开放该接口",
"fetchModelsTimeout": "请求超时,请检查网络连接",
"requiredFields": "请填写供应商名称、API 地址、API Key 和模型"
"fetchModelsTimeout": "请求超时,请检查网络连接"
},
"copilot": {
"authSection": "GitHub Copilot 认证",
@@ -1369,8 +1365,7 @@
"reasoningEffortHint": "上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。",
"reasoningGroupTitle": "思考能力",
"reasoningSectionHint": "预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需手动覆盖。",
"advancedSectionHint": "包含上游格式、模型映射、思考能力与自定义 User-Agent。使用 Chat Completions 协议的供应商需开启路由接管才能使用。",
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容"
"advancedSectionHint": "包含上游格式、模型映射、思考能力与自定义 User-Agent。使用 Chat Completions 协议的供应商需开启路由接管才能使用。"
},
"geminiConfig": {
"envFile": "环境变量 (.env)",
@@ -1469,7 +1464,6 @@
"totalRequests": "总请求数",
"totalCost": "总成本",
"cost": "成本",
"unpriced": "未定价",
"perMillion": "(每百万)",
"trends": "使用趋势",
"rangeToday": "过去 24 小时 (按小时)",
@@ -2555,8 +2549,7 @@
},
"tooltip": {
"enabled": "{{app}} 故障转移已启用\n按队列优先级(P1→P2→...)选择供应商",
"disabled": "启用 {{app}} 故障转移\n将立即切换到队列 P1,并在失败时自动切换到下一个",
"takeoverRequired": "请先接管 {{app}},再启用故障转移"
"disabled": "启用 {{app}} 故障转移\n将立即切换到队列 P1,并在失败时自动切换到下一个"
}
},
"proxy": {
@@ -2734,9 +2727,7 @@
},
"server": {
"started": "路由服务已启动 - {{address}}:{{port}}",
"startFailed": "启动路由服务失败: {{detail}}",
"stopped": "路由服务已停止",
"stopFailed": "停止路由服务失败:{{detail}}"
"startFailed": "启动路由服务失败: {{detail}}"
},
"stoppedWithRestore": "路由服务已关闭,已恢复所有路由配置",
"stopWithRestoreFailed": "停止失败: {{detail}}"
+2 -9
View File
@@ -7,14 +7,7 @@ export interface DeepLinkImportRequest {
resource: ResourceType;
// Common fields
app?:
| "claude"
| "codex"
| "gemini"
| "grokbuild"
| "opencode"
| "openclaw"
| "hermes";
app?: "claude" | "codex" | "gemini";
name?: string;
enabled?: boolean;
@@ -34,7 +27,7 @@ export interface DeepLinkImportRequest {
description?: string;
// MCP fields
apps?: string; // Comma-separated application IDs
apps?: string; // "claude,codex,gemini"
// Skill fields
repo?: string;
+2 -2
View File
@@ -7,7 +7,7 @@ import type { EnvConflict, BackupInfo } from "@/types/env";
/**
*
* @param appType ("claude" | "codex" | "gemini" | "grokbuild")
* @param appType ("claude" | "codex" | "gemini")
* @returns
*/
export async function checkEnvConflicts(
@@ -42,7 +42,7 @@ export async function restoreEnvBackup(backupPath: string): Promise<void> {
export async function checkAllEnvConflicts(): Promise<
Record<string, EnvConflict[]>
> {
const apps = ["claude", "codex", "gemini", "grokbuild"];
const apps = ["claude", "codex", "gemini"];
const results: Record<string, EnvConflict[]> = {};
await Promise.all(
+2 -11
View File
@@ -13,10 +13,6 @@ function toStandardBase64Alphabet(value: string): string {
return value.replace(/ /g, "+").replace(/-/g, "+").replace(/_/g, "/");
}
function trimOuterLineBreaks(value: string): string {
return value.replace(/^[\r\n]+|[\r\n]+$/g, "");
}
/**
* Decode Base64 encoded UTF-8 string
*
@@ -31,10 +27,7 @@ function trimOuterLineBreaks(value: string): string {
*/
export function decodeBase64Utf8(str: string): string {
try {
// Keep spaces intact until they are restored to `+`. Using `trim()` here
// would discard a URL-decoded `+` at either edge and diverge from the
// backend decoder, which trims only CR/LF characters.
let cleaned = toStandardBase64Alphabet(trimOuterLineBreaks(str));
let cleaned = toStandardBase64Alphabet(str.trim());
// Try to decode with standard Base64 first
try {
@@ -55,9 +48,7 @@ export function decodeBase64Utf8(str: string): string {
console.error("Base64 decode error:", e, "Input:", str);
// Last resort fallback using deprecated but sometimes working method
try {
return decodeURIComponent(
escape(atob(toStandardBase64Alphabet(trimOuterLineBreaks(str)))),
);
return decodeURIComponent(escape(atob(toStandardBase64Alphabet(str))));
} catch {
// If all else fails, return original string
return str;
-95
View File
@@ -1,95 +0,0 @@
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import type { DeepLinkImportRequest } from "@/lib/api/deeplink";
import { decodeBase64Utf8 } from "@/lib/utils/base64";
import { isSensitiveConfigKey, maskSensitiveValue } from "@/utils/deeplinkRisk";
export interface ParsedDeepLinkConfig {
type: "claude" | "codex" | "gemini" | "grokbuild";
env?: Record<string, string>;
auth?: Record<string, string>;
tomlConfig?: string;
}
const maskStructuredSecrets = (
value: unknown,
key = "",
inheritedSensitive = false,
): unknown => {
const sensitive = inheritedSensitive || isSensitiveConfigKey(key);
if (typeof value === "string") {
return sensitive ? maskSensitiveValue(value) : value;
}
if (Array.isArray(value)) {
return value.map((item) => maskStructuredSecrets(item, key, sensitive));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(
([childKey, childValue]) => [
childKey,
maskStructuredSecrets(childValue, childKey, sensitive),
],
),
);
}
return value;
};
const sanitizeTomlForPreview = (configToml: string): string => {
const parsed = parseToml(configToml) as Record<string, unknown>;
return `${stringifyToml(maskStructuredSecrets(parsed) as Record<string, unknown>).trim()}\n`;
};
export function parseDeepLinkConfigPreview(
request: Pick<DeepLinkImportRequest, "app" | "config" | "configFormat">,
): ParsedDeepLinkConfig | null {
if (!request.config) return null;
try {
const decoded = decodeBase64Utf8(request.config);
const format = request.configFormat?.trim().toLowerCase();
if (request.app === "grokbuild" && format === "toml") {
return {
type: "grokbuild",
tomlConfig: sanitizeTomlForPreview(decoded),
};
}
const parsed = JSON.parse(decoded) as Record<string, unknown>;
if (request.app === "claude") {
return {
type: "claude",
env: (parsed.env as Record<string, string>) || {},
};
}
if (request.app === "codex") {
const config = typeof parsed.config === "string" ? parsed.config : "";
return {
type: "codex",
auth: (parsed.auth as Record<string, string>) || {},
tomlConfig: config ? sanitizeTomlForPreview(config) : "",
};
}
if (request.app === "gemini") {
return {
type: "gemini",
env: parsed as Record<string, string>,
};
}
if (request.app === "grokbuild") {
const config =
typeof parsed.config === "string"
? parsed.config
: stringifyToml(parsed);
return {
type: "grokbuild",
tomlConfig: sanitizeTomlForPreview(config),
};
}
return null;
} catch (error) {
console.error("Failed to parse deep link config preview:", error);
return null;
}
}
+3 -10
View File
@@ -194,19 +194,12 @@ describe("decodeDeeplinkPayload", () => {
describe("maskValue", () => {
it("masks credential-shaped keys but keeps ordinary values readable", () => {
expect(maskValue("ANTHROPIC_AUTH_TOKEN", "sk-ant-1234567890abcdef")).toBe(
"sk-a************",
"sk-ant-1************",
);
expect(maskValue("ANTHROPIC_BASE_URL", "https://example.com")).toBe(
"https://example.com",
);
expect(maskValue("API_KEY", "short")).toBe("****");
expect(maskValue("Authorization", "Basic abcd")).not.toContain("abcd");
expect(maskValue("Cookie", "sid=1234")).toBe("****");
expect(maskValue("Credential", "credential-value")).not.toContain(
"credential-value",
);
expect(maskValue("auth", "short")).toBe("****");
expect(maskValue("bearer", "short")).toBe("****");
expect(maskValue("API_KEY", "")).toBe("");
// 短值不脱敏,否则连"是不是空的"都看不出来
expect(maskValue("API_KEY", "short")).toBe("short");
});
});
+6 -29
View File
@@ -7,34 +7,6 @@
export type RiskKind = "envHijack" | "privateEndpoint" | "shellCommand";
const SENSITIVE_CONFIG_KEY_MARKERS = [
"TOKEN",
"KEY",
"SECRET",
"PASSWORD",
"AUTHORIZATION",
"COOKIE",
"CREDENTIAL",
];
const SENSITIVE_CONFIG_KEY_NAMES = new Set(["AUTH", "BEARER"]);
export function isSensitiveConfigKey(key: string): boolean {
const normalizedKey = key.toUpperCase();
return (
SENSITIVE_CONFIG_KEY_NAMES.has(normalizedKey) ||
SENSITIVE_CONFIG_KEY_MARKERS.some((marker) =>
normalizedKey.includes(marker),
)
);
}
export function maskSensitiveValue(value: string): string {
if (value.length === 0) return value;
return value.length > 8
? `${value.substring(0, 4)}${"*".repeat(12)}`
: "****";
}
/**
*
*
@@ -227,7 +199,12 @@ export function classifyCommand(
*
*/
export function maskValue(key: string, value: string): string {
return isSensitiveConfigKey(key) ? maskSensitiveValue(value) : value;
const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"];
const isSensitive = sensitiveKeys.some((k) => key.toUpperCase().includes(k));
if (isSensitive && value.length > 8) {
return `${value.substring(0, 8)}${"*".repeat(12)}`;
}
return value;
}
/** 风险种类 → i18n key。 */
@@ -1,13 +0,0 @@
import { describe, expect, it } from "vitest";
import { FAILOVER_APPS } from "@/components/settings/ProxyTabContent";
describe("ProxyTabContent failover apps", () => {
it("exposes Grok Build alongside the existing failover applications", () => {
expect(FAILOVER_APPS.map(({ id }) => id)).toEqual([
"claude",
"codex",
"gemini",
"grokbuild",
]);
});
});
+17 -20
View File
@@ -10,8 +10,13 @@ const expectedChatPresets = new Map<
string,
{ baseUrl: string; contextWindows: Record<string, number> }
>([
// 火山Agentplan(国内站 coding/v3)已切原生 Responses,见下方 native 清单;
// BytePlus 国际站未核实,保持 Chat 路由
[
"火山Agentplan",
{
baseUrl: "https://ark.cn-beijing.volces.com/api/coding/v3",
contextWindows: { "ark-code-latest": 256000 },
},
],
[
"BytePlus",
{
@@ -19,6 +24,16 @@ const expectedChatPresets = new Map<
contextWindows: { "ark-code-latest": 256000 },
},
],
[
"DeepSeek",
{
baseUrl: "https://api.deepseek.com",
contextWindows: {
"deepseek-v4-flash": 1000000,
"deepseek-v4-pro": 1000000,
},
},
],
[
"Zhipu GLM",
{
@@ -151,29 +166,11 @@ describe("Codex Chat provider presets", () => {
string,
{ contextWindows: Record<string, number> }
>([
// 官方 Codex 文档确认 Coding Plan /api/coding/v3 支持 Responses API
["火山Agentplan", { contextWindows: { "ark-code-latest": 256000 } }],
[
"DouBaoSeed",
{ contextWindows: { "doubao-seed-2-1-pro-260628": 262144 } },
],
["Bailian", { contextWindows: { "qwen3-coder-plus": 1048576 } }],
// 腾讯 TokenHub 官方 Codex 文档确认 hy3 原生 Responses2026-07-14
[
"Tencent Hunyuan",
{ contextWindows: { hy3: 256000, "hy3-preview": 256000 } },
],
// DeepSeek 官方 Codex 文档确认 deepseek-v4-flash 原生 Responses
// catalog 由后端按 deepseek.com host 镜像官方 models.json 生成
[
"DeepSeek",
{
contextWindows: {
"deepseek-v4-flash": 1048576,
"deepseek-v4-pro": 1048576,
},
},
],
["Longcat", { contextWindows: { "LongCat-2.0": 1048576 } }],
["MiniMax", { contextWindows: { "MiniMax-M3": 1000000 } }],
["MiniMax en", { contextWindows: { "MiniMax-M3": 1000000 } }],
@@ -1,83 +0,0 @@
import { describe, expect, it } from "vitest";
import en from "@/i18n/locales/en.json";
import ja from "@/i18n/locales/ja.json";
import zhTW from "@/i18n/locales/zh-TW.json";
import zh from "@/i18n/locales/zh.json";
const requiredKeys = [
"manualInstallCommands",
"updateAllTools",
"currentVersion",
"latestVersion",
"updateAvailableShort",
"toolInstall",
"toolUpdate",
"toolReady",
"toolActionDone",
"toolActionPartial",
"toolActionFailed",
"toolNotRunnable",
"toolActionVersionUnchangedTitle",
"toolActionVersionUnchanged",
"toolActionInstalledNotRunnable",
"installedNotRunnable",
"toolCheckEnv",
"toolDiagnose",
"toolDiagnosing",
"toolConflictTitle",
"toolConflictHint",
"toolConflictDefault",
"toolConflictNotRunnable",
"toolDiagnoseNoConflict",
"toolDiagnoseFailed",
"toolUpgradeConfirmTitle",
"toolUpgradeConfirmHint",
"toolUpgradeWillRun",
"toolUpgradeConfirmBtn",
"toolUpgradeUnanchoredHint",
] as const;
type SettingsTranslations = Record<string, unknown>;
const locales = [
["en", en.settings],
["ja", ja.settings],
["zh", zh.settings],
["zh-TW", zhTW.settings],
] as const;
function interpolationVariables(value: string): string[] {
return Array.from(
value.matchAll(/\{\{([^}]+)\}\}/g),
([, name]) => name,
).sort();
}
describe("About tool management locale coverage", () => {
it.each(locales)(
"defines every tool management key in %s",
(_locale, settings) => {
const missing = requiredKeys.filter((key) => {
const value = (settings as SettingsTranslations)[key];
return typeof value !== "string" || value.trim().length === 0;
});
expect(missing).toEqual([]);
},
);
it.each(locales.slice(1))(
"preserves interpolation variables in %s",
(_locale, settings) => {
for (const key of requiredKeys) {
const expected = en.settings[key];
const actual = (settings as SettingsTranslations)[key];
expect(typeof actual).toBe("string");
expect(interpolationVariables(actual as string)).toEqual(
interpolationVariables(expected),
);
}
},
);
});
+1 -1
View File
@@ -218,7 +218,7 @@ describe("App integration with MSW", () => {
expect(toastErrorMock).not.toHaveBeenCalled();
expect(toastSuccessMock).toHaveBeenCalled();
}, 10_000);
});
it("shows toast when auto sync fails in background", async () => {
const { default: App } = await import("@/App");
+1 -4
View File
@@ -82,10 +82,7 @@ describe("syncModelsDevPricing", () => {
});
it("skips startup network access when pricing synced within the interval", async () => {
// Keep a meaningful margin inside the interval. A 1 ms margin races the
// async mocked config lookup and makes this test depend on machine load.
const lastSyncAt =
Date.now() - MODELS_DEV_STARTUP_SYNC_INTERVAL_MS + 60_000;
const lastSyncAt = Date.now() - MODELS_DEV_STARTUP_SYNC_INTERVAL_MS + 1;
getModelsDevSyncConfig.mockResolvedValue({
...state,
config: { ...state.config, lastSyncAt },
-1
View File
@@ -44,7 +44,6 @@ export const handlers = [
http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () =>
success(null),
),
http.post(`${TAURI_ENDPOINT}/list_profiles`, () => success([])),
http.post(`${TAURI_ENDPOINT}/get_providers`, async ({ request }) => {
const { app } = await withJson<{ app: AppId }>(request);
return success(getProviders(app));
-146
View File
@@ -1,146 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseDeepLinkConfigPreview } from "@/utils/deepLinkConfigPreview";
const encodeBase64 = (value: string) =>
btoa(String.fromCharCode(...new TextEncoder().encode(value)));
const encodeUrlSafeBase64WithoutPadding = (value: string) =>
encodeBase64(value)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const grokConfig = `[models]
default = "grok-4.5"
[model."grok-4.5"]
model = "grok-4.5"
base_url = "https://relay.example/v1"
name = "Relay"
api_key = "secret-grok-key"
api_backend = "responses"
context_window = 500000
`;
describe("parseDeepLinkConfigPreview", () => {
it("previews direct Grok Build TOML and masks its API key", () => {
const preview = parseDeepLinkConfigPreview({
app: "grokbuild",
config: encodeBase64(grokConfig),
configFormat: "toml",
});
expect(preview?.type).toBe("grokbuild");
expect(preview?.tomlConfig).toContain("https://relay.example/v1");
expect(preview?.tomlConfig).toContain("secr************");
expect(preview?.tomlConfig).not.toContain("secret-grok-key");
});
it("previews wrapped Grok Build config JSON", () => {
const preview = parseDeepLinkConfigPreview({
app: "grokbuild",
config: encodeBase64(JSON.stringify({ config: grokConfig })),
configFormat: "json",
});
expect(preview?.type).toBe("grokbuild");
expect(preview?.tomlConfig).toContain('default = "grok-4.5"');
expect(preview?.tomlConfig).not.toContain("secret-grok-key");
});
it("previews URL-safe, unpadded, and space-normalized Grok Build TOML", () => {
let config = "";
for (let shift = 0; shift < 12; shift += 1) {
config = `${grokConfig}\n# ${"p".repeat(shift)}🚀`;
const candidate = encodeBase64(config);
if (candidate.includes("+") && /=+$/.test(candidate)) break;
}
const standard = encodeBase64(config);
const encoded = encodeUrlSafeBase64WithoutPadding(config);
const encodedWithSpaces = standard.replace(/\+/g, " ");
expect(standard).toContain("+");
expect(standard).toMatch(/=+$/);
expect(encoded).toContain("-");
expect(encoded).not.toMatch(/=$/);
expect(encodedWithSpaces).toContain(" ");
const urlSafePreview = parseDeepLinkConfigPreview({
app: "grokbuild",
config: encoded,
configFormat: "toml",
});
const spaceNormalizedPreview = parseDeepLinkConfigPreview({
app: "grokbuild",
config: encodedWithSpaces,
configFormat: "toml",
});
for (const preview of [urlSafePreview, spaceNormalizedPreview]) {
expect(preview?.type).toBe("grokbuild");
expect(preview?.tomlConfig).toContain("https://relay.example/v1");
expect(preview?.tomlConfig).not.toContain("secret-grok-key");
}
});
it("masks authentication headers in nested TOML", () => {
const config = `${grokConfig}
[mcp.servers.example]
url = "https://mcp.example"
headers = { Authorization = "Bearer top-secret", Cookie = "session=secret", credential = "credential-secret", auth = "auth-secret", safe_header = "visible" }
`;
const preview = parseDeepLinkConfigPreview({
app: "grokbuild",
config: encodeBase64(config),
configFormat: "toml",
});
expect(preview?.tomlConfig).not.toContain("Bearer top-secret");
expect(preview?.tomlConfig).not.toContain("session=secret");
expect(preview?.tomlConfig).not.toContain("credential-secret");
expect(preview?.tomlConfig).not.toContain("auth-secret");
expect(preview?.tomlConfig).toContain("visible");
});
it("inherits sensitivity through nested tables and arrays of tables", () => {
const config = `${grokConfig}
[model."grok-4.5".auth]
value = "nested-auth-secret"
short = "tiny"
empty = ""
nested = { value = "inline-nested-secret" }
[[model."grok-4.5".credentials]]
value = "array-table-secret"
`;
const preview = parseDeepLinkConfigPreview({
app: "grokbuild",
config: encodeBase64(config),
configFormat: "toml",
});
expect(preview?.tomlConfig).not.toContain("nested-auth-secret");
expect(preview?.tomlConfig).not.toContain("inline-nested-secret");
expect(preview?.tomlConfig).not.toContain("array-table-secret");
expect(preview?.tomlConfig).toContain("nest************");
expect(preview?.tomlConfig).toContain("inli************");
expect(preview?.tomlConfig).toContain("arra************");
expect(preview?.tomlConfig).toContain('short = "****"');
expect(preview?.tomlConfig).toContain('empty = ""');
});
it("also masks secrets in Codex TOML previews", () => {
const preview = parseDeepLinkConfigPreview({
app: "codex",
config: encodeBase64(
JSON.stringify({
auth: { OPENAI_API_KEY: "secret-auth-key" },
config: 'experimental_bearer_token = "secret-config-key"',
}),
),
configFormat: "json",
});
expect(preview?.type).toBe("codex");
expect(preview?.tomlConfig).not.toContain("secret-config-key");
});
});