Compare commits

..

30 Commits

Author SHA1 Message Date
Jason 6cf49ee032 fix(proxy/gemini): prefer exact tool-call id over normalized-name fallback
The shadow-turn matcher used a three-branch `||` chain (id / full name /
normalized name). When two tools share a suffix (e.g. `server_a:search`
and `server_b:search`), the normalized-name clause could short-circuit
on an earlier turn whose id is actually wrong for the incoming tool_use,
mis-routing replay state (functionCall id / thoughtSignature) for later
tool_result resolution.

Split matching into two layers: when the incoming message carries any
tool_use ids, run id-based lookup first and return on the earliest hit.
Only fall back to full-name / normalized-name matching when the incoming
ids are absent or none of them resolve.

Add two regressions:

- shadow_replay_prefers_exact_id_match_over_normalized_name_collision
  Two shadow turns with colliding normalized names and two assistant
  messages whose ids cross the positional order; asserts each message
  replays the id-correct shadow turn (including thoughtSignature).

- shadow_replay_falls_back_to_name_when_ids_absent
  Shadow turn with no id and incoming tool_use with an empty id;
  asserts the name fallback still populates the replayed part.
2026-04-16 22:31:49 +08:00
Jason 06ff5e211a fix(proxy/gemini): reconcile synthesized tool-call ids with later real ids + preserve thoughtSignature
Three related findings on `streaming_gemini.rs` for Gemini's cumulative
`streamGenerateContent` stream, all centered on `merge_tool_call_snapshots`:

1. (P1) Match upgraded tool-call IDs by position.
   When Gemini delivers a `functionCall` without an id on chunk 1
   (cc-switch synthesizes `gemini_synth_*`) and then upgrades it to a
   real id on chunk 2, the `Some(incoming_id)` branch only matched by
   id and missed the existing synthesized snapshot. A second entry
   would be pushed, yielding duplicate `tool_use` content blocks at
   stream end — one with the synthesized id, one with the real id —
   which could trigger duplicate tool execution and break tool_result
   correlation. Add a positional fallback: when no id match exists but
   the same-position slot holds a synthesized id, merge into it.
   `or(preserved_id)` already lets the real id win the merge.

2. (P2) Preserve prior thoughtSignature when merging snapshots.
   `tool_call_snapshots[index] = tool_call` overwrote the slot
   entirely, dropping any `thoughtSignature` captured on an earlier
   chunk if the current cumulative snapshot omitted it. Since
   `build_shadow_assistant_parts` writes `thoughtSignature` into the
   shadow turn from `tool_call.thought_signature`, a dropped signature
   would cause later replay requests to Gemini to be rejected with
   invalid-signature errors. Preserve the existing signature when the
   incoming chunk does not carry one.

3. (P2) Document the part-order streaming trade-off.
   All `tool_use` content blocks are emitted after the final text
   `content_block_stop`, so interleaved [text, functionCall, text,
   functionCall] parts arrive at the Anthropic client as [text(concat),
   tool_use, tool_use] — different from the non-streaming transformer,
   which preserves part order. This is intentional given the cumulative
   snapshot model and the consumers we target (claude-code-like clients
   don't depend on strict interleaving for tool execution correctness).
   Add a block comment at the flush site describing the trade-off and
   what a strict-order fix would entail, so this isn't rediscovered as
   a bug later.

Regression tests:
- upgraded_real_id_merges_into_existing_synthesized_snapshot
- thought_signature_preserved_when_later_chunk_omits_it

Test count: 868 -> 870. clippy 1.95 clean. fmt clean.
2026-04-16 22:02:10 +08:00
Jason 1b0e259492 chore(lint): address clippy 1.95 findings in existing modules
CI upgraded to Rust 1.95 and flagged ten pre-existing warnings that
older toolchains did not enforce. None relate to the Gemini proxy
integration PR itself but they block CI on the feature branch, so
clean them up here as a separate commit for easy review:

collapsible_match:
- proxy/providers/gemini_schema.rs: `"items" if value.is_object()`
  match guard instead of nested if.
- proxy/providers/transform_responses.rs: fold
  `map_responses_stop_reason`'s `"completed"` / `"incomplete"` arms
  into match guards, relying on the existing `_ => "end_turn"` fall-
  through for non-matching guard conditions (semantics preserved).
- services/session_usage_codex.rs: fold
  `"session_meta" if state.session_id.is_none()` guard, relying on
  the existing `_ => {}` fall-through.

unnecessary_sort_by:
- services/provider/endpoints.rs: `sort_by_key(|ep| Reverse(ep.added_at))`.
- services/skill.rs (backup list): same Reverse idiom on `created_at`.
- services/skill.rs (skill listings x2): `sort_by_key(|s| s.name.to_lowercase())`.

useless_conversion:
- services/skill.rs: drop the explicit `.into_iter()` on `zip`'s argument.

while_let_loop:
- services/webdav_auto_sync.rs: `while let Some(wait_for) = ...`
  instead of `loop { let Some(...) = ... else { break }; ... }`.

All changes are mechanical and preserve behavior. `cargo test --lib`
remains green (868 passed).
2026-04-16 21:37:02 +08:00
Jason 6b2e5e07cc test(proxy/gemini): pin non-full-URL versioned relay base stripping
Adds two regression tests that lock in the intentional asymmetry
between full-URL and non-full-URL modes:

- Full-URL mode: opaque base path (e.g. `https://relay.example/custom/v1beta`)
  is preserved verbatim. Already covered by
  `preserves_opaque_full_url_with_bare_v1beta_suffix`.
- Non-full-URL mode: base path MUST strip `/v1`, `/v1beta`, etc. so the
  standard `/v1beta/models/{model}:method` endpoint can be appended
  without producing a doubled `/v1beta/v1beta/models/...` path.

The non-full-URL contract is "base URL + cc-switch appends the
canonical Gemini endpoint". A user who needs a relay's custom
namespace (e.g. `/v1/models/...`) must use full-URL mode and paste
the complete method path. This commit adds regression coverage so a
future attempt to mirror full-URL's host-whitelist gating into
`normalize_gemini_base_path` will fail the test suite immediately.
2026-04-16 21:36:48 +08:00
Jason 3349189864 Revert "fix(proxy/gemini): gate generic REST suffix stripping behind Google host in non-full-URL mode"
This reverts commit d19ff09cb7.
2026-04-16 21:32:04 +08:00
Jason d19ff09cb7 fix(proxy/gemini): gate generic REST suffix stripping behind Google host in non-full-URL mode
`build_gemini_native_url` unconditionally stripped `/v1`, `/v1beta`,
`/models`, and `/openai` suffixes from the base path regardless of
host. This worked for Google's own endpoints but silently rewrote
third-party relay URLs like `https://relay.example/custom/v1` to
`.../custom/v1beta/models/...`, breaking any relay that mounts its
Gemini-compatible namespace under a versioned prefix.

The result was also asymmetric with the previously-fixed full-URL
branch: toggling the "full URL" switch changed the outbound URL for
the same base_url, which is exactly the kind of invisible behavior
that makes debugging proxy deployments painful.

Align `normalize_gemini_base_path` with
`should_normalize_gemini_full_url`'s layered model:

- Unconditional: `/models/...:method` structured paths and deep
  OpenAI-compat endpoints (`/openai/chat/completions`,
  `/openai/responses` and their versioned variants) — these are
  unambiguous Gemini-specific grammar on any host.
- Google-host gated: generic `/v1`, `/v1beta`, `/models`, `/openai`
  suffixes only get stripped on `generativelanguage.googleapis.com`,
  `aiplatform.googleapis.com`, or `*-aiplatform.googleapis.com`.
  Other hosts preserve the prefix verbatim so relays keep their
  intended routing.

Adds seven regression tests for the non-full-URL flow: opaque relay
preservation (v1 / v1beta / models / openai suffix variants), Google
host normalization (counter-case), and boundary cases (structured
method path and deep OpenAI-compat endpoint stripped regardless of
host).

Test count: 864 -> 873.
2026-04-16 21:20:59 +08:00
Jason 6ef8d2f56a fix(proxy/gemini): trim API key before provider-type detection and OAuth parsing
Leading whitespace on a copied oauth_creds.json (e.g. trailing newline
when the user copies the file content as-is) would slip past the
`starts_with("ya29.") || starts_with('{')` prefix check in
`ClaudeAdapter::provider_type`, causing the provider to be misclassified
as raw-API-key Gemini and fall back to `x-goog-api-key` with the raw
JSON as the key — which upstream rejects with 401.

The frontend's `handleApiKeyChange` already trims on keystrokes but
deep-link imports, the JSON editor, and live-config backfill all bypass
that path. Trim at every backend extraction point so the coverage is
uniform:

- `ClaudeAdapter::extract_key` (5 env / fallback branches) gets
  `.map(str::trim)` before `.filter(|s| !s.is_empty())` so that
  whitespace-only values are also treated as missing.
- `GeminiAdapter::extract_key_raw` gets the same chain (including
  the `.filter` it was missing before).
- `GeminiAdapter::parse_oauth_credentials` gets a defensive
  `let key = key.trim();` at the entry as a belt-and-suspenders guard.

Adds two regression tests covering JSON and bare `ya29.` keys with
leading newline/space.
2026-04-16 21:20:51 +08:00
Jason 7a5c3725b0 fix(proxy/gemini): gate /v1beta behind Google host + normalize models/ model id prefix
Two related P2 corrections to the Gemini Native URL surface, both
folding into the existing Google-host-whitelist architecture.

## P2a — `/v1beta` suffix should not unconditionally trigger rewrite

`should_normalize_gemini_full_url` placed `/v1beta` and `/v1beta/models`
in the unconditional layer on the reasoning that `/v1beta` is
Google-specific. In practice an opaque relay fronting a non-Gemini
service at `https://relay.example/custom/v1beta` would still be
silently rewritten to `/v1beta/models/{model}:generateContent`,
breaking the deployment.

Move `/v1beta`, `/v1beta/models`, and `/v1beta/openai` into the
Google-host gated layer alongside `/v1`, `/models`, and friends. The
unconditional layer now only accepts paths whose grammar is
intrinsically Gemini — `/models/...:generateContent` method calls and
the deep OpenAI-compat endpoints like `/openai/chat/completions` and
`/openai/responses`. Pasted AI-Studio URLs such as
`https://generativelanguage.googleapis.com/v1beta` still normalize
because the host matches the whitelist.

## P2b — `model: "models/gemini-2.5-pro"` produced doubled path prefix

Gemini SDKs (and the official `list_models` response) commonly surface
model ids in resource-name form `models/gemini-2.5-pro`. Raw
interpolation into `format!("/v1beta/models/{model}:...")` produced
`/v1beta/models/models/gemini-2.5-pro:streamGenerateContent` which
upstream rejects — yielding false-negative health checks for otherwise
valid provider configs.

Introduce `normalize_gemini_model_id(&str) -> &str` in `gemini_url`
as the single source of truth: strips an optional leading `/` then an
optional `models/` prefix, leaving bare ids untouched. Apply in the
three call sites that build a Gemini method URL:
- `services/stream_check.rs::resolve_claude_stream_url` (unified path)
- `services/stream_check.rs::check_gemini_stream` (Gemini-only path)
- `proxy/forwarder.rs::rewrite_claude_transform_endpoint` (production)

Tests (9 new):
- `gemini_url`: 3 regressions for opaque vs Google-host `/v1beta*`
  handling + 5 unit tests pinning `normalize_gemini_model_id` behavior
  (strip prefix, leave bare id, preserve nested slashes past the one
  stripped prefix, tolerate leading slash, pass through empty input).
- `stream_check`: one end-to-end regression confirming
  `models/gemini-2.5-pro` collapses to the expected single-prefix URL.
- `forwarder`: one end-to-end regression on the production rewrite
  path.

All 864 lib tests pass; cargo fmt + clippy -D warnings clean.

Addresses Codex P2 feedback on #1918.
2026-04-16 20:50:07 +08:00
Jason 1e04f68745 refactor(proxy/gemini): share build_anthropic_usage between stream and non-stream paths
`streaming_gemini::anthropic_usage_from_gemini` and
`transform_gemini::build_anthropic_usage` were byte-for-byte identical
(32 lines each) — both converting Gemini `usageMetadata` into the
Anthropic `usage` shape including `cache_read_input_tokens` mapping.

Promote the non-streaming version to `pub(crate)` and reuse it from the
streaming SSE converter. Removes ~30 lines of duplication and guarantees
the two paths cannot drift apart.

No behavioral change; all 854 lib tests pass; cargo fmt + clippy -D
warnings clean.
2026-04-16 17:50:58 +08:00
Jason b02b8f3b1b fix(proxy/gemini): align shadow id with client-visible id in non-streaming path
When Gemini returns a `functionCall` without an id (common in 2.x
parallel calls), `gemini_to_anthropic_with_shadow_and_hints` previously
generated TWO independent synthesized UUIDs:

  1. Line 186-197 — synthesized id `A` used for the Anthropic-visible
     `content[tool_use].id` returned to the client.
  2. Line 850-881 — `extract_tool_call_meta` independently synthesized
     id `B ≠ A`, which populated `shadow_turn.tool_calls[i].id`.

`shadow_content` (line 225-228, cloned from `rectified_parts`) retained
the original missing/empty id. Result: the client sees id `A`, the
shadow store holds id `B`.

On the next turn, `convert_messages_to_contents` builds
`tool_name_by_id` from `build_tool_name_map_from_shadow_turns`, which
uses `tool_calls[i].id` — so the map contains `B → name` but not
`A → name`. When the client sends back `tool_result(tool_use_id=A)`,
resolution fails with:

  Unable to resolve Gemini functionResponse.name for tool_use_id `A`

This affects both truncated histories (client sends only the
tool_result) and full histories (shadow-replay branch at line 342-354
skips `convert_message_content_to_parts`, so the assistant tool_use
block never registers id `A` itself).

Fix: make `rectified_parts` the single source of truth. After
`rectify_tool_call_parts`, run a pre-pass that writes
`synthesize_tool_call_id()` back into any `functionCall` that lacks a
non-empty id. All three readers — the content builder (186-197), the
shadow_content clone (225-228), and `extract_tool_call_meta` — then
observe the same id. `shadow_parts()` already strips synthesized ids on
replay (line 616-628), so the internal identifier never leaks to
Gemini upstream.

This mirrors the streaming path, which already has single-source-of-
truth semantics via `tool_call_snapshots` in `streaming_gemini.rs` —
no change needed there.

Tests (5 new in `transform_gemini::tests`):
- `non_stream_shadow_id_matches_client_visible_id`: asserts
  `response.content[0].id == shadow.tool_calls[0].id ==
  shadow.assistant_content.parts[0].functionCall.id`.
- `non_stream_missing_id_scenario_a_truncated_history_resolves`: turn 2
  sends only `[tool_result(id=A)]`; resolution must succeed.
- `non_stream_missing_id_scenario_b_full_history_replay_resolves`: turn 2
  sends `[assistant(tool_use=A), tool_result(A)]`; shadow-replay branch
  strips the synth id from outgoing `functionCall` while still
  resolving the subsequent `tool_result`.
- `non_stream_preserves_original_gemini_id_when_present`: regression —
  genuine Gemini ids flow through unchanged.
- `non_stream_synthesized_id_not_leaked_to_gemini_via_shadow_replay`:
  defensive — shadow-replay path must strip synth ids from both
  `functionCall.id` and `functionResponse.id`.

All 854 lib tests pass; cargo fmt + clippy -D warnings clean.

Addresses Codex follow-up P1 on #1918.
2026-04-16 17:38:07 +08:00
Jason 1f3ebe9c62 fix(proxy/gemini): gate generic REST path suffixes behind Google host whitelist
`should_normalize_gemini_full_url` previously treated any full URL whose
path ends with `/v1`, `/v1/models`, `/models`, `/v1/openai`, or `/openai`
as a structured Gemini endpoint and rewrote it to
`/v1beta/models/{model}:generateContent`. These are ubiquitous REST
conventions — opaque relays such as `https://relay.example/custom/v1`
legitimately use them for fixed endpoints — so the rewrite silently
routed traffic to the wrong upstream path.

Split the predicate into two layers:

- **Unconditional**: `matches_structured_gemini_models_path` (i.e. a
  `/models/...:generateContent` method call anywhere in the path), the
  Google-specific `/v1beta*` family, and the deep OpenAI-compat paths
  (`/v1beta/openai/chat/completions`, `/openai/chat/completions`, and
  their `responses` siblings). These remain host-agnostic because the
  path grammar itself is Gemini-specific.
- **Google-host gated**: `/v1`, `/v1/models`, `/models`, `/v1/openai`,
  `/openai`. Only normalized when the host is one of
  `generativelanguage.googleapis.com`, `aiplatform.googleapis.com`, or a
  real `*-aiplatform.googleapis.com` Vertex regional endpoint. The match
  is exact/suffix (not `contains`), so lookalike hosts like
  `aiplatform.example.com` are correctly treated as opaque relays.

Tests (8 new in `gemini_url::tests`):
- Four opaque-relay cases: `/custom/v1`, `/custom/models`,
  `/custom/v1/models`, `/custom/openai` — all preserved as-is.
- Three Google-host counter-cases: `/v1`, `/models`, and
  `us-central1-aiplatform.googleapis.com/v1` still normalize.
- One lookalike safety case: `aiplatform.example.com/v1` is NOT
  treated as Google.

All 849 lib tests pass; cargo fmt + clippy -D warnings clean.

Addresses Codex review P2 on #1918.
2026-04-16 17:35:11 +08:00
Jason baaefcc2fc fix(proxy/gemini): treat empty-string functionCall id as missing in streaming path
Follow-up to the earlier P1 fix: some Gemini relays serialize an absent
functionCall id as `"id": ""` instead of omitting the field. The
non-streaming `extract_tool_call_meta` already filters these via
`.filter(|s| !s.is_empty())`, but the streaming counterpart
`extract_tool_calls` passed the empty string straight through
`function_call.get("id").and_then(|v| v.as_str())` into
`GeminiToolCallMeta::new`, producing a `Some("")` id.

Downstream, `merge_tool_call_snapshots` would then match two parallel
no-id calls against each other on their shared empty-string id,
collapsing them into a single snapshot (silent data loss for the first
call) and emitting an Anthropic `tool_use.id: ""` that breaks tool_result
correlation on the Claude Code client.

Fix:
- `extract_tool_calls`: apply the same `filter(|s| !s.is_empty())` guard
  used in the non-streaming path so empty strings become `None` before
  reaching the shadow meta.
- `merge_tool_call_snapshots`: defensively collapse any incoming
  `Some("")` to `None` up front — keeps the "missing vs present" invariant
  local to the merge step for future callers that might build
  `GeminiToolCallMeta` by hand.

Tests (2 new, both in streaming_gemini):
- `parallel_empty_string_id_calls_are_treated_as_missing_and_preserved`
  covers two parallel calls with explicit `"id": ""` — asserts both
  surface, no empty tool_use id leaks, and each gets a unique
  `gemini_synth_` id.
- `single_empty_string_id_tool_call_gets_synthesized_id` covers the
  non-parallel degraded-relay case.

All 841 lib tests pass; cargo fmt + clippy -D warnings clean.

Addresses Codex follow-up P1 on #1918.
2026-04-16 17:14:22 +08:00
Jason 8b65488a89 fix(proxy/gemini): narrow URL normalization + guard empty OAuth access_token
P2a — Preserve opaque relay URLs that contain `/v1/models/` prefixes.

`should_normalize_gemini_full_url` previously flagged any full URL whose
path merely contained `/v1beta/models/` or `/v1/models/` as a structured
Gemini endpoint, forcing rewrite to `.../v1beta/models/{model}:method`.
This silently dropped legitimate relay route segments (e.g.
`https://relay.example/v1/models/invoke` → `.../v1beta/models/...:generateContent`,
losing `/invoke`) and sent traffic to the wrong upstream path.

Replace the bare `contains(...)` checks with
`matches_structured_gemini_models_path`, which requires the
`/models/` segment to be followed by a canonical Gemini method call
(`*:generateContent` or `*:streamGenerateContent`). The
`matches_bare_gemini_models_path` helper is generalized (and renamed) to
handle both `/v1beta/models/` and `/v1/models/` alongside the original
bare `/models/` shape.

P2b — Reject empty Gemini OAuth access_tokens before they reach the
bearer header.

`GeminiAdapter::parse_oauth_credentials` accepts refresh-token-only JSON
(and surfaces `{"access_token": "", ...}` for expired credentials) with
`access_token` defaulting to `""`. The Claude adapter's GeminiCli branch
then called `AuthInfo::with_access_token(key, creds.access_token)`
unconditionally, so the bearer-header builder at
`AuthStrategy::GoogleOAuth` resolved to `Authorization: Bearer ` — a
deterministic 401 from upstream.

CC Switch does not currently exchange the refresh_token for a fresh
access_token (`OAuthCredentials::needs_refresh` / `can_refresh` are
annotated `#[allow(dead_code)]`). Until that exists, only attach
`access_token` when it is non-empty; fall back to plain GoogleOAuth
strategy with the raw key and log a warn pointing users at
`~/.gemini/oauth_creds.json` so the failure mode is observable.

Tests:
- gemini_url.rs: three new regressions — opaque `/v1/models/invoke`,
  opaque `/v1beta/models/route`, and the positive counter-case where a
  structured `/v1/models/...:generateContent` path still normalizes.
- claude.rs: three new `test_extract_auth_gemini_cli_*` tests covering
  refresh-only JSON, empty-string access_token JSON, and the valid-JSON
  pass-through.

All 839 lib tests pass; cargo fmt + clippy -D warnings clean.

Addresses Codex review P2 findings on #1918.
2026-04-16 16:55:11 +08:00
Jason 128d431084 fix(proxy/gemini): synthesize unique ids for no-id tool calls + enforce object params schema
P1 — Parallel tool calls without Gemini-assigned ids no longer collapse.
Gemini 2.x native parallel `functionCall` entries may omit the `id` field.
The previous `merge_tool_call_snapshots` fell back to matching by `name`,
which silently merged two parallel calls to the same function into one
entry — dropping the first call's args. The non-streaming path and shadow
store further bottlenecked on empty-string ids: multiple `tool_use` blocks
shared the same id, and `tool_name_by_id.get("")` could only return one
mapping, causing later `tool_result` round-trips to fail with
`Unable to resolve Gemini functionResponse.name` or bind to the wrong tool.

Fix: introduce `synthesize_tool_call_id()` producing `gemini_synth_<uuid>`.
Both streaming and non-streaming response paths now guarantee every
Anthropic-visible tool_use carries a unique id. `merge_tool_call_snapshots`
matches by id first, falling back to the `parts` array position (for the
cumulative-streaming case) while preserving the synthesized id across
chunks. `convert_message_content_to_parts` detects the synthetic prefix
and strips the id from outbound `functionCall`/`functionResponse` so the
internal identifier never leaks upstream. `shadow_parts` performs the
same strip when replaying a recorded assistant turn.

P2 — Vertex AI rejects empty `parameters` schemas. When an Anthropic tool
arrives with missing or empty `input_schema`, the proxy used to emit
`"parameters": {}` (no `type`), which fails Vertex AI validation with
`functionDeclaration parameters schema should be of type OBJECT`.
Contrary to the automated-review suggestion, the fix is not to omit
`parameters` (that too is rejected) but to normalize to the canonical
empty-object form `{type: "object", properties: {}}`.
Refs: google-gemini/generative-ai-python#423, BerriAI/litellm#5055.

Fix: new `ensure_object_schema` helper in `gemini_schema` promotes
missing `type` to `"object"` and adds empty `properties` when absent,
while leaving atomic (non-object) schemas untouched.

Tests: seven new regressions covering parallel no-id calls, cumulative
chunk id reuse, synthetic-id round-trip both directions, shadow replay
id stripping, and the three Vertex-AI schema shapes.

The two existing wrapper functions (`gemini_to_anthropic` and
`gemini_to_anthropic_with_shadow`) gain `#[allow(dead_code)]` to clear
a pre-existing clippy -D warnings failure — they are part of the public
transform API surface and intentionally kept for future callers.

Addresses Codex review P1/P2 on #1918.
2026-04-16 16:22:52 +08:00
Jason 2f4a680d70 style: apply cargo fmt to pass Backend Checks CI
Wrap prompt_cache_key chained call across lines per rustfmt default
formatting. Pure formatting change, no behavior difference.
2026-04-16 13:11:33 +08:00
YoVinchen 2309b49ed5 Keep Gemini tool replay stable across Claude request boundaries
Claude Code follow-up requests were still falling back to locally reconstructed functionCall parts, which dropped Gemini thought signatures and triggered INVALID_ARGUMENT errors from the official Gemini API. The replay path needed to survive real Claude request boundaries, not just idealized in-process test flows.

This change makes Claude requests reuse X-Claude-Code-Session-Id as the shadow session key, records streamed Gemini tool turns before tool_use events are fully drained, and matches assistant tool_use turns to shadow state by tool_use id and normalized tool name before positional fallback. Together these fixes keep thoughtSignature-bearing Gemini tool calls available for the next request in the loop.

Constraint: Claude Code sends a stable X-Claude-Code-Session-Id header while metadata.session_id may be absent on follow-up requests
Rejected: Rely on metadata-only Claude session extraction | generated fresh session ids and broke cross-request shadow replay
Rejected: Record Gemini shadow only after streaming completes | loses the race when the client sends the next request immediately after tool_use
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Preserve Gemini shadow continuity across requests by keying Claude sessions from the header first and persisting tool-call shadow before yielding tool_use events downstream
Tested: cargo fmt --manifest-path src-tauri/Cargo.toml --all; cargo test --manifest-path src-tauri/Cargo.toml test_extract_session_from_claude_header; cargo test --manifest-path src-tauri/Cargo.toml test_extract_session_from_claude_header_precedes_metadata; cargo test --manifest-path src-tauri/Cargo.toml stores_tool_shadow_before_tool_use_events_are_fully_drained; cargo test --manifest-path src-tauri/Cargo.toml shadow_replay_matches_tool_use_turn_by_id_when_position_drifts; cargo test --manifest-path src-tauri/Cargo.toml shadow_replay_aligns_to_latest_turns_after_client_truncation
Not-tested: Full src-tauri test suite without test filters; live end-to-end Gemini relay after this exact commit hash
2026-04-16 11:31:23 +08:00
YoVinchen 8bdc4d6326 Prevent Gemini review regressions in streaming and tool rectification
PR #1918 review feedback exposed two correctness issues in the Gemini Native adapter path. Gemini SSE buffering was still using lossy UTF-8 decoding, which could corrupt split multibyte payloads and drop streamed output. Tool arg rectification also removed top-level parameters eagerly, which broke tools that legitimately define a parameters field.

This change moves Gemini SSE buffering onto the existing append_utf8_safe path and makes parameters flattening conditional on the schema actually expecting nested extraction. The old Skill rectification path stays intact, and new regression tests cover both the preserved parameters case and UTF-8-split JSON payloads.

Constraint: Existing PR #1918 review feedback must be fixed without staging unrelated local docs and artifact files
Rejected: Keep String::from_utf8_lossy in Gemini SSE buffering | corrupts split multibyte payloads and can drop JSON chunks
Rejected: Always preserve the parameters wrapper | regresses the existing nested-parameters rectification path for Skill-style tools
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep Gemini SSE buffering on the UTF-8-safe accumulator path and only unwrap parameters when the target schema does not declare it as a legitimate field
Tested: cargo fmt --manifest-path src-tauri/Cargo.toml --all; cargo test --manifest-path src-tauri/Cargo.toml preserves_utf8_boundaries_when_json_payload_spans_chunks; cargo test --manifest-path src-tauri/Cargo.toml gemini_to_anthropic_rectifies_tool_args_from_schema_hints; cargo test --manifest-path src-tauri/Cargo.toml rectifies_streamed_skill_args_from_nested_parameters; cargo test --manifest-path src-tauri/Cargo.toml gemini_to_anthropic_preserves_legitimate_parameters_arg
Not-tested: Full src-tauri test suite; live end-to-end Gemini relay traffic against upstream services
2026-04-16 10:57:54 +08:00
YoVinchen a186f6baca Merge branch 'main' into feat/gemini-proxy-integration 2026-04-15 17:01:21 +08:00
YoVinchen 32e3cad141 Merge branch 'main' into feat/gemini-proxy-integration
# Conflicts:
#	src-tauri/src/proxy/providers/claude.rs
#	src/i18n/locales/en.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/zh.json
2026-04-15 16:58:26 +08:00
YoVinchen 9bbc8bb789 fix: revert unrelated cache_key change in claude proxy transform
Restore .unwrap_or(&provider.id) fallback for cache_key to match main
branch behavior. Only gemini_native related changes should be in this branch.
2026-04-11 16:22:07 +08:00
YoVinchen d52a811016 Merge branch 'main' into feat/gemini-proxy-integration
Resolve conflict in claudeProviderPresets.ts: keep both Gemini Native
and Shengsuanyun presets.
2026-04-11 15:50:48 +08:00
YoVinchen 92668f610e Merge branch 'main' into feat/gemini-proxy-integration
# Conflicts:
#	src-tauri/src/proxy/providers/claude.rs
#	src-tauri/src/proxy/sse.rs
#	src-tauri/src/services/stream_check.rs
2026-04-10 09:08:39 +08:00
YoVinchen 44ac926210 fix(proxy): align shadow turns to tail on client history truncation 2026-04-06 23:42:47 +08:00
YoVinchen 7cf554c011 feat(proxy): update Gemini streaming and transformation logic 2026-04-06 12:23:19 +08:00
YoVinchen 8f300f4591 feat(proxy): add Gemini Native tool argument rectification 2026-04-06 11:53:06 +08:00
YoVinchen 70886d9847 feat(ui): add Gemini Native provider preset and api format option
- Add gemini_native to ClaudeApiFormat type and ProviderMeta.apiFormat
- Add "Gemini Native" provider preset with default Google AI endpoints
- Show Gemini-specific endpoint hints and full-URL mode guidance
- Add gemini_native option to API format selector in ClaudeFormFields
- Add i18n strings for zh/en/ja
2026-04-06 11:12:41 +08:00
YoVinchen 8603d4a373 feat(proxy): wire Gemini Native format into proxy core and Claude adapter
Integrate gemini_native api_format throughout the proxy pipeline:
- ClaudeAdapter: detect Gemini provider type, Google/GoogleOAuth auth
  strategies, and suppress Anthropic-specific headers for Gemini targets
- Forwarder: Gemini URL resolution, shadow store threading, endpoint
  rewriting to models/*:generateContent with stream/non-stream variants
- Handlers: route Gemini streaming through streaming_gemini adapter and
  non-streaming through transform_gemini converter
- Server/State: add GeminiShadowStore to shared ProxyState
- StreamCheck: support gemini_native health check with proper auth headers
2026-04-06 11:12:11 +08:00
YoVinchen 8c09d8c7d3 feat(proxy): add Gemini Native schema, shadow store, transform, and streaming
- gemini_schema: Gemini generateContent request/response type definitions
- gemini_shadow: session-scoped shadow store for thinking signature and
  tool-call state replay across streaming chunks
- transform_gemini: bidirectional Anthropic Messages ↔ Gemini Native
  request/response conversion with thinking block and tool-use support
- streaming_gemini: Gemini SSE → Anthropic SSE streaming adapter with
  incremental thinking/text/tool_use delta emission
2026-04-06 11:11:30 +08:00
YoVinchen 3ce8245f2d feat(proxy): add Gemini Native URL builder and full-URL resolver
Introduce gemini_url module that normalizes legacy Gemini/OpenAI-compatible
base URLs into canonical models/*:generateContent endpoints. Supports both
structured Gemini URLs (auto-normalized) and opaque relay URLs (pass-through
with query params only).
2026-04-06 11:10:42 +08:00
YoVinchen abbf43ea6f refactor(proxy): extract take_sse_block helper with CRLF delimiter support
Replace inline `buffer.find("\n\n")` SSE splitting logic across streaming,
streaming_responses, response_handler, and response_processor with a shared
`take_sse_block` function that handles both `\n\n` and `\r\n\r\n` delimiters.
2026-04-06 11:09:23 +08:00
272 changed files with 3077 additions and 26802 deletions
+3 -3
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -24,7 +24,7 @@ jobs:
node-version: "20"
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v5
with:
version: 10.12.3
run_install: false
@@ -58,7 +58,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
-61
View File
@@ -1,61 +0,0 @@
name: Claude
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
issues:
types: [opened, assigned]
jobs:
claude:
if: |
((github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR'))
||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR'))
||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') &&
(github.event.review.author_association == 'OWNER' ||
github.event.review.author_association == 'MEMBER' ||
github.event.review.author_association == 'COLLABORATOR'))
||
(github.event_name == 'issues' &&
(contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) &&
(github.event.issue.author_association == 'OWNER' ||
github.event.issue.author_association == 'MEMBER' ||
github.event.issue.author_association == 'COLLABORATOR')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Run Claude (review only)
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_args: >-
--model claude-opus-4-7
--disallowedTools Edit,Write,MultiEdit,NotebookEdit
--append-system-prompt "You are reviewing PRs for cc-switch — a small open-source Tauri 2.0 + React + TypeScript desktop app maintained by a single developer who values pragmatism over perfection.
ONLY flag HIGH SIGNAL issues. An issue qualifies only if it meets at least one of: code that fails to compile/parse/run; code that will definitely produce wrong results; a security vulnerability with a concrete trigger (SSRF, injection, unsafe deserialization, credential or data leaks); database/migration correctness (schema mismatch, data loss on upgrade, broken SCHEMA_VERSION transition); cross-platform breakage (Windows/macOS/Linux specific); clear, quotable violations of CLAUDE.md.
Each finding must include a confidence score from 0 to 100. Do NOT post any finding scored below 80. Each finding must carry a severity tag — Important (bug that should be fixed before merge), Nit (minor, worth fixing but not blocking), or Pre-existing (exists already, not introduced by this PR).
DO NOT flag any of the following: pedantic nitpicks a senior engineer would not raise; anything a linter, typechecker, pnpm format, or cargo fmt would catch; missing test coverage unless CLAUDE.md explicitly mandates it; style, naming, or 'could be more idiomatic' suggestions; speculative future-proofing ('what if X is added later'); issues on lines the PR did not modify; potential bugs for which you cannot construct a concrete failure scenario; positive feedback or 'what is well done' sections.
OUTPUT RULES: Cap Nit-level findings at 5 per review. If you found more nits, append 'plus N similar nits' to the summary instead of listing them inline. If only nits were found, lead the review with 'No blocking issues.' If nothing material is wrong, say 'LGTM' explicitly and stop — approval with zero comments is a valid and expected outcome for most PRs. Do NOT post a final APPROVE or REQUEST CHANGES verdict; leave the merge decision to the maintainer.
Never modify files, push commits, or create PRs."
+3 -5
View File
@@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -74,7 +74,7 @@ jobs:
|| sudo apt-get install -y --no-install-recommends libsoup2.4-dev
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v5
with:
version: 10.12.3
run_install: false
@@ -535,7 +535,7 @@ jobs:
ls -la release-assets
- name: Upload Release Assets
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: CC Switch ${{ github.ref_name }}
@@ -543,8 +543,6 @@ jobs:
body: |
## CC Switch ${{ github.ref_name }}
🌐 **Only Official Website / 唯一官方网站 / 唯一の公式サイト**: [ccswitch.io](https://ccswitch.io)
Claude Code 供应商切换工具
### 下载
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
- uses: actions/stale@v9
with:
# --- Timing ---
days-before-stale: 60
-1
View File
@@ -29,4 +29,3 @@ copilot-api
.history
CODEBUDDY.md
.github
mainWindow.js
-134
View File
@@ -5,140 +5,6 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Fixed
- **OpenAI Responses API usage parsing robustness**: Hardened `build_anthropic_usage_from_responses()` and the Responses → Anthropic SSE translator so a missing or malformed upstream `usage` no longer produces `"usage": null` in `message_delta`. This unblocks strict Anthropic clients (notably the VSCode Claude Code extension) that crashed with "Cannot read properties of null (reading 'output_tokens')" against providers such as Codex OAuth and DashScope's `compatible-mode/v1/responses` endpoint. Added OpenAI field-name fallbacks (`prompt_tokens` / `completion_tokens`), null/empty/partial object handling, and preserved cache token fields even when input/output tokens are missing (#2422).
## [3.14.1] - 2026-04-23
Development since v3.14.0 focuses on Codex OAuth stability, tray usage visibility, Skills import/install reliability, Gemini session restore paths, and simplifying Hermes configuration health handling.
**Stats**: 13 commits | 48 files changed | +1,883 insertions | -808 deletions
### Added
- **Tray Usage Visibility**: System tray submenus now show cached usage for the current Claude / Codex / Gemini provider, including subscription and script-based usage summaries with utilization color markers. Tray-triggered refreshes are throttled, limited to visible apps, and synchronized back into React Query so the main window and tray share fresh usage data (#2184).
- **Tray Coding-Plan Usage (Kimi / Zhipu / MiniMax)**: System tray now renders 5-hour + weekly window usage for Chinese coding-plan providers using the same `🟢 h12% w80%` two-window layout as official subscription badges (worst utilization drives the emoji). Creating a Claude provider whose `ANTHROPIC_BASE_URL` matches a known coding-plan host now auto-injects `meta.usage_script`, so the tray lights up without opening the Usage Script modal. Existing `usage_script` values are preserved on update.
- **Codex OAuth FAST Mode**: Added an explicit FAST mode toggle for Codex OAuth-backed Claude providers. When enabled, converted Responses requests send `service_tier="priority"` for lower latency; the toggle stays off by default to avoid unexpectedly increasing ChatGPT quota consumption (#2210).
### Changed
- **Session and Settings Layout Polish**: Hardened the scroll-area viewport with width containment to fix horizontal overflow, and tightened app bottom spacing plus settings footer spacing so long session/settings views fit more cleanly (#2201).
### Removed
- **Hermes Config Health Scanner**: Removed the in-app Hermes config health scanner, warning banner, `scan_hermes_config_health` command, `HermesHealthWarning` type, and `HermesWriteOutcome.warnings` payload. CC Switch now keeps the Hermes surface focused on active provider display, provider switching defaults, memory editing, and launching the Hermes Web UI for deep configuration.
### Fixed
- **Codex OAuth Cache Routing**: Stabilized ChatGPT Codex reverse-proxy cache identity by using client-provided session IDs for `prompt_cache_key` and Codex session headers, preserving explicit cache keys, and avoiding generated UUID cache churn (#2218).
- **Codex OAuth Responses SSE Aggregation**: Non-streaming Anthropic clients now receive JSON even when the ChatGPT Codex upstream forces OpenAI Responses SSE; CC Switch aggregates the upstream SSE events before running the non-streaming transform (#2235).
- **Codex OAuth Stream Check Parity**: Stream checks now build Codex OAuth test requests with the same `store: false`, encrypted reasoning include, and provider FAST mode setting as production proxy requests (#2210).
- **Codex Model Extraction**: Replaced first-line regex matching with TOML parsing when reading Codex config models, so multiline TOML is handled correctly (#2227).
- **Model Quick-Set / One-Click Config**: Model quick-set updates now apply against the latest provider form config, preventing stale state from making one-click configuration fail (#2249).
- **Skills Import Duplicates**: The Skills import dialog disables actions while import is pending and the installed-skills cache deduplicates imported results by ID, preventing double-clicks from adding duplicate installed entries (#2139, #2211).
- **Root-Level Skill Repos**: Skill install and update flows now consistently resolve three source patterns: direct nested paths, install-name recursive search, and repository-root `SKILL.md` sources (#2231).
- **Gemini Session Restore Paths**: Gemini session scanning now reads `.project_root` metadata so restore flows can pass the original project directory when available (#2240).
- **Provider Hover Names**: Provider icons now expose the provider name on hover for inline SVG, image URL, and fallback initials render paths (#2237).
## [3.14.0] - 2026-04-21
Development since v3.13.0 focuses on onboarding Hermes Agent as a first-class managed app, rolling out Claude Opus 4.7 across the preset matrix, adding a Gemini Native API proxy, and sharpening session, usage, and proxy workflows.
**Stats**: 100 commits | 219 files changed | +20,548 insertions | -3,569 deletions
### Added
- **Hermes Agent Support (6th Managed App)**: Added Hermes Agent as a first-class managed app with database migration v9→v10, full Rust command surface, YAML-backed `~/.hermes/config.yaml` read/write with atomic backups, MCP sync, Skills sync, session manager with SQLite + JSONL support, and dedicated frontend panels. Supports four API protocols (`chat_completions`, `anthropic_messages`, `codex_responses`, `bedrock_converse`) aligned with Hermes Agent 0.10.0 schema. Read-only rendering for providers owned by the user-authored `providers:` dict, with deep configuration delegated to the Hermes Web UI.
- **Hermes Memory Panel**: Added a Memory panel for editing `MEMORY.md` and `USER.md` directly from CC Switch, with an enable switch, character-count limits, and a live save flow. Replaces the Prompts entry for Hermes.
- **Hermes Provider Presets**: Added ~50 Hermes provider presets spanning Nous Research, Shengsuanyun, OpenRouter, DeepSeek, Together AI, StepFun, Zhipu GLM, Bailian, Kimi, MiniMax, DouBao, BaiLing, ModelScope, KAT-Coder, PackyCode, Cubence, AIGoCode, RightCode, AICodeMirror, AICoding, CrazyRouter, SSSAiCode, Micu, CTok.ai, DDSHub, E-FlowCode, LionCCAPI, PIPELLM, Compshare, SiliconFlow, AiHubMix, DMXAPI, TheRouter, Novita, Nvidia, and Xiaomi MiMo.
- **Claude Opus 4.7 Support**: Added Claude Opus 4.7 with adaptive thinking whitelisting, per-million pricing seed, and Bedrock SKU (`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`, dropping the legacy `-v1` suffix). Migrated all aggregator and Bedrock presets to Opus 4.7 as the default Opus model.
- **Claude `max` Effort Tier**: Upgraded the Claude effort dropdown from `high` to `max` for extended reasoning capacity.
- **Gemini Native API Proxy**: Added `api_format = "gemini_native"` so the proxy can forward to Google's `generateContent` API with full streaming, schema conversion, and shadow request support. Adds `gemini_url.rs`, `gemini_schema.rs`, `gemini_shadow.rs`, `streaming_gemini.rs`, and `transform_gemini.rs` under the proxy providers module.
- **GitHub Copilot Enterprise Server**: Added GHES authentication and endpoint configuration for Copilot-backed Claude providers, plus thinking-block stripping before upstream to preserve premium interaction quota.
- **Session List Virtualization**: Virtualized the session list via `@tanstack/react-virtual` so long conversations (thousands of records) scroll smoothly; long session messages are now collapsed by default to reduce text layout cost.
- **Codex / OpenClaw Session Title Extraction**: Added meaningful title auto-extraction for Codex and OpenClaw sessions with 2-line display; strips OpenClaw `message_id` suffix noise.
- **Usage Date Range Picker**: Added a date range selector to the usage dashboard with preset tabs (Today / 1d / 7d / 14d / 30d), a custom date + time calendar picker, and a page-jump input on paginated lists.
- **Model Mapping Quick-Set**: Added a quick-set button next to model mapping fields in provider forms for faster edits.
- **Stream Check Error Classification**: Classified Stream Check errors and surfaced them as color-coded toasts; refreshed default probe models and added explicit detection for "model not found" responses.
- **Block Official Provider Switching During Local Routing**: Blocks switching to official providers while Local Routing is active, since routing official API traffic through the local proxy carries account-suspension risk. A warning toast surfaces the block.
- **Pricing Database Refresh (v8 → v9)**: Added ~50 new model pricing entries and corrected stale prices via a reseed-on-migration step, including Claude 4.7, Opus 4.7 Adaptive Thinking, Grok 4, Qwen 3.5/3.6, MiniMax M2.5/M2.7, Doubao Seed 2.0 series, and GLM-5/5.1. DeepSeek and Kimi K2.5 prices updated.
- **Application-Level Window Controls**: Added an opt-in setting to render CC Switch's own minimize / toggle-maximize / close buttons instead of the system decorations, materially improving the experience on Linux Wayland where compositor-drawn buttons can become inert.
- **Hermes in Unified Skills Management**: Added Hermes to the unified Skills surface; skill install, enable, and filter now cover the Hermes app alongside Claude / Codex / Gemini / OpenCode / OpenClaw.
- **OpenClaw Config Directory Override**: Added a settings option to point CC Switch at a custom `openclaw.json` location.
- **Hermes Config Directory Override**: Added a settings option to point CC Switch at a custom `~/.hermes/config.yaml` location, backed by data-driven dispatch.
- **StepFun Step Plan Preset**: Added StepFun Step Plan (EN/ZH) provider presets.
- **New API Usage Script Template**: Added a User-Agent header to the New API usage script template for better upstream compatibility.
- **Launch Hermes Dashboard from Toolbar**: When the Hermes Web UI probe fails, the toolbar entry now offers to run `hermes dashboard` in the user's preferred terminal via a temp bash/batch script. `hermes dashboard` opens the browser itself once ready, so no polling is required. Also corrects the stale `hermes web` hint in the offline toast (the real command is `hermes dashboard`) and reorders Linux terminal detection to try `which` before stat'ing `/usr/bin`, `/bin`, `/usr/local/bin`.
- **LemonData Provider Preset (All Six Apps)**: Registered LemonData as a third-party partner preset across Claude, Codex, Gemini, OpenCode, OpenClaw, and Hermes, with icon assets and zh/en/ja partner-promotion copy. Claude uses `ANTHROPIC_API_KEY` auth; OpenAI-compatible apps target `gpt-5.4`.
- **DDSHub Codex Preset**: Added a Codex-compatible endpoint for DDSHub at the same host as its Claude service; base URL omits the `/v1` suffix because the gateway auto-routes OpenAI SDK paths.
### Changed
- **"Local Proxy Takeover" → "Local Routing"**: Unified terminology across UI copy, README, and docs in all three locales. Functional behavior is unchanged.
- **Hermes `Auto` api_mode Removed**: Users must now pick an explicit protocol; new deeplinks default to `chat_completions`. Eliminates URL-based heuristic surprises.
- **Hermes Provider Form**: Added an API mode dropdown and per-provider model editor; bound per-provider models to the top-level `model:` when switching active providers.
- **Hermes Deep Config Delegation**: Deep YAML knobs are now delegated to the Hermes Web UI via a direct launch action, rather than duplicated in the CC Switch form.
- **`ANTHROPIC_REASONING_MODEL` Removed from Claude Quick-Set**: Decoupled the reasoning capability from model selection; the legacy field is no longer surfaced in the quick-set form.
- **Per-Provider Proxy Config Removed**: Consolidated into global Local Routing; the provider-level proxy toggle and associated storage are gone.
- **Unified Toolbar Icon Button Width**: Normalized icon-button widths across Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes panels for a consistent header look.
- **Rust Toolchain Pinned to 1.95**: Adopted clippy 1.95 suggestions across the workspace and pinned the toolchain to prevent nightly drift.
- **Tray Menu ID Constant**: The tray identifier moved from the hardcoded string `"main"` to a `TRAY_ID` constant (`"cc-switch"`) across all call sites.
- **Copilot Request Classification**: Refined request routing inside the Copilot optimizer to further reduce unnecessary premium interaction consumption.
- **Usage Script Intranet Support**: Removed private-IP / suspicious-hostname blocking from usage scripts, unblocking enterprise intranet, Docker, and self-hosted API endpoints. Built-in templates still enforce HTTPS (except localhost) and same-origin checks; custom templates remain user-controlled with those request-URL checks skipped.
- **Failover Queue Notes**: Provider notes now appear in failover queue selectors and queue rows for easier identification across multi-provider queues.
- **Hermes Toolbar Layout**: Swapped the Hermes Web UI button from `ExternalLink` to `LayoutDashboard` (clicking may spawn `hermes dashboard` rather than just opening a URL), and moved MCP to the final toolbar slot so Hermes matches the Claude / Codex / Gemini / OpenCode layout.
### Fixed
- **Header Auto-Compact Latching After Maximize**: The toolbar no longer stays compacted after maximize/restore; compaction now reevaluates on size changes.
- **Hermes YAML Pollution & OAuth MCP Auth Drop**: Round-tripping through CC Switch no longer drops OAuth MCP `auth` blocks or pollutes unrelated YAML keys; guard tests added via `tests/hermes_roundtrip.rs`.
- **Hermes Active Provider Display**: Hermes UI now correctly surfaces the active provider and wires add / enable / remove actions.
- **Hermes Provider Persistence**: Providers persist under `custom_providers:` so `api_mode` and `model` survive restarts and config reloads.
- **Codex `cache_control` Preservation**: Preserve `cache_control` when merging system prompts during Codex format conversion (#1946).
- **Claude Prompt Cache Key Leak**: Stopped sending prompt cache keys during Claude chat conversions (#2003).
- **Proxy Hop-by-Hop Header Stripping**: Strip hop-by-hop response headers (Connection, Keep-Alive, Transfer-Encoding, etc.) per RFC 7230.
- **Permissive Proxy CORS Removed**: Removed the permissive CORS layer from the proxy (#1915).
- **Copilot Premium Consumption**: Further reduced unnecessary Copilot premium interaction consumption during pass-through traffic.
- **Backend Error Details in Proxy Toast**: Surface backend error payload details in proxy-related toast messages instead of a generic failure string.
- **Usage Log Deduplication**: Deduplicated proxy and session-log usage records so the same request is no longer double-counted; synced the request log time range with the dashboard's 1d / 7d / 30d selector.
- **Common Config Checkbox Persistence**: Checkbox state for Claude / Codex / Gemini common-config toggles now persists correctly across reopens.
- **Claude Plugin `settings.json` Sync**: Editing the current provider now syncs back to `settings.json` for the Claude plugin path.
- **Google Official Gemini Env Preservation**: Saving the Google Official Gemini provider no longer clobbers the `env` block.
- **OpenCode JSON5 Parser for Trailing Commas**: OpenCode config reads now tolerate trailing commas via a JSON5 parser.
- **Preset Refreshes**: Refreshed stale context windows for DeepSeek and Claude 1M; refreshed stale model IDs; backfilled Hermes model lists; fixed the Nous endpoint and replaced the Hermes placeholder icon with Nous brand artwork; pruned unused official Hermes presets.
- **Auto-Expand Collapsed Messages on Search Hit**: Collapsed messages now auto-expand when a search match lands inside hidden content.
- **Unknown Subscription Quota Tiers Hidden**: Provider cards no longer render unknown subscription quota tiers.
- **Weekly Limit Label Unified**: Aligned the weekly_limit tier label with the official 7-day naming across locales.
- **Root-Level Skill Repo Install**: Fixed skill installation when the repository root itself is a skill.
- **Session ID Parsing Clippy**: Removed a redundant closure in session ID parsing (clippy warning).
- **Usage Log Stat Dedup**: Deduplicated proxy-sourced and session-log-sourced usage records for accurate totals.
- **Stream Check Default Models Refresh**: Updated stream-check default probe models to match each vendor's current lineup.
- **Skills Import Sync**: Imported Skills are now immediately synced into enabled app directories instead of only being recorded in the database, so the UI no longer shows "installed" while the target app directory is missing the skill.
- **Ghostty Session Restore**: Fixed Ghostty session restore launch by using shell execution with `--working-directory`, avoiding `cwd` escaping issues when the path contains spaces or special characters.
- **Hermes Health Check Borrowing OpenClaw Schema**: Hermes providers were routed through `check_additive_app_stream` (the OpenClaw dispatcher), which reads camelCase `baseUrl` / `apiKey` / `api` and surfaced "OpenClaw provider is missing baseUrl" even when every Hermes field was filled. Introduced `check_hermes_stream` with Hermes-specific extractors that map `api_mode` (`chat_completions` / `anthropic_messages` / `codex_responses`) to the matching `check_claude_stream` `api_format`, and returns `bedrock_converse` as unsupported. `api_mode` is now resolved before URL / API key extraction, so `bedrock_converse` users see the real cause rather than a misleading "missing base_url".
- **Usage Query Modal for Hermes & OpenClaw**: `getProviderCredentials` now reads flat `settingsConfig` fields for Hermes (snake_case `base_url` / `api_key`) and OpenClaw (camelCase `baseUrl` / `apiKey`), so the "official balance" template auto-selects for matching providers like SiliconFlow. Also refactored the BALANCE and TOKEN_PLAN test paths to reuse the precomputed `providerCredentials` instead of re-reading `env.ANTHROPIC_*` directly, fixing the "empty key" error for non-Claude apps even when the key was configured.
### Docs
- **README Sponsor Updates**: Updated SiliconFlow signup bonus to ¥16, trimmed the SSSAiCode sponsor blurb, updated partner logos, and added LemonData as a new sponsor.
- **Global Proxy Hint Clarified**: Clarified the global proxy hint about local routing across all three locales.
- **Takeover → Routing Rename**: Renamed takeover docs to routing and updated anchors across all languages.
- **PIPELLM Website URL**: Updated the PIPELLM sponsor website URL to `code.pipellm.ai`.
### Breaking
- **Hermes requires explicit `api_mode`**: The `Auto` mode is gone; imported or deeplinked providers default to `chat_completions`. Users with prior `Auto` configs will be prompted to pick a protocol.
- **`ANTHROPIC_REASONING_MODEL` removed from Claude quick-set**: The legacy field is no longer exposed; existing settings are cleaned up automatically.
- **Per-provider proxy configuration removed**: Migrate to the global Local Routing setting. Existing per-provider proxy values are ignored.
- **Database schema bumped v9 → v10**: Adds `enabled_hermes` columns to `mcp_servers` and `skills` (auto-migrated with `DEFAULT 0`; no data loss).
- **Pricing table reseeded (v8 → v9)**: The `model_pricing` table is cleared and reseeded on first launch to pick up new models and corrected prices.
- **XCodeAPI preset removed**: Users of the XCodeAPI preset should switch to another provider.
---
## [3.13.0] - 2026-04-10
Development since v3.12.3 focuses on quota visibility, provider workflow upgrades, stronger proxy compatibility, and lower-overhead tray / session workflows.
+17 -41
View File
@@ -2,7 +2,7 @@
# CC Switch
### The All-in-One Manager for Claude Code, Codex, Gemini CLI, OpenCode, OpenClaw & Hermes Agent
### The All-in-One Manager for Claude Code, Codex, Gemini CLI, OpenCode & OpenClaw
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
@@ -11,8 +11,6 @@
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
### 🌐 The Only Official Website: **[ccswitch.io](https://ccswitch.io)**
English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANGELOG.md)
</div>
@@ -44,33 +42,21 @@ MiniMax-M2.7 is a next-generation large language model designed for autonomous e
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.svg" alt="Shengsuanyun" width="150"></a></td>
<td>Thanks to Shengsuanyun for sponsoring this project! Shengsuanyun is a super factory serving AI Native Teams — an industrial-grade AI task parallel execution platform. Its model marketplace aggregates Claude, ChatGPT, Gemini, and other domestic and international LLM and multimedia model capabilities with direct supply. Absolutely no reverse engineering or dilution — platform-wide model SLA availability reaches 99.7%, with <a href="https://watch.shengsuanyun.com/status/shengsuanyun">monitoring dashboards</a> showing green across the board. It also offers enterprise-grade custom gateways for fine-grained team cost and permission management, smart routing, security protection, and BYOK (Bring Your Own Key) hosting. The platform charges on a pay-per-use and tokens plan (coming soon) basis, with invoicing available. Register via <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">this link</a> as a new user to receive ¥10 in credits plus a 10% bonus on your first top-up.</td>
</tr>
<tr>
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
<td>Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via <a href="https://cloud.siliconflow.cn/i/drGuwc9k">this link</a> and complete real-name verification to receive ¥20 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free.</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support.
Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CC Switch users: register via <a href="https://www.aicodemirror.com/register?invitecode=9915W3">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
</tr>
<tr>
<td width="180"><a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/"><img src="assets/partners/logos/pateway.png" alt="PatewayAI" width="150"></a></td>
<td>Thanks to PatewayAI for sponsoring this project! PatewayAI is an API relay service provider built for heavy AI developers, focused on directly relaying official high-quality model APIs. It offers the full Claude lineup and the Codex series, 100% sourced from official channels — no dilution, no fakes, verification welcome. Billing is transparent and every token-level invoice can be audited line by line.
It also supports enterprise-grade concurrency and provides a dedicated management platform for enterprise customers — formal contracts and invoicing are available; visit the official website for contact details.
Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this link</a> to receive $3 in trial credit. Top-ups go as low as 60% of the original price, with a two-way referral bonus of up to $150!</td>
</tr>
<tr>
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
<td>Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a fullmodal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, longtask execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, endtoend complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">this link</a> to get 500,000 tokens of free inference quota per model.</td>
</tr>
<tr>
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
<td>Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via <a href="https://cloud.siliconflow.cn/i/drGuwc9k">this link</a> and complete real-name verification to receive ¥16 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free.</td>
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more with flexible billing options including pay-as-you-go and monthly plans. Cubence provides special discounts for CC Switch users: register using <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">this link</a> and enter the "CCSWITCH" promo code during recharge to get 10% off every top-up!</td>
@@ -83,7 +69,7 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
<tr>
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Compshare" width="150"></a></td>
<td>Thanks to Compshare for sponsoring this project! Compshare is UCloud's AI cloud platform, providing stable and comprehensive domestic and international model APIs with just one key. Featuring cost-effective monthly and per-use domestic-model Coding Plan packages, alongside stable officially-relayed overseas models. Supports Claude Code, Codex, and API access. Enterprise-grade high concurrency, 24/7 technical support, and self-service invoicing. Users who register via <a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">this link</a> will receive a free 5 CNY platform trial credit!</td>
<td>Thanks to Compshare for sponsoring this project! Compshare is UCloud's AI cloud platform, providing stable and comprehensive domestic and international model APIs with just one key. Featuring cost-effective monthly and pay-as-you-go Coding Plan packages at 60-80% off official prices. Supports Claude Code, Codex, and API access. Enterprise-grade high concurrency, 24/7 technical support, and self-service invoicing. Users who register via <a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">this link</a> will receive a free 5 CNY platform trial credit!</td>
</tr>
<tr>
@@ -93,22 +79,22 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
<tr>
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.png" alt="Crazyrouter" width="150"></a></td>
<td>Thanks to Crazyrouter for sponsoring this project! Crazyrouter is a high-performance AI API aggregation platform — one API key for 300+ models including Claude Code, Codex, Gemini CLI, and more. All models at 55% of official pricing with auto-failover, smart routing, and unlimited concurrency. Crazyrouter offers an exclusive deal for CC Switch users: register via <a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">this link</a> and contact customer support to claim <strong>$2 free credit</strong>, plus enter promo code `CCSWITCH` on your first top-up for an extra <strong>30% bonus credit</strong>! </td>
<td>Thanks to Crazyrouter for sponsoring this project! Crazyrouter is a high-performance AI API aggregation platform — one API key for 300+ models including Claude Code, Codex, Gemini CLI, and more. All models at 55% of official pricing with auto-failover, smart routing, and unlimited concurrency. Crazyrouter offers an exclusive deal for CC Switch users: register via <a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">this link</a> to get <strong>$2 free credit</strong> instantly, plus enter promo code `CCSWITCH` on your first top-up for an extra <strong>30% bonus credit</strong>! </td>
</tr>
<tr>
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
<td>Thank you to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support. Right Code also offers an exclusive discount for CC Switch users: register via <a href="https://www.right.codes/register?aff=CCSWITCH">this link</a>, and with every top-up you will receive pay-as-you-go credit equivalent to 5% of the amount paid.</td>
<td>Thank you to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini. It features a highly cost-effective Codex monthly subscription plan and <strong>supports quota rollovers—unused quota from one day can be carried over and used the next day.</strong> Invoices are available upon top-up. Enterprise and team users can receive dedicated one-on-one support. Right Code also offers an exclusive discount for CC Switch users: register via <a href="https://www.right.codes/register?aff=CCSWITCH">this link</a>, and with every top-up you will receive pay-as-you-go credit equivalent to 25% of the amount paid.</td>
</tr>
<tr>
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
<td>Thanks to SSSAiCode for sponsoring this project! SSSAiCode is a stable and reliable API relay service, dedicated to providing stable, reliable, and affordable Claude and Codex model services, with same-day fast invoicing. SSSAiCode offers a special deal for CC Switch users: register via <a href="https://www.sssaicode.com/register?ref=DCP0SM">this link</a> to enjoy $10 extra credit on every top-up!</td>
<td>Thanks to SSSAiCode for sponsoring this project! SSSAiCode is a stable and reliable API relay service, dedicated to providing stable, reliable, and affordable Claude and Codex model services, <strong>offering high cost-effective official Claude service at just ¥0.5/$ equivalent</strong>, supporting monthly and pay-as-you-go billing plans with same-day fast invoicing. SSSAiCode offers a special deal for CC Switch users: register via <a href="https://www.sssaicode.com/register?ref=DCP0SM">this link</a> to enjoy $10 extra credit on every top-up!</td>
</tr>
<tr>
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
<td>Thanks to Micu API for sponsoring this project! Micu API is a global LLM relay service provider dedicated to delivering the best cost-performance ratio with high stability. Backed by a registered enterprise for core assurance, eliminating any risk of service discontinuation, with fast official invoicing support! We champion "zero cost to try": top up from as low as ¥1 with no minimum, and get fee-free refunds anytime! Micu API offers an exclusive deal for CC Switch users: register via <a href="https://www.micuapi.ai/register?aff=aOYQ">this link</a> and enter promo code "ccswitch" when topping up to enjoy a <strong>10% discount</strong>!</td>
<td width="180"><a href="https://www.openclaudecode.cn/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
<td>Thanks to Micu API for sponsoring this project! Micu API is a global LLM relay service provider dedicated to delivering the best cost-performance ratio with high stability. Backed by a registered enterprise for core assurance, eliminating any risk of service discontinuation, with fast official invoicing support! We champion "zero cost to try": top up from as low as ¥1 with no minimum, and get fee-free refunds anytime! Micu API offers an exclusive deal for CC Switch users: register via <a href="https://www.openclaudecode.cn/register?aff=aOYQ">this link</a> and enter promo code "ccswitch" when topping up to enjoy a <strong>10% discount</strong>!</td>
</tr>
<tr>
@@ -122,13 +108,13 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
</tr>
<tr>
<td width="180"><a href="https://vibecodingapi.ai"><img src="assets/partners/logos/lioncc.png" alt="LionCC" width="150"></a></td>
<td>Thanks to LionCC for sponsoring this project! LionCC is built for Vibe Coders who pursue the ultimate development experience. We provide stable, low-latency, and competitively priced computing services for Claude Code, Codex, and OpenClaw, saving up to 50% in costs. After registering, add customer service on WeChat (HSQBJ088888888) with the code "cc-switch" to receive $10 in free credits (10 million tokens). For other collaborations, follow the blog @LionCC.ai. Click <a href="https://vibecodingapi.ai">here</a> to register!</td>
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
<td>Thanks to ChefShop AI for sponsoring this project! ChefShop AI is a premium account service provider tailored for heavy AI subscription users. The platform offers official top-up and stable account services for mainstream large models including ChatGPT Plus/Pro, Claude Max, Grok Super/Heavy, and Gemini. Click <a href="https://chefshop.ai">here</a> to purchase!</td>
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/register?aff=pCLD"><img src="assets/partners/logos/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
<td>This project is sponsored by <a href="https://console.claudeapi.com/register?aff=pCLD">Claude API</a>. Direct Claude API access — connect Claude Code and Agent apps in 3 minutes. New users can claim a free trial credit.Powered by official Anthropic API keys + AWS Bedrock official channels. No reverse engineering, no model degradation. Full support for Opus / Sonnet / Haiku model lineup, with official capabilities preserved including Tool Use, 1M context window, and more. Built for Claude Code power users, Agent engineers, and enterprise engineering teams. Invoicing and dedicated team support available. Click <a href="https://console.claudeapi.com/register?aff=pCLD">here</a> to register!</td>
<td width="180"><a href="https://vibecodingapi.ai"><img src="assets/partners/logos/lioncc.png" alt="LionCC" width="150"></a></td>
<td>Thanks to LionCC for sponsoring this project! LionCC is built for Vibe Coders who pursue the ultimate development experience. We provide stable, low-latency, and competitively priced computing services for Claude Code, Codex, and OpenClaw, saving up to 50% in costs. After registering, add customer service on WeChat (HSQBJ088888888) with the code "cc-switch" to receive $10 in free credits (10 million tokens). For other collaborations, follow the blog @LionCC.ai. Click <a href="https://vibecodingapi.ai">here</a> to register!</td>
</tr>
<tr>
@@ -137,16 +123,6 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
Exclusive benefit for CC Switch users: Register via <a href="https://ddshub.short.gy/ccswitch">the link </a>below and enjoy an extra 10% credit on your first recharge (please contact the group admin to claim after recharging)!</td>
</tr>
<tr>
<td width="180"><a href="https://claudecn.top"><img src="assets/partners/logos/claudecn.jpg" alt="ClaudeCN" width="150"></a></td>
<td>Thanks to ClaudeCN for sponsoring this project! ClaudeCN is an enterprise-grade AI gateway platform operated by a registered company. It delivers high-availability commercial API access to popular models including Claude, GPT, and DeepSeek, and is built around formal enterprise procurement workflows — corporate bank transfers, signed contracts, and full compliance. Register via <a href="https://claudecn.top">this link</a>!</td>
</tr>
<tr>
<td width="180"><a href="https://runapi.co"><img src="assets/partners/logos/runapi.jpg" alt="RunAPI" width="150"></a></td>
<td>Thanks to RunAPI for sponsoring this project! RunAPI is a high-performance and reliable AI model API gateway — one API key gives you access to 150+ mainstream models including OpenAI, Claude, Gemini, DeepSeek, and Grok, with prices as low as 10% of the official rate and excellent stability. It works seamlessly with Claude Code, OpenClaw, and other tools. Exclusive benefit for CC Switch users: register and contact customer support to claim a free ¥14 credit. Register via <a href="https://runapi.co">this link</a>!</td>
</tr>
</table>
</details>
+21 -42
View File
@@ -2,7 +2,7 @@
# CC Switch
### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes Agent のオールインワン管理ツール
### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw のオールインワン管理ツール
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
@@ -11,8 +11,6 @@
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
### 🌐 唯一の公式サイト:**[ccswitch.io](https://ccswitch.io)**
[English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md)
</div>
@@ -44,33 +42,21 @@ MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.svg" alt="Shengsuanyun" width="150"></a></td>
<td>胜算雲(Shengsuanyun)のご支援に感謝します!胜算雲は AI ネイティブチーム向けのスーパーファクトリーであり、産業グレードの AI タスク並列実行プラットフォームです。モデルマーケットプレイスでは Claude、ChatGPT、Gemini をはじめとする国内外の LLM およびマルチメディアモデルの計算リソースを集約・直接提供。リバースエンジニアリングや品質低下は一切なく、プラットフォーム全体のモデル SLA 可用性は 99.7% に達し、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">監視ダッシュボード</a>は常時グリーン表示です。さらにエンタープライズ向けカスタムゲートウェイを提供し、チームのきめ細かなコスト・権限管理、スマートルーティング、セキュリティ保護、BYOK(自社キー持ち込み)ホスティングを実現します。従量課金およびトークンプラン(近日公開)対応で、請求書発行にも対応。<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">このリンク</a>から新規登録すると 10 元分のクレジットと初回チャージ 10% ボーナスが付与されます。</td>
</tr>
<tr>
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
<td>SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。<a href="https://cloud.siliconflow.cn/i/drGuwc9k">このリンク</a>から登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥20 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定リレーサービスを提供しており、エンタープライズ級の同時接続、迅速な請求書発行、24時間年中無休の専用テクニカルサポートを備えています。
Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / 2% / 9%、チャージ時にはさらに割引!AICodeMirror は CC Switch ユーザー向けに特別特典を用意:<a href="https://www.aicodemirror.com/register?invitecode=9915W3">このリンク</a>から登録すると初回チャージ 20% オフ、法人のお客様は最大 25% オフ!</td>
</tr>
<tr>
<td width="180"><a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/"><img src="assets/partners/logos/pateway.png" alt="PatewayAI" width="150"></a></td>
<td>PatewayAI のご支援に感謝します!PatewayAI はヘビーな AI 開発者向けに、公式直結の高品質モデル API 中継サービスを専門に提供するプロバイダーです。Claude シリーズ全モデルおよび Codex シリーズに対応し、100% 公式ソースから直接提供。混ぜ物・水増しは一切なく、検証も歓迎します。課金は透明で、トークン単位の請求書を 1 件ずつ照合可能です。
エンタープライズ級の高同時接続にも対応し、法人のお客様には専用の管理プラットフォームを提供。正式契約および請求書発行に対応しており、詳細は公式サイトの連絡先よりお問い合わせください。
現在、<a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">このリンク</a>からご登録いただくと $3 のトライアルクレジットを進呈。チャージは最安で元価格の 60%、友達紹介は双方にボーナスが付与され、紹介報酬は最大 $150!</td>
</tr>
<tr>
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は ByteDance がグローバル市場向けに独自開発したフルモーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャを基盤に、テキスト・画像・音声・動画の統合的な理解と生成をサポートします。エージェント連携をネイティブに実現し、強力な推論、長時間タスクの実行、ツール統合、コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど幅広いシナリオに適用可能で、マルチモーダル認識、エンドツーエンドの複雑なタスク遂行、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて手軽に利用・デプロイできます。<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">このリンク</a>からご登録いただくと、モデルごとに 500,000 トークンの無料推論クォータを進呈します。</td>
</tr>
<tr>
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
<td>SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。<a href="https://cloud.siliconflow.cn/i/drGuwc9k">このリンク</a>から登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥16 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。</td>
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>Cubence のご支援に感謝します!Cubence は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームで、従量課金や月額プランなど柔軟な料金体系を提供しています。CC Switch ユーザー向けの特別割引:<a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">このリンク</a>で登録し、チャージ時に「CCSWITCH」クーポンを入力すると、毎回 10% オフになります!</td>
@@ -83,9 +69,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
<tr>
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Compshare" width="150"></a></td>
<td>Compshare のご支援に感謝します!Compshare は UCloud 傘下の AI クラウドプラットフォームで、国内外の安定した包括的なモデル API を 1 つのキーだけで利用可能。月額・都度課金のコストパフォーマンスに優れた国内モデル Coding Plan パッケージを提供し、公式リレーによる安定した海外モデルも利用できます。Claude Code、Codex および API アクセスに対応。エンタープライズ級の高同時接続、24 時間年中無休のテクニカルサポート、セルフサービス請求書発行に対応。<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">こちらのリンク</a>から登録すると、無料で 5 元分のプラットフォーム体験クレジットがもらえます!</td>
<td>Compshare のご支援に感謝します!Compshare は UCloud 傘下の AI クラウドプラットフォームで、国内外の安定した包括的なモデル API を 1 つのキーだけで利用可能。月額・従量課金のコストパフォーマンスに優れた Coding Plan パッケージを提供し、公式価格の 60〜80% オフで利用できます。Claude Code、Codex および API アクセスに対応。エンタープライズ級の高同時接続、24 時間年中無休のテクニカルサポート、セルフサービス請求書発行に対応。<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">こちらのリンク</a>から登録すると、無料で 5 元分のプラットフォーム体験クレジットがもらえます!</td>
</tr>
<tr>
<tr>
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
<td>AICoding.sh のご支援に感謝します!AICoding.sh —— グローバル AI モデル API 超お得な中継サービス!Claude Code 81% オフ、GPT 99% オフ!数百社の企業に高コストパフォーマンスの AI サービスを提供。Claude Code、GPT、Gemini および国内主要モデルに対応、エンタープライズ級の高同時接続、迅速な請求書発行、24 時間年中無休の専属テクニカルサポート。<a href="https://aicoding.sh/i/CCSWITCH">こちらのリンク</a>から登録した CC Switch ユーザーは、初回チャージ 10% オフ!</td>
@@ -93,43 +81,44 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
<tr>
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.png" alt="Crazyrouter" width="150"></a></td>
<td>Crazyrouter のご支援に感謝します!Crazyrouter は高性能 AI API アグリゲーションプラットフォームです。1 つの API キーで Claude Code、Codex、Gemini CLI など 300 以上のモデルにアクセス可能。全モデルが公式価格の 55% で利用でき、自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。CC Switch ユーザー向けの限定特典:<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">こちらのリンク</a>から登録後、カスタマーサポートまでご連絡いただくと <strong>$2 の無料クレジット</strong> を受け取れます。さらに初回チャージ時にプロモコード `CCSWITCH` を入力すると <strong>30% のボーナスクレジット</strong> が追加されます!</td>
<td>Crazyrouter のご支援に感謝します!Crazyrouter は高性能 AI API アグリゲーションプラットフォームです。1 つの API キーで Claude Code、Codex、Gemini CLI など 300 以上のモデルにアクセス可能。全モデルが公式価格の 55% で利用でき、自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。CC Switch ユーザー向けの限定特典:<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">こちらのリンク</a>から登録すると <strong>$2 の無料クレジット</strong> を即時進呈。さらに初回チャージ時にプロモコード `CCSWITCH` を入力すると <strong>30% のボーナスクレジット</strong> が追加されます!</td>
</tr>
<tr>
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
<td>本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデル向け中継サービスを安定して提供しており、従量課金と月額プランの 2 つの料金体系から選択できます。チャージ後に請求書の発行が可能で、法人・チームのお客様には専任担当による個別対応も行っています。さらにCC Switch ユーザー向けの特別優待として、<a href="https://www.right.codes/register?aff=CCSWITCH">こちらのリンク</a>から登録すると、チャージのたびに実際の支払額の 5% 相当の従量課金クレジットが付与されます。</td>
<td>本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデルに対応した中継(プロキシ)サービスを安定して出しています。特に高いコストパフォーマンスを誇る Codex の月刊プランを主力としており、<strong>未使用分の利用枠を翌日に繰り越して利用できる(繰越対応)</strong>점이特长です。チャージ(入金)後に請求書の発行が可能で、企業・チーム向けには専任担当による個別対応も行っています。さらに CC Switch ユーザー向けの特別優待として、<a href="https://www.right.codes/register?aff=CCSWITCH">こちらのリンク</a>から登録と、チャージのたびに実支払額の 25% 相当の従量課金クレジットが付与されます。</td>
</tr>
<tr>
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
<td>SSSAiCode のご支援に感謝します!SSSAiCode は安定性と信頼性に優れた API 中継サービスで、安定的で信頼性が高く、手頃な価格の Claude・Codex モデルサービスを提供しています。当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典:<a href="https://www.sssaicode.com/register?ref=DCP0SM">こちらのリンク</a>から登録すると、毎回のチャージで $10 の追加ボーナスを受けられます!</td>
<td>SSSAiCode のご支援に感謝します!SSSAiCode は安定性と信頼性に優れた API 中継サービスで、安定的で信頼性が高く、手頃な価格の Claude・Codex モデルサービスを提供しています。<strong>高コストパフォーマンスの公式 Claude サービスを 0.5¥/$ 換算で提供</strong>、月額制・Paygo など多様な課金方式に対応し、当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典:<a href="https://www.sssaicode.com/register?ref=DCP0SM">こちらのリンク</a>から登録すると、毎回のチャージで $10 の追加ボーナスを受けられます!</td>
</tr>
<tr>
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
<td>Micu API のご支援に感謝します!Micu API は、最高のコストパフォーマンスと高い安定性を追求するグローバル大規模言語モデル中継サービスプロバイダーです。法人企業がバックアップしており、サービス停止のリスクを排除、迅速な正規請求書発行に対応!「試行コストゼロ」をモットーに、最低 1 元からチャージ可能で手数料無料、いつでも返金可能!CC Switch ユーザー向けの限定特典:<a href="https://www.micuapi.ai/register?aff=aOYQ">こちらのリンク</a>から登録し、チャージ時にプロモコード「ccswitch」を入力すると <strong>10% 割引</strong> が適用されます!</td>
<td width="180"><a href="https://www.openclaudecode.cn/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
<td>Micu API のご支援に感謝します!Micu API は、最高のコストパフォーマンスと高い安定性を追求するグローバル大規模言語モデル中継サービスプロバイダーです。法人企業がバックアップしており、サービス停止のリスクを排除、迅速な正規請求書発行に対応!「試行コストゼロ」をモットーに、最低 1 元からチャージ可能で手数料無料、いつでも返金可能!CC Switch ユーザー向けの限定特典:<a href="https://www.openclaudecode.cn/register?aff=aOYQ">こちらのリンク</a>から登録し、チャージ時にプロモコード「ccswitch」を入力すると <strong>10% 割引</strong> が適用されます!</td>
</tr>
<tr>
<td width="180"><a href="https://lemondata.cc/r/FFX1ZDUP"><img src="assets/partners/logos/lemondata.png" alt="LemonData" width="150"></a></td>
<td>LemonData のご支援に感謝します!LemonData は高性能 AI API アグリゲーションプラットフォームで、GPT、Claude、Gemini、DeepSeek など 300 以上のモデルに 1 つの API キーでアクセス可能。全モデルが公式価格の 30〜70% オフで自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。新規ユーザーは登録だけで即座に $1 の無料クレジットを獲得 — <a href="https://lemondata.cc/r/FFX1ZDUP">こちらのリンク</a>から登録してボーナスを獲得し、すぐに開発を始めましょう!</td>
</tr>
<tr>
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
<td>CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href="https://ctok.ai">こちら</a>から登録してください!</td>
</tr>
<tr>
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
<td>ChefShop AI のご支援に感謝します!ChefShop AI は、AI ヘビーユーザー向けにカスタマイズされたプレミアムアカウントサービスプロバイダーです。ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy、Gemini など主流の大規模モデルの公式チャージと安定したアカウントサービスを提供しています。<a href="https://chefshop.ai">こちら</a>から購入してください!</td>
</tr>
<tr>
<td width="180"><a href="https://vibecodingapi.ai"><img src="assets/partners/logos/lioncc.png" alt="LionCC" width="150"></a></td>
<td>LionCC のご支援に感謝します!LionCC は究極の開発体験を追求する「Vibe Coders」のために生まれました。Claude Code、Codex、OpenClaw 向けに安定・低遅延・お得な価格の計算リソースサービスを提供し、最大 50% のコスト削減を実現します。登録後、カスタマーサービスの WeChatHSQBJ088888888)を追加し、合言葉「cc-switch」を送信すると、10 ドル分のクレジット(1,000 万トークン)がもらえます。その他のコラボレーションについてはブログ @LionCC.ai をフォローしてください。<a href="https://vibecodingapi.ai">こちら</a>から登録してください!</td>
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/register?aff=pCLD"><img src="assets/partners/logos/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
<td>本プロジェクトは <a href="https://console.claudeapi.com/register?aff=pCLD">Claude API</a> がスポンサーです。Claude API 直結 — わずか 3 分で Claude Code や Agent アプリに接続可能。新規ユーザーにはテストクレジットを提供しています。Anthropic 公式キーおよび AWS Bedrock 公式チャネルに基づいており、リバースエンジニアリングや性能劣化はありません。Opus / Sonnet / Haiku の全モデルラインナップをサポートし、Tool Use や 1M コンテキストなどの公式機能をすべて保持しています。Claude Code ヘビーユーザー、Agent エンジニア、企業技術チームに最適です。請求書発行およびチーム対応が可能です。<a href="https://console.claudeapi.com/register?aff=pCLD">こちら</a>から登録してください!</td>
</tr>
<tr>
<td width="180"><a href="https://ddshub.short.gy/ccswitch"><img src="assets/partners/logos/dds.png" alt="DDS" width="150"></a></td>
<td>本プロジェクトのスポンサーである DDS に感謝いたします! DDS(呆呆獣 / DDS Hub)は、Claude に特化した信頼性とパフォーマンスの高い API プロキシサービスです。個人および企業ユーザーの皆様に、圧倒的なコストパフォーマンスを誇る Claude 直結アクセラレーションサービスを提供しています。Claude Haiku / Opus / Sonnet などのフルスペックモデルを完全サポートし、安定した低遅延のアクセスを実現します。
@@ -137,16 +126,6 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
CC Switch ユーザー限定特典: 専用リンクからご<a href="https://ddshub.short.gy/ccswitch">登録</a>いただくと、初回チャージ時に 10% の追加ボーナスクレジット をプレゼントいたします!(※チャージ完了後、グループ管理人へご連絡の上お受け取りください。)</td>
</tr>
<tr>
<td width="180"><a href="https://claudecn.top"><img src="assets/partners/logos/claudecn.jpg" alt="ClaudeCN" width="150"></a></td>
<td>本プロジェクトのスポンサーである ClaudeCN に感謝いたします!ClaudeCN は、実体のある企業によって運営されるエンタープライズ向け AI ゲートウェイプラットフォームです。Claude、GPT、DeepSeek など主要モデルへの高可用な商用 API アクセスを提供し、企業の調達プロセスにも対応 — 法人振込や正式契約に対応し、コンプライアンス面でも安心してご利用いただけます。<a href="https://claudecn.top">こちら</a>からご登録ください!</td>
</tr>
<tr>
<td width="180"><a href="https://runapi.co"><img src="assets/partners/logos/runapi.jpg" alt="RunAPI" width="150"></a></td>
<td>本プロジェクトのスポンサーである RunAPI に感謝いたします!RunAPI は高効率で安定した AI モデル API ゲートウェイです。一つの API Key で、OpenAI、Claude、Gemini、DeepSeek、Grok など 150 種類以上の主要モデルにアクセス可能。料金は公式価格の最大 10%、安定性にも優れ、Claude Code や OpenClaw などのツールとシームレスに連携できます。CC Switch ユーザー限定特典:ご登録後にカスタマーサポートへご連絡いただくと、14 元の無料クレジットを進呈いたします。<a href="https://runapi.co">こちら</a>からご登録ください!</td>
</tr>
</table>
</details>
+19 -42
View File
@@ -2,7 +2,7 @@
# CC Switch
### Claude Code、Codex、Gemini CLI、OpenCodeOpenClaw 和 Hermes Agent 的全方位管理工具
### Claude Code、Codex、Gemini CLI、OpenCodeOpenClaw 的全方位管理工具
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
@@ -11,8 +11,6 @@
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
### 🌐 唯一官方网站:**[ccswitch.io](https://ccswitch.io)**
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md)
</div>
@@ -44,33 +42,21 @@ MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构
</tr>
<tr>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.svg" alt="Shengsuanyun" width="150"></a></td>
<td>感谢胜算云赞助了本项目!胜算云是专为AI Native Teams服务的超级工厂,工业级AI任务并行执行平台,模型商城集采直供聚合接入了Claude、Chatgpt、Gemini等海内外LLM及图片视频多媒体模型算力,绝无逆向掺水、全站模型SLA可用性高达99.7%、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">监测接口</a>日常全绿。更有企业级专属定制网关,实现团队精细化成本与权限管控,智能路由+安全防护+BYOK企业自带密钥托管。平台按量及tokens plan(即将上线)计费,可开票,使用<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">此链接</a>注册新用户可获10元模力及首充10%赠送。</td>
</tr>
<tr>
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_zh.jpg" alt="SiliconFlow" width="150"></a></td>
<td>感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过<a href="https://cloud.siliconflow.cn/i/drGuwc9k">此链接</a>注册并完成实名认证,即可获得 ¥20 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。
Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CCSwitch 的用户提供了特别福利,通过<a href="https://www.aicodemirror.com/register?invitecode=9915W3">此链接</a>注册的用户,可享受首充8折,企业客户最高可享 7.5 折!</td>
</tr>
<tr>
<td width="180"><a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/"><img src="assets/partners/logos/pateway.png" alt="PatewayAI" width="150"></a></td>
<td>感谢 PatewayAI 赞助了本项目!PatewayAI 是一家面向重度 AI 开发者、专注官方直连高品质模型 API 中转服务商。提供 Claude 全系列与 Codex 系列模型,100% 官方源直供,不掺假不注水,欢迎检验。计费透明,Token 级账单可逐笔核验。
同时支持企业级高并发,并为企业客户提供了专业的管理平台,企业客户可签订正式合同并开具发票,更多详情进入官网获取联系方式。
现在通过<a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">此链接</a>注册即送 $3 试用额度,用户充值低至 6 折,邀请好友双向赠送,邀请奖励可达 $150!</td>
</tr>
<tr>
<td width="180"><a href="https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
<td>感谢火山方舟Agent Plan 模型赞助了本项目!方舟Agent Plan 模型订阅套餐集成了包含Doubao-Seed、Doubao-Seedance、Doubao-Seedream等在内的字节跳动自研SOTA级模型,覆盖文本、代码、图像、视频等多模态任务。同时支持一站式接入DeepSeek V4、GLM 5.1等主流大模型。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。方舟 Agent Plan 为 CC Switch 的用户提供了专属福利:通过<a href="https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">此链接</a>订阅方舟AgentPlan,新客户首月40元起!</td>
</tr>
<tr>
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_zh.jpg" alt="SiliconFlow" width="150"></a></td>
<td>感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过<a href="https://cloud.siliconflow.cn/i/drGuwc9k">此链接</a>注册并完成实名认证,即可获得 ¥16 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。</td>
</tr>
<tr>
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
<td>感谢 Cubence 赞助本项目!Cubence 是一家可靠高效的 API 中继服务提供商,提供对 Claude Code、Codex、Gemini 等模型的中继服务,并提供按量、包月等灵活的计费方式。Cubence 为 CC Switch 的用户提供了特别优惠:使用 <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">此链接</a> 注册,并在充值时输入 "CCSWITCH" 优惠码,每次充值均可享受九折优惠!</td>
@@ -84,7 +70,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
<tr>
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="优云智算" width="150"></a></td>
<td>感谢优云智算赞助了本项目!优云智算是UCloud旗下AI云平台,提供稳定、全面的国内外模型API,仅一个key即可调用。主打包月、按的高性价比 国模Coding Plan套餐,同时提供官转稳定海外模型。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24技术支持、自助开票。通过<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">此链接</a>注册的用户,可得免费5元平台体验金!</td>
<td>感谢优云智算赞助了本项目!优云智算是UCloud旗下AI云平台,提供稳定、全面的国内外模型API,仅一个key即可调用。主打包月、按的高性价比 Coding Plan 套餐,基于官方2~5折优惠。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24技术支持、自助开票。通过<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">此链接</a>注册的用户,可得免费5元平台体验金!</td>
</tr>
<tr>
@@ -94,58 +80,49 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
<tr>
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.png" alt="Crazyrouter" width="150"></a></td>
<td>感谢 Crazyrouter 赞助了本项目!Crazyrouter 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 300+ 模型,包括 Claude Code、Codex、Gemini CLI 等。全部模型低至官方定价的 55%,支持自动故障转移、智能路由和无限并发。Crazyrouter 为 CC Switch 用户提供了专属优惠:通过<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">此链接</a>注册后联系客服即可领取 <strong>$2 免费额度</strong>,首次充值时输入优惠码 `CCSWITCH` 还可获得额外 <strong>30% 奖励额度</strong></td>
<td>感谢 Crazyrouter 赞助了本项目!Crazyrouter 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 300+ 模型,包括 Claude Code、Codex、Gemini CLI 等。全部模型低至官方定价的 55%,支持自动故障转移、智能路由和无限并发。Crazyrouter 为 CC Switch 用户提供了专属优惠:通过<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">此链接</a>注册即可获得 <strong>$2 免费额度</strong>,首次充值时输入优惠码 `CCSWITCH` 还可获得额外 <strong>30% 奖励额度</strong></td>
</tr>
<tr>
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
<td>感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过<a href="https://www.right.codes/register?aff=CCSWITCH">此链接</a>注册,每次充值均可获得实付金额5%的按量额度!</td>
<td>感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务。主打<strong>极高性价比</strong>的Codex包月套餐,<strong>提供额度转结,套餐当天用不完的额度,第二天还能接着用!</strong>充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过<a href="https://www.right.codes/register?aff=CCSWITCH">此链接</a>注册,每次充值均可获得实付金额25%的按量额度!</td>
</tr>
<tr>
<td width="180"><a href="https://www.sssaicode.com/register?ref=DCP0SM"><img src="assets/partners/logos/sssaicode.png" alt="SSSAiCode" width="150"></a></td>
<td>感谢 SSSAiCode 赞助了本项目!SSSAiCode 是一家稳定可靠的API中转站,致力于提供稳定、可靠、平价的Claude、CodeX模型服务,支持当日快速开票,SSSAiCode为本软件的用户提供特别优惠,使用<a href="https://www.sssaicode.com/register?ref=DCP0SM">此链接</a>注册每次充值均可享受10$的额外奖励!</td>
<td>感谢 SSSAiCode 赞助了本项目!SSSAiCode 是一家稳定可靠的API中转站,致力于提供稳定、可靠、平价的Claude、CodeX模型服务,<strong>提供高性价比折合0.5¥/$的官方Claude服务</strong>,支持包月、Paygo多种计费方式、支持当日快速开票,SSSAiCode为本软件的用户提供特别优惠,使用<a href="https://www.sssaicode.com/register?ref=DCP0SM">此链接</a>注册每次充值均可享受10$的额外奖励!</td>
</tr>
<tr>
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
<td>感谢 米醋API 赞助了本项目!米醋API 是一家致力于提供极致性价比与高稳定性的全球大模型中转服务商。米醋API 背后有实体企业做核心保障,杜绝跑路风险,支持极速正规开票!我们主打“试错零成本”:1 元起充低门槛,0 手续费随时退款!米醋API 为本软件的用户提供了特别优惠,使用<a href="https://www.micuapi.ai/register?aff=aOYQ">此链接</a>注册并在充值时填写"ccswitch"优惠码可享九折优惠!</td>
<td width="180"><a href="https://www.openclaudecode.cn/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
<td>感谢 米醋API 赞助了本项目!米醋API 是一家致力于提供极致性价比与高稳定性的全球大模型中转服务商。米醋API 背后有实体企业做核心保障,杜绝跑路风险,支持极速正规开票!我们主打“试错零成本”:1 元起充低门槛,0 手续费随时退款!米醋API 为本软件的用户提供了特别优惠,使用<a href="https://www.openclaudecode.cn/register?aff=aOYQ">此链接</a>注册并在充值时填写"ccswitch"优惠码可享九折优惠!</td>
</tr>
<tr>
<td width="180"><a href="https://lemondata.cc/r/FFX1ZDUP"><img src="assets/partners/logos/lemondata.png" alt="LemonData" width="150"></a></td>
<td>感谢 LemonData 赞助了本项目!LemonData 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 GPT、Claude、Gemini、DeepSeek 等 300+ 模型。所有模型定价为官方价格的 30%-70%,支持自动故障转移、智能路由和无限并发。新用户注册即获 $1 免费额度——通过<a href="https://lemondata.cc/r/FFX1ZDUP">此链接</a>注册即可领取奖励,立即开始开发!</td>
</tr>
<tr>
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
<td>感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://ctok.ai">这里</a>注册!</td>
</tr>
<tr>
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
<td>感谢 厨师长AI小铺 赞助了本项目!厨师长AI小铺 是一家专为 AI 重度订阅用户量身定制的优质账号服务商。平台提供涵盖 ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy 以及 Gemini 等主流大模型的官方代充与稳定成品账号服务。点击<a href="https://chefshop.ai">这里</a>购买!</td>
</tr>
<tr>
<td width="180"><a href="https://vibecodingapi.ai"><img src="assets/partners/logos/lioncc.png" alt="LionCC" width="150"></a></td>
<td>感谢 LionCC 狮子API 赞助了本项目!LionCC 专为追求极致开发体验的”Vibe Coders”而生。我们提供稳定、低延迟、优惠价格的 Claude Code、Codex 及 OpenClaw 算力服务,可节约 50% 成本。注册后添加客服微信 HSQBJ088888888,发暗号 cc-switch 备注即可送 10 美金额度(1000 万 token 算力)。其他项目合作关注博客 @LionCC.ai,点击<a href=”https://vibecodingapi.ai”>这里</a>注册!</td>
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/register?aff=pCLD"><img src="assets/partners/logos/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
<td>本项目由 <a href="https://console.claudeapi.com/register?aff=pCLD">Claude API</a> 赞助。Claude API 直连,三分钟接入 Claude Code 与 Agent 应用 新用户可领取测试额度。基于 Anthropic 官方 Key + AWS Bedrock 官方渠道,非逆向、非降智,支持 Opus / Sonnet / Haiku 全系列模型,保留 Tool Use、1M 上下文等官方能力。适合 Claude Code 深度用户、Agent 工程师与企业技术团队,支持开票和团队对接。点击<a href="https://console.claudeapi.com/register?aff=pCLD">这里</a>注册!</td>
</tr>
<tr>
<td width="180"><a href="https://ddshub.short.gy/ccswitch"><img src="assets/partners/logos/dds.png" alt="DDS" width="150"></a></td>
<td>感谢 DDS 赞助本项目!呆呆兽是一家专注 Claude 的可靠高效 API 中转站,为个人和企业用户提供极具性价比的国内 Claude 直连加速服务。支持 Claude Haiku / Opus / Sonnet 等满血模型。充值满 1000 元即可开具发票,企业客户更可享受定制化分组和技术支持服务。CC Switch 用户专属福利:通过<a href="https://ddshub.short.gy/ccswitch">此链接</a>注册后,首单充值可额外赠送 10% 额度(充值后请联系群主领取)!</td>
</tr>
<tr>
<td width="180"><a href="https://claudecn.top"><img src="assets/partners/logos/claudecn.jpg" alt="ClaudeCN" width="150"></a></td>
<td>感谢 ClaudeCN 赞助本项目!ClaudeCN 由是一家实体企业运营的企业级AI中转平台。平台可提供高可用性的商用API服务,提供Claude、GPT、Deepseek等热门模型,支持企业采购流程,可对公打款、签约,服务合规有保障。点击<a href="https://claudecn.top">此链接</a>注册!</td>
</tr>
<tr>
<td width="180"><a href="https://runapi.co"><img src="assets/partners/logos/runapi.jpg" alt="RunAPI" width="150"></a></td>
<td>感谢 RunAPI 赞助本项目!RunAPI 是高效稳定的 AI 模型 API 中转平台,一个 API Key 即可访问 OpenAI、Claude、Gemini、DeepSeek、Grok 等 150+ 主流模型,低至 1 折,极其稳定,可以无缝兼容 Claude Code、OpenClaw 等工具。RunAPI为CC switch的用户提供了特别福利,注册后联系客服可以领取14元额度,点击<a href="https://runapi.co">此链接</a>注册!</td>
</tr>
</table>
</details>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 511 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 31 KiB

-469
View File
@@ -1,469 +0,0 @@
# CC Switch v3.14.0
> Hermes Agent becomes the 6th managed app, Claude Opus 4.7 rolls out across the preset matrix, Gemini Native API proxy, "Local Routing" rename, and application-level window controls
**[中文版 →](v3.14.0-zh.md) | [日本語版 →](v3.14.0-ja.md)**
---
## Overview
CC Switch v3.14.0 is a major release centered on onboarding **Hermes Agent as the 6th first-class managed app** and rolling out **Claude Opus 4.7** across the full aggregator and Bedrock preset matrix. Hermes support covers a database v9 → v10 migration, a complete Rust command surface, YAML-backed `~/.hermes/config.yaml` read/write with atomic backups, MCP sync, Skills sync, SQLite + JSONL session management, and dedicated frontend panels including a Memory editor. All four API protocols aligned with Hermes Agent 0.10.0 (`chat_completions`, `anthropic_messages`, `codex_responses`, `bedrock_converse`) are selectable. Providers owned by the user-authored `providers:` dict are rendered as read-only cards, and deep YAML configuration is delegated directly to the Hermes Web UI.
Beyond Hermes, this release adds a **Gemini Native API proxy** (`api_format = "gemini_native"`) so the proxy can forward directly to Google's `generateContent` endpoint with full streaming, schema conversion, and shadow request support; renames the legacy "Local Proxy Takeover" to **Local Routing** across UI copy, README, and docs in all three locales; introduces **application-level window controls**, an opt-in setting that materially improves the experience on Linux Wayland where compositor-drawn buttons can become inert; and bundles late additions for launching `hermes dashboard` from the toolbar, a LemonData preset across all six apps, a DDSHub Codex endpoint, plus several Hermes health-check and Usage modal fixes.
On the session side, the message list is **virtualized** via `@tanstack/react-virtual` so conversations with thousands of records scroll smoothly and long messages collapse by default; the Usage dashboard adds a **date range picker** (Today / 1d / 7d / 14d / 30d + custom date-time calendar) and a page-jump input; **Stream Check error classification** now surfaces color-coded toasts with refreshed default probe models and an explicit "model not found" branch; and switching to official providers is **blocked while Local Routing is active** to avoid account-suspension risk. The pricing database is reseeded from v8 → v9 with ~50 new model entries (Claude 4.7, Opus 4.7 Adaptive Thinking, Grok 4, Qwen 3.5/3.6, MiniMax M2.5/M2.7, Doubao Seed 2.0 series, GLM-5/5.1 and others) and corrected stale prices.
**Release Date**: 2026-04-21
**Update Scale**: 100 commits | 219 files changed | +20,548 / -3,569 lines
---
## Highlights
- **Hermes Agent Support (6th Managed App)**: Database v9 → v10 migration, full Rust command surface, YAML read/write with atomic backups, MCP sync, Skills sync, SQLite + JSONL session management, dedicated frontend panels, and four API protocols (`chat_completions` / `anthropic_messages` / `codex_responses` / `bedrock_converse`)
- **Claude Opus 4.7 Rollout**: Adaptive thinking whitelisting, per-million pricing seed, Bedrock SKU (`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`, dropping the legacy `-v1` suffix); all aggregator and Bedrock presets migrated to Opus 4.7 as the default Opus model
- **Claude `max` Effort Tier**: Effort dropdown upgraded from `high` to `max`
- **Gemini Native API Proxy**: New `api_format = "gemini_native"` forwards directly to Google's `generateContent` with full streaming / schema conversion / shadow request support
- **GitHub Copilot Enterprise Server**: GHES authentication and endpoint configuration for Copilot-backed Claude providers
- **Copilot Premium Consumption Deep Optimization**: Proactive thinking-block stripping before forwarding, `tool_result` classification fix, subagent detection, `x-interaction-id` billing merge, orphan `tool_result` sanitization, and default warmup downgrade — a systematic reduction in premium interaction consumption
- **Session List Virtualization**: Long conversations scroll smoothly and long messages collapse by default to reduce text layout cost
- **Codex / OpenClaw Session Title Extraction**: Meaningful title extraction with 2-line display; strips OpenClaw `message_id` suffix noise
- **Usage Date Range Picker**: Today / 1d / 7d / 14d / 30d preset tabs + custom date-time calendar; page-jump input on paginated lists
- **Stream Check Error Classification**: Color-coded error toasts; refreshed default probe models; explicit "model not found" detection
- **Block Official Provider Switching During Local Routing**: Routing official API traffic through the local proxy carries account-suspension risk — switches are blocked with a warning toast
- **Pricing Database Refresh (v8 → v9)**: ~50 new model entries and corrected stale prices
- **Application-Level Window Controls**: Opt-in setting to render CC Switch's own min/max/close buttons, materially improving Linux Wayland experience
- **Hermes in Unified Skills Management**: Skill install, enable, and filter now cover Hermes
- **Hermes / OpenClaw Config Directory Override**: Point CC Switch at a custom `~/.hermes/config.yaml` or `openclaw.json` location
- **Launch Hermes Dashboard from Toolbar**: When the Hermes Web UI probe fails, the toolbar entry offers to run `hermes dashboard` in the user's preferred terminal
- **New Partner Presets**: LemonData across all six apps; DDSHub Codex endpoint; StepFun Step Plan
---
## Added
### Hermes Agent Support (6th Managed App)
CC Switch now treats Hermes Agent as a first-class managed app alongside Claude / Codex / Gemini / OpenCode / OpenClaw.
- **Database Migration v9 → v10**: Adds `enabled_hermes` columns to `mcp_servers` and `skills` tables (`DEFAULT 0`, auto-migrated, no data loss)
- **YAML Configuration Read/Write**: `~/.hermes/config.yaml` read/write with atomic backups; `tests/hermes_roundtrip.rs` guards against dropped OAuth MCP `auth` blocks or pollution of unrelated YAML keys
- **Four API Protocols**: Aligned with Hermes Agent 0.10.0 — `chat_completions` / `anthropic_messages` / `codex_responses` / `bedrock_converse`; new deeplinks default to `chat_completions`
- **User `providers:` Dict Read-Only Rendering**: User-authored providers in the YAML appear as read-only cards in CC Switch; deep configuration delegates to the Hermes Web UI
- **Additive Switching**: Unlike Claude / Codex's "override" style, all Hermes providers coexist in the same YAML
### Hermes Memory Panel
- New Memory panel for editing `MEMORY.md` / `USER.md` directly, with an enable switch, character-count limits, and a live save flow
- Replaces the Prompts entry for Hermes
### Hermes Provider Presets (~50)
- Covers Nous Research, Shengsuanyun, OpenRouter, DeepSeek, Together AI, StepFun, Zhipu GLM, Bailian, Kimi, MiniMax, DouBao, BaiLing, ModelScope, KAT-Coder, PackyCode, Cubence, AIGoCode, RightCode, AICodeMirror, AICoding, CrazyRouter, SSSAiCode, Micu, CTok.ai, DDSHub, E-FlowCode, LionCCAPI, PIPELLM, Compshare, SiliconFlow, AiHubMix, DMXAPI, TheRouter, Novita, Nvidia, and Xiaomi MiMo
### Launch Hermes Dashboard from Toolbar
- When the Hermes Web UI probe fails, the toolbar entry opens a confirm dialog offering to run `hermes dashboard` in the user's preferred terminal
- Spawned via a temp bash / batch script; `hermes dashboard` opens the browser itself once ready, so no polling is required
- The Memory panel and Health banner keep the existing toast behavior
- Also corrects the stale `hermes web` hint in the offline toast (the real command is `hermes dashboard`)
- Linux terminal detection reordered to try `which` before stat'ing `/usr/bin`, `/bin`, `/usr/local/bin`
### Claude Opus 4.7 Support
- New Claude Opus 4.7 with adaptive thinking whitelisting, per-million pricing seed, and Bedrock SKU (`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`, dropping the legacy `-v1` suffix)
- All aggregator and Bedrock presets migrated to Opus 4.7 as the default Opus model
### Claude `max` Effort Tier
- Claude effort dropdown upgraded from `high` to `max` for extended reasoning capacity
### Gemini Native API Proxy
- New `api_format = "gemini_native"` so the proxy can forward directly to Google's `generateContent` API (#1918, thanks @yovinchen)
- Full streaming, schema conversion, and shadow request support
- Adds `gemini_url.rs`, `gemini_schema.rs`, `gemini_shadow.rs`, `streaming_gemini.rs`, and `transform_gemini.rs` under the proxy providers module
### GitHub Copilot Enterprise Server (GHES)
- GHES authentication and endpoint configuration for Copilot-backed Claude providers (#2175, thanks @hotelbe)
### Session List Virtualization
- Virtualized the session list via `@tanstack/react-virtual` so long conversations (thousands of records) scroll smoothly
- Long session messages are collapsed by default to reduce text layout cost
### Codex / OpenClaw Session Title Extraction
- Meaningful title auto-extraction for Codex and OpenClaw sessions with 2-line display
- Strips OpenClaw `message_id` suffix noise
### Usage Date Range Picker
- New date range selector on the usage dashboard with preset tabs (Today / 1d / 7d / 14d / 30d) + custom date + time calendar (#2002, thanks @yovinchen)
- Page-jump input added on paginated lists
### Model Mapping Quick-Set
- New quick-set button next to model mapping fields in provider forms for faster edits (#2179, thanks @lispking)
### Stream Check Error Classification
- Stream Check errors are classified and surfaced as color-coded toasts
- Refreshed default probe models to match each vendor's current lineup
- Explicit detection for "model not found" responses
### Block Official Provider Switching During Local Routing
- Switching to official providers is blocked while Local Routing is active, with a warning toast
- Reason: routing official API traffic through the local proxy carries account-suspension risk
### Pricing Database Refresh (v8 → v9)
- Reseed-on-migration pricing table
- ~50 new model pricing entries including Claude 4.7, Opus 4.7 Adaptive Thinking, Grok 4, Qwen 3.5/3.6, MiniMax M2.5/M2.7, Doubao Seed 2.0 series, GLM-5/5.1
- Corrected stale prices for DeepSeek, Kimi K2.5, and others
### Application-Level Window Controls
- Opt-in setting to render CC Switch's own minimize / toggle-maximize / close buttons instead of system decorations (#1119, thanks @git1677967754)
- Materially improves the experience on Linux Wayland where compositor-drawn buttons can become inert
### Hermes in Unified Skills Management
- Hermes is added to the unified Skills surface
- Skill install, enable, and filter now cover the Hermes app alongside Claude / Codex / Gemini / OpenCode / OpenClaw
### OpenClaw Config Directory Override
- New settings option to point CC Switch at a custom `openclaw.json` location (#1518, thanks @mrFranklin)
### Hermes Config Directory Override
- New settings option to point CC Switch at a custom `~/.hermes/config.yaml` location, backed by data-driven dispatch
### StepFun Step Plan Preset
- StepFun Step Plan (EN / ZH) provider presets (#2155, thanks @hengm3467)
### New API Usage Script Template
- Added a User-Agent header to the New API usage script template for better upstream compatibility
### LemonData Provider Preset (All Six Apps)
- LemonData registered as a third-party partner preset across Claude, Codex, Gemini, OpenCode, OpenClaw, and Hermes
- Icon assets and zh / en / ja partner-promotion copy
- Claude preset uses `ANTHROPIC_API_KEY` auth; OpenAI-compatible apps target `gpt-5.4`
### DDSHub Codex Preset
- Added a Codex-compatible endpoint for DDSHub at the same host as its Claude service
- Base URL omits the `/v1` suffix because the gateway auto-routes OpenAI SDK paths
---
## Changed
### "Local Proxy Takeover" → "Local Routing"
- Unified the terminology across UI copy, README, and docs in all three locales
- Functional behavior is unchanged
### Hermes `Auto` api_mode Removed
- Users must pick an explicit protocol; new deeplinks default to `chat_completions`
- Eliminates URL-based heuristic surprises
### Hermes Provider Form
- Added an API mode dropdown and per-provider model editor
- Binds per-provider models to the top-level `model:` when switching active providers
### Hermes Deep Config Delegation
- Deep YAML knobs are no longer duplicated in the CC Switch form — they are delegated to the Hermes Web UI via a direct launch action
### Hermes Toolbar Layout
- Swapped the Hermes Web UI button from `ExternalLink` to `LayoutDashboard` (clicking may spawn `hermes dashboard` rather than just opening a URL)
- Moved MCP to the final toolbar slot so Hermes matches the Claude / Codex / Gemini / OpenCode layout
### `ANTHROPIC_REASONING_MODEL` Removed from Claude Quick-Set
- Decoupled the reasoning capability from model selection; the legacy field is no longer surfaced in the quick-set form
### Per-Provider Proxy Config Removed
- Consolidated into global Local Routing
- Provider-level proxy toggle and associated storage are gone
### Unified Toolbar Icon Button Width
- Normalized icon-button widths across Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes panels for a consistent header look
### Rust Toolchain Pinned to 1.95
- Adopted clippy 1.95 suggestions across the workspace and pinned the toolchain to prevent nightly drift
### Tray Menu ID Constant
- The tray identifier moved from the hardcoded string `"main"` to a `TRAY_ID` constant (`"cc-switch"`) across all call sites (#1978, thanks @lidaxian121)
### Copilot Premium Consumption Deep Optimization
A systematic overhaul to reduce Copilot reverse-proxy premium interaction consumption across multiple dimensions:
- **Proactive Thinking Block Stripping Before Forwarding**: Anthropic's `thinking` / `redacted_thinking` blocks are rejected by OpenAI-compatible endpoints. Previously, the request failed upstream, burning one premium interaction before the `thinking_rectifier` could retry. A new proactive strip step (Copilot optimization pipeline step 3.5, after `tool_result` merging) eliminates that wasted interaction
- **Request Classification Fix**: Messages containing `tool_result` are now classified as agent continuation instead of user-initiated, preventing every tool call from being falsely counted as a premium interaction
- **Subagent Detection**: Identifies subagents via `__SUBAGENT_MARKER__` with `metadata._agent_` fallback, setting `x-interaction-type=conversation-subagent`
- **Deterministic `x-interaction-id` Billing Merge**: Derives `x-interaction-id` from the session ID so multiple requests within the same session collapse into a single billing interaction
- **Orphan `tool_result` Sanitization**: Cleans up orphan `tool_result` entries to prevent upstream errors that would trigger retries and duplicate billing
- **Warmup Downgrade Enabled by Default**: Uses `gpt-5-mini` as the default downgrade model
- **Optimization Pipeline Reorder**: classify → sanitize → merge → warmup, so classification sees raw `tool_result` semantics
- Fixed a `CopilotOptimizerConfig` default-value inconsistency (unified to `gpt-5-mini`)
### Usage Script Intranet Support
- Removed private-IP / suspicious-hostname blocking from usage scripts, unblocking enterprise intranet, Docker, and self-hosted API endpoints
- Built-in templates still enforce HTTPS (except localhost) and same-origin checks; custom templates remain user-controlled with those request-URL checks skipped
### Failover Queue Notes
- Provider notes now appear in failover queue selectors and queue rows for easier identification across multi-provider queues (#2138, thanks @Coconut-Fish)
---
## Fixed
### Header Auto-Compact Latching After Maximize
- The toolbar no longer stays compacted after maximize/restore; compaction now reevaluates on size changes
### Hermes YAML Pollution & OAuth MCP `auth` Drop
- Round-tripping through CC Switch no longer drops OAuth MCP `auth` blocks or pollutes unrelated YAML keys
- Guard tests added via `tests/hermes_roundtrip.rs`
### Hermes Active Provider Display
- Hermes UI now correctly surfaces the active provider and wires add / enable / remove actions
### Hermes Provider Persistence
- Providers persist under `custom_providers:` so `api_mode` and `model` survive restarts and config reloads
### Hermes Health Check Borrowing OpenClaw Schema
- Hermes providers were routed through `check_additive_app_stream` (the OpenClaw dispatcher), which reads camelCase `baseUrl` / `apiKey` / `api` and surfaced "OpenClaw provider is missing baseUrl" even when every Hermes field was filled
- Introduced `check_hermes_stream` with Hermes-specific extractors that map `api_mode` (`chat_completions` / `anthropic_messages` / `codex_responses`) to the matching `check_claude_stream` `api_format`; `bedrock_converse` returns as unsupported
- `api_mode` is now resolved before URL / API key extraction, so `bedrock_converse` users see the real cause rather than a misleading "missing base_url"
### Usage Query Modal for Hermes & OpenClaw
- `getProviderCredentials` now reads flat `settingsConfig` fields for Hermes (snake_case `base_url` / `api_key`) and OpenClaw (camelCase `baseUrl` / `apiKey`), so the "official balance" template auto-selects for matching providers like SiliconFlow
- Refactored the BALANCE and TOKEN_PLAN test paths to reuse the precomputed `providerCredentials` instead of re-reading `env.ANTHROPIC_*` directly, fixing the "empty key" error for non-Claude apps even when the key was configured
### Codex `cache_control` Preservation
- Preserve `cache_control` when merging system prompts during Codex format conversion (#1946, thanks @yovinchen)
### Claude Prompt Cache Key Leak
- Stopped sending prompt cache keys during Claude chat conversions (#2003, thanks @yovinchen)
### Proxy Hop-by-Hop Header Stripping
- Strip hop-by-hop response headers (Connection, Keep-Alive, Transfer-Encoding, etc.) per RFC 7230 (#2060, thanks @yovinchen)
### Permissive Proxy CORS Removed
- Removed the permissive CORS layer from the proxy (#1915, thanks @zerone0x)
### Backend Error Details in Proxy Toast
- Surface backend error payload details in proxy-related toast messages instead of a generic failure string
### Usage Log Deduplication
- Deduplicated proxy and session-log usage records so the same request is no longer double-counted
- Synced the request log time range with the dashboard's 1d / 7d / 30d selector
### Common Config Checkbox Persistence
- Checkbox state for Claude / Codex / Gemini common-config toggles now persists correctly across reopens (#2191, thanks @zxZeng)
### Claude Plugin `settings.json` Sync
- Editing the current provider now syncs back to `settings.json` for the Claude plugin path (#1905, thanks @chengww5217)
### Google Official Gemini Env Preservation
- Saving the Google Official Gemini provider no longer clobbers the `env` block
### OpenCode JSON5 Parser for Trailing Commas
- OpenCode config reads now tolerate trailing commas via a JSON5 parser (#2023, thanks @wwminger)
### Preset Refreshes
- Refreshed stale context windows for DeepSeek and Claude 1M
- Refreshed stale model IDs; backfilled Hermes model lists
- Fixed the Nous endpoint and replaced the Hermes placeholder icon with Nous brand artwork
- Pruned unused official Hermes presets
### Auto-Expand Collapsed Messages on Search Hit
- Collapsed messages now auto-expand when a search match lands inside hidden content
### Unknown Subscription Quota Tiers Hidden
- Provider cards no longer render unknown subscription quota tiers
### Weekly Limit Label Unified
- Aligned the `weekly_limit` tier label with the official 7-day naming across locales
### Root-Level Skill Repo Install
- Fixed skill installation when the repository root itself is a skill
### Session ID Parsing Clippy
- Removed a redundant closure in session ID parsing (clippy warning)
### Stream Check Default Models Refresh
- Updated stream-check default probe models to match each vendor's current lineup
### Skills Import Sync
- Imported Skills are now immediately synced into enabled app directories instead of only being recorded in the database (#2101, thanks @yaoguohh)
- The UI no longer shows "installed" while the target app directory is missing the skill
### Ghostty Session Restore
- Fixed Ghostty session restore launch by using shell execution with `--working-directory` (#1976, thanks @Suda202)
- Avoids `cwd` escaping issues when the path contains spaces or special characters
---
## Docs
### README Sponsor Updates
- Updated SiliconFlow signup bonus to ¥16
- Trimmed the SSSAiCode sponsor blurb
- Updated partner logos
- Added LemonData as a new sponsor
### Global Proxy Hint Clarified
- Clarified the global proxy hint about local routing across all three locales
### Takeover → Routing Rename
- Renamed takeover docs to routing and updated anchors across all languages
### PIPELLM Website URL
- Updated the PIPELLM sponsor website URL to `code.pipellm.ai`
---
## ⚠️ Breaking Changes
### Hermes requires explicit `api_mode`
- The `Auto` mode is gone; imported or deeplinked providers default to `chat_completions`
- Users with prior `Auto` configs will be prompted to pick a protocol
### `ANTHROPIC_REASONING_MODEL` removed from Claude quick-set
- The legacy field is no longer exposed; existing settings are cleaned up automatically
### Per-provider proxy configuration removed
- Migrate to the global Local Routing setting
- Existing per-provider proxy values are ignored
### Database schema v9 → v10
- Adds `enabled_hermes` columns to `mcp_servers` and `skills`
- Auto-migrated with `DEFAULT 0`; no data loss
### Pricing table reseeded (v8 → v9)
- The `model_pricing` table is cleared and reseeded on first launch to pick up new models and corrected prices
### XCodeAPI preset removed
- Users of the XCodeAPI preset should switch to another provider
---
## ⚠️ Risk Notice
This release inherits the risk notices originally introduced in v3.12.3 / v3.13.0 for reverse-proxy-style features.
**GitHub Copilot Reverse Proxy**: Using Copilot's reverse-proxy path may violate GitHub / Microsoft's terms of service. See [v3.12.3 release notes](v3.12.3-en.md#-risk-notice).
**Codex OAuth Reverse Proxy**: Using the Codex OAuth reverse proxy with a ChatGPT subscription may violate OpenAI's terms of service. See [v3.13.0 release notes](v3.13.0-en.md#-risk-notice).
By enabling these features, users **accept all associated risks**. CC Switch is not responsible for any account restrictions, warnings, or service suspensions that result from using these features.
---
## Download & Installation
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
### System Requirements
| OS | Minimum Version | Architecture |
| ------- | ----------------------- | ----------------------------------- |
| Windows | Windows 10 or later | x64 |
| macOS | macOS 12 (Monterey) or later | Intel (x64) / Apple Silicon (arm64) |
| Linux | See table below | x64 |
### Windows
| File | Description |
| ---------------------------------------- | ----------------------------------------------- |
| `CC-Switch-v3.14.0-Windows.msi` | **Recommended** - MSI installer, supports auto-update |
| `CC-Switch-v3.14.0-Windows-Portable.zip` | Portable, extract and run, no registry writes |
### macOS
| File | Description |
| -------------------------------- | -------------------------------------------------------- |
| `CC-Switch-v3.14.0-macOS.dmg` | **Recommended** - DMG installer, drag into Applications |
| `CC-Switch-v3.14.0-macOS.zip` | Extract and drag into Applications, Universal Binary |
| `CC-Switch-v3.14.0-macOS.tar.gz` | For Homebrew installation and auto-update |
> macOS builds are Apple code-signed and notarized — install directly.
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
### Linux
| Distribution | Recommended | Installation |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| 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` | Add execute permission and run, or use AUR |
| Other distros / not sure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
-469
View File
@@ -1,469 +0,0 @@
# CC Switch v3.14.0
> Hermes Agent が 6 番目の管理対象アプリに、Claude Opus 4.7 をプリセットマトリクス全体へ展開、Gemini Native API プロキシ、「Local Routing」への名称統一、アプリケーションレベルのウィンドウコントロール
**[中文版 →](v3.14.0-zh.md) | [English →](v3.14.0-en.md)**
---
## 概要
CC Switch v3.14.0 は、**Hermes Agent を 6 番目の一等管理対象アプリケーション**として CC Switch に取り込み、**Claude Opus 4.7** をアグリゲーターおよび Bedrock プリセットのマトリクス全体に展開することを中心に据えた大型リリースです。Hermes サポートは、データベース v9 → v10 マイグレーション、完全な Rust コマンド面、アトミックバックアップ付きの YAML ベースな `~/.hermes/config.yaml` 読み書き、MCP 同期、Skills 同期、SQLite + JSONL セッション管理、および Memory エディターを含む専用のフロントエンドパネルをカバーします。Hermes Agent 0.10.0 スキーマに整合する 4 つの API プロトコル(`chat_completions``anthropic_messages``codex_responses``bedrock_converse`)すべてを選択可能です。ユーザーが直接記述した `providers:` dict のエントリは読み取り専用カードとして表示され、深い YAML 設定は Hermes Web UI に委譲されます。
Hermes に加えて、本リリースでは **Gemini Native API プロキシ**`api_format = "gemini_native"`)を追加し、プロキシがリクエストを Google の `generateContent` エンドポイントに直接転送できるようにしました(完全なストリーミング、スキーマ変換、シャドウリクエストをサポート)。また、旧「Local Proxy Takeover」を三言語の UI / README / ドキュメント全体で **Local Routing** に統一リネームし、コンポジターが描画するボタンが無反応になり得る Linux Wayland などのシーンで、CC Switch が自前で最小化 / 最大化 / 閉じるボタンを描画できるオプション「**アプリケーションレベルのウィンドウコントロール**」を導入しました。さらにリリース直前に、ツールバーからの `hermes dashboard` 直接起動、LemonData の全アプリプリセット、DDSHub の Codex エンドポイント、および複数の Hermes ヘルスチェックと Usage モーダルの修正が追加されました。
セッション側では、`@tanstack/react-virtual` によるセッションリストの**仮想化**で数千件のレコードを持つ長い会話も滑らかにスクロールでき、長いメッセージはデフォルトで折り畳まれます。Usage ダッシュボードには**日付範囲ピッカー**(今日 / 1d / 7d / 14d / 30d + カスタム日時カレンダー)とページジャンプ入力が追加され、**Stream Check エラー分類**は色分けされたトーストで提示され、デフォルトの探索モデルが更新され、「モデルが見つからない」レスポンスを個別に識別するようになりました。また、Local Routing が有効な間に公式プロバイダーへの切り替えを**強制的にブロック**する保護を追加し、公式 API トラフィックがローカルプロキシを経由することによるアカウント停止リスクを防ぎます。Pricing データベースは v8 → v9 で再シードされ、約 50 件の新しいモデルエントリ(Claude 4.7、Opus 4.7 Adaptive Thinking、Grok 4、Qwen 3.5/3.6、MiniMax M2.5/M2.7、Doubao Seed 2.0 系列、GLM-5/5.1 など)を追加し、いくつかの古い価格を修正しました。
**リリース日**: 2026-04-21
**更新規模**: 100 commits | 219 files changed | +20,548 / -3,569 lines
---
## ハイライト
- **Hermes Agent サポート(6 番目の管理対象アプリ)**: データベース v9 → v10 マイグレーション、完全な Rust コマンド面、アトミックバックアップ付き YAML 読み書き、MCP 同期、Skills 同期、SQLite + JSONL セッション管理、専用フロントエンドパネル、4 つの API プロトコル(`chat_completions` / `anthropic_messages` / `codex_responses` / `bedrock_converse`
- **Claude Opus 4.7 の全面展開**: 適応的思考のホワイトリスト、百万トークン単位の価格シード、Bedrock SKU(`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`、旧 `-v1` サフィックスを廃止)、全アグリゲーター / Bedrock プリセットを Opus 4.7 をデフォルト Opus モデルに移行
- **Claude `max` エフォートティア**: エフォートのドロップダウンを `high` から `max` に引き上げ
- **Gemini Native API プロキシ**: 新しい `api_format = "gemini_native"` により、プロキシが Google の `generateContent` に直接転送可能に(完全なストリーミング / スキーマ変換 / シャドウリクエスト対応)
- **GitHub Copilot Enterprise Server**: Copilot ベースの Claude プロバイダーに GHES 認証とエンドポイント設定を追加
- **Copilot 交互消費の大幅最適化**: 転送前の thinking ブロック主動削除、`tool_result` メッセージ分類修正、subagent 検出、`x-interaction-id` 課金マージ、孤立 `tool_result` のサニタイズ、Warmup ダウングレードのデフォルト有効化など、premium 交互消費を系統的に削減
- **セッションリスト仮想化**: 長い会話が滑らかにスクロール。長いメッセージはデフォルトで折り畳まれ、テキストレイアウトコストを削減
- **Codex / OpenClaw セッションタイトル抽出**: 意味のあるタイトルを自動抽出(2 行表示)、OpenClaw の `message_id` 末尾ノイズを除去
- **Usage 日付範囲ピッカー**: Today / 1d / 7d / 14d / 30d プリセットタブ + カスタム日時カレンダー。ページネーションリストにページジャンプ入力
- **Stream Check エラー分類**: エラーを分類し色分けトーストで提示。デフォルト探索モデル更新。「モデルが見つからない」レスポンスを明示的に検出
- **Local Routing 有効時の公式プロバイダー切り替えブロック**: 公式 API トラフィックをローカルプロキシ経由で流すとアカウント停止のリスクがあるため、切り替えを強制ブロックして警告トーストを表示
- **Pricing データベース刷新(v8 → v9)**: 約 50 件の新しいモデルエントリを追加し、古い価格を修正
- **アプリケーションレベルのウィンドウコントロール**: CC Switch が自前で最小化 / 最大化トグル / 閉じるボタンを描画するオプション設定。Linux Wayland での体験を大きく改善
- **統一 Skills 管理への Hermes 追加**: Skill のインストール / 有効化 / フィルターが Hermes をカバー
- **Hermes / OpenClaw 設定ディレクトリのカスタマイズ**: 設定で `~/.hermes/config.yaml``openclaw.json` のカスタム位置を指定可能
- **ツールバーからの Hermes Dashboard 起動**: Hermes Web UI のプローブに失敗した際、ツールバーエントリからユーザーの優先ターミナルで `hermes dashboard` を実行可能
- **新パートナープリセット**: LemonData を全 6 アプリにわたって追加、DDSHub の Codex エンドポイント、StepFun Step Plan
---
## 新機能
### Hermes Agent サポート(6 番目の管理対象アプリ)
CC Switch は Hermes Agent を Claude / Codex / Gemini / OpenCode / OpenClaw と並ぶ一等の管理対象アプリとして初めてサポートします。
- **データベースマイグレーション v9 → v10**: `mcp_servers``skills` テーブルに `enabled_hermes` カラムを追加(`DEFAULT 0`、自動マイグレーション、データ損失なし)
- **YAML 設定の読み書き**: `~/.hermes/config.yaml` をアトミックバックアップ付きで読み書き。`tests/hermes_roundtrip.rs` が OAuth MCP `auth` ブロックの消失や無関係なキーの汚染を防止
- **4 つの API プロトコル**: Hermes Agent 0.10.0 と整合する `chat_completions` / `anthropic_messages` / `codex_responses` / `bedrock_converse`。新しいディープリンクはデフォルトで `chat_completions`
- **ユーザー `providers:` dict の読み取り専用表示**: YAML に手書きされたプロバイダーエントリは CC Switch で読み取り専用カードとして表示され、深い設定は Hermes Web UI に委譲
- **加算的な切り替え**: Claude / Codex の「上書き」型切り替えと異なり、Hermes ではすべてのプロバイダーが同じ YAML に共存
### Hermes Memory パネル
- `MEMORY.md` / `USER.md` を直接編集できる Memory パネルを追加(有効化スイッチ、文字数制限、ライブ保存フロー付き)
- Hermes の Prompts エントリを置き換え
### Hermes プロバイダープリセット(約 50 個)
- Nous Research、Shengsuanyun(胜算云)、OpenRouter、DeepSeek、Together AI、StepFun、Zhipu GLM、Bailian(百炼)、Kimi、MiniMax、DouBao(豆包)、BaiLing(百灵)、ModelScope(魔搭)、KAT-Coder、PackyCode、Cubence、AIGoCode、RightCode、AICodeMirror、AICoding、CrazyRouter、SSSAiCode、Micu、CTok.ai、DDSHub、E-FlowCode、LionCCAPI、PIPELLM、Compshare、SiliconFlow、AiHubMix、DMXAPI、TheRouter、Novita、Nvidia、Xiaomi MiMo をカバー
### ツールバーからの Hermes Dashboard 起動
- Hermes Web UI のプローブに失敗した際、ツールバーエントリがユーザーの優先ターミナルで `hermes dashboard` を実行する確認ダイアログを表示
- 一時 bash / batch スクリプト経由で起動。`hermes dashboard` 自身が準備完了後にブラウザを開くため、ポーリングは不要
- Memory パネルと Health バナーは既存のトースト動作を維持
- オフラインのトーストにあった古い `hermes web` のヒントも修正(正しいコマンドは `hermes dashboard`
- Linux ターミナル検出の順序を変更し、`/usr/bin``/bin``/usr/local/bin` を stat する前に `which` を試すように
### Claude Opus 4.7 サポート
- Claude Opus 4.7 を追加。適応的思考のホワイトリスト、百万トークン単位の価格シード、Bedrock SKU(`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`、旧 `-v1` サフィックスを廃止)
- 全アグリゲーター / Bedrock プリセットをデフォルト Opus モデルとして Opus 4.7 に移行
### Claude `max` エフォートティア
- Claude エフォートドロップダウンを `high` から `max` に引き上げ、より強力な推論容量を解放
### Gemini Native API プロキシ
- 新しい `api_format = "gemini_native"` により、プロキシが Google の `generateContent` API に直接転送可能 (#1918, 感謝 @yovinchen)
- 完全なストリーミング、スキーマ変換、シャドウリクエストに対応
- proxy providers モジュール下に `gemini_url.rs``gemini_schema.rs``gemini_shadow.rs``streaming_gemini.rs``transform_gemini.rs` を追加
### GitHub Copilot Enterprise ServerGHES
- Copilot ベースの Claude プロバイダーに GHES 認証とエンドポイント設定を追加 (#2175, 感謝 @hotelbe)
### セッションリスト仮想化
- `@tanstack/react-virtual` によりセッションリストを仮想化。数千件のレコードを持つ長い会話も滑らかにスクロール
- 長いセッションメッセージはデフォルトで折り畳まれ、テキストレイアウトコストを削減
### Codex / OpenClaw セッションタイトル抽出
- Codex と OpenClaw セッションから意味のあるタイトルを自動抽出し、2 行表示
- OpenClaw の `message_id` 末尾ノイズを除去
### Usage 日付範囲ピッカー
- Usage ダッシュボードに日付範囲セレクターを追加。プリセットタブ(Today / 1d / 7d / 14d / 30d+ カスタム日時カレンダー (#2002, 感謝 @yovinchen)
- ページネーションリストにページジャンプ入力を追加
### モデルマッピングのクイック入力
- プロバイダーフォームのモデルマッピングフィールドの横にクイック入力ボタンを追加し、編集を高速化 (#2179, 感謝 @lispking)
### Stream Check エラー分類
- Stream Check エラーを分類し、色分けトーストとして提示
- デフォルトの探索モデルを各ベンダーの現行ラインナップに合わせて更新
- 「モデルが見つからない」レスポンスを明示的に検出
### Local Routing 有効時の公式プロバイダー切り替えブロック
- Local Routing が有効な状態で公式プロバイダーに切り替えようとすると、強制的にブロックされ警告トーストが表示される
- 理由: 公式 API トラフィックをローカルプロキシ経由で流すとアカウント停止のリスクがあるため
### Pricing データベース刷新(v8 → v9)
- マイグレーション時に定価テーブルを再シード
- Claude 4.7、Opus 4.7 Adaptive Thinking、Grok 4、Qwen 3.5/3.6、MiniMax M2.5/M2.7、Doubao Seed 2.0 系列、GLM-5/5.1 などを含む約 50 件の新しいモデルエントリを追加
- DeepSeek、Kimi K2.5 などの古い価格を修正
### アプリケーションレベルのウィンドウコントロール
- CC Switch が自前で最小化 / 最大化トグル / 閉じるボタンを描画するオプション設定を追加。システム装飾の代わりに使用 (#1119, 感謝 @git1677967754)
- コンポジター描画ボタンが無反応になり得る Linux Wayland での体験を大きく改善
### 統一 Skills 管理への Hermes 追加
- 統一 Skills サーフェスに Hermes を追加
- Skill のインストール / 有効化 / フィルターが、Claude / Codex / Gemini / OpenCode / OpenClaw と並んで Hermes アプリをカバー
### OpenClaw 設定ディレクトリのカスタマイズ
- CC Switch が参照する `openclaw.json` のカスタム位置を設定できるオプションを追加 (#1518, 感謝 @mrFranklin)
### Hermes 設定ディレクトリのカスタマイズ
- CC Switch が参照する `~/.hermes/config.yaml` のカスタム位置を設定できるオプションを追加。データ駆動 dispatch でサポート
### StepFun Step Plan プリセット
- StepFun Step PlanEN / ZH)プロバイダープリセットを追加 (#2155, 感謝 @hengm3467)
### New API 用量スクリプトテンプレート
- New API の用量スクリプトテンプレートに User-Agent ヘッダーを追加し、上流互換性を向上
### LemonData プロバイダープリセット(全 6 アプリ)
- LemonData をサードパーティパートナープリセットとして Claude、Codex、Gemini、OpenCode、OpenClaw、Hermes の全 6 アプリに登録
- アイコンアセットと zh / en / ja 三言語のパートナー推奨文面を追加
- Claude プリセットは `ANTHROPIC_API_KEY` 認証を使用。OpenAI 互換アプリは `gpt-5.4` をターゲット
### DDSHub Codex プリセット
- DDSHub の Codex 互換エンドポイントを追加(Claude サービスと同じホスト)
- ベース URL は `/v1` サフィックスを省略(ゲートウェイが OpenAI SDK パスを自動ルーティング)
---
## 変更
### 「Local Proxy Takeover」→「Local Routing」
- 三言語の UI 文言、README、ドキュメント全体で用語を統一リネーム
- 機能的な動作は変更なし
### Hermes `Auto` api_mode の削除
- ユーザーは明示的にプロトコルを選択する必要あり。新しいディープリンクはデフォルトで `chat_completions`
- URL ベースのヒューリスティックによる意外な挙動を排除
### Hermes プロバイダーフォーム
- API モードドロップダウンとプロバイダー単位のモデルエディターを追加
- アクティブなプロバイダーを切り替える際、プロバイダー単位のモデルをトップレベルの `model:` にバインド
### Hermes 深い設定の委譲
- 深い YAML 設定は CC Switch フォームで重複させず、「Hermes Web UI を起動」ボタン経由で Web UI に直接委譲
### Hermes ツールバーレイアウト
- Hermes Web UI ボタンのアイコンを `ExternalLink` から `LayoutDashboard` に変更(クリック時に単に URL を開くのではなく `hermes dashboard` を起動する場合があるため、パネル型アイコンのほうが意味的に正確)
- MCP をツールバーの末尾に移動し、Hermes のレイアウトを Claude / Codex / Gemini / OpenCode と揃える
### Claude Quick-Set から `ANTHROPIC_REASONING_MODEL` を削除
- 推論能力とモデル選択を分離。レガシーフィールドは Quick-Set フォームから除外
### プロバイダー単位のプロキシ設定を削除
- グローバルな Local Routing に統合
- プロバイダー単位のプロキシトグルと関連ストレージは削除済み
### ツールバーアイコンボタン幅の統一
- Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes パネルの間でアイコンボタン幅を正規化し、ヘッダーの見た目を統一
### Rust Toolchain を 1.95 にピン留め
- ワークスペース全体で clippy 1.95 の提案を採用し、nightly ドリフトを防ぐためツールチェーンをピン留め
### トレイメニュー ID 定数
- トレイ識別子をハードコーディング文字列 `"main"` から `TRAY_ID` 定数(`"cc-switch"`)に移行。すべての呼び出し箇所で同期 (#1978, 感謝 @lidaxian121)
### Copilot 交互消費の大幅最適化
Copilot リバースプロキシの premium 交互消費を削減するための系統的な最適化。以下の複数の改善をカバー:
- **転送前に thinking ブロックを主動削除**: Anthropic の `thinking` / `redacted_thinking` ブロックは OpenAI 互換エンドポイントに拒否される。従来は上流でリクエストが失敗して premium 交互を 1 回消費した後、`thinking_rectifier` によってリトライされていた。新しい主動削除ステップ(Copilot 最適化パイプラインの 3.5 ステップ目、`tool_result` マージ後)により、この無駄な premium 消費を直接解消
- **リクエスト分類の修正**: `tool_result` を含むメッセージをユーザー発起の新規リクエストではなく、エージェント継続として分類。ツール呼び出しが毎回 premium 交互としてカウントされる問題を防止
- **subagent 検出**: `__SUBAGENT_MARKER__``metadata._agent_` フォールバックで subagent を識別し、`x-interaction-type=conversation-subagent` を設定
- **決定論的 `x-interaction-id` による課金マージ**: セッション ID から `x-interaction-id` を導出し、同一セッション内の複数リクエストを 1 回の課金交互に統合
- **孤立 `tool_result` のサニタイズ**: 孤立した `tool_result` を整理し、上流エラーによるリトライおよび重複課金を防止
- **Warmup ダウングレードをデフォルトで有効化**: `gpt-5-mini` をデフォルトのダウングレードモデルとして使用
- **最適化パイプラインの並び替え**: classify → sanitize → merge → warmup の順序で、分類が生の `tool_result` セマンティクスを参照可能に
- `CopilotOptimizerConfig` のデフォルト値の不一致を修正(`gpt-5-mini` に統一)
### 用量スクリプトのイントラネットサポート
- 用量スクリプトからプライベート IP / 不審なホスト名のブロッキングを削除し、エンタープライズイントラネット、Docker、自己ホスト API エンドポイントを解放
- ビルトインテンプレートは引き続き HTTPS(localhost を除く)と同一オリジンチェックを強制。カスタムテンプレートはユーザー制御のまま、リクエスト URL のチェックをスキップ
### Failover キューの備考表示
- プロバイダーの備考が failover キューセレクターとキュー行に表示され、マルチプロバイダーキューでの識別が容易に (#2138, 感謝 @Coconut-Fish)
---
## バグ修正
### 最大化後のツールバー自動折り畳みラッチ
- ウィンドウの最大化 / 復元後、ツールバーが折り畳まれたままになる問題を修正。折り畳み判定はサイズ変更時に再評価される
### Hermes YAML 汚染と OAuth MCP `auth` 消失
- CC Switch 経由でラウンドトリップしても OAuth MCP `auth` ブロックが消失したり、無関係な YAML キーが汚染されたりしなくなった
- `tests/hermes_roundtrip.rs` をガードテストとして追加
### Hermes アクティブプロバイダー表示
- Hermes UI がアクティブプロバイダーを正しく表示するようになり、追加 / 有効化 / 削除アクションが正しく動作
### Hermes プロバイダーの永続化
- プロバイダーは `custom_providers:` の下に永続化され、`api_mode``model` が再起動 / 設定再読み込みを生き延びる
### Hermes ヘルスチェックが OpenClaw のスキーマを流用していた問題
- 以前 Hermes プロバイダーは `check_additive_app_stream`(OpenClaw のディスパッチャー)にルーティングされており、これは camelCase の `baseUrl` / `apiKey` / `api` を読むため、Hermes フィールドをすべて記入しても "OpenClaw provider is missing baseUrl" と表示されていた
- `check_hermes_stream` を導入し、Hermes 専用のエクストラクターで `api_mode``chat_completions` / `anthropic_messages` / `codex_responses`)を対応する `check_claude_stream``api_format` にマッピング。`bedrock_converse` は非対応として返す
- URL / API キーの抽出前に `api_mode` を解決することで、`bedrock_converse` を選んだユーザーには「missing base_url」という誤解を招くメッセージではなく実際の原因が表示される
### Hermes / OpenClaw 向け Usage クエリモーダル
- `getProviderCredentials` が Hermessnake_case の `base_url` / `api_key`)と OpenClawcamelCase の `baseUrl` / `apiKey`)のフラットな `settingsConfig` フィールドを読むようになり、SiliconFlow などマッチするプロバイダーで「official balance」テンプレートが自動選択される
- BALANCE と TOKEN_PLAN テストパスをリファクタリングし、`env.ANTHROPIC_*` を直接再読するのではなく、事前計算された `providerCredentials` を再利用するように変更。これにより非 Claude アプリでキーが設定されていても「empty key」エラーが出ていた問題を修正
### Codex `cache_control` 保持
- Codex フォーマット変換中に system prompt をマージする際の `cache_control` を保持 (#1946, 感謝 @yovinchen)
### Claude プロンプトキャッシュキーのリーク
- Claude chat 変換時にプロンプトキャッシュキーを送信しないように修正 (#2003, 感謝 @yovinchen)
### プロキシ Hop-by-Hop レスポンスヘッダーの削除
- RFC 7230 に従ってプロキシレスポンスの hop-by-hop ヘッダー(Connection、Keep-Alive、Transfer-Encoding など)を削除 (#2060, 感謝 @yovinchen)
### プロキシの寛容な CORS レイヤー削除
- プロキシの寛容な CORS レイヤーを削除 (#1915, 感謝 @zerone0x)
### プロキシトーストでのバックエンドエラー詳細表示
- プロキシ関連のトーストメッセージで、汎用的な失敗文字列ではなくバックエンドのエラーペイロードの詳細を表示
### Usage ログの重複排除
- プロキシとセッションログの用量レコードを重複排除し、同じリクエストが二重にカウントされないように修正
- リクエストログの時間範囲をダッシュボードの 1d / 7d / 30d セレクターと同期
### Common Config チェックボックスの永続化
- Claude / Codex / Gemini の common-config トグルのチェック状態が再オープンをまたいで正しく保持されるように修正 (#2191, 感謝 @zxZeng)
### Claude プラグイン `settings.json` 同期
- 現在のプロバイダーを編集すると、Claude プラグインパスの `settings.json` に同期されるように修正 (#1905, 感謝 @chengww5217)
### Google Official Gemini の env 保持
- Google Official Gemini プロバイダーを保存しても `env` ブロックが消えないように修正
### OpenCode の JSON5 による末尾カンマ解析
- OpenCode 設定読み取りが JSON5 パーサーにより末尾カンマを許容するように修正 (#2023, 感謝 @wwminger)
### プリセットの刷新
- DeepSeek と Claude 1M の古いコンテキストウィンドウを刷新
- 古いモデル ID を刷新。Hermes のモデルリストをバックフィル
- Nous エンドポイントを修正し、Hermes のプレースホルダーアイコンを Nous ブランドのアートワークに置き換え
- 未使用の公式 Hermes プリセットを整理
### 検索ヒット時の折り畳みメッセージの自動展開
- 隠されたコンテンツ内部で検索マッチが発生した場合、折り畳みメッセージを自動展開してマッチを示す
### 不明なサブスクリプション配額ティアの非表示
- プロバイダーカードは不明なサブスクリプション配額ティアを表示しないように変更
### weekly_limit ラベルの統一
- `weekly_limit` ティアラベルを公式の「7 日」命名にロケール間で揃えた
### ルートレベルの Skill リポジトリインストール
- リポジトリのルート自体が skill の場合のインストール失敗を修正
### Session ID 解析の clippy 警告
- session ID 解析内の冗長なクロージャを削除(clippy 警告)
### Stream Check デフォルトモデルの刷新
- Stream Check のデフォルト探索モデルを各ベンダーの現行ラインナップに合わせて更新
### Skills インポートの同期
- インポートされた Skills はデータベースに記録されるだけでなく、有効化されたアプリディレクトリにも即座に同期されるように変更 (#2101, 感謝 @yaoguohh)
- UI が「インストール済み」と表示しているのに対象アプリディレクトリに skill が存在しない状態を解消
### Ghostty セッション復元
- Ghostty セッション復元の起動を `--working-directory` 付きのシェル実行に変更 (#1976, 感謝 @Suda202)
- パスにスペースや特殊文字が含まれる場合の `cwd` エスケープ問題を回避
---
## ドキュメント
### README スポンサー更新
- SiliconFlow のサインアップボーナスを ¥16 に更新
- SSSAiCode のスポンサー文面を簡潔化
- パートナーロゴを更新
- 新しいスポンサーとして LemonData を追加
### グローバルプロキシヒントの明確化
- 三言語でグローバルプロキシと Local Routing の関係を明確化
### Takeover → Routing ドキュメントのリネーム
- テイクオーバー関連ドキュメントを三言語で routing にリネームし、アンカーを同期更新
### PIPELLM ウェブサイト URL
- PIPELLM スポンサーのウェブサイト URL を `code.pipellm.ai` に更新
---
## ⚠️ 重要な変更(Breaking
### Hermes は明示的な `api_mode` が必須
- `Auto` モードは廃止。インポートまたはディープリンクで取得したプロバイダーはデフォルトで `chat_completions`
- 既存の `Auto` 設定のユーザーはプロトコルを選択するよう促される
### Claude Quick-Set から `ANTHROPIC_REASONING_MODEL` を削除
- レガシーフィールドは公開されなくなった。既存の設定は自動的にクリーンアップされる
### プロバイダー単位のプロキシ設定を削除
- グローバル Local Routing 設定に移行
- 既存のプロバイダー単位のプロキシ値は無視される
### データベーススキーマ v9 → v10
- `mcp_servers``skills``enabled_hermes` カラムを追加
- `DEFAULT 0` で自動マイグレーション、データ損失なし
### Pricing テーブルの再シード(v8 → v9)
- 新しいモデルと修正済み価格を取り込むため、初回起動時に `model_pricing` テーブルがクリアされ再シードされる
### XCodeAPI プリセットの削除
- XCodeAPI プリセットを使用していたユーザーは別のプロバイダーに切り替える必要がある
---
## ⚠️ リスクに関する注意事項
本リリースは、リバースプロキシ型機能について v3.12.3 / v3.13.0 で提起された既存のリスク注意事項を継承します。
**GitHub Copilot リバースプロキシ**: Copilot のリバースプロキシパスを使用すると、GitHub / Microsoft の利用規約に違反する可能性があります。詳細は [v3.12.3 リリースノート](v3.12.3-ja.md#-リスクに関する注意事項) を参照してください。
**Codex OAuth リバースプロキシ**: ChatGPT サブスクリプションで Codex OAuth リバースプロキシを使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 リリースノート](v3.13.0-ja.md#-リスクに関する注意事項) を参照してください。
これらの機能を有効にすることで、ユーザーは**すべての関連リスクを自己責任で受諾**したものとみなされます。CC Switch はこれらの機能の使用に起因するアカウントの制限、警告、サービス停止について一切の責任を負いません。
---
## ダウンロード・インストール
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から対応バージョンをダウンロードしてください。
### システム要件
| OS | 最小バージョン | アーキテクチャ |
| ------- | ---------------------------- | ----------------------------------- |
| Windows | Windows 10 以降 | x64 |
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 下表参照 | x64 |
### Windows
| ファイル | 説明 |
| ---------------------------------------- | ------------------------------------------- |
| `CC-Switch-v3.14.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
| `CC-Switch-v3.14.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ不要 |
### macOS
| ファイル | 説明 |
| -------------------------------- | -------------------------------------------------------- |
| `CC-Switch-v3.14.0-macOS.dmg` | **推奨** - DMG インストーラー、Applications にドラッグ |
| `CC-Switch-v3.14.0-macOS.zip` | 解凍して Applications にドラッグ、Universal Binary |
| `CC-Switch-v3.14.0-macOS.tar.gz` | Homebrew インストールと自動更新用 |
> macOS 版は Apple のコード署名および公証済みで、直接インストールして使用できます。
### HomebrewmacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| ディストリビューション | 推奨形式 | インストール方法 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| 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` |
-468
View File
@@ -1,468 +0,0 @@
# CC Switch v3.14.0
> Hermes Agent 成为第 6 个受管应用、Claude Opus 4.7 全面接入、Gemini Native API 代理、Local Routing 统一重命名、应用级窗口控件
**[English →](v3.14.0-en.md) | [日本語版 →](v3.14.0-ja.md)**
---
## 概览
CC Switch v3.14.0 是一次大版本更新,核心焦点是把 **Hermes Agent 作为第 6 个一等受管应用**接入 CC Switch,并把 **Claude Opus 4.7** 铺设到全部聚合器与 Bedrock 预设矩阵。Hermes 支持覆盖数据库 v9 → v10 迁移、完整的 Rust 命令面、基于 YAML 的 `~/.hermes/config.yaml` 读写(含原子备份)、MCP 同步、Skills 同步、SQLite + JSONL 会话管理,以及专属的前端面板和 Memory 编辑面板;与 Hermes Agent 0.10.0 schema 对齐的四种协议(`chat_completions``anthropic_messages``codex_responses``bedrock_converse`)全部可选。用户自行维护的 `providers:` dict 条目以只读卡片形式呈现,深度 YAML 配置则直接委托给 Hermes Web UI。
除了 Hermes,本次还新增了 **Gemini Native API 代理**`api_format = "gemini_native"`),让代理可以把请求直接转发到 Google 的 `generateContent` 端点,完整支持流式、schema 转换和 shadow 请求;把老的 "Local Proxy Takeover" 在三语 UI / README / 文档中统一重命名为 **Local Routing**;新增 **应用级窗口控件**,在 Linux Wayland 等合成器绘制按钮失灵的场景下可选让 CC Switch 自绘最小化 / 最大化 / 关闭按钮;并在本版本发布前额外合入了从工具栏直接启动 `hermes dashboard`、LemonData 全应用预设、DDSHub Codex 端点以及若干 Hermes 健康检查与 Usage 模态框的修复。
会话侧通过 `@tanstack/react-virtual` **虚拟化会话列表**,让上千条记录的长会话也能流畅滚动,长消息默认折叠;Usage 面板新增**日期范围选择器**(今日 / 1d / 7d / 14d / 30d + 自定义日期时间)和翻页输入;**Stream Check 错误分类**以彩色 toast 呈现,默认探测模型重新梳理,"模型不存在"响应被单独识别;并新增在 Local Routing 激活时**阻止切换到官方供应商**的保护,以免官方流量被引入本地代理造成账号风险。Pricing 数据库 v8 → v9 重新种入约 50 个新模型条目(包括 Claude 4.7、Opus 4.7 Adaptive Thinking、Grok 4、Qwen 3.5/3.6、MiniMax M2.5/M2.7、Doubao Seed 2.0 系列、GLM-5/5.1 等),并修正了多项陈旧价格。
**发布日期**2026-04-21
**更新规模**100 commits | 219 files changed | +20,548 / -3,569 lines
---
## 重点内容
- **Hermes Agent 支持(第 6 个受管应用)**:数据库 v9 → v10 迁移、完整 Rust 命令面、YAML 读写带原子备份、MCP 同步、Skills 同步、SQLite + JSONL 会话管理、专属前端面板、四种 API 协议(`chat_completions` / `anthropic_messages` / `codex_responses` / `bedrock_converse`
- **Claude Opus 4.7 全面接入**:自适应思维白名单、按百万 token 定价种子、Bedrock SKU`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`,丢弃老 `-v1` 后缀),全部聚合器 / Bedrock 预设升级为默认 Opus 模型
- **Claude `max` 推理力度**:推理下拉从 `high` 升级到 `max`
- **Gemini Native API 代理**:新增 `api_format = "gemini_native"`,代理可直达 Google `generateContent`,完整流式 / schema 转换 / shadow 请求
- **GitHub Copilot 企业版**:为 Copilot 型 Claude 供应商新增 GHES 认证与端点配置
- **Copilot 次数消耗深度优化**:转发前主动剥离 thinking 块、`tool_result` 消息归类修正、subagent 检测、`x-interaction-id` 合并计费、orphan `tool_result` 清理、默认启用 warmup 降级 —— 系统性降低 premium 交互消耗
- **会话列表虚拟化**:长会话流畅滚动,长消息默认折叠降低文字布局成本
- **Codex / OpenClaw 会话标题提取**:自动抽取有意义标题,两行显示,剥离 OpenClaw `message_id` 尾噪声
- **Usage 日期范围选择器**Today / 1d / 7d / 14d / 30d 预设 + 自定义日期时间日历;分页列表支持页码跳转输入
- **Stream Check 错误分类**:错误按类别分色 toast;默认探测模型刷新;单独识别 "model not found"
- **Local Routing 激活时阻止官方供应商切换**:官方流量走本地代理有账号暂停风险,强制拦截并 toast 警告
- **Pricing 数据库刷新(v8 → v9)**:新增 ~50 条模型条目并修正陈旧价格
- **应用级窗口控件**:可选让 CC Switch 自绘 min/max/close,显著改善 Linux Wayland 体验
- **Hermes 接入统一 Skills 管理**Skills 安装 / 启用 / 过滤现覆盖 Hermes
- **Hermes / OpenClaw 配置目录自定义**:在设置里指定 `~/.hermes/config.yaml``openclaw.json` 的自定义位置
- **从工具栏启动 Hermes Dashboard**Web UI 探测失败时,点击可在用户首选终端中启动 `hermes dashboard`
- **新合作伙伴预设**:LemonData 覆盖全部 6 个应用;DDSHub 新增 Codex 端点;StepFun Step Plan
---
## 新功能
### Hermes Agent 支持(第 6 个受管应用)
CC Switch 首次支持 Hermes Agent 作为一等受管应用,与 Claude / Codex / Gemini / OpenCode / OpenClaw 并列。
- **数据库迁移 v9 → v10**:为 `mcp_servers``skills` 表新增 `enabled_hermes` 列(`DEFAULT 0` 自动迁移,无数据丢失)
- **YAML 配置读写**`~/.hermes/config.yaml` 读写带原子备份;`tests/hermes_roundtrip.rs` 守护不损坏不相关键和 OAuth MCP `auth`
- **四种 API 协议**:与 Hermes Agent 0.10.0 对齐的 `chat_completions` / `anthropic_messages` / `codex_responses` / `bedrock_converse`;新 deeplink 默认为 `chat_completions`
- **用户 `providers:` dict 只读呈现**:用户在 YAML 里手写的 providers 条目在 CC Switch 中以只读卡片展示,深度配置跳转到 Hermes Web UI
- **累加式切换**:与 Claude / Codex 的"覆盖式"切换不同,Hermes 所有供应商共存于同一 YAML
### Hermes Memory 面板
- 新增 Memory 面板直接编辑 `MEMORY.md` / `USER.md`,带启用开关、字符数限制和保存流
- 替换 Hermes 的 Prompts 入口
### Hermes 供应商预设(约 50 个)
- 覆盖 Nous Research、胜算云、OpenRouter、DeepSeek、Together AI、StepFun、智谱 GLM、百炼、Kimi、MiniMax、豆包、百灵、魔搭、KAT-Coder、PackyCode、Cubence、AIGoCode、RightCode、AICodeMirror、AICoding、CrazyRouter、SSSAiCode、Micu、CTok.ai、DDSHub、E-FlowCode、LionCCAPI、PIPELLM、Compshare、SiliconFlow、AiHubMix、DMXAPI、TheRouter、Novita、Nvidia、小米 MiMo
### 从工具栏启动 Hermes Dashboard
- Hermes Web UI 探测失败时,工具栏按钮改为弹出确认框,提供在用户首选终端里运行 `hermes dashboard`
- 通过临时 bash / batch 脚本启动,`hermes dashboard` 就绪后自动打开浏览器,无需轮询
- Memory 面板和 Health banner 保留原有 toast 行为
- 顺便修正了离线 toast 里过时的 `hermes web` 提示(正确命令是 `hermes dashboard`
- Linux 终端探测改为先 `which` 后 stat,提升兼容性
### Claude Opus 4.7 支持
- 新增 Claude Opus 4.7 及其自适应思维白名单、按百万 token 定价种子、Bedrock SKU`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`,丢弃老 `-v1` 后缀)
- 全部聚合器 / Bedrock 预设升级为默认 Opus 模型
### Claude `max` 推理力度
- Claude 推理下拉从 `high` 升级到 `max`,解锁更强的思考容量
### Gemini Native API 代理
- 新增 `api_format = "gemini_native"`,代理可直接转发到 Google `generateContent` API (#1918, 感谢 @yovinchen)
- 完整支持流式、schema 转换、shadow 请求
- 在 proxy providers 模块下新增 `gemini_url.rs``gemini_schema.rs``gemini_shadow.rs``streaming_gemini.rs``transform_gemini.rs`
### GitHub Copilot 企业版(GHES
- 为 Copilot 型 Claude 供应商新增 GHES 认证与端点配置 (#2175, 感谢 @hotelbe)
### 会话列表虚拟化
- 通过 `@tanstack/react-virtual` 虚拟化会话列表,上千条记录流畅滚动
- 长会话消息默认折叠,减少文字布局开销
### Codex / OpenClaw 会话标题提取
- Codex 和 OpenClaw 会话自动抽取有意义的标题,两行显示
- 剥离 OpenClaw `message_id` 后缀噪声
### Usage 日期范围选择器
- Usage 面板新增日期范围选择器,预设 TabToday / 1d / 7d / 14d / 30d+ 自定义日期 + 时间日历 (#2002, 感谢 @yovinchen)
- 分页列表新增页码跳转输入
### 模型映射快速填入
- 供应商表单的模型映射字段旁新增快速填入按钮,加快编辑 (#2179, 感谢 @lispking)
### Stream Check 错误分类
- 按类别为 Stream Check 错误上色并以 toast 呈现
- 刷新所有厂商默认探测模型到当前主力机型
- 对 "model not found" 响应做单独识别
### Local Routing 激活时阻止官方供应商切换
- 在 Local Routing 激活状态下,切换到官方供应商会被强制拦截并弹出警告 toast
- 原因:官方 API 流量经由本地代理存在账号暂停风险
### Pricing 数据库刷新(v8 → v9
- 迁移时重新种入定价表
- 新增约 50 条模型条目,覆盖 Claude 4.7、Opus 4.7 Adaptive Thinking、Grok 4、Qwen 3.5/3.6、MiniMax M2.5/M2.7、Doubao Seed 2.0 系列、GLM-5/5.1
- 修正 DeepSeek、Kimi K2.5 等陈旧价格
### 应用级窗口控件
- 新增可选设置,让 CC Switch 自绘最小化 / 切换最大化 / 关闭按钮,代替系统装饰 (#1119, 感谢 @git1677967754)
- 在合成器按钮可能失灵的 Linux Wayland 上显著改善体验
### Hermes 接入统一 Skills 管理
- 统一的 Skills 界面新增 Hermes
- Skills 安装 / 启用 / 过滤现覆盖 Hermes,与 Claude / Codex / Gemini / OpenCode / OpenClaw 并列
### OpenClaw 配置目录自定义
- 新增设置项,允许把 CC Switch 指向自定义的 `openclaw.json` 位置 (#1518, 感谢 @mrFranklin)
### Hermes 配置目录自定义
- 新增设置项,允许把 CC Switch 指向自定义的 `~/.hermes/config.yaml` 位置,底层通过数据驱动 dispatch
### StepFun Step Plan 预设
- 新增 StepFun Step PlanEN / ZH)供应商预设 (#2155, 感谢 @hengm3467)
### New API 用量脚本模板
- 为 New API 用量脚本模板新增 User-Agent 头,提升上游兼容性
### LemonData 全应用预设
- LemonData 作为第三方合作伙伴预设覆盖 Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes 全部 6 个应用
- 含图标资源和 zh / en / ja 三语合作伙伴推广文案
- Claude 预设使用 `ANTHROPIC_API_KEY` 认证,OpenAI 兼容应用目标为 `gpt-5.4`
### DDSHub Codex 预设
- 新增 DDSHub 的 Codex 兼容端点(与 Claude 服务同 host
- base URL 省略 `/v1` 后缀,由网关自动路由 OpenAI SDK 路径
---
## 变更
### "Local Proxy Takeover" → "Local Routing"
- 三语 UI 文案、README、文档中全部统一重命名
- 功能行为保持不变
### Hermes `Auto` api_mode 移除
- 用户必须显式选择协议;新 deeplink 默认为 `chat_completions`
- 消除了基于 URL 的启发式识别带来的意外
### Hermes 供应商表单
- 新增 API mode 下拉和按供应商的模型编辑器
- 切换激活供应商时,把按供应商的模型绑定到顶层 `model:`
### Hermes 深度配置委托
- 深度 YAML 配置不再在 CC Switch 表单里重复,直接通过"启动 Hermes Web UI"按钮交给 Web UI
### Hermes 工具栏布局
- Web UI 按钮图标从 `ExternalLink` 换成 `LayoutDashboard` —— 点击可能启动 `hermes dashboard` 而非仅仅打开 URL,面板式图标语义更准
- MCP 移到工具栏末尾,与 Claude / Codex / Gemini / OpenCode 的布局对齐
### Claude Quick-Set 移除 `ANTHROPIC_REASONING_MODEL`
- 把推理能力和模型选择解耦,quick-set 表单不再暴露该遗留字段
### 按供应商代理配置移除
- 统一到全局的 Local Routing
- 按供应商的代理开关和存储都已移除
### 统一工具栏图标按钮宽度
- 在 Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes 面板之间规格化图标按钮宽度,表头视觉一致
### Rust Toolchain 锁定 1.95
- 全仓库采纳 clippy 1.95 建议并锁定 toolchain,防止 nightly 漂移
### 托盘菜单 ID 常量
- 托盘标识符从硬编码字符串 `"main"` 改为 `TRAY_ID` 常量(`"cc-switch"`),所有调用点同步 (#1978, 感谢 @lidaxian121)
### Copilot 次数消耗深度优化
一次系统性优化专门降低 Copilot 反向代理的 premium 交互消耗,涵盖以下多项改进:
- **转发前主动剥离 thinking 块**Anthropic 的 `thinking` / `redacted_thinking` 块会被 OpenAI 兼容端点拒绝,过去一次请求先失败消耗一次 premium 交互、再由 `thinking_rectifier` 触发重试。新增主动剥离步骤(Copilot 优化管线第 3.5 步,位于 `tool_result` 合并之后),直接省掉那一次无谓的 premium 消耗
- **请求分类修正**:含 `tool_result` 的消息归类为代理继续,而不是用户发起的新请求 —— 避免每次工具调用都被错误计入 premium 次数
- **subagent 检测**:通过 `__SUBAGENT_MARKER__``metadata._agent_` 回退识别 subagent,设置 `x-interaction-type=conversation-subagent`
- **确定性 `x-interaction-id` 合并计费**:从 session ID 推导 `x-interaction-id`,把同一会话内的多次请求合并为一次计费交互
- **Orphan `tool_result` 清理**:清理孤立的 `tool_result`,避免触发上游错误导致重试和重复计费
- **Warmup 降级默认开启**:使用 `gpt-5-mini` 作为默认降级模型
- **优化管线重排**classify → sanitize → merge → warmup,让分类看到原始 `tool_result` 语义
- 修复 `CopilotOptimizerConfig` 默认值不一致(统一到 `gpt-5-mini`
### 用量脚本内网支持
- 移除 usage script 的私网 IP / 可疑主机名屏蔽,解锁企业内网、Docker、自建 API 端点
- 内置模板仍强制 HTTPS(localhost 除外)和同源检查;自定义模板仍由用户控制,这类请求 URL 检查跳过
### Failover 队列备注
- 供应商备注现在在 failover 队列选择器和队列行中显示,方便在多供应商队列里识别 (#2138, 感谢 @Coconut-Fish)
---
## Bug 修复
### 工具栏最大化后持续折叠
- 窗口最大化 / 还原后,工具栏不再卡在折叠状态;折叠判定会随尺寸变化重新计算
### Hermes YAML 污染与 OAuth MCP `auth` 丢失
- 经 CC Switch 往返写入不再丢失 OAuth MCP `auth` 块、也不污染不相关的 YAML 键
- 新增 `tests/hermes_roundtrip.rs` 作为守护测试
### Hermes 激活供应商展示
- Hermes UI 现在正确展示激活供应商,并连通添加 / 启用 / 移除动作
### Hermes 供应商持久化
- 供应商持久化到 `custom_providers:` 下,`api_mode``model` 可跨重启 / 配置重载存活
### Hermes 健康检查错借 OpenClaw schema
- 以前 Hermes 供应商被路由到 `check_additive_app_stream`(OpenClaw 的调度器),后者读 camelCase 的 `baseUrl` / `apiKey` / `api`,导致即便 Hermes 字段全填还是报 "OpenClaw provider is missing baseUrl"
- 新增 `check_hermes_stream`,用 Hermes 专用提取器把 `api_mode``chat_completions` / `anthropic_messages` / `codex_responses`)映射到对应的 `check_claude_stream` `api_format``bedrock_converse` 明确标记为不支持
- 先解析 `api_mode` 再抽 URL / API key,让 `bedrock_converse` 用户看到真实原因,而不是误导性的 "missing base_url"
### Usage 查询模态框支持 Hermes / OpenClaw
- `getProviderCredentials` 新增对 Hermessnake_case `base_url` / `api_key`)和 OpenClawcamelCase `baseUrl` / `apiKey`)的扁平 `settingsConfig` 字段读取,让 SiliconFlow 等匹配供应商自动选中 "official balance" 模板
- 重构 BALANCE 和 TOKEN_PLAN 测试路径复用 `providerCredentials`,不再直接读 `env.ANTHROPIC_*`,修正了非 Claude 应用即使配置了 key 也报 "empty key" 的问题
### Codex `cache_control` 保留
- 在 Codex 格式转换合并 system prompt 时保留 `cache_control` (#1946, 感谢 @yovinchen)
### Claude prompt cache key 泄漏
- Claude chat 转换时不再发送 prompt cache key (#2003, 感谢 @yovinchen)
### 代理逐跳响应头剥离
- 按 RFC 7230 剥离代理响应的 hop-by-hop 头(Connection、Keep-Alive、Transfer-Encoding 等) (#2060, 感谢 @yovinchen)
### 代理 CORS 层移除
- 移除代理中过于宽松的 CORS 层 (#1915, 感谢 @zerone0x)
### 代理 toast 显示后端错误详情
- 代理相关 toast 现在展示后端错误 payload 的详情,而不是一句笼统的失败
### Usage 日志去重
- 代理和会话日志的用量记录去重,相同请求不再被重复计数
- 请求日志时间范围与面板的 1d / 7d / 30d 选择器同步
### Common Config 勾选持久化
- Claude / Codex / Gemini common-config 勾选状态重开后正确保留 (#2191, 感谢 @zxZeng)
### Claude 插件 `settings.json` 同步
- 编辑当前供应商时,会同步回 Claude 插件路径下的 `settings.json` (#1905, 感谢 @chengww5217)
### Google Official Gemini env 保留
- 保存 Google Official Gemini 供应商时不再清空 `env`
### OpenCode JSON5 尾逗号解析
- OpenCode 配置读取容忍尾逗号(JSON5) (#2023, 感谢 @wwminger)
### 预设刷新
- 刷新 DeepSeek 和 Claude 1M 的陈旧 context 窗口
- 刷新陈旧模型 ID,回填 Hermes 模型列表
- 修正 Nous 端点,Hermes 占位图替换为 Nous 品牌图
- 移除未使用的官方 Hermes 预设
### 搜索命中时折叠消息自动展开
- 搜索匹配落在折叠内容内部时,消息自动展开以定位匹配
### 未知订阅配额等级隐藏
- 供应商卡片不再渲染未知订阅配额等级
### weekly_limit 标签统一
- 跨语言把 `weekly_limit` 等级标签对齐到官方的"7 天"命名
### 根级 Skill 仓库安装
- 修复当仓库根本身就是一个 skill 时的安装失败
### Session ID 解析 clippy
- 移除 session ID 解析里的冗余闭包(clippy 警告)
### Stream Check 默认探测模型刷新
- 默认探测模型更新到每家厂商当前主力
### Skills 导入同步
- 导入的 Skills 即时同步到启用应用目录,不再仅记录在数据库里导致 UI 显示"已安装"但目标目录空缺 (#2101, 感谢 @yaoguohh)
### Ghostty 会话恢复
- 改为通过 shell 执行 + `--working-directory` 启动 Ghostty 会话恢复 (#1976, 感谢 @Suda202)
- 避免路径含空格 / 特殊字符时 `cwd` 转义问题
---
## 文档
### README 赞助商更新
- SiliconFlow 注册赠送更新为 ¥16
- 精简 SSSAiCode 赞助文案
- 更新合作伙伴 logo
- 新增 LemonData 赞助商
### 全局代理提示澄清
- 三语澄清全局代理与 Local Routing 的关系
### Takeover → Routing 文档重命名
- 接管相关文档在三语下重命名为 routing,同步更新锚点
### PIPELLM 网站 URL
- PIPELLM 赞助商网站 URL 更新为 `code.pipellm.ai`
---
## ⚠️ 重要变更(Breaking
### Hermes 必须显式 `api_mode`
- `Auto` 模式移除;导入或 deeplink 得到的供应商默认落到 `chat_completions`
- 既有 `Auto` 配置的用户会被提示选择协议
### Claude Quick-Set 移除 `ANTHROPIC_REASONING_MODEL`
- 该遗留字段不再暴露;既有设置自动清理
### 按供应商代理配置移除
- 迁移到全局 Local Routing 设置
- 既有按供应商代理值被忽略
### 数据库 schema v9 → v10
-`mcp_servers``skills` 表新增 `enabled_hermes`
- 自动迁移,`DEFAULT 0`,无数据丢失
### Pricing 表 v8 → v9 重置
- 首次启动时 `model_pricing` 表被清空并重新种入,以应用新模型和修正后的价格
### XCodeAPI 预设移除
- 使用 XCodeAPI 预设的用户请迁移到其它供应商
---
## ⚠️ 风险提示
本版本在涉及反向代理类功能上沿用 v3.12.3 / v3.13.0 提出的风险提示。
**GitHub Copilot 反向代理**:使用 Copilot 的反代路径可能违反 GitHub / Microsoft 服务条款。详情见 [v3.12.3 release notes](v3.12.3-zh.md#-风险提示)。
**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#-风险提示)。
用户启用上述功能即表示**自行承担所有风险**。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
---
## 下载与安装
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
### 系统要求
| 系统 | 最低版本 | 架构 |
| ------- | -------------------------- | ----------------------------------- |
| Windows | Windows 10 及以上 | x64 |
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 见下表 | x64 |
### Windows
| 文件 | 说明 |
| ---------------------------------------- | ----------------------------------- |
| `CC-Switch-v3.14.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.14.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
### macOS
| 文件 | 说明 |
| -------------------------------- | --------------------------------------------- |
| `CC-Switch-v3.14.0-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
| `CC-Switch-v3.14.0-macOS.zip` | 解压后拖入 ApplicationsUniversal Binary |
| `CC-Switch-v3.14.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
> macOS 版本已通过 Apple 代码签名和公证,可直接安装使用。
### HomebrewmacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| 发行版 | 推荐格式 | 安装方式 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| 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` |
-185
View File
@@ -1,185 +0,0 @@
# CC Switch v3.14.1
> Tray usage visibility, Codex OAuth stability fixes, Skills import/install reliability, and removal of the Hermes config health scanner
**[中文版 →](v3.14.1-zh.md) | [日本語版 →](v3.14.1-ja.md)**
---
## Overview
CC Switch v3.14.1 is a patch release following v3.14.0, focused on **Codex OAuth reverse-proxy stability**, **tray usage visibility**, **Skills import / install reliability**, **Gemini session restore paths**, and **simplifying Hermes configuration health handling**.
For the first time, the system tray surfaces **cached usage** for the current Claude / Codex / Gemini provider directly in its submenus — including subscription summaries and usage-script summaries with color-coded utilization markers. For Chinese coding-plan providers like Kimi / Zhipu / MiniMax, the tray additionally renders a **5-hour + weekly window** layout in the `🟢 h12% w80%` style (worst utilization drives the emoji), semantically identical to the official subscription badges. Creating a Claude provider whose `ANTHROPIC_BASE_URL` matches a known coding-plan host now auto-injects `meta.usage_script` so the tray lights up without opening the Usage Script modal.
Several Codex OAuth reverse-proxy stability issues are addressed this release: client-provided session IDs are now used as both `prompt_cache_key` and the Codex session header to avoid UUID-driven cache churn; non-streaming Anthropic clients receive proper JSON responses even when the ChatGPT Codex upstream forces OpenAI Responses SSE; and Stream Check now builds probes with the same `store: false`, encrypted reasoning include, and provider FAST mode setting as production requests, eliminating the "check fails but it actually works" mismatch. Paired with a new explicit **FAST mode toggle**, users can now opt into `service_tier="priority"` on Codex OAuth-backed Claude providers, trading latency against ChatGPT quota consumption on their own terms.
Additionally, the in-app **Hermes config health scanner** and its warning banner are removed (along with the `scan_hermes_config_health` command, `HermesHealthWarning` type, and `HermesWriteOutcome.warnings` payload), refocusing the Hermes surface on active provider display, switching defaults, memory editing, and launching the Hermes Web UI — deep configuration health is now Hermes's own responsibility.
**Release Date**: 2026-04-23
**Update Scale**: 13 commits | 48 files changed | +1,883 / -808 lines
---
## Highlights
- **Tray Usage Visibility**: Claude / Codex / Gemini tray submenus show cached usage for the current provider, including subscription and script-based summaries with color markers; refreshes are throttled, limited to visible apps, and synchronized back into React Query (#2184, thanks @TuYv)
- **Tray Coding-Plan Usage (Kimi / Zhipu / MiniMax)**: The tray renders 5-hour + weekly window usage using the `🟢 h12% w80%` layout; Claude providers whose base URL matches a known host auto-inject `meta.usage_script`
- **Codex OAuth FAST Mode**: New explicit FAST mode toggle for Codex OAuth-backed Claude providers; when enabled, converted Responses requests send `service_tier="priority"`. Off by default (#2210, thanks @JesusDR01)
- **Codex OAuth Stability**: Fixed reverse-proxy cache routing (#2218, thanks @majiayu000), Responses SSE aggregation (#2235, thanks @xpfo-go), and Stream Check parity with production (#2210, thanks @JesusDR01)
- **Hermes Config Health Scanner Removed**: Refocuses the Hermes surface on provider management, memory editing, and launching the Web UI — no longer duplicates deep configuration health judgments
- **Skills Import / Install Reliability**: Import dialog disables actions while pending and deduplicates results by ID (#2211, thanks @TuYv); model quick-set / one-click config applies against the latest form state (#2249, thanks @Coconut-Fish); root-level `SKILL.md` repo installs are stable (#2231, thanks @santugege)
- **Gemini Session Restore Paths**: Session scanning reads `.project_root` metadata and passes the original project directory back into restore flows (#2240, thanks @tisonkun)
- **Session / Settings Layout Polish**: Hardened the scroll-area viewport with width containment to fix horizontal overflow; tightened app bottom and settings footer spacing (#2201, thanks @Coconut-Fish)
---
## Added
### Tray Usage Visibility
- System tray submenus now show **cached usage** for the current Claude / Codex / Gemini provider (#2184, thanks @TuYv)
- Includes subscription quota summaries and usage-script summaries with color-coded utilization markers
- Tray-triggered refreshes are **throttled**, **limited to visible apps**, and synchronized back into React Query so the main window and tray share the same usage data
### Tray Coding-Plan Usage (Kimi / Zhipu / MiniMax)
- The tray renders **5-hour + weekly window** usage for Chinese coding-plan providers
- Uses the same `🟢 h12% w80%` two-window layout as official subscription badges (worst utilization drives the emoji color)
- Creating a Claude provider whose `ANTHROPIC_BASE_URL` matches a known coding-plan host **auto-injects** `meta.usage_script`, so the tray lights up without opening the Usage Script modal
- Existing `usage_script` values are **preserved on update**, never clobbering user customizations
### Codex OAuth FAST Mode
- New explicit FAST mode toggle for Codex OAuth-backed Claude providers (#2210, thanks @JesusDR01)
- When enabled, converted Responses requests send `service_tier="priority"` for lower latency
- Off by default to avoid unexpectedly increasing ChatGPT quota consumption
---
## Changed
### Session and Settings Layout Polish
- Hardened the scroll-area viewport with width containment to fix horizontal overflow (#2201, thanks @Coconut-Fish)
- Tightened app bottom and settings footer spacing so long session / settings views fit more cleanly
---
## Removed
### Hermes Config Health Scanner
- Removed the in-app Hermes config health scanner and its warning banner
- Removed the `scan_hermes_config_health` command, `HermesHealthWarning` type, and `HermesWriteOutcome.warnings` payload
- The CC Switch Hermes surface now focuses on its core job: active provider display, default provider switching, memory editing, and launching the Hermes Web UI for deep configuration
---
## Fixed
### Codex OAuth Cache Routing
- Use the client-provided session ID as both `prompt_cache_key` and the Codex session header, preserving explicit cache keys (#2218, thanks @majiayu000)
- Stop generating UUIDs that caused cache-identity churn, stabilizing the ChatGPT Codex reverse-proxy cache identity
### Codex OAuth Responses SSE Aggregation
- Non-streaming Anthropic clients now receive proper JSON even when the ChatGPT Codex upstream forces OpenAI Responses SSE (#2235, thanks @xpfo-go)
- CC Switch aggregates the upstream SSE events before running the non-streaming transform
### Codex OAuth Stream Check Parity
- Stream Check now builds Codex OAuth probe requests with the same `store: false`, encrypted reasoning include, and provider FAST mode setting as production proxy traffic (#2210, thanks @JesusDR01)
- Eliminates the "check fails but it actually works" mismatch
### Codex Model Extraction
- Reading the `model` field from Codex config now uses TOML parsing instead of first-line regex matching (#2227, thanks @nmsn)
- Multiline TOML is handled correctly
### Model Quick-Set / One-Click Config
- Model quick-set now applies against the **latest** provider form config (#2249, thanks @Coconut-Fish)
- Fixes stale form state preventing one-click configuration from succeeding
### Skills Import Duplicates
- The Skills import dialog disables actions while import is pending (#2211, thanks @TuYv)
- The installed-skills cache deduplicates imported results by ID, preventing double-clicks from adding duplicate installed entries (#2139)
### Root-Level Skill Repos
- Skill install and update flows now consistently resolve three source patterns: direct nested paths, install-name recursive search, and repository-root `SKILL.md` sources (#2231, thanks @santugege)
### Gemini Session Restore Paths
- Gemini session scanning now reads `.project_root` metadata (#2240, thanks @tisonkun)
- Restore flows can pass the original project directory when available
### Provider Hover Names
- Provider icons now expose the provider name on hover for inline SVG, image URL, and fallback initials render paths (#2237, thanks @tisonkun)
---
## Notes & Caveats
- **Hermes Health Scanner Removed**: If you were relying on CC Switch to surface deep Hermes YAML configuration issues, switch to the "Launch Hermes Web UI" toolbar button and inspect them in Hermes's own panel. Day-to-day provider management, switching, memory editing, and MCP / Skills sync continue to be handled by CC Switch.
- **Codex OAuth FAST Mode Off by Default**: Only turn it on if you accept potentially increased ChatGPT quota consumption in exchange for lower latency.
- **Tray Cached Usage**: Refreshes are throttled and limited to the currently visible app to avoid unnecessary upstream API calls; values are synchronized into React Query so the main window and tray stay in sync.
---
## Download & Installation
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
### System Requirements
| OS | Minimum Version | Architecture |
| ------- | ---------------------------- | ----------------------------------- |
| Windows | Windows 10 or later | x64 |
| macOS | macOS 12 (Monterey) or later | Intel (x64) / Apple Silicon (arm64) |
| Linux | See table below | x64 |
### Windows
| File | Description |
| ---------------------------------------- | ----------------------------------------------------- |
| `CC-Switch-v3.14.1-Windows.msi` | **Recommended** - MSI installer, supports auto-update |
| `CC-Switch-v3.14.1-Windows-Portable.zip` | Portable, extract and run, no registry writes |
### macOS
| File | Description |
| -------------------------------- | ------------------------------------------------------- |
| `CC-Switch-v3.14.1-macOS.dmg` | **Recommended** - DMG installer, drag into Applications |
| `CC-Switch-v3.14.1-macOS.zip` | Extract and drag into Applications, Universal Binary |
| `CC-Switch-v3.14.1-macOS.tar.gz` | For Homebrew installation and auto-update |
> macOS builds are Apple code-signed and notarized — install directly.
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
### Linux
| Distribution | Recommended | Installation |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| 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` | Add execute permission and run, or use AUR |
| Other distros / not sure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
-185
View File
@@ -1,185 +0,0 @@
# CC Switch v3.14.1
> トレイでの用量可視化、Codex OAuth の複数の安定性修正、Skills インポート/インストールの信頼性向上、Hermes 設定ヘルススキャナーの削除
**[中文版 →](v3.14.1-zh.md) | [English →](v3.14.1-en.md)**
---
## 概要
CC Switch v3.14.1 は v3.14.0 に続くパッチリリースで、**Codex OAuth リバースプロキシの安定性**、**トレイでの用量可視化**、**Skills インポート / インストールの信頼性**、**Gemini セッション復元パス**、および **Hermes 設定ヘルス処理の簡素化**を中心に据えています。
システムトレイは初めて、現在の Claude / Codex / Gemini プロバイダーの**キャッシュ済み用量**をサブメニューに直接表示するようになりました — サブスクリプション要約と用量スクリプト要約を、使用率に応じた色分けマーカーとともに表示します。Kimi / Zhipu / MiniMax のような中国系コーディングプランプロバイダーには、公式サブスクリプションバッジと同じ `🟢 h12% w80%` スタイルで **5 時間 + 週次ウィンドウ**の 2 ウィンドウレイアウトを追加描画します(より厳しい方の使用率が絵文字色を決定)。`ANTHROPIC_BASE_URL` が既知のコーディングプランホストに一致する Claude プロバイダーを作成すると、`meta.usage_script` が自動注入されるため、Usage Script モーダルを開かなくてもトレイが点灯します。
Codex OAuth 側では、複数のリバースプロキシ安定性の問題を修正しました: クライアント提供の session ID を `prompt_cache_key` と Codex session ヘッダーの両方に使用し、UUID 生成によるキャッシュ揺らぎを回避。ChatGPT Codex 上流が OpenAI Responses SSE を強制する場合でも、非ストリーミングの Anthropic クライアントが適切な JSON レスポンスを受け取れるようになりました。Stream Check は、本番環境と同じ `store: false`、暗号化 reasoning include、およびプロバイダーの FAST モード設定でプローブを構築するようになり、「検出は失敗するのに実際は動く」というズレが解消されました。新しい明示的な **FAST モードトグル**と組み合わせることで、ユーザーは Codex OAuth バックの Claude プロバイダーで `service_tier="priority"` を選択的に送信でき、レイテンシと ChatGPT 配額消費の間で自分で選べるようになりました。
さらに、CC Switch 内蔵の **Hermes 設定ヘルススキャナー**と警告バナー(および対応する `scan_hermes_config_health` コマンド、`HermesHealthWarning` 型、`HermesWriteOutcome.warnings` ペイロード)を削除し、Hermes サーフェスをアクティブプロバイダー表示、デフォルト切り替え、Memory 編集、および Hermes Web UI の起動に再フォーカスしました — 深い設定ヘルスは Hermes 自身の責任になります。
**リリース日**: 2026-04-23
**更新規模**: 13 commits | 48 files changed | +1,883 / -808 lines
---
## ハイライト
- **トレイでの用量可視化**: Claude / Codex / Gemini のトレイサブメニューに、現在のプロバイダーのキャッシュ済み用量(サブスクリプション要約とスクリプト要約、色分けマーカー付き)を表示。リフレッシュはスロットル、可視アプリに限定、React Query に同期 (#2184, 感謝 @TuYv)
- **トレイのコーディングプラン用量(Kimi / Zhipu / MiniMax**: トレイが 5 時間 + 週次ウィンドウの用量を `🟢 h12% w80%` レイアウトで描画。既知のホストにマッチする Claude プロバイダーは `meta.usage_script` を自動注入
- **Codex OAuth FAST モード**: Codex OAuth バックの Claude プロバイダーに明示的な FAST モードトグルを追加。有効時は変換された Responses リクエストに `service_tier="priority"` を送信、デフォルトは OFF (#2210, 感謝 @JesusDR01)
- **Codex OAuth 安定性**: リバースプロキシのキャッシュルーティング (#2218, 感謝 @majiayu000)、Responses SSE 集約 (#2235, 感謝 @xpfo-go)、Stream Check と本番の一致性 (#2210, 感謝 @JesusDR01) を修正
- **Hermes 設定ヘルススキャナー削除**: Hermes サーフェスをプロバイダー管理、Memory 編集、Web UI 起動に再フォーカス。深い設定ヘルス判定を重複して担わなくなる
- **Skills インポート / インストールの信頼性**: インポート中はダイアログのアクションを無効化し、結果を ID で重複排除 (#2211, 感謝 @TuYv); ワンクリック設定は最新のフォーム状態に基づいて適用 (#2249, 感謝 @Coconut-Fish); ルートレベルの `SKILL.md` リポジトリインストールが安定 (#2231, 感謝 @santugege)
- **Gemini セッション復元パス**: セッションスキャン時に `.project_root` メタデータを読み、元のプロジェクトディレクトリを復元フローに渡す (#2240, 感謝 @tisonkun)
- **セッション / 設定レイアウトの磨き込み**: スクロールエリアビューポートに幅制約を追加して横方向のはみ出しを修正。アプリ下部と設定フッター間隔をよりタイトに (#2201, 感謝 @Coconut-Fish)
---
## 新機能
### トレイでの用量可視化
- システムトレイサブメニューに、現在の Claude / Codex / Gemini プロバイダーの**キャッシュ済み用量**を表示 (#2184, 感謝 @TuYv)
- サブスクリプション配額要約と用量スクリプト要約を含み、使用率に応じた色分けマーカー付き
- トレイ起因のリフレッシュは**スロットル**、**可視アプリに限定**、React Query に同期されるため、メインウィンドウとトレイが同じ用量データを共有
### トレイのコーディングプラン用量(Kimi / Zhipu / MiniMax
- 中国系コーディングプランプロバイダー向けに、トレイが **5 時間 + 週次ウィンドウ**の用量を描画
- 公式サブスクリプションバッジと同じ `🟢 h12% w80%` の 2 ウィンドウレイアウトを使用(より厳しい使用率が絵文字色を決定)
- `ANTHROPIC_BASE_URL` が既知のコーディングプランホストにマッチする Claude プロバイダーを作成すると、`meta.usage_script` が**自動注入**され、Usage Script モーダルを開かなくてもトレイが点灯
- 更新時は既存の `usage_script` 値を**保持**し、ユーザーカスタマイズを上書きしない
### Codex OAuth FAST モード
- Codex OAuth バックの Claude プロバイダーに明示的な FAST モードトグルを追加 (#2210, 感謝 @JesusDR01)
- 有効時は変換された Responses リクエストに `service_tier="priority"` を送信してレイテンシを低減
- 予期せぬ ChatGPT 配額消費の増加を避けるため、デフォルトは OFF
---
## 変更
### セッション・設定レイアウトの磨き込み
- スクロールエリアビューポートに幅制約を追加して横方向のはみ出しを修正 (#2201, 感謝 @Coconut-Fish)
- アプリ下部と設定フッター間隔をよりタイトにし、長いセッション / 設定ビューをすっきり表示
---
## 削除
### Hermes 設定ヘルススキャナー
- アプリ内の Hermes 設定ヘルススキャナーと警告バナーを削除
- `scan_hermes_config_health` コマンド、`HermesHealthWarning` 型、`HermesWriteOutcome.warnings` ペイロードを削除
- CC Switch の Hermes サーフェスは本来の役割に回帰: アクティブプロバイダー表示、デフォルトプロバイダー切り替え、Memory 編集、および深い設定用の Hermes Web UI 起動
---
## バグ修正
### Codex OAuth キャッシュルーティング
- クライアント提供の session ID を `prompt_cache_key` と Codex session ヘッダーの両方に使用し、明示的なキャッシュキーを保持 (#2218, 感謝 @majiayu000)
- キャッシュアイデンティティの揺らぎを引き起こしていた UUID 生成を停止し、ChatGPT Codex リバースプロキシのキャッシュアイデンティティを安定化
### Codex OAuth Responses SSE 集約
- ChatGPT Codex 上流が OpenAI Responses SSE を強制する場合でも、非ストリーミングの Anthropic クライアントが適切な JSON を受け取れるように修正 (#2235, 感謝 @xpfo-go)
- CC Switch が非ストリーミング変換を実行する前に上流 SSE イベントを集約
### Codex OAuth Stream Check の一致性
- Stream Check が構築する Codex OAuth プローブリクエストは、本番プロキシと同じ `store: false`、暗号化 reasoning include、プロバイダー FAST モード設定を使用するように修正 (#2210, 感謝 @JesusDR01)
- 「検出は失敗するのに実際は動く」ズレを解消
### Codex モデル抽出
- Codex 設定の `model` フィールドを読む際、先頭行の正規表現マッチではなく TOML パーサーを使用するように変更 (#2227, 感謝 @nmsn)
- 複数行 TOML も正しく処理
### モデルのクイック入力 / ワンクリック設定
- モデルクイック入力は**最新の**プロバイダーフォーム設定に対して適用されるように修正 (#2249, 感謝 @Coconut-Fish)
- 古いフォーム状態によってワンクリック設定が失敗する問題を修正
### Skills インポートの重複排除
- Skills インポートダイアログは、インポート中にすべてのアクションボタンを無効化 (#2211, 感謝 @TuYv)
- インストール済み Skills のキャッシュを ID で重複排除し、ダブルクリックによる重複したインストール済みエントリを防止 (#2139)
### ルートレベルの Skill リポジトリ
- Skill のインストールと更新フローが 3 つのソースパターンを一貫して解決: 直接ネストパス、install-name の再帰検索、およびリポジトリルートの `SKILL.md` ソース (#2231, 感謝 @santugege)
### Gemini セッション復元パス
- Gemini セッションスキャンが `.project_root` メタデータを読み取るように修正 (#2240, 感謝 @tisonkun)
- 復元フローは利用可能な場合に元のプロジェクトディレクトリを渡せる
### プロバイダー名のホバー表示
- プロバイダーアイコンは、inline SVG、画像 URL、およびフォールバックの頭文字レンダリングパスで、ホバー時にプロバイダー名を表示 (#2237, 感謝 @tisonkun)
---
## 備考・注意事項
- **Hermes ヘルススキャナー削除済み**: Hermes YAML の深い設定の問題提示を CC Switch に頼っていた場合は、ツールバーの「Hermes Web UI を起動」ボタンから Hermes 自身のパネルで確認してください。日常のプロバイダー管理、切り替え、Memory 編集、MCP / Skills 同期は引き続き CC Switch が担います。
- **Codex OAuth FAST モードはデフォルト OFF**: レイテンシ低減と引き換えに ChatGPT 配額消費が増える可能性を許容する場合にのみ有効化してください。
- **トレイのキャッシュ用量**: リフレッシュはスロットル済み、かつ現在可視のアプリに限定されており、不要な上流 API 呼び出しを回避します。値は React Query に同期されるため、メインウィンドウとトレイで同じ値が見えます。
---
## ダウンロード・インストール
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から対応バージョンをダウンロードしてください。
### システム要件
| OS | 最小バージョン | アーキテクチャ |
| ------- | ------------------------ | ----------------------------------- |
| Windows | Windows 10 以降 | x64 |
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 下表参照 | x64 |
### Windows
| ファイル | 説明 |
| ---------------------------------------- | ------------------------------------------- |
| `CC-Switch-v3.14.1-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
| `CC-Switch-v3.14.1-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ不要 |
### macOS
| ファイル | 説明 |
| -------------------------------- | ------------------------------------------------------ |
| `CC-Switch-v3.14.1-macOS.dmg` | **推奨** - DMG インストーラー、Applications にドラッグ |
| `CC-Switch-v3.14.1-macOS.zip` | 解凍して Applications にドラッグ、Universal Binary |
| `CC-Switch-v3.14.1-macOS.tar.gz` | Homebrew インストールと自動更新用 |
> macOS 版は Apple のコード署名および公証済みで、直接インストールして使用できます。
### HomebrewmacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| ディストリビューション | 推奨形式 | インストール方法 |
| --------------------------------------- | ----------- | -------------------------------------------------------------------------- |
| 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` |
-185
View File
@@ -1,185 +0,0 @@
# CC Switch v3.14.1
> 托盘用量可见化、Codex OAuth 多项稳定性修复、Skills 导入/安装可靠性提升、Hermes 配置健康扫描器移除
**[English →](v3.14.1-en.md) | [日本語版 →](v3.14.1-ja.md)**
---
## 概览
CC Switch v3.14.1 是 v3.14.0 之后的一次补丁版本,围绕 **Codex OAuth 反代稳定性**、**托盘用量可见化**、**Skills 导入 / 安装可靠性**、**Gemini 会话恢复路径**,以及**简化 Hermes 配置健康处理**展开。
系统托盘第一次把当前 Claude / Codex / Gemini 供应商的**缓存用量**直接呈现在子菜单里——包含订阅额度摘要和用量脚本摘要,并用颜色标记利用率;针对 Kimi / 智谱 / MiniMax 这类中国编码套餐供应商,托盘还会额外渲染 `🟢 h12% w80%` 风格的 **5 小时 + 周窗口**双窗口排版,语义与官方订阅徽章完全一致(取更紧的那个驱动 emoji)。创建 Claude 供应商时,如果 `ANTHROPIC_BASE_URL` 命中已知的编码套餐 host,会自动注入 `meta.usage_script`,托盘可以不打开 Usage Script 模态框就直接点亮。
Codex OAuth 侧修复了多项反代稳定性问题:使用客户端自带的 session ID 作为 `prompt_cache_key` 和 Codex session 头,避免生成 UUID 造成缓存抖动,显著提高缓存命中率;非流式 Anthropic 客户端在 ChatGPT Codex 上游强制 OpenAI Responses SSE 时也能正确拿到 JSON 响应;Stream Check 现在会以和生产一致的 `store: false`、encrypted reasoning include 以及供应商 FAST 模式构造探测请求,避免出现"检测失败但实际能用"的错位。配合新增的 **FAST 模式显式开关**,让用户可以在 Codex OAuth 型 Claude 供应商上按需发 `service_tier="priority"`,在延迟和 ChatGPT 配额消耗之间自己选。
另外,移除了 CC Switch 内置的 **Hermes 配置健康扫描器**及其警告横幅(以及对应的 `scan_hermes_config_health` 命令、`HermesHealthWarning` 类型和 `HermesWriteOutcome.warnings` 载荷),把 Hermes 面板聚焦回当前供应商展示、默认切换、Memory 编辑和启动 Hermes Web UI,深度配置健康度由 Hermes 自己负责。
**发布日期**2026-04-23
**更新规模**13 commits | 48 files changed | +1,883 / -808 lines
---
## 重点内容
- **托盘用量可见化**Claude / Codex / Gemini 托盘子菜单展示当前供应商缓存用量,含订阅与脚本摘要及颜色标记;刷新带节流、仅针对可见应用、并回写到 React Query (#2184, 感谢 @TuYv)
- **托盘编码套餐用量(Kimi / 智谱 / MiniMax**:托盘渲染 5 小时 + 周窗口双窗口用量,沿用 `🟢 h12% w80%` 排版;命中已知 host 的 Claude 供应商自动注入 `meta.usage_script`
- **Codex OAuth FAST 模式**:为 Codex OAuth 型 Claude 供应商新增显式 FAST 开关,开启后转换后的 Responses 请求发 `service_tier="priority"`,默认关闭 (#2210, 感谢 @JesusDR01)
- **Codex OAuth 稳定性**:修复反代缓存路由 (#2218, 感谢 @majiayu000)、Responses SSE 聚合 (#2235, 感谢 @xpfo-go)、Stream Check 与生产一致性 (#2210, 感谢 @JesusDR01)
- **Hermes 配置健康扫描器移除**:把 Hermes 面板聚焦回供应商管理、Memory 编辑和 Web UI 启动,不再重复承担深度配置健康判断
- **Skills 导入 / 安装可靠性**:导入过程中禁用操作按钮、结果按 ID 去重 (#2211, 感谢 @TuYv);一键配置基于最新表单状态 (#2249, 感谢 @Coconut-Fish);根级 `SKILL.md` 仓库安装稳定 (#2231, 感谢 @santugege)
- **Gemini 会话恢复路径**:扫描会话时读取 `.project_root` 元数据,把原始项目目录带回恢复流程 (#2240, 感谢 @tisonkun)
- **Session / 设置布局打磨**:滚动区域视口加宽度约束修复横向溢出,应用底部和设置页底部间距更紧凑 (#2201, 感谢 @Coconut-Fish)
---
## 新功能
### 托盘用量可见化
- 系统托盘子菜单新增当前 Claude / Codex / Gemini 供应商的**缓存用量**展示 (#2184, 感谢 @TuYv)
- 包含订阅额度摘要和用量脚本摘要,并用颜色标记利用率
- 托盘触发的刷新**带节流**、**只覆盖可见应用**,并同步回 React Query,主窗口和托盘共享同一份用量数据
### 托盘编码套餐用量(Kimi / 智谱 / MiniMax
- 托盘为中国编码套餐供应商渲染 **5 小时 + 周窗口**双窗口用量
- 使用与官方订阅徽章一致的 `🟢 h12% w80%` 两窗口排版,取更紧的那个利用率驱动 emoji 颜色
- 创建 Claude 供应商时,如果 `ANTHROPIC_BASE_URL` 匹配已知编码套餐 host,会**自动注入** `meta.usage_script`,托盘不打开 Usage Script 模态框也能直接点亮
- 更新时会**保留已有** `usage_script` 值,不覆盖用户自定义
### Codex OAuth FAST 模式
- 为 Codex OAuth 型 Claude 供应商新增显式 FAST 模式开关 (#2210, 感谢 @JesusDR01)
- 开启时,转换后的 Responses 请求会发 `service_tier="priority"` 以降低延迟
- 默认关闭,避免意外增加 ChatGPT 配额消耗
---
## 变更
### Session 与设置布局打磨
- 滚动区域视口加上宽度约束,修复横向溢出 (#2201, 感谢 @Coconut-Fish)
- 应用底部和设置页底部间距更紧凑,让长 Session / 设置视图看起来更干净
---
## 移除
### Hermes 配置健康扫描器
- 移除应用内的 Hermes 配置健康扫描器和警告横幅
- 移除 `scan_hermes_config_health` 命令、`HermesHealthWarning` 类型以及 `HermesWriteOutcome.warnings` 载荷
- CC Switch 的 Hermes 面板回归核心职责:当前供应商展示、切换默认供应商、Memory 编辑、以及启动 Hermes Web UI 处理深度配置
---
## 修复
### Codex OAuth 缓存路由
- 使用客户端自带的 session ID 作为 `prompt_cache_key` 和 Codex session 头,保留显式缓存 key (#2218, 感谢 @majiayu000)
- 停止生成 UUID 导致的缓存抖动,让 ChatGPT Codex 反代的缓存身份更稳定
### Codex OAuth Responses SSE 聚合
- ChatGPT Codex 上游强制 OpenAI Responses SSE 时,非流式 Anthropic 客户端也能正确拿到 JSON (#2235, 感谢 @xpfo-go)
- CC Switch 会在非流式转换之前先聚合上游 SSE 事件
### Codex OAuth Stream Check 对齐
- Stream Check 构造的 Codex OAuth 测试请求现在与生产代理一致,使用相同的 `store: false`、加密 reasoning include 和供应商 FAST 模式设置 (#2210, 感谢 @JesusDR01)
- 避免"检测失败但实际能用"的错位
### Codex 模型提取
- 读取 Codex 配置的 `model` 字段时,改用 TOML 解析替代首行正则匹配 (#2227, 感谢 @nmsn)
- 多行 TOML 也能正确处理
### 模型快速填入 / 一键配置
- 模型快速填入现在基于**最新的**供应商表单配置应用 (#2249, 感谢 @Coconut-Fish)
- 修复陈旧表单状态导致一键配置失败的问题
### Skills 导入去重
- Skills 导入对话框在导入进行时禁用所有操作按钮 (#2211, 感谢 @TuYv)
- 已安装 Skills 的缓存按 ID 去重,避免双击造成重复的已安装条目 (#2139)
### 根级 Skill 仓库
- Skill 的安装与更新流程现在能一致地识别三种源路径:直接嵌套路径、按 install-name 递归搜索、以及仓库根的 `SKILL.md` 源 (#2231, 感谢 @santugege)
### Gemini 会话恢复路径
- Gemini 会话扫描时读取 `.project_root` 元数据 (#2240, 感谢 @tisonkun)
- 恢复流程可以在可用时把原始项目目录传回
### 供应商名悬浮提示
- 供应商图标在 inline SVG、图像 URL、以及首字母回退渲染路径下都会在 hover 时展示供应商名称 (#2237, 感谢 @tisonkun)
---
## 说明与注意事项
- **Hermes 健康扫描器已移除**:如果你依赖 CC Switch 提示 Hermes YAML 的深度配置问题,请改为通过工具栏的"启动 Hermes Web UI"按钮在 Hermes 原生面板里查看。日常供应商管理、切换、Memory 编辑、MCP 与 Skills 同步仍然由 CC Switch 负责。
- **Codex OAuth FAST 模式默认关闭**:只有在你接受可能增加 ChatGPT 配额消耗换取更低延迟时,才需要打开。
- **托盘缓存用量**:刷新带节流,只覆盖当前显示的应用,避免无必要的上游 API 调用;数据会回写到 React Query,因此主窗口和托盘看到的值一致。
---
## 下载与安装
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
### 系统要求
| 系统 | 最低版本 | 架构 |
| ------- | -------------------------- | ----------------------------------- |
| Windows | Windows 10 及以上 | x64 |
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 见下表 | x64 |
### Windows
| 文件 | 说明 |
| ---------------------------------------- | ----------------------------------- |
| `CC-Switch-v3.14.1-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.14.1-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
### macOS
| 文件 | 说明 |
| -------------------------------- | --------------------------------------------- |
| `CC-Switch-v3.14.1-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
| `CC-Switch-v3.14.1-macOS.zip` | 解压后拖入 ApplicationsUniversal Binary |
| `CC-Switch-v3.14.1-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
> macOS 版本已通过 Apple 代码签名和公证,可直接安装使用。
### HomebrewmacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| 发行版 | 推荐格式 | 安装方式 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| 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
@@ -509,7 +509,7 @@ When editing Claude providers, a set of **quick toggles** is available above the
| **Hide Attribution** | Clears commit/PR attribution metadata | Sets `attribution: {commit: "", pr: ""}` |
| **Enable Teammates** | Enables the agent teams feature | Sets `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` |
| **Enable Tool Search** | Enables tool search functionality | Sets `env.ENABLE_TOOL_SEARCH = "true"` |
| **Max Effort** | Sets effort level to max | Sets `env.CLAUDE_CODE_EFFORT_LEVEL = "max"` |
| **High Effort** | Sets effort level to high | Sets `effortLevel = "high"` |
| **Disable Auto Upgrade** | Prevents Claude Code auto-updates | Sets `env.DISABLE_AUTOUPDATER = "1"` |
When a toggle is unchecked, its corresponding config entry is removed entirely. Changes are reflected in the JSON editor in real-time.
+3 -4
View File
@@ -277,16 +277,15 @@ CC Switch includes preset official prices for common models (per million tokens)
**Chinese Provider Models**:
> Note: Currency follows each provider's official pricing page. StepFun is currently listed in USD.
>
> **DeepSeek compatibility**: Legacy model IDs `deepseek-chat` / `deepseek-reasoner` now alias to `deepseek-v4-flash` (non-thinking / thinking modes) and are billed at v4-flash rates.
| Model | Input | Output | Cache Read |
|-------|-------|--------|------------|
| **StepFun** | | | |
| step-3.5-flash | $0.10 | $0.30 | $0.02 |
| **DeepSeek** | | | |
| deepseek-v4-flash | ¥1.00 | ¥2.00 | ¥0.20 |
| deepseek-v4-pro | ¥12.00 | ¥24.00 | ¥1.00 |
| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |
| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |
| deepseek-v3 | ¥2.00 | ¥8.00 | ¥0.40 |
| **Kimi (Moonshot)** | | | |
| kimi-k2-thinking | ¥4.00 | ¥16.00 | ¥1.00 |
| kimi-k2 | ¥4.00 | ¥16.00 | ¥1.00 |
+1 -1
View File
@@ -509,7 +509,7 @@ Claude プロバイダーの編集時、JSON エディタの上部に **クイ
| **帰属情報を非表示** | コミット/PR の帰属メタデータをクリア | `attribution: {commit: "", pr: ""}` を設定 |
| **チームメイトを有効化** | エージェントチーム機能を有効化 | `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` を設定 |
| **ツール検索を有効化** | ツール検索機能を有効化 | `env.ENABLE_TOOL_SEARCH = "true"` を設定 |
| **最大強度思考** | エフォートレベルを max に設定 | `env.CLAUDE_CODE_EFFORT_LEVEL = "max"` を設定 |
| **高強度** | エフォートレベルをに設定 | `effortLevel = "high"` を設定 |
| **自動アップグレードを無効化** | Claude Code の自動更新を防止 | `env.DISABLE_AUTOUPDATER = "1"` を設定 |
トグルのチェックを外すと、対応する設定エントリが完全に削除されます。変更は JSON エディタにリアルタイムで反映されます。
+3 -4
View File
@@ -277,16 +277,15 @@ CC Switch は一般的なモデルの公式価格(100 万 Token あたり)
**中国メーカーのモデル**
> 注: 通貨は各プロバイダーの公式料金ページに従います。StepFun は現在 USD 表記です。
>
> **DeepSeek 互換**: 旧モデル名 `deepseek-chat` / `deepseek-reasoner` は `deepseek-v4-flash`(非思考/思考モード)と等価になり、v4-flash 料金で課金されます。
| モデル | 入力 | 出力 | キャッシュ読取 |
|------|------|------|----------|
| **StepFun** | | | |
| step-3.5-flash | $0.10 | $0.30 | $0.02 |
| **DeepSeek** | | | |
| deepseek-v4-flash | ¥1.00 | ¥2.00 | ¥0.20 |
| deepseek-v4-pro | ¥12.00 | ¥24.00 | ¥1.00 |
| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |
| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |
| deepseek-v3 | ¥2.00 | ¥8.00 | ¥0.40 |
| **Kimi (月之暗面)** | | | |
| kimi-k2-thinking | ¥4.00 | ¥16.00 | ¥1.00 |
| kimi-k2 | ¥4.00 | ¥16.00 | ¥1.00 |
+1 -1
View File
@@ -509,7 +509,7 @@ v3.13.0 起新增的高级选项。默认情况下,CC Switch 会把配置的 `
| **隐藏署名** | 清除提交/PR 的署名元数据 | 设置 `attribution: {commit: "", pr: ""}` |
| **启用 Teammates** | 启用 Agent 团队功能 | 设置 `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` |
| **启用工具搜索** | 启用工具搜索功能 | 设置 `env.ENABLE_TOOL_SEARCH = "true"` |
| **最大强度思考** | 将 effort 级别设为 max | 设置 `env.CLAUDE_CODE_EFFORT_LEVEL = "max"` |
| **高效能模式** | 将 effort 级别设为 high | 设置 `effortLevel = "high"` |
| **禁用自动更新** | 阻止 Claude Code 自动更新 | 设置 `env.DISABLE_AUTOUPDATER = "1"` |
取消勾选开关时,对应的配置项会被完全移除。更改会实时反映在 JSON 编辑器中。
+3 -4
View File
@@ -277,16 +277,15 @@ CC Switch 预设了常用模型的官方价格(每百万 Token)。v3.13.0
**中国厂商模型**
> 注:币种遵循各供应商官方定价页面。StepFun 当前按美元列出。
>
> **DeepSeek 兼容**:旧模型名 `deepseek-chat` / `deepseek-reasoner` 现等价于 `deepseek-v4-flash`(非思考/思考模式),按 v4-flash 价格计费。
| 模型 | 输入 | 输出 | 缓存读取 |
|------|------|------|----------|
| **StepFun** | | | |
| step-3.5-flash | $0.10 | $0.30 | $0.02 |
| **DeepSeek** | | | |
| deepseek-v4-flash | ¥1.00 | ¥2.00 | ¥0.20 |
| deepseek-v4-pro | ¥12.00 | ¥24.00 | ¥1.00 |
| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |
| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |
| deepseek-v3 | ¥2.00 | ¥8.00 | ¥0.40 |
| **Kimi (月之暗面)** | | | |
| kimi-k2-thinking | ¥4.00 | ¥16.00 | ¥1.00 |
| kimi-k2 | ¥4.00 | ¥16.00 | ¥1.00 |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.14.1",
"version": "3.13.0",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
-4
View File
@@ -1,4 +0,0 @@
[toolchain]
channel = "1.95"
components = ["rustfmt", "clippy"]
profile = "minimal"
+1 -17
View File
@@ -735,7 +735,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.14.1"
version = "3.13.0"
dependencies = [
"anyhow",
"arboard",
@@ -785,7 +785,6 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -5688,21 +5687,6 @@ dependencies = [
"zip 4.6.1",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
dependencies = [
"bitflags 2.11.0",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-runtime"
version = "2.10.1"
+1 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.14.1"
version = "3.13.0"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
@@ -35,7 +35,6 @@ tauri-plugin-updater = "2"
tauri-plugin-dialog = "2"
tauri-plugin-store = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-window-state = "2"
dirs = "5.0"
toml = "0.8"
toml_edit = "0.22"
+7 -101
View File
@@ -15,8 +15,6 @@ pub struct McpApps {
pub gemini: bool,
#[serde(default)]
pub opencode: bool,
#[serde(default)]
pub hermes: bool,
}
impl McpApps {
@@ -28,8 +26,6 @@ impl McpApps {
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
AppType::Hermes => self.hermes,
AppType::ClaudeDesktop => false,
}
}
@@ -41,8 +37,6 @@ impl McpApps {
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
AppType::Hermes => self.hermes = enabled,
AppType::ClaudeDesktop => {} // Claude Desktop 3P provider config doesn't support MCP here
}
}
@@ -61,15 +55,12 @@ impl McpApps {
if self.opencode {
apps.push(AppType::OpenCode);
}
if self.hermes {
apps.push(AppType::Hermes);
}
apps
}
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
!self.claude && !self.codex && !self.gemini && !self.opencode
}
}
@@ -84,8 +75,6 @@ pub struct SkillApps {
pub gemini: bool,
#[serde(default)]
pub opencode: bool,
#[serde(default)]
pub hermes: bool,
}
impl SkillApps {
@@ -96,9 +85,7 @@ impl SkillApps {
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
AppType::Hermes => self.hermes,
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
AppType::ClaudeDesktop => false,
}
}
@@ -109,9 +96,7 @@ impl SkillApps {
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::Hermes => self.hermes = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
AppType::ClaudeDesktop => {} // Claude Desktop 3P profiles don't use CC Switch skill sync
}
}
@@ -130,15 +115,12 @@ impl SkillApps {
if self.opencode {
apps.push(AppType::OpenCode);
}
if self.hermes {
apps.push(AppType::Hermes);
}
apps
}
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
!self.claude && !self.codex && !self.gemini && !self.opencode
}
/// 仅启用指定应用(其他应用设为禁用)
@@ -259,14 +241,6 @@ pub struct McpRoot {
/// 旧的分应用存储(v3.6.x 及以前,保留用于迁移)
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub claude: McpConfig,
#[serde(
rename = "claude-desktop",
alias = "claudeDesktop",
alias = "claude_desktop",
default,
skip_serializing_if = "McpConfig::is_empty"
)]
pub claude_desktop: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub codex: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
@@ -277,9 +251,6 @@ pub struct McpRoot {
/// OpenClaw MCP 配置(v4.1.0+,实际使用 openclaw.json
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub openclaw: McpConfig,
/// Hermes MCP 配置(实际使用 config.yaml
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub hermes: McpConfig,
}
impl Default for McpRoot {
@@ -289,12 +260,10 @@ impl Default for McpRoot {
servers: Some(HashMap::new()),
// 旧结构保持空,仅用于反序列化旧配置时的迁移
claude: McpConfig::default(),
claude_desktop: McpConfig::default(),
codex: McpConfig::default(),
gemini: McpConfig::default(),
opencode: McpConfig::default(),
openclaw: McpConfig::default(),
hermes: McpConfig::default(),
}
}
}
@@ -311,13 +280,6 @@ pub struct PromptConfig {
pub struct PromptRoot {
#[serde(default)]
pub claude: PromptConfig,
#[serde(
rename = "claude-desktop",
alias = "claudeDesktop",
alias = "claude_desktop",
default
)]
pub claude_desktop: PromptConfig,
#[serde(default)]
pub codex: PromptConfig,
#[serde(default)]
@@ -326,8 +288,6 @@ pub struct PromptRoot {
pub opencode: PromptConfig,
#[serde(default)]
pub openclaw: PromptConfig,
#[serde(default)]
pub hermes: PromptConfig,
}
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
@@ -336,57 +296,43 @@ use crate::prompt_files::prompt_file_path;
use crate::provider::ProviderManager;
/// 应用类型
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AppType {
Claude,
#[serde(
rename = "claude-desktop",
alias = "claude_desktop",
alias = "claudeDesktop"
)]
ClaudeDesktop,
Codex,
Gemini,
OpenCode,
OpenClaw,
Hermes,
}
impl AppType {
pub fn as_str(&self) -> &str {
match self {
AppType::Claude => "claude",
AppType::ClaudeDesktop => "claude-desktop",
AppType::Codex => "codex",
AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
AppType::OpenClaw => "openclaw",
AppType::Hermes => "hermes",
}
}
/// Check if this app uses additive mode
///
/// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini)
/// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw, Hermes)
/// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw)
pub fn is_additive_mode(&self) -> bool {
matches!(
self,
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes
)
matches!(self, AppType::OpenCode | AppType::OpenClaw)
}
/// Return an iterator over all app types
pub fn all() -> impl Iterator<Item = AppType> {
[
AppType::Claude,
AppType::ClaudeDesktop,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
AppType::Hermes,
]
.into_iter()
}
@@ -399,16 +345,14 @@ impl FromStr for AppType {
let normalized = s.trim().to_lowercase();
match normalized.as_str() {
"claude" => Ok(AppType::Claude),
"claude-desktop" | "claude_desktop" | "claudedesktop" => Ok(AppType::ClaudeDesktop),
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini),
"opencode" => Ok(AppType::OpenCode),
"openclaw" => Ok(AppType::OpenClaw),
"hermes" => Ok(AppType::Hermes),
other => Err(AppError::localized(
"unsupported_app",
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes"),
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes."),
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw。"),
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw."),
)),
}
}
@@ -431,9 +375,6 @@ pub struct CommonConfigSnippets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openclaw: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hermes: Option<String>,
}
impl CommonConfigSnippets {
@@ -441,12 +382,10 @@ impl CommonConfigSnippets {
pub fn get(&self, app: &AppType) -> Option<&String> {
match app {
AppType::Claude => self.claude.as_ref(),
AppType::ClaudeDesktop => None,
AppType::Codex => self.codex.as_ref(),
AppType::Gemini => self.gemini.as_ref(),
AppType::OpenCode => self.opencode.as_ref(),
AppType::OpenClaw => self.openclaw.as_ref(),
AppType::Hermes => self.hermes.as_ref(),
}
}
@@ -454,12 +393,10 @@ impl CommonConfigSnippets {
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
match app {
AppType::Claude => self.claude = snippet,
AppType::ClaudeDesktop => {}
AppType::Codex => self.codex = snippet,
AppType::Gemini => self.gemini = snippet,
AppType::OpenCode => self.opencode = snippet,
AppType::OpenClaw => self.openclaw = snippet,
AppType::Hermes => self.hermes = snippet,
}
}
}
@@ -497,12 +434,10 @@ impl Default for MultiAppConfig {
fn default() -> Self {
let mut apps = HashMap::new();
apps.insert("claude".to_string(), ProviderManager::default());
apps.insert("claude-desktop".to_string(), ProviderManager::default());
apps.insert("codex".to_string(), ProviderManager::default());
apps.insert("gemini".to_string(), ProviderManager::default());
apps.insert("opencode".to_string(), ProviderManager::default());
apps.insert("openclaw".to_string(), ProviderManager::default());
apps.insert("hermes".to_string(), ProviderManager::default());
Self {
version: 2,
@@ -659,12 +594,10 @@ impl MultiAppConfig {
pub fn mcp_for(&self, app: &AppType) -> &McpConfig {
match app {
AppType::Claude => &self.mcp.claude,
AppType::ClaudeDesktop => &self.mcp.claude_desktop,
AppType::Codex => &self.mcp.codex,
AppType::Gemini => &self.mcp.gemini,
AppType::OpenCode => &self.mcp.opencode,
AppType::OpenClaw => &self.mcp.openclaw,
AppType::Hermes => &self.mcp.hermes,
}
}
@@ -672,12 +605,10 @@ impl MultiAppConfig {
pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig {
match app {
AppType::Claude => &mut self.mcp.claude,
AppType::ClaudeDesktop => &mut self.mcp.claude_desktop,
AppType::Codex => &mut self.mcp.codex,
AppType::Gemini => &mut self.mcp.gemini,
AppType::OpenCode => &mut self.mcp.opencode,
AppType::OpenClaw => &mut self.mcp.openclaw,
AppType::Hermes => &mut self.mcp.hermes,
}
}
@@ -693,7 +624,6 @@ impl MultiAppConfig {
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Hermes)?;
Ok(config)
}
@@ -711,12 +641,10 @@ impl MultiAppConfig {
fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result<bool, AppError> {
// 如果任一应用已经有提示词配置,说明用户已经在使用 Prompt 功能,避免再次自动导入
if !self.prompts.claude.prompts.is_empty()
|| !self.prompts.claude_desktop.prompts.is_empty()
|| !self.prompts.codex.prompts.is_empty()
|| !self.prompts.gemini.prompts.is_empty()
|| !self.prompts.opencode.prompts.is_empty()
|| !self.prompts.openclaw.prompts.is_empty()
|| !self.prompts.hermes.prompts.is_empty()
{
return Ok(false);
}
@@ -730,7 +658,6 @@ impl MultiAppConfig {
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
AppType::Hermes,
] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
@@ -798,12 +725,10 @@ impl MultiAppConfig {
// 插入到对应的应用配置中
let prompts = match app {
AppType::Claude => &mut config.prompts.claude.prompts,
AppType::ClaudeDesktop => &mut config.prompts.claude_desktop.prompts,
AppType::Codex => &mut config.prompts.codex.prompts,
AppType::Gemini => &mut config.prompts.gemini.prompts,
AppType::OpenCode => &mut config.prompts.opencode.prompts,
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
AppType::Hermes => &mut config.prompts.hermes.prompts,
};
prompts.insert(id, prompt);
@@ -840,12 +765,10 @@ impl MultiAppConfig {
] {
let old_servers = match app {
AppType::Claude => &self.mcp.claude.servers,
AppType::ClaudeDesktop => continue, // Claude Desktop 3P profiles don't use MCP here
AppType::Codex => &self.mcp.codex.servers,
AppType::Gemini => &self.mcp.gemini.servers,
AppType::OpenCode => &self.mcp.opencode.servers,
AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip
AppType::Hermes => continue, // Hermes didn't exist in v3.6.x, skip
};
for (id, entry) in old_servers {
@@ -961,23 +884,6 @@ mod tests {
use std::fs;
use tempfile::TempDir;
#[test]
fn app_type_parses_claude_desktop_aliases() {
assert_eq!(
"claude-desktop".parse::<AppType>().unwrap(),
AppType::ClaudeDesktop
);
assert_eq!(
"claude_desktop".parse::<AppType>().unwrap(),
AppType::ClaudeDesktop
);
assert_eq!(
"claudeDesktop".parse::<AppType>().unwrap(),
AppType::ClaudeDesktop
);
assert_eq!(AppType::ClaudeDesktop.as_str(), "claude-desktop");
}
struct TempHome {
#[allow(dead_code)] // 字段通过 Drop trait 管理临时目录生命周期
dir: TempDir,
File diff suppressed because it is too large Load Diff
-511
View File
@@ -11,20 +11,6 @@ use std::fs;
use std::path::Path;
use toml_edit::DocumentMut;
pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch";
/// Reserved built-in provider IDs from OpenAI Codex's config/model-provider
/// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy
/// removed provider aliases.
const CODEX_RESERVED_MODEL_PROVIDER_IDS: &[&str] = &[
"amazon-bedrock",
"openai",
"ollama",
"lmstudio",
"oss",
"ollama-chat",
];
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() {
@@ -151,268 +137,6 @@ pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
Ok(s)
}
fn active_codex_model_provider_id(doc: &DocumentMut) -> Option<String> {
doc.get("model_provider")
.and_then(|item| item.as_str())
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_string)
}
fn is_custom_codex_model_provider_id(id: &str) -> bool {
let id = id.trim();
!id.is_empty()
&& !CODEX_RESERVED_MODEL_PROVIDER_IDS
.iter()
.any(|reserved| reserved.eq_ignore_ascii_case(id))
}
fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option<String> {
let doc = config_text.parse::<DocumentMut>().ok()?;
let provider_id = active_codex_model_provider_id(&doc)?;
if is_custom_codex_model_provider_id(&provider_id) {
Some(provider_id)
} else {
None
}
}
fn codex_model_provider_id_with_table_from_config(
config_text: &str,
) -> Result<Option<String>, AppError> {
if config_text.trim().is_empty() {
return Ok(None);
}
let doc = config_text
.parse::<DocumentMut>()
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
let Some(provider_id) = active_codex_model_provider_id(&doc) else {
return Ok(None);
};
let has_provider_table = doc
.get("model_providers")
.and_then(|item| item.as_table())
.and_then(|table| table.get(provider_id.as_str()))
.is_some();
Ok(has_provider_table.then_some(provider_id))
}
fn normalize_codex_live_config_model_provider_with_anchors<'a>(
config_text: &str,
anchor_config_texts: impl IntoIterator<Item = &'a str>,
) -> Result<String, AppError> {
if config_text.trim().is_empty() {
return Ok(config_text.to_string());
}
let mut doc = config_text
.parse::<DocumentMut>()
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
let Some(source_provider_id) = active_codex_model_provider_id(&doc) else {
return Ok(config_text.to_string());
};
let has_source_provider_table = doc
.get("model_providers")
.and_then(|item| item.as_table())
.and_then(|table| table.get(source_provider_id.as_str()))
.is_some();
if !has_source_provider_table {
return Ok(config_text.to_string());
}
let stable_provider_id = anchor_config_texts
.into_iter()
.find_map(stable_codex_model_provider_id_from_config)
.or_else(|| {
is_custom_codex_model_provider_id(&source_provider_id)
.then(|| source_provider_id.clone())
})
.unwrap_or_else(|| CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
if stable_provider_id == source_provider_id {
return Ok(config_text.to_string());
}
if let Some(model_providers) = doc
.get_mut("model_providers")
.and_then(|item| item.as_table_mut())
{
let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
return Ok(config_text.to_string());
};
model_providers[stable_provider_id.as_str()] = provider_table;
}
rewrite_codex_profile_model_provider_refs(&mut doc, &source_provider_id, &stable_provider_id);
doc["model_provider"] = toml_edit::value(stable_provider_id.as_str());
Ok(doc.to_string())
}
fn rewrite_codex_profile_model_provider_refs(
doc: &mut DocumentMut,
source_provider_id: &str,
stable_provider_id: &str,
) {
let Some(profiles) = doc
.get_mut("profiles")
.and_then(|item| item.as_table_like_mut())
else {
return;
};
let profile_keys: Vec<String> = profiles.iter().map(|(key, _)| key.to_string()).collect();
for profile_key in profile_keys {
let Some(profile_table) = profiles
.get_mut(&profile_key)
.and_then(|item| item.as_table_like_mut())
else {
continue;
};
let references_source = profile_table
.get("model_provider")
.and_then(|item| item.as_str())
== Some(source_provider_id);
if references_source {
profile_table.insert("model_provider", toml_edit::value(stable_provider_id));
}
}
}
/// Keep Codex's active `model_provider` stable across CC Switch provider changes.
///
/// Codex stores and filters resume history by `model_provider`, so switching between
/// provider-specific ids like `rightcode` and `aihubmix` makes history appear to move.
/// We preserve an existing custom provider id when possible and only rewrite the
/// live config text that Codex sees at provider-driven write boundaries.
pub fn normalize_codex_settings_config_model_provider(
settings: &mut Value,
anchor_config_text: Option<&str>,
) -> Result<(), AppError> {
let Some(config_text) = settings
.get("config")
.and_then(|value| value.as_str())
.map(str::to_string)
else {
return Ok(());
};
let current_config_text = read_codex_config_text().ok();
let anchors = anchor_config_text
.into_iter()
.chain(current_config_text.as_deref());
let normalized =
normalize_codex_live_config_model_provider_with_anchors(&config_text, anchors)?;
if let Some(obj) = settings.as_object_mut() {
obj.insert("config".to_string(), Value::String(normalized));
}
Ok(())
}
fn restore_codex_backfill_model_provider_id(
config_text: &str,
template_config_text: &str,
) -> Result<String, AppError> {
let Some(template_provider_id) =
codex_model_provider_id_with_table_from_config(template_config_text)?
else {
return Ok(config_text.to_string());
};
if config_text.trim().is_empty() {
return Ok(config_text.to_string());
}
let mut doc = config_text
.parse::<DocumentMut>()
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
let Some(live_provider_id) = active_codex_model_provider_id(&doc) else {
return Ok(config_text.to_string());
};
if live_provider_id == template_provider_id {
return Ok(config_text.to_string());
}
if let Some(model_providers) = doc
.get_mut("model_providers")
.and_then(|item| item.as_table_mut())
{
let Some(provider_table) = model_providers.remove(live_provider_id.as_str()) else {
return Ok(config_text.to_string());
};
model_providers[template_provider_id.as_str()] = provider_table;
} else {
return Ok(config_text.to_string());
}
rewrite_codex_profile_model_provider_refs(&mut doc, &live_provider_id, &template_provider_id);
doc["model_provider"] = toml_edit::value(template_provider_id.as_str());
Ok(doc.to_string())
}
/// Convert a Codex live config that was normalized for history stability back
/// to the provider-specific id used by the stored provider template.
pub fn restore_codex_settings_config_model_provider_for_backfill(
settings: &mut Value,
template_settings: &Value,
) -> Result<(), AppError> {
let Some(config_text) = settings
.get("config")
.and_then(|value| value.as_str())
.map(str::to_string)
else {
return Ok(());
};
let Some(template_config_text) = template_settings
.get("config")
.and_then(|value| value.as_str())
else {
return Ok(());
};
let restored = restore_codex_backfill_model_provider_id(&config_text, template_config_text)?;
if let Some(obj) = settings.as_object_mut() {
obj.insert("config".to_string(), Value::String(restored));
}
Ok(())
}
/// Atomically write Codex live config after normalizing provider-specific ids.
///
/// Use this for provider-driven live writes. Keep `write_codex_live_atomic` available
/// for exact restore/backup paths that must preserve the config text byte-for-byte.
pub fn write_codex_live_atomic_with_stable_provider(
auth: &Value,
config_text_opt: Option<&str>,
) -> Result<(), AppError> {
match config_text_opt {
Some(config_text) => {
let mut settings = serde_json::Map::new();
settings.insert("config".to_string(), Value::String(config_text.to_string()));
let mut settings = Value::Object(settings);
normalize_codex_settings_config_model_provider(&mut settings, None)?;
let config_text = settings
.get("config")
.and_then(|value| value.as_str())
.unwrap_or(config_text);
write_codex_live_atomic(auth, Some(config_text))
}
None => write_codex_live_atomic(auth, None),
}
}
/// Update a field in Codex config.toml using toml_edit (syntax-preserving).
///
/// Supported fields:
@@ -530,241 +254,6 @@ pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) ->
mod tests {
use super::*;
#[test]
fn normalize_live_config_preserves_current_custom_model_provider_id() {
let current = r#"model_provider = "rightcode"
[model_providers.rightcode]
name = "RightCode"
base_url = "https://rightcode.example/v1"
wire_api = "responses"
"#;
let target = 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
[mcp_servers.context7]
command = "npx"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("rightcode")
);
let model_providers = parsed
.get("model_providers")
.and_then(|v| v.as_table())
.expect("model_providers should exist");
assert!(
model_providers.get("aihubmix").is_none(),
"source provider id should not remain in live config"
);
let stable_provider = model_providers
.get("rightcode")
.expect("stable provider table should exist");
assert_eq!(
stable_provider.get("base_url").and_then(|v| v.as_str()),
Some("https://aihubmix.example/v1")
);
assert!(
parsed.get("mcp_servers").is_some(),
"unrelated config should be preserved"
);
}
#[test]
fn normalize_live_config_uses_target_custom_provider_when_current_is_reserved() {
let current = r#"model_provider = "openai""#;
let target = r#"model_provider = "aihubmix"
[model_providers.aihubmix]
name = "AiHubMix"
base_url = "https://aihubmix.example/v1"
wire_api = "responses"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("aihubmix")
);
assert!(
parsed
.get("model_providers")
.and_then(|v| v.get("aihubmix"))
.is_some(),
"target provider id should be kept when there is no reusable live custom id"
);
}
#[test]
fn normalize_live_config_leaves_official_empty_config_unchanged() {
let current = r#"model_provider = "rightcode"
[model_providers.rightcode]
base_url = "https://rightcode.example/v1"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors("", Some(current)).unwrap();
assert_eq!(result, "");
}
#[test]
fn normalize_live_config_rewrites_matching_profile_model_provider_refs() {
let current = r#"model_provider = "session_anchor"
[model_providers.session_anchor]
name = "Session Anchor"
base_url = "https://anchor.example/v1"
wire_api = "responses"
"#;
let target = r#"model_provider = "vendor_alpha"
model = "gpt-5.4"
profile = "work"
[model_providers.vendor_alpha]
name = "Vendor Alpha"
base_url = "https://alpha.example/v1"
wire_api = "responses"
[profiles.work]
model_provider = "vendor_alpha"
model = "gpt-5.4"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("session_anchor")
);
assert_eq!(
parsed
.get("profiles")
.and_then(|v| v.get("work"))
.and_then(|v| v.get("model_provider"))
.and_then(|v| v.as_str()),
Some("session_anchor"),
"profile override matching the rewritten provider should stay valid"
);
}
#[test]
fn normalize_live_config_keeps_unrelated_profile_model_provider_refs() {
let current = r#"model_provider = "session_anchor"
[model_providers.session_anchor]
name = "Session Anchor"
base_url = "https://anchor.example/v1"
wire_api = "responses"
"#;
let target = r#"model_provider = "vendor_alpha"
model = "gpt-5.4"
[model_providers.vendor_alpha]
name = "Vendor Alpha"
base_url = "https://alpha.example/v1"
wire_api = "responses"
[model_providers.local_profile]
name = "Local Profile"
base_url = "http://localhost:11434/v1"
wire_api = "responses"
[profiles.local]
model_provider = "local_profile"
model = "local-model"
"#;
let result =
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(
parsed
.get("profiles")
.and_then(|v| v.get("local"))
.and_then(|v| v.get("model_provider"))
.and_then(|v| v.as_str()),
Some("local_profile"),
"unrelated profile provider references should be preserved"
);
assert!(
parsed
.get("model_providers")
.and_then(|v| v.get("local_profile"))
.is_some(),
"unrelated provider tables should also remain available"
);
}
#[test]
fn normalize_live_config_keeps_stable_provider_across_repeated_switches() {
let anchor = r#"model_provider = "session_anchor"
[model_providers.session_anchor]
name = "Session Anchor"
base_url = "https://anchor.example/v1"
wire_api = "responses"
"#;
let first_target = r#"model_provider = "vendor_alpha"
[model_providers.vendor_alpha]
name = "Vendor Alpha"
base_url = "https://alpha.example/v1"
wire_api = "responses"
"#;
let second_target = r#"model_provider = "vendor_beta"
[model_providers.vendor_beta]
name = "Vendor Beta"
base_url = "https://beta.example/v1"
wire_api = "responses"
"#;
let first =
normalize_codex_live_config_model_provider_with_anchors(first_target, Some(anchor))
.unwrap();
let second = normalize_codex_live_config_model_provider_with_anchors(
second_target,
Some(first.as_str()),
)
.unwrap();
let parsed: toml::Value = toml::from_str(&second).unwrap();
assert_eq!(
parsed.get("model_provider").and_then(|v| v.as_str()),
Some("session_anchor"),
"stable provider id should not drift across repeated switches"
);
assert_eq!(
parsed
.get("model_providers")
.and_then(|v| v.get("session_anchor"))
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str()),
Some("https://beta.example/v1")
);
}
#[test]
fn base_url_writes_into_correct_model_provider_section() {
let input = r#"model_provider = "any"
+2 -9
View File
@@ -18,7 +18,6 @@ pub struct ManagedAuthAccount {
pub avatar_url: Option<String>,
pub authenticated_at: i64,
pub is_default: bool,
pub github_domain: String,
}
#[derive(Debug, Clone, serde::Serialize)]
@@ -60,7 +59,6 @@ fn map_account(
login: account.login,
avatar_url: account.avatar_url,
authenticated_at: account.authenticated_at,
github_domain: account.github_domain,
}
}
@@ -81,7 +79,6 @@ fn map_device_code_response(
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_start_login(
auth_provider: String,
github_domain: Option<String>,
copilot_state: State<'_, CopilotAuthState>,
codex_state: State<'_, CodexOAuthState>,
) -> Result<ManagedAuthDeviceCodeResponse, String> {
@@ -90,7 +87,7 @@ pub async fn auth_start_login(
AUTH_PROVIDER_GITHUB_COPILOT => {
let auth_manager = copilot_state.0.read().await;
let response = auth_manager
.start_device_flow(github_domain.as_deref())
.start_device_flow()
.await
.map_err(|e| e.to_string())?;
Ok(map_device_code_response(auth_provider, response))
@@ -111,7 +108,6 @@ pub async fn auth_start_login(
pub async fn auth_poll_for_account(
auth_provider: String,
device_code: String,
github_domain: Option<String>,
copilot_state: State<'_, CopilotAuthState>,
codex_state: State<'_, CodexOAuthState>,
) -> Result<Option<ManagedAuthAccount>, String> {
@@ -119,10 +115,7 @@ pub async fn auth_poll_for_account(
match auth_provider {
AUTH_PROVIDER_GITHUB_COPILOT => {
let auth_manager = copilot_state.0.write().await;
match auth_manager
.poll_for_token(&device_code, github_domain.as_deref())
.await
{
match auth_manager.poll_for_token(&device_code).await {
Ok(account) => {
let default_account_id = auth_manager.get_status().await.default_account_id;
Ok(account.map(|account| {
+2 -34
View File
@@ -1,6 +1,6 @@
#![allow(non_snake_case)]
use tauri::{AppHandle, State};
use tauri::AppHandle;
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
@@ -8,7 +8,6 @@ use crate::app_config::AppType;
use crate::codex_config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::settings;
use crate::store::AppState;
#[tauri::command]
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
@@ -63,23 +62,9 @@ fn validate_common_config_snippet(app_type: &str, snippet: &str) -> Result<(), S
}
#[tauri::command]
pub async fn get_config_status(
state: State<'_, AppState>,
app: String,
) -> Result<ConfigStatus, String> {
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
match AppType::from_str(&app).map_err(|e| e.to_string())? {
AppType::Claude => Ok(config::get_claude_config_status()),
AppType::ClaudeDesktop => {
let status = crate::claude_desktop_config::get_status(
state.db.as_ref(),
state.proxy_service.is_running().await,
)
.map_err(|e| e.to_string())?;
Ok(ConfigStatus {
exists: status.configured,
path: status.config_library_path.unwrap_or_default(),
})
}
AppType::Codex => {
let auth_path = codex_config::get_codex_auth_path();
let exists = auth_path.exists();
@@ -116,15 +101,6 @@ pub async fn get_config_status(
Ok(ConfigStatus { exists, path })
}
AppType::Hermes => {
let config_path = crate::hermes_config::get_hermes_config_path();
let exists = config_path.exists();
let path = crate::hermes_config::get_hermes_dir()
.to_string_lossy()
.to_string();
Ok(ConfigStatus { exists, path })
}
}
}
@@ -137,14 +113,10 @@ pub async fn get_claude_code_config_path() -> Result<String, String> {
pub async fn get_config_dir(app: String) -> Result<String, String> {
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
AppType::Claude => config::get_claude_config_dir(),
AppType::ClaudeDesktop => {
crate::claude_desktop_config::get_config_library_path().map_err(|e| e.to_string())?
}
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
};
Ok(dir.to_string_lossy().to_string())
@@ -154,14 +126,10 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
AppType::Claude => config::get_claude_config_dir(),
AppType::ClaudeDesktop => {
crate::claude_desktop_config::get_config_library_path().map_err(|e| e.to_string())?
}
AppType::Codex => codex_config::get_codex_config_dir(),
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
};
if !config_dir.exists() {
+3 -12
View File
@@ -20,12 +20,11 @@ pub struct CopilotAuthState(pub Arc<RwLock<CopilotAuthManager>>);
/// 返回设备码和用户码,用于 OAuth 认证
#[tauri::command]
pub async fn copilot_start_device_flow(
github_domain: Option<String>,
state: State<'_, CopilotAuthState>,
) -> Result<GitHubDeviceCodeResponse, String> {
let auth_manager = state.0.read().await;
auth_manager
.start_device_flow(github_domain.as_deref())
.start_device_flow()
.await
.map_err(|e| e.to_string())
}
@@ -37,14 +36,10 @@ pub async fn copilot_start_device_flow(
#[tauri::command(rename_all = "camelCase")]
pub async fn copilot_poll_for_auth(
device_code: String,
github_domain: Option<String>,
state: State<'_, CopilotAuthState>,
) -> Result<bool, String> {
let auth_manager = state.0.write().await;
match auth_manager
.poll_for_token(&device_code, github_domain.as_deref())
.await
{
match auth_manager.poll_for_token(&device_code).await {
Ok(Some(_account)) => {
log::info!("[CopilotAuth] 用户已授权");
Ok(true)
@@ -66,14 +61,10 @@ pub async fn copilot_poll_for_auth(
#[tauri::command(rename_all = "camelCase")]
pub async fn copilot_poll_for_account(
device_code: String,
github_domain: Option<String>,
state: State<'_, CopilotAuthState>,
) -> Result<Option<GitHubAccount>, String> {
let auth_manager = state.0.write().await;
match auth_manager
.poll_for_token(&device_code, github_domain.as_deref())
.await
{
match auth_manager.poll_for_token(&device_code).await {
Ok(account) => Ok(account),
Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => {
Ok(None)
+1 -1
View File
@@ -162,7 +162,7 @@ pub async fn set_auto_failover_enabled(
// 刷新托盘菜单,确保状态同步
if let Ok(new_menu) = crate::tray::create_tray_menu(&app, &state) {
if let Some(tray) = app.tray_by_id(crate::tray::TRAY_ID) {
if let Some(tray) = app.tray_by_id("main") {
let _ = tray.set_menu(Some(new_menu));
}
}
-143
View File
@@ -1,143 +0,0 @@
use std::time::Duration;
use tauri::{AppHandle, State};
use tauri_plugin_opener::OpenerExt;
use crate::hermes_config;
use crate::store::AppState;
/// Error string returned when `open_hermes_web_ui` cannot reach the Hermes
/// FastAPI server. Kept in sync with the `HERMES_WEB_OFFLINE_ERROR` constant
/// in `src/hooks/useHermes.ts` so the frontend can branch on it.
const HERMES_WEB_OFFLINE_ERROR: &str = "hermes_web_offline";
// ============================================================================
// Hermes Provider Commands
// ============================================================================
/// Import providers from Hermes live config to database.
///
/// Hermes uses additive mode — users may already have providers
/// configured in config.yaml.
#[tauri::command]
pub fn import_hermes_providers_from_live(state: State<'_, AppState>) -> Result<usize, String> {
crate::services::provider::import_hermes_providers_from_live(state.inner())
.map_err(|e| e.to_string())
}
/// Get provider names in the Hermes live config.
#[tauri::command]
pub fn get_hermes_live_provider_ids() -> Result<Vec<String>, String> {
hermes_config::get_providers()
.map(|providers| providers.keys().cloned().collect())
.map_err(|e| e.to_string())
}
/// Get a single Hermes provider fragment from live config.
#[tauri::command]
pub fn get_hermes_live_provider(
#[allow(non_snake_case)] providerId: String,
) -> Result<Option<serde_json::Value>, String> {
hermes_config::get_provider(&providerId).map_err(|e| e.to_string())
}
// ============================================================================
// Model Configuration Commands
// ============================================================================
/// Get Hermes model config (model section of config.yaml). Read-only — writes
/// happen implicitly through `apply_switch_defaults` when switching providers.
#[tauri::command]
pub fn get_hermes_model_config() -> Result<Option<hermes_config::HermesModelConfig>, String> {
hermes_config::get_model_config().map_err(|e| e.to_string())
}
// ============================================================================
// Memory Files Commands
// ============================================================================
#[tauri::command]
pub fn get_hermes_memory(kind: hermes_config::MemoryKind) -> Result<String, String> {
hermes_config::read_memory(kind).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_hermes_memory(kind: hermes_config::MemoryKind, content: String) -> Result<(), String> {
hermes_config::write_memory(kind, &content).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_hermes_memory_limits() -> Result<hermes_config::HermesMemoryLimits, String> {
hermes_config::read_memory_limits().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_hermes_memory_enabled(
kind: hermes_config::MemoryKind,
enabled: bool,
) -> Result<hermes_config::HermesWriteOutcome, String> {
hermes_config::set_memory_enabled(kind, enabled).map_err(|e| e.to_string())
}
// ============================================================================
// Hermes Web UI launcher
// ============================================================================
/// Probe the local Hermes Web UI (FastAPI) and open it in the system browser.
///
/// Port discovery priority:
/// 1. `HERMES_WEB_PORT` environment variable
/// 2. Default 9119
///
/// Hermes wraps all `/api/*` routes in a Bearer-token middleware, so a GET
/// against `/api/status` returning **either 200 or 401** confirms the server
/// is live. The session token lives only in the Hermes process memory and is
/// injected into the returned HTML via `window.__HERMES_SESSION_TOKEN__`, so
/// there is no need (and no way) for CC Switch to inject it — we just open
/// the URL and let Hermes handle auth.
#[tauri::command]
pub async fn open_hermes_web_ui(app: AppHandle, path: Option<String>) -> Result<(), String> {
let port = std::env::var("HERMES_WEB_PORT")
.ok()
.and_then(|raw| raw.trim().parse::<u16>().ok())
.unwrap_or(9119);
let base = format!("http://127.0.0.1:{port}");
// Probe /api/status with a short timeout. Hermes returns 200 when open or
// 401 when the session token is required — either way the server is live.
// Only a connection error / timeout means the server isn't running.
let probe_url = format!("{base}/api/status");
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(1200))
.no_proxy()
.build()
.map_err(|e| format!("failed to build probe client: {e}"))?;
match client.get(&probe_url).send().await {
Ok(_) => {}
Err(_) => return Err(HERMES_WEB_OFFLINE_ERROR.to_string()),
}
let target = match path.as_deref() {
Some(p) if p.starts_with('/') => format!("{base}{p}"),
Some(p) if !p.is_empty() => format!("{base}/{p}"),
_ => format!("{base}/"),
};
app.opener()
.open_url(&target, None::<String>)
.map_err(|e| format!("failed to open Hermes Web UI: {e}"))
}
/// Open the preferred terminal and run `hermes dashboard`. Non-blocking —
/// callers should reinvoke `open_hermes_web_ui` once the server is ready,
/// since Hermes startup can take several seconds and may fail outright if
/// the `hermes-agent[web]` extras are missing.
#[tauri::command]
pub async fn launch_hermes_dashboard() -> Result<(), String> {
tokio::task::spawn_blocking(|| {
crate::commands::misc::launch_terminal_running("hermes dashboard", "hermes_dashboard")
})
.await
.map_err(|e| format!("launch task join error: {e}"))?
}
-1
View File
@@ -202,6 +202,5 @@ pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, S
total += McpService::import_from_codex(&state).unwrap_or(0);
total += McpService::import_from_gemini(&state).unwrap_or(0);
total += McpService::import_from_opencode(&state).unwrap_or(0);
total += McpService::import_from_hermes(&state).unwrap_or(0);
Ok(total)
}
+20 -320
View File
@@ -300,13 +300,8 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
#[cfg(not(target_os = "windows"))]
let output = {
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);
Command::new(shell)
.arg(flag)
Command::new("sh")
.arg("-c")
.arg(format!("{tool} --version"))
.output()
};
@@ -350,6 +345,7 @@ fn is_valid_wsl_distro_name(name: &str) -> bool {
}
/// Validate that the given shell name is one of the allowed shells.
#[cfg(target_os = "windows")]
fn is_valid_shell(shell: &str) -> bool {
matches!(
shell.rsplit('/').next().unwrap_or(shell),
@@ -364,6 +360,7 @@ fn is_valid_shell_flag(flag: &str) -> bool {
}
/// Return the default invocation flag for the given shell.
#[cfg(target_os = "windows")]
fn default_flag_for_shell(shell: &str) -> &'static str {
match shell.rsplit('/').next().unwrap_or(shell) {
"dash" | "sh" => "-c",
@@ -784,7 +781,7 @@ fn extract_env_vars_from_config(
// 处理 base_url: 根据应用类型添加对应的环境变量
let base_url_key = match app_type {
AppType::Claude | AppType::ClaudeDesktop => Some("ANTHROPIC_BASE_URL"),
AppType::Claude => Some("ANTHROPIC_BASE_URL"),
AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"),
_ => None,
};
@@ -947,7 +944,6 @@ exec bash --norc --noprofile
// Note: Kitty doesn't need the -e flag, others do
let result = match terminal {
"iterm2" => launch_macos_iterm2(&script_file),
"warp" => launch_macos_warp(&script_file),
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
"kitty" => launch_macos_open_app("kitty", &script_file, false),
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
@@ -1000,48 +996,23 @@ end tell"#,
Ok(())
}
/// macOS: iTerm2
#[cfg(target_os = "macos")]
fn build_macos_iterm2_applescript(script_file: &std::path::Path) -> String {
format!(
r#"set launcher_script to "bash '{}'"
set was_running to application "iTerm" is running
tell application "iTerm"
if was_running then
activate
if (count of windows) = 0 then
create window with default profile
else
tell current window
create tab with default profile
end tell
end if
else
activate
set waited to 0
repeat while (count of windows) = 0
delay 0.1
set waited to waited + 1
if waited >= 30 then exit repeat
end repeat
if (count of windows) = 0 then
create window with default profile
end if
end if
tell current session of current window
write text launcher_script
end tell
end tell"#,
script_file.display()
)
}
/// macOS: iTerm2
#[cfg(target_os = "macos")]
fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
use std::process::Command;
let applescript = build_macos_iterm2_applescript(script_file);
let applescript = format!(
r#"tell application "iTerm"
activate
tell current window
create tab with default profile
tell current session
write text "bash '{}'"
end tell
end tell
end tell"#,
script_file.display()
);
let output = Command::new("osascript")
.arg("-e")
@@ -1095,57 +1066,6 @@ fn launch_macos_open_app(
Ok(())
}
#[cfg(target_os = "macos")]
fn launch_macos_warp(script_file: &std::path::Path) -> Result<(), String> {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
let mut cmd = Command::new("open");
cmd.arg("-a").arg("Warp");
// Warp URI scheme cannot work well with script_file, because:
//
// 1. script_file's name ends up with .sh, so Warp would open the file rather than execute it
// 2. script_file has no execution permission, so we need to add one more indirection
let mut second_script_file = tempfile::Builder::new()
.disable_cleanup(true)
.permissions(std::fs::Permissions::from_mode(0o755))
.tempfile()
.map_err(|e| format!("Failed to create temporary script file: {e}"))?;
writeln!(
&mut second_script_file,
r#"#!/usr/bin/env sh
rm -- "$0"
exec bash {}
"#,
script_file.display(),
)
.map_err(|e| format!("Failed to write to temporary script file for Warp: {e}"))?;
let mut warp_url = url::Url::parse("warp://action/new_tab").unwrap();
warp_url
.query_pairs_mut()
.append_pair("path", &second_script_file.path().to_string_lossy());
let warp_url = warp_url.to_string();
cmd.arg(warp_url);
let output = cmd.output().map_err(|e| format!("启动 Warp 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"Warp 启动失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
}
Ok(())
}
/// Linux: 根据用户首选终端启动
#[cfg(target_os = "linux")]
fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
@@ -1390,189 +1310,6 @@ fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), S
Ok(())
}
/// 打开用户首选终端并在其中执行一条命令行。脚本尾部 `read -n 1` / `pause`
/// 是刻意设计的——让命令退出后窗口不要瞬间关闭,用户才看得到 `command
/// not found` / `ModuleNotFoundError` 这类诊断信息。
///
/// **Security**`command_line` 会被原样拼进 shell/batch 脚本,调用方必须
/// 保证它是可信字符串(当前只由后端硬编码调用)。
pub(crate) fn launch_terminal_running(command_line: &str, label: &str) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let pid = std::process::id();
#[cfg(any(target_os = "macos", target_os = "linux"))]
let (script_file, script_content) = {
let file = temp_dir.join(format!("cc_switch_{}_{}.sh", label, pid));
let content = format!(
r#"#!/bin/bash
trap 'rm -f "{script_path}"' EXIT
echo "[cc-switch] Starting: {cmd}"
echo ""
{cmd}
echo ""
echo "[cc-switch] Command exited. Press any key to close."
read -n 1 -s
"#,
script_path = file.display(),
cmd = command_line,
);
(file, content)
};
#[cfg(target_os = "macos")]
{
use std::os::unix::fs::PermissionsExt;
std::fs::write(&script_file, &script_content)
.map_err(|e| format!("写入启动脚本失败: {e}"))?;
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("terminal");
let result = match terminal {
"iterm2" => launch_macos_iterm2(&script_file),
"warp" => launch_macos_warp(&script_file),
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
"kitty" => launch_macos_open_app("kitty", &script_file, false),
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
"wezterm" => launch_macos_open_app("WezTerm", &script_file, true),
"kaku" => launch_macos_open_app("Kaku", &script_file, true),
_ => launch_macos_terminal_app(&script_file),
};
if result.is_err() && terminal != "terminal" {
log::warn!(
"首选终端 {} 启动失败,回退到 Terminal.app: {:?}",
terminal,
result.as_ref().err()
);
return launch_macos_terminal_app(&script_file);
}
result
}
#[cfg(target_os = "linux")]
{
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
std::fs::write(&script_file, &script_content)
.map_err(|e| format!("写入启动脚本失败: {e}"))?;
std::fs::set_permissions(&script_file, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("设置脚本权限失败: {e}"))?;
let preferred = crate::settings::get_preferred_terminal();
let default_terminals = [
("gnome-terminal", vec!["--"]),
("konsole", vec!["-e"]),
("xfce4-terminal", vec!["-e"]),
("mate-terminal", vec!["--"]),
("lxterminal", vec!["-e"]),
("alacritty", vec!["-e"]),
("kitty", vec!["-e"]),
("ghostty", vec!["-e"]),
];
let terminals_to_try: Vec<(&str, Vec<&str>)> = if let Some(ref pref) = preferred {
let pref_args = default_terminals
.iter()
.find(|(name, _)| *name == pref.as_str())
.map(|(_, args)| args.to_vec())
.unwrap_or_else(|| vec!["-e"]);
let mut list = vec![(pref.as_str(), pref_args)];
for (name, args) in &default_terminals {
if *name != pref.as_str() {
list.push((*name, args.to_vec()));
}
}
list
} else {
default_terminals
.iter()
.map(|(name, args)| (*name, args.to_vec()))
.collect()
};
let mut last_error = String::from("未找到可用的终端");
for (terminal, args) in terminals_to_try {
let terminal_exists = which_command(terminal)
|| ["/usr/bin", "/bin", "/usr/local/bin"]
.iter()
.any(|dir| std::path::Path::new(&format!("{}/{}", dir, terminal)).exists());
if terminal_exists {
let spawn_result = Command::new(terminal)
.args(&args)
.arg("bash")
.arg(script_file.to_string_lossy().as_ref())
.spawn();
match spawn_result {
Ok(_) => return Ok(()),
Err(e) => {
last_error = format!("执行 {} 失败: {}", terminal, e);
}
}
}
}
let _ = std::fs::remove_file(&script_file);
Err(last_error)
}
#[cfg(target_os = "windows")]
{
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("cmd");
let bat_file = temp_dir.join(format!("cc_switch_{}_{}.bat", label, pid));
let content = format!(
"@echo off\r\necho [cc-switch] Starting: {cmd}\r\necho.\r\n{cmd}\r\necho.\r\necho [cc-switch] Command exited. Press any key to close.\r\npause >nul\r\ndel \"%~f0\" >nul 2>&1\r\n",
cmd = command_line,
);
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
let bat_path = bat_file.to_string_lossy();
let ps_cmd = format!("& '{}'", bat_path);
let result = match terminal {
"powershell" => run_windows_start_command(
&["powershell", "-NoExit", "-Command", &ps_cmd],
"PowerShell",
),
"wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"),
_ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"),
};
let final_result = if result.is_err() && terminal != "cmd" {
log::warn!(
"首选终端 {} 启动失败,回退到 cmd: {:?}",
terminal,
result.as_ref().err()
);
run_windows_start_command(&["cmd", "/K", &bat_path], "cmd")
} else {
result
};
// The .bat self-deletes (`del "%~f0"`) after it runs, but that only
// fires if *some* terminal actually launched it. If every attempt
// failed, sweep the temp file ourselves to avoid pollution.
if final_result.is_err() {
let _ = std::fs::remove_file(&bat_file);
}
final_result
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
let _ = (temp_dir, pid, command_line, label);
Err("不支持的操作系统".to_string())
}
}
/// 设置窗口主题(Windows/macOS 标题栏颜色)
/// theme: "dark" | "light" | "system"
#[tauri::command]
@@ -1591,7 +1328,7 @@ pub async fn set_window_theme(window: tauri::Window, theme: String) -> Result<()
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
#[test]
fn test_extract_version() {
@@ -1679,7 +1416,7 @@ mod tests {
let count = paths
.iter()
.filter(|path| path.as_path() == Path::new("/same/path"))
.filter(|path| **path == PathBuf::from("/same/path"))
.count();
assert_eq!(count, 1);
}
@@ -1691,7 +1428,7 @@ mod tests {
let count = paths
.iter()
.filter(|path| path.as_path() == Path::new("/home/tester/.bun/bin"))
.filter(|path| **path == PathBuf::from("/home/tester/.bun/bin"))
.count();
assert_eq!(count, 1);
}
@@ -1752,43 +1489,6 @@ mod tests {
assert_eq!(command, "cd '/tmp/project O'\"'\"'Brien' || exit 1\n");
}
#[cfg(target_os = "macos")]
#[test]
fn iterm2_applescript_cold_start_avoids_current_window_before_one_exists() {
let script = build_macos_iterm2_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
let cold_start_branch = script
.split("else\n activate")
.nth(1)
.expect("cold start branch should be present")
.split(" end if\n tell current session")
.next()
.expect("cold start branch should end before writing command");
assert!(cold_start_branch.contains("repeat while (count of windows) = 0"));
assert!(cold_start_branch.contains("create window with default profile"));
assert!(!cold_start_branch.contains("tell current window"));
assert!(!cold_start_branch.contains("create tab with default profile"));
}
#[cfg(target_os = "macos")]
#[test]
fn iterm2_applescript_keeps_new_tab_behavior_for_existing_windows() {
let script = build_macos_iterm2_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
let running_branch = script
.split("if was_running then")
.nth(1)
.expect("already-running branch should be present")
.split("else\n activate")
.next()
.expect("already-running branch should end before cold start branch");
assert!(running_branch.contains("if (count of windows) = 0 then"));
assert!(running_branch.contains("create window with default profile"));
assert!(running_branch.contains("create tab with default profile"));
}
#[test]
fn build_windows_cwd_command_str_uses_cd_for_drive_paths() {
let command = build_windows_cwd_command_str(r"C:\work\repo");
-2
View File
@@ -10,7 +10,6 @@ mod deeplink;
mod env;
mod failover;
mod global_proxy;
mod hermes;
mod import_export;
mod mcp;
mod misc;
@@ -43,7 +42,6 @@ pub use deeplink::*;
pub use env::*;
pub use failover::*;
pub use global_proxy::*;
pub use hermes::*;
pub use import_export::*;
pub use mcp::*;
pub use misc::*;
+3 -10
View File
@@ -6,20 +6,13 @@ use crate::services::model_fetch::{self, FetchedModel};
/// 获取供应商的可用模型列表
///
/// 使用 OpenAI 兼容的 GET /v1/models 端点。优先使用 `models_url` 精确覆写;
/// 否则对 baseURL 生成候选列表(含「剥离 Anthropic 兼容子路径」兜底),按序尝试
/// 使用 OpenAI 兼容的 GET /v1/models 端点。
/// 主要面向第三方聚合站(硅基流动、OpenRouter 等)
#[tauri::command(rename_all = "camelCase")]
pub async fn fetch_models_for_config(
base_url: String,
api_key: String,
is_full_url: Option<bool>,
models_url: Option<String>,
) -> Result<Vec<FetchedModel>, String> {
model_fetch::fetch_models(
&base_url,
&api_key,
is_full_url.unwrap_or(false),
models_url.as_deref(),
)
.await
model_fetch::fetch_models(&base_url, &api_key, is_full_url.unwrap_or(false)).await
}
+5 -189
View File
@@ -1,10 +1,10 @@
use indexmap::IndexMap;
use tauri::{Emitter, State};
use tauri::State;
use crate::app_config::AppType;
use crate::commands::copilot::CopilotAuthState;
use crate::error::AppError;
use crate::provider::{ClaudeDesktopMode, Provider};
use crate::provider::Provider;
use crate::services::{
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
};
@@ -150,206 +150,22 @@ pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<
import_default_config_internal(&state, app_type).map_err(Into::into)
}
#[tauri::command]
pub async fn get_claude_desktop_status(
state: State<'_, AppState>,
) -> Result<crate::claude_desktop_config::ClaudeDesktopStatus, String> {
let proxy_running = state.proxy_service.is_running().await;
crate::claude_desktop_config::get_status(state.db.as_ref(), proxy_running)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_claude_desktop_default_routes(
) -> Vec<crate::claude_desktop_config::ClaudeDesktopDefaultRoute> {
crate::claude_desktop_config::default_proxy_routes()
}
#[tauri::command]
pub fn import_claude_desktop_providers_from_claude(
state: State<'_, AppState>,
) -> Result<usize, String> {
let claude_providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.map_err(|e| e.to_string())?;
let existing_ids = state
.db
.get_provider_ids(AppType::ClaudeDesktop.as_str())
.map_err(|e| e.to_string())?;
let mut imported = 0usize;
for provider in claude_providers.values() {
if existing_ids.contains(&provider.id) {
continue;
}
if matches!(
provider
.meta
.as_ref()
.and_then(|meta| meta.provider_type.as_deref()),
Some("github_copilot") | Some("codex_oauth")
) {
continue;
}
let mut desktop_provider = provider.clone();
desktop_provider.in_failover_queue = false;
let meta = desktop_provider.meta.get_or_insert_with(Default::default);
if crate::claude_desktop_config::is_compatible_direct_provider(provider)
&& claude_provider_models_are_claude_safe(provider)
{
meta.claude_desktop_mode = Some(ClaudeDesktopMode::Direct);
} else if let Some(routes) = suggested_claude_desktop_routes(provider) {
meta.claude_desktop_mode = Some(ClaudeDesktopMode::Proxy);
meta.claude_desktop_model_routes = routes;
} else {
continue;
}
state
.db
.save_provider(AppType::ClaudeDesktop.as_str(), &desktop_provider)
.map_err(|e| e.to_string())?;
imported += 1;
}
Ok(imported)
}
fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool {
let Some(env) = provider
.settings_config
.get("env")
.and_then(|value| value.as_object())
else {
return true;
};
[
"ANTHROPIC_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
]
.into_iter()
.filter_map(|key| env.get(key).and_then(|value| value.as_str()))
.map(str::trim)
.filter(|value| !value.is_empty())
.all(crate::claude_desktop_config::is_claude_safe_model_id)
}
fn suggested_claude_desktop_routes(
provider: &Provider,
) -> Option<std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>> {
let env = provider
.settings_config
.get("env")
.and_then(|value| value.as_object())?;
let mut routes = std::collections::HashMap::new();
fn add_route(
routes: &mut std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>,
env: &serde_json::Map<String, serde_json::Value>,
route_id: &str,
env_key: &str,
display_name: &str,
) {
if let Some(model) = env
.get(env_key)
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
{
routes.insert(
route_id.to_string(),
crate::provider::ClaudeDesktopModelRoute {
model: model.to_string(),
display_name: Some(display_name.to_string()),
supports_1m: Some(true),
},
);
}
}
for spec in crate::claude_desktop_config::DEFAULT_PROXY_ROUTES {
add_route(
&mut routes,
env,
spec.route_id,
spec.env_key,
spec.display_name,
);
}
let primary_route = crate::claude_desktop_config::DEFAULT_PROXY_ROUTES[0];
if !routes.contains_key(primary_route.route_id) {
add_route(
&mut routes,
env,
primary_route.route_id,
"ANTHROPIC_MODEL",
primary_route.display_name,
);
}
(!routes.is_empty()).then_some(routes)
}
#[allow(non_snake_case)]
#[tauri::command]
pub async fn queryProviderUsage(
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
copilot_state: State<'_, CopilotAuthState>,
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
app: String,
) -> Result<crate::provider::UsageResult, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
// inner 可能以两种形式失败:
// 1) 返回 Ok(UsageResult { success: false, .. }) —— 业务失败(401、脚本报错等)
// 2) 返回 Err(String) —— RPC/DB/Copilot fetch_usage 等 transport 层失败
// 两种都要把"失败"写进 UsageCache 并刷新托盘,让 format_script_summary 的
// success 守卫生效、suffix 自然消失,避免旧 success 快照长期滞留。
// 同时保持原始 Err 返回给前端 React Query 的 onError 回调,不吞错误。
let inner =
query_provider_usage_inner(&state, &copilot_state, app_type.clone(), &providerId).await;
let snapshot = match &inner {
Ok(r) => r.clone(),
Err(err_msg) => crate::provider::UsageResult {
success: false,
data: None,
error: Some(err_msg.clone()),
},
};
let payload = serde_json::json!({
"kind": "script",
"appType": app_type.as_str(),
"providerId": &providerId,
"data": &snapshot,
});
if let Err(e) = app_handle.emit("usage-cache-updated", payload) {
log::error!("emit usage-cache-updated (script) 失败: {e}");
}
state.usage_cache.put_script(app_type, providerId, snapshot);
crate::tray::schedule_tray_refresh(&app_handle);
inner
}
async fn query_provider_usage_inner(
state: &AppState,
copilot_state: &CopilotAuthState,
app_type: AppType,
provider_id: &str,
) -> Result<crate::provider::UsageResult, String> {
// 从数据库读取供应商信息,检查特殊模板类型
let providers = state
.db
.get_all_providers(app_type.as_str())
.map_err(|e| format!("Failed to get providers: {e}"))?;
let provider = providers.get(provider_id);
let provider = providers.get(&providerId);
let usage_script = provider
.and_then(|p| p.meta.as_ref())
.and_then(|m| m.usage_script.as_ref());
@@ -478,7 +294,7 @@ async fn query_provider_usage_inner(
}
// ── 通用 JS 脚本路径 ──
ProviderService::query_usage(state, app_type, provider_id)
ProviderService::query_usage(state.inner(), app_type, &providerId)
.await
.map_err(|e| e.to_string())
}
@@ -590,7 +406,7 @@ pub fn update_providers_sort_order(
use crate::provider::UniversalProvider;
use std::collections::HashMap;
use tauri::AppHandle;
use tauri::{AppHandle, Emitter};
#[derive(Clone, serde::Serialize)]
pub struct UniversalProviderSyncedEvent {
-18
View File
@@ -15,24 +15,6 @@ pub async fn start_proxy_server(
state.proxy_service.start().await
}
/// 停止代理服务器(仅停止服务,不恢复/清理 Live 接管状态)
#[tauri::command]
pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(), String> {
let takeover = state.proxy_service.get_takeover_status().await?;
if takeover.claude
|| takeover.codex
|| takeover.gemini
|| takeover.opencode
|| takeover.openclaw
{
return Err(
"仍有应用处于代理接管状态,请先在设置中关闭对应应用接管后再停止本地路由。".to_string(),
);
}
state.proxy_service.stop().await
}
/// 停止代理服务器(恢复 Live 配置)
#[tauri::command]
pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {
+49 -65
View File
@@ -42,8 +42,6 @@ pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<boo
/// 重启应用程序(当 app_config_dir 变更后使用)
#[tauri::command]
pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
crate::save_window_state_before_exit(&app);
// 在后台延迟重启,让函数有时间返回响应
tauri::async_runtime::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
@@ -87,15 +85,13 @@ mod tests {
#[test]
fn save_settings_should_preserve_existing_webdav_when_payload_omits_it() {
let existing = AppSettings {
webdav_sync: Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "secret".to_string(),
..WebDavSyncSettings::default()
}),
..AppSettings::default()
};
let mut existing = AppSettings::default();
existing.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "secret".to_string(),
..WebDavSyncSettings::default()
});
let incoming = AppSettings::default();
let merged = merge_settings_for_save(incoming, &existing);
@@ -109,25 +105,21 @@ mod tests {
#[test]
fn save_settings_should_keep_incoming_webdav_when_present() {
let existing = AppSettings {
webdav_sync: Some(WebDavSyncSettings {
base_url: "https://dav.old.example.com".to_string(),
username: "old".to_string(),
password: "old-pass".to_string(),
..WebDavSyncSettings::default()
}),
..AppSettings::default()
};
let mut existing = AppSettings::default();
existing.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.old.example.com".to_string(),
username: "old".to_string(),
password: "old-pass".to_string(),
..WebDavSyncSettings::default()
});
let incoming = AppSettings {
webdav_sync: Some(WebDavSyncSettings {
base_url: "https://dav.new.example.com".to_string(),
username: "new".to_string(),
password: "new-pass".to_string(),
..WebDavSyncSettings::default()
}),
..AppSettings::default()
};
let mut incoming = AppSettings::default();
incoming.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.new.example.com".to_string(),
username: "new".to_string(),
password: "new-pass".to_string(),
..WebDavSyncSettings::default()
});
let merged = merge_settings_for_save(incoming, &existing);
@@ -143,26 +135,22 @@ mod tests {
/// must NOT overwrite the existing one.
#[test]
fn save_settings_should_preserve_password_when_incoming_has_empty_password() {
let existing = AppSettings {
webdav_sync: Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "secret".to_string(),
..WebDavSyncSettings::default()
}),
..AppSettings::default()
};
let mut existing = AppSettings::default();
existing.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "secret".to_string(),
..WebDavSyncSettings::default()
});
// Simulate frontend sending settings with cleared password
let incoming = AppSettings {
webdav_sync: Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "".to_string(),
..WebDavSyncSettings::default()
}),
..AppSettings::default()
};
let mut incoming = AppSettings::default();
incoming.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "".to_string(),
..WebDavSyncSettings::default()
});
let merged = merge_settings_for_save(incoming, &existing);
@@ -177,25 +165,21 @@ mod tests {
/// work without panicking and keep the empty state.
#[test]
fn save_settings_should_handle_both_empty_passwords() {
let existing = AppSettings {
webdav_sync: Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "".to_string(),
..WebDavSyncSettings::default()
}),
..AppSettings::default()
};
let mut existing = AppSettings::default();
existing.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "".to_string(),
..WebDavSyncSettings::default()
});
let incoming = AppSettings {
webdav_sync: Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "".to_string(),
..WebDavSyncSettings::default()
}),
..AppSettings::default()
};
let mut incoming = AppSettings::default();
incoming.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "".to_string(),
..WebDavSyncSettings::default()
});
let merged = merge_settings_for_save(incoming, &existing);
+7 -2
View File
@@ -12,7 +12,6 @@ use crate::services::skill::{
SkillsShSearchResult,
};
use crate::store::AppState;
use std::str::FromStr;
use std::sync::Arc;
use tauri::State;
@@ -21,7 +20,13 @@ pub struct SkillServiceState(pub Arc<SkillService>);
/// 解析 app 参数为 AppType
fn parse_app_type(app: &str) -> Result<AppType, String> {
AppType::from_str(app).map_err(|e| e.to_string())
match app.to_lowercase().as_str() {
"claude" => Ok(AppType::Claude),
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini),
"opencode" => Ok(AppType::OpenCode),
_ => Err(format!("不支持的 app 类型: {app}")),
}
}
// ========== 统一管理命令 ==========
+4 -35
View File
@@ -1,41 +1,10 @@
use std::str::FromStr;
use tauri::{Emitter, State};
use crate::app_config::AppType;
use crate::services::subscription::{CredentialStatus, SubscriptionQuota};
use crate::store::AppState;
use crate::services::subscription::SubscriptionQuota;
/// 查询官方订阅额度
///
/// 读取 CLI 工具已有的 OAuth 凭据并调用官方 API 获取使用额度。
/// 结果(无论业务失败还是 transport 层 Err)都会写入 `UsageCache`、通知托盘
/// 刷新,并 emit `usage-cache-updated`,让前端 React Query 与托盘共享同一份
/// 最新数据。失败快照写入后 `format_subscription_summary` 会通过 `success=false`
/// 守卫返回 `None`,托盘 suffix 自然消失,避免长期滞留旧配额数字。
/// Err 原样向前端返回,React Query 的 onError 不会被吞掉。
/// 不需要 AppState(不访问数据库),直接读文件 + 发 HTTP。
#[tauri::command]
pub async fn get_subscription_quota(
app: tauri::AppHandle,
state: State<'_, AppState>,
tool: String,
) -> Result<SubscriptionQuota, String> {
let inner = crate::services::subscription::get_subscription_quota(&tool).await;
let snapshot = match &inner {
Ok(q) => q.clone(),
// transport 层 Err —— 凭据状态不明,用 Valid 表达"凭据没问题,是通信/parse 出错"。
Err(err_msg) => SubscriptionQuota::error(&tool, CredentialStatus::Valid, err_msg.clone()),
};
if let Ok(app_type) = AppType::from_str(&tool) {
let payload = serde_json::json!({
"kind": "subscription",
"appType": app_type.as_str(),
"data": &snapshot,
});
if let Err(e) = app.emit("usage-cache-updated", payload) {
log::error!("emit usage-cache-updated (subscription) 失败: {e}");
}
state.usage_cache.put_subscription(app_type, snapshot);
crate::tray::schedule_tray_refresh(&app);
}
inner
pub async fn get_subscription_quota(tool: String) -> Result<SubscriptionQuota, String> {
crate::services::subscription::get_subscription_quota(&tool).await
}
+2 -10
View File
@@ -35,26 +35,18 @@ pub fn get_usage_trends(
#[tauri::command]
pub fn get_provider_stats(
state: State<'_, AppState>,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<String>,
) -> Result<Vec<ProviderStats>, AppError> {
state
.db
.get_provider_stats(start_date, end_date, app_type.as_deref())
state.db.get_provider_stats(app_type.as_deref())
}
/// 获取模型统计
#[tauri::command]
pub fn get_model_stats(
state: State<'_, AppState>,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<String>,
) -> Result<Vec<ModelStats>, AppError> {
state
.db
.get_model_stats(start_date, end_date, app_type.as_deref())
state.db.get_model_stats(app_type.as_deref())
}
/// 获取请求日志列表
+3 -120
View File
@@ -1,5 +1,4 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
@@ -160,34 +159,15 @@ pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, AppE
serde_json::from_str(&content).map_err(|e| AppError::json(path, e))
}
/// 递归排序 JSON 对象的键(按字母顺序),确保序列化输出是确定性的
fn sort_json_keys(value: &Value) -> Value {
match value {
Value::Object(map) => {
let mut sorted_map = Map::new();
let mut keys: Vec<_> = map.keys().collect();
keys.sort();
for key in keys {
sorted_map.insert(key.clone(), sort_json_keys(&map[key]));
}
Value::Object(sorted_map)
}
Value::Array(arr) => Value::Array(arr.iter().map(sort_json_keys).collect()),
other => other.clone(),
}
}
/// 写入 JSON 配置文件(键按字母排序,确保确定性输出)
/// 写入 JSON 配置文件
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppError> {
// 确保目录存在
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let value = serde_json::to_value(data).map_err(|e| AppError::JsonSerialize { source: e })?;
let sorted_value = sort_json_keys(&value);
let json = serde_json::to_string_pretty(&sorted_value)
.map_err(|e| AppError::JsonSerialize { source: e })?;
let json =
serde_json::to_string_pretty(data).map_err(|e| AppError::JsonSerialize { source: e })?;
atomic_write(path, json.as_bytes())
}
@@ -291,103 +271,6 @@ mod tests {
let override_dir = PathBuf::from("/");
assert!(derive_mcp_path_from_override(&override_dir).is_none());
}
#[test]
fn sort_json_keys_sorts_top_level_object() {
let input = serde_json::json!({
"z": 1,
"a": 2,
"m": 3,
});
let sorted = sort_json_keys(&input);
let serialized = serde_json::to_string(&sorted).unwrap();
assert_eq!(serialized, r#"{"a":2,"m":3,"z":1}"#);
}
#[test]
fn sort_json_keys_recurses_into_nested_objects() {
let input = serde_json::json!({
"outer_b": {"z": 1, "a": 2},
"outer_a": {"y": 3, "b": 4},
});
let sorted = sort_json_keys(&input);
let serialized = serde_json::to_string(&sorted).unwrap();
assert_eq!(
serialized,
r#"{"outer_a":{"b":4,"y":3},"outer_b":{"a":2,"z":1}}"#
);
}
#[test]
fn sort_json_keys_preserves_array_order() {
let input = serde_json::json!([3, 1, 2]);
let sorted = sort_json_keys(&input);
let serialized = serde_json::to_string(&sorted).unwrap();
assert_eq!(serialized, "[3,1,2]");
}
#[test]
fn sort_json_keys_sorts_objects_inside_arrays_but_keeps_array_order() {
let input = serde_json::json!([
{"z": 1, "a": 2},
{"y": 3, "b": 4},
]);
let sorted = sort_json_keys(&input);
let serialized = serde_json::to_string(&sorted).unwrap();
assert_eq!(serialized, r#"[{"a":2,"z":1},{"b":4,"y":3}]"#);
}
#[test]
fn sort_json_keys_passes_through_primitives() {
let cases = vec![
serde_json::json!("hello"),
serde_json::json!(42),
serde_json::json!(3.5),
serde_json::json!(true),
serde_json::json!(null),
];
for value in cases {
let sorted = sort_json_keys(&value);
assert_eq!(sorted, value);
}
}
#[test]
fn sort_json_keys_handles_empty_collections() {
let empty_obj = serde_json::json!({});
assert_eq!(
serde_json::to_string(&sort_json_keys(&empty_obj)).unwrap(),
"{}"
);
let empty_arr = serde_json::json!([]);
assert_eq!(
serde_json::to_string(&sort_json_keys(&empty_arr)).unwrap(),
"[]"
);
}
#[test]
fn sort_json_keys_produces_identical_output_for_different_insertion_orders() {
// 核心保证:同一逻辑配置无论键的插入顺序如何,写出的字节序列必须一致。
let mut a = Map::new();
a.insert("env".to_string(), serde_json::json!({"PATH": "/usr/bin"}));
a.insert("model".to_string(), serde_json::json!("claude-sonnet-4-5"));
a.insert("permissions".to_string(), serde_json::json!({"allow": []}));
let mut b = Map::new();
b.insert("permissions".to_string(), serde_json::json!({"allow": []}));
b.insert("model".to_string(), serde_json::json!("claude-sonnet-4-5"));
b.insert("env".to_string(), serde_json::json!({"PATH": "/usr/bin"}));
let sorted_a = sort_json_keys(&Value::Object(a));
let sorted_b = sort_json_keys(&Value::Object(b));
assert_eq!(
serde_json::to_string(&sorted_a).unwrap(),
serde_json::to_string(&sorted_b).unwrap(),
);
}
}
/// 复制文件
+2 -4
View File
@@ -791,10 +791,8 @@ mod tests {
std::fs::create_dir_all(&test_home).expect("create test home");
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
let settings = AppSettings {
backup_interval_hours: Some(0),
..AppSettings::default()
};
let mut settings = AppSettings::default();
settings.backup_interval_hours = Some(0);
update_settings(settings).expect("disable auto backup");
let db = Database::memory()?;
+1 -4
View File
@@ -14,8 +14,6 @@ pub struct FailoverQueueItem {
pub provider_id: String,
pub provider_name: String,
pub sort_index: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_notes: Option<String>,
}
impl Database {
@@ -25,7 +23,7 @@ impl Database {
let mut stmt = conn
.prepare(
"SELECT id, name, sort_index, notes
"SELECT id, name, sort_index
FROM providers
WHERE app_type = ?1 AND in_failover_queue = 1
ORDER BY COALESCE(sort_index, 999999), id ASC",
@@ -38,7 +36,6 @@ impl Database {
provider_id: row.get(0)?,
provider_name: row.get(1)?,
sort_index: row.get(2)?,
provider_notes: row.get(3)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?
+3 -6
View File
@@ -13,7 +13,7 @@ impl Database {
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare(
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
FROM mcp_servers
ORDER BY name ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
@@ -31,7 +31,6 @@ impl Database {
let enabled_codex: bool = row.get(8)?;
let enabled_gemini: bool = row.get(9)?;
let enabled_opencode: bool = row.get(10)?;
let enabled_hermes: bool = row.get(11)?;
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
@@ -47,7 +46,6 @@ impl Database {
codex: enabled_codex,
gemini: enabled_gemini,
opencode: enabled_opencode,
hermes: enabled_hermes,
},
description,
homepage,
@@ -72,8 +70,8 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO mcp_servers (
id, name, server_config, description, homepage, docs, tags,
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
params![
server.id,
server.name,
@@ -89,7 +87,6 @@ impl Database {
server.apps.codex,
server.apps.gemini,
server.apps.opencode,
server.apps.hermes,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
-16
View File
@@ -535,22 +535,6 @@ impl Database {
Ok(ids)
}
/// 判断指定 app 下是否已存在任意 provider。
///
/// 启动阶段的 live import 需要使用这个更严格的判断:
/// 只要该 app 已经有任何 provider(包括官方 seed),就不应再自动导入 `default`。
pub fn has_any_provider_for_app(&self, app_type: &str) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let exists: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM providers WHERE app_type = ?1)",
params![app_type],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(exists)
}
/// 判断指定 app 下是否存在非官方种子的供应商。
///
/// 比 `get_all_providers` 轻量得多:只读 id 列、无 endpoint 子查询、首条命中即返回。
+1 -29
View File
@@ -10,8 +10,6 @@
use crate::app_config::AppType;
pub(crate) const CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID: &str = "claude-desktop-official";
/// 单条官方供应商种子定义。
pub(crate) struct OfficialProviderSeed {
pub id: &'static str,
@@ -24,7 +22,7 @@ pub(crate) struct OfficialProviderSeed {
pub settings_config_json: &'static str,
}
/// Claude / Claude Desktop / Codex / Gemini 的官方预设。
/// Claude / Codex / Gemini 三个应用的官方预设。
///
/// id 固定,便于幂等检查;name 直接用英文原名(与前端预设一致),不做 i18n。
pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
@@ -38,16 +36,6 @@ pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
// 空 env 让用户走 Claude CLI 默认认证流程
settings_config_json: r#"{"env":{}}"#,
},
OfficialProviderSeed {
id: CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID,
app_type: AppType::ClaudeDesktop,
name: "Claude Desktop Official",
website_url: "https://claude.ai/download",
icon: "anthropic",
icon_color: "#D4915D",
// 空 env 只是占位;切换该 provider 时会恢复 Claude Desktop 1P 模式
settings_config_json: r#"{"env":{}}"#,
},
OfficialProviderSeed {
id: "codex-official",
app_type: AppType::Codex,
@@ -76,19 +64,3 @@ pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
pub(crate) fn is_official_seed_id(id: &str) -> bool {
OFFICIAL_SEEDS.iter().any(|seed| seed.id == id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn official_seeds_include_claude_desktop() {
let seed = OFFICIAL_SEEDS
.iter()
.find(|seed| seed.id == CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID)
.expect("claude desktop official seed");
assert_eq!(seed.app_type, AppType::ClaudeDesktop);
assert!(is_official_seed_id(CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID));
}
}
+12 -15
View File
@@ -23,7 +23,7 @@ impl Database {
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
enabled_hermes, installed_at, content_hash, updated_at
installed_at, content_hash, updated_at
FROM skills ORDER BY name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -44,11 +44,10 @@ impl Database {
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
hermes: row.get(12)?,
},
installed_at: row.get(13)?,
content_hash: row.get(14)?,
updated_at: row.get::<_, i64>(15).unwrap_or(0),
installed_at: row.get(12)?,
content_hash: row.get(13)?,
updated_at: row.get::<_, i64>(14).unwrap_or(0),
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -68,7 +67,7 @@ impl Database {
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
enabled_hermes, installed_at, content_hash, updated_at
installed_at, content_hash, updated_at
FROM skills WHERE id = ?1",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -88,11 +87,10 @@ impl Database {
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
hermes: row.get(12)?,
},
installed_at: row.get(13)?,
content_hash: row.get(14)?,
updated_at: row.get::<_, i64>(15).unwrap_or(0),
installed_at: row.get(12)?,
content_hash: row.get(13)?,
updated_at: row.get::<_, i64>(14).unwrap_or(0),
})
});
@@ -109,9 +107,9 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO skills
(id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
installed_at, content_hash, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
params![
skill.id,
skill.name,
@@ -125,7 +123,6 @@ impl Database {
skill.apps.codex,
skill.apps.gemini,
skill.apps.opencode,
skill.apps.hermes,
skill.installed_at,
skill.content_hash,
skill.updated_at,
@@ -157,8 +154,8 @@ impl Database {
let conn = lock_conn!(self.conn);
let affected = conn
.execute(
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4, enabled_hermes = ?5 WHERE id = ?6",
params![apps.claude, apps.codex, apps.gemini, apps.opencode, apps.hermes, id],
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4 WHERE id = ?5",
params![apps.claude, apps.codex, apps.gemini, apps.opencode, id],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
+17 -174
View File
@@ -4,62 +4,13 @@
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::services::usage_stats::effective_usage_log_filter;
use chrono::{Duration, Local, TimeZone};
/// Compute the rollup/prune cutoff aligned to a local-day boundary.
///
/// Anything strictly older than the returned timestamp will be aggregated into
/// `usage_daily_rollups` and deleted from `proxy_request_logs`. Aligning to the
/// next local midnight after `(now - retain_days)` guarantees that the youngest
/// rollup row always represents a *complete* local day. Without this alignment
/// the cutoff falls mid-day, leaving the day half-rolled-up and half-pruned —
/// which would silently under-count any range query that touches that day
/// after `compute_rollup_date_bounds` trims partial-coverage rollup days.
fn compute_local_midnight_cutoff(
now: chrono::DateTime<Local>,
retain_days: i64,
) -> Result<i64, AppError> {
let target_day = now
.checked_sub_signed(Duration::days(retain_days))
.ok_or_else(|| AppError::Database("rollup cutoff overflow".to_string()))?
.date_naive();
// Use the *next* day's midnight so anything before it has fully been bucketed.
let next_day = target_day
.succ_opt()
.ok_or_else(|| AppError::Database("rollup cutoff next-day overflow".to_string()))?;
let naive_midnight = next_day
.and_hms_opt(0, 0, 0)
.ok_or_else(|| AppError::Database("rollup cutoff midnight overflow".to_string()))?;
let local_dt = match Local.from_local_datetime(&naive_midnight) {
chrono::LocalResult::Single(dt) => dt,
chrono::LocalResult::Ambiguous(earliest, _) => earliest,
chrono::LocalResult::None => {
// DST gap: fall back to one hour later, which always exists.
let bumped = naive_midnight + Duration::hours(1);
match Local.from_local_datetime(&bumped) {
chrono::LocalResult::Single(dt) => dt,
chrono::LocalResult::Ambiguous(earliest, _) => earliest,
chrono::LocalResult::None => {
return Err(AppError::Database(
"rollup cutoff fell into DST gap".to_string(),
))
}
}
}
};
Ok(local_dt.timestamp())
}
impl Database {
/// Aggregate proxy_request_logs older than `retain_days` into usage_daily_rollups,
/// then delete the aggregated detail rows.
/// Returns the number of deleted detail rows.
pub fn rollup_and_prune(&self, retain_days: i64) -> Result<u64, AppError> {
let cutoff = compute_local_midnight_cutoff(Local::now(), retain_days)?;
let cutoff = chrono::Utc::now().timestamp() - retain_days * 86400;
let conn = lock_conn!(self.conn);
// Check if there are any rows to process
@@ -102,8 +53,7 @@ impl Database {
fn do_rollup_and_prune(conn: &rusqlite::Connection, cutoff: i64) -> Result<u64, AppError> {
// Aggregate old logs, merging with any pre-existing rollup rows via LEFT JOIN.
let effective_filter = effective_usage_log_filter("l");
let aggregation_sql = format!(
conn.execute(
"INSERT OR REPLACE INTO usage_daily_rollups
(date, app_type, provider_id, model,
request_count, success_count,
@@ -126,30 +76,27 @@ impl Database {
ELSE 0 END
FROM (
SELECT
date(l.created_at, 'unixepoch', 'localtime') as d,
l.app_type as a, l.provider_id as p, l.model as m,
date(created_at, 'unixepoch', 'localtime') as d,
app_type as a, provider_id as p, model as m,
COUNT(*) as new_req,
SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END) as new_succ,
COALESCE(SUM(l.input_tokens), 0) as new_in,
COALESCE(SUM(l.output_tokens), 0) as new_out,
COALESCE(SUM(l.cache_read_tokens), 0) as new_cr,
COALESCE(SUM(l.cache_creation_tokens), 0) as new_cc,
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as new_cost,
COALESCE(AVG(l.latency_ms), 0) as new_lat
FROM proxy_request_logs l
WHERE l.created_at < ?1 AND {effective_filter}
SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END) as new_succ,
COALESCE(SUM(input_tokens), 0) as new_in,
COALESCE(SUM(output_tokens), 0) as new_out,
COALESCE(SUM(cache_read_tokens), 0) as new_cr,
COALESCE(SUM(cache_creation_tokens), 0) as new_cc,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as new_cost,
COALESCE(AVG(latency_ms), 0) as new_lat
FROM proxy_request_logs WHERE created_at < ?1
GROUP BY d, a, p, m
) agg
LEFT JOIN usage_daily_rollups old
ON old.date = agg.d AND old.app_type = agg.a
AND old.provider_id = agg.p AND old.model = agg.m"
);
AND old.provider_id = agg.p AND old.model = agg.m",
[cutoff],
)
.map_err(|e| AppError::Database(format!("Rollup aggregation failed: {e}")))?;
conn.execute(&aggregation_sql, [cutoff])
.map_err(|e| AppError::Database(format!("Rollup aggregation failed: {e}")))?;
// INSERT uses the effective-log filter to exclude duplicate session rows.
// DELETE intentionally prunes all old details so those duplicates are discarded.
// Delete the aggregated detail rows
let deleted = conn
.execute(
"DELETE FROM proxy_request_logs WHERE created_at < ?1",
@@ -163,49 +110,8 @@ impl Database {
#[cfg(test)]
mod tests {
use super::compute_local_midnight_cutoff;
use crate::database::Database;
use crate::error::AppError;
use chrono::{Local, TimeZone};
fn local_dt(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
) -> chrono::DateTime<Local> {
match Local.with_ymd_and_hms(year, month, day, hour, minute, second) {
chrono::LocalResult::Single(dt) => dt,
chrono::LocalResult::Ambiguous(earliest, _) => earliest,
chrono::LocalResult::None => panic!("invalid local datetime in test fixture"),
}
}
#[test]
fn cutoff_is_aligned_to_local_midnight_after_target_day() -> Result<(), AppError> {
// now = 2026-04-16 14:32:17 local; retain_days = 30
// target day = 2026-03-17; cutoff should be 2026-03-18 00:00 local.
let now = local_dt(2026, 4, 16, 14, 32, 17);
let cutoff_ts = compute_local_midnight_cutoff(now, 30)?;
let cutoff_dt = Local.timestamp_opt(cutoff_ts, 0).single().unwrap();
let expected = local_dt(2026, 3, 18, 0, 0, 0);
assert_eq!(cutoff_dt, expected);
Ok(())
}
#[test]
fn cutoff_at_local_midnight_now_still_lands_on_midnight() -> Result<(), AppError> {
// If `now` is itself local midnight, the math should not introduce drift.
let now = local_dt(2026, 4, 16, 0, 0, 0);
let cutoff_ts = compute_local_midnight_cutoff(now, 7)?;
let cutoff_dt = Local.timestamp_opt(cutoff_ts, 0).single().unwrap();
// (2026-04-16 - 7d) = 2026-04-09; cutoff = 2026-04-10 00:00 local.
let expected = local_dt(2026, 4, 10, 0, 0, 0);
assert_eq!(cutoff_dt, expected);
Ok(())
}
#[test]
fn test_rollup_and_prune() -> Result<(), AppError> {
@@ -259,69 +165,6 @@ mod tests {
Ok(())
}
#[test]
fn test_rollup_uses_effective_usage_logs() -> Result<(), AppError> {
let db = Database::memory()?;
let now = chrono::Utc::now().timestamp();
let old_ts = now - 40 * 86400;
{
let conn = crate::database::lock_conn!(db.conn);
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,
total_cost_usd, latency_ms, status_code, created_at, data_source
) VALUES (?1, 'openai', 'codex', 'gpt-5.4', 'gpt-5.4', 100, 20, 10, 0, '0.10', 100, 200, ?2, 'proxy')",
rusqlite::params!["codex-proxy-old", old_ts],
)?;
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,
total_cost_usd, latency_ms, status_code, created_at, data_source
) VALUES (?1, '_codex_session', 'codex', 'gpt-5.4', 'gpt-5.4', 100, 20, 10, 0, '0.10', 0, 200, ?2, 'codex_session')",
rusqlite::params!["codex-session-old-dup", old_ts + 60],
)?;
}
let deleted = db.rollup_and_prune(30)?;
assert_eq!(deleted, 2);
let conn = crate::database::lock_conn!(db.conn);
let mut stmt = conn.prepare(
"SELECT provider_id, request_count, input_tokens, output_tokens, cache_read_tokens
FROM usage_daily_rollups WHERE app_type = 'codex'",
)?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, i64>(3)?,
row.get::<_, i64>(4)?,
))
})?
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(rows.len(), 1);
let (provider_id, request_count, input_tokens, output_tokens, cache_read_tokens) = &rows[0];
assert_eq!(provider_id, "openai");
assert_eq!(*request_count, 1);
assert_eq!(*input_tokens, 100);
assert_eq!(*output_tokens, 20);
assert_eq!(*cache_read_tokens, 10);
let remaining: i64 =
conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
row.get(0)
})?;
assert_eq!(remaining, 0);
Ok(())
}
#[test]
fn test_rollup_noop_when_no_old_data() -> Result<(), AppError> {
let db = Database::memory()?;
+1 -2
View File
@@ -32,7 +32,6 @@ mod schema;
mod tests;
// DAO 类型导出供外部使用
pub(crate) use dao::providers_seed::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID;
pub use dao::FailoverQueueItem;
use crate::config::get_app_config_dir;
@@ -45,7 +44,7 @@ use std::sync::Mutex;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 10;
pub(crate) const SCHEMA_VERSION: i32 = 9;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+1 -115
View File
@@ -65,8 +65,7 @@ impl Database {
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
enabled_hermes BOOLEAN NOT NULL DEFAULT 0
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0
)",
[],
)
@@ -94,7 +93,6 @@ impl Database {
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
enabled_hermes BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0,
content_hash TEXT,
updated_at INTEGER NOT NULL DEFAULT 0
@@ -214,7 +212,6 @@ impl Database {
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Self::create_request_logs_usage_indexes_if_supported(conn)?;
// 11. Model Pricing 表
conn.execute(
@@ -426,11 +423,6 @@ impl Database {
Self::migrate_v8_to_v9(conn)?;
Self::set_user_version(conn, 9)?;
}
9 => {
log::info!("迁移数据库从 v9 到 v10(添加 Hermes Agent 支持)");
Self::migrate_v9_to_v10(conn)?;
Self::set_user_version(conn, 10)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -1108,7 +1100,6 @@ impl Database {
"data_source",
"TEXT NOT NULL DEFAULT 'proxy'",
)?;
Self::create_request_logs_usage_indexes_if_supported(conn)?;
}
// 2. 创建会话日志同步状态表
@@ -1177,43 +1168,11 @@ impl Database {
Ok(())
}
/// v9 -> v10 迁移:添加 Hermes Agent 支持
fn migrate_v9_to_v10(conn: &Connection) -> Result<(), AppError> {
Self::add_column_if_missing(
conn,
"mcp_servers",
"enabled_hermes",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
// skills table may not exist in databases migrated from very old versions
if Self::table_exists(conn, "skills")? {
Self::add_column_if_missing(
conn,
"skills",
"enabled_hermes",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
}
log::info!("v9 -> v10 迁移完成:已添加 Hermes Agent 支持");
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
let pricing_data = [
// Claude 4.7 系列
(
"claude-opus-4-7",
"Claude Opus 4.7",
"5",
"25",
"0.50",
"6.25",
),
// Claude 4.6 系列
(
"claude-opus-4-6-20260206",
@@ -1298,13 +1257,6 @@ impl Database {
"0.30",
"3.75",
),
// GPT-5.5 系列
("gpt-5.5", "GPT-5.5", "5", "30", "0.50", "0"),
("gpt-5.5-low", "GPT-5.5", "5", "30", "0.50", "0"),
("gpt-5.5-medium", "GPT-5.5", "5", "30", "0.50", "0"),
("gpt-5.5-high", "GPT-5.5", "5", "30", "0.50", "0"),
("gpt-5.5-xhigh", "GPT-5.5", "5", "30", "0.50", "0"),
("gpt-5.5-minimal", "GPT-5.5", "5", "30", "0.50", "0"),
// GPT-5.4 系列
("gpt-5.4", "GPT-5.4", "2.50", "15", "0.25", "0"),
("gpt-5.4-mini", "GPT-5.4 Mini", "0.75", "4.50", "0.075", "0"),
@@ -1629,23 +1581,6 @@ impl Database {
"0.14",
"0",
),
// DeepSeek V4 系列(官方 CNY 按 1 USD ≈ 7.14 折算)
(
"deepseek-v4-flash",
"DeepSeek V4 Flash",
"0.14",
"0.28",
"0.028",
"0",
),
(
"deepseek-v4-pro",
"DeepSeek V4 Pro",
"1.68",
"3.36",
"0.14",
"0",
),
// Kimi (月之暗面)
(
"kimi-k2-thinking",
@@ -1665,7 +1600,6 @@ impl Database {
"0",
),
("kimi-k2.5", "Kimi K2.5", "0.60", "2.50", "0.10", "0"),
("kimi-k2.6", "Kimi K2.6", "0.95", "4.00", "0.16", "0"),
// MiniMax 系列
("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"),
(
@@ -1917,54 +1851,6 @@ impl Database {
Ok(())
}
fn create_request_logs_usage_indexes_if_supported(conn: &Connection) -> Result<(), AppError> {
if !Self::table_exists(conn, "proxy_request_logs")? {
return Ok(());
}
let has_app_type = Self::has_column(conn, "proxy_request_logs", "app_type")?;
let has_created_at = Self::has_column(conn, "proxy_request_logs", "created_at")?;
if has_app_type && has_created_at {
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_app_created_at
ON proxy_request_logs(app_type, created_at DESC)",
[],
)
.map_err(|e| AppError::Database(format!("创建使用量应用时间索引失败: {e}")))?;
}
let required_columns = [
"app_type",
"data_source",
"input_tokens",
"output_tokens",
"cache_read_tokens",
"created_at",
"cache_creation_tokens",
];
for column in required_columns {
if !Self::has_column(conn, "proxy_request_logs", column)? {
return Ok(());
}
}
conn.execute("DROP INDEX IF EXISTS idx_request_logs_dedup_lookup", [])
.map_err(|e| AppError::Database(format!("删除旧使用量去重索引失败: {e}")))?;
// 查询层为了兼容历史 NULL data_source 行,会使用
// COALESCE(data_source, 'proxy')。普通 data_source 索引无法匹配该表达式,
// 会让跨源去重子查询退化成大量扫描;表达式索引让 SQLite 能按同一表达式查找。
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_request_logs_dedup_lookup_expr
ON proxy_request_logs(app_type, COALESCE(data_source, 'proxy'), input_tokens,
output_tokens, cache_read_tokens, created_at,
cache_creation_tokens)",
[],
)
.map_err(|e| AppError::Database(format!("创建使用量去重表达式索引失败: {e}")))?;
Ok(())
}
fn validate_identifier(s: &str, kind: &str) -> Result<(), AppError> {
if s.is_empty() {
return Err(AppError::Database(format!("{kind} 不能为空")));
-2
View File
@@ -167,7 +167,6 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
codex: false,
gemini: false,
opencode: false,
hermes: false,
};
for app in apps_str.split(',') {
@@ -180,7 +179,6 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
// OpenClaw doesn't support MCP, ignore silently
log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter");
}
"hermes" => apps.hermes = true,
other => {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': {other}"
+1 -1
View File
@@ -31,7 +31,7 @@ pub use skill::import_skill_from_deeplink;
///
/// Represents a parsed ccswitch:// URL ready for processing.
/// This struct contains all possible fields for all resource types.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeepLinkImportRequest {
/// Protocol version (e.g., "v1")
+6 -6
View File
@@ -81,10 +81,10 @@ fn parse_provider_deeplink(
// Validate app type
if !matches!(
app.as_str(),
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
"claude" | "codex" | "gemini" | "opencode" | "openclaw"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{app}'"
)));
}
@@ -190,10 +190,10 @@ fn parse_prompt_deeplink(
// Validate app type
if !matches!(
app.as_str(),
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
"claude" | "codex" | "gemini" | "opencode" | "openclaw"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{app}'"
)));
}
@@ -262,10 +262,10 @@ fn parse_mcp_deeplink(
let trimmed = app.trim();
if !matches!(
trimmed,
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
"claude" | "codex" | "gemini" | "opencode" | "openclaw"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'"
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{trimmed}'"
)));
}
}
+10 -140
View File
@@ -5,7 +5,7 @@
use super::utils::{decode_base64_param, infer_homepage_from_endpoint};
use super::DeepLinkImportRequest;
use crate::error::AppError;
use crate::provider::{ClaudeDesktopMode, Provider, ProviderMeta, UsageScript};
use crate::provider::{Provider, ProviderMeta, UsageScript};
use crate::services::ProviderService;
use crate::store::AppState;
use crate::AppType;
@@ -142,20 +142,15 @@ pub(crate) fn build_provider_from_request(
request: &DeepLinkImportRequest,
) -> Result<Provider, AppError> {
let settings_config = match app_type {
AppType::Claude | AppType::ClaudeDesktop => build_claude_settings(request),
AppType::Claude => build_claude_settings(request),
AppType::Codex => build_codex_settings(request),
AppType::Gemini => build_gemini_settings(request),
AppType::OpenCode => build_opencode_settings(request),
AppType::OpenClaw => build_additive_app_settings(request),
AppType::Hermes => build_hermes_settings(request),
AppType::OpenClaw => build_openclaw_settings(request),
};
// Build usage script configuration if provided
let mut meta = build_provider_meta(request)?;
if matches!(app_type, AppType::ClaudeDesktop) {
meta.get_or_insert_with(ProviderMeta::default)
.claude_desktop_mode = Some(ClaudeDesktopMode::Direct);
}
let meta = build_provider_meta(request)?;
let provider = Provider {
id: String::new(), // Will be generated by caller
@@ -398,11 +393,11 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value
})
}
/// Build settings for OpenClaw (camelCase live config).
/// Format: { baseUrl, apiKey, api, models }
fn build_additive_app_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let endpoint = get_primary_endpoint(request);
// Build OpenClaw provider config
// Format: { baseUrl, apiKey, api, models }
let mut config = serde_json::Map::new();
if !endpoint.is_empty() {
@@ -413,49 +408,10 @@ fn build_additive_app_settings(request: &DeepLinkImportRequest) -> serde_json::V
config.insert("apiKey".to_string(), json!(api_key));
}
// Default to OpenAI-compatible API
config.insert("api".to_string(), json!("openai-completions"));
if let Some(model) = &request.model {
config.insert(
"models".to_string(),
json!([{ "id": model, "name": model }]),
);
}
json!(config)
}
/// Build Hermes provider settings (snake_case YAML-native fields).
///
/// Hermes' `custom_providers:` entries use `base_url` / `api_key` / `api_mode`
/// (see `_VALID_CUSTOM_PROVIDER_FIELDS` in upstream `hermes_cli/config.py`).
/// Emitting camelCase here — as the OpenClaw path does — would poison the
/// YAML with unknown root fields the Hermes runtime ignores.
///
/// `api_mode` is always written explicitly. Deeplinks have no field to carry
/// it, so we default to `chat_completions` (the most widely compatible
/// protocol) and let the user adjust via the UI after import. We never rely
/// on Hermes' built-in URL heuristics, which only recognize a handful of
/// official endpoints.
fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let endpoint = get_primary_endpoint(request);
let mut config = serde_json::Map::new();
if let Some(name) = request.name.as_deref().filter(|s| !s.is_empty()) {
config.insert("name".to_string(), json!(name));
}
if !endpoint.is_empty() {
config.insert("base_url".to_string(), json!(endpoint));
}
if let Some(api_key) = &request.api_key {
config.insert("api_key".to_string(), json!(api_key));
}
config.insert("api_mode".to_string(), json!("chat_completions"));
// Build models array
if let Some(model) = &request.model {
config.insert(
"models".to_string(),
@@ -528,7 +484,7 @@ pub fn parse_and_merge_config(
"codex" => merge_codex_config(&mut merged, &config_value)?,
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
// Additive mode apps use JSON config directly; pass through as-is
"openclaw" | "opencode" | "hermes" => {
"openclaw" | "opencode" => {
merge_additive_config(&mut merged, &config_value)?;
}
"" => {
@@ -755,89 +711,3 @@ fn extract_codex_base_url(toml_value: &toml::Value) -> Option<String> {
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn hermes_request() -> DeepLinkImportRequest {
DeepLinkImportRequest {
resource: "provider".to_string(),
app: Some("hermes".to_string()),
name: Some("MyHermes".to_string()),
endpoint: Some("https://api.example.com/v1".to_string()),
api_key: Some("sk-test".to_string()),
model: Some("anthropic/claude-opus-4-7".to_string()),
..Default::default()
}
}
#[test]
fn build_hermes_settings_emits_snake_case() {
let settings = build_hermes_settings(&hermes_request());
let obj = settings.as_object().expect("settings must be object");
assert_eq!(obj.get("name").unwrap(), "MyHermes");
assert_eq!(obj.get("base_url").unwrap(), "https://api.example.com/v1");
assert_eq!(obj.get("api_key").unwrap(), "sk-test");
// camelCase and legacy fields must NOT be present
assert!(obj.get("baseUrl").is_none(), "no camelCase baseUrl");
assert!(obj.get("apiKey").is_none(), "no camelCase apiKey");
assert!(obj.get("api").is_none(), "no legacy 'api' field");
// models array with the deeplink model id
let models = obj.get("models").unwrap().as_array().unwrap();
assert_eq!(models.len(), 1);
assert_eq!(models[0]["id"], "anthropic/claude-opus-4-7");
}
#[test]
fn build_hermes_settings_writes_default_api_mode() {
let settings = build_hermes_settings(&hermes_request());
assert_eq!(
settings.as_object().unwrap().get("api_mode").unwrap(),
"chat_completions",
"api_mode must be written explicitly so Hermes never falls back to URL auto-detection"
);
}
#[test]
fn build_hermes_settings_skips_missing_optional_fields() {
let request = DeepLinkImportRequest {
resource: "provider".to_string(),
app: Some("hermes".to_string()),
name: Some("Minimal".to_string()),
endpoint: None,
api_key: None,
model: None,
..Default::default()
};
let settings = build_hermes_settings(&request);
let obj = settings.as_object().unwrap();
assert_eq!(obj.get("name").unwrap(), "Minimal");
assert!(obj.get("base_url").is_none());
assert!(obj.get("api_key").is_none());
assert!(obj.get("models").is_none());
assert_eq!(obj.get("api_mode").unwrap(), "chat_completions");
}
#[test]
fn openclaw_still_uses_camel_case() {
// OpenClaw's live config natively uses camelCase; guard against a
// refactor accidentally flipping it to snake_case.
let request = DeepLinkImportRequest {
resource: "provider".to_string(),
app: Some("openclaw".to_string()),
name: Some("c".to_string()),
endpoint: Some("https://api.example.com".to_string()),
api_key: Some("k".to_string()),
..Default::default()
};
let settings = build_additive_app_settings(&request);
let obj = settings.as_object().unwrap();
assert!(obj.contains_key("baseUrl"));
assert!(obj.contains_key("apiKey"));
}
}
File diff suppressed because it is too large Load Diff
+47 -123
View File
@@ -1,7 +1,6 @@
mod app_config;
mod app_store;
mod auto_launch;
mod claude_desktop_config;
mod claude_mcp;
mod claude_plugin;
mod codex_config;
@@ -12,7 +11,6 @@ mod deeplink;
mod error;
mod gemini_config;
mod gemini_mcp;
pub mod hermes_config;
mod init_status;
mod lightweight;
#[cfg(target_os = "linux")]
@@ -65,7 +63,6 @@ use tauri::image::Image;
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
use tauri::RunEvent;
use tauri::{Emitter, Manager};
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
fn redact_url_for_log(url_str: &str) -> String {
match url::Url::parse(url_str) {
@@ -171,7 +168,7 @@ async fn update_tray_menu(
) -> Result<bool, String> {
match tray::create_tray_menu(&app, state.inner()) {
Ok(new_menu) => {
if let Some(tray) = app.tray_by_id(tray::TRAY_ID) {
if let Some(tray) = app.tray_by_id("main") {
tray.set_menu(Some(new_menu))
.map_err(|e| format!("更新托盘菜单失败: {e}"))?;
return Ok(true);
@@ -266,7 +263,6 @@ pub fn run() {
tray::apply_tray_policy(window.app_handle(), false);
}
} else {
api.prevent_close();
window.app_handle().exit(0);
}
}
@@ -275,14 +271,7 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(
tauri_plugin_window_state::Builder::default()
.with_state_flags(window_state_flags())
.build(),
)
.setup(|app| {
let _ = rustls::crypto::ring::default_provider().install_default();
// 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等)
app_store::refresh_app_config_dir_override(app.handle());
panic_hook::init_app_config_dir(crate::config::get_app_config_dir());
@@ -495,19 +484,6 @@ pub fn run() {
for app_type in
crate::app_config::AppType::all().filter(|t| !t.is_additive_mode())
{
if !crate::services::provider::should_import_default_config_on_startup(
&app_state,
&app_type,
)
.unwrap_or(false)
{
log::debug!(
"○ {} already has providers; live import skipped",
app_type.as_str()
);
continue;
}
match crate::services::provider::import_default_config(
&app_state,
app_type.clone(),
@@ -564,13 +540,6 @@ pub fn run() {
Ok(_) => log::debug!("○ No new OpenClaw providers to import"),
Err(e) => log::warn!("✗ Failed to import OpenClaw providers: {e}"),
}
match crate::services::provider::import_hermes_providers_from_live(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} Hermes provider(s) from live config");
}
Ok(_) => log::debug!("○ No new Hermes providers to import"),
Err(e) => log::warn!("✗ Failed to import Hermes providers: {e}"),
}
// 2. OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入)
{
@@ -658,14 +627,6 @@ pub fn run() {
Ok(_) => log::debug!("○ No OpenCode MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import OpenCode MCP: {e}"),
}
match crate::services::mcp::McpService::import_from_hermes(&app_state) {
Ok(count) if count > 0 => {
log::info!("✓ Imported {count} MCP server(s) from Hermes");
}
Ok(_) => log::debug!("○ No Hermes MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import Hermes MCP: {e}"),
}
}
// 4. 导入提示词文件(表空时触发)
@@ -678,7 +639,6 @@ pub fn run() {
crate::app_config::AppType::Gemini,
crate::app_config::AppType::OpenCode,
crate::app_config::AppType::OpenClaw,
crate::app_config::AppType::Hermes,
] {
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
@@ -768,18 +728,10 @@ pub fn run() {
let menu = tray::create_tray_menu(app.handle(), &app_state)?;
// 构建托盘
let mut tray_builder = TrayIconBuilder::with_id(tray::TRAY_ID)
.tooltip("CC Switch") // 鼠标悬停提示
.on_tray_icon_event(|tray, event| match event {
// 鼠标悬停/点击到托盘图标时,后台异步刷新用量缓存,
// 让用户下一次(或快速打开菜单的那一刻)看到较新的数字。
// refresh_all_usage_in_tray 内部有 10 秒防抖。
TrayIconEvent::Enter { .. } | TrayIconEvent::Click { .. } => {
let app = tray.app_handle().clone();
tauri::async_runtime::spawn(async move {
crate::tray::refresh_all_usage_in_tray(&app).await;
});
}
let mut tray_builder = TrayIconBuilder::with_id("main")
.on_tray_icon_event(|_tray, event| match event {
// 左键点击已通过 show_menu_on_left_click(true) 打开菜单,这里不再额外处理
TrayIconEvent::Click { .. } => {}
_ => log::debug!("unhandled event {event:?}"),
})
.menu(&menu)
@@ -946,31 +898,28 @@ pub fn run() {
tauri::async_runtime::spawn(async move {
const SESSION_SYNC_INTERVAL_SECS: u64 = 60;
fn run_step<T>(name: &str, result: Result<T, crate::error::AppError>) {
if let Err(e) = result {
log::warn!("{name} failed: {e}");
}
}
let db = &db_for_session_sync;
// 首次同步
run_step(
"Usage cost startup backfill",
db.backfill_missing_usage_costs(),
);
run_step(
"Session usage initial sync",
crate::services::session_usage::sync_claude_session_logs(db),
);
run_step(
"Codex usage initial sync",
crate::services::session_usage_codex::sync_codex_usage(db),
);
run_step(
"Gemini usage initial sync",
crate::services::session_usage_gemini::sync_gemini_usage(db),
);
if let Err(e) =
crate::services::session_usage::sync_claude_session_logs(
&db_for_session_sync,
)
{
log::warn!("Session usage initial sync failed: {e}");
}
if let Err(e) =
crate::services::session_usage_codex::sync_codex_usage(
&db_for_session_sync,
)
{
log::warn!("Codex usage initial sync failed: {e}");
}
if let Err(e) =
crate::services::session_usage_gemini::sync_gemini_usage(
&db_for_session_sync,
)
{
log::warn!("Gemini usage initial sync failed: {e}");
}
// 定期同步
let mut interval = tokio::time::interval(std::time::Duration::from_secs(
@@ -979,18 +928,27 @@ pub fn run() {
interval.tick().await; // skip immediate first tick
loop {
interval.tick().await;
run_step(
"Session usage periodic sync",
crate::services::session_usage::sync_claude_session_logs(db),
);
run_step(
"Codex usage periodic sync",
crate::services::session_usage_codex::sync_codex_usage(db),
);
run_step(
"Gemini usage periodic sync",
crate::services::session_usage_gemini::sync_gemini_usage(db),
);
if let Err(e) =
crate::services::session_usage::sync_claude_session_logs(
&db_for_session_sync,
)
{
log::warn!("Session usage periodic sync failed: {e}");
}
if let Err(e) =
crate::services::session_usage_codex::sync_codex_usage(
&db_for_session_sync,
)
{
log::warn!("Codex usage periodic sync failed: {e}");
}
if let Err(e) =
crate::services::session_usage_gemini::sync_gemini_usage(
&db_for_session_sync,
)
{
log::warn!("Gemini usage periodic sync failed: {e}");
}
}
});
});
@@ -1052,9 +1010,6 @@ pub fn run() {
commands::remove_provider_from_live_config,
commands::switch_provider,
commands::import_default_config,
commands::get_claude_desktop_status,
commands::get_claude_desktop_default_routes,
commands::import_claude_desktop_providers_from_claude,
commands::get_claude_config_status,
commands::get_config_status,
commands::get_claude_code_config_path,
@@ -1196,7 +1151,6 @@ pub fn run() {
commands::get_auto_launch_status,
// Proxy server management
commands::start_proxy_server,
commands::stop_proxy_server,
commands::stop_proxy_with_restore,
commands::get_proxy_takeover_status,
commands::set_proxy_takeover_for_app,
@@ -1280,17 +1234,6 @@ pub fn run() {
commands::set_openclaw_env,
commands::get_openclaw_tools,
commands::set_openclaw_tools,
// Hermes specific
commands::import_hermes_providers_from_live,
commands::get_hermes_live_provider_ids,
commands::get_hermes_live_provider,
commands::get_hermes_model_config,
commands::open_hermes_web_ui,
commands::launch_hermes_dashboard,
commands::get_hermes_memory,
commands::set_hermes_memory,
commands::get_hermes_memory_limits,
commands::set_hermes_memory_enabled,
// Global upstream proxy
commands::get_global_proxy_url,
commands::set_global_proxy_url,
@@ -1368,7 +1311,6 @@ pub fn run() {
let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
save_window_state_before_exit(&app_handle);
cleanup_before_exit(&app_handle).await;
log::info!("清理完成,退出应用");
@@ -1775,21 +1717,3 @@ fn show_database_init_error_dialog(
))
.blocking_show()
}
// ============================================================
// 在应用主动退出前显式持久化窗口状态
// ============================================================
fn window_state_flags() -> StateFlags {
StateFlags::POSITION | StateFlags::SIZE | StateFlags::MAXIMIZED
}
/// 当前应用的退出路径会拦截 `ExitRequested` 并最终直接 `std::process::exit(0)`
/// 这里需要在真正结束进程前手动落盘,避免 window-state 插件的默认退出钩子被绕过。
pub fn save_window_state_before_exit(app_handle: &tauri::AppHandle) {
if let Err(err) = app_handle.save_window_state(window_state_flags()) {
log::error!("退出前保存窗口状态失败: {err}");
} else {
log::info!("已在退出前保存窗口状态");
}
}
+1 -3
View File
@@ -17,7 +17,6 @@ pub fn enter_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
}
if let Some(window) = app.get_webview_window("main") {
crate::save_window_state_before_exit(app);
window
.destroy()
.map_err(|e| format!("销毁主窗口失败: {e}"))?;
@@ -65,12 +64,11 @@ pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
WebviewWindowBuilder::from_config(app, window_config)
.map_err(|e| format!("加载主窗口配置失败: {e}"))?
.visible(true)
.build()
.map_err(|e| format!("创建主窗口失败: {e}"))?;
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
#[cfg(target_os = "linux")]
{
-1
View File
@@ -92,7 +92,6 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
codex: false,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
-1
View File
@@ -236,7 +236,6 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
codex: true,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
-1
View File
@@ -88,7 +88,6 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
codex: false,
gemini: true,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
-574
View File
@@ -1,574 +0,0 @@
//! Hermes MCP sync and import module
//!
//! Handles conversion between CC Switch unified MCP format and Hermes config.yaml format.
//!
//! ## Format mapping
//!
//! | CC Switch unified (JSON) | Hermes config.yaml (YAML) |
//! |-------------------------------------------------|---------------------------------|
//! | `{"type":"stdio","command":"npx","args":[...],"env":{}}` | `command: npx`, `args: [...]`, `env: {}` |
//! | `{"type":"sse"/"http","url":"...","headers":{}}` | `url: "..."`, `headers: {}` |
//!
//! Key differences from Claude format:
//! - Hermes has NO explicit `type` field -- it infers stdio (has `command`) vs HTTP (has `url`)
//! - Hermes has extra fields: `enabled`, `timeout`, `connect_timeout`, `tools`, `sampling`
//! - These Hermes-specific fields are preserved on merge-on-write and stripped on import
use serde_json::{json, Value};
use std::collections::HashMap;
use crate::app_config::{McpApps, McpServer, MultiAppConfig};
use crate::error::AppError;
use crate::hermes_config;
use super::validation::validate_server_spec;
/// Hermes-specific fields preserved on merge-on-write, stripped on import.
/// Update this list when Hermes adds new per-server config fields.
///
/// `auth` ("oauth" / absent) is an OAuth-type declaration read by Hermes —
/// CC Switch has no OAuth UI, but losing the field on round-trip downgrades
/// the server to unauthenticated calls.
const HERMES_EXTRA_FIELDS: &[&str] = &[
"enabled",
"timeout",
"connect_timeout",
"tools",
"sampling",
"roots",
"auth",
];
// ============================================================================
// Helper Functions
// ============================================================================
/// Check if Hermes MCP sync should proceed
fn should_sync_hermes_mcp() -> bool {
hermes_config::get_hermes_dir().exists()
}
// ============================================================================
// Format Conversion: CC Switch -> Hermes
// ============================================================================
/// Convert CC Switch unified format to Hermes format
///
/// Conversion rules:
/// - `stdio`: output `command`, `args`, `env` (strip `type` field)
/// - `sse`/`http`: output `url`, `headers` (strip `type` field)
/// - Always add `enabled: true`
fn convert_to_hermes_format(spec: &Value) -> Result<Value, AppError> {
let obj = spec
.as_object()
.ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?;
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
let mut result = serde_json::Map::new();
match typ {
"stdio" => {
if let Some(command) = obj.get("command") {
result.insert("command".into(), command.clone());
}
if let Some(args) = obj.get("args") {
if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) {
result.insert("args".into(), args.clone());
}
}
if let Some(env) = obj.get("env") {
if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
result.insert("env".into(), env.clone());
}
}
}
"sse" | "http" => {
if let Some(url) = obj.get("url") {
result.insert("url".into(), url.clone());
}
if let Some(headers) = obj.get("headers") {
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
{
result.insert("headers".into(), headers.clone());
}
}
}
_ => {
return Err(AppError::McpValidation(format!("Unknown MCP type: {typ}")));
}
}
result.insert("enabled".into(), json!(true));
Ok(Value::Object(result))
}
// ============================================================================
// Format Conversion: Hermes -> CC Switch
// ============================================================================
/// Convert Hermes format to CC Switch unified format
///
/// Conversion rules:
/// - If `command` exists: set `type: "stdio"`, extract `command`, `args`, `env`
/// - If `url` exists: set `type: "sse"`, extract `url`, `headers`
/// - Strip Hermes-specific fields: `enabled`, `timeout`, `connect_timeout`, `tools`, `sampling`
fn convert_from_hermes_format(id: &str, spec: &Value) -> Result<Value, AppError> {
let obj = spec
.as_object()
.ok_or_else(|| AppError::McpValidation("Hermes MCP spec must be a JSON object".into()))?;
let mut result = serde_json::Map::new();
if obj.contains_key("command") {
// stdio type
result.insert("type".into(), json!("stdio"));
if let Some(command) = obj.get("command") {
result.insert("command".into(), command.clone());
}
if let Some(args) = obj.get("args") {
if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) {
result.insert("args".into(), args.clone());
}
}
if let Some(env) = obj.get("env") {
if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
result.insert("env".into(), env.clone());
}
}
} else if obj.contains_key("url") {
// HTTP/SSE type
result.insert("type".into(), json!("sse"));
if let Some(url) = obj.get("url") {
result.insert("url".into(), url.clone());
}
if let Some(headers) = obj.get("headers") {
if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) {
result.insert("headers".into(), headers.clone());
}
}
} else {
return Err(AppError::McpValidation(format!(
"Hermes MCP server '{id}' has neither 'command' nor 'url' field"
)));
}
// Note: Hermes-specific fields (enabled, timeout, connect_timeout, tools, sampling)
// are intentionally NOT copied -- they are stripped on import.
Ok(Value::Object(result))
}
// ============================================================================
// Public API: Sync Functions
// ============================================================================
/// Sync a single MCP server to Hermes live config (merge-on-write)
///
/// Strategy:
/// 1. Read existing mcp_servers from config.yaml
/// 2. If server already exists, merge: keep Hermes-specific fields, overwrite core fields
/// 3. Set `enabled: true`
/// 4. Write back
pub fn sync_single_server_to_hermes(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
if !should_sync_hermes_mcp() {
return Ok(());
}
let hermes_spec = convert_to_hermes_format(server_spec)?;
let id_owned = id.to_string();
hermes_config::update_mcp_servers_yaml(|servers| {
let id_yaml = serde_yaml::Value::String(id_owned.clone());
let merged_json = if let Some(existing_yaml) = servers.get(&id_yaml) {
let existing_json = hermes_config::yaml_to_json(existing_yaml)?;
merge_hermes_spec(&existing_json, &hermes_spec)
} else {
hermes_spec.clone()
};
let merged_yaml_value = hermes_config::json_to_yaml(&merged_json)?;
servers.insert(id_yaml, merged_yaml_value);
Ok(())
})
}
/// Merge new spec into existing Hermes spec, preserving Hermes-specific fields.
///
/// Core fields (command, args, env, url, headers) come from `new_spec`.
/// Hermes-specific fields (enabled, tools, sampling, etc.) are kept from
/// `existing` — this prevents CC Switch from overwriting user customizations.
fn merge_hermes_spec(existing: &Value, new_spec: &Value) -> Value {
let mut result = serde_json::Map::new();
// Copy Hermes-specific fields from existing config
if let Some(existing_obj) = existing.as_object() {
for &field in HERMES_EXTRA_FIELDS {
if let Some(val) = existing_obj.get(field) {
result.insert(field.to_string(), val.clone());
}
}
}
// Overwrite with core fields from new spec; for Hermes-specific fields,
// only apply from new_spec if existing didn't already have them
if let Some(new_obj) = new_spec.as_object() {
for (key, val) in new_obj {
if HERMES_EXTRA_FIELDS.contains(&key.as_str()) && result.contains_key(key) {
continue; // Existing Hermes-specific field takes precedence
}
result.insert(key.clone(), val.clone());
}
}
Value::Object(result)
}
/// Remove a single MCP server from Hermes live config
pub fn remove_server_from_hermes(id: &str) -> Result<(), AppError> {
if !should_sync_hermes_mcp() {
return Ok(());
}
let id_owned = id.to_string();
hermes_config::update_mcp_servers_yaml(|servers| {
servers.remove(serde_yaml::Value::String(id_owned.clone()));
Ok(())
})
}
/// Import MCP servers from Hermes config to unified structure
///
/// Existing servers will have Hermes app enabled without overwriting other fields.
pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result<usize, AppError> {
let yaml_map = hermes_config::get_mcp_servers_yaml()?;
if yaml_map.is_empty() {
return Ok(0);
}
// Ensure servers map exists
let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
let mut changed = 0;
let mut errors = Vec::new();
for (key, spec_yaml) in &yaml_map {
let id = match key.as_str() {
Some(s) => s.to_string(),
None => {
log::warn!("Skip Hermes MCP server with non-string key");
continue;
}
};
// Convert YAML value to JSON
let spec_json = match hermes_config::yaml_to_json(spec_yaml) {
Ok(j) => j,
Err(e) => {
log::warn!("Skip Hermes MCP server '{id}': failed to convert YAML to JSON: {e}");
errors.push(format!("{id}: {e}"));
continue;
}
};
// Convert from Hermes format to unified format
let unified_spec = match convert_from_hermes_format(&id, &spec_json) {
Ok(s) => s,
Err(e) => {
log::warn!("Skip invalid Hermes MCP server '{id}': {e}");
errors.push(format!("{id}: {e}"));
continue;
}
};
// Validate the converted spec
if let Err(e) = validate_server_spec(&unified_spec) {
log::warn!("Skip invalid MCP server '{id}' after conversion: {e}");
errors.push(format!("{id}: {e}"));
continue;
}
if let Some(existing) = servers.get_mut(&id) {
// Existing server: just enable Hermes app
if !existing.apps.hermes {
existing.apps.hermes = true;
changed += 1;
log::info!("MCP server '{id}' enabled for Hermes");
}
} else {
// New server: default to only Hermes enabled
servers.insert(
id.clone(),
McpServer {
id: id.clone(),
name: id.clone(),
server: unified_spec,
apps: McpApps {
claude: false,
codex: false,
gemini: false,
opencode: false,
hermes: true,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
changed += 1;
log::info!("Imported new MCP server '{id}' from Hermes");
}
}
if !errors.is_empty() {
log::warn!(
"Import completed with {} failures: {:?}",
errors.len(),
errors
);
}
Ok(changed)
}
#[cfg(test)]
mod tests {
use super::*;
// ========================================================================
// convert_to_hermes_format tests
// ========================================================================
#[test]
fn test_convert_stdio_to_hermes() {
let spec = json!({
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
"env": { "HOME": "/Users/test" }
});
let result = convert_to_hermes_format(&spec).unwrap();
// No type field in Hermes format
assert!(result.get("type").is_none());
assert_eq!(result["command"], "npx");
assert_eq!(result["args"][0], "-y");
assert_eq!(result["args"][1], "@modelcontextprotocol/server-filesystem");
assert_eq!(result["env"]["HOME"], "/Users/test");
assert_eq!(result["enabled"], true);
}
#[test]
fn test_convert_http_to_hermes() {
let spec = json!({
"type": "sse",
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer xxx" }
});
let result = convert_to_hermes_format(&spec).unwrap();
assert!(result.get("type").is_none());
assert_eq!(result["url"], "https://example.com/mcp");
assert_eq!(result["headers"]["Authorization"], "Bearer xxx");
assert_eq!(result["enabled"], true);
}
#[test]
fn test_convert_http_type_to_hermes() {
let spec = json!({
"type": "http",
"url": "https://example.com/mcp"
});
let result = convert_to_hermes_format(&spec).unwrap();
assert!(result.get("type").is_none());
assert_eq!(result["url"], "https://example.com/mcp");
assert_eq!(result["enabled"], true);
}
#[test]
fn test_convert_stdio_empty_env_to_hermes() {
let spec = json!({
"type": "stdio",
"command": "node",
"args": [],
"env": {}
});
let result = convert_to_hermes_format(&spec).unwrap();
assert_eq!(result["command"], "node");
// Empty args and env should be omitted
assert!(result.get("args").is_none());
assert!(result.get("env").is_none());
assert_eq!(result["enabled"], true);
}
#[test]
fn test_convert_unknown_type_to_hermes_fails() {
let spec = json!({ "type": "grpc", "command": "foo" });
assert!(convert_to_hermes_format(&spec).is_err());
}
// ========================================================================
// convert_from_hermes_format tests
// ========================================================================
#[test]
fn test_convert_hermes_stdio_to_unified() {
let spec = json!({
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
"env": { "HOME": "/Users/test" },
"enabled": true,
"timeout": 30,
"connect_timeout": 10,
"tools": { "include": ["read_file"] },
"sampling": { "enabled": true }
});
let result = convert_from_hermes_format("filesystem", &spec).unwrap();
assert_eq!(result["type"], "stdio");
assert_eq!(result["command"], "npx");
assert_eq!(result["args"][0], "-y");
assert_eq!(result["args"][1], "@modelcontextprotocol/server-filesystem");
assert_eq!(result["env"]["HOME"], "/Users/test");
// Hermes-specific fields should be stripped
assert!(result.get("enabled").is_none());
assert!(result.get("timeout").is_none());
assert!(result.get("connect_timeout").is_none());
assert!(result.get("tools").is_none());
assert!(result.get("sampling").is_none());
}
#[test]
fn test_convert_hermes_http_to_unified() {
let spec = json!({
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer xxx" },
"enabled": true,
"timeout": 60
});
let result = convert_from_hermes_format("remote-server", &spec).unwrap();
assert_eq!(result["type"], "sse");
assert_eq!(result["url"], "https://example.com/mcp");
assert_eq!(result["headers"]["Authorization"], "Bearer xxx");
// Hermes-specific fields should be stripped
assert!(result.get("enabled").is_none());
assert!(result.get("timeout").is_none());
}
#[test]
fn test_convert_hermes_no_command_no_url_fails() {
let spec = json!({ "enabled": true, "timeout": 30 });
assert!(convert_from_hermes_format("bad-server", &spec).is_err());
}
// ========================================================================
// Merge-on-write tests
// ========================================================================
#[test]
fn test_merge_preserves_hermes_specific_fields() {
let existing = json!({
"command": "old-cmd",
"args": ["old-arg"],
"enabled": true,
"timeout": 30,
"connect_timeout": 10,
"tools": { "include": ["read_file"] },
"sampling": { "enabled": true }
});
let new_spec = json!({
"command": "new-cmd",
"args": ["new-arg"],
"env": { "KEY": "value" },
"enabled": true
});
let merged = merge_hermes_spec(&existing, &new_spec);
// Core fields should be overwritten
assert_eq!(merged["command"], "new-cmd");
assert_eq!(merged["args"][0], "new-arg");
assert_eq!(merged["env"]["KEY"], "value");
// Hermes-specific fields should be preserved from existing
assert_eq!(merged["timeout"], 30);
assert_eq!(merged["connect_timeout"], 10);
assert_eq!(merged["tools"]["include"][0], "read_file");
assert_eq!(merged["sampling"]["enabled"], true);
assert_eq!(merged["enabled"], true);
}
#[test]
fn test_merge_preserves_auth_field() {
let existing = json!({
"url": "https://mcp.example.com",
"auth": "oauth",
"enabled": true
});
let new_spec = json!({
"url": "https://mcp.example.com/updated",
"headers": { "X-Trace": "abc" },
"enabled": true
});
let merged = merge_hermes_spec(&existing, &new_spec);
assert_eq!(merged["url"], "https://mcp.example.com/updated");
assert_eq!(merged["headers"]["X-Trace"], "abc");
assert_eq!(
merged["auth"], "oauth",
"auth declaration must survive CC Switch round-trip"
);
}
#[test]
fn test_convert_hermes_strips_auth_on_import() {
let spec = json!({
"url": "https://mcp.example.com",
"auth": "oauth",
"enabled": true
});
let result = convert_from_hermes_format("remote", &spec).unwrap();
assert_eq!(result["type"], "sse");
assert_eq!(result["url"], "https://mcp.example.com");
assert!(
result.get("auth").is_none(),
"auth stays Hermes-specific; stripped from unified format"
);
}
#[test]
fn test_merge_new_server_no_existing_extra_fields() {
let existing = json!({
"command": "old-cmd"
});
let new_spec = json!({
"command": "new-cmd",
"args": ["arg1"],
"enabled": true
});
let merged = merge_hermes_spec(&existing, &new_spec);
assert_eq!(merged["command"], "new-cmd");
assert_eq!(merged["args"][0], "arg1");
assert_eq!(merged["enabled"], true);
// No extra fields to preserve
assert!(merged.get("timeout").is_none());
}
}
-3
View File
@@ -9,12 +9,10 @@
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
//! - `gemini` - Gemini MCP 同步和导入
//! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换)
//! - `hermes` - Hermes MCP 同步和导入
mod claude;
mod codex;
mod gemini;
mod hermes;
mod opencode;
mod validation;
@@ -30,7 +28,6 @@ pub use gemini::{
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
sync_single_server_to_gemini,
};
pub use hermes::{import_from_hermes, remove_server_from_hermes, sync_single_server_to_hermes};
pub use opencode::{
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
};
-1
View File
@@ -259,7 +259,6 @@ pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppErr
codex: false,
gemini: false,
opencode: true,
hermes: false,
},
description: None,
homepage: None,
-5
View File
@@ -914,7 +914,6 @@ pub fn set_tools_config(tools: &OpenClawToolsConfig) -> Result<OpenClawWriteOutc
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::sync::{Mutex, OnceLock};
fn test_guard() -> std::sync::MutexGuard<'static, ()> {
@@ -967,7 +966,6 @@ mod tests {
}
#[test]
#[serial]
fn default_model_write_preserves_top_level_comments() {
let source = r#"{
// top-level comment
@@ -996,7 +994,6 @@ mod tests {
}
#[test]
#[serial]
fn default_model_noop_write_skips_backup() {
let source = r#"{
models: {
@@ -1031,7 +1028,6 @@ mod tests {
}
#[test]
#[serial]
fn save_detects_external_conflict() {
let source = r#"{
models: {
@@ -1054,7 +1050,6 @@ mod tests {
}
#[test]
#[serial]
fn remove_last_provider_writes_empty_providers_without_panic() {
let source = r#"{
models: {
+2 -12
View File
@@ -10,30 +10,20 @@ use crate::opencode_config::get_opencode_dir;
/// 返回指定应用所使用的提示词文件路径。
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
if matches!(app, AppType::ClaudeDesktop) {
return Err(AppError::localized(
"claude_desktop.prompts_unsupported",
"Claude Desktop 暂不支持 Prompts",
"Claude Desktop does not support Prompts",
));
}
let base_dir: PathBuf = match app {
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
AppType::Gemini => get_gemini_dir(),
AppType::OpenCode => get_opencode_dir(),
AppType::OpenClaw => get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
AppType::ClaudeDesktop => unreachable!("handled above"),
};
let filename = match app {
AppType::Claude => "CLAUDE.md",
AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md",
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
AppType::ClaudeDesktop => unreachable!("handled above"),
AppType::OpenCode => "AGENTS.md",
AppType::OpenClaw => "AGENTS.md", // OpenClaw uses AGENTS.md for agent instructions
};
Ok(base_dir.join(filename))
+3 -66
View File
@@ -65,25 +65,6 @@ impl Provider {
in_failover_queue: false,
}
}
pub fn is_codex_oauth(&self) -> bool {
self.meta.as_ref().and_then(|m| m.provider_type.as_deref()) == Some("codex_oauth")
}
pub fn codex_fast_mode_enabled(&self) -> bool {
self.meta
.as_ref()
.map(|m| m.codex_fast_mode_enabled())
.unwrap_or(false)
}
pub fn has_usage_script_enabled(&self) -> bool {
self.meta
.as_ref()
.and_then(|m| m.usage_script.as_ref())
.map(|s| s.enabled)
.unwrap_or(false)
}
}
/// 供应商管理器
@@ -216,28 +197,6 @@ pub struct AuthBinding {
pub account_id: Option<String>,
}
/// Claude Desktop 3P 写入模式。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ClaudeDesktopMode {
Direct,
Proxy,
}
/// Claude Desktop 本地路由模式下暴露给 Desktop 的安全模型路由。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ClaudeDesktopModelRoute {
/// 真实上游模型名,只保存在 CC Switch 内部,不写入 Claude Desktop profile。
pub model: String,
/// Desktop /v1/models 中显示的名称。
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
/// Claude Desktop 3P 识别的 1M 上下文能力标记。
#[serde(rename = "supports1m", skip_serializing_if = "Option::is_none")]
pub supports_1m: Option<bool>,
}
/// 供应商元数据
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderMeta {
@@ -250,16 +209,6 @@ pub struct ProviderMeta {
skip_serializing_if = "Option::is_none"
)]
pub common_config_enabled: Option<bool>,
/// Claude Desktop 3P 写入模式:direct(直连)或 proxy(预留)
#[serde(rename = "claudeDesktopMode", skip_serializing_if = "Option::is_none")]
pub claude_desktop_mode: Option<ClaudeDesktopMode>,
/// Claude Desktop proxy 模式的模型路由映射:Claude-safe route -> upstream model。
#[serde(
default,
rename = "claudeDesktopModelRoutes",
skip_serializing_if = "HashMap::is_empty"
)]
pub claude_desktop_model_routes: HashMap<String, ClaudeDesktopModelRoute>,
/// 用量查询脚本配置
#[serde(skip_serializing_if = "Option::is_none")]
pub usage_script: Option<UsageScript>,
@@ -309,13 +258,9 @@ pub struct ProviderMeta {
pub is_full_url: Option<bool>,
/// Prompt cache key for OpenAI Responses-compatible endpoints.
/// When set, injected into converted Responses requests to improve cache hit rate.
/// If not set, Claude -> Responses conversions use a client-provided session/thread
/// identity when available; generated session IDs are not sent upstream.
/// If not set, provider ID is used automatically during Claude -> Responses conversion.
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
/// Codex OAuth FAST mode: inject `service_tier = "priority"` for ChatGPT Codex requests.
#[serde(rename = "codexFastMode", skip_serializing_if = "Option::is_none")]
pub codex_fast_mode: Option<bool>,
/// 累加模式应用中,该 provider 是否已写入 live config。
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
@@ -331,12 +276,6 @@ pub struct ProviderMeta {
}
impl ProviderMeta {
/// Codex OAuth FAST mode 是否启用。默认关闭,因为 `service_tier="priority"`
/// 会按更高速率消耗 ChatGPT 订阅配额,用户需显式开启以换取更低延迟。
pub fn codex_fast_mode_enabled(&self) -> bool {
self.codex_fast_mode.unwrap_or(false)
}
/// 解析指定托管认证供应商绑定的账号 ID。
///
/// 新版优先读取 authBinding,旧版继续兼容 githubAccountId。
@@ -760,10 +699,8 @@ mod tests {
#[test]
fn provider_meta_serializes_pricing_model_source() {
let meta = ProviderMeta {
pricing_model_source: Some("response".to_string()),
..ProviderMeta::default()
};
let mut meta = ProviderMeta::default();
meta.pricing_model_source = Some("response".to_string());
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
+7 -49
View File
@@ -6,8 +6,6 @@
//! - 以 `_` 开头的字段被视为私有参数,会被递归过滤
//! - 支持白名单机制,允许透传特定的 `_` 前缀字段
//! - 支持嵌套对象和数组的深度过滤
//! - JSON Schema 的 properties / patternProperties / definitions / $defs 名称
//! 是用户定义的字段名,不按私有参数过滤
//!
//! ## 使用场景
//! - `_internal_id`: 内部追踪 ID
@@ -67,35 +65,29 @@ pub fn filter_private_params(body: Value) -> Value {
/// ```
pub fn filter_private_params_with_whitelist(body: Value, whitelist: &[String]) -> Value {
let whitelist_set: HashSet<&str> = whitelist.iter().map(|s| s.as_str()).collect();
filter_recursive_with_whitelist(body, &mut Vec::new(), &mut Vec::new(), &whitelist_set)
filter_recursive_with_whitelist(body, &mut Vec::new(), &whitelist_set)
}
/// 递归过滤实现(支持白名单)
fn filter_recursive_with_whitelist(
value: Value,
path: &mut Vec<String>,
removed_keys: &mut Vec<String>,
whitelist: &HashSet<&str>,
) -> Value {
match value {
Value::Object(map) => {
let is_schema_name_map = path.last().is_some_and(|key| matches_schema_name_map(key));
let filtered: serde_json::Map<String, Value> = map
.into_iter()
.filter_map(|(key, val)| {
// 以 _ 开头且不在白名单中的字段被过滤
if key.starts_with('_')
&& !whitelist.contains(key.as_str())
&& !is_schema_name_map
{
if key.starts_with('_') && !whitelist.contains(key.as_str()) {
removed_keys.push(key);
None
} else {
path.push(key.clone());
let filtered_value =
filter_recursive_with_whitelist(val, path, removed_keys, whitelist);
path.pop();
Some((key, filtered_value))
Some((
key,
filter_recursive_with_whitelist(val, removed_keys, whitelist),
))
}
})
.collect();
@@ -110,20 +102,13 @@ fn filter_recursive_with_whitelist(
}
Value::Array(arr) => Value::Array(
arr.into_iter()
.map(|v| filter_recursive_with_whitelist(v, path, removed_keys, whitelist))
.map(|v| filter_recursive_with_whitelist(v, removed_keys, whitelist))
.collect(),
),
other => other,
}
}
fn matches_schema_name_map(key: &str) -> bool {
matches!(
key,
"properties" | "patternProperties" | "definitions" | "$defs"
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -297,33 +282,6 @@ mod tests {
assert!(data.get("normal").is_some());
}
#[test]
fn test_preserves_json_schema_property_names_with_underscore() {
let input = json!({
"tools": [
{
"name": "lookup",
"input_schema": {
"type": "object",
"properties": {
"_id": {"type": "string", "_internal_note": "remove"},
"_meta": {"type": "object"}
},
"_private_schema_note": "remove"
}
}
]
});
let output = filter_private_params(input);
let schema = &output["tools"][0]["input_schema"];
assert!(schema["properties"].get("_id").is_some());
assert!(schema["properties"].get("_meta").is_some());
assert!(schema["properties"]["_id"].get("_internal_note").is_none());
assert!(schema.get("_private_schema_note").is_none());
}
#[test]
fn test_empty_whitelist_same_as_default() {
let input = json!({
-169
View File
@@ -443,41 +443,6 @@ pub fn sanitize_orphan_tool_results(mut body: Value) -> Value {
body
}
/// 请求前主动剥离所有 assistant 消息里的 thinking / redacted_thinking block
///
/// Copilot 的三条目标端点(`/chat/completions`、`/v1/responses`、`/v1/chat/completions`
/// 均为 OpenAI 兼容格式,不识别 Anthropic 的 thinking block。若原样转发,上游会
/// 拒绝并返回 invalid_request_error —— 届时 `thinking_rectifier` 才做反应式清理并
/// 重试。那次已经失败的请求依旧消耗一次 premium quota,所以此处提前剥离。
///
/// 与 `thinking_rectifier::rectify_anthropic_request` 的区别:
/// - 本函数只剥 thinking / redacted_thinking 两类 block,不触碰 signature,也不
/// 移除顶层 thinking 字段——那些是错误路径上的激进整流,常规路径不需要。
/// - 保持与 `merge_tool_results` / `sanitize_orphan_tool_results` 一致的"消费 body、
/// 返回新 body"签名,便于接入 forwarder 管道。
pub fn strip_thinking_blocks(mut body: Value) -> Value {
let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) else {
return body;
};
for msg in messages.iter_mut() {
if msg.get("role").and_then(|r| r.as_str()) != Some("assistant") {
continue;
}
let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut()) else {
continue;
};
content.retain(|block| {
!matches!(
block.get("type").and_then(|t| t.as_str()),
Some("thinking") | Some("redacted_thinking")
)
});
}
body
}
// ─── 内部辅助 ─────────────────────────────────
/// 从请求体的 `system` 字段提取文本(处理 string/array 两种格式)。
@@ -1406,138 +1371,4 @@ mod tests {
assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "text");
}
// === strip_thinking_blocks 测试 ===
#[test]
fn test_strip_thinking_removes_assistant_thinking_blocks() {
let body = serde_json::json!({
"messages": [
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
{"role": "assistant", "content": [
{"type": "thinking", "thinking": "let me ponder", "signature": "sig"},
{"type": "redacted_thinking", "data": "opaque"},
{"type": "text", "text": "hello"},
{"type": "tool_use", "id": "t1", "name": "read", "input": {}}
]}
]
});
let result = strip_thinking_blocks(body);
let content = result["messages"][1]["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "tool_use");
}
#[test]
fn test_strip_thinking_leaves_user_messages_untouched() {
// 仅处理 assistantuser 的 thinking 块(极少见,但可能)不动
let body = serde_json::json!({
"messages": [
{"role": "user", "content": [
{"type": "thinking", "thinking": "x"},
{"type": "text", "text": "hi"}
]}
]
});
let result = strip_thinking_blocks(body);
let content = result["messages"][0]["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
}
#[test]
fn test_strip_thinking_handles_missing_messages() {
let body = serde_json::json!({ "model": "claude-3-5-sonnet" });
let result = strip_thinking_blocks(body.clone());
assert_eq!(result, body);
}
#[test]
fn test_strip_thinking_leaves_empty_content_array() {
// 仅含 thinking 的 assistant 消息剥完后 content 为空——保留上游自处理
let body = serde_json::json!({
"messages": [
{"role": "assistant", "content": [
{"type": "thinking", "thinking": "solo"}
]}
]
});
let result = strip_thinking_blocks(body);
let content = result["messages"][0]["content"].as_array().unwrap();
assert_eq!(content.len(), 0);
}
#[test]
fn test_strip_thinking_preserves_signature_on_non_thinking_blocks() {
// signature 留给 thinking_rectifier 在错误路径处理,此处不动
let body = serde_json::json!({
"messages": [
{"role": "assistant", "content": [
{"type": "tool_use", "id": "t1", "name": "x", "input": {}, "signature": "s"}
]}
]
});
let result = strip_thinking_blocks(body);
let block = &result["messages"][0]["content"][0];
assert_eq!(block["signature"], "s");
}
#[test]
fn test_strip_thinking_multiple_assistant_turns() {
let body = serde_json::json!({
"messages": [
{"role": "user", "content": [{"type": "text", "text": "q1"}]},
{"role": "assistant", "content": [
{"type": "thinking", "thinking": "a"},
{"type": "text", "text": "r1"}
]},
{"role": "user", "content": [{"type": "text", "text": "q2"}]},
{"role": "assistant", "content": [
{"type": "redacted_thinking", "data": "x"},
{"type": "text", "text": "r2"}
]}
]
});
let result = strip_thinking_blocks(body);
let a1 = result["messages"][1]["content"].as_array().unwrap();
let a2 = result["messages"][3]["content"].as_array().unwrap();
assert_eq!(a1.len(), 1);
assert_eq!(a1[0]["text"], "r1");
assert_eq!(a2.len(), 1);
assert_eq!(a2[0]["text"], "r2");
}
#[test]
fn test_strip_thinking_ignores_string_content() {
// assistant.content 是字符串而非 block 数组 — 历史请求或极简客户端会这样
// 不应崩溃,也不应转换结构
let body = serde_json::json!({
"messages": [
{"role": "assistant", "content": "plain text response"}
]
});
let result = strip_thinking_blocks(body.clone());
assert_eq!(result, body);
}
#[test]
fn test_strip_thinking_preserves_block_order() {
let body = serde_json::json!({
"messages": [
{"role": "assistant", "content": [
{"type": "thinking", "thinking": "pre"},
{"type": "text", "text": "A"},
{"type": "tool_use", "id": "t1", "name": "x", "input": {}},
{"type": "redacted_thinking", "data": "mid"},
{"type": "text", "text": "B"}
]}
]
});
let result = strip_thinking_blocks(body);
let content = result["messages"][0]["content"].as_array().unwrap();
assert_eq!(content.len(), 3);
assert_eq!(content[0]["text"], "A");
assert_eq!(content[1]["type"], "tool_use");
assert_eq!(content[2]["text"], "B");
}
}
+1 -1
View File
@@ -111,7 +111,7 @@ impl FailoverSwitchManager {
}
if let Ok(new_menu) = crate::tray::create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id(crate::tray::TRAY_ID) {
if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) {
log::error!("[Failover] 更新托盘菜单失败: {e}");
}
+61 -638
View File
@@ -7,7 +7,6 @@ use super::{
body_filter::filter_private_params_with_whitelist,
error::*,
failover_switch::FailoverSwitchManager,
json_canonical::{canonicalize_value, short_value_hash},
log_codes::fwd as log_fwd,
provider_router::ProviderRouter,
providers::{
@@ -56,8 +55,6 @@ pub struct RequestForwarder {
current_provider_id_at_start: String,
/// 代理会话 ID(用于 Gemini Native shadow replay
session_id: String,
/// Session ID 是否由客户端提供;生成值不能作为上游缓存身份。
session_client_provided: bool,
/// 整流器配置
rectifier_config: RectifierConfig,
/// 优化器配置
@@ -66,8 +63,6 @@ pub struct RequestForwarder {
copilot_optimizer_config: CopilotOptimizerConfig,
/// 非流式请求超时(秒)
non_streaming_timeout: std::time::Duration,
/// 流式请求响应头等待超时(秒)
streaming_first_byte_timeout: std::time::Duration,
}
impl RequestForwarder {
@@ -82,8 +77,7 @@ impl RequestForwarder {
app_handle: Option<tauri::AppHandle>,
current_provider_id_at_start: String,
session_id: String,
session_client_provided: bool,
streaming_first_byte_timeout: u64,
_streaming_first_byte_timeout: u64,
_streaming_idle_timeout: u64,
rectifier_config: RectifierConfig,
optimizer_config: OptimizerConfig,
@@ -98,51 +92,13 @@ impl RequestForwarder {
app_handle,
current_provider_id_at_start,
session_id,
session_client_provided,
rectifier_config,
optimizer_config,
copilot_optimizer_config,
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
streaming_first_byte_timeout: std::time::Duration::from_secs(
streaming_first_byte_timeout,
),
}
}
async fn record_success_result(
&self,
provider_id: &str,
app_type: &str,
used_half_open_permit: bool,
) {
if used_half_open_permit {
if let Err(e) = self
.router
.record_result(provider_id, app_type, true, true, None)
.await
{
log::warn!(
"[{app_type}] 记录 Provider 成功结果失败: provider_id={provider_id}, error={e}"
);
}
return;
}
let router = self.router.clone();
let provider_id = provider_id.to_string();
let app_type = app_type.to_string();
tokio::spawn(async move {
if let Err(e) = router
.record_result(&provider_id, &app_type, false, true, None)
.await
{
log::warn!(
"[{app_type}] 异步记录 Provider 成功结果失败: provider_id={provider_id}, error={e}"
);
}
});
}
/// 转发请求(带故障转移)
///
/// # Arguments
@@ -230,7 +186,6 @@ impl RequestForwarder {
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制)
match self
.forward(
app_type,
provider,
endpoint,
&provider_body,
@@ -241,9 +196,16 @@ impl RequestForwarder {
.await
{
Ok((response, claude_api_format)) => {
// 成功:普通闭合熔断状态异步记录,避免阻塞流式首包返回;
// HalfOpen 探测仍同步等待,保证 permit 与熔断状态及时释放。
self.record_success_result(&provider.id, app_type_str, used_half_open_permit)
// 成功:记录成功并更新熔断器
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
true,
None,
)
.await;
// 更新当前应用类型使用的 provider
@@ -354,7 +316,6 @@ impl RequestForwarder {
// 使用同一供应商重试(不计入熔断器)
match self
.forward(
app_type,
provider,
endpoint,
&provider_body,
@@ -366,12 +327,17 @@ impl RequestForwarder {
{
Ok((response, claude_api_format)) => {
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
self.record_success_result(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
// 记录成功
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
true,
None,
)
.await;
// 更新当前应用类型使用的 provider
{
@@ -549,7 +515,6 @@ impl RequestForwarder {
// 使用同一供应商重试(不计入熔断器)
match self
.forward(
app_type,
provider,
endpoint,
&provider_body,
@@ -561,12 +526,16 @@ impl RequestForwarder {
{
Ok((response, claude_api_format)) => {
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
self.record_success_result(
&provider.id,
app_type_str,
used_half_open_permit,
)
.await;
let _ = self
.router
.record_result(
&provider.id,
app_type_str,
used_half_open_permit,
true,
None,
)
.await;
{
let mut current_providers =
@@ -783,10 +752,8 @@ impl RequestForwarder {
}
/// 转发单个请求(使用适配器)
#[allow(clippy::too_many_arguments)]
async fn forward(
&self,
app_type: &AppType,
provider: &Provider,
endpoint: &str,
body: &Value,
@@ -804,16 +771,8 @@ impl RequestForwarder {
.unwrap_or(false);
// 应用模型映射(独立于格式转换)
// Claude Desktop proxy 模式必须先把 Desktop 可见的 claude-* route
// 映射成真实上游模型名,并且未知 route 要直接报错,不能使用默认模型兜底。
let mapped_body = if matches!(app_type, AppType::ClaudeDesktop) {
crate::claude_desktop_config::map_proxy_request_model(body.clone(), provider)
.map_err(|e| ProxyError::InvalidRequest(e.to_string()))?
} else {
let (mapped_body, _original_model, _mapped_model) =
super::model_mapper::apply_model_mapping(body.clone(), provider);
mapped_body
};
let (mapped_body, _original_model, _mapped_model) =
super::model_mapper::apply_model_mapping(body.clone(), provider);
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
let mut mapped_body = normalize_thinking_type(mapped_body);
@@ -827,13 +786,6 @@ impl RequestForwarder {
== Some("github_copilot")
|| base_url.contains("githubcopilot.com");
if is_copilot {
mapped_body =
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
self.apply_copilot_live_model_resolution(provider, &mut mapped_body)
.await;
}
// --- Copilot 优化器:分类 + 请求体优化(在格式转换之前执行) ---
// 注意:确定性 ID 也在此处计算,因为 mapped_body 在格式转换时会被 move
//
@@ -869,12 +821,6 @@ impl RequestForwarder {
mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body);
}
// 3.5. 主动剥离 thinking block — Copilot 走 OpenAI 兼容端点不识别该块
// 避免上游拒绝后由 rectifier 反应式重试(首次请求已消耗 quota)
if self.copilot_optimizer_config.strip_thinking {
mapped_body = super::copilot_optimizer::strip_thinking_blocks(mapped_body);
}
// 4. Warmup 小模型降级
if self.copilot_optimizer_config.warmup_downgrade && classification.is_warmup {
log::info!(
@@ -1014,8 +960,7 @@ impl RequestForwarder {
mapped_body,
provider,
api_format,
self.session_client_provided
.then_some(self.session_id.as_str()),
Some(&self.session_id),
Some(self.gemini_shadow.as_ref()),
)?
} else {
@@ -1027,21 +972,12 @@ impl RequestForwarder {
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
// 默认使用空白名单,过滤所有 _ 前缀字段
let filtered_body = prepare_upstream_request_body(request_body);
log_prompt_cache_trace(
app_type,
provider,
&effective_endpoint,
resolved_claude_api_format.as_deref(),
&filtered_body,
self.session_client_provided,
);
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
let force_identity_encoding = needs_transform
|| should_force_identity_encoding(&effective_endpoint, &filtered_body, headers);
// Codex OAuth 需要注入的 ChatGPT-Account-Id(在动态 token 获取期间填充)
let mut codex_oauth_account_id: Option<String> = None;
let mut should_send_codex_oauth_session_headers = false;
// 获取认证头(提前准备,用于内联替换)
let mut auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
@@ -1123,7 +1059,6 @@ impl RequestForwarder {
match token_result {
Ok(token) => {
auth = AuthInfo::new(token, AuthStrategy::CodexOAuth);
should_send_codex_oauth_session_headers = true;
// 解析使用的 account_id(用于注入 ChatGPT-Account-Id header
codex_oauth_account_id = match account_id {
Some(id) => Some(id),
@@ -1161,13 +1096,6 @@ impl RequestForwarder {
}
}
let codex_oauth_session_headers =
if should_send_codex_oauth_session_headers && self.session_client_provided {
build_codex_oauth_session_headers(&self.session_id)
} else {
Vec::new()
};
// --- Copilot 优化器:动态 header 注入 ---
if let Some((ref classification, ref det_request_id, ref interaction_id)) =
copilot_optimization
@@ -1232,10 +1160,6 @@ impl RequestForwarder {
.parse::<http::Uri>()
.ok()
.and_then(|u| u.authority().map(|a| a.to_string()));
let strip_openrouter_hop_by_hop_headers =
should_strip_openrouter_hop_by_hop_request_headers(provider, &base_url);
let connection_header_tokens =
strip_openrouter_hop_by_hop_headers.then(|| collect_connection_header_tokens(headers));
let should_send_anthropic_headers = adapter.name() == "Claude"
&& matches!(resolved_claude_api_format.as_deref(), Some("anthropic"));
@@ -1316,13 +1240,6 @@ impl RequestForwarder {
continue;
}
// --- OpenRouter 全请求:补充剥离 hop-by-hop 请求头 ---
if let Some(connection_header_tokens) = connection_header_tokens.as_ref() {
if should_strip_openrouter_request_header(key_str, connection_header_tokens) {
continue;
}
}
// --- 认证类 — 用 adapter 提供的认证头替换(在原始位置) ---
if key_str.eq_ignore_ascii_case("authorization")
|| key_str.eq_ignore_ascii_case("x-api-key")
@@ -1419,12 +1336,6 @@ impl RequestForwarder {
);
}
// Codex OAuth 反代尽量对齐官方 Codex CLI 的会话路由信号。
// 只发送客户端提供的 session_id;生成的 UUID 每次不同,反而会破坏前缀缓存。
for (name, value) in codex_oauth_session_headers {
ordered_headers.insert(name, value);
}
// 序列化请求体
let body_bytes = serde_json::to_vec(&filtered_body)
.map_err(|e| ProxyError::Internal(format!("Failed to serialize request body: {e}")))?;
@@ -1444,14 +1355,12 @@ impl RequestForwarder {
.and_then(|v| v.as_str())
.unwrap_or("<none>");
log::info!("[{tag}] >>> 请求 URL: {url} (model={request_model})");
if log::log_enabled!(log::Level::Debug) {
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
log::debug!(
"[{tag}] >>> 请求体内容 ({}字节): {}",
body_str.len(),
body_str
);
}
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
log::debug!(
"[{tag}] >>> 请求体内容 ({}字节): {}",
body_str.len(),
body_str
);
}
// 确定超时
@@ -1470,60 +1379,35 @@ impl RequestForwarder {
.map(|u| u.starts_with("socks5"))
.unwrap_or(false);
let preserve_exact_header_case = should_preserve_exact_header_case(
adapter.name(),
provider,
resolved_claude_api_format.as_deref(),
is_copilot,
);
let uri: http::Uri = url
.parse()
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid URL '{url}': {e}")))?;
// 发送请求
let response = if is_socks_proxy || !preserve_exact_header_case {
// OpenAI / Copilot / Codex 类后端不依赖原始 header 大小写;走 reqwest
// 连接池,避免 raw TCP/TLS path 每次请求都重新握手。SOCKS5 也只能走 reqwest。
log::debug!(
"[Forwarder] Using pooled reqwest client (preserve_exact_header_case={preserve_exact_header_case}, socks_proxy={is_socks_proxy})"
);
let response = if is_socks_proxy {
// SOCKS5 代理:只能走 reqwest(不支持 header case 保留)
log::debug!("[Forwarder] Using reqwest for SOCKS5 proxy");
let client = super::http_client::get();
let mut request = client.post(&url);
let request_is_streaming =
is_streaming_request(&effective_endpoint, &filtered_body, headers);
if request_is_streaming {
// reqwest 的 timeout 是整请求超时;流式请求交给 response_processor
// 的首包/静默期超时控制,避免长流被总时长误杀。
request = request.timeout(std::time::Duration::from_secs(24 * 60 * 60));
} else if !self.non_streaming_timeout.is_zero() {
if !self.non_streaming_timeout.is_zero() {
request = request.timeout(self.non_streaming_timeout);
}
for (key, value) in &ordered_headers {
request = request.header(key, value);
}
let send = request.body(body_bytes).send();
let send_result = if request_is_streaming {
let header_timeout = if self.streaming_first_byte_timeout.is_zero() {
timeout
let reqwest_resp = request.body(body_bytes).send().await.map_err(|e| {
if e.is_timeout() {
ProxyError::Timeout(format!("请求超时: {e}"))
} else if e.is_connect() {
ProxyError::ForwardFailed(format!("连接失败: {e}"))
} else {
self.streaming_first_byte_timeout
};
tokio::time::timeout(header_timeout, send)
.await
.map_err(|_| {
ProxyError::Timeout(format!(
"流式响应首包超时: {}s(上游未返回响应头)",
header_timeout.as_secs()
))
})?
} else {
send.await
};
let reqwest_resp = send_result.map_err(map_reqwest_send_error)?;
ProxyError::ForwardFailed(e.to_string())
}
})?;
ProxyResponse::Reqwest(reqwest_resp)
} else {
// HTTP 代理或直连:走 hyper raw write(保持 header 大小写)
// 如果有 HTTP 代理,hyper_client 会用 CONNECT 隧道穿过代理
let uri: http::Uri = url
.parse()
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid URL '{url}': {e}")))?;
super::hyper_client::send_request(
uri,
http::Method::POST,
@@ -1575,49 +1459,6 @@ impl RequestForwarder {
"openai_chat".to_string()
}
/// 用 Copilot live `/models` 列表确认 model ID 真实可用,找不到时按 family 降级。
/// 命中缓存后是同步的;首次请求或 5 min 缓存过期后会触发一次 HTTP。
async fn apply_copilot_live_model_resolution(
&self,
provider: &Provider,
body: &mut serde_json::Value,
) {
let Some(model_id) = body.get("model").and_then(|v| v.as_str()) else {
return;
};
let model_id = model_id.to_string();
let Some(app_handle) = &self.app_handle else {
return;
};
let copilot_state = app_handle.state::<CopilotAuthState>();
let copilot_auth = copilot_state.0.read().await;
let account_id = provider
.meta
.as_ref()
.and_then(|m| m.managed_account_id_for("github_copilot"));
let models_result = match account_id.as_deref() {
Some(id) => copilot_auth.fetch_models_for_account(id).await,
None => copilot_auth.fetch_models().await,
};
let models = match models_result {
Ok(m) => m,
Err(err) => {
log::debug!("[Copilot] live model list unavailable, skip resolution: {err}");
return;
}
};
if let Some(resolved) =
super::providers::copilot_model_map::resolve_against_models(&model_id, &models)
{
log::info!("[Copilot] live-model resolve: {model_id} → {resolved}");
body["model"] = serde_json::Value::String(resolved);
}
}
async fn is_copilot_openai_vendor_model(&self, provider: &Provider, model_id: &str) -> bool {
let Some(app_handle) = &self.app_handle else {
log::debug!("[Copilot] AppHandle unavailable, fallback to chat/completions");
@@ -1699,52 +1540,6 @@ fn is_bedrock_provider(provider: &Provider) -> bool {
.unwrap_or(false)
}
const OPENROUTER_HOP_BY_HOP_REQUEST_HEADERS: &[&str] = &[
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"trailers",
"upgrade",
];
fn should_strip_openrouter_hop_by_hop_request_headers(provider: &Provider, base_url: &str) -> bool {
let provider_type = provider
.meta
.as_ref()
.and_then(|meta| meta.provider_type.as_deref())
.unwrap_or_default();
provider_type.eq_ignore_ascii_case(ProviderType::OpenRouter.as_str())
|| base_url.to_ascii_lowercase().contains("openrouter.ai")
}
fn collect_connection_header_tokens(
headers: &axum::http::HeaderMap,
) -> std::collections::HashSet<String> {
headers
.get_all("connection")
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.map(str::trim)
.filter(|name| !name.is_empty())
.map(|name| name.to_ascii_lowercase())
.collect()
}
fn should_strip_openrouter_request_header(
key_str: &str,
connection_header_tokens: &std::collections::HashSet<String>,
) -> bool {
let lower_key = key_str.to_ascii_lowercase();
OPENROUTER_HOP_BY_HOP_REQUEST_HEADERS.contains(&lower_key.as_str())
|| connection_header_tokens.contains(lower_key.as_str())
}
fn build_retryable_failure_log(
provider_name: &str,
attempted_providers: usize,
@@ -1972,46 +1767,11 @@ fn append_query_to_full_url(base_url: &str, query: Option<&str>) -> String {
}
}
fn build_codex_oauth_session_headers(
session_id: &str,
) -> Vec<(http::HeaderName, http::HeaderValue)> {
let session_id = session_id.trim();
if session_id.is_empty() {
return Vec::new();
}
let mut headers = Vec::new();
if let Ok(value) = http::HeaderValue::from_str(session_id) {
headers.push((http::HeaderName::from_static("session_id"), value.clone()));
headers.push((http::HeaderName::from_static("x-client-request-id"), value));
}
let window_id = format!("{session_id}:0");
if let Ok(value) = http::HeaderValue::from_str(&window_id) {
headers.push((http::HeaderName::from_static("x-codex-window-id"), value));
}
headers
}
fn should_preserve_exact_header_case(
adapter_name: &str,
provider: &Provider,
resolved_claude_api_format: Option<&str>,
is_copilot: bool,
fn should_force_identity_encoding(
endpoint: &str,
body: &Value,
headers: &axum::http::HeaderMap,
) -> bool {
if matches!(adapter_name, "Codex" | "Gemini") {
return false;
}
if is_copilot || provider.is_codex_oauth() {
return false;
}
matches!(resolved_claude_api_format, None | Some("anthropic"))
}
fn is_streaming_request(endpoint: &str, body: &Value, headers: &axum::http::HeaderMap) -> bool {
if body
.get("stream")
.and_then(|value| value.as_bool())
@@ -2031,24 +1791,6 @@ fn is_streaming_request(endpoint: &str, body: &Value, headers: &axum::http::Head
.unwrap_or(false)
}
fn should_force_identity_encoding(
endpoint: &str,
body: &Value,
headers: &axum::http::HeaderMap,
) -> bool {
is_streaming_request(endpoint, body, headers)
}
fn map_reqwest_send_error(error: reqwest::Error) -> ProxyError {
if error.is_timeout() {
ProxyError::Timeout(format!("请求超时: {error}"))
} else if error.is_connect() {
ProxyError::ForwardFailed(format!("连接失败: {error}"))
} else {
ProxyError::ForwardFailed(error.to_string())
}
}
fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
let trimmed = normalized.trim();
@@ -2062,65 +1804,6 @@ fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
format!("{truncated}...")
}
fn prepare_upstream_request_body(request_body: Value) -> Value {
canonicalize_value(filter_private_params_with_whitelist(request_body, &[]))
}
fn log_prompt_cache_trace(
app_type: &AppType,
provider: &Provider,
endpoint: &str,
api_format: Option<&str>,
body: &Value,
session_client_provided: bool,
) {
if !log::log_enabled!(log::Level::Debug) {
return;
}
let prompt_cache_key = body
.get("prompt_cache_key")
.and_then(|value| value.as_str())
.map(|key| format!("present(len={})", key.len()))
.unwrap_or_else(|| "absent".to_string());
let store = body
.get("store")
.map(value_for_log)
.unwrap_or_else(|| "absent".to_string());
let stream = body
.get("stream")
.map(value_for_log)
.unwrap_or_else(|| "absent".to_string());
log::debug!(
"[CacheTrace] app={}, provider={}, endpoint={}, api_format={}, session_client_provided={}, prompt_cache_key={}, store={}, stream={}, instructions_hash={}, tools_hash={}, input_hash={}, include_hash={}, body_hash={}",
app_type.as_str(),
provider.id,
endpoint,
api_format.unwrap_or("native"),
session_client_provided,
prompt_cache_key,
store,
stream,
short_value_hash(body.get("instructions")),
short_value_hash(body.get("tools")),
short_value_hash(body.get("input")),
short_value_hash(body.get("include")),
short_value_hash(Some(body)),
);
}
fn value_for_log(value: &Value) -> String {
match value {
Value::Bool(value) => value.to_string(),
Value::Number(value) => value.to_string(),
Value::String(value) => value.clone(),
Value::Null => "null".to_string(),
Value::Array(values) => format!("array(len={})", values.len()),
Value::Object(values) => format!("object(len={})", values.len()),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2128,26 +1811,6 @@ mod tests {
use axum::http::HeaderMap;
use serde_json::json;
fn test_provider_with_type(provider_type: Option<&str>) -> Provider {
Provider {
id: "provider-1".to_string(),
name: "Provider 1".to_string(),
settings_config: json!({}),
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: provider_type.map(|value| crate::provider::ProviderMeta {
provider_type: Some(value.to_string()),
..Default::default()
}),
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn single_provider_retryable_log_uses_single_provider_code() {
let error = ProxyError::UpstreamError {
@@ -2213,151 +1876,6 @@ mod tests {
assert_eq!(summary, "line1 line2...");
}
#[test]
fn canonical_json_sorts_object_keys_for_cache_trace_hashes() {
let left = json!({
"tools": [
{
"parameters": {
"properties": {
"b": {"type": "string"},
"a": {"type": "number"}
},
"type": "object"
},
"name": "lookup"
}
]
});
let right = json!({
"tools": [
{
"name": "lookup",
"parameters": {
"type": "object",
"properties": {
"a": {"type": "number"},
"b": {"type": "string"}
}
}
}
]
});
assert_eq!(
crate::proxy::json_canonical::canonical_json_string(&left),
crate::proxy::json_canonical::canonical_json_string(&right)
);
assert_eq!(
short_value_hash(Some(&left)),
short_value_hash(Some(&right))
);
}
#[test]
fn prepare_upstream_request_body_filters_private_fields_and_canonicalizes_order() {
let body = json!({
"z": 1,
"_internal": "drop",
"tools": [
{
"name": "lookup",
"parameters": {
"type": "object",
"properties": {
"_id": {
"_private_note": "drop",
"type": "string"
},
"b": {"type": "number"},
"a": {"type": "string"}
}
}
}
],
"a": 2
});
let prepared = prepare_upstream_request_body(body);
assert!(prepared.get("_internal").is_none());
assert!(prepared["tools"][0]["parameters"]["properties"]
.get("_id")
.is_some());
assert!(prepared["tools"][0]["parameters"]["properties"]["_id"]
.get("_private_note")
.is_none());
assert_eq!(
serde_json::to_string(&prepared).unwrap(),
r#"{"a":2,"tools":[{"name":"lookup","parameters":{"properties":{"_id":{"type":"string"},"a":{"type":"string"},"b":{"type":"number"}},"type":"object"}}],"z":1}"#
);
}
#[test]
fn codex_oauth_session_headers_match_codex_cache_identity() {
let headers = build_codex_oauth_session_headers("session-123");
let mut map = HeaderMap::new();
for (name, value) in headers {
map.insert(name, value);
}
assert_eq!(
map.get("session_id"),
Some(&HeaderValue::from_static("session-123"))
);
assert_eq!(
map.get("x-client-request-id"),
Some(&HeaderValue::from_static("session-123"))
);
assert_eq!(
map.get("x-codex-window-id"),
Some(&HeaderValue::from_static("session-123:0"))
);
}
#[test]
fn exact_header_case_preserved_for_native_claude_only() {
let provider = test_provider_with_type(None);
assert!(should_preserve_exact_header_case(
"Claude",
&provider,
Some("anthropic"),
false
));
assert!(!should_preserve_exact_header_case(
"Claude",
&provider,
Some("openai_responses"),
false
));
assert!(!should_preserve_exact_header_case(
"Codex", &provider, None, false
));
assert!(!should_preserve_exact_header_case(
"Gemini", &provider, None, false
));
}
#[test]
fn exact_header_case_skipped_for_codex_oauth_and_copilot() {
let codex_oauth = test_provider_with_type(Some("codex_oauth"));
let copilot = test_provider_with_type(Some("github_copilot"));
assert!(!should_preserve_exact_header_case(
"Claude",
&codex_oauth,
Some("openai_responses"),
false
));
assert!(!should_preserve_exact_header_case(
"Claude",
&copilot,
Some("openai_chat"),
true
));
}
#[test]
fn rewrite_claude_transform_endpoint_strips_beta_for_chat_completions() {
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
@@ -2523,17 +2041,6 @@ mod tests {
));
}
#[test]
fn streaming_request_detects_gemini_sse_without_body_stream_flag() {
let headers = HeaderMap::new();
assert!(is_streaming_request(
"/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse",
&json!({ "model": "gemini-2.5-pro" }),
&headers
));
}
#[test]
fn force_identity_for_sse_accept_header() {
let mut headers = HeaderMap::new();
@@ -2557,90 +2064,6 @@ mod tests {
));
}
#[test]
fn collect_connection_header_tokens_tracks_dynamic_hop_by_hop_headers() {
let mut headers = HeaderMap::new();
headers.insert(
"connection",
HeaderValue::from_static("keep-alive, x-custom-hop, Upgrade"),
);
let tokens = collect_connection_header_tokens(&headers);
assert!(tokens.contains("keep-alive"));
assert!(tokens.contains("x-custom-hop"));
assert!(tokens.contains("upgrade"));
}
#[test]
fn should_strip_openrouter_hop_by_hop_request_headers_for_any_openrouter_base_url() {
let mut custom_domain_openrouter = Provider::with_id(
"openrouter-custom".to_string(),
"OpenRouter Custom".to_string(),
serde_json::json!({}),
None,
);
custom_domain_openrouter.meta = Some(crate::provider::ProviderMeta {
provider_type: Some("openrouter".to_string()),
..Default::default()
});
assert!(should_strip_openrouter_hop_by_hop_request_headers(
&Provider::with_id(
"a".to_string(),
"A".to_string(),
serde_json::json!({}),
None
),
"https://openrouter.ai/api"
));
assert!(should_strip_openrouter_hop_by_hop_request_headers(
&Provider::with_id(
"b".to_string(),
"B".to_string(),
serde_json::json!({}),
None
),
"https://OPENROUTER.ai/api/v1"
));
assert!(should_strip_openrouter_hop_by_hop_request_headers(
&custom_domain_openrouter,
"https://relay.example/custom"
));
assert!(!should_strip_openrouter_hop_by_hop_request_headers(
&Provider::with_id(
"c".to_string(),
"C".to_string(),
serde_json::json!({}),
None
),
"https://api.openai.com/v1"
));
}
#[test]
fn should_strip_openrouter_request_header_covers_static_and_dynamic_hop_by_hop_headers() {
let mut connection_tokens = std::collections::HashSet::new();
connection_tokens.insert("x-custom-hop".to_string());
assert!(should_strip_openrouter_request_header(
"connection",
&connection_tokens
));
assert!(should_strip_openrouter_request_header(
"proxy-connection",
&connection_tokens
));
assert!(should_strip_openrouter_request_header(
"x-custom-hop",
&connection_tokens
));
assert!(!should_strip_openrouter_request_header(
"anthropic-version",
&connection_tokens
));
}
// ==================== Copilot 动态 endpoint 路由相关测试 ====================
/// 验证 is_copilot 检测逻辑:通过 provider_type 判断
-39
View File
@@ -81,10 +81,6 @@ fn should_normalize_gemini_full_url(base_url: &str) -> bool {
let path = path.trim_end_matches('/');
let on_google_host = is_google_gemini_host(extract_host(origin));
if matches_vertex_ai_publisher_model_path(path) {
return false;
}
// Unconditional layer: only paths whose grammar is *intrinsically*
// Gemini-specific — the `/models/...:generateContent` method-call
// shape and the deep OpenAI-compat endpoints (`/openai/chat/completions`,
@@ -242,27 +238,6 @@ fn matches_structured_gemini_models_path(path: &str) -> bool {
false
}
/// Vertex AI endpoint paths include project/location/publisher routing before
/// `models/*:generateContent`; in full-URL mode that routing is user-provided
/// and must not be collapsed into the public Gemini `/v1beta/models/*` shape.
fn matches_vertex_ai_publisher_model_path(path: &str) -> bool {
let Some(projects_index) = path.find("/projects/") else {
return false;
};
let Some(publisher_models_index) = path.find("/publishers/google/models/") else {
return false;
};
if projects_index >= publisher_models_index
|| !path[projects_index..publisher_models_index].contains("/locations/")
{
return false;
}
let after_model = &path[publisher_models_index + "/publishers/google/models/".len()..];
after_model.contains(":generateContent") || after_model.contains(":streamGenerateContent")
}
fn merge_queries(base_query: Option<&str>, endpoint_query: Option<&str>) -> Option<String> {
let parts: Vec<&str> = [base_query, endpoint_query]
.into_iter()
@@ -359,20 +334,6 @@ mod tests {
assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse");
}
#[test]
fn preserves_cloudflare_vertex_ai_full_url_with_action() {
let url = resolve_gemini_native_url(
"https://gateway.ai.cloudflare.com/v1/account/gateway/google-vertex-ai/v1/projects/project/locations/us-central1/publishers/google/models/gemini-3.1-pro-preview:streamGenerateContent",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(
url,
"https://gateway.ai.cloudflare.com/v1/account/gateway/google-vertex-ai/v1/projects/project/locations/us-central1/publishers/google/models/gemini-3.1-pro-preview:streamGenerateContent?alt=sse"
);
}
#[test]
fn preserves_opaque_full_url_containing_models_segment() {
let url = resolve_gemini_native_url(
-32
View File
@@ -14,12 +14,6 @@ pub type ResponseUsageParser = fn(&Value) -> Option<TokenUsage>;
/// 参数: (流式事件列表, 请求中的模型名称) -> 最终使用的模型名称
pub type StreamModelExtractor = fn(&[Value], &str) -> String;
/// 流式 usage 事件预过滤器类型别名。
///
/// 参数是 SSE `data:` 原始字符串。返回 false 时跳过 JSON parse,避免在
/// token/chunk 高频路径上解析与 usage 无关的事件。
pub type StreamUsageEventFilter = fn(&str) -> bool;
/// 各 API 的使用量解析配置
#[derive(Clone, Copy)]
pub struct UsageParserConfig {
@@ -29,32 +23,10 @@ pub struct UsageParserConfig {
pub response_parser: ResponseUsageParser,
/// 流式响应中的模型提取器
pub model_extractor: StreamModelExtractor,
/// 流式 usage 事件预过滤器
pub stream_event_filter: Option<StreamUsageEventFilter>,
/// 应用类型字符串(用于日志记录)
pub app_type_str: &'static str,
}
// ============================================================================
// 流式 usage 事件预过滤
// ============================================================================
pub fn claude_stream_usage_event_filter(data: &str) -> bool {
data.contains("\"message_start\"") || data.contains("\"message_delta\"")
}
fn openai_stream_usage_event_filter(data: &str) -> bool {
data.contains("\"usage\"")
}
fn codex_stream_usage_event_filter(data: &str) -> bool {
data.contains("\"response.completed\"") || data.contains("\"usage\"")
}
fn gemini_stream_usage_event_filter(data: &str) -> bool {
data.contains("\"usageMetadata\"")
}
// ============================================================================
// 模型提取器实现
// ============================================================================
@@ -132,7 +104,6 @@ pub const CLAUDE_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_claude_stream_events,
response_parser: TokenUsage::from_claude_response,
model_extractor: claude_model_extractor,
stream_event_filter: Some(claude_stream_usage_event_filter),
app_type_str: "claude",
};
@@ -141,7 +112,6 @@ pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_openai_stream_events,
response_parser: TokenUsage::from_openai_response,
model_extractor: openai_model_extractor,
stream_event_filter: Some(openai_stream_usage_event_filter),
app_type_str: "codex",
};
@@ -150,7 +120,6 @@ pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_codex_stream_events_auto,
response_parser: TokenUsage::from_codex_response_auto,
model_extractor: codex_auto_model_extractor,
stream_event_filter: Some(codex_stream_usage_event_filter),
app_type_str: "codex",
};
@@ -159,7 +128,6 @@ pub const GEMINI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_gemini_stream_chunks,
response_parser: TokenUsage::from_gemini_response,
model_extractor: gemini_model_extractor,
stream_event_filter: Some(gemini_stream_usage_event_filter),
app_type_str: "gemini",
};
-4
View File
@@ -57,8 +57,6 @@ pub struct RequestContext {
pub app_type: AppType,
/// Session ID(从客户端请求提取或新生成)
pub session_id: String,
/// Session ID 是否由客户端提供。生成的 UUID 不能作为上游缓存 key,否则每个请求都会换 key。
pub session_client_provided: bool,
/// 整流器配置
pub rectifier_config: RectifierConfig,
/// 优化器配置
@@ -163,7 +161,6 @@ impl RequestContext {
app_type_str,
app_type,
session_id,
session_client_provided: session_result.client_provided,
rectifier_config,
optimizer_config,
copilot_optimizer_config,
@@ -226,7 +223,6 @@ impl RequestContext {
state.app_handle.clone(),
self.current_provider_id.clone(),
self.session_id.clone(),
self.session_client_provided,
first_byte_timeout,
idle_timeout,
self.rectifier_config.clone(),
+40 -330
View File
@@ -10,8 +10,7 @@
use super::{
error_mapper::{get_error_message, map_proxy_error_to_status},
handler_config::{
claude_stream_usage_event_filter, CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG,
GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
},
handler_context::RequestContext,
providers::{
@@ -23,10 +22,9 @@ use super::{
response_processor::{
create_logged_passthrough_stream, process_response, read_decoded_body,
strip_entity_headers_for_rebuilt_body, strip_hop_by_hop_response_headers,
usage_logging_enabled, SseUsageCollector,
SseUsageCollector,
},
server::ProxyState,
sse::{strip_sse_field, take_sse_block},
types::*,
usage::parser::TokenUsage,
ProxyError,
@@ -70,49 +68,6 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
pub async fn handle_messages(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
handle_messages_for_app(state, request, AppType::Claude, "Claude", "claude", None).await
}
pub async fn handle_claude_desktop_messages(
State(state): State<ProxyState>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
validate_claude_desktop_gateway_auth(&state, request.headers())?;
handle_messages_for_app(
state,
request,
AppType::ClaudeDesktop,
"Claude Desktop",
"claude-desktop",
Some("/claude-desktop"),
)
.await
}
pub async fn handle_claude_desktop_models(
State(state): State<ProxyState>,
headers: axum::http::HeaderMap,
) -> Result<Json<Value>, ProxyError> {
validate_claude_desktop_gateway_auth(&state, &headers)?;
let providers = state
.provider_router
.select_providers("claude-desktop")
.await
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
let provider = providers.first().ok_or(ProxyError::NoAvailableProvider)?;
let response = crate::claude_desktop_config::model_list_response(provider)
.map_err(|e| ProxyError::ConfigError(e.to_string()))?;
Ok(Json(response))
}
async fn handle_messages_for_app(
state: ProxyState,
request: axum::extract::Request,
app_type: AppType,
tag: &'static str,
app_type_str: &'static str,
strip_prefix: Option<&'static str>,
) -> Result<axum::response::Response, ProxyError> {
let (parts, body) = request.into_parts();
let uri = parts.uri;
@@ -127,15 +82,12 @@ async fn handle_messages_for_app(
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
let mut ctx =
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
let raw_endpoint = uri
let endpoint = uri
.path_and_query()
.map(|path_and_query| path_and_query.as_str())
.unwrap_or(uri.path());
let endpoint = strip_prefix
.and_then(|prefix| raw_endpoint.strip_prefix(prefix))
.unwrap_or(raw_endpoint);
let is_stream = body
.get("stream")
@@ -146,7 +98,7 @@ async fn handle_messages_for_app(
let forwarder = ctx.create_forwarder(&state);
let result = match forwarder
.forward_with_retry(
&app_type,
&AppType::Claude,
endpoint,
body.clone(),
headers,
@@ -174,7 +126,7 @@ async fn handle_messages_for_app(
let response = result.response;
// 检查是否需要格式转换(OpenRouter 等中转服务)
let adapter = get_adapter(&app_type);
let adapter = get_adapter(&AppType::Claude);
let needs_transform = adapter.needs_transform(&ctx.provider);
// Claude 特有:格式转换处理
@@ -187,33 +139,6 @@ async fn handle_messages_for_app(
process_response(response, &ctx, &state, &CLAUDE_PARSER_CONFIG).await
}
fn validate_claude_desktop_gateway_auth(
state: &ProxyState,
headers: &axum::http::HeaderMap,
) -> Result<(), ProxyError> {
let expected = crate::claude_desktop_config::get_or_create_gateway_token(state.db.as_ref())
.map_err(|e| ProxyError::AuthError(e.to_string()))?;
let Some(value) = headers.get(axum::http::header::AUTHORIZATION) else {
return Err(ProxyError::AuthError(
"Claude Desktop gateway 缺少 Authorization 头".to_string(),
));
};
let value = value
.to_str()
.map_err(|_| ProxyError::AuthError("Authorization 头格式无效".to_string()))?;
let token = value
.strip_prefix("Bearer ")
.or_else(|| value.strip_prefix("bearer "))
.unwrap_or("")
.trim();
if token != expected {
return Err(ProxyError::AuthError(
"Claude Desktop gateway token 无效".to_string(),
));
}
Ok(())
}
/// Claude 格式转换处理(独有逻辑)
///
/// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换
@@ -226,33 +151,10 @@ async fn handle_claude_transform(
api_format: &str,
) -> Result<axum::response::Response, ProxyError> {
let status = response.status();
let is_codex_oauth = ctx
.provider
.meta
.as_ref()
.and_then(|meta| meta.provider_type.as_deref())
== Some("codex_oauth");
// Codex OAuth 会把 openai_responses 响应强制升级为 SSE,即使客户端发的是 stream:false。
// should_use_claude_transform_streaming 默认会把这个组合路由到流式转换器——虽然能避免
// JSON parse 报 422,但会让非流客户端收到 text/event-stream,违反 Anthropic 非流语义。
// 这里为这个特定组合打开 override:把上游 SSE 聚合成 Anthropic JSON 回给客户端,其它
// 场景(任意上游 is_sse、非 Codex OAuth 等)仍沿用原有流式兜底。
let aggregate_codex_oauth_responses_sse =
!is_stream && is_codex_oauth && api_format == "openai_responses";
let use_streaming = if aggregate_codex_oauth_responses_sse {
false
} else {
should_use_claude_transform_streaming(
is_stream,
response.is_sse(),
api_format,
is_codex_oauth,
)
};
let tool_schema_hints = transform_gemini::extract_anthropic_tool_schema_hints(original_body);
let tool_schema_hints = (!tool_schema_hints.is_empty()).then_some(tool_schema_hints);
if use_streaming {
if is_stream {
// 根据 api_format 选择流式转换器
let stream = response.bytes_stream();
let sse_stream: Box<
@@ -271,49 +173,40 @@ async fn handle_claude_transform(
Box::new(Box::pin(create_anthropic_sse_stream(stream)))
};
// 创建使用量收集器;关闭 usage logging 时不要再解析转换后的 SSE。
let usage_collector = if usage_logging_enabled(state) {
// 创建使用量收集器
let usage_collector = {
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let model = ctx.request_model.clone();
let status_code = status.as_u16();
let start_time = ctx.start_time;
let session_id = ctx.session_id.clone();
Some(SseUsageCollector::new(
start_time,
Some(claude_stream_usage_event_filter),
move |events, first_token_ms| {
if let Some(usage) = TokenUsage::from_claude_stream_events(&events) {
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let model = model.clone();
let session_id = session_id.clone();
SseUsageCollector::new(start_time, move |events, first_token_ms| {
if let Some(usage) = TokenUsage::from_claude_stream_events(&events) {
let latency_ms = start_time.elapsed().as_millis() as u64;
let state = state.clone();
let provider_id = provider_id.clone();
let model = model.clone();
tokio::spawn(async move {
log_usage(
&state,
&provider_id,
"claude",
&model,
&model,
usage,
latency_ms,
first_token_ms,
true,
status_code,
Some(session_id),
)
.await;
});
} else {
log::debug!("[Claude] OpenRouter 流式响应缺少 usage 统计,跳过消费记录");
}
},
))
} else {
None
tokio::spawn(async move {
log_usage(
&state,
&provider_id,
"claude",
&model,
&model,
usage,
latency_ms,
first_token_ms,
true,
status_code,
)
.await;
});
} else {
log::debug!("[Claude] OpenRouter 流式响应缺少 usage 统计,跳过消费记录");
}
})
};
// 获取流式超时配置
@@ -322,7 +215,7 @@ async fn handle_claude_transform(
let logged_stream = create_logged_passthrough_stream(
sse_stream,
"Claude/OpenRouter",
usage_collector,
Some(usage_collector),
timeout_config,
);
@@ -352,14 +245,10 @@ async fn handle_claude_transform(
let body_str = String::from_utf8_lossy(&body_bytes);
let upstream_response: Value = if aggregate_codex_oauth_responses_sse {
responses_sse_to_response_value(&body_str)?
} else {
serde_json::from_slice(&body_bytes).map_err(|e| {
log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}");
ProxyError::TransformError(format!("Failed to parse upstream response: {e}"))
})?
};
let upstream_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}");
ProxyError::TransformError(format!("Failed to parse upstream response: {e}"))
})?;
// 根据 api_format 选择非流式转换器
let anthropic_response = if api_format == "openai_responses" {
@@ -393,7 +282,6 @@ async fn handle_claude_transform(
let state = state.clone();
let provider_id = ctx.provider.id.clone();
let model = model.to_string();
let session_id = ctx.session_id.clone();
async move {
log_usage(
&state,
@@ -406,7 +294,6 @@ async fn handle_claude_transform(
None,
false,
status.as_u16(),
Some(session_id),
)
.await;
}
@@ -674,87 +561,6 @@ pub async fn handle_gemini(
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
}
fn should_use_claude_transform_streaming(
requested_streaming: bool,
upstream_is_sse: bool,
api_format: &str,
is_codex_oauth: bool,
) -> bool {
requested_streaming || upstream_is_sse || (is_codex_oauth && api_format == "openai_responses")
}
/// 把 OpenAI Responses SSE 流聚合成一个完整的 Responses JSON 对象,供下游转成 Anthropic
/// 非流响应。仅在 Codex OAuth 把 `stream:false` 强制升级为 SSE 的场景下调用。
///
/// 复用 `proxy::sse` 的 `take_sse_block`/`strip_sse_field``take_sse_block` 同时支持
/// `\n\n` 与 `\r\n\r\n` 两种分隔符,`strip_sse_field` 兼容带/不带空格的字段写法。
fn responses_sse_to_response_value(body: &str) -> Result<Value, ProxyError> {
let mut buffer = body.to_string();
let mut completed_response: Option<Value> = None;
let mut output_items = Vec::new();
while let Some(block) = take_sse_block(&mut buffer) {
let mut event_name = "";
let mut data_lines: Vec<&str> = Vec::new();
for line in block.lines() {
if let Some(evt) = strip_sse_field(line, "event") {
event_name = evt.trim();
} else if let Some(d) = strip_sse_field(line, "data") {
data_lines.push(d);
}
}
if data_lines.is_empty() {
continue;
}
let data_str = data_lines.join("\n");
if data_str.trim() == "[DONE]" {
continue;
}
let data: Value = serde_json::from_str(&data_str).map_err(|e| {
ProxyError::TransformError(format!("Failed to parse upstream SSE event: {e}"))
})?;
match event_name {
"response.output_item.done" => {
if let Some(item) = data.get("item") {
output_items.push(item.clone());
}
}
"response.completed" => {
completed_response = Some(data.get("response").cloned().unwrap_or(data));
}
"response.failed" => {
let message = data
.pointer("/response/error/message")
.and_then(|v| v.as_str())
.unwrap_or("response.failed event received");
return Err(ProxyError::TransformError(message.to_string()));
}
_ => {}
}
}
let mut response = completed_response.ok_or_else(|| {
ProxyError::TransformError("No response.completed event in upstream SSE".to_string())
})?;
if !output_items.is_empty() {
if let Some(obj) = response.as_object_mut() {
obj.insert("output".to_string(), Value::Array(output_items));
} else {
return Err(ProxyError::TransformError(
"response.completed payload is not an object".to_string(),
));
}
}
Ok(response)
}
// ============================================================================
// 使用量记录(保留用于 Claude 转换逻辑)
// ============================================================================
@@ -801,14 +607,9 @@ async fn log_usage(
first_token_ms: Option<u64>,
is_streaming: bool,
status_code: u16,
session_id: Option<String>,
) {
use super::usage::logger::UsageLogger;
if !usage_logging_enabled(state) {
return;
}
let logger = UsageLogger::new(&state.db);
let (multiplier, pricing_model_source) =
@@ -833,101 +634,10 @@ async fn log_usage(
latency_ms,
first_token_ms,
status_code,
session_id,
None,
None, // provider_type
is_streaming,
) {
log::warn!("[USG-001] 记录使用量失败: {e}");
}
}
#[cfg(test)]
mod tests {
use super::{responses_sse_to_response_value, should_use_claude_transform_streaming};
use crate::proxy::ProxyError;
#[test]
fn codex_oauth_responses_force_streaming_even_if_client_sent_false() {
assert!(should_use_claude_transform_streaming(
false,
false,
"openai_responses",
true,
));
}
#[test]
fn upstream_sse_response_always_uses_streaming_path() {
assert!(should_use_claude_transform_streaming(
false,
true,
"openai_chat",
false,
));
}
#[test]
fn non_streaming_response_stays_non_streaming_for_regular_openai_responses() {
assert!(!should_use_claude_transform_streaming(
false,
false,
"openai_responses",
false,
));
}
#[test]
fn responses_sse_to_response_value_collects_output_items() {
let sse = r#"event: response.output_item.done
data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}}
event: response.completed
data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":10,"output_tokens":2}}}
"#;
let response = responses_sse_to_response_value(sse).unwrap();
assert_eq!(response["id"], "resp_1");
assert_eq!(response["output"][0]["type"], "message");
assert_eq!(response["output"][0]["content"][0]["text"], "hello");
}
#[test]
fn responses_sse_to_response_value_handles_crlf_delimiters() {
// 真实 HTTP SSE 按规范使用 \r\n\r\n 分隔事件;take_sse_block 必须同时处理两种分隔符,
// 否则此路径在任何标准上游(含 Codex OAuth HTTPS 后端)下都会 TransformError。
let sse = "event: response.output_item.done\r\n\
data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"hi\"}]}}\r\n\
\r\n\
event: response.completed\r\n\
data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_crlf\",\"status\":\"completed\",\"model\":\"gpt-5.4\",\"output\":[],\"usage\":{\"input_tokens\":5,\"output_tokens\":1}}}\r\n\
\r\n";
let response = responses_sse_to_response_value(sse).unwrap();
assert_eq!(response["id"], "resp_crlf");
assert_eq!(response["output"][0]["type"], "message");
assert_eq!(response["output"][0]["content"][0]["text"], "hi");
}
#[test]
fn responses_sse_to_response_value_returns_err_on_response_failed() {
let sse = "event: response.failed\n\
data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"message\":\"upstream blew up\"}}}\n\n";
let err = responses_sse_to_response_value(sse).unwrap_err();
match err {
ProxyError::TransformError(msg) => assert!(msg.contains("upstream blew up")),
other => panic!("expected TransformError, got {other:?}"),
}
}
#[test]
fn responses_sse_to_response_value_errors_when_no_completed_event() {
let sse = "event: response.output_item.done\n\
data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\"}}\n\n";
assert!(responses_sse_to_response_value(sse).is_err());
}
}

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