Compare commits

..

85 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
Jason Young 507bf038a9 feat(stream-check): refresh default models and detect model-not-found errors (#2099)
* chore(stream-check): update default health check models to latest

Replaces deprecated gpt-5.1-codex@low with gpt-5.4@low and switches
the Gemini default from gemini-3-pro-preview to gemini-3-flash-preview
to pick the lightest variant of the latest series for fast, low-cost
health checks.

https://claude.ai/code/session_01NGWLchcTP76rJHjiP5Ehte

* feat(stream-check): detect model-not-found errors with dedicated toast

Health check previously classified failures purely by HTTP status code,
which meant deprecated/invalid models showed up as a generic "Not found
(404)" error pointing users to check the Base URL — misleading when the
URL is fine and only the test model is wrong (e.g. gpt-5.1-codex after
it was retired).

Backend: add detect_error_category() that inspects 4xx response bodies
for model-not-found indicators (model_not_found, does not exist,
invalid model, not_found_error, etc.) and returns a "modelNotFound"
category. Thread the resolved test model through build_stream_check_result
so the failed result carries it in model_used. Add StreamCheckResult
.error_category field (serde-skipped when None).

Frontend: useStreamCheck branches on errorCategory === "modelNotFound"
before the HTTP-status fallback and renders a toast.error with the model
name and a description pointing to Model Test Config. Add i18n keys
(modelNotFound / modelNotFoundHint) for zh/en/ja.

Tests: unit-test detect_error_category against real OpenAI/Anthropic
error shapes, 5xx false-positive avoidance, and plain 401 auth errors.

https://claude.ai/code/session_01NGWLchcTP76rJHjiP5Ehte

* fix(stream-check): add missing error_category field in fallback

The error_category field was added to StreamCheckResult in this branch
but the fallback constructor in stream_check_all_providers was not
updated, which broke cargo build.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-15 15:25:32 +08:00
Dex Miller ef41e4da46 fix(proxy): strip hop-by-hop response headers per RFC 7230 (#2060) 2026-04-15 11:28:56 +08:00
wwminger 78198e262b fix(opencode): use json5 parser for trailing comma tolerance (#2023)
* fix(opencode): use json5 parser for trailing comma tolerance

OpenCode CLI writes opencode.json with trailing commas (valid JSONC),
but CC Switch parsed it with serde_json (strict JSON), causing errors
like 'trailing comma at line 35 column 3'.

Switch to json5::from_str which accepts both JSON and JSONC. The json5
crate is already a project dependency. Change error type from
AppError::json() to AppError::Config() since json5::Error differs from
serde_json::Error.

* style(opencode): apply rustfmt to satisfy cargo fmt --check

The previous commit's .map_err(...) chain exceeded rustfmt's default
100-char max_width, breaking CI's `cargo fmt --check`. Let rustfmt
wrap the closure body as a multi-line block. No behavior change.

---------

Co-authored-by: 18067889926 <ming.flute@outlook.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-04-15 11:11:48 +08:00
Jason 79eb773195 fix: remove unused mut to pass clippy -D warnings 2026-04-15 09:16:22 +08:00
Jason 6092a87b40 fix: preserve env vars when saving Google Official Gemini provider (#2087)
write_gemini_live() unconditionally cleared env_map for GoogleOfficial
auth type, discarding user-configured env vars (e.g. GEMINI_MODEL).
Remove the env_map.clear() call so the user's settings_config.env is
written as-is, and merge identical Packycode/Generic match arms.
2026-04-15 09:05:45 +08:00
Jason 689ca08409 feat: classify stream check errors with color-coded toasts
Distinguish between "provider rejects probe" (yellow warning) and
"genuinely broken" (red error) in health check results.

Backend: add AppError::HttpStatus variant to carry structured HTTP
status codes, populate http_status on error results, classify codes
into short labels (e.g. "Auth rejected (401)"), and truncate overly
long response bodies.

Frontend: route 401/403/400/429/5xx to toast.warning with localized
hints explaining the error may not indicate actual unusability; route
404/402/connection errors to toast.error. Add i18n keys for all three
locales (zh/en/ja).

Also deduplicate check_once by reusing build_stream_check_result.
2026-04-14 17:11:13 +08:00
Jason 8b851dc602 fix: auto-expand collapsed messages when search matches hidden content
When a search query matches text beyond the collapse point, the message
automatically expands to show the highlighted match. Also adds
aria-expanded for accessibility.
2026-04-14 16:50:22 +08:00
Jason 0383c13e66 perf: collapse long session messages to reduce text layout cost
Messages over 3000 characters are now truncated to 1500 characters by
default, with an expand/collapse toggle. This avoids expensive browser
text layout for large AI responses containing code or tool output.
2026-04-14 16:16:57 +08:00
Jason 7ae89e9106 perf: virtualize session message list for long conversations
Replace full DOM rendering with @tanstack/react-virtual to only render
visible messages (~25 DOM nodes instead of N). Wrap SessionMessageItem
in React.memo to prevent unnecessary re-renders on state changes.
2026-04-14 16:12:48 +08:00
Jason 04508801ef fix: handle root-level skill repos during installation
When a repo itself is a single skill (SKILL.md at repo root), the
discovery phase sets directory to the repo name, but after ZIP
extraction (which strips the root folder), no matching subdirectory
exists. Add a fallback to check if SKILL.md exists directly in the
extracted temp directory before reporting SKILL_DIR_NOT_FOUND.

Fixes installation of repos like zlbigger/Google-SEOs.skill.
2026-04-14 15:56:31 +08:00
Jason 57343432da fix: remove duplicate usage summary from app filter bar
The request count and total cost were displayed both in the app filter
bar and in the UsageSummaryCards below it. Remove the redundant inline
summary and its unused imports.
2026-04-14 15:47:55 +08:00
Jason 5e412d2c30 refactor: remove per-provider proxy config feature
Replace all remaining "代理服务/Proxy Service/プロキシサービス" references
in the local routing feature context with "路由服务/Routing Service/
ルーティングサービス". This covers service settings, status messages,
tooltips, field descriptions, and tab labels.

Global Proxy, HTTP proxy hints, and AI Agent references are unchanged.
2026-04-14 15:39:23 +08:00
Jason 36f2d6cccb docs: clarify global proxy hint about local routing across all locales 2026-04-14 15:26:08 +08:00
Jason 989c445828 docs: rename takeover docs to routing across all languages
Rename 4.2-takeover.md to 4.2-routing.md in zh/en/ja user manuals,
replacing all "接管/takeover" terminology with "路由/routing" to match
the rebranded feature name. Update README index links accordingly.
2026-04-14 15:15:39 +08:00
Jason 7ec92c32a5 rename: rebrand "Local Proxy Takeover" to "Local Routing" in all locales
Replace all user-facing references to "本地代理接管/Local Proxy/Takeover"
with "本地路由/Local Routing/ローカルルーティング" across zh/en/ja to
eliminate naming confusion with the separate "Global Proxy" feature.

Only i18n string values are changed; keys, code identifiers, and
database schema remain untouched.
2026-04-14 15:13:59 +08:00
Jason 4a0b5c3dec refactor: remove per-provider proxy config feature
The per-provider proxy configuration (meta.proxyConfig) is removed
because its scope is too narrow and covered by global proxy settings
and proxy takeover mode. Users can achieve the same result via the
global proxy panel.

Changes:
- Remove ProviderProxyConfig type (frontend TS + backend Rust)
- Remove ProviderAdvancedConfig proxy UI block, keep testConfig/pricingConfig
- Simplify http_client: delete build_proxy_url_from_config,
  build_client_for_provider, get_for_provider
- Simplify forwarder/stream_check/model_fetch to use global client
- Remove i18n keys (en/zh/ja)
- Fix pre-existing test bug in transform.rs (extra None arg)
2026-04-14 14:26:55 +08:00
Jason 449a171238 Add LemonData sponsor and update partner logo formats
Add new sponsor LemonData to partner section. Update Crazyrouter logo
from JPG to PNG format.
2026-04-14 11:15:25 +08:00
Jason c60f204808 Update partner logos and sync across languages
Synchronize Crazyrouter logo format and partner section updates across
EN/JA/ZH README files.
2026-04-14 10:58:36 +08:00
Jason d13a8d7353 fix(clippy): remove redundant closure in session ID parsing
Replace `|uid| parse_session_from_user_id(uid)` with direct
function reference to satisfy clippy::redundant_closure.
2026-04-14 10:34:58 +08:00
Jason 0739b60341 fix(proxy): reduce unnecessary Copilot premium interaction consumption
- Fix request classification: treat messages containing tool_result as
  agent continuation instead of user-initiated, preventing false premium
  charges on every tool call
- Add subagent detection via __SUBAGENT_MARKER__ and metadata._agent_
  fallback, setting x-interaction-type=conversation-subagent
- Add deterministic x-interaction-id derived from session ID to group
  requests into a single billing interaction
- Add orphan tool_result sanitization to prevent upstream API errors
  that could cause retries and duplicate billing
- Reorder pipeline: classify (on original body) → sanitize → merge →
  warmup, ensuring classification sees raw tool_result semantics
- Enable warmup downgrade by default with gpt-5-mini model
- Enhance session ID extraction priority chain for Copilot cache keys
- Detect infinite whitespace bug in streaming tool call arguments
2026-04-14 10:34:58 +08:00
Jason c01338ac33 fix(usage): remove unnecessary private IP restrictions from usage script
SSRF protection (private IP blocking, suspicious hostname detection) was
originally added for web-server threat models but is unnecessary for a
local desktop app where the user already has full network access. This
removal unblocks legitimate use cases like enterprise intranet APIs,
Docker container addresses, and self-hosted services.

Retained: HTTPS enforcement and same-origin checks which still provide
meaningful security (protecting API keys in transit and preventing
scripts from leaking keys to unrelated domains).
2026-04-14 10:33:41 +08:00
Jason 8c94c23c3c chore: update PIPELLM website URL to code.pipellm.ai 2026-04-14 10:33:41 +08:00
Jason d6fa611833 chore: remove X-Code API (XCodeAPI) provider preset and sponsorship 2026-04-14 10:33:41 +08:00
Jason 5c1b457520 fix(usage): sync request log time range with dashboard 1d/7d/30d selector
The RequestLogTable had a hardcoded 24-hour rolling window, ignoring the
dashboard's time range selector. Now it accepts a timeRange prop and
dynamically adjusts the query window, so users can view logs beyond just
the last day.
2026-04-14 10:33:41 +08:00
Jason 3c8f9d1287 feat(usage): improve pagination with first/last 3 pages and page jump input
Show first 3 and last 3 page buttons instead of just first/last, with
Set-based deduplication for clean edge merging. Add a page number input
field with Go button for direct page navigation.
2026-04-14 10:33:41 +08:00
Jason 420f4c8c23 fix(sessions): strip OpenClaw message_id suffix and allow 2-line titles
OpenClaw gateway injects `[message_id: UUID]` metadata at the end of
every message, wasting display space. Strip this suffix from both title
and summary fields.

Also change session title display from single-line truncate to
line-clamp-2, so longer titles (e.g. OpenClaw's timestamp-prefixed
messages) can show more meaningful content across two lines.
2026-04-14 10:33:41 +08:00
Jason ed269cc20e feat(sessions): extract meaningful titles for Codex and OpenClaw sessions
Previously Codex and OpenClaw sessions only showed the working directory
basename as the title, making it hard to distinguish sessions in the same
project. Now both providers extract the first real user message as the
session title, matching the existing Claude Code behavior.

- Codex: first user message → dir basename (skips AGENTS.md injection)
- OpenClaw: displayName (sessions.json) → first user message → dir basename
- Move TITLE_MAX_CHARS constant to shared utils.rs
- Use Option<&HashMap> for OpenClaw parse_session to avoid leaky abstraction
2026-04-14 10:33:41 +08:00
Jason 8669b408e9 fix(usage): deduplicate proxy and session log usage records
Extract message_id from Claude API responses (msg_xxx) and use it to
generate a shared request_id format (session:{msg_xxx}) between the
proxy logger and session log sync. When session sync encounters the
same request_id via INSERT OR IGNORE, it skips the duplicate.

- Add message_id field to TokenUsage, extracted from Claude responses
- Add TokenUsage::dedup_request_id() to generate shared request IDs
- Define SESSION_REQUEST_ID_PREFIX constant to eliminate magic strings
- Change proxy logger to INSERT OR REPLACE for richer-data-wins semantics
2026-04-14 10:33:41 +08:00
Jason bb7c83c214 feat(pricing): add ~50 new model pricing entries and fix outdated prices
Add pricing data for 4 new providers (Qwen, xAI Grok, Mistral, Cohere)
and supplement existing providers (MiniMax M2.5/M2.7, GLM-5/5.1,
Doubao Seed 2.0, MiMo V2 Pro, OpenAI o1/o3/codex-mini/gpt-5-mini/nano).

Fix outdated prices for deepseek-chat, deepseek-reasoner, and kimi-k2.5.
Fix display_name casing "Mimo" → "MiMo" for consistency.

Use prepared statement in seed_model_pricing() to avoid recompiling SQL
on each of ~130 INSERT iterations.

Schema migration v8→v9: DELETE + re-seed model_pricing for existing users.
2026-04-14 10:33:41 +08:00
Jason a514f27937 feat: block official provider switching during proxy takeover
Prevent users from switching to official providers (Anthropic/OpenAI/Google)
when proxy takeover is active, as using a proxy with official APIs may cause
account bans.

Defense-in-depth across 4 layers:
- Backend: ProviderService::switch(), hot_switch_provider(), switch_proxy_provider command
- Frontend: useProviderActions soft guard with error toast
- UI: ProviderActions button disabled with ShieldAlert icon
- Tray menu: official provider items disabled with  indicator

Also warns when enabling proxy takeover while current provider is official.
2026-04-14 10:33:41 +08:00
zerone0x 2937eb6766 fix(proxy): remove permissive CORS layer (#1915) 2026-04-13 12:26:19 +08:00
Dex Miller 313a6e3f6c [codex] Preserve cache_control when merging system prompts (#1946)
* Preserve cache hints when collapsing system prompts

Strict OpenAI-compatible chat backends still need fragmented Claude\nsystem prompts collapsed into one leading system message, but that\nnormalization should not silently drop stable cache hints. Preserve\nmessage-level cache_control when the merged system fragments agree,\nand fall back to omitting it when the fragments conflict.\n\nConstraint: Must keep single-system normalization for Nvidia/Qwen-style chat backends\nRejected: Always copy the first cache_control | could misrepresent conflicting cache boundaries\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: If system prompt merging changes again, preserve cache_control whenever the merged metadata is unambiguous\nTested: cargo test proxy::providers::transform --manifest-path src-tauri/Cargo.toml\nNot-tested: End-to-end prompt caching behavior against cache-aware OpenAI-compatible upstreams\nRelated: #1881

* Tighten cache hint inheritance for merged system prompts

The follow-up cache hint fix still treated mixed present/absent\ncache_control across fragmented system prompts as inheritable, which\nexpanded the cache scope after prompt collapse. Treat that mix as\nambiguous and only preserve cache_control when every merged fragment\nexplicitly agrees on the same value.\n\nConstraint: Must preserve strict-backend system prompt normalization from #1942\nRejected: Inherit first present cache_control | widens cache scope when later fragments were intentionally uncached\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Any future merged-system cache hint logic should treat missing cache_control as semantically significant\nTested: cargo test proxy::providers::transform --manifest-path src-tauri/Cargo.toml\nNot-tested: End-to-end upstream caching behavior against cache-aware relays\nRelated: #1881\nRelated: #1946

* Keep cache-control merge regressions easy to review

Reflow the two long cache-control regression assertions in transform.rs so the neighboring merge cases stay rustfmt-aligned and easier to scan.

This keeps the preserved code change separate from the untracked Markdown design notes the user did not want committed.

Constraint: Exclude Markdown design files from the commit while preserving the local code change
Rejected: Include docs in the same commit | user explicitly asked to leave Markdown files out
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Treat this as a readability-only test change; do not infer runtime behavior changes from it
Tested: cargo test --manifest-path src-tauri/Cargo.toml test_anthropic_to_openai_drops_ --lib
Tested: cargo check --manifest-path src-tauri/Cargo.toml --tests
Tested: pnpm format:check
Tested: pnpm typecheck
Not-tested: Full application integration and manual flows
2026-04-13 10:42:29 +08:00
Dex Miller 5566be2b4b Stop sending prompt cache keys on Claude chat conversions (#2003)
Responses conversions still use promptCacheKey, but chat completions now stay a pure shape transform. This keeps Claude -> chat requests aligned with providers that do not understand the field and keeps stream checks consistent with production behavior.

Constraint: Issue #1919 requires removing prompt_cache_key from Claude -> OpenAI Chat requests
Rejected: Add a runtime toggle for chat injection | requested behavior is unconditional removal
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep promptCacheKey limited to Claude -> Responses conversions unless a provider-specific contract is proven
Tested: cargo test anthropic_to_openai
Tested: cargo test anthropic_to_responses_with_cache_key
Tested: cargo test transform_claude_request_for_api_format_responses
Not-tested: Full src-tauri test suite
Related: #1919
2026-04-13 10:22:55 +08:00
v2v cfcf9452d0 添加应用级别窗口按钮,以改善linux wayland下系统窗口按钮失效的问题 (#1119)
* feat(window): add app-level window controls with settings toggle

Add a persistent settings toggle to enable app-level minimize/maximize/close controls and hide system decorations when enabled, providing a Wayland-friendly fallback for broken native titlebar interactions.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(window): restrict app-level window controls to Linux only and fix startup flicker

- Guard useAppWindowControls with isLinux() in App.tsx so it's always
  false on macOS/Windows even if persisted as true
- Wrap set_decorations call in lib.rs with #[cfg(target_os = "linux")]
- Only show the toggle in WindowSettings on Linux
- Skip setDecorations effect while settingsData is still loading to
  prevent the Rust-side decoration state from being overridden by the
  undefined->false fallback, which caused a brief title bar flicker

---------

Co-authored-by: wzk <wx13571681304@outlook.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-04-12 20:59:04 +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
Jason df01755328 docs(release-notes): sync user edits across en/zh/ja and add PR credits
- Simplify "ChatGPT Plus / Pro" → "ChatGPT" across all three languages
- Clarify Codex OAuth description to highlight Claude Code usage
- Add "requires manual activation" note for Token Plan and third-party balances
- Add Copilot API consumption caveat to the interaction optimizer section
- Update overview with skills.sh search, usage tracking, and onboarding mentions
- Add PR credits for TheRouter (@cmzz), Kaku/OMO Slim/Thinking fallback/auth tab (@yovinchen)
2026-04-10 23:44:06 +08:00
Jason 74b9f52d90 chore(release): bump version to v3.13.0 and sync changelog/release notes
Backfill post-draft changes into CHANGELOG and three-language release
notes (en/zh/ja): 16 new Added entries, 6 Fixed entries, 1 Docs entry,
and updated header stats (139 commits, 280 files, +31627/-3042).
2026-04-10 23:20:57 +08:00
Jason 8838317087 chore: simplify Codex provider preset name 2026-04-10 22:40:29 +08:00
Jason 4d570691e1 chore: add missing Shengsuanyun icon SVG file 2026-04-10 22:40:29 +08:00
Jason 337224546c feat: update PIPELLM preset with full config, add Codex support and icon
Update base URL to cc-api.pipellm.ai, add complete model definitions
(Opus/Sonnet/Haiku), add Codex preset with custom TOML config, add
pipellm PNG icon, and set apiKeyUrl to referral link.
2026-04-10 22:40:29 +08:00
Jason 7d21663e6d feat: add E-FlowCode provider preset across all five apps
Multi-protocol provider: Anthropic for Claude, OpenAI Responses for
Codex/OpenClaw, OpenAI for OpenCode, custom Gemini config. Includes
PNG icon via URL import and provider-specific settings like effortLevel,
enabledPlugins, and custom TOML config for Codex.
2026-04-10 22:40:29 +08:00
Jason 926e69b8c9 feat: add PIPELLM provider preset for Claude, OpenCode, and OpenClaw 2026-04-10 22:40:29 +08:00
Jason a03ad4f47f feat: add Shengsuanyun provider preset with partner promotion
Add Shengsuanyun (胜算云) as an aggregator partner across all five apps,
positioned right after official providers. Uses anthropic-messages
protocol for OpenCode/OpenClaw. Includes URL-based icon import (217KB
SVG), partner promotion i18n for zh/en/ja, and localized display name.
2026-04-10 22:40:29 +08:00
Jason c38bef517a fix: capitalize DDSHub provider display name 2026-04-10 22:40:29 +08:00
Jason 7526a031a7 chore: remove generate-icon-index.js to prevent accidental regeneration
The icon index (index.ts) is hand-curated with optimized SVG content
and custom name mappings. Running the generation script destroys these
optimizations. Remove the script entirely to eliminate the risk.
2026-04-10 22:40:29 +08:00
Jason 84bc98cf83 feat: add LionCCAPI provider preset with partner promotion
Add LionCCAPI as a third-party partner provider across all five apps
(Claude, Codex, Gemini, OpenCode, OpenClaw) with anthropic-messages
protocol for OpenCode and OpenClaw. Include partner promotion i18n
entries for zh/en/ja locales and lioncc icon.
2026-04-10 22:40:29 +08:00
Jason af679cda25 fix: map adaptive thinking to xhigh reasoning_effort instead of high
When thinking.type is "adaptive" (Claude's maximum thinking mode) and
output_config.effort is absent, resolve_reasoning_effort() incorrectly
mapped it to "high" instead of "xhigh" in OpenAI format conversions.
2026-04-10 22:40:29 +08:00
Jason a83bd90fa9 feat: replace x-code icon with high-res xcode icon via URL import
Replace the old low-res x-code inline SVG (7.6KB) with a new high-res
xcode.svg (286KB) loaded via Vite URL import. Update all three provider
preset files to reference the new icon name.
2026-04-10 22:40:29 +08:00
Jason 794795cad4 feat: support URL-based icons for large SVGs and raster images
Add dual rendering mode to the icon system: small optimized SVGs
continue to be inlined via dangerouslySetInnerHTML, while large SVGs
and raster images (png/jpg/webp/etc) use Vite URL imports rendered
as <img> tags. Added dds.svg (1.4MB) as the first URL-based icon.

Updated generate-icon-index.js to support multi-format icons with
a manual URL_ICONS control list. Updated ProviderIcon to handle
both inline SVG and URL-based rendering paths.
2026-04-10 22:40:29 +08:00
Jason eff85dc66d feat: add ddshub provider preset with partner promotion
Add ddshub as a third-party partner provider for Claude, including
SVG icon and i18n promotion text in zh/en/ja locales.
2026-04-10 22:40:29 +08:00
Dex Miller 3aef5217cb Restore first-class OMO Slim council support (#1981) (#1982)
cc-switch could already persist arbitrary OMO Slim agent keys and top-level fields, but the built-in metadata and UI copy still reflected the pre-council agent set. This made the upstream council feature look unsupported and pushed users toward manual JSON-only setup.

Promote council to a built-in OMO Slim agent, add copy that points top-level plugin settings at Other Fields, and lock the behavior with regression tests.

Constraint: oh-my-opencode-slim exposes council through both agents.council and top-level council config
Rejected: Add a dedicated council editor UI now | too much surface area for issue #1981
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep OMO Slim built-in agent metadata aligned with upstream agent additions before shipping UI support
Tested: pnpm exec vitest run tests/utils/omoConfig.test.ts tests/components/OmoFormFields.mergeCustomModelsIntoStore.test.ts
Tested: pnpm typecheck
Not-tested: End-to-end validation against a live oh-my-opencode-slim installation
Related: farion1231/cc-switch#1981
2026-04-10 22:36:32 +08:00
Dex Miller e4b58c7206 Let Kaku users launch sessions from their chosen terminal (#1954) (#1983)
Kaku is a WezTerm-derived macOS terminal, so reusing the existing WezTerm-compatible launch path keeps the change small while making it selectable in settings and session resume flows.

Constraint: Kaku support should stay macOS-only and avoid introducing a separate launcher model
Rejected: Treat Kaku as a silent WezTerm fallback | users could not explicitly choose it in settings
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep Kaku on the shared WezTerm-compatible launch path unless upstream drops the start-compatible CLI
Tested: pnpm typecheck; pnpm format:check; cargo check --manifest-path src-tauri/Cargo.toml; cargo fmt --manifest-path src-tauri/Cargo.toml --check; cargo test --manifest-path src-tauri/Cargo.toml --lib session_manager::terminal::tests
Not-tested: End-to-end launch against a locally installed Kaku.app
Related: #1954
2026-04-10 22:35:16 +08:00
Dex Miller 8c32610a7d Align Thinking fallback with main-model-only Claude mappings (#1984)
The Claude provider form reopened with an empty Thinking model after users saved only a main model. This updates model-state hydration to mirror the existing Haiku-style fallback semantics: read ANTHROPIC_REASONING_MODEL when present, otherwise display ANTHROPIC_MODEL, without writing a synthetic reasoning field back into config.

Constraint: Existing Haiku, Sonnet, and Opus selectors already rely on read-time fallback behavior
Rejected: Persist ANTHROPIC_REASONING_MODEL from ANTHROPIC_MODEL automatically | would diverge from Haiku behavior and silently rewrite saved config
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep Thinking fallback read-only unless all model-mapping fields are intentionally migrated to write-through semantics
Tested: pnpm typecheck
Tested: pnpm test:unit (1 unrelated pre-existing failure in tests/components/UnifiedSkillsPanel.test.tsx mock setup)
Not-tested: Manual add-provider reopen flow in the desktop UI
2026-04-10 22:34:18 +08:00
Dex Miller afdda27bd5 Restore auth tab localization in settings (#1985)
The settings page already routes the auth tab label through the shared i18n key, but the locale bundles never defined that key. Adding the missing entries fixes the label with the same simple pattern used by the other tabs.

Constraint: Keep the fix aligned with the existing settings-tab i18n flow
Rejected: Add component-level bilingual rendering | unnecessary for a missing translation key
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: When adding settings tabs, define locale keys in every bundled language before relying on fallback text
Tested: pnpm format:check; pnpm typecheck
Not-tested: Manual verification in the desktop UI
2026-04-10 22:24:25 +08:00
Max 7f1963ab49 feat(provider): add TheRouter presets for Claude, Codex, and Gemini (#1891)
* feat(provider): add TheRouter presets for Claude and Codex

* feat(provider): add TheRouter Gemini preset

---------

Co-authored-by: max <me19@qq.com>
2026-04-10 21:48:14 +08:00
Max 68df60bd4a feat(provider): add TheRouter presets for OpenCode and OpenClaw (#1892)
Co-authored-by: max <me19@qq.com>
2026-04-10 21:47:18 +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
126 changed files with 12334 additions and 2250 deletions
+25 -2
View File
@@ -5,7 +5,7 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [3.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.
@@ -13,7 +13,7 @@ Development since v3.12.3 focuses on quota visibility, provider workflow upgrade
- **Lightweight Mode**: Added a tray-only mode that destroys the main window and keeps CC Switch running from the system tray, with the window recreated when users reopen it.
- **Provider Model Auto-Fetch**: Added OpenAI-compatible `/v1/models` discovery for Claude, Codex, Gemini, OpenCode, and OpenClaw provider forms, including grouped dropdown selection and failure-specific error messages.
- **Quota & Balance Visibility**: Added inline quota or balance display for official Claude / Codex / Gemini providers, GitHub Copilot premium interactions, Codex OAuth providers, Token Plan providers (Kimi / Zhipu GLM / MiniMax), and official balance queries for DeepSeek, StepFun, SiliconFlow, OpenRouter, and Novita AI.
- **Quota & Balance Visibility**: Added inline quota or balance display for official Claude / Codex / Gemini providers, GitHub Copilot premium interactions, Codex OAuth providers, Token Plan providers (Kimi / Zhipu GLM / MiniMax), and official balance queries for DeepSeek, StepFun, SiliconFlow, OpenRouter, and Novita AI. Copilot / ChatGPT OAuth and CLI subscription quota now only auto-poll for the currently active provider, preventing unnecessary API calls and misleading displays on non-current cards.
- **Skills Discovery & Batch Updates**: Added SHA-256 based skill update detection, per-skill and batch update actions, a storage-location toggle between CC Switch and `~/.agents/skills`, and public `skills.sh` search integration.
- **Session Workflow Upgrades**: Added batch delete in Session Manager, a directory picker before launching Claude terminal restore commands, usage import from Claude / Codex / Gemini session logs without requiring proxy interception, and per-app usage filtering for Claude / Codex / Gemini dashboards.
- **Codex OAuth Reverse Proxy**: Added ChatGPT Plus / Pro based Codex OAuth reverse proxy support for Claude provider cards, including managed OAuth login and inline subscription quota display.
@@ -21,6 +21,22 @@ Development since v3.12.3 focuses on quota visibility, provider workflow upgrade
- **Full URL Endpoint Mode**: Added a provider option that treats `base_url` as a complete upstream endpoint so proxy forwarding and stream checks can work with vendors that require nonstandard URL layouts.
- **OpenCode StepFun Step Plan Preset**: Added a StepFun Step Plan provider preset for OpenCode.
- **Copilot Interaction Optimizer**: Added request classification and routing logic to reduce unnecessary GitHub Copilot premium interaction consumption.
- **First-Run Welcome Dialog**: Added a one-time welcome dialog on fresh installs explaining how existing configuration is preserved as a default provider and how the bundled official preset enables one-click revert. Upgrade users are excluded.
- **Official Provider Seeding**: Added automatic seeding of Claude Official, OpenAI Official, and Google Official provider entries on startup, giving every user a one-click path back to the official endpoint.
- **OpenCode / OpenClaw Auto-Import**: Added automatic startup import of live OpenCode and OpenClaw provider configurations, matching the auto-import behavior already present for Claude, Codex, and Gemini.
- **Common Config Editor Guidance**: Added an informational guide and empty-state prompt to the Common Config snippet editor modal for Claude, Codex, and Gemini, with i18n support.
- **Common Config First-Run Notice**: Added a one-time informational dialog explaining Common Config Snippets when users first open the provider add/edit form.
- **Claude Session Titles**: Added meaningful title extraction for Claude sessions using a priority chain: custom-title metadata, first real user message, then directory basename fallback.
- **Session Search Highlighting**: Added keyword highlighting in session titles and messages during Session Manager search.
- **URL-Based Provider Icons**: Added a dual rendering mode to the icon system supporting Vite URL imports for large SVGs and raster images (PNG, JPG, WebP), keeping small SVGs inlined.
- **Kaku Terminal Support**: Added Kaku as a selectable terminal for session launch on macOS, reusing the WezTerm-compatible launch path.
- **OMO Slim Council Support**: Restored first-class council support as a built-in oh-my-opencode-slim agent with updated metadata and UI copy.
- **TheRouter Provider Preset**: Added TheRouter provider presets across Claude, Codex, Gemini, OpenCode, and OpenClaw.
- **DDSHub Provider Preset**: Added DDSHub as a third-party partner provider for Claude with icon and partner promotion text.
- **LionCCAPI Provider Preset**: Added LionCCAPI as a third-party partner provider across all five apps with anthropic-messages protocol for OpenCode and OpenClaw.
- **Shengsuanyun Provider Preset**: Added Shengsuanyun (胜算云) as an aggregator partner provider across all five apps with URL-based icon and localized display name.
- **PIPELLM Provider Preset**: Added PIPELLM provider preset across Claude, Codex, OpenCode, and OpenClaw with full model definitions and icon.
- **E-FlowCode Provider Preset**: Added E-FlowCode provider preset across all five apps with per-app protocol configuration.
### Changed
@@ -47,12 +63,19 @@ Development since v3.12.3 focuses on quota visibility, provider workflow upgrade
- **Linux UI Unresponsive on Startup**: Fixed a bug where the window UI (including native title bar buttons) couldn't receive clicks on Linux until the user manually maximized and restored the window. Root causes: (1) Tauri webview did not acquire keyboard focus after `show()` on Linux, so the first click was consumed by X11/Wayland click-to-activate (Tauri #10746, wry #637); (2) GTK surface's input region failed to renegotiate on the `visible:false → show()` path under some WebKitGTK/compositor combinations, leaving the entire window unresponsive. Mitigations: set `WEBKIT_DISABLE_COMPOSITING_MODE=1` at startup, and added a new `linux_fix::nudge_main_window` helper that performs `set_focus` + a ±1px no-op resize ~200ms after show, equivalent to a visually invisible "maximize-and-restore". Wired into all window-re-show paths (normal startup, deeplink, single_instance, tray `show_main`, lightweight exit).
- **Linux Drag Region on Header**: Removed `data-tauri-drag-region` from the top header bar on Linux to avoid triggering `gtk_window_begin_move_drag` paths affected by Tauri #13440 under Wayland. macOS drag behavior is preserved.
- **OpenCode / OpenClaw Stream Check Edge Cases**: Fixed custom-header passthrough, OpenClaw custom auth-header detection, Bedrock error messaging, and OpenCode default `baseURL` fallback handling in Stream Check.
- **Duplicate Toast on Provider Switch**: Fixed double toast notifications (proxy-required warning followed by switch-success) when switching to Copilot, ChatGPT, or OpenAI-format providers with the proxy not running.
- **Session Search Accuracy & Chinese Support**: Fixed session search result truncation across providers and switched FlexSearch tokenizer to full mode for proper Chinese substring matching.
- **Adaptive Thinking Reasoning Effort**: Fixed `resolve_reasoning_effort()` mapping adaptive thinking to `xhigh` instead of incorrectly using `high` in OpenAI format conversions.
- **Thinking Model Fallback Display**: Fixed the Claude provider form showing an empty Thinking model field after saving only a main model by applying read-only fallback to ANTHROPIC_MODEL.
- **Auth Tab Localization**: Fixed missing i18n translation keys for the settings auth tab label across all locale bundles.
- **Schema Migration Guard**: Fixed database migrations failing when skills or model_pricing tables did not exist by adding table-existence checks before ALTER and UPDATE operations.
### Docs
- **User Manual Refresh**: Updated the EN / ZH / JA manuals for tray submenus, lightweight mode, provider model fetching, session management, workspace files, WebDAV v2 behavior, OpenCode / OpenClaw activation, and other provider workflow improvements.
- **Community & Contribution Docs**: Added `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, bilingual issue / PR templates, Dependabot config, and CI quality checks.
- **Release Notes Risk Notice**: Added a Copilot reverse proxy risk notice and anchored highlight links in the v3.12.3 release notes across all three languages.
- **Sponsor Partners**: Added Shengsuanyun, LionCC, and DDS as sponsor partners in README across all languages.
---
+12 -12
View File
@@ -37,8 +37,8 @@ MiniMax-M2.7 is a next-generation large language model designed for autonomous e
</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>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
</tr>
<tr>
@@ -47,8 +47,8 @@ MiniMax-M2.7 is a next-generation large language model designed for autonomous e
</tr>
<tr>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
<td width="180"><a href="https://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>
@@ -72,21 +72,21 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
<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>
<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. 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://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
<td>Thanks to AICoding.sh for sponsoring this project! AICoding.sh — Global AI Model API Relay Service at Unbeatable Prices! Claude Code at 19% of original price, GPT at just 1%! Trusted by hundreds of enterprises for cost-effective AI services. Supports Claude Code, GPT, Gemini and major domestic models, with enterprise-grade high concurrency, fast invoicing, and 24/7 dedicated technical support. CC Switch users who register via <a href="https://aicoding.sh/i/CCSWITCH">this link</a> get 10% off their first top-up!</td>
</tr>
<tr>
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="Crazyrouter" width="150"></a></td>
<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> 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. 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, <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>
@@ -98,8 +98,8 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
</tr>
<tr>
<td width="180"><a href="https://x-code.cc/register?aff=IbPp"><img src="assets/partners/logos/xcodeapi.png" alt="XCodeAPI" width="150"></a></td>
<td>Thanks to XCodeAPI for sponsoring this project! XCodeAPI offers a special benefit for CC Switch users: register via <a href="https://x-code.cc/register?aff=IbPp">this link</a> and get an extra 10% credit bonus on your first order! (Contact the site admin to claim)</td>
<td width="180"><a href="https://lemondata.cc/r/FFX1ZDUP"><img src="assets/partners/logos/lemondata.png" alt="LemonData" width="150"></a></td>
<td>Thanks to LemonData for sponsoring this project! LemonData is a high-performance AI API aggregation platform — one API key for 300+ models including GPT, Claude, Gemini, DeepSeek, and more. All models priced 3070% below official rates with auto-failover, smart routing, and unlimited concurrency. New users get $1 free credit instantly upon registration — sign up via <a href="https://lemondata.cc/r/FFX1ZDUP">this link</a>to claim your bonus and start building right away</strong>!</td>
</tr>
<tr>
+13 -11
View File
@@ -37,8 +37,8 @@ MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設
</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>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
</tr>
<tr>
@@ -47,8 +47,8 @@ MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設
</tr>
<tr>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
<td width="180"><a href="https://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>
@@ -73,9 +73,6 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
</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 などのモデルに対応した中継(プロキシ)サービスを安定して提供しています。特に高いコストパフォーマンスを誇る 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://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
@@ -83,10 +80,15 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
</tr>
<tr>
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="Crazyrouter" width="150"></a></td>
<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>
</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 などのモデルに対応した中継(プロキシ)サービスを安定して出しています。特に高いコストパフォーマンスを誇る 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 モデルサービスを提供しています。<strong>高コストパフォーマンスの公式 Claude サービスを 0.5¥/$ 換算で提供</strong>、月額制・Paygo など多様な課金方式に対応し、当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典:<a href="https://www.sssaicode.com/register?ref=DCP0SM">こちらのリンク</a>から登録すると、毎回のチャージで $10 の追加ボーナスを受けられます!</td>
@@ -96,12 +98,12 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
<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://x-code.cc/register?aff=IbPp"><img src="assets/partners/logos/xcodeapi.png" alt="XCodeAPI" width="150"></a></td>
<td>XCodeAPI のご支援に感謝します!CC Switch ユーザー向けの特別特典:<a href="https://x-code.cc/register?aff=IbPp">こちらのリンク</a>から登録すると、初回注文で 10% の追加クレジットボーナスがもらえます!(サイト管理者に連絡して受け取りください)</td>
<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>
+13 -13
View File
@@ -37,8 +37,8 @@ MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构
</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>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
</tr>
<tr>
@@ -47,8 +47,8 @@ MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构
</tr>
<tr>
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
<td width="180"><a href="https://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>
@@ -73,21 +73,21 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
<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>
<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 等模型的中转服务。主打<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://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
<td>感谢 AICoding.sh 赞助了本项目!AICoding.sh —— 全球大模型 API 超值中转服务!Claude Code 1.9 折,GPT 0.1 折,已为数百家企业提供高性价比 AI 服务。支持 Claude Code、GPT、Gemini 及国内主流模型,企业级高并发、极速开票、7×24 专属技术支持,通过<a href="https://aicoding.sh/i/CCSWITCH">此链接</a> 注册的 CC Switch 用户,首充可享受九折优惠!</td>
</tr>
<tr>
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="Crazyrouter" width="150"></a></td>
<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>
</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 等模型的中转服务。主打<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模型服务,<strong>提供高性价比折合0.5¥/$的官方Claude服务</strong>,支持包月、Paygo多种计费方式、支持当日快速开票,SSSAiCode为本软件的用户提供特别优惠,使用<a href="https://www.sssaicode.com/register?ref=DCP0SM">此链接</a>注册每次充值均可享受10$的额外奖励!</td>
@@ -97,12 +97,12 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
<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://x-code.cc/register?aff=IbPp"><img src="assets/partners/logos/xcodeapi.png" alt="XCodeAPI" width="150"></a></td>
<td>感谢 XCodeAPI 赞助了本项目!XCodeAPI 为本软件的用户提供特别福利,使用<a href="https://x-code.cc/register?aff=IbPp">此链接</a>注册后首单加赠10%的额度!(联系站长领取)</td>
<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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

+94 -14
View File
@@ -8,11 +8,11 @@
## Overview
CC Switch v3.13.0 is a major feature release centered on observability, provider workflow ergonomics, and proxy compatibility. It adds inline **quota and balance displays** across official Claude / Codex / Gemini providers plus Token Plan, Copilot, and third-party balance APIs; introduces a **Lightweight Mode** that keeps CC Switch running from the system tray without a main window; delivers **automatic model discovery** via OpenAI-compatible `/v1/models` across all five supported applications; ships a **Codex OAuth reverse proxy** for ChatGPT Plus / Pro subscribers; reorganizes the tray menu into **per-app submenus**; rebuilds the proxy forwarding stack on a **Hyper-based client**; and overhauls the **Skills workflow** with discovery, batch updates, and storage-location toggling. Additional improvements include full URL endpoint mode, session-log usage tracking without proxy interception, the Copilot interaction optimizer, a UTF-8 streaming chunk boundary fix for multi-byte output, and a Linux startup UI responsiveness fix.
CC Switch v3.13.0 is a major feature release centered on observability, provider workflow ergonomics, and proxy compatibility. It adds inline **quota and balance displays** across official Claude / Codex / Gemini providers plus Token Plan, Copilot, and third-party balance APIs; introduces a **Lightweight Mode** that keeps CC Switch running from the system tray without a main window; delivers **automatic model discovery** via OpenAI-compatible `/v1/models` across all five supported applications; ships a **Codex OAuth reverse proxy** for ChatGPT subscribers; reorganizes the tray menu into **per-app submenus**; rebuilds the proxy forwarding stack on a **Hyper-based client**; and overhauls the **Skills workflow** with discovery, batch updates, storage-location toggling, and built-in skills.sh search and install. Additional improvements include full URL endpoint mode, enhanced token usage tracking, the Copilot interaction optimizer, a UTF-8 streaming chunk boundary fix for multi-byte output, a Linux startup UI responsiveness fix, and a friendlier new-user onboarding experience.
**Release Date**: 2026-04-08
**Release Date**: 2026-04-10
**Update Scale**: 98 commits | 229 files changed | +23,891 / -2,305 lines
**Update Scale**: 139 commits | 280 files changed | +31,627 / -3,042 lines
---
@@ -21,9 +21,9 @@ CC Switch v3.13.0 is a major feature release centered on observability, provider
- **Lightweight Mode**: Tray-only operating mode that destroys the main window on exit to tray and recreates it on demand, reducing CC Switch's desktop footprint to near zero when idle
- **Quota & Balance Visibility**: Inline quota or balance readout across provider cards — official Claude / Codex / Gemini subscriptions, GitHub Copilot premium interactions, Codex OAuth, Token Plan providers (Kimi / Zhipu GLM / MiniMax), plus official balance queries for DeepSeek, StepFun, SiliconFlow, OpenRouter, and Novita AI
- **Provider Model Auto-Fetch**: OpenAI-compatible `/v1/models` discovery across Claude, Codex, Gemini, OpenCode, and OpenClaw provider forms, with grouped dropdown selection and failure-specific error messages
- **Codex OAuth Reverse Proxy**: ChatGPT Plus / Pro Codex reverse proxy exposed as a new Claude provider card with managed OAuth login and inline subscription quota display ([⚠️ Risk Notice](#-risk-notice))
- **Codex OAuth Reverse Proxy**: ChatGPT Codex reverse proxy exposed as a new Claude provider card type, allowing users to use their ChatGPT subscription in Claude Code. Includes managed OAuth login and inline subscription quota display ([⚠️ Risk Notice](#-risk-notice))
- **Tray Per-App Submenus**: Reworked the tray menu into per-application submenus so it never overflows the screen and background provider switching scales to dozens of providers per app
- **Skills Discovery & Batch Updates**: SHA-256-based skill update detection, per-skill and "Update All" batch actions, public `skills.sh` search integration, and a storage-location toggle between CC Switch storage and `~/.agents/skills`
- **Skills Discovery & Batch Updates**: SHA-256-based skill update detection, per-skill and "Update All" batch actions, `skills.sh` search integration, and a storage-location toggle between CC Switch storage and `~/.agents/skills`
- **Session Workflow Upgrades**: Batch session deletion, a directory picker before launching Claude terminal restore, usage import from Claude / Codex / Gemini session logs without proxy interception, precise Codex JSONL parsing, and per-app usage filtering
- **OpenCode / OpenClaw Stream Check Coverage**: OpenCode detection via npm package mapping, OpenClaw `openai-completions` support, and the remaining OpenClaw protocol variants — with custom-header passthrough and auth-header detection fixes
- **Full URL Endpoint Mode**: Provider option that treats `base_url` as a complete upstream endpoint, unblocking vendors that require nonstandard URL layouts
@@ -31,6 +31,10 @@ CC Switch v3.13.0 is a major feature release centered on observability, provider
- **Copilot Interaction Optimizer**: Request classification and routing logic that reduces unnecessary GitHub Copilot premium interaction consumption
- **UTF-8 Stream Chunk Boundary Fix**: All four SSE streaming paths now preserve incomplete multi-byte UTF-8 sequences across TCP chunks, eliminating intermittent U+FFFD garbled output via the Copilot reverse proxy
- **Linux Startup UI Fix**: Fixed the long-standing issue where the window UI couldn't receive clicks on Linux until the user manually maximized and restored the window
- **First-Run Onboarding**: One-time welcome dialog on fresh installs, automatic seeding of Claude / OpenAI / Google official presets, and auto-import of OpenCode / OpenClaw live configurations on startup
- **Claude Session Titles & Search Highlighting**: Meaningful title extraction for Claude sessions using a priority chain (custom-title metadata → first user message → directory basename), plus keyword highlighting in Session Manager search results
- **URL-Based Provider Icons**: Dual rendering mode supporting Vite URL imports for large SVGs and raster images (PNG, JPG, WebP), keeping small SVGs inlined
- **New Provider Presets**: TheRouter, DDSHub, LionCCAPI, Shengsuanyun (胜算云), PIPELLM, and E-FlowCode across supported applications
---
@@ -50,9 +54,9 @@ Added inline quota and balance readouts to provider cards so users can see remai
- **Official subscriptions**: Inline quota display for Claude, Codex, and Gemini official providers
- **GitHub Copilot**: Premium interactions quota display on the Copilot provider card
- **Codex OAuth**: ChatGPT Plus / Pro subscription quota inline with the Codex OAuth provider card
- **Token Plan providers**: Kimi, Zhipu GLM, and MiniMax with corrected quota math and consistent 0% → 100% usage progression
- **Third-party balances**: Official balance queries for DeepSeek, StepFun, SiliconFlow, OpenRouter, and Novita AI
- **Codex OAuth**: ChatGPT subscription quota inline with the Codex OAuth provider card
- **Token Plan providers**: Kimi, Zhipu GLM, and MiniMax usage progression display (requires manual activation to avoid confusion)
- **Third-party balances**: Official balance queries for DeepSeek, StepFun, SiliconFlow, OpenRouter, and Novita AI (requires manual activation to avoid confusion)
- Health-check and usage-config buttons are hidden for official providers to keep the card clean
### Provider Model Auto-Fetch
@@ -66,13 +70,12 @@ Added OpenAI-compatible model discovery to every provider form, removing the man
### Codex OAuth Reverse Proxy
Added a reverse proxy path for ChatGPT Plus / Pro subscribers who want to route their Codex OAuth session through CC Switch.
Added a reverse proxy path for ChatGPT subscribers who want to use their ChatGPT subscription in Claude Code.
- Managed OAuth login flow with ChatGPT Plus / Pro authentication
- Managed OAuth login flow with ChatGPT authentication
- Surfaces as a new Claude provider card type alongside API-key providers
- Inline subscription quota display
- Integrated into the Auth Center with tightened copy, layout, and icon presentation
- Bumped the Codex OAuth preset to the GPT-5.4 model family
- Integrated into the Auth Center for unified token management
- See the [⚠️ Risk Notice](#-risk-notice) below before enabling
### Tray Per-App Submenus
@@ -131,6 +134,54 @@ Added request classification and routing logic that reduces unnecessary GitHub C
- Classifies incoming requests by intent and weight
- Routes low-value requests away from premium interaction consumption paths
- Designed to extend the usable lifetime of a Copilot subscription
- Note: Even with optimized consumption, using the Copilot API outside of Copilot still consumes more than using it within Copilot.
### First-Run Welcome Dialog
Added a one-time welcome dialog on fresh installs to guide new users through the CC Switch workflow.
- Explains how existing live configuration is preserved as a default provider
- Introduces the bundled official preset that enables one-click revert to official endpoints
- Upgrade users are automatically excluded via empty provider check
### Official Provider Seeding
- Added automatic seeding of Claude Official, OpenAI Official, and Google Official provider entries on startup, giving every user a one-click path back to the official endpoint
### OpenCode / OpenClaw Auto-Import
- Added automatic startup import of live OpenCode and OpenClaw provider configurations, matching the auto-import behavior already present for Claude, Codex, and Gemini
### Common Config Editor Guidance
- Added an informational guide and empty-state prompt to the Common Config snippet editor modal for Claude, Codex, and Gemini
- Added a one-time informational dialog explaining Common Config Snippets when users first open the provider add/edit form
### Claude Session Titles & Search Highlighting
- Added meaningful title extraction for Claude sessions using a priority chain: custom-title metadata, first real user message, then directory basename fallback
- Added keyword highlighting in session titles and messages during Session Manager search
### URL-Based Provider Icons
- Added a dual rendering mode to the icon system: small SVGs are inlined as React components, while large SVGs and raster images (PNG, JPG, WebP) are loaded via Vite URL imports as `<img>` tags
### Kaku Terminal Support
- Added Kaku as a selectable terminal for session launch on macOS, reusing the WezTerm-compatible launch path (#1983, thanks @yovinchen)
### OMO Slim Council Support
- Restored first-class council support as a built-in oh-my-opencode-slim agent with updated metadata and UI copy (#1982, thanks @yovinchen)
### New Provider Presets
- **TheRouter**: Added across Claude, Codex, Gemini, OpenCode, and OpenClaw (#1891, #1892, thanks @cmzz)
- **DDSHub**: Added as a third-party partner provider for Claude with icon and partner promotion text
- **LionCCAPI**: Added across all five apps with anthropic-messages protocol for OpenCode and OpenClaw
- **Shengsuanyun (胜算云)**: Added as an aggregator partner provider across all five apps with URL-based icon and localized display name
- **PIPELLM**: Added across Claude, Codex, OpenCode, and OpenClaw with full model definitions and icon
- **E-FlowCode**: Added across all five apps with per-app protocol configuration
---
@@ -263,6 +314,31 @@ Fixed a long-standing Linux bug where the window UI (including native title bar
- Bedrock error messaging
- OpenCode default `baseURL` fallback handling
### Duplicate Toast on Provider Switch
- Fixed double toast notifications (proxy-required warning followed by switch-success) when switching to Copilot, ChatGPT, or OpenAI-format providers with the proxy not running
### Session Search Accuracy & Chinese Support
- Fixed session search result truncation across providers
- Switched FlexSearch tokenizer to full mode for proper Chinese substring matching
### Adaptive Thinking Reasoning Effort
- Fixed `resolve_reasoning_effort()` mapping adaptive thinking to `xhigh` instead of incorrectly using `high` in OpenAI format conversions
### Thinking Model Fallback Display
- Fixed the Claude provider form showing an empty Thinking model field after saving only a main model by applying read-only fallback to ANTHROPIC_MODEL (#1984, thanks @yovinchen)
### Auth Tab Localization
- Fixed missing i18n translation keys for the settings auth tab label across all locale bundles (#1985, thanks @yovinchen)
### Schema Migration Guard
- Fixed database migrations failing when skills or model_pricing tables did not exist by adding table-existence checks before ALTER and UPDATE operations
---
## Documentation
@@ -282,16 +358,20 @@ Fixed a long-standing Linux bug where the window UI (including native title bar
- Added a Copilot reverse proxy risk notice and anchored highlight links in the v3.12.3 release notes across all three languages
### Sponsor Partners
- Added Shengsuanyun, LionCC, and DDS as sponsor partners in README across all languages
---
## ⚠️ Risk Notice
**Codex OAuth Reverse Proxy Disclaimer**
The Codex OAuth reverse proxy introduced in this release accesses ChatGPT Plus / Pro Codex services through reverse-engineered OAuth flows. Please be aware of the following risks before enabling this feature:
The Codex OAuth reverse proxy introduced in this release accesses ChatGPT Codex services through reverse-engineered OAuth flows. Please be aware of the following risks before enabling this feature:
1. **Terms of Service**: Using reverse-engineered OAuth flows to access OpenAI services may violate OpenAI's terms of service, which prohibit unauthorized automated access, service reproduction, and circumventing intended access paths.
2. **Account Risk**: OpenAI may flag unusual usage patterns as suspicious automated activity, potentially resulting in temporary or permanent restrictions on ChatGPT Plus / Pro access.
2. **Account Risk**: OpenAI may flag unusual usage patterns as suspicious automated activity, potentially resulting in temporary or permanent restrictions on ChatGPT access.
3. **No Guarantee**: OpenAI may update its authentication and detection mechanisms at any time, and usage patterns that work today may be flagged in the future.
The **GitHub Copilot reverse proxy** introduced in v3.12.3 also remains subject to its existing risk notice — see the [v3.12.3 release notes](v3.12.3-en.md#-risk-notice) for the full disclosure.
+94 -14
View File
@@ -8,11 +8,11 @@
## 概要
CC Switch v3.13.0 は、可観測性、プロバイダーワークフローの使いやすさ、プロキシ互換性を中心とした大型機能リリースです。Claude / Codex / Gemini の公式プロバイダー、Token Plan、Copilot、サードパーティ残高 API にわたる**クォータと残高のインライン表示**を追加し、メインウィンドウなしでシステムトレイから CC Switch を動作させる**軽量モード**を導入しました。OpenAI 互換の `/v1/models` による**自動モデル発見**を 5 つのサポート対象アプリケーションすべてに提供し、ChatGPT Plus / Pro サブスクライバー向けの **Codex OAuth リバースプロキシ**を同梱しています。トレイメニューを**アプリ別サブメニュー**に再編成し、プロキシ転送スタックを **Hyper ベースのクライアント**に再構築し、**Skills ワークフロー**を発見、バッチ更新、ストレージ位置切り替えで刷新しました。さらに、フル URL エンドポイントモード、プロキシ傍受なしのセッションログ用量追跡、Copilot インタラクション最適化、マルチバイト UTF-8 ストリームチャンク境界修正、Linux 起動時の UI 応答性修正なども含まれます。
CC Switch v3.13.0 は、可観測性、プロバイダーワークフローの使いやすさ、プロキシ互換性を中心とした大型機能リリースです。Claude / Codex / Gemini の公式プロバイダー、Token Plan、Copilot、サードパーティ残高 API にわたる**クォータと残高のインライン表示**を追加し、メインウィンドウなしでシステムトレイから CC Switch を動作させる**軽量モード**を導入しました。OpenAI 互換の `/v1/models` による**自動モデル発見**を 5 つのサポート対象アプリケーションすべてに提供し、ChatGPT サブスクライバー向けの **Codex OAuth リバースプロキシ**を同梱しています。トレイメニューを**アプリ別サブメニュー**に再編成し、プロキシ転送スタックを **Hyper ベースのクライアント**に再構築し、**Skills ワークフロー**を発見、バッチ更新、ストレージ位置切り替え、および組み込みの skills.sh 検索・インストールで刷新しました。さらに、フル URL エンドポイントモード、強化されたトークン用量追跡、Copilot インタラクション最適化、マルチバイト UTF-8 ストリームチャンク境界修正、Linux 起動時の UI 応答性修正、およびよりフレンドリーな新規ユーザーオンボーディングなども含まれます。
**リリース日**: 2026-04-08
**リリース日**: 2026-04-10
**更新規模**: 98 commits | 229 files changed | +23,891 / -2,305 lines
**更新規模**: 139 commits | 280 files changed | +31,627 / -3,042 lines
---
@@ -21,9 +21,9 @@ CC Switch v3.13.0 は、可観測性、プロバイダーワークフローの
- **軽量モード**: トレイ専用の動作モード。トレイへの終了時にメインウィンドウを破棄し、必要時に再作成することで、アイドル時の CC Switch のデスクトップフットプリントを最小化
- **クォータと残高の可視化**: プロバイダーカードでのインラインクォータ/残高表示 — Claude / Codex / Gemini 公式サブスクリプション、GitHub Copilot premium interactions、Codex OAuth、Token Plan プロバイダー(Kimi / Zhipu GLM / MiniMax)、および DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI の公式残高クエリをカバー
- **プロバイダーモデル自動取得**: Claude / Codex / Gemini / OpenCode / OpenClaw のプロバイダーフォームに OpenAI 互換の `/v1/models` 発見機能を追加。グループ化ドロップダウンと失敗時の具体的なエラーメッセージ付き
- **Codex OAuth リバースプロキシ**: ChatGPT Plus / Pro の Codex リバースプロキシを新しい Claude プロバイダーカードタイプとして追加。マネージド OAuth ログインとサブスクリプションクォータのインライン表示を提供([⚠️ リスクに関する注意事項](#-リスクに関する注意事項)
- **Codex OAuth リバースプロキシ**: ChatGPT の Codex リバースプロキシを新しい Claude プロバイダーカードタイプとして追加。ユーザーは ChatGPT サブスクリプションを Claude Code で利用可能に。マネージド OAuth ログインとサブスクリプションクォータのインライン表示を提供([⚠️ リスクに関する注意事項](#-リスクに関する注意事項)
- **トレイのアプリ別サブメニュー**: トレイメニューをアプリ別サブメニューに再編成し、プロバイダー数が多くてもメニューがオーバーフローせず、バックグラウンドのプロバイダー切り替えが長いリストでもスケール
- **Skills 発見とバッチ更新**: SHA-256 ベースの skill 更新検出、各 skill および「すべて更新」のバッチ更新、公開 `skills.sh` 検索統合、CC Switch ストレージと `~/.agents/skills` の間のストレージ位置切り替え
- **Skills 発見とバッチ更新**: SHA-256 ベースの skill 更新検出、各 skill および「すべて更新」のバッチ更新、`skills.sh` 検索統合、CC Switch ストレージと `~/.agents/skills` の間のストレージ位置切り替え
- **セッションワークフローの改善**: Session Manager でのバッチ削除、Claude ターミナル復元前のディレクトリピッカー、プロキシ傍受なしでの Claude / Codex / Gemini セッションログからの用量インポート、正確な Codex JSONL 解析、アプリ別の用量フィルタリング
- **OpenCode / OpenClaw Stream Check カバレッジ**: OpenCode の npm パッケージマッピング検出、OpenClaw `openai-completions` サポート、および残りの OpenClaw プロトコルバリアント
- **フル URL エンドポイントモード**: `base_url` を完全な上流エンドポイントとして扱うプロバイダーオプションを追加し、非標準 URL レイアウトを要求するベンダーに対応
@@ -31,6 +31,10 @@ CC Switch v3.13.0 は、可観測性、プロバイダーワークフローの
- **Copilot インタラクション最適化**: GitHub Copilot premium interaction の不要な消費を削減するリクエスト分類とルーティングロジックを追加
- **UTF-8 ストリームチャンク境界修正**: マルチバイト UTF-8 シーケンスが TCP チャンクを跨いで分割された際の Copilot リバースプロキシ経由での文字化け(U+FFFD 置換文字)を解消するため、すべての 4 つの SSE ストリーミングパスを修正
- **Linux 起動時 UI 修正**: ユーザーが手動でウィンドウを最大化・復元するまでウィンドウ UI がクリックを受け付けない長年の問題を修正
- **初回起動オンボーディング**: 新規インストール時のワンタイムウェルカムダイアログ、Claude / OpenAI / Google 公式プリセットの自動シード、起動時の OpenCode / OpenClaw ライブ設定の自動インポート
- **Claude セッションタイトルと検索ハイライト**: カスタムタイトルメタデータ → 最初のユーザーメッセージ → ディレクトリベースネームの優先チェーンによる Claude セッションの意味のあるタイトル抽出、Session Manager 検索でのキーワードハイライト
- **URL ベースのプロバイダーアイコン**: 大きな SVG とラスター画像(PNG / JPG / WebP)を Vite URL import でロードし、小さな SVG はインライン保持するデュアルレンダリングモード
- **新プロバイダープリセット**: TheRouter、DDSHub、LionCCAPI、Shengsuanyun(胜算云)、PIPELLM、E-FlowCode を対応アプリケーションに追加
---
@@ -50,9 +54,9 @@ CC Switch のアイドル時のデスクトップフットプリントを大幅
- **公式サブスクリプション**: Claude / Codex / Gemini 公式プロバイダーのサブスクリプションクォータ表示
- **GitHub Copilot**: Copilot プロバイダーカードに premium interactions 残量を表示
- **Codex OAuth**: Codex OAuth カードに ChatGPT Plus / Pro サブスクリプションクォータをインライン表示
- **Token Plan プロバイダー**: Kimi、Zhipu GLM、MiniMax。クォータ計算と 0% → 100% 使用量進行を修正
- **サードパーティ残高**: DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI に公式残高クエリを追加
- **Codex OAuth**: Codex OAuth カードに ChatGPT サブスクリプションクォータをインライン表示
- **Token Plan プロバイダー**: Kimi、Zhipu GLM、MiniMax の使用量進行表示(混乱を避けるため手動で有効化が必要)
- **サードパーティ残高**: DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI に公式残高クエリを追加(混乱を避けるため手動で有効化が必要)
- 公式プロバイダーではヘルスチェックと用量設定ボタンを非表示にし、カードをクリーンに保つ
### プロバイダーモデル自動取得
@@ -66,13 +70,12 @@ CC Switch のアイドル時のデスクトップフットプリントを大幅
### Codex OAuth リバースプロキシ
ChatGPT Plus / Pro のサブスクライバーが Codex OAuth セッションを CC Switch 経由で利用できるリバースプロキシパスを追加。
ChatGPT サブスクライバーが ChatGPT サブスクリプションを Claude Code で利用できるリバースプロキシパスを追加。
- ChatGPT Plus / Pro 認証を使ったマネージド OAuth ログインフロー
- ChatGPT 認証を使ったマネージド OAuth ログインフロー
- API キー型プロバイダーと並ぶ新しい Claude プロバイダーカードタイプとして表示
- サブスクリプションクォータのインライン表示
- Auth Center との緊密な統合と、コピー・レイアウト・アイコンの調整
- Codex OAuth プリセットを GPT-5.4 モデルファミリーに更新
- Auth Center との統合によるトークンの一元管理
- 有効化前に下記の [⚠️ リスクに関する注意事項](#-リスクに関する注意事項) をご確認ください
### トレイのアプリ別サブメニュー
@@ -131,6 +134,54 @@ GitHub Copilot premium interaction の不要な消費を削減するリクエス
- 受信リクエストを意図と重要度で分類
- 価値の低いリクエストを premium interaction 消費パスから迂回
- Copilot サブスクリプションの使用可能期間を延長することを目的
- 注意: 消費を最適化しても、Copilot 外で Copilot API を使用する場合、Copilot 内で使用するよりも消費量は多くなります。
### 初回起動ウェルカムダイアログ
新規インストールのユーザーに CC Switch のワークフローを案内するワンタイムウェルカムダイアログを追加。
- 既存のライブ設定がデフォルトプロバイダーとして保持される仕組みを説明
- 内蔵の公式プリセットによるワンクリックでの公式エンドポイント復帰を紹介
- アップグレードユーザーは空プロバイダーチェックにより自動的にスキップ
### 公式プロバイダーの自動シード
- 起動時に Claude Official / OpenAI Official / Google Official プロバイダーエントリを自動シードし、すべてのユーザーにワンクリックで公式エンドポイントに戻るパスを提供
### OpenCode / OpenClaw 自動インポート
- 起動時に OpenCode と OpenClaw のライブプロバイダー設定を自動インポート。Claude / Codex / Gemini で既にある自動インポート動作と同等に
### Common Config エディタガイダンス
- Claude / Codex / Gemini の Common Config スニペットエディタモーダルに情報ガイドと空状態プロンプトを追加
- ユーザーがプロバイダー追加/編集フォームを初めて開く際、Common Config Snippets を説明するワンタイムダイアログを追加
### Claude セッションタイトルと検索ハイライト
- Claude セッションの意味のあるタイトル抽出を追加。優先チェーン: カスタムタイトルメタデータ → 最初の実ユーザーメッセージ → ディレクトリベースネームフォールバック
- Session Manager 検索時にセッションタイトルとメッセージ内のキーワードをハイライト
### URL ベースのプロバイダーアイコン
- アイコンシステムにデュアルレンダリングモードを追加: 小さな SVG は React コンポーネントとしてインライン、大きな SVG とラスター画像(PNG / JPG / WebP)は Vite URL import で `<img>` タグとしてロード
### Kaku ターミナルサポート
- macOS でセッション起動用の選択可能なターミナルとして Kaku を追加。WezTerm 互換の起動パスを再利用 (#1983, @yovinchen に感謝)
### OMO Slim Council サポート
- 内蔵 oh-my-opencode-slim エージェントとしての council のファーストクラスサポートを復元。メタデータと UI コピーを更新 (#1982, @yovinchen に感謝)
### 新プロバイダープリセット
- **TheRouter**: Claude / Codex / Gemini / OpenCode / OpenClaw の 5 アプリに追加 (#1891, #1892, @cmzz に感謝)
- **DDSHub**: Claude のサードパーティパートナープロバイダーとして追加。アイコンとパートナープロモーションテキスト付き
- **LionCCAPI**: 5 アプリすべてに追加。OpenCode / OpenClaw は anthropic-messages プロトコルを使用
- **Shengsuanyun(胜算云)**: アグリゲーターパートナープロバイダーとして 5 アプリすべてに追加。URL ベースのアイコンとローカライズ名をサポート
- **PIPELLM**: Claude / Codex / OpenCode / OpenClaw に追加。完全なモデル定義とアイコン付き
- **E-FlowCode**: 5 アプリすべてに追加。アプリごとに異なるプロトコル設定
---
@@ -263,6 +314,31 @@ Claude Code で Copilot リバースプロキシ経由時、中国語文字や
- Bedrock エラーメッセージを修正
- OpenCode デフォルト `baseURL` のフォールバック処理を修正
### プロバイダー切り替え時の重複 Toast
- プロキシ未実行時に Copilot / ChatGPT / OpenAI フォーマットプロバイダーに切り替えた際の二重 toast 通知(プロキシ必要警告 + 切り替え成功)を修正
### セッション検索精度と中国語サポート
- プロバイダーをまたぐセッション検索結果の切り詰めを修正
- FlexSearch トークナイザーを full モードに切り替え、中国語サブストリングマッチングを正しく動作させる
### 適応的思考の推論エフォート
- `resolve_reasoning_effort()` が適応的思考を `high` ではなく正しく `xhigh` にマッピングするよう修正(OpenAI フォーマット変換時)
### Thinking モデルフォールバック表示
- Claude プロバイダーフォームでメインモデルのみ保存後に Thinking モデルフィールドが空で表示される問題を修正。ANTHROPIC_MODEL への読み取り専用フォールバックを適用 (#1984, @yovinchen に感謝)
### Auth タブのローカライゼーション
- 設定の auth タブラベルに不足していた i18n 翻訳キーをすべてのロケールバンドルで修正 (#1985, @yovinchen に感謝)
### スキーマ移行ガード
- skills または model_pricing テーブルが存在しない場合にデータベース移行が失敗する問題を修正。ALTER および UPDATE 操作の前にテーブル存在チェックを追加
---
## ドキュメント
@@ -282,16 +358,20 @@ Claude Code で Copilot リバースプロキシ経由時、中国語文字や
- v3.12.3 の release notes に Copilot リバースプロキシのリスク通知とハイライトリンクのアンカーを 3 言語すべてに追加
### スポンサーパートナー
- README の 3 言語すべてに Shengsuanyun、LionCC、DDS をスポンサーパートナーとして追加
---
## ⚠️ リスクに関する注意事項
**Codex OAuth リバースプロキシに関する免責事項**
本リリースで追加された Codex OAuth リバースプロキシ機能は、リバースエンジニアリングによる OAuth フローを通じて ChatGPT Plus / Pro の Codex サービスにアクセスします。この機能を有効にする前に、以下のリスクをご確認ください:
本リリースで追加された Codex OAuth リバースプロキシ機能は、リバースエンジニアリングによる OAuth フローを通じて ChatGPT の Codex サービスにアクセスします。この機能を有効にする前に、以下のリスクをご確認ください:
1. **利用規約違反の可能性**: リバースエンジニアリングされた OAuth フローを使用して OpenAI サービスにアクセスすることは、OpenAI の利用規約に違反する可能性があります。これらの規約では、未承認の自動アクセス、サービス複製、および意図されたアクセスパスの回避が禁止されています。
2. **アカウントリスク**: OpenAI は異常な使用パターンを疑わしい自動化活動としてフラグ付けし、ChatGPT Plus / Pro へのアクセスに一時的または永久的な制限を科す可能性があります。
2. **アカウントリスク**: OpenAI は異常な使用パターンを疑わしい自動化活動としてフラグ付けし、ChatGPT へのアクセスに一時的または永久的な制限を科す可能性があります。
3. **将来の利用保証なし**: OpenAI は認証および検出メカニズムをいつでも更新する可能性があり、現在動作する使用パターンが将来的にフラグ付けされる可能性があります。
v3.12.3 で導入された **GitHub Copilot リバースプロキシ**も、既存のリスク通知の対象となります — 詳細は [v3.12.3 リリースノート](v3.12.3-ja.md#-リスクに関する注意事項) を参照してください。
+107 -27
View File
@@ -8,11 +8,11 @@
## 概览
CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供应商工作流与代理兼容性。本版本在各主要供应商卡片上新增了**配额与余额展示**,覆盖 Claude / Codex / Gemini 官方订阅、Token PlanKimi / Zhipu GLM / MiniMax)、Copilot premium interactions 以及 DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI 等第三方余额查询;引入了**轻量模式**,让 CC Switch 可以仅驻留在系统托盘中运行;通过 OpenAI 兼容的 `/v1/models` 端点在 Claude / Codex / Gemini / OpenCode / OpenClaw 五个应用的供应商表单中实现了**模型自动发现**;为 ChatGPT Plus / Pro 订阅者提供了 **Codex OAuth 反向代理**;将托盘菜单重构为**按应用分级的子菜单**;将代理转发层重建在 **Hyper 客户端**之上;并完成了 **Skills 工作流**的发现、批量更新和存储位置切换改造。其他改进还包括完整 URL 端点模式、无需代理拦截的会话日志用量追踪、Copilot 调用优化器、多字节 UTF-8 流式分片边界修复以及 Linux 启动时 UI 无响应修复等。
CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供应商工作流与代理兼容性。本版本在各主要供应商卡片上新增了**配额与余额展示**,覆盖 Claude / Codex / Gemini 官方订阅、Token PlanKimi / Zhipu GLM / MiniMax)、Copilot premium interactions 以及 DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI 等第三方余额查询;引入了**轻量模式**,让 CC Switch 可以仅驻留在系统托盘中运行;通过 OpenAI 兼容的 `/v1/models` 端点在 Claude / Codex / Gemini / OpenCode / OpenClaw 五个应用的供应商表单中实现了**模型自动发现**;为 ChatGPT 订阅者提供了 **Codex OAuth 反向代理**;将托盘菜单重构为**按应用分级的子菜单**;将代理转发层重建在 **Hyper 客户端**之上;并完成了 **Skills 工作流**的发现、批量更新和存储位置切换改造,内置了 skills.sh 搜索安装。其他改进还包括完整 URL 端点模式、更完善的 token 用量追踪、Copilot 调用优化器、多字节 UTF-8 流式分片边界修复以及 Linux 启动时 UI 无响应修复,以及更友善的新用户引导等。
**发布日期**2026-04-08
**发布日期**2026-04-10
**更新规模**98 commits | 229 files changed | +23,891 / -2,305 lines
**更新规模**139 commits | 280 files changed | +31,627 / -3,042 lines
---
@@ -21,9 +21,9 @@ CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供
- **轻量模式**:新增仅托盘运行模式,退出到托盘时销毁主窗口、按需重建,空闲时资源占用接近零
- **配额与余额展示**:供应商卡片上直接展示配额或余额 —— 覆盖 Claude / Codex / Gemini 官方订阅、GitHub Copilot premium interactions、Codex OAuth、Token PlanKimi / Zhipu GLM / MiniMax),以及 DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI 的官方余额查询
- **供应商模型自动获取**:为 Claude / Codex / Gemini / OpenCode / OpenClaw 的供应商表单新增 OpenAI 兼容的 `/v1/models` 发现能力,按分组下拉展示并提供针对性错误提示
- **Codex OAuth 反向代理**:新增 ChatGPT Plus / Pro 的 Codex 反向代理,作为新的 Claude 供应商卡片类型,包含受管 OAuth 登录流程和订阅配额展示([⚠️ 风险提示](#-风险提示)
- **Codex OAuth 反向代理**:新增 ChatGPT 的 Codex 反向代理,作为新的 Claude 供应商卡片类型,让用户在可以在 Claude Code 里面使用 ChatGPT 订阅。包含受管 OAuth 登录流程和订阅配额展示([⚠️ 风险提示](#-风险提示)
- **托盘按应用分级菜单**:将托盘菜单重构为按应用分组的子菜单,防止供应商多时菜单溢出,让后台切换供应商在大量供应商场景下仍可用
- **Skills 发现与批量更新**:基于 SHA-256 内容哈希的更新检测、单项和"全部更新"批量操作、`skills.sh` 公共注册表搜索集成,以及 CC Switch 与 `~/.agents/skills` 的存储位置切换
- **Skills 发现与批量更新**:基于 SHA-256 内容哈希的更新检测、单项和"全部更新"批量操作、`skills.sh` 表搜索集成,以及 CC Switch 与 `~/.agents/skills` 的存储位置切换
- **会话工作流升级**:会话管理器批量删除、Claude 终端恢复前的目录选择器、无需代理拦截即可导入 Claude / Codex / Gemini 会话日志用量、精确的 Codex JSONL 解析、按应用筛选用量面板
- **OpenCode / OpenClaw 流式检测覆盖**:新增 OpenCode 的 npm 包映射检测、OpenClaw `openai-completions` 支持,以及其余所有 OpenClaw 协议变体
- **完整 URL 端点模式**:新增将 `base_url` 视作完整上游端点的供应商选项,支持非标准 URL 布局的厂商
@@ -31,6 +31,10 @@ CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供
- **Copilot 调用优化器**:新增请求分类和路由逻辑,降低 GitHub Copilot premium interaction 的不必要消耗
- **UTF-8 流式分片边界修复**:所有 4 条 SSE 流式路径改为跨分片保留残留多字节序列,消除 Copilot 反代下中文/emoji 乱码
- **Linux 启动 UI 修复**:修复长期存在的 Linux 窗口初次无法响应点击、需用户手动最大化再还原才能操作的问题
- **首次运行引导**:新安装时弹出一次性欢迎对话框、自动种入 Claude / OpenAI / Google 官方预设、启动时自动导入 OpenCode / OpenClaw 的 live 配置
- **Claude 会话标题与搜索高亮**:从 Claude 会话中提取有意义的标题(自定义标题 → 首条用户消息 → 目录名),在会话管理器搜索时高亮匹配关键词
- **URL 图标支持**:图标系统新增双渲染模式,大 SVG 和光栅图片(PNG / JPG / WebP)通过 Vite URL import 加载,小 SVG 保持内联
- **新供应商预设**:新增 TheRouter、DDSHub、LionCCAPI、胜算云、PIPELLM、E-FlowCode 预设
---
@@ -50,9 +54,9 @@ CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供
- **官方订阅**Claude / Codex / Gemini 官方供应商的订阅配额展示
- **GitHub Copilot**:在 Copilot 供应商卡片上显示 premium interactions 剩余量
- **Codex OAuth**:在 Codex OAuth 卡片上内联展示 ChatGPT Plus / Pro 订阅配额
- **Token Plan 供应商**Kimi、Zhipu GLM、MiniMax,修正配额数学与 0% → 100% 用量进度
- **第三方余额**:为 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 提供官方余额查询
- **Codex OAuth**:在 Codex OAuth 卡片上内联展示 ChatGPT 订阅配额
- **Token Plan 供应商**Kimi、Zhipu GLM、MiniMax 用量进度显示(为避免混淆,需要手动开启)
- **第三方余额**:为 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 提供官方余额查询(为避免混淆,需要手动开启)
- 官方供应商的健康检查和用量配置按钮自动隐藏,保持卡片简洁
### 供应商模型自动获取
@@ -66,13 +70,12 @@ CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供
### Codex OAuth 反向代理
新增 ChatGPT Plus / Pro 订阅者的 Codex OAuth 反向代理路径,让 ChatGPT 订阅者可以在 Claude Code 中使用自己的订阅。
新增 ChatGPT 订阅者的 Codex OAuth 反向代理路径,让 ChatGPT 订阅者可以在 Claude Code 中使用自己的订阅。
- 受管 OAuth 登录流程,通过 ChatGPT Plus / Pro 认证
- 受管 OAuth 登录流程,通过 ChatGPT 认证
- 作为新的 Claude 供应商卡片类型出现在列表中,与 API-key 型供应商并列
- 订阅配额内联展示
- 与 Auth Center UI 紧密集成,统一管理 Token
- Codex OAuth 预设升级到 GPT-5.4 系列
- 启用前请参见下文的 [⚠️ 风险提示](#-风险提示)
### 托盘按应用分级菜单
@@ -131,6 +134,54 @@ CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供
- 根据请求意图和权重进行分类
- 将低价值请求路由到非 premium 通道
- 旨在延长 Copilot 订阅的可用时长
- 注意,即使优化过消耗以后,在 Copilot 外使用 Copilot 的 API 消耗仍然会高于在 Copilot 内使用。
### 首次运行欢迎对话框
新安装用户首次打开时显示一次性欢迎对话框,引导了解 CC Switch 工作流程。
- 说明已有 live 配置如何被保留为默认供应商
- 介绍内置官方预设如何实现一键回滚到官方端点
- 升级用户通过空供应商检查自动跳过
### 官方供应商自动种入
- 启动时自动种入 Claude Official / OpenAI Official / Google Official 供应商条目,为每位用户提供一键回滚到官方端点的路径
### OpenCode / OpenClaw 自动导入
- 启动时自动导入 OpenCode 和 OpenClaw 的 live 供应商配置,与 Claude / Codex / Gemini 已有的自动导入行为对齐
### Common Config 编辑器引导
- 在 Claude / Codex / Gemini 的 Common Config 代码片段编辑器弹窗中添加引导信息和空状态提示
- 用户首次打开供应商添加/编辑表单时弹出一次性对话框说明 Common Config Snippets
### Claude 会话标题与搜索高亮
- 为 Claude 会话新增有意义的标题提取,优先链:自定义标题元数据 → 首条真实用户消息 → 目录名回退
- 在会话管理器搜索时高亮匹配关键词
### URL 图标支持
- 图标系统新增双渲染模式:小 SVG 以 React 组件内联,大 SVG 和光栅图片(PNG / JPG / WebP)通过 Vite URL import 以 `<img>` 标签加载
### Kaku 终端支持
- macOS 上新增 Kaku 作为可选终端用于启动会话,复用 WezTerm 兼容的启动路径 (#1983, 感谢 @yovinchen)
### OMO Slim Council 支持
- 恢复 council 作为内置 oh-my-opencode-slim agent 的一等支持,更新元数据和 UI 文案 (#1982, 感谢 @yovinchen)
### 新供应商预设
- **TheRouter**:覆盖 Claude / Codex / Gemini / OpenCode / OpenClaw 五个应用 (#1891, #1892, 感谢 @cmzz)
- **DDSHub**:作为 Claude 的第三方合作伙伴供应商,含图标和推广文案
- **LionCCAPI**:覆盖全部五个应用,OpenCode / OpenClaw 使用 anthropic-messages 协议
- **胜算云 (Shengsuanyun)**:作为聚合类合作伙伴供应商覆盖全部五个应用,支持 URL 图标和本地化名称
- **PIPELLM**:覆盖 Claude / Codex / OpenCode / OpenClaw,含完整模型定义和图标
- **E-FlowCode**:覆盖全部五个应用,按应用配置不同协议
---
@@ -264,6 +315,31 @@ CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供
- 修复 Bedrock 错误消息
- 修复 OpenCode 默认 `baseURL` 回退处理
### 供应商切换时重复 Toast
- 修复代理未运行时切换到 Copilot / ChatGPT / OpenAI 格式供应商时出现双重 toast 通知(代理必需警告 + 切换成功)
### 会话搜索精度与中文支持
- 修复会话搜索结果在跨供应商时被截断的问题
- 将 FlexSearch 分词器切换为 full 模式以支持中文子串匹配
### 自适应思维推理力度
- 修复 `resolve_reasoning_effort()` 将自适应思维错误映射为 `high`,应为 `xhigh`OpenAI 格式转换场景)
### Thinking 模型回退显示
- 修复 Claude 供应商表单仅填写主模型后 Thinking 模型字段显示为空,改为只读回退到 ANTHROPIC_MODEL (#1984, 感谢 @yovinchen)
### Auth Tab 本地化
- 修复设置面板 auth tab 标签在所有语言包中缺失 i18n 翻译 key (#1985, 感谢 @yovinchen)
### 数据库迁移守卫
- 修复 skills 或 model_pricing 表不存在时数据库迁移失败,在 ALTER 和 UPDATE 操作前添加表存在性检查
---
## 文档
@@ -283,16 +359,20 @@ CC Switch v3.13.0 是一次重要的功能版本,聚焦于可观测性、供
- 在三语 v3.12.3 release notes 中新增 Copilot 反代风险提示,并为重点内容添加锚点链接
### 赞助商合作伙伴
- 在三语 README 中新增胜算云、LionCC、DDS 作为赞助商合作伙伴
---
## ⚠️ 风险提示
**Codex OAuth 反向代理免责声明**
本版本新增的 Codex OAuth 反向代理功能通过逆向工程的 OAuth 流程访问 ChatGPT Plus / Pro 的 Codex 服务。启用此功能前,请注意以下风险:
本版本新增的 Codex OAuth 反向代理功能通过逆向工程的 OAuth 流程访问 ChatGPT 的 Codex 服务。启用此功能前,请注意以下风险:
1. **违反服务条款**:使用逆向 OAuth 流程访问 OpenAI 服务可能违反 OpenAI 的服务条款,其中禁止未经授权的自动化访问、服务复制以及绕过既定的访问路径。
2. **账号风险**:OpenAI 可能将异常使用模式标记为可疑的自动化行为,从而对 ChatGPT Plus / Pro 访问施加临时或永久限制。
2. **账号风险**:OpenAI 可能将异常使用模式标记为可疑的自动化行为,从而对 ChatGPT 访问施加临时或永久限制。
3. **无法保证长期可用**:OpenAI 可能随时更新其认证和检测机制,当前可用的使用方式未来可能被标记。
v3.12.3 引入的 **GitHub Copilot 反向代理**同样适用原有风险提示 —— 详见 [v3.12.3 release notes](v3.12.3-zh.md#-风险提示)。
@@ -307,26 +387,26 @@ v3.12.3 引入的 **GitHub Copilot 反向代理**同样适用原有风险提示
### 系统要求
| 系统 | 最低版本 | 架构 |
| ------- | ----------------------------- | ----------------------------------- |
| Windows | Windows 10 及以上 | x64 |
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 见下表 | x64 |
| 系统 | 最低版本 | 架构 |
| ------- | -------------------------- | ----------------------------------- |
| Windows | Windows 10 及以上 | x64 |
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 见下表 | x64 |
### Windows
| 文件 | 说明 |
| ------------------------------------------ | ----------------------------------- |
| `CC-Switch-v3.13.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.13.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
| 文件 | 说明 |
| ---------------------------------------- | ----------------------------------- |
| `CC-Switch-v3.13.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.13.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
### macOS
| 文件 | 说明 |
| ---------------------------------- | --------------------------------------------------------- |
| `CC-Switch-v3.13.0-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
| `CC-Switch-v3.13.0-macOS.zip` | 解压后拖入 ApplicationsUniversal Binary |
| `CC-Switch-v3.13.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
| 文件 | 说明 |
| -------------------------------- | --------------------------------------------- |
| `CC-Switch-v3.13.0-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
| `CC-Switch-v3.13.0-macOS.zip` | 解压后拖入 ApplicationsUniversal Binary |
| `CC-Switch-v3.13.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
> macOS 版本已通过 Apple 代码签名和公证,可直接安装使用。
+195
View File
@@ -0,0 +1,195 @@
# 4.2 App Routing
## Overview
App routing means letting CC Switch route a specific application's API requests through the local routing service.
When routing is enabled:
- The app's API requests are forwarded through local routing
- Request logs and usage statistics can be recorded
- Failover functionality becomes available
## Prerequisites
The routing service must be started before using the app routing feature.
## Enable Routing
### Location
Settings > Advanced > Routing Service > App Routing area
### Steps
1. Ensure the routing service is started
2. Find the "App Routing" area
3. Enable the toggle for the desired apps
### Routing Toggles
| Toggle | Effect |
|--------|--------|
| Claude Routing | Route Claude Code requests |
| Codex Routing | Route Codex requests |
| Gemini Routing | Route Gemini CLI requests |
Multiple app routings can be enabled simultaneously.
## How Routing Works
### Configuration Changes
When routing is enabled, CC Switch modifies the app's configuration file to point the API endpoint to the local routing service.
**Claude configuration change**:
```json
// Before routing
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
}
}
// After routing
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
}
}
```
**Codex configuration change**:
```toml
# Before routing
base_url = "https://api.openai.com/v1"
# After routing
base_url = "http://127.0.0.1:15721/v1"
```
**Gemini configuration change**:
```bash
# Before routing
GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
# After routing
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
```
### Request Forwarding
When the routing service receives a request:
1. Identifies the request source (Claude/Codex/Gemini)
2. Looks up the currently enabled provider for that app
3. Forwards the request to the provider's actual endpoint
4. Records the request log
5. Returns the response to the app
## Routing Status Indicators
### Main Interface Indicators
When routing is enabled, the main interface shows the following changes:
- **Routing logo color**: Changes from colorless to green
- **Provider cards**: The currently active provider shows a green border
### Provider Card States
| State | Border Color | Description |
|-------|--------------|-------------|
| Currently Active | Blue | Provider in the config file (non-routing mode) |
| Routing Active | Green | Provider actually used by routing |
| Normal | Default | Unused provider |
## Disable Routing
### Steps
1. Turn off the corresponding app's routing toggle in the routing panel
2. Or directly stop the routing service
### Configuration Restoration
When disabling routing, CC Switch will:
1. Restore the app configuration to its pre-routing state
2. Save current request logs
## Routing and Provider Switching
### Switching Providers in Routing Mode
When switching providers in routing mode:
1. Click the "Enable" button on a provider in the main interface
2. The routing service immediately uses the new provider to forward requests
3. **No need to restart the CLI tool**
This is a major advantage of routing mode: provider switching takes effect instantly.
### Switching Without Routing
When switching providers without routing:
1. Configuration file is modified
2. CLI tool must be restarted for changes to take effect
## Multi-app Routing
Multiple apps can be routed simultaneously, each managed independently:
- Independent provider configurations
- Independent failover queues
- Independent request statistics
## Use Cases
### Scenario 1: Usage Monitoring
Enable routing + log recording to monitor API usage.
### Scenario 2: Quick Switching
With routing enabled, switching providers does not require restarting CLI tools.
### Scenario 3: Failover
Enabling routing is a prerequisite for using the failover feature.
## Notes
### Performance Impact
Routing adds minimal latency (typically < 10ms), negligible for most scenarios.
### Network Requirements
In routing mode, CLI tools must be able to access the local routing address.
### Configuration Backup
Before enabling routing, CC Switch backs up the original configuration and restores it when disabled.
## FAQ
### Requests Fail After Enabling Routing
Check:
- Is the routing service running normally
- Is the provider configuration correct
- Is the network working properly
### Configuration Not Restored After Disabling Routing
Possible causes:
- Routing service exited abnormally
- Configuration file was modified by another program
Solutions:
- Manually edit the provider and re-save
- Or re-enable and then disable routing
-195
View File
@@ -1,195 +0,0 @@
# 4.2 App Takeover
## Overview
App takeover means letting CC Switch's proxy intercept and forward a specific application's API requests.
When takeover is enabled:
- The app's API requests are forwarded through the local proxy
- Request logs and usage statistics can be recorded
- Failover functionality becomes available
## Prerequisites
The proxy service must be started before using the app takeover feature.
## Enable Takeover
### Location
Settings > Advanced > Proxy Service > App Takeover area
### Steps
1. Ensure the proxy service is started
2. Find the "App Takeover" area
3. Enable the toggle for the desired apps
### Takeover Toggles
| Toggle | Effect |
|--------|--------|
| Claude Takeover | Intercept Claude Code requests |
| Codex Takeover | Intercept Codex requests |
| Gemini Takeover | Intercept Gemini CLI requests |
Multiple app takeovers can be enabled simultaneously.
## How Takeover Works
### Configuration Changes
When takeover is enabled, CC Switch modifies the app's configuration file to point the API endpoint to the local proxy.
**Claude configuration change**:
```json
// Before takeover
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
}
}
// After takeover
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
}
}
```
**Codex configuration change**:
```toml
# Before takeover
base_url = "https://api.openai.com/v1"
# After takeover
base_url = "http://127.0.0.1:15721/v1"
```
**Gemini configuration change**:
```bash
# Before takeover
GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
# After takeover
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
```
### Request Forwarding
When the proxy receives a request:
1. Identifies the request source (Claude/Codex/Gemini)
2. Looks up the currently enabled provider for that app
3. Forwards the request to the provider's actual endpoint
4. Records the request log
5. Returns the response to the app
## Takeover Status Indicators
### Main Interface Indicators
When takeover is enabled, the main interface shows the following changes:
- **Proxy logo color**: Changes from colorless to green
- **Provider cards**: The currently active provider shows a green border
### Provider Card States
| State | Border Color | Description |
|-------|--------------|-------------|
| Currently Active | Blue | Provider in the config file (non-proxy mode) |
| Proxy Active | Green | Provider actually used by the proxy |
| Normal | Default | Unused provider |
## Disable Takeover
### Steps
1. Turn off the corresponding app's takeover toggle in the proxy panel
2. Or directly stop the proxy service
### Configuration Restoration
When disabling takeover, CC Switch will:
1. Restore the app configuration to its pre-takeover state
2. Save current request logs
## Takeover and Provider Switching
### Switching Providers in Takeover Mode
When switching providers in takeover mode:
1. Click the "Enable" button on a provider in the main interface
2. The proxy immediately uses the new provider to forward requests
3. **No need to restart the CLI tool**
This is a major advantage of takeover mode: provider switching takes effect instantly.
### Switching Without Takeover
When switching providers without takeover:
1. Configuration file is modified
2. CLI tool must be restarted for changes to take effect
## Multi-app Takeover
Multiple apps can be taken over simultaneously, each managed independently:
- Independent provider configurations
- Independent failover queues
- Independent request statistics
## Use Cases
### Scenario 1: Usage Monitoring
Enable takeover + log recording to monitor API usage.
### Scenario 2: Quick Switching
With takeover enabled, switching providers does not require restarting CLI tools.
### Scenario 3: Failover
Enabling takeover is a prerequisite for using the failover feature.
## Notes
### Performance Impact
The proxy adds minimal latency (typically < 10ms), negligible for most scenarios.
### Network Requirements
In takeover mode, CLI tools must be able to access the local proxy address.
### Configuration Backup
Before enabling takeover, CC Switch backs up the original configuration and restores it when disabled.
## FAQ
### Requests Fail After Takeover
Check:
- Is the proxy service running normally
- Is the provider configuration correct
- Is the network working properly
### Configuration Not Restored After Disabling Takeover
Possible causes:
- Proxy exited abnormally
- Configuration file was modified by another program
Solutions:
- Manually edit the provider and re-save
- Or re-enable and then disable takeover
+1 -1
View File
@@ -79,7 +79,7 @@ CC Switch User Manual
| File | Description |
|------|-------------|
| [4.1-service.md](./4-proxy/4.1-service.md) | Start proxy, configuration, running status |
| [4.2-takeover.md](./4-proxy/4.2-takeover.md) | App takeover, configuration changes, status indicators |
| [4.2-routing.md](./4-proxy/4.2-routing.md) | App routing, configuration changes, status indicators |
| [4.3-failover.md](./4-proxy/4.3-failover.md) | Failover queue, circuit breaker, health status |
| [4.4-usage.md](./4-proxy/4.4-usage.md) | Usage statistics, trend charts, pricing configuration |
| [4.5-model-test.md](./4-proxy/4.5-model-test.md) | Model test, health check, latency testing |
+195
View File
@@ -0,0 +1,195 @@
# 4.2 アプリケーションルーティング
## 機能説明
アプリケーションルーティングとは、CC Switch のルーティングサービスが特定アプリの API リクエストをルーティングすることです。
ルーティングを有効にすると:
- アプリの API リクエストがローカルルーティング経由で転送される
- リクエストログと使用量の統計を記録できる
- フェイルオーバー機能を使用できる
## 前提条件
アプリケーションルーティング機能を使用する前に、ルーティングサービスを起動する必要があります。
## ルーティングの有効化
### 操作場所
設定 → 詳細 → ルーティングサービス → アプリケーションルーティングエリア
### 操作手順
1. ルーティングサービスが起動していることを確認
2. 「アプリケーションルーティング」エリアを見つける
3. 必要なアプリのスイッチをオンにする
### ルーティングスイッチ
| スイッチ | 作用 |
|------|------|
| Claude ルーティング | Claude Code のリクエストをルーティング |
| Codex ルーティング | Codex のリクエストをルーティング |
| Gemini ルーティング | Gemini CLI のリクエストをルーティング |
複数のアプリのルーティングを同時に有効にできます。
## ルーティングの仕組み
### 設定の変更
ルーティングを有効にすると、CC Switch はアプリの設定ファイルを変更し、API エンドポイントをローカルルーティングに向けます。
**Claude 設定の変更**
```json
// ルーティング前
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
}
}
// ルーティング後
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
}
}
```
**Codex 設定の変更**
```toml
# ルーティング前
base_url = "https://api.openai.com/v1"
# ルーティング後
base_url = "http://127.0.0.1:15721/v1"
```
**Gemini 設定の変更**
```bash
# ルーティング前
GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
# ルーティング後
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
```
### リクエストの転送
ルーティングサービスがリクエストを受信すると:
1. リクエスト元を識別(Claude/Codex/Gemini
2. そのアプリで現在有効なプロバイダーを検索
3. プロバイダーの実際のエンドポイントにリクエストを転送
4. リクエストログを記録
5. アプリにレスポンスを返却
## ルーティングステータスの表示
### メイン画面の表示
ルーティングを有効にすると、メイン画面に以下の変化があります:
- **ルーティング Logo の色**:無色から緑に変化
- **プロバイダーカード**:現在アクティブなプロバイダーに緑の枠が表示
### プロバイダーカードの状態
| 状態 | 枠の色 | 説明 |
|------|----------|------|
| 現在有効 | 青 | 設定ファイル内のプロバイダー(非ルーティングモード) |
| ルーティングアクティブ | 緑 | ルーティングが実際に使用しているプロバイダー |
| 通常 | デフォルト | 使用されていないプロバイダー |
## ルーティングの無効化
### 操作手順
1. ルーティングパネルで対応するアプリのルーティングスイッチをオフにする
2. またはルーティングサービスを直接停止
### 設定の復元
ルーティングを無効にすると、CC Switch は以下を実行します:
1. アプリの設定をルーティング前の状態に復元
2. 現在のリクエストログを保存
## ルーティングとプロバイダーの切り替え
### ルーティングモードでのプロバイダー切り替え
ルーティングモードでプロバイダーを切り替える場合:
1. メイン画面でプロバイダーの「有効化」ボタンをクリック
2. ルーティングサービスが新しいプロバイダーを使用してリクエストを即座に転送
3. **CLI ツールの再起動は不要**
これがルーティングモードの大きなメリットです:プロバイダーの切り替えが即座に反映されます。
### 非ルーティングモードでのプロバイダー切り替え
非ルーティングモードでプロバイダーを切り替える場合:
1. 設定ファイルを変更
2. CLI ツールの再起動が必要
## 複数アプリのルーティング
複数のアプリを同時にルーティングでき、それぞれ独立して管理されます:
- 独立したプロバイダー設定
- 独立したフェイルオーバーキュー
- 独立したリクエスト統計
## 使用シーン
### シーン 1:使用量の監視
ルーティング + ログ記録を有効にして、API の使用状況を監視します。
### シーン 2:素早い切り替え
ルーティングを有効にすると、プロバイダーの切り替えに CLI ツールの再起動が不要になります。
### シーン 3:フェイルオーバー
ルーティングの有効化はフェイルオーバー機能を使用するための前提条件です。
## 注意事項
### パフォーマンスへの影響
ルーティングにより少量のレイテンシ(通常 < 10ms)が追加されますが、ほとんどのシーンでは無視できます。
### ネットワーク要件
ルーティングモードでは、CLI ツールがローカルルーティングアドレスにアクセスできる必要があります。
### 設定のバックアップ
ルーティングを有効にする前に、CC Switch は元の設定をバックアップし、無効化時に復元します。
## よくある質問
### ルーティング後にリクエストが失敗する
確認事項:
- ルーティングサービスが正常に実行されているか
- プロバイダーの設定が正しいか
- ネットワークが正常か
### ルーティングを無効にしても設定が復元されない
考えられる原因:
- ルーティングサービスの異常終了
- 設定ファイルが他のプログラムに変更された
解決方法:
- プロバイダーを手動で編集して保存し直す
- または再度ルーティングを有効にしてから無効にする
-195
View File
@@ -1,195 +0,0 @@
# 4.2 アプリケーション接管
## 機能説明
アプリケーション接管とは、CC Switch のプロキシが特定アプリの API リクエストを接管することです。
接管を有効にすると:
- アプリの API リクエストがローカルプロキシ経由で転送される
- リクエストログと使用量の統計を記録できる
- フェイルオーバー機能を使用できる
## 前提条件
アプリケーション接管機能を使用する前に、プロキシサービスを起動する必要があります。
## 接管の有効化
### 操作場所
設定 → 詳細 → プロキシサービス → アプリケーション接管エリア
### 操作手順
1. プロキシサービスが起動していることを確認
2. 「アプリケーション接管」エリアを見つける
3. 必要なアプリのスイッチをオンにする
### 接管スイッチ
| スイッチ | 作用 |
|------|------|
| Claude 接管 | Claude Code のリクエストを接管 |
| Codex 接管 | Codex のリクエストを接管 |
| Gemini 接管 | Gemini CLI のリクエストを接管 |
複数のアプリの接管を同時に有効にできます。
## 接管の仕組み
### 設定の変更
接管を有効にすると、CC Switch はアプリの設定ファイルを変更し、API エンドポイントをローカルプロキシに向けます。
**Claude 設定の変更**
```json
// 接管前
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
}
}
// 接管後
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
}
}
```
**Codex 設定の変更**
```toml
# 接管前
base_url = "https://api.openai.com/v1"
# 接管後
base_url = "http://127.0.0.1:15721/v1"
```
**Gemini 設定の変更**
```bash
# 接管前
GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
# 接管後
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
```
### リクエストの転送
プロキシがリクエストを受信すると:
1. リクエスト元を識別(Claude/Codex/Gemini
2. そのアプリで現在有効なプロバイダーを検索
3. プロバイダーの実際のエンドポイントにリクエストを転送
4. リクエストログを記録
5. アプリにレスポンスを返却
## 接管ステータスの表示
### メイン画面の表示
接管を有効にすると、メイン画面に以下の変化があります:
- **プロキシ Logo の色**:無色から緑に変化
- **プロバイダーカード**:現在アクティブなプロバイダーに緑の枠が表示
### プロバイダーカードの状態
| 状態 | 枠の色 | 説明 |
|------|----------|------|
| 現在有効 | 青 | 設定ファイル内のプロバイダー(非プロキシモード) |
| プロキシアクティブ | 緑 | プロキシが実際に使用しているプロバイダー |
| 通常 | デフォルト | 使用されていないプロバイダー |
## 接管の無効化
### 操作手順
1. プロキシパネルで対応するアプリの接管スイッチをオフにする
2. またはプロキシサービスを直接停止
### 設定の復元
接管を無効にすると、CC Switch は以下を実行します:
1. アプリの設定を接管前の状態に復元
2. 現在のリクエストログを保存
## 接管とプロバイダーの切り替え
### 接管モードでのプロバイダー切り替え
接管モードでプロバイダーを切り替える場合:
1. メイン画面でプロバイダーの「有効化」ボタンをクリック
2. プロキシが新しいプロバイダーを使用してリクエストを即座に転送
3. **CLI ツールの再起動は不要**
これが接管モードの大きなメリットです:プロバイダーの切り替えが即座に反映されます。
### 非接管モードでのプロバイダー切り替え
非接管モードでプロバイダーを切り替える場合:
1. 設定ファイルを変更
2. CLI ツールの再起動が必要
## 複数アプリの接管
複数のアプリを同時に接管でき、それぞれ独立して管理されます:
- 独立したプロバイダー設定
- 独立したフェイルオーバーキュー
- 独立したリクエスト統計
## 使用シーン
### シーン 1:使用量の監視
接管 + ログ記録を有効にして、API の使用状況を監視します。
### シーン 2:素早い切り替え
接管を有効にすると、プロバイダーの切り替えに CLI ツールの再起動が不要になります。
### シーン 3:フェイルオーバー
接管の有効化はフェイルオーバー機能を使用するための前提条件です。
## 注意事項
### パフォーマンスへの影響
プロキシにより少量のレイテンシ(通常 < 10ms)が追加されますが、ほとんどのシーンでは無視できます。
### ネットワーク要件
接管モードでは、CLI ツールがローカルプロキシアドレスにアクセスできる必要があります。
### 設定のバックアップ
接管を有効にする前に、CC Switch は元の設定をバックアップし、無効化時に復元します。
## よくある質問
### 接管後にリクエストが失敗する
確認事項:
- プロキシサービスが正常に実行されているか
- プロバイダーの設定が正しいか
- ネットワークが正常か
### 接管を無効にしても設定が復元されない
考えられる原因:
- プロキシの異常終了
- 設定ファイルが他のプログラムに変更された
解決方法:
- プロバイダーを手動で編集して保存し直す
- または接管を再度有効にしてから無効にする
+1 -1
View File
@@ -79,7 +79,7 @@ CC Switch ユーザーマニュアル
| ファイル | 内容 |
|------|------|
| [4.1-service.md](./4-proxy/4.1-service.md) | プロキシの起動、設定項目、実行状態 |
| [4.2-takeover.md](./4-proxy/4.2-takeover.md) | アプリケーション接管、設定変更、ステータス表示 |
| [4.2-routing.md](./4-proxy/4.2-routing.md) | アプリケーションルーティング、設定変更、ステータス表示 |
| [4.3-failover.md](./4-proxy/4.3-failover.md) | フェイルオーバーキュー、サーキットブレーカー、ヘルスステータス |
| [4.4-usage.md](./4-proxy/4.4-usage.md) | 使用量統計、トレンドグラフ、料金設定 |
| [4.5-model-test.md](./4-proxy/4.5-model-test.md) | モデルテスト、ヘルスチェック、レイテンシテスト |
+195
View File
@@ -0,0 +1,195 @@
# 4.2 应用路由
## 功能说明
应用路由是指让 CC Switch 路由特定应用的 API 请求。
开启路由后:
- 应用的 API 请求会通过本地路由转发
- 可以记录请求日志和统计用量
- 可以使用故障转移功能
## 前提条件
使用应用路由功能前,需要先启动路由服务。
## 开启路由
### 操作位置
设置 → 高级 → 路由服务 → 应用路由区域
### 操作步骤
1. 确保路由服务已启动
2. 找到「应用路由」区域
3. 为需要的应用开启开关
### 路由开关
| 开关 | 作用 |
|------|------|
| Claude 路由 | 路由 Claude Code 的请求 |
| Codex 路由 | 路由 Codex 的请求 |
| Gemini 路由 | 路由 Gemini CLI 的请求 |
可以同时开启多个应用的路由。
## 路由原理
### 配置修改
开启路由后,CC Switch 会修改应用的配置文件,将 API 端点指向本地路由。
**Claude 配置变更**
```json
// 路由前
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
}
}
// 路由后
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
}
}
```
**Codex 配置变更**
```toml
# 路由前
base_url = "https://api.openai.com/v1"
# 路由后
base_url = "http://127.0.0.1:15721/v1"
```
**Gemini 配置变更**
```bash
# 路由前
GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
# 路由后
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
```
### 请求转发
路由收到请求后:
1. 识别请求来源(Claude/Codex/Gemini
2. 查找该应用当前启用的供应商
3. 将请求转发到供应商的实际端点
4. 记录请求日志
5. 返回响应给应用
## 路由状态指示
### 主界面指示
开启路由后,主界面会有以下变化:
- **路由 Logo 颜色**:从无色变为绿色
- **供应商卡片**:当前活跃的供应商显示绿色边框
### 供应商卡片状态
| 状态 | 边框颜色 | 说明 |
|------|----------|------|
| 当前启用 | 蓝色 | 配置文件中的供应商(非路由模式) |
| 路由活跃 | 绿色 | 路由实际使用的供应商 |
| 普通 | 默认 | 未使用的供应商 |
## 关闭路由
### 操作步骤
1. 在路由面板中关闭对应应用的路由开关
2. 或直接停止路由服务
### 配置恢复
关闭路由时,CC Switch 会:
1. 将应用配置恢复到路由前的状态
2. 保存当前的请求日志
## 路由与供应商切换
### 路由模式下切换供应商
在路由模式下切换供应商:
1. 在主界面点击供应商的「启用」按钮
2. 路由立即使用新供应商转发请求
3. **无需重启 CLI 工具**
这是路由模式的一大优势:切换供应商即时生效。
### 非路由模式下切换
在非路由模式下切换供应商:
1. 修改配置文件
2. 需要重启 CLI 工具才能生效
## 多应用路由
可以同时路由多个应用,每个应用独立管理:
- 独立的供应商配置
- 独立的故障转移队列
- 独立的请求统计
## 使用场景
### 场景一:用量监控
开启路由 + 日志记录,监控 API 使用情况。
### 场景二:快速切换
开启路由后,切换供应商无需重启 CLI 工具。
### 场景三:故障转移
开启路由是使用故障转移功能的前提。
## 注意事项
### 性能影响
路由会增加少量延迟(通常 < 10ms),对于大多数场景可以忽略。
### 网络要求
路由模式下,CLI 工具需要能够访问本地路由地址。
### 配置备份
开启路由前,CC Switch 会备份原始配置,关闭时恢复。
## 常见问题
### 路由后请求失败
检查:
- 路由服务是否正常运行
- 供应商配置是否正确
- 网络是否正常
### 关闭路由后配置未恢复
可能原因:
- 路由异常退出
- 配置文件被其他程序修改
解决方法:
- 手动编辑供应商,重新保存
- 或重新启用再关闭路由
-195
View File
@@ -1,195 +0,0 @@
# 4.2 应用接管
## 功能说明
应用接管是指让 CC Switch 代理接管特定应用的 API 请求。
开启接管后:
- 应用的 API 请求会通过本地代理转发
- 可以记录请求日志和统计用量
- 可以使用故障转移功能
## 前提条件
使用应用接管功能前,需要先启动代理服务。
## 开启接管
### 操作位置
设置 → 高级 → 代理服务 → 应用接管区域
### 操作步骤
1. 确保代理服务已启动
2. 找到「应用接管」区域
3. 为需要的应用开启开关
### 接管开关
| 开关 | 作用 |
|------|------|
| Claude 接管 | 接管 Claude Code 的请求 |
| Codex 接管 | 接管 Codex 的请求 |
| Gemini 接管 | 接管 Gemini CLI 的请求 |
可以同时开启多个应用的接管。
## 接管原理
### 配置修改
开启接管后,CC Switch 会修改应用的配置文件,将 API 端点指向本地代理。
**Claude 配置变更**
```json
// 接管前
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
}
}
// 接管后
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
}
}
```
**Codex 配置变更**
```toml
# 接管前
base_url = "https://api.openai.com/v1"
# 接管后
base_url = "http://127.0.0.1:15721/v1"
```
**Gemini 配置变更**
```bash
# 接管前
GOOGLE_GEMINI_BASE_URL=https://generativelanguage.googleapis.com
# 接管后
GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:15721
```
### 请求转发
代理收到请求后:
1. 识别请求来源(Claude/Codex/Gemini
2. 查找该应用当前启用的供应商
3. 将请求转发到供应商的实际端点
4. 记录请求日志
5. 返回响应给应用
## 接管状态指示
### 主界面指示
开启接管后,主界面会有以下变化:
- **代理 Logo 颜色**:从无色变为绿色
- **供应商卡片**:当前活跃的供应商显示绿色边框
### 供应商卡片状态
| 状态 | 边框颜色 | 说明 |
|------|----------|------|
| 当前启用 | 蓝色 | 配置文件中的供应商(非代理模式) |
| 代理活跃 | 绿色 | 代理实际使用的供应商 |
| 普通 | 默认 | 未使用的供应商 |
## 关闭接管
### 操作步骤
1. 在代理面板中关闭对应应用的接管开关
2. 或直接停止代理服务
### 配置恢复
关闭接管时,CC Switch 会:
1. 将应用配置恢复到接管前的状态
2. 保存当前的请求日志
## 接管与供应商切换
### 接管模式下切换供应商
在接管模式下切换供应商:
1. 在主界面点击供应商的「启用」按钮
2. 代理立即使用新供应商转发请求
3. **无需重启 CLI 工具**
这是接管模式的一大优势:切换供应商即时生效。
### 非接管模式下切换
在非接管模式下切换供应商:
1. 修改配置文件
2. 需要重启 CLI 工具才能生效
## 多应用接管
可以同时接管多个应用,每个应用独立管理:
- 独立的供应商配置
- 独立的故障转移队列
- 独立的请求统计
## 使用场景
### 场景一:用量监控
开启接管 + 日志记录,监控 API 使用情况。
### 场景二:快速切换
开启接管后,切换供应商无需重启 CLI 工具。
### 场景三:故障转移
开启接管是使用故障转移功能的前提。
## 注意事项
### 性能影响
代理会增加少量延迟(通常 < 10ms),对于大多数场景可以忽略。
### 网络要求
接管模式下,CLI 工具需要能够访问本地代理地址。
### 配置备份
开启接管前,CC Switch 会备份原始配置,关闭时恢复。
## 常见问题
### 接管后请求失败
检查:
- 代理服务是否正常运行
- 供应商配置是否正确
- 网络是否正常
### 关闭接管后配置未恢复
可能原因:
- 代理异常退出
- 配置文件被其他程序修改
解决方法:
- 手动编辑供应商,重新保存
- 或重新启用再关闭接管
+1 -1
View File
@@ -79,7 +79,7 @@
| 文件 | 内容 |
|------|------|
| [4.1-service.md](./4-proxy/4.1-service.md) | 启动代理、配置项、运行状态 |
| [4.2-takeover.md](./4-proxy/4.2-takeover.md) | 应用接管、配置修改、状态指示 |
| [4.2-routing.md](./4-proxy/4.2-routing.md) | 应用路由、配置修改、状态指示 |
| [4.3-failover.md](./4-proxy/4.3-failover.md) | 故障转移队列、熔断器、健康状态 |
| [4.4-usage.md](./4-proxy/4.4-usage.md) | 用量统计、趋势图表、定价配置 |
| [4.5-model-test.md](./4-proxy/4.5-model-test.md) | 模型检查、健康检测、延迟测试 |
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.12.3",
"version": "3.13.0",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
@@ -67,6 +67,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.4",
"@tanstack/react-query": "^5.90.3",
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-process": "^2.0.0",
+20
View File
@@ -89,6 +89,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.90.3
version: 5.90.3(react@18.3.1)
'@tanstack/react-virtual':
specifier: ^3.13.23
version: 3.13.23(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tauri-apps/api':
specifier: ^2.8.0
version: 2.8.0
@@ -1468,6 +1471,15 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/react-virtual@3.13.23':
resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.13.23':
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
'@tauri-apps/api@2.8.0':
resolution: {integrity: sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw==}
@@ -4275,6 +4287,14 @@ snapshots:
'@tanstack/query-core': 5.90.3
react: 18.3.1
'@tanstack/react-virtual@3.13.23(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/virtual-core': 3.13.23
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@tanstack/virtual-core@3.13.23': {}
'@tauri-apps/api@2.8.0': {}
'@tauri-apps/cli-darwin-arm64@2.8.1':
-115
View File
@@ -1,115 +0,0 @@
const fs = require('fs');
const path = require('path');
const ICONS_DIR = path.join(__dirname, '../src/icons/extracted');
const INDEX_FILE = path.join(ICONS_DIR, 'index.ts');
const METADATA_FILE = path.join(ICONS_DIR, 'metadata.ts');
// Known metadata from previous configuration
const KNOWN_METADATA = {
openai: { name: 'openai', displayName: 'OpenAI', category: 'ai-provider', keywords: ['gpt', 'chatgpt'], defaultColor: '#00A67E' },
anthropic: { name: 'anthropic', displayName: 'Anthropic', category: 'ai-provider', keywords: ['claude'], defaultColor: '#D4915D' },
claude: { name: 'claude', displayName: 'Claude', category: 'ai-provider', keywords: ['anthropic'], defaultColor: '#D4915D' },
google: { name: 'google', displayName: 'Google', category: 'ai-provider', keywords: ['gemini', 'bard'], defaultColor: '#4285F4' },
gemini: { name: 'gemini', displayName: 'Gemini', category: 'ai-provider', keywords: ['google'], defaultColor: '#4285F4' },
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
stepfun: { name: 'stepfun', displayName: 'StepFun', category: 'ai-provider', keywords: ['stepfun', 'step', 'jieyue', '阶跃星辰'], defaultColor: '#005AFF' },
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
alibaba: { name: 'alibaba', displayName: 'Alibaba', category: 'ai-provider', keywords: ['qwen', 'tongyi'], defaultColor: '#FF6A00' },
tencent: { name: 'tencent', displayName: 'Tencent', category: 'ai-provider', keywords: ['hunyuan'], defaultColor: '#00A4FF' },
meta: { name: 'meta', displayName: 'Meta', category: 'ai-provider', keywords: ['facebook', 'llama'], defaultColor: '#0081FB' },
microsoft: { name: 'microsoft', displayName: 'Microsoft', category: 'ai-provider', keywords: ['copilot', 'azure'], defaultColor: '#00A4EF' },
cohere: { name: 'cohere', displayName: 'Cohere', category: 'ai-provider', keywords: ['cohere'], defaultColor: '#39594D' },
perplexity: { name: 'perplexity', displayName: 'Perplexity', category: 'ai-provider', keywords: ['perplexity'], defaultColor: '#20808D' },
packycode: { name: 'packycode', displayName: 'PackyCode', category: 'ai-provider', keywords: ['packycode', 'packy', 'packyapi'], defaultColor: 'currentColor' },
mistral: { name: 'mistral', displayName: 'Mistral', category: 'ai-provider', keywords: ['mistral'], defaultColor: '#FF7000' },
huggingface: { name: 'huggingface', displayName: 'Hugging Face', category: 'ai-provider', keywords: ['huggingface', 'hf'], defaultColor: '#FFD21E' },
aws: { name: 'aws', displayName: 'AWS', category: 'cloud', keywords: ['amazon', 'cloud'], defaultColor: '#FF9900' },
azure: { name: 'azure', displayName: 'Azure', category: 'cloud', keywords: ['microsoft', 'cloud'], defaultColor: '#0078D4' },
huawei: { name: 'huawei', displayName: 'Huawei', category: 'cloud', keywords: ['huawei', 'cloud'], defaultColor: '#FF0000' },
cloudflare: { name: 'cloudflare', displayName: 'Cloudflare', category: 'cloud', keywords: ['cloudflare', 'cdn'], defaultColor: '#F38020' },
github: { name: 'github', displayName: 'GitHub', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#181717' },
gitlab: { name: 'gitlab', displayName: 'GitLab', category: 'tool', keywords: ['git', 'version control'], defaultColor: '#FC6D26' },
docker: { name: 'docker', displayName: 'Docker', category: 'tool', keywords: ['container'], defaultColor: '#2496ED' },
kubernetes: { name: 'kubernetes', displayName: 'Kubernetes', category: 'tool', keywords: ['k8s', 'container'], defaultColor: '#326CE5' },
vscode: { name: 'vscode', displayName: 'VS Code', category: 'tool', keywords: ['editor', 'ide'], defaultColor: '#007ACC' },
settings: { name: 'settings', displayName: 'Settings', category: 'other', keywords: ['config', 'preferences'], defaultColor: '#6B7280' },
folder: { name: 'folder', displayName: 'Folder', category: 'other', keywords: ['directory'], defaultColor: '#6B7280' },
file: { name: 'file', displayName: 'File', category: 'other', keywords: ['document'], defaultColor: '#6B7280' },
link: { name: 'link', displayName: 'Link', category: 'other', keywords: ['url', 'hyperlink'], defaultColor: '#6B7280' },
};
// Get all SVG files
const files = fs.readdirSync(ICONS_DIR).filter(file => file.endsWith('.svg'));
console.log(`Found ${files.length} SVG files.`);
// Generate index.ts
const indexContent = `// Auto-generated icon index
// Do not edit manually
export const icons: Record<string, string> = {
${files.map(file => {
const name = path.basename(file, '.svg');
const svg = fs.readFileSync(path.join(ICONS_DIR, file), 'utf-8');
const escaped = svg.replace(/`/g, '\\`').replace(/\$/g, '\\$');
return ` '${name}': \`${escaped}\`,`;
}).join('\n')}
};
export const iconList = Object.keys(icons);
export function getIcon(name: string): string {
return icons[name.toLowerCase()] || '';
}
export function hasIcon(name: string): boolean {
return name.toLowerCase() in icons;
}
`;
fs.writeFileSync(INDEX_FILE, indexContent);
console.log(`Generated ${INDEX_FILE}`);
// Generate metadata.ts
const metadataEntries = files.map(file => {
const name = path.basename(file, '.svg').toLowerCase();
const known = KNOWN_METADATA[name];
if (known) {
return ` ${name}: ${JSON.stringify(known)},`;
}
// Default metadata for unknown icons
return ` '${name}': { name: '${name}', displayName: '${name}', category: 'other', keywords: [], defaultColor: 'currentColor' },`;
});
const metadataContent = `// Icon metadata for search and categorization
import { IconMetadata } from '@/types/icon';
export const iconMetadata: Record<string, IconMetadata> = {
${metadataEntries.join('\n')}
};
export function getIconMetadata(name: string): IconMetadata | undefined {
return iconMetadata[name.toLowerCase()];
}
export function searchIcons(query: string): string[] {
const lowerQuery = query.toLowerCase();
return Object.values(iconMetadata)
.filter(meta =>
meta.name.includes(lowerQuery) ||
meta.displayName.toLowerCase().includes(lowerQuery) ||
meta.keywords.some(k => k.includes(lowerQuery))
)
.map(meta => meta.name);
}
`;
fs.writeFileSync(METADATA_FILE, metadataContent);
console.log(`Generated ${METADATA_FILE}`);
+1 -1
View File
@@ -735,7 +735,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.12.3"
version = "3.13.0"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.12.3"
version = "3.13.0"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
+5
View File
@@ -11,6 +11,11 @@
"updater:default",
"core:window:allow-set-skip-taskbar",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-is-maximized",
"core:window:allow-close",
"core:window:allow-set-decorations",
"process:allow-restart",
"dialog:default"
]
+1
View File
@@ -948,6 +948,7 @@ exec bash --norc --noprofile
"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), // "terminal" or default
};
+13
View File
@@ -253,6 +253,19 @@ pub async fn switch_proxy_provider(
app_type: String,
provider_id: String,
) -> Result<(), String> {
// Block official providers during proxy takeover
let provider = state
.db
.get_provider_by_id(&provider_id, &app_type)
.map_err(|e| format!("读取供应商失败: {e}"))?
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
if provider.category.as_deref() == Some("official") {
return Err(
"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)"
.to_string(),
);
}
state
.proxy_service
.switch_proxy_target(&app_type, &provider_id)
+19 -9
View File
@@ -116,15 +116,25 @@ pub async fn stream_check_all_providers(
claude_api_format_override,
)
.await
.unwrap_or_else(|e| StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message: e.to_string(),
response_time_ms: None,
http_status: None,
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
retry_count: 0,
.unwrap_or_else(|e| {
let (http_status, message) = match &e {
crate::error::AppError::HttpStatus { status, .. } => (
Some(*status),
StreamCheckService::classify_http_status(*status).to_string(),
),
_ => (None, e.to_string()),
};
StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message,
response_time_ms: None,
http_status,
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
retry_count: 0,
error_category: None,
}
});
let _ = state
+1 -1
View File
@@ -44,7 +44,7 @@ use std::sync::Mutex;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 8;
pub(crate) const SCHEMA_VERSION: i32 = 9;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+251 -19
View File
@@ -418,6 +418,11 @@ impl Database {
Self::migrate_v7_to_v8(conn)?;
Self::set_user_version(conn, 8)?;
}
8 => {
log::info!("迁移数据库从 v8 到 v9(全面补充模型定价)");
Self::migrate_v8_to_v9(conn)?;
Self::set_user_version(conn, 9)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -1144,6 +1149,25 @@ impl Database {
Ok(())
}
/// v8 → v9: 全面补充模型定价(清空 + 重新 seed)
fn migrate_v8_to_v9(conn: &Connection) -> Result<(), AppError> {
conn.execute(
"CREATE TABLE IF NOT EXISTS model_pricing (
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
)",
[],
)
.map_err(|e| AppError::Database(format!("创建 model_pricing 表失败: {e}")))?;
conn.execute("DELETE FROM model_pricing", [])
.map_err(|e| AppError::Database(format!("清空模型定价失败: {e}")))?;
Self::seed_model_pricing(conn)?;
log::info!("v8 -> v9 迁移完成:已刷新全部模型定价数据");
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
@@ -1491,6 +1515,38 @@ impl Database {
"0.02",
"0",
),
(
"doubao-seed-2-0-pro",
"Doubao Seed 2.0 Pro",
"0.47",
"2.37",
"0",
"0",
),
(
"doubao-seed-2-0-code",
"Doubao Seed 2.0 Code",
"0.47",
"2.37",
"0",
"0",
),
(
"doubao-seed-2-0-lite",
"Doubao Seed 2.0 Lite",
"0.25",
"2",
"0",
"0",
),
(
"doubao-seed-2-0-mini",
"Doubao Seed 2.0 Mini",
"0.03",
"0.31",
"0",
"0",
),
// DeepSeek 系列
(
"deepseek-v3.2",
@@ -1512,17 +1568,17 @@ impl Database {
(
"deepseek-chat",
"DeepSeek Chat",
"0.28",
"0.42",
"0.028",
"0.27",
"1.10",
"0.07",
"0",
),
(
"deepseek-reasoner",
"DeepSeek Reasoner",
"0.28",
"0.42",
"0.028",
"0.55",
"2.19",
"0.14",
"0",
),
// Kimi (月之暗面)
@@ -1543,7 +1599,7 @@ impl Database {
"0.14",
"0",
),
("kimi-k2.5", "Kimi K2.5", "0.60", "3.00", "0.10", "0"),
("kimi-k2.5", "Kimi K2.5", "0.60", "2.50", "0.10", "0"),
// MiniMax 系列
("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"),
(
@@ -1555,35 +1611,211 @@ impl Database {
"0",
),
("minimax-m2", "MiniMax M2", "0.27", "0.95", "0.03", "0"),
("minimax-m2.5", "MiniMax M2.5", "0.12", "0.95", "0.03", "0"),
(
"minimax-m2.5-lightning",
"MiniMax M2.5 Lightning",
"0.30",
"2.40",
"0.03",
"0",
),
(
"minimax-m2.7",
"MiniMax M2.7",
"0.30",
"1.20",
"0.06",
"0.375",
),
(
"minimax-m2.7-highspeed",
"MiniMax M2.7 Highspeed",
"0.60",
"2.40",
"0.06",
"0.375",
),
// GLM (智谱)
("glm-4.7", "GLM-4.7", "0.39", "1.75", "0.04", "0"),
("glm-4.6", "GLM-4.6", "0.28", "1.11", "0.03", "0"),
// Mimo (小米)
("glm-5", "GLM-5", "0.72", "2.30", "0", "0"),
("glm-5.1", "GLM-5.1", "0.95", "3.15", "0", "0"),
// MiMo (小米)
(
"mimo-v2-flash",
"Mimo V2 Flash",
"MiMo V2 Flash",
"0.09",
"0.29",
"0.009",
"0",
),
("mimo-v2-pro", "MiMo V2 Pro", "1", "3", "0", "0"),
// Qwen 系列 (阿里巴巴)
("qwen3.6-plus", "Qwen3.6 Plus", "0.325", "1.95", "0", "0"),
("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0", "0"),
("qwen3-max", "Qwen3 Max", "0.78", "3.90", "0", "0"),
(
"qwen3-235b-a22b",
"Qwen3 235B-A22B",
"0.70",
"8.40",
"0",
"0",
),
(
"qwen3-coder-plus",
"Qwen3 Coder Plus",
"0.65",
"3.25",
"0",
"0",
),
(
"qwen3-coder-flash",
"Qwen3 Coder Flash",
"0.195",
"0.975",
"0",
"0",
),
(
"qwen3-coder-next",
"Qwen3 Coder Next",
"0.12",
"0.75",
"0",
"0",
),
("qwq-plus", "QwQ Plus", "0.80", "2.40", "0", "0"),
("qwq-32b", "QwQ 32B", "0.20", "0.60", "0", "0"),
("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"),
// Grok 系列 (xAI)
(
"grok-4.20-0309-reasoning",
"Grok 4.20 Reasoning",
"2",
"6",
"0.20",
"0",
),
(
"grok-4.20-0309-non-reasoning",
"Grok 4.20",
"2",
"6",
"0.20",
"0",
),
(
"grok-4-1-fast-reasoning",
"Grok 4.1 Fast Reasoning",
"0.20",
"0.50",
"0.05",
"0",
),
(
"grok-4-1-fast-non-reasoning",
"Grok 4.1 Fast",
"0.20",
"0.50",
"0.05",
"0",
),
("grok-4", "Grok 4", "3", "15", "0.75", "0"),
(
"grok-code-fast-1",
"Grok Code Fast",
"0.20",
"1.50",
"0.02",
"0",
),
("grok-3", "Grok 3", "3", "15", "0.75", "0"),
("grok-3-mini", "Grok 3 Mini", "0.25", "0.50", "0.075", "0"),
// Mistral 系列
("codestral-2508", "Codestral", "0.30", "0.90", "0.03", "0"),
(
"devstral-small-1.1",
"Devstral Small 1.1",
"0.07",
"0.28",
"0.01",
"0",
),
("devstral-2-2512", "Devstral 2", "0.40", "0.90", "0.04", "0"),
(
"devstral-medium",
"Devstral Medium",
"0.40",
"2",
"0.04",
"0",
),
(
"mistral-large-3-2512",
"Mistral Large 3",
"0.50",
"1.50",
"0.05",
"0",
),
(
"mistral-medium-3.1",
"Mistral Medium 3.1",
"0.40",
"2",
"0.04",
"0",
),
(
"mistral-small-3.2-24b",
"Mistral Small 3.2",
"0.075",
"0.20",
"0.01",
"0",
),
("magistral-medium", "Magistral Medium", "2", "5", "0", "0"),
// Cohere 系列
("command-a", "Cohere Command A", "2.50", "10", "0", "0"),
(
"command-r-plus",
"Cohere Command R+",
"2.50",
"10",
"0",
"0",
),
("command-r", "Cohere Command R", "0.15", "0.60", "0", "0"),
// OpenAI 补充
("o3-pro", "OpenAI o3-pro", "20", "80", "0", "0"),
("o3-mini", "OpenAI o3-mini", "0.55", "2.20", "0.55", "0"),
("o1", "OpenAI o1", "15", "60", "7.50", "0"),
("o1-mini", "OpenAI o1-mini", "0.55", "2.20", "0.55", "0"),
("codex-mini", "Codex Mini", "0.75", "3", "0.025", "0"),
("gpt-5-mini", "GPT-5 Mini", "0.25", "2", "0.025", "0"),
("gpt-5-nano", "GPT-5 Nano", "0.05", "0.40", "0.005", "0"),
];
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
conn.execute(
let mut stmt = conn
.prepare(
"INSERT OR IGNORE INTO model_pricing (
model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![
model_id,
display_name,
input,
output,
cache_read,
cache_creation
],
)
.map_err(|e| AppError::Database(format!("准备模型定价语句失败: {e}")))?;
for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
stmt.execute(rusqlite::params![
model_id,
display_name,
input,
output,
cache_read,
cache_creation
])
.map_err(|e| AppError::Database(format!("插入模型定价失败: {e}")))?;
}
+2
View File
@@ -44,6 +44,8 @@ pub enum AppError {
McpValidation(String),
#[error("{0}")]
Message(String),
#[error("HTTP {status}: {body}")]
HttpStatus { status: u16, body: String },
#[error("{zh} ({en})")]
Localized {
key: &'static str,
+4
View File
@@ -971,6 +971,10 @@ pub fn run() {
// 静默启动:根据设置决定是否显示主窗口
let settings = crate::settings::get_settings();
if let Some(window) = app.get_webview_window("main") {
// 在窗口首次显示前同步装饰状态,避免前端加载后再切换导致标题栏闪烁
// 仅 Linux 生效:解决 Wayland 下系统窗口按钮不可用的问题
#[cfg(target_os = "linux")]
let _ = window.set_decorations(!settings.use_app_window_controls);
if settings.silent_startup {
// 静默启动模式:保持窗口隐藏
let _ = window.hide();
+6 -1
View File
@@ -61,7 +61,12 @@ pub fn read_opencode_config() -> Result<Value, AppError> {
}
let content = std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
serde_json::from_str(&content).map_err(|e| AppError::json(&path, e))
json5::from_str(&content).map_err(|e| {
AppError::Config(format!(
"Failed to parse OpenCode config: {}: {e}",
path.display()
))
})
}
pub fn write_opencode_config(config: &Value) -> Result<(), AppError> {
+3 -29
View File
@@ -172,29 +172,6 @@ pub struct ProviderTestConfig {
pub max_retries: Option<u32>,
}
/// 供应商单独的代理配置
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderProxyConfig {
/// 是否启用单独配置(false 时使用全局/系统代理)
#[serde(default)]
pub enabled: bool,
/// 代理类型:http, https, socks5
#[serde(rename = "proxyType", skip_serializing_if = "Option::is_none")]
pub proxy_type: Option<String>,
/// 代理主机
#[serde(rename = "proxyHost", skip_serializing_if = "Option::is_none")]
pub proxy_host: Option<String>,
/// 代理端口
#[serde(rename = "proxyPort", skip_serializing_if = "Option::is_none")]
pub proxy_port: Option<u16>,
/// 代理用户名(可选)
#[serde(rename = "proxyUsername", skip_serializing_if = "Option::is_none")]
pub proxy_username: Option<String>,
/// 代理密码(可选)
#[serde(rename = "proxyPassword", skip_serializing_if = "Option::is_none")]
pub proxy_password: Option<String>,
}
/// 认证绑定来源
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
@@ -262,9 +239,6 @@ pub struct ProviderMeta {
/// 供应商单独的模型测试配置
#[serde(rename = "testConfig", skip_serializing_if = "Option::is_none")]
pub test_config: Option<ProviderTestConfig>,
/// 供应商单独的代理配置
#[serde(rename = "proxyConfig", skip_serializing_if = "Option::is_none")]
pub proxy_config: Option<ProviderProxyConfig>,
/// Claude API 格式(仅 Claude 供应商使用)
/// - "anthropic": 原生 Anthropic Messages API,直接透传
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
@@ -282,9 +256,9 @@ pub struct ProviderMeta {
/// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径)
#[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")]
pub is_full_url: Option<bool>,
/// Prompt cache key for OpenAI-compatible endpoints.
/// When set, injected into converted requests to improve cache hit rate.
/// If not set, provider ID is used automatically during format conversion.
/// Prompt cache key for OpenAI Responses-compatible endpoints.
/// When set, injected into converted Responses requests to improve cache hit rate.
/// 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>,
/// 累加模式应用中,该 provider 是否已写入 live config。
+517 -44
View File
@@ -8,6 +8,8 @@
//!
//! 参考实现: https://github.com/caozhiyuan/copilot-api
use std::collections::HashSet;
use serde_json::Value;
use sha2::{Digest, Sha256};
use uuid::Uuid;
@@ -21,6 +23,9 @@ pub struct CopilotClassification {
pub is_warmup: bool,
/// 是否为上下文压缩请求
pub is_compact: bool,
/// 是否为 Claude Code 子代理请求(Agent tool 生成的 subagent
/// 子代理请求应设置 x-interaction-type=conversation-subagent,不计 premium interaction
pub is_subagent: bool,
}
/// 分类 Anthropic 格式的请求体,决定 Copilot 请求头。
@@ -38,12 +43,17 @@ pub struct CopilotClassification {
///
/// `compact_detection`:是否启用 compact 检测。为 false 时跳过,
/// 确保 `CopilotOptimizerConfig.compact_detection` 开关真正生效。
///
/// `subagent_detection`:是否启用子代理检测。为 true 时,会扫描首条用户消息
/// 中的 `__SUBAGENT_MARKER__` 标记,将子代理请求标记为不计费。
pub fn classify_request(
body: &Value,
has_anthropic_beta: bool,
compact_detection: bool,
subagent_detection: bool,
) -> CopilotClassification {
let is_compact = compact_detection && is_compact_request(body);
let is_subagent = subagent_detection && detect_subagent(body);
let messages = match body.get("messages").and_then(|m| m.as_array()) {
Some(msgs) if !msgs.is_empty() => msgs,
@@ -52,6 +62,7 @@ pub fn classify_request(
initiator: "user",
is_warmup: is_warmup_request(body, has_anthropic_beta, false),
is_compact: false,
is_subagent,
}
}
};
@@ -62,29 +73,33 @@ pub fn classify_request(
// 只有 role=user 的消息需要细分
if role != "user" {
return CopilotClassification {
initiator: "user",
initiator: if is_subagent { "agent" } else { "user" },
is_warmup: false,
is_compact,
is_subagent,
};
}
// 参考实现的判定逻辑(Messages API 路径):
// 如果 content 数组,检查是否有非 tool_result 的 block
// 有 → "user",全是 tool_result → "agent"
// 如果 content 是字符串 → "user"
// 判定逻辑(与 copilot-api 的 merge-then-classify 效果对齐):
// 只要 content 数组中包含 tool_result → 视为工具续写 → agent
// 这覆盖了 skill/edit hook/plan follow-up 等常见场景,
// 它们的 content 通常是 [tool_result, text] 混合形态。
// copilot-api 通过先 mergetext 吸收进 tool_result)再 classify 实现同等效果;
// 直接在分类层处理更稳健,不依赖 merge 启用状态和执行顺序。
let is_user_initiated = match last_msg.get("content") {
Some(content) if content.is_array() => {
let blocks = content.as_array().unwrap();
// 存在非 tool_result block → 用户发起
blocks
// 含有 tool_result → 工具续写(agent),否则 → 用户发起user
!blocks
.iter()
.any(|block| block.get("type").and_then(|t| t.as_str()) != Some("tool_result"))
.any(|block| block.get("type").and_then(|t| t.as_str()) == Some("tool_result"))
}
Some(content) if content.is_string() => true,
_ => false,
};
let initiator = if !is_user_initiated || is_compact {
// 子代理请求始终标记为 agent(即使首条消息包含用户文本)
let initiator = if is_subagent || !is_user_initiated || is_compact {
"agent"
} else {
"user"
@@ -94,6 +109,7 @@ pub fn classify_request(
initiator,
is_warmup: initiator == "user" && is_warmup_request(body, has_anthropic_beta, is_compact),
is_compact,
is_subagent,
}
}
@@ -123,23 +139,11 @@ fn is_warmup_request(body: &Value, has_anthropic_beta: bool, is_compact: bool) -
fn is_compact_request(body: &Value) -> bool {
// 信号 1: system prompt 以 Claude Code compact 专用前缀开头
// 用户在 Claude Code 中无法直接控制 system prompt,这是最可靠的信号
if let Some(system) = body.get("system") {
let system_text = if let Some(s) = system.as_str() {
s.to_string()
} else if let Some(arr) = system.as_array() {
arr.iter()
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join(" ")
} else {
String::new()
};
if system_text
.starts_with("You are a helpful AI assistant tasked with summarizing conversations")
{
return true;
}
let system_text = extract_system_text(body);
if system_text
.starts_with("You are a helpful AI assistant tasked with summarizing conversations")
{
return true;
}
// 信号 2 & 3: 检查最后一条用户消息中的机器生成特征
@@ -259,8 +263,9 @@ pub fn merge_tool_results(mut body: Value) -> Value {
/// 基于最后一条用户消息内容生成确定性 Request ID。
///
/// 与参考实现对齐
/// CC Switch 额外策略(参考项目 copilot-api 使用随机 UUID
/// - 哈希输入: sessionId + lastUserContent(排除 tool_result 和 cache_control
/// - 相同内容产生相同 ID,可能帮助 Copilot 去重
/// - 找不到用户内容时退化为随机 UUID
/// - 使用 UUID v4 格式
pub fn deterministic_request_id(body: &Value, session_id: &str) -> String {
@@ -285,8 +290,176 @@ pub fn deterministic_request_id(body: &Value, session_id: &str) -> String {
}
}
/// 基于 session ID 生成稳定的 Interaction ID。
///
/// 与参考实现(copilot-api session.ts)对齐:
/// - 同一主对话的所有请求共享同一个 interaction ID
/// - 哈希输入: 仅 session ID(不包含消息内容,与 request ID 不同)
/// - Copilot 用此 ID 将请求聚合为同一个 "interaction",影响 premium 计费归属
/// - 空 session ID 时返回 None(不应注入随机值,避免 interaction 碎片化)
pub fn deterministic_interaction_id(session_id: &str) -> Option<String> {
if session_id.is_empty() {
return None;
}
let mut hasher = Sha256::new();
hasher.update(b"interaction:");
hasher.update(session_id.as_bytes());
let result = hasher.finalize();
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&result[..16]);
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
Some(Uuid::from_bytes(bytes).to_string())
}
/// 检测请求是否来自 Claude Code 子代理(Agent tool 生成的 subagent)。
///
/// Claude Code 的 Agent tool 会在子代理首条用户消息的 `<system-reminder>` 标签中
/// 注入 `__SUBAGENT_MARKER__` JSON 标记,格式如:
/// ```json
/// {"__SUBAGENT_MARKER__": {"session_id": "...", "agent_id": "...", "agent_type": "..."}}
/// ```
///
/// 扫描策略(与 copilot-api 的 subagent-marker.ts 对齐):
/// 1. 遍历所有 user 消息(不仅是第一条,因为 context 压缩可能重排消息)
/// 2. 在消息文本中查找 `__SUBAGENT_MARKER__` 关键字
/// 3. 找到即判定为子代理请求
fn detect_subagent(body: &Value) -> bool {
// 信号 1: 显式 __SUBAGENT_MARKER__Claude Code 2.x+ 自动注入)
if extract_system_text(body).contains("__SUBAGENT_MARKER__") {
return true;
}
if let Some(messages) = body.get("messages").and_then(|m| m.as_array()) {
for msg in messages {
if msg.get("role").and_then(|r| r.as_str()) != Some("user") {
continue;
}
let text = extract_text_from_message(msg);
if text.contains("__SUBAGENT_MARKER__") {
return true;
}
}
}
// 信号 2fallback: metadata.user_id 包含子代理标识
// Claude Code 的 Agent tool 会将 subagent session 标记为
// "parentSessionId_agent_agentId" 格式,检测 "_agent_" 后缀
if let Some(user_id) = body.pointer("/metadata/user_id").and_then(|v| v.as_str()) {
// "_agent_" 是 Claude Code Agent tool 的内部标记
if user_id.contains("_agent_") {
return true;
}
}
// 信号 3fallback: system prompt 包含 Claude Code 子代理的典型框架文本
// Agent tool 生成的子代理会在 system prompt 中包含由 Agent tool 注入的任务描述,
// 但主对话的 system prompt 由 Claude Code CLI 直接生成,两者格式不同
// 这个信号不够可靠(用户 prompt 也可能包含这些词),因此只作为辅助判据
// 暂不启用,预留接口
false
}
/// 清理孤立的 tool_result — 没有对应 tool_use 的 tool_result 转为 text block。
///
/// 场景:上下文压缩、消息截断等可能导致 assistant 消息中的 tool_use 被删除,
/// 但后续 user 消息中的 tool_result 仍在。上游 API 可能因不匹配而报错/重试。
///
/// 与 copilot-api 的 `sanitizeOrphanToolResults` 对齐。
pub fn sanitize_orphan_tool_results(mut body: Value) -> Value {
let messages = match body.get_mut("messages").and_then(|m| m.as_array_mut()) {
Some(msgs) if msgs.len() >= 2 => msgs,
_ => return body,
};
// Anthropic 协议要求 tool_result 紧跟其对应 tool_use 所在的 assistant turn。
// 只检查 messages[i-1](紧邻上一条 assistant)来判定是否 orphan
// 与参考实现 sanitizeOrphanToolResults 对齐。
for i in 1..messages.len() {
if messages[i].get("role").and_then(|r| r.as_str()) != Some("user") {
continue;
}
// 收集紧邻上一条 assistant 的 tool_use id
let prev_tool_use_ids: HashSet<String> =
if messages[i - 1].get("role").and_then(|r| r.as_str()) == Some("assistant") {
messages[i - 1]
.get("content")
.and_then(|c| c.as_array())
.map(|blocks| {
blocks
.iter()
.filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
.filter_map(|b| b.get("id").and_then(|i| i.as_str()).map(String::from))
.collect()
})
.unwrap_or_default()
} else {
// 上一条不是 assistant → 这条 user 中的所有 tool_result 都是 orphan
HashSet::new()
};
let content = match messages[i]
.get_mut("content")
.and_then(|c| c.as_array_mut())
{
Some(blocks) => blocks,
None => continue,
};
for block in content.iter_mut() {
if block.get("type").and_then(|t| t.as_str()) != Some("tool_result") {
continue;
}
let tool_use_id = block
.get("tool_use_id")
.and_then(|id| id.as_str())
.unwrap_or("");
// 空 tool_use_id 或不在紧邻 assistant 的 tool_use 中 → orphan
if tool_use_id.is_empty() || !prev_tool_use_ids.contains(tool_use_id) {
let content_text = match block.get("content") {
Some(c) if c.is_string() => c.as_str().unwrap_or("").to_string(),
Some(c) if c.is_array() => c
.as_array()
.unwrap()
.iter()
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
};
*block = serde_json::json!({
"type": "text",
"text": format!("[Tool result for {}]: {}", tool_use_id, content_text)
});
}
}
}
body
}
// ─── 内部辅助 ─────────────────────────────────
/// 从请求体的 `system` 字段提取文本(处理 string/array 两种格式)。
fn extract_system_text(body: &Value) -> String {
match body.get("system") {
Some(s) if s.is_string() => s.as_str().unwrap_or("").to_string(),
Some(arr) if arr.is_array() => arr
.as_array()
.unwrap()
.iter()
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join(" "),
_ => String::new(),
}
}
/// 查找最后一条 user 消息的非 tool_result 文本内容。
///
/// 与参考实现的 `findLastUserContent` 对齐:
@@ -433,7 +606,7 @@ mod tests {
{"role": "user", "content": "Hello, please help me write some code"}
]
});
let result = classify_request(&body, false, true);
let result = classify_request(&body, false, true, false);
assert_eq!(result.initiator, "user");
assert!(!result.is_compact);
}
@@ -448,7 +621,7 @@ mod tests {
]}
]
});
let result = classify_request(&body, false, true);
let result = classify_request(&body, false, true, false);
assert_eq!(result.initiator, "user");
}
@@ -468,15 +641,16 @@ mod tests {
]}
]
});
let result = classify_request(&body, true, true);
let result = classify_request(&body, true, true, false);
assert_eq!(result.initiator, "agent");
assert!(!result.is_warmup);
}
#[test]
fn test_classify_tool_result_with_text_block() {
// 参考实现的关键场景:tool_result + text block
// 有 tool_result block → 仍然是 "user"
// tool_result + text blockskill/edit hook/plan follow-up 的常见形态)
// 有 tool_result → 视为工具续写 → agent
// 与 copilot-api 的 merge-then-classify 效果对齐
let body = json!({
"model": "claude-sonnet-4-20250514",
"messages": [
@@ -486,8 +660,8 @@ mod tests {
]}
]
});
let result = classify_request(&body, false, true);
assert_eq!(result.initiator, "user");
let result = classify_request(&body, false, true, false);
assert_eq!(result.initiator, "agent");
}
#[test]
@@ -496,14 +670,14 @@ mod tests {
"model": "claude-sonnet-4-20250514",
"messages": []
});
let result = classify_request(&body, false, true);
let result = classify_request(&body, false, true, false);
assert_eq!(result.initiator, "user");
}
#[test]
fn test_classify_no_messages() {
let body = json!({"model": "claude-sonnet-4-20250514"});
let result = classify_request(&body, false, true);
let result = classify_request(&body, false, true, false);
assert_eq!(result.initiator, "user");
}
@@ -517,7 +691,7 @@ mod tests {
{"role": "user", "content": "Here is the conversation history to summarize..."}
]
});
let result = classify_request(&body, false, true);
let result = classify_request(&body, false, true, false);
assert_eq!(result.initiator, "agent");
assert!(result.is_compact);
}
@@ -533,7 +707,7 @@ mod tests {
]}
]
});
let result = classify_request(&body, false, true);
let result = classify_request(&body, false, true, false);
assert_eq!(result.initiator, "agent");
assert!(result.is_compact);
}
@@ -548,7 +722,7 @@ mod tests {
{"role": "user", "content": "Summarize"}
]
});
let result = classify_request(&body, false, false); // compact_detection=false
let result = classify_request(&body, false, false, false); // compact_detection=false
assert_eq!(result.initiator, "user"); // 不被标记为 agent
assert!(!result.is_compact);
}
@@ -562,7 +736,7 @@ mod tests {
{"role": "user", "content": "Please summarize the conversation so far into a concise summary."}
]
});
let result = classify_request(&body, false, true);
let result = classify_request(&body, false, true, false);
// 没有 system prompt 强特征,也没有 CRITICAL 指令 → 不是 compact → user
assert_eq!(result.initiator, "user");
assert!(!result.is_compact);
@@ -579,7 +753,7 @@ mod tests {
]
});
// has_anthropic_beta=true, 无 tools → warmup
let result = classify_request(&body, true, true);
let result = classify_request(&body, true, true, false);
assert!(result.is_warmup);
}
@@ -592,7 +766,7 @@ mod tests {
]
});
// has_anthropic_beta=false → 不是 warmup
let result = classify_request(&body, false, true);
let result = classify_request(&body, false, true, false);
assert!(!result.is_warmup);
}
@@ -606,7 +780,7 @@ mod tests {
]
});
// 有 tools → 不是 warmup(即使有 anthropic-beta
let result = classify_request(&body, true, true);
let result = classify_request(&body, true, true, false);
assert!(!result.is_warmup);
}
@@ -621,7 +795,7 @@ mod tests {
]}
]
});
let result = classify_request(&body, true, true);
let result = classify_request(&body, true, true, false);
assert_eq!(result.initiator, "agent");
assert!(!result.is_warmup);
}
@@ -831,6 +1005,44 @@ mod tests {
assert!(Uuid::parse_str(&id).is_ok());
}
// === deterministic_interaction_id 测试 ===
#[test]
fn test_interaction_id_stable_for_same_session() {
let id1 = deterministic_interaction_id("session_abc");
let id2 = deterministic_interaction_id("session_abc");
assert_eq!(id1, id2);
}
#[test]
fn test_interaction_id_differs_across_sessions() {
let id1 = deterministic_interaction_id("session_abc");
let id2 = deterministic_interaction_id("session_def");
assert_ne!(id1, id2);
}
#[test]
fn test_interaction_id_differs_from_request_id() {
let body = json!({
"messages": [{"role": "user", "content": "Hello"}]
});
let interaction = deterministic_interaction_id("session_abc").unwrap();
let request = deterministic_request_id(&body, "session_abc");
assert_ne!(interaction, request);
}
#[test]
fn test_interaction_id_empty_session_is_none() {
// 无 session 时不应生成 interaction ID(避免碎片化)
assert!(deterministic_interaction_id("").is_none());
}
#[test]
fn test_interaction_id_is_valid_uuid() {
let id = deterministic_interaction_id("test_session").unwrap();
assert!(Uuid::parse_str(&id).is_ok());
}
// === compact 检测增强测试 ===
#[test]
@@ -898,4 +1110,265 @@ mod tests {
});
assert!(is_compact_request(&body));
}
// === detect_subagent 测试 ===
#[test]
fn test_detect_subagent_with_marker_in_user_message() {
let body = json!({
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "<system-reminder>\n{\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc123\",\"agent_id\":\"explore-1\",\"agent_type\":\"Explore\"}}\n</system-reminder>\nPlease search the codebase for auth handlers"}
]}
]
});
assert!(detect_subagent(&body));
}
#[test]
fn test_detect_subagent_with_marker_in_system() {
let body = json!({
"system": "You are an agent. {\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc\",\"agent_id\":\"plan-1\",\"agent_type\":\"Plan\"}}",
"messages": [
{"role": "user", "content": "Design the implementation plan"}
]
});
assert!(detect_subagent(&body));
}
#[test]
fn test_detect_subagent_no_marker() {
let body = json!({
"messages": [
{"role": "user", "content": "Hello, please help me write code"}
]
});
assert!(!detect_subagent(&body));
}
#[test]
fn test_detect_subagent_via_metadata_user_id() {
// fallback 信号: metadata.user_id 包含 "_agent_" 标记
let body = json!({
"metadata": {
"user_id": "session_abc123_agent_explore-1"
},
"messages": [
{"role": "user", "content": "Search for files"}
]
});
assert!(detect_subagent(&body));
}
#[test]
fn test_detect_subagent_normal_user_id_not_matched() {
// 普通 session ID 不应被误判
let body = json!({
"metadata": {
"user_id": "session_abc123"
},
"messages": [
{"role": "user", "content": "Hello"}
]
});
assert!(!detect_subagent(&body));
}
#[test]
fn test_classify_subagent_sets_agent_initiator() {
let body = json!({
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "<system-reminder>\n{\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc\",\"agent_id\":\"explore-1\",\"agent_type\":\"Explore\"}}\n</system-reminder>\nSearch for files"}
]}
]
});
let result = classify_request(&body, false, true, true);
assert_eq!(result.initiator, "agent");
assert!(result.is_subagent);
}
#[test]
fn test_classify_subagent_disabled_flag() {
let body = json!({
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "<system-reminder>\n{\"__SUBAGENT_MARKER__\":{\"session_id\":\"abc\",\"agent_id\":\"explore-1\",\"agent_type\":\"Explore\"}}\n</system-reminder>\nSearch for files"}
]}
]
});
// subagent_detection=false → 不检测子代理
let result = classify_request(&body, false, true, false);
assert_eq!(result.initiator, "user");
assert!(!result.is_subagent);
}
// === sanitize_orphan_tool_results 测试 ===
#[test]
fn test_sanitize_orphan_tool_results_converts_orphans() {
let body = json!({
"messages": [
{"role": "user", "content": "Help me"},
{"role": "assistant", "content": [
{"type": "tool_use", "id": "tool_1", "name": "read_file", "input": {}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "tool_1", "content": "file contents"},
{"type": "tool_result", "tool_use_id": "tool_orphan", "content": "orphan data"}
]}
]
});
let result = sanitize_orphan_tool_results(body);
let msgs = result["messages"].as_array().unwrap();
let last_content = msgs[2]["content"].as_array().unwrap();
// tool_1 保留为 tool_result
assert_eq!(last_content[0]["type"], "tool_result");
// tool_orphan 转为 text
assert_eq!(last_content[1]["type"], "text");
assert!(last_content[1]["text"]
.as_str()
.unwrap()
.contains("tool_orphan"));
}
#[test]
fn test_sanitize_orphan_tool_results_no_orphans() {
let body = json!({
"messages": [
{"role": "assistant", "content": [
{"type": "tool_use", "id": "tool_1", "name": "read_file", "input": {}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "tool_1", "content": "ok"}
]}
]
});
let result = sanitize_orphan_tool_results(body.clone());
// 无孤立 tool_result,不应有变化
assert_eq!(result["messages"][1]["content"][0]["type"], "tool_result");
}
#[test]
fn test_sanitize_orphan_non_adjacent_assistant_tool_use_is_orphan() {
// tool_use 在更早的 assistant 中,但 tool_result 的紧邻上一条是另一个 assistant
// → 对 Anthropic 协议来说这个 tool_result 是 orphan
let body = json!({
"messages": [
{"role": "user", "content": "step 1"},
{"role": "assistant", "content": [
{"type": "tool_use", "id": "old_tool", "name": "search", "input": {}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "old_tool", "content": "found it"}
]},
{"role": "assistant", "content": [
{"type": "text", "text": "OK, now let me think..."}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "old_tool", "content": "stale ref"}
]}
]
});
let result = sanitize_orphan_tool_results(body);
let msgs = result["messages"].as_array().unwrap();
// messages[2]: 紧邻 assistant 有 old_tool → 保留
assert_eq!(msgs[2]["content"][0]["type"], "tool_result");
// messages[4]: 紧邻 assistant 无 tool_use → orphan → text
assert_eq!(msgs[4]["content"][0]["type"], "text");
}
#[test]
fn test_sanitize_orphan_prev_not_assistant() {
// tool_result 紧邻上一条是 user(非 assistant)→ 全部 orphan
let body = json!({
"messages": [
{"role": "user", "content": "first"},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "t1", "content": "data"}
]}
]
});
let result = sanitize_orphan_tool_results(body);
assert_eq!(result["messages"][1]["content"][0]["type"], "text");
}
/// 关键场景:orphan tool_result(上下文压缩丢失了紧邻 tool_use)
/// 在分类时仍应被视为 agent continuation,不能因为后续的 sanitize
/// 将其转为 text 而变成 user 请求。
///
/// 这个测试验证 classify_request 在原始(未 sanitize)的 body 上
/// 正确识别 orphan tool_result 为 agent。
#[test]
fn test_orphan_tool_result_classified_as_agent_before_sanitize() {
// 场景:最后一条 user 消息全是 tool_result,但紧邻的 assistant
// 消息里没有对应的 tool_use(因上下文压缩丢失了)
let body = json!({
"messages": [
{"role": "assistant", "content": "I'll help you with that."},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "orphan_tool_1", "content": "file contents here"},
{"type": "tool_result", "tool_use_id": "orphan_tool_2", "content": "another result"}
]}
]
});
// 在原始 body 上分类 → 全是 tool_result → agent
let classification = classify_request(&body, false, false, false);
assert_eq!(classification.initiator, "agent");
// sanitize 后 → tool_result 变为 text → 如果再分类就会变成 user
let sanitized = sanitize_orphan_tool_results(body);
let classification_after = classify_request(&sanitized, false, false, false);
assert_eq!(
classification_after.initiator, "user",
"sanitize 后 orphan tool_result 变为 text,分类变成 user — \
sanitize "
);
}
/// orphan tool_result + text 混合场景:
/// 分类器直接识别含 tool_result 的消息为 agent(无论是否有 text block),
/// 不依赖 merge 的执行顺序。即使 orphan tool_result 后续被 sanitize 转为 text
/// 分类结果在此之前已经确定为 agent。
#[test]
fn test_orphan_tool_result_with_text_classified_as_agent() {
let body = json!({
"messages": [
{"role": "assistant", "content": "Processing..."},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "orphan_1", "content": "result data"},
{"type": "text", "text": "Here's the output from the tool"}
]}
]
});
// 含有 tool_result → agent(无论是否有 text block
let classification = classify_request(&body, false, false, false);
assert_eq!(classification.initiator, "agent");
// sanitize 后 orphan tool_result 变为 text → 纯 text → 分类会变成 user
// 但正确的执行顺序是先分类再 sanitize,所以这不是问题
let sanitized = sanitize_orphan_tool_results(body);
let classification_after = classify_request(&sanitized, false, false, false);
assert_eq!(classification_after.initiator, "user");
}
#[test]
fn test_sanitize_orphan_empty_tool_use_id_is_orphan() {
// tool_use_id 为空或缺失 → 无法匹配任何 tool_use → orphan
let body = json!({
"messages": [
{"role": "assistant", "content": [
{"type": "tool_use", "id": "tool_1", "name": "read", "input": {}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "", "content": "empty id"},
{"type": "tool_result", "content": "missing id field"}
]}
]
});
let result = sanitize_orphan_tool_results(body);
let content = result["messages"][1]["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "text");
}
}
+257 -36
View File
@@ -9,7 +9,10 @@ use super::{
failover_switch::FailoverSwitchManager,
log_codes::fwd as log_fwd,
provider_router::ProviderRouter,
providers::{get_adapter, AuthInfo, AuthStrategy, ProviderAdapter, ProviderType},
providers::{
gemini_shadow::GeminiShadowStore, get_adapter, AuthInfo, AuthStrategy, ProviderAdapter,
ProviderType,
},
thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget},
thinking_rectifier::{
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
@@ -43,12 +46,15 @@ pub struct RequestForwarder {
router: Arc<ProviderRouter>,
status: Arc<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
gemini_shadow: Arc<GeminiShadowStore>,
/// 故障转移切换管理器
failover_manager: Arc<FailoverSwitchManager>,
/// AppHandle,用于发射事件和更新托盘
app_handle: Option<tauri::AppHandle>,
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
current_provider_id_at_start: String,
/// 代理会话 ID(用于 Gemini Native shadow replay
session_id: String,
/// 整流器配置
rectifier_config: RectifierConfig,
/// 优化器配置
@@ -66,9 +72,11 @@ impl RequestForwarder {
non_streaming_timeout: u64,
status: Arc<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
gemini_shadow: Arc<GeminiShadowStore>,
failover_manager: Arc<FailoverSwitchManager>,
app_handle: Option<tauri::AppHandle>,
current_provider_id_at_start: String,
session_id: String,
_streaming_first_byte_timeout: u64,
_streaming_idle_timeout: u64,
rectifier_config: RectifierConfig,
@@ -79,9 +87,11 @@ impl RequestForwarder {
router,
status,
current_providers,
gemini_shadow,
failover_manager,
app_handle,
current_provider_id_at_start,
session_id,
rectifier_config,
optimizer_config,
copilot_optimizer_config,
@@ -776,32 +786,42 @@ impl RequestForwarder {
== Some("github_copilot")
|| base_url.contains("githubcopilot.com");
// --- Copilot 优化器:请求体优化 + 分类(在格式转换之前执行) ---
// --- Copilot 优化器:分类 + 请求体优化(在格式转换之前执行) ---
// 注意:确定性 ID 也在此处计算,因为 mapped_body 在格式转换时会被 move
//
// 执行顺序(与 copilot-api 对齐):
// 1. 先在原始 body 上分类(保留 tool_result 语义,避免误判为 user
// 2. 再清洗孤立 tool_result(防止上游 API 报错)
// 3. 再合并 tool_result + text(减少 premium 计费)
let copilot_optimization = if is_copilot && self.copilot_optimizer_config.enabled {
// 1. Tool result 合并 — 必须在分类之前执行
// 合并将 [tool_result, text] 变为 [tool_result(含text)]
// 分类才能正确识别为 agent(全是 tool_result)而非 user(有 text block
if self.copilot_optimizer_config.tool_result_merging {
mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body);
}
// 2. 在合并后的 body 上进行分类
// 1. 在原始 body 上分类 — 必须在清洗/合并之前执行
// 孤立 tool_result 仍保持 tool_result 类型,分类能正确识别为 agent
let has_anthropic_beta = headers.contains_key("anthropic-beta");
let classification = super::copilot_optimizer::classify_request(
&mapped_body,
has_anthropic_beta,
self.copilot_optimizer_config.compact_detection,
self.copilot_optimizer_config.subagent_detection,
);
log::debug!(
"[Copilot] 优化器分类: initiator={}, is_warmup={}, is_compact={}",
"[Copilot] 优化器分类: initiator={}, is_warmup={}, is_compact={}, is_subagent={}",
classification.initiator,
classification.is_warmup,
classification.is_compact
classification.is_compact,
classification.is_subagent
);
// 3. Warmup 小模型降级
// 2. 孤立 tool_result 清理 — 分类完成后再清洗
// 防止上游 API 因不匹配的 tool_result 报错导致重试/重复计费
mapped_body = super::copilot_optimizer::sanitize_orphan_tool_results(mapped_body);
// 3. Tool result 合并 — 将 [tool_result, text] 变为 [tool_result(含text)]
if self.copilot_optimizer_config.tool_result_merging {
mapped_body = super::copilot_optimizer::merge_tool_results(mapped_body);
}
// 4. Warmup 小模型降级
if self.copilot_optimizer_config.warmup_downgrade && classification.is_warmup {
log::info!(
"[Copilot] Warmup 请求降级到模型: {}",
@@ -812,22 +832,52 @@ impl RequestForwarder {
}
// 预计算确定性 Request ID(在 body 被 move 之前)
// 使用 session_id 从 body.metadata.user_id 或请求头提取
let session_id = body
.pointer("/metadata/user_id")
// Session 提取优先级(与 session.rs extract_from_metadata 对齐):
// 1. metadata.user_id 中的 _session_ 后缀
// 2. metadata.session_id(直接字段)
// 3. raw metadata.user_id(整串 fallback
// 4. x-session-id header
let metadata = body.get("metadata");
let session_id = metadata
.and_then(|m| m.get("user_id"))
.and_then(|v| v.as_str())
.or_else(|| headers.get("x-session-id").and_then(|v| v.to_str().ok()))
.unwrap_or("");
.and_then(super::session::parse_session_from_user_id)
.or_else(|| {
metadata
.and_then(|m| m.get("session_id"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
})
.or_else(|| {
metadata
.and_then(|m| m.get("user_id"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
})
.or_else(|| {
headers
.get("x-session-id")
.and_then(|v| v.to_str().ok())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
})
.unwrap_or_default();
let det_request_id = if self.copilot_optimizer_config.deterministic_request_id {
Some(super::copilot_optimizer::deterministic_request_id(
&mapped_body,
session_id,
&session_id,
))
} else {
None
};
Some((classification, det_request_id))
// 从 session ID 派生稳定的 interaction ID(同一主对话共享)
let interaction_id =
super::copilot_optimizer::deterministic_interaction_id(&session_id);
Some((classification, det_request_id, interaction_id))
} else {
None
};
@@ -878,7 +928,7 @@ impl RequestForwarder {
let api_format = resolved_claude_api_format
.as_deref()
.unwrap_or_else(|| super::providers::get_claude_api_format(provider));
rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot)
rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot, &mapped_body)
} else {
(
endpoint.to_string(),
@@ -888,7 +938,13 @@ impl RequestForwarder {
)
};
let url = if is_full_url {
let url = if matches!(resolved_claude_api_format.as_deref(), Some("gemini_native")) {
super::gemini_url::resolve_gemini_native_url(
&base_url,
&effective_endpoint,
is_full_url,
)
} else if is_full_url {
append_query_to_full_url(&base_url, passthrough_query.as_deref())
} else {
adapter.build_url(&base_url, &effective_endpoint)
@@ -904,6 +960,8 @@ impl RequestForwarder {
mapped_body,
provider,
api_format,
Some(&self.session_id),
Some(self.gemini_shadow.as_ref()),
)?
} else {
adapter.transform_request(mapped_body, provider)?
@@ -1039,12 +1097,18 @@ impl RequestForwarder {
}
// --- Copilot 优化器:动态 header 注入 ---
if let Some((ref classification, ref det_request_id)) = copilot_optimization {
if let Some((ref classification, ref det_request_id, ref interaction_id)) =
copilot_optimization
{
for (name, value) in auth_headers.iter_mut() {
match name.as_str() {
"x-initiator" if self.copilot_optimizer_config.request_classification => {
*value = http::HeaderValue::from_static(classification.initiator);
}
"x-interaction-type" if classification.is_subagent => {
// 子代理请求:conversation-subagent 不计 premium interaction
*value = http::HeaderValue::from_static("conversation-subagent");
}
"x-request-id" | "x-agent-task-id" => {
if let Some(ref det_id) = det_request_id {
if let Ok(hv) = http::HeaderValue::from_str(det_id) {
@@ -1055,6 +1119,19 @@ impl RequestForwarder {
_ => {}
}
}
// x-interaction-id:仅在有 session 时注入(不在 get_auth_headers 中)
if let Some(ref iid) = interaction_id {
if let Ok(hv) = http::HeaderValue::from_str(iid) {
auth_headers.push((http::HeaderName::from_static("x-interaction-id"), hv));
}
}
if classification.is_subagent {
log::info!(
"[Copilot] 子代理请求: x-initiator=agent, x-interaction-type=conversation-subagent"
);
}
}
// Copilot 指纹头名(由 get_auth_headers 注入,需在原始头中去重)
@@ -1069,6 +1146,7 @@ impl RequestForwarder {
// 新增 headers
"x-initiator",
"x-interaction-type",
"x-interaction-id",
"x-vscode-user-agent-library-version",
"x-request-id",
"x-agent-task-id",
@@ -1083,8 +1161,11 @@ impl RequestForwarder {
.ok()
.and_then(|u| u.authority().map(|a| a.to_string()));
let should_send_anthropic_headers = adapter.name() == "Claude"
&& matches!(resolved_claude_api_format.as_deref(), Some("anthropic"));
// 预计算 anthropic-beta 值(仅 Claude
let anthropic_beta_value = if adapter.name() == "Claude" {
let anthropic_beta_value = if should_send_anthropic_headers {
const CLAUDE_CODE_BETA: &str = "claude-code-20250219";
Some(if let Some(beta) = headers.get("anthropic-beta") {
if let Ok(beta_str) = beta.to_str() {
@@ -1204,8 +1285,10 @@ impl RequestForwarder {
// --- anthropic-version — 透传客户端值 ---
if key_str.eq_ignore_ascii_case("anthropic-version") {
saw_anthropic_version = true;
ordered_headers.append(key.clone(), value.clone());
if should_send_anthropic_headers {
saw_anthropic_version = true;
ordered_headers.append(key.clone(), value.clone());
}
continue;
}
@@ -1246,7 +1329,7 @@ impl RequestForwarder {
}
// anthropic-version:仅在缺失时补充默认值
if adapter.name() == "Claude" && !saw_anthropic_version {
if should_send_anthropic_headers && !saw_anthropic_version {
ordered_headers.append(
"anthropic-version",
http::HeaderValue::from_static("2023-06-01"),
@@ -1287,12 +1370,8 @@ impl RequestForwarder {
self.non_streaming_timeout
};
// 解析上游代理 URL(供应商单独代理 > 全局代理 > 无)
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
let upstream_proxy_url: Option<String> = proxy_config
.filter(|c| c.enabled)
.and_then(super::http_client::build_proxy_url_from_config)
.or_else(super::http_client::get_current_proxy_url);
// 获取全局代理 URL
let upstream_proxy_url: Option<String> = super::http_client::get_current_proxy_url();
// SOCKS5 代理不支持 CONNECT 隧道,需要用 reqwest
let is_socks_proxy = upstream_proxy_url
@@ -1308,7 +1387,7 @@ impl RequestForwarder {
let response = if is_socks_proxy {
// SOCKS5 代理:只能走 reqwest(不支持 header case 保留)
log::debug!("[Forwarder] Using reqwest for SOCKS5 proxy");
let client = super::http_client::get_for_provider(proxy_config);
let client = super::http_client::get();
let mut request = client.post(&url);
if !self.non_streaming_timeout.is_zero() {
request = request.timeout(self.non_streaming_timeout);
@@ -1594,6 +1673,7 @@ fn rewrite_claude_transform_endpoint(
endpoint: &str,
api_format: &str,
is_copilot: bool,
body: &Value,
) -> (String, Option<String>) {
let (path, query) = split_endpoint_and_query(endpoint);
let passthrough_query = if is_claude_messages_path(path) {
@@ -1606,6 +1686,36 @@ fn rewrite_claude_transform_endpoint(
return (endpoint.to_string(), passthrough_query);
}
if api_format == "gemini_native" {
let model =
super::providers::transform_gemini::extract_gemini_model(body).unwrap_or("unknown");
// Accept both bare ids (`gemini-2.5-pro`) and the resource-name
// form (`models/gemini-2.5-pro`) that Gemini SDKs emit. See
// `normalize_gemini_model_id` for rationale.
let model = super::gemini_url::normalize_gemini_model_id(model);
let is_stream = body
.get("stream")
.and_then(|value| value.as_bool())
.unwrap_or(false);
let target_path = if is_stream {
format!("/v1beta/models/{model}:streamGenerateContent")
} else {
format!("/v1beta/models/{model}:generateContent")
};
let rewritten_query = merge_query_params(
passthrough_query.as_deref(),
if is_stream { Some("alt=sse") } else { None },
);
let rewritten = match rewritten_query.as_deref() {
Some(query) if !query.is_empty() => format!("{target_path}?{query}"),
_ => target_path,
};
return (rewritten, rewritten_query);
}
let target_path = if is_copilot && api_format == "openai_responses" {
"/v1/responses"
} else if is_copilot {
@@ -1624,6 +1734,26 @@ fn rewrite_claude_transform_endpoint(
(rewritten, passthrough_query)
}
fn merge_query_params(base_query: Option<&str>, extra_param: Option<&str>) -> Option<String> {
let mut params: Vec<String> = base_query
.into_iter()
.flat_map(|query| query.split('&'))
.filter(|pair| !pair.is_empty())
.filter(|pair| !pair.starts_with("alt="))
.map(ToString::to_string)
.collect();
if let Some(extra_param) = extra_param {
params.push(extra_param.to_string());
}
if params.is_empty() {
None
} else {
Some(params.join("&"))
}
}
fn append_query_to_full_url(base_url: &str, query: Option<&str>) -> String {
match query {
Some(query) if !query.is_empty() => {
@@ -1752,6 +1882,7 @@ mod tests {
"/v1/messages?beta=true&foo=bar",
"openai_chat",
false,
&json!({ "model": "gpt-5.4" }),
);
assert_eq!(endpoint, "/v1/chat/completions?foo=bar");
@@ -1764,6 +1895,7 @@ mod tests {
"/claude/v1/messages?beta=true&x-id=1",
"openai_responses",
false,
&json!({ "model": "gpt-5.4" }),
);
assert_eq!(endpoint, "/v1/responses?x-id=1");
@@ -1772,8 +1904,12 @@ mod tests {
#[test]
fn rewrite_claude_transform_endpoint_uses_copilot_path() {
let (endpoint, passthrough_query) =
rewrite_claude_transform_endpoint("/v1/messages?beta=true&x-id=1", "anthropic", true);
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
"/v1/messages?beta=true&x-id=1",
"anthropic",
true,
&json!({ "model": "claude-sonnet-4-6" }),
);
assert_eq!(endpoint, "/chat/completions?x-id=1");
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
@@ -1785,12 +1921,60 @@ mod tests {
"/v1/messages?beta=true&x-id=1",
"openai_responses",
true,
&json!({ "model": "gpt-5.4" }),
);
assert_eq!(endpoint, "/v1/responses?x-id=1");
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
}
#[test]
fn rewrite_claude_transform_endpoint_maps_gemini_generate_content() {
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
"/v1/messages?beta=true&x-id=1",
"gemini_native",
false,
&json!({ "model": "gemini-2.5-pro" }),
);
assert_eq!(
endpoint,
"/v1beta/models/gemini-2.5-pro:generateContent?x-id=1"
);
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
}
/// Regression: body.model arriving as the resource-name form
/// `models/gemini-2.5-pro` must not produce a doubled
/// `/v1beta/models/models/...` path.
#[test]
fn rewrite_claude_transform_endpoint_strips_gemini_model_resource_prefix() {
let (endpoint, _) = rewrite_claude_transform_endpoint(
"/v1/messages",
"gemini_native",
false,
&json!({ "model": "models/gemini-2.5-pro" }),
);
assert_eq!(endpoint, "/v1beta/models/gemini-2.5-pro:generateContent");
}
#[test]
fn rewrite_claude_transform_endpoint_maps_gemini_streaming() {
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
"/v1/messages?beta=true",
"gemini_native",
false,
&json!({ "model": "gemini-2.5-flash", "stream": true }),
);
assert_eq!(
endpoint,
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
);
assert_eq!(passthrough_query.as_deref(), Some("alt=sse"));
}
#[test]
fn append_query_to_full_url_preserves_existing_query_string() {
let url = append_query_to_full_url("https://relay.example/api?foo=bar", Some("x-id=1"));
@@ -1798,6 +1982,43 @@ mod tests {
assert_eq!(url, "https://relay.example/api?foo=bar&x-id=1");
}
#[test]
fn build_gemini_native_url_uses_origin_when_base_ends_with_v1beta() {
let url = crate::proxy::gemini_url::build_gemini_native_url(
"https://generativelanguage.googleapis.com/v1beta",
"/v1beta/models/gemini-2.5-pro:generateContent",
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent"
);
}
#[test]
fn build_gemini_native_url_uses_origin_when_base_already_contains_models_prefix() {
let url = crate::proxy::gemini_url::build_gemini_native_url(
"https://generativelanguage.googleapis.com/v1beta/models",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
);
}
#[test]
fn resolve_gemini_native_url_keeps_opaque_full_url_as_is() {
let url = crate::proxy::gemini_url::resolve_gemini_native_url(
"https://relay.example/custom/generate-content",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse");
}
#[test]
fn force_identity_for_stream_flag_requests() {
let headers = HeaderMap::new();
+665
View File
@@ -0,0 +1,665 @@
//! Gemini Native URL helpers.
//!
//! Normalizes legacy Gemini/OpenAI-compatible base URLs into the canonical
//! Gemini Native `models/*:generateContent` endpoints.
/// Normalize a Gemini model identifier to its bare form, stripping an
/// optional leading `models/` (and any leading `/`) so that the value can
/// be safely interpolated into a URL template like
/// `/v1beta/models/{model}:generateContent`.
///
/// Gemini SDKs and documentation commonly surface model ids as
/// `models/gemini-2.5-pro` (the resource-name form). Passing that value
/// through to the format string would otherwise yield a doubled prefix
/// like `/v1beta/models/models/gemini-2.5-pro:generateContent`, which is
/// rejected by the upstream API and turns any health check or live
/// request into a false negative.
pub fn normalize_gemini_model_id(model: &str) -> &str {
let trimmed = model.strip_prefix('/').unwrap_or(model);
trimmed.strip_prefix("models/").unwrap_or(trimmed)
}
pub fn resolve_gemini_native_url(base_url: &str, endpoint: &str, is_full_url: bool) -> String {
if !is_full_url || should_normalize_gemini_full_url(base_url) {
return build_gemini_native_url(base_url, endpoint);
}
let base_url = base_url
.split_once('#')
.map_or(base_url, |(base, _)| base)
.trim_end_matches('/');
let (base_without_query, base_query) = split_query(base_url);
let (_, endpoint_query) = split_query(endpoint);
let mut url = base_without_query.to_string();
if let Some(query) = merge_queries(base_query, endpoint_query) {
url.push('?');
url.push_str(&query);
}
url
}
pub fn build_gemini_native_url(base_url: &str, endpoint: &str) -> String {
let base_url = base_url
.split_once('#')
.map_or(base_url, |(base, _)| base)
.trim_end_matches('/');
let (base_without_query, base_query) = split_query(base_url);
let (endpoint_without_query, endpoint_query) = split_query(endpoint);
let endpoint_path = format!("/{}", endpoint_without_query.trim_start_matches('/'));
let (origin, raw_path) = split_origin_and_path(base_without_query);
let prefix_path = normalize_gemini_base_path(raw_path);
let mut url = if prefix_path.is_empty() {
format!("{origin}{endpoint_path}")
} else {
format!("{origin}{prefix_path}{endpoint_path}")
};
if let Some(query) = merge_queries(base_query, endpoint_query) {
url.push('?');
url.push_str(&query);
}
url
}
fn should_normalize_gemini_full_url(base_url: &str) -> bool {
let base_url = base_url
.split_once('#')
.map_or(base_url, |(base, _)| base)
.trim_end_matches('/');
let (base_without_query, _) = split_query(base_url);
let (origin, path) = split_origin_and_path(base_without_query);
if path.is_empty() || path == "/" {
return true;
}
let path = path.trim_end_matches('/');
let on_google_host = is_google_gemini_host(extract_host(origin));
// 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`,
// `/openai/responses`) that are implausibly used as a relay's fixed
// terminal path — get rewritten regardless of host.
if matches_structured_gemini_models_path(path)
|| path.ends_with("/v1beta/openai/chat/completions")
|| path.ends_with("/v1beta/openai/responses")
|| path.ends_with("/openai/chat/completions")
|| path.ends_with("/openai/responses")
|| path.ends_with("/v1/openai/chat/completions")
|| path.ends_with("/v1/openai/responses")
{
return true;
}
// All other version / resource-root suffixes — `/v1beta`, `/v1`,
// `/models`, `/openai`, and variants — could legitimately be an
// opaque relay's fixed endpoint (`https://relay.example/custom/v1beta`
// is a real deployment shape, even if uncommon outside Google's
// ecosystem). Only rewrite when the host itself is Google's Gemini
// or Vertex AI endpoint.
if on_google_host
&& (path.ends_with("/v1beta")
|| path.ends_with("/v1beta/models")
|| path.ends_with("/v1beta/openai")
|| path.ends_with("/v1")
|| path.ends_with("/v1/models")
|| path.ends_with("/models")
|| path.ends_with("/v1/openai")
|| path.ends_with("/openai"))
{
return true;
}
false
}
/// Extract the host portion of an origin like `https://host:port` or
/// `https://host`. Returns an empty string if no host can be found (e.g.
/// bare `http://`).
fn extract_host(origin: &str) -> &str {
let after_scheme = origin.split_once("://").map_or(origin, |(_, rest)| rest);
// authority may carry credentials (`user:pass@host`) and a port
// (`host:port`). Strip userinfo first, then port.
let without_userinfo = after_scheme
.rsplit_once('@')
.map_or(after_scheme, |(_, h)| h);
let without_port = without_userinfo
.split_once(':')
.map_or(without_userinfo, |(h, _)| h);
// Strip trailing `/` defensively (split_origin_and_path already handled
// it, but this helper may be reused elsewhere).
without_port.trim_end_matches('/')
}
/// Returns true when `host` is one of Google's Gemini / Vertex AI endpoints.
/// Case-insensitive. Requires exact match or a real `-aiplatform.googleapis.com`
/// subdomain suffix — not a substring match, so lookalikes like
/// `aiplatform.example.com` are rejected.
fn is_google_gemini_host(host: &str) -> bool {
if host.is_empty() {
return false;
}
let host_lower = host.to_ascii_lowercase();
host_lower == "generativelanguage.googleapis.com"
|| host_lower == "aiplatform.googleapis.com"
|| host_lower.ends_with("-aiplatform.googleapis.com")
}
fn split_query(input: &str) -> (&str, Option<&str>) {
input
.split_once('?')
.map_or((input, None), |(path, query)| (path, Some(query)))
}
fn split_origin_and_path(base_url: &str) -> (&str, &str) {
let Some(scheme_sep) = base_url.find("://") else {
return (base_url, "");
};
let authority_start = scheme_sep + 3;
let Some(path_start_rel) = base_url[authority_start..].find('/') else {
return (base_url, "");
};
let path_start = authority_start + path_start_rel;
(&base_url[..path_start], &base_url[path_start..])
}
fn normalize_gemini_base_path(path: &str) -> String {
let path = path.trim_end_matches('/');
if path.is_empty() || path == "/" {
return String::new();
}
for marker in ["/v1beta/models/", "/v1/models/", "/models/"] {
if let Some(index) = path.find(marker) {
return normalize_prefix(&path[..index]);
}
}
for suffix in [
"/v1beta/openai/chat/completions",
"/v1/openai/chat/completions",
"/openai/chat/completions",
"/v1beta/openai/responses",
"/v1/openai/responses",
"/openai/responses",
"/v1beta/openai",
"/v1/openai",
"/openai",
"/v1beta/models",
"/v1/models",
"/models",
"/v1beta",
"/v1",
] {
if path == suffix {
return String::new();
}
if let Some(prefix) = path.strip_suffix(suffix) {
return normalize_prefix(prefix);
}
}
path.to_string()
}
fn normalize_prefix(prefix: &str) -> String {
let prefix = prefix.trim_end_matches('/');
if prefix.is_empty() || prefix == "/" {
String::new()
} else {
prefix.to_string()
}
}
/// Returns true when `path` contains a `/models/` segment followed by a
/// canonical Gemini method call (`*:generateContent` or
/// `*:streamGenerateContent`). The `/models/` segment alone is not enough:
/// opaque relay routes such as `/v1/models/invoke` or `/custom/models/foo`
/// also contain `/models/` but are not Gemini-structured and must not be
/// rewritten.
fn matches_structured_gemini_models_path(path: &str) -> bool {
let mut cursor = path;
while let Some(idx) = cursor.find("/models/") {
let after = &cursor[idx + "/models/".len()..];
if after.contains(":generateContent") || after.contains(":streamGenerateContent") {
return true;
}
// Advance past this `/models/` occurrence so a later Gemini-style
// segment in the same path (unusual but cheap to handle) can still
// match.
cursor = &cursor[idx + "/models/".len()..];
}
false
}
fn merge_queries(base_query: Option<&str>, endpoint_query: Option<&str>) -> Option<String> {
let parts: Vec<&str> = [base_query, endpoint_query]
.into_iter()
.flatten()
.flat_map(|query| query.split('&'))
.filter(|part| !part.is_empty())
.collect();
if parts.is_empty() {
None
} else {
Some(parts.join("&"))
}
}
#[cfg(test)]
mod tests {
use super::{build_gemini_native_url, normalize_gemini_model_id, resolve_gemini_native_url};
#[test]
fn strips_version_root_for_official_base() {
let url = build_gemini_native_url(
"https://generativelanguage.googleapis.com/v1beta",
"/v1beta/models/gemini-2.5-pro:generateContent",
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent"
);
}
#[test]
fn strips_openai_compat_path_for_official_base() {
let url = build_gemini_native_url(
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
"/v1beta/models/gemini-2.5-pro:generateContent",
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent"
);
}
#[test]
fn preserves_custom_proxy_prefix_while_stripping_openai_suffix() {
let url = build_gemini_native_url(
"https://proxy.example.com/google/v1beta/openai/chat/completions",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
);
assert_eq!(
url,
"https://proxy.example.com/google/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
);
}
#[test]
fn strips_model_method_path_from_full_url_base() {
let url = build_gemini_native_url(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
);
}
#[test]
fn resolves_structured_full_url_by_normalizing_to_requested_method() {
let url = resolve_gemini_native_url(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
);
}
#[test]
fn resolves_opaque_full_url_without_appending_gemini_models_path() {
let url = resolve_gemini_native_url(
"https://relay.example/custom/generate-content",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse");
}
#[test]
fn preserves_opaque_full_url_containing_models_segment() {
let url = resolve_gemini_native_url(
"https://relay.example/custom/models/invoke",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(url, "https://relay.example/custom/models/invoke?alt=sse");
}
/// Regression: a relay whose fixed path starts with `/v1/models/` is an
/// opaque route, not a Gemini-structured endpoint. The previous
/// heuristic matched any `contains("/v1/models/")` and rewrote it to
/// `/v1beta/models/{model}:generateContent`, dropping the relay's own
/// route component (`/invoke`) and sending traffic to the wrong place.
#[test]
fn preserves_opaque_full_url_with_v1_models_prefix() {
let url = resolve_gemini_native_url(
"https://relay.example/v1/models/invoke",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(url, "https://relay.example/v1/models/invoke?alt=sse");
}
/// Same regression, `/v1beta/models/` variant — relays may use Google's
/// path layout defensively for routing while still being opaque.
#[test]
fn preserves_opaque_full_url_with_v1beta_models_prefix() {
let url = resolve_gemini_native_url(
"https://relay.example/v1beta/models/route",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(url, "https://relay.example/v1beta/models/route?alt=sse");
}
/// Counter-case: a full URL that *does* carry a genuine Gemini method
/// segment (`:generateContent`) under `/v1/models/` should still be
/// recognized as structured and normalized to the requested model.
#[test]
fn normalizes_structured_v1_models_path_with_method_segment() {
let url = resolve_gemini_native_url(
"https://relay.example/v1/models/gemini-2.5-pro:generateContent",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(
url,
"https://relay.example/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
);
}
// ------------------------------------------------------------------
// Google-host whitelist tests (generic REST suffix handling)
//
// Generic REST conventions like `/v1`, `/models`, `/openai` legitimately
// appear on opaque relays. `should_normalize_gemini_full_url` only
// treats these as structured Gemini endpoints when the host itself is
// Google's Gemini or Vertex AI endpoint.
// ------------------------------------------------------------------
/// Regression: a relay whose fixed path ends with `/v1` (a ubiquitous
/// REST convention) used to be rewritten to
/// `/v1beta/models/{model}:generateContent`, dropping the relay's own
/// `/v1` endpoint.
#[test]
fn preserves_opaque_full_url_with_v1_suffix() {
let url = resolve_gemini_native_url(
"https://relay.example/custom/v1",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(url, "https://relay.example/custom/v1?alt=sse");
}
/// Companion case: bare `/models` suffix on a non-Google host is a
/// generic REST path, not a Gemini-structured endpoint.
#[test]
fn preserves_opaque_full_url_with_models_suffix() {
let url = resolve_gemini_native_url(
"https://relay.example/custom/models",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(url, "https://relay.example/custom/models?alt=sse");
}
/// Companion case: `/v1/models` — same ambiguity as `/models`, with the
/// version prefix. Must stay as-is on non-Google hosts.
#[test]
fn preserves_opaque_full_url_with_v1_models_suffix() {
let url = resolve_gemini_native_url(
"https://relay.example/custom/v1/models",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(url, "https://relay.example/custom/v1/models?alt=sse");
}
/// Companion case: a relay that exposes an `/openai` compatibility
/// surface without the deep `/openai/chat/completions` path. Must stay
/// as-is on non-Google hosts.
#[test]
fn preserves_opaque_full_url_with_openai_suffix_on_non_google_host() {
let url = resolve_gemini_native_url(
"https://relay.example/custom/openai",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(url, "https://relay.example/custom/openai?alt=sse");
}
/// Counter-case: `/v1` on the official Gemini host must still be
/// normalized to the full `/v1beta/models/...` endpoint — users who
/// paste `https://generativelanguage.googleapis.com/v1` as their base
/// URL expect the proxy to resolve the method path.
#[test]
fn normalizes_google_host_with_v1_suffix() {
let url = resolve_gemini_native_url(
"https://generativelanguage.googleapis.com/v1",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
);
}
/// Counter-case: `/models` on the official Gemini host is recognized
/// and normalized.
#[test]
fn normalizes_google_host_with_models_suffix() {
let url = resolve_gemini_native_url(
"https://generativelanguage.googleapis.com/models",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
);
}
/// Counter-case: Vertex AI regional endpoints live under
/// `*-aiplatform.googleapis.com`. Those should also be treated as
/// Google-host for the whitelist.
#[test]
fn normalizes_vertex_aiplatform_host_with_v1_suffix() {
let url = resolve_gemini_native_url(
"https://us-central1-aiplatform.googleapis.com/v1",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(
url,
"https://us-central1-aiplatform.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
);
}
/// Safety: the Google-host whitelist must do an exact/suffix match, not
/// a `contains`. A lookalike host like `aiplatform.example.com` must
/// NOT be treated as Google.
#[test]
fn preserves_non_google_aiplatform_lookalike_host() {
let url = resolve_gemini_native_url(
"https://aiplatform.example.com/v1",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(url, "https://aiplatform.example.com/v1?alt=sse");
}
/// Regression: `/v1beta` by itself is Google-conventional but not
/// literally impossible on other hosts. An opaque relay fronting a
/// non-Gemini service at `https://relay.example/custom/v1beta` would
/// be silently rewritten if `/v1beta` were classified as unconditional
/// structured Gemini. Require the Google-host whitelist instead.
#[test]
fn preserves_opaque_full_url_with_bare_v1beta_suffix() {
let url = resolve_gemini_native_url(
"https://relay.example/custom/v1beta",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(url, "https://relay.example/custom/v1beta?alt=sse");
}
/// Companion case: `/v1beta/models` (no method segment) on a non-Google
/// host stays as-is too.
#[test]
fn preserves_opaque_full_url_with_v1beta_models_suffix_no_method() {
let url = resolve_gemini_native_url(
"https://relay.example/custom/v1beta/models",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(url, "https://relay.example/custom/v1beta/models?alt=sse");
}
/// Counter-case: `/v1beta` on the official Gemini host must still
/// normalize — this is the canonical base URL shape users paste from
/// AI Studio documentation.
#[test]
fn normalizes_google_host_with_v1beta_suffix() {
let url = resolve_gemini_native_url(
"https://generativelanguage.googleapis.com/v1beta",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
true,
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
);
}
/// Regression guard: in non-full-URL mode, a versioned third-party
/// relay base must have its `/v1beta` suffix **stripped** so the
/// appended standard endpoint (`/v1beta/models/{model}:method`) does
/// not produce a doubled `/v1beta/v1beta/models/...` path. Non-full
/// mode's contract is "base URL + cc-switch appends the canonical
/// Gemini endpoint" — a user who wants a relay's custom namespace
/// (e.g. `/v1/models/...`) must use full-URL mode instead.
///
/// This test pins the intentional asymmetry with
/// `preserves_opaque_full_url_with_bare_v1beta_suffix` (full-URL
/// preserves, non-full strips) so nobody "fixes" one side into
/// breaking the other.
#[test]
fn strips_versioned_relay_base_suffix_in_non_full_url_mode() {
let url = resolve_gemini_native_url(
"https://relay.example/custom/v1beta",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
false,
);
assert_eq!(
url,
"https://relay.example/custom/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
);
}
/// Companion case: `/v1` base suffix also stripped in non-full-URL
/// mode regardless of host.
#[test]
fn strips_v1_relay_base_suffix_in_non_full_url_mode() {
let url = resolve_gemini_native_url(
"https://relay.example/custom/v1",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
false,
);
assert_eq!(
url,
"https://relay.example/custom/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
);
}
// ------------------------------------------------------------------
// Model ID normalization tests.
//
// Gemini SDKs and documentation commonly surface model identifiers as
// `models/gemini-2.5-pro` (resource-name form). If that value flows
// straight into our URL builder, the format string
// `/v1beta/models/{model}:generateContent` produces a doubled prefix
// `/v1beta/models/models/gemini-2.5-pro:generateContent`, which is
// rejected upstream. `normalize_gemini_model_id` is the single source
// of truth callers should run the model through first.
// ------------------------------------------------------------------
#[test]
fn normalize_model_id_strips_models_prefix() {
assert_eq!(
normalize_gemini_model_id("models/gemini-2.5-pro"),
"gemini-2.5-pro"
);
}
#[test]
fn normalize_model_id_leaves_bare_id_unchanged() {
assert_eq!(
normalize_gemini_model_id("gemini-2.5-pro"),
"gemini-2.5-pro"
);
}
#[test]
fn normalize_model_id_preserves_nested_slashes_after_prefix() {
// e.g. tuned model resource like `models/gemini-2.5-pro/tunedModels/xxx`
// — the caller asked for a specific tuned model resource, keep its
// identity intact after stripping only the single leading prefix.
assert_eq!(
normalize_gemini_model_id("models/tunedModels/my-tuned"),
"tunedModels/my-tuned"
);
}
#[test]
fn normalize_model_id_tolerates_leading_slash() {
assert_eq!(
normalize_gemini_model_id("/models/gemini-2.5-flash"),
"gemini-2.5-flash"
);
}
#[test]
fn normalize_model_id_preserves_empty_input() {
// Edge: caller has no model at all. Pass through so the URL error
// surfaces at the request layer rather than producing a misleading
// empty segment here.
assert_eq!(normalize_gemini_model_id(""), "");
}
}
+2
View File
@@ -218,9 +218,11 @@ impl RequestContext {
non_streaming_timeout,
state.status.clone(),
state.current_providers.clone(),
state.gemini_shadow.clone(),
state.failover_manager.clone(),
state.app_handle.clone(),
self.current_provider_id.clone(),
self.session_id.clone(),
first_byte_timeout,
idle_timeout,
self.rectifier_config.clone(),
+25 -8
View File
@@ -15,12 +15,14 @@ use super::{
handler_context::RequestContext,
providers::{
get_adapter, get_claude_api_format, streaming::create_anthropic_sse_stream,
streaming_gemini::create_anthropic_sse_stream_from_gemini,
streaming_responses::create_anthropic_sse_stream_from_responses, transform,
transform_responses,
transform_gemini, transform_responses,
},
response_processor::{
create_logged_passthrough_stream, process_response, read_decoded_body,
strip_entity_headers_for_rebuilt_body, SseUsageCollector,
strip_entity_headers_for_rebuilt_body, strip_hop_by_hop_response_headers,
SseUsageCollector,
},
server::ProxyState,
types::*,
@@ -144,11 +146,13 @@ async fn handle_claude_transform(
response: super::hyper_client::ProxyResponse,
ctx: &RequestContext,
state: &ProxyState,
_original_body: &Value,
original_body: &Value,
is_stream: bool,
api_format: &str,
) -> Result<axum::response::Response, ProxyError> {
let status = response.status();
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 is_stream {
// 根据 api_format 选择流式转换器
@@ -157,6 +161,14 @@ async fn handle_claude_transform(
dyn futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + Unpin,
> = if api_format == "openai_responses" {
Box::new(Box::pin(create_anthropic_sse_stream_from_responses(stream)))
} else if api_format == "gemini_native" {
Box::new(Box::pin(create_anthropic_sse_stream_from_gemini(
stream,
Some(state.gemini_shadow.clone()),
Some(ctx.provider.id.clone()),
Some(ctx.session_id.clone()),
tool_schema_hints.clone(),
)))
} else {
Box::new(Box::pin(create_anthropic_sse_stream(stream)))
};
@@ -216,10 +228,6 @@ async fn handle_claude_transform(
"Cache-Control",
axum::http::HeaderValue::from_static("no-cache"),
);
headers.insert(
"Connection",
axum::http::HeaderValue::from_static("keep-alive"),
);
let body = axum::body::Body::from_stream(logged_stream);
return Ok((headers, body).into_response());
@@ -245,6 +253,14 @@ async fn handle_claude_transform(
// 根据 api_format 选择非流式转换器
let anthropic_response = if api_format == "openai_responses" {
transform_responses::responses_to_anthropic(upstream_response)
} else if api_format == "gemini_native" {
transform_gemini::gemini_to_anthropic_with_shadow_and_hints(
upstream_response,
Some(state.gemini_shadow.as_ref()),
Some(&ctx.provider.id),
Some(&ctx.session_id),
tool_schema_hints.as_ref(),
)
} else {
transform::openai_to_anthropic(upstream_response)
}
@@ -287,6 +303,7 @@ async fn handle_claude_transform(
// 构建响应
let mut builder = axum::response::Response::builder().status(status);
strip_entity_headers_for_rebuilt_body(&mut response_headers);
strip_hop_by_hop_response_headers(&mut response_headers);
for (key, value) in response_headers.iter() {
builder = builder.header(key, value);
@@ -603,7 +620,7 @@ async fn log_usage(
model
};
let request_id = uuid::Uuid::new_v4().to_string();
let request_id = usage.dedup_request_id();
if let Err(e) = logger.log_with_calculation(
request_id,
-98
View File
@@ -3,7 +3,6 @@
//! 提供支持全局代理配置的 HTTP 客户端。
//! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。
use crate::provider::ProviderProxyConfig;
use once_cell::sync::OnceCell;
use reqwest::Client;
use std::env;
@@ -334,103 +333,6 @@ pub fn mask_url(url: &str) -> String {
}
}
/// 根据供应商单独代理配置构建代理 URL
///
/// 将 ProviderProxyConfig 转换为代理 URL 字符串
pub fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option<String> {
let proxy_type = config.proxy_type.as_deref().unwrap_or("http");
let host = config.proxy_host.as_deref()?;
let port = config.proxy_port?;
// 构建带认证的代理 URL
if let (Some(username), Some(password)) = (&config.proxy_username, &config.proxy_password) {
if !username.is_empty() && !password.is_empty() {
return Some(format!(
"{proxy_type}://{username}:{password}@{host}:{port}"
));
}
}
Some(format!("{proxy_type}://{host}:{port}"))
}
/// 根据供应商单独代理配置构建 HTTP 客户端
///
/// 如果供应商配置了单独代理(enabled = true),则使用该代理构建客户端;
/// 否则返回 None,调用方应使用全局客户端。
///
/// # Arguments
/// * `proxy_config` - 供应商的代理配置
///
/// # Returns
/// 如果配置有效则返回 Some(Client),否则返回 None
pub fn build_client_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Option<Client> {
let config = proxy_config.filter(|c| c.enabled)?;
let proxy_url = build_proxy_url_from_config(config)?;
log::debug!(
"[ProviderProxy] Building client with proxy: {}",
mask_url(&proxy_url)
);
// 构建带代理的客户端
let proxy = match reqwest::Proxy::all(&proxy_url) {
Ok(p) => p,
Err(e) => {
log::error!(
"[ProviderProxy] Failed to create proxy from '{}': {}",
mask_url(&proxy_url),
e
);
return None;
}
};
match Client::builder()
.timeout(Duration::from_secs(600))
.connect_timeout(Duration::from_secs(30))
.pool_max_idle_per_host(10)
.tcp_keepalive(Duration::from_secs(60))
.no_gzip()
.no_brotli()
.no_deflate()
.proxy(proxy)
.build()
{
Ok(client) => {
log::info!(
"[ProviderProxy] Client built with proxy: {}",
mask_url(&proxy_url)
);
Some(client)
}
Err(e) => {
log::error!("[ProviderProxy] Failed to build client: {e}");
None
}
}
}
/// 获取供应商专用的 HTTP 客户端
///
/// 优先使用供应商单独代理配置,如果未启用则返回全局客户端。
///
/// # Arguments
/// * `proxy_config` - 供应商的代理配置
///
/// # Returns
/// 返回适合该供应商的 HTTP 客户端
pub fn get_for_provider(proxy_config: Option<&ProviderProxyConfig>) -> Client {
// 优先使用供应商单独代理
if let Some(client) = build_client_for_provider(proxy_config) {
return client;
}
// 回退到全局客户端
get()
}
#[cfg(test)]
mod tests {
use super::*;
+1
View File
@@ -10,6 +10,7 @@ pub mod error;
pub mod error_mapper;
pub(crate) mod failover_switch;
mod forwarder;
pub mod gemini_url;
pub mod handler_config;
pub mod handler_context;
mod handlers;
+401 -18
View File
@@ -6,6 +6,7 @@
//! - **anthropic** (默认): Anthropic Messages API 格式,直接透传
//! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换
//! - **openai_responses**: OpenAI Responses API 格式,需要 Anthropic ↔ Responses 转换
//! - **gemini_native**: Google Gemini Native generateContent 格式,需要 Anthropic ↔ Gemini 转换
//!
//! ## 认证模式
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
@@ -35,6 +36,7 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
return match api_format {
"openai_chat" => "openai_chat",
"openai_responses" => "openai_responses",
"gemini_native" => "gemini_native",
_ => "anthropic",
};
}
@@ -49,6 +51,7 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
return match api_format {
"openai_chat" => "openai_chat",
"openai_responses" => "openai_responses",
"gemini_native" => "gemini_native",
_ => "anthropic",
};
}
@@ -73,20 +76,61 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str {
}
pub fn claude_api_format_needs_transform(api_format: &str) -> bool {
matches!(api_format, "openai_chat" | "openai_responses")
matches!(
api_format,
"openai_chat" | "openai_responses" | "gemini_native"
)
}
pub fn transform_claude_request_for_api_format(
body: serde_json::Value,
provider: &Provider,
api_format: &str,
session_id: Option<&str>,
shadow_store: Option<&super::gemini_shadow::GeminiShadowStore>,
) -> Result<serde_json::Value, ProxyError> {
let cache_key = provider
// Copilot 场景:优先从 metadata.user_id 提取 session ID 作为 cache key
// 格式: "uuid_sessionId" → 提取 "_" 后面的部分作为 session 标识
// 同一会话的请求共享 cache key,提升 Copilot 缓存命中率
let is_copilot = provider
.meta
.as_ref()
.and_then(|m| m.prompt_cache_key.as_deref())
.unwrap_or(&provider.id);
.and_then(|m| m.provider_type.as_deref())
== Some("github_copilot")
|| provider
.settings_config
.get("baseUrl")
.and_then(|v| v.as_str())
.is_some_and(|u| u.contains("githubcopilot.com"));
let session_cache_key: Option<String> = if is_copilot {
let metadata = body.get("metadata");
// Session 提取优先级(与 forwarder 和 session.rs 统一):
// 1. metadata.user_id 中的 _session_ 后缀
// 2. metadata.session_id(直接字段)
metadata
.and_then(|m| m.get("user_id"))
.and_then(|v| v.as_str())
.and_then(super::super::session::parse_session_from_user_id)
.or_else(|| {
metadata
.and_then(|m| m.get("session_id"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
})
} else {
None
};
let cache_key = session_cache_key
.as_deref()
.or_else(|| {
provider
.meta
.as_ref()
.and_then(|m| m.prompt_cache_key.as_deref())
})
.unwrap_or(&provider.id);
match api_format {
"openai_responses" => {
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false
@@ -102,7 +146,24 @@ pub fn transform_claude_request_for_api_format(
is_codex_oauth,
)
}
"openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)),
"openai_chat" => {
let mut result = super::transform::anthropic_to_openai(body)?;
// Inject prompt_cache_key only if explicitly configured in meta
if let Some(key) = provider
.meta
.as_ref()
.and_then(|m| m.prompt_cache_key.as_deref())
{
result["prompt_cache_key"] = serde_json::json!(key);
}
Ok(result)
}
"gemini_native" => super::transform_gemini::anthropic_to_gemini_with_shadow(
body,
shadow_store,
Some(&provider.id),
session_id,
),
_ => Ok(body),
}
}
@@ -124,6 +185,16 @@ impl ClaudeAdapter {
/// - ClaudeAuth: auth_mode 为 bearer_only
/// - Claude: 默认 Anthropic 官方
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
// 检测 Gemini Native 格式
if self.get_api_format(provider) == "gemini_native" {
return match self.extract_key(provider) {
Some(key) if key.starts_with("ya29.") || key.starts_with('{') => {
ProviderType::GeminiCli
}
_ => ProviderType::Gemini,
};
}
// 检测 Codex OAuth (ChatGPT Plus/Pro)
if self.is_codex_oauth(provider) {
return ProviderType::CodexOAuth;
@@ -226,6 +297,7 @@ impl ClaudeAdapter {
if let Some(key) = env
.get("ANTHROPIC_AUTH_TOKEN")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN");
@@ -234,6 +306,7 @@ impl ClaudeAdapter {
if let Some(key) = env
.get("ANTHROPIC_API_KEY")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 ANTHROPIC_API_KEY");
@@ -243,6 +316,7 @@ impl ClaudeAdapter {
if let Some(key) = env
.get("OPENROUTER_API_KEY")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 OPENROUTER_API_KEY");
@@ -252,6 +326,7 @@ impl ClaudeAdapter {
if let Some(key) = env
.get("OPENAI_API_KEY")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 OPENAI_API_KEY");
@@ -265,6 +340,7 @@ impl ClaudeAdapter {
.get("apiKey")
.or_else(|| provider.settings_config.get("api_key"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
{
log::debug!("[Claude] 使用 apiKey/api_key");
@@ -352,14 +428,43 @@ impl ProviderAdapter for ClaudeAdapter {
));
}
let strategy = match provider_type {
ProviderType::OpenRouter => AuthStrategy::Bearer,
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
_ => AuthStrategy::Anthropic,
};
let key = self.extract_key(provider)?;
self.extract_key(provider)
.map(|key| AuthInfo::new(key, strategy))
match provider_type {
ProviderType::GeminiCli => {
// Parse stored OAuth JSON and only attach access_token when
// it's actually usable. `parse_oauth_credentials` accepts
// refresh-token-only JSON (which is legitimate before the
// first refresh) and also surfaces `{"access_token": "", ...}`
// for expired credentials. In both cases we would otherwise
// send `Authorization: Bearer ` to upstream and get a 401.
//
// CC Switch does not currently exchange the refresh_token for
// a fresh access_token. Until that path exists, degrade to
// plain GoogleOAuth strategy (which still sends the raw key
// as a fallback) and log loudly so users know to refresh
// their `~/.gemini/oauth_creds.json`.
match super::gemini::GeminiAdapter::new().parse_oauth_credentials(&key) {
Some(creds) if !creds.access_token.is_empty() => {
Some(AuthInfo::with_access_token(key, creds.access_token))
}
Some(_) => {
log::warn!(
"[Gemini OAuth] access_token missing or empty for provider `{}`; \
bearer auth will likely fail with 401. Refresh \
~/.gemini/oauth_creds.json via the gemini CLI to obtain a new token.",
provider.id
);
Some(AuthInfo::new(key, AuthStrategy::GoogleOAuth))
}
None => Some(AuthInfo::new(key, AuthStrategy::GoogleOAuth)),
}
}
ProviderType::Gemini => Some(AuthInfo::new(key, AuthStrategy::Google)),
ProviderType::OpenRouter => Some(AuthInfo::new(key, AuthStrategy::Bearer)),
ProviderType::ClaudeAuth => Some(AuthInfo::new(key, AuthStrategy::ClaudeAuth)),
_ => Some(AuthInfo::new(key, AuthStrategy::Anthropic)),
}
}
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
@@ -401,6 +506,23 @@ impl ProviderAdapter for ClaudeAdapter {
HeaderValue::from_str(&bearer).unwrap(),
)]
}
AuthStrategy::Google => vec![(
HeaderName::from_static("x-goog-api-key"),
HeaderValue::from_str(&auth.api_key).unwrap(),
)],
AuthStrategy::GoogleOAuth => {
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
vec![
(
HeaderName::from_static("authorization"),
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
),
(
HeaderName::from_static("x-goog-api-client"),
HeaderValue::from_static("GeminiCLI/1.0"),
),
]
}
AuthStrategy::CodexOAuth => {
// 注意:bearer token 由 forwarder 动态注入到 auth.api_key
// ChatGPT-Account-Id 由 forwarder 注入额外 header
@@ -456,6 +578,7 @@ impl ProviderAdapter for ClaudeAdapter {
HeaderName::from_static("x-interaction-type"),
HeaderValue::from_static("conversation-agent"),
),
// x-interaction-id 由 forwarder 按需注入(仅在有 session 时)
(
HeaderName::from_static("x-vscode-user-agent-library-version"),
HeaderValue::from_static("electron-fetch"),
@@ -470,7 +593,6 @@ impl ProviderAdapter for ClaudeAdapter {
),
]
}
_ => vec![],
}
}
@@ -491,7 +613,7 @@ impl ProviderAdapter for ClaudeAdapter {
// - "openai_responses": 需要 Anthropic ↔ OpenAI Responses API 格式转换
matches!(
self.get_api_format(provider),
"openai_chat" | "openai_responses"
"openai_chat" | "openai_responses" | "gemini_native"
)
}
@@ -500,7 +622,13 @@ impl ProviderAdapter for ClaudeAdapter {
body: serde_json::Value,
provider: &Provider,
) -> Result<serde_json::Value, ProxyError> {
transform_claude_request_for_api_format(body, provider, self.get_api_format(provider))
transform_claude_request_for_api_format(
body,
provider,
self.get_api_format(provider),
None,
None,
)
}
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
@@ -509,7 +637,9 @@ impl ProviderAdapter for ClaudeAdapter {
// config, so we can't check api_format here. Instead we rely on the fact that
// Responses API always returns "output" while Chat Completions returns "choices".
// This is safe because the two formats are structurally disjoint.
if body.get("output").is_some() {
if body.get("candidates").is_some() || body.get("promptFeedback").is_some() {
super::transform_gemini::gemini_to_anthropic(body)
} else if body.get("output").is_some() {
super::transform_responses::responses_to_anthropic(body)
} else {
super::transform::openai_to_anthropic(body)
@@ -647,6 +777,146 @@ mod tests {
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
}
/// Regression: a Gemini OAuth credential JSON that carries only a
/// refresh_token (no active access_token) must not be surfaced as an
/// `AuthInfo` whose bearer would be empty. Without the guard, downstream
/// header injection produces `Authorization: Bearer ` and a deterministic
/// 401 from upstream.
#[test]
fn test_extract_auth_gemini_cli_refresh_only_json_does_not_expose_empty_bearer() {
let adapter = ClaudeAdapter::new();
let refresh_only_json =
r#"{"refresh_token":"rt-abc","client_id":"cid","client_secret":"cs"}"#;
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
"ANTHROPIC_API_KEY": refresh_only_json
}
}),
ProviderMeta {
api_format: Some("gemini_native".to_string()),
..Default::default()
},
);
let auth = adapter.extract_auth(&provider).unwrap();
// access_token must not be surfaced as `Some("")` — the OAuth header
// builder uses `access_token.as_ref().unwrap_or(&api_key)`, so a
// `Some("")` would win over the raw key and emit `Bearer `.
assert!(
auth.access_token.as_deref().is_none_or(|t| !t.is_empty()),
"empty access_token leaked into AuthInfo"
);
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
}
/// Companion case: a JSON credential with an empty-string `access_token`
/// field (the shape an expired credential can take after partial writes)
/// must degrade the same way.
#[test]
fn test_extract_auth_gemini_cli_empty_access_token_degrades_to_raw_key() {
let adapter = ClaudeAdapter::new();
let expired_json = r#"{"access_token":"","refresh_token":"rt-abc","client_id":"cid","client_secret":"cs"}"#;
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
"ANTHROPIC_API_KEY": expired_json
}
}),
ProviderMeta {
api_format: Some("gemini_native".to_string()),
..Default::default()
},
);
let auth = adapter.extract_auth(&provider).unwrap();
assert!(
auth.access_token.as_deref().is_none_or(|t| !t.is_empty()),
"empty access_token leaked into AuthInfo"
);
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
}
/// Counter-case: a well-formed JSON credential with a non-empty
/// access_token must still flow through the OAuth path unchanged.
#[test]
fn test_extract_auth_gemini_cli_valid_json_keeps_access_token() {
let adapter = ClaudeAdapter::new();
let valid_json = r#"{"access_token":"ya29.valid","refresh_token":"rt"}"#;
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
"ANTHROPIC_API_KEY": valid_json
}
}),
ProviderMeta {
api_format: Some("gemini_native".to_string()),
..Default::default()
},
);
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.access_token.as_deref(), Some("ya29.valid"));
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
}
/// 回归:从 oauth_creds.json 复制时常带前导换行/空格。未 trim 时
/// `starts_with('{')` 会落空,导致误分类为 `ProviderType::Gemini`,再
/// 以 raw JSON 当 `x-goog-api-key` 发出去触发 401。trim 应在 provider
/// 类型判定和 OAuth 解析前统一生效。
#[test]
fn test_extract_auth_gemini_cli_json_with_leading_whitespace_classifies_correctly() {
let adapter = ClaudeAdapter::new();
let valid_json = r#"{"access_token":"ya29.valid","refresh_token":"rt"}"#;
let key_with_whitespace = format!("\n {valid_json}\n");
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
"ANTHROPIC_API_KEY": key_with_whitespace
}
}),
ProviderMeta {
api_format: Some("gemini_native".to_string()),
..Default::default()
},
);
assert_eq!(adapter.provider_type(&provider), ProviderType::GeminiCli);
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.access_token.as_deref(), Some("ya29.valid"));
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
}
/// 回归:裸 `ya29.` access_token 若带前导换行,也应被 trim 后识别为
/// Gemini CLI OAuth,避免前导空白把 `starts_with("ya29.")` 检查顶穿。
#[test]
fn test_extract_auth_gemini_cli_access_token_with_leading_newline_classifies_correctly() {
let adapter = ClaudeAdapter::new();
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
"ANTHROPIC_API_KEY": "\nya29.raw-token-value\n"
}
}),
ProviderMeta {
api_format: Some("gemini_native".to_string()),
..Default::default()
},
);
assert_eq!(adapter.provider_type(&provider), ProviderType::GeminiCli);
let auth = adapter.extract_auth(&provider).unwrap();
assert_eq!(auth.access_token.as_deref(), Some("ya29.raw-token-value"));
assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth);
}
#[test]
fn test_provider_type_detection() {
let adapter = ClaudeAdapter::new();
@@ -813,6 +1083,24 @@ mod tests {
);
assert!(adapter.needs_transform(&openai_responses_provider));
let gemini_native_provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
"ANTHROPIC_API_KEY": "test-key"
}
}),
ProviderMeta {
api_format: Some("gemini_native".to_string()),
..Default::default()
},
);
assert!(adapter.needs_transform(&gemini_native_provider));
assert_eq!(
adapter.provider_type(&gemini_native_provider),
ProviderType::Gemini
);
// meta takes precedence over legacy settings_config fields
let meta_precedence_over_settings = create_provider_with_meta(
json!({
@@ -920,11 +1208,106 @@ mod tests {
"max_tokens": 128
});
let transformed =
transform_claude_request_for_api_format(body, &provider, "openai_responses").unwrap();
let transformed = transform_claude_request_for_api_format(
body,
&provider,
"openai_responses",
None,
None,
)
.unwrap();
assert_eq!(transformed["model"], "gpt-5.4");
assert!(transformed.get("input").is_some());
assert!(transformed.get("max_output_tokens").is_some());
}
#[test]
fn test_transform_claude_request_for_api_format_gemini_native() {
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com",
"ANTHROPIC_API_KEY": "test-key"
}
}),
ProviderMeta {
api_format: Some("gemini_native".to_string()),
..Default::default()
},
);
let body = json!({
"model": "gemini-2.5-pro",
"system": "You are helpful.",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 64
});
let transformed =
transform_claude_request_for_api_format(body, &provider, "gemini_native", None, None)
.unwrap();
assert!(transformed.get("contents").is_some());
assert_eq!(
transformed["systemInstruction"]["parts"][0]["text"],
"You are helpful."
);
assert_eq!(transformed["generationConfig"]["maxOutputTokens"], 64);
}
#[test]
fn test_transform_claude_request_for_api_format_openai_chat_skips_prompt_cache_key_by_default()
{
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com",
"ANTHROPIC_API_KEY": "test-key"
}
}),
ProviderMeta {
api_format: Some("openai_chat".to_string()),
..Default::default()
},
);
let body = json!({
"model": "gpt-5.4",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 64
});
let transformed =
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
.unwrap();
assert!(transformed.get("prompt_cache_key").is_none());
}
#[test]
fn test_transform_claude_request_for_api_format_openai_chat_keeps_explicit_prompt_cache_key() {
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com",
"ANTHROPIC_API_KEY": "test-key"
}
}),
ProviderMeta {
api_format: Some("openai_chat".to_string()),
prompt_cache_key: Some("claude-cache-route".to_string()),
..Default::default()
},
);
let body = json!({
"model": "gpt-5.4",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 64
});
let transformed =
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
.unwrap();
assert_eq!(transformed["prompt_cache_key"], "claude-cache-route");
}
}
+13 -1
View File
@@ -70,6 +70,11 @@ impl GeminiAdapter {
/// 解析 OAuth 凭证
pub fn parse_oauth_credentials(&self, key: &str) -> Option<OAuthCredentials> {
// 防御性 trim:前端在 input 事件中会 trim,但 JSON 编辑器 / deeplink
// 导入 / live 回填等路径会绕过。带前导换行的 oauth_creds.json 粘贴
// 是常见场景,此处统一兜底。
let key = key.trim();
// 直接是 access_token
if key.starts_with("ya29.") {
return Some(OAuthCredentials {
@@ -120,7 +125,12 @@ impl GeminiAdapter {
fn extract_key_raw(&self, provider: &Provider) -> Option<String> {
if let Some(env) = provider.settings_config.get("env") {
// 使用 GEMINI_API_KEY
if let Some(key) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) {
if let Some(key) = env
.get("GEMINI_API_KEY")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
{
return Some(key.to_string());
}
}
@@ -131,6 +141,8 @@ impl GeminiAdapter {
.get("apiKey")
.or_else(|| provider.settings_config.get("api_key"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
{
return Some(key.to_string());
}
@@ -0,0 +1,338 @@
//! Gemini tool schema helpers.
//!
//! Gemini `FunctionDeclaration` supports two schema channels:
//! - `parameters`: a restricted `Schema` subset
//! - `parametersJsonSchema`: richer JSON Schema via arbitrary JSON `Value`
//!
//! Anthropic tool schemas are closer to JSON Schema, so we choose the richer
//! channel when unsupported `Schema` fields are present.
use serde_json::{json, Map, Value};
#[derive(Debug, Clone, PartialEq)]
pub enum GeminiFunctionParameters {
Schema(Value),
JsonSchema(Value),
}
pub fn build_gemini_function_parameters(input_schema: Value) -> GeminiFunctionParameters {
let schema = ensure_object_schema(normalize_json_schema(input_schema));
if requires_parameters_json_schema(&schema) {
GeminiFunctionParameters::JsonSchema(schema)
} else {
GeminiFunctionParameters::Schema(to_gemini_schema(schema))
}
}
/// Vertex AI rejects FunctionDeclarations whose `parameters` schema lacks an
/// explicit `type: "object"`, returning:
///
/// > functionDeclaration parameters schema should be of type OBJECT.
///
/// Anthropic tools sometimes arrive with empty or type-less `input_schema`
/// (e.g. no-argument tools like Claude Code's `TodoRead`). Normalize those to
/// `{type: "object", properties: {}}` so the Gemini upstream accepts them.
///
/// References: google-gemini/generative-ai-python#423, BerriAI/litellm#5055.
fn ensure_object_schema(schema: Value) -> Value {
match schema {
Value::Object(mut obj) => {
obj.entry("type".to_string())
.or_insert_with(|| json!("object"));
if obj.get("type").and_then(|v| v.as_str()) == Some("object") {
obj.entry("properties".to_string())
.or_insert_with(|| json!({}));
}
Value::Object(obj)
}
other => other,
}
}
fn normalize_json_schema(schema: Value) -> Value {
match schema {
Value::Object(mut obj) => {
obj.remove("$schema");
obj.remove("$id");
if let Some(properties) = obj
.get_mut("properties")
.and_then(|value| value.as_object_mut())
{
for value in properties.values_mut() {
*value = normalize_json_schema(value.clone());
}
}
if let Some(items) = obj.get_mut("items") {
*items = normalize_json_schema(items.clone());
}
for key in ["anyOf", "oneOf", "allOf", "prefixItems"] {
if let Some(values) = obj.get_mut(key).and_then(|value| value.as_array_mut()) {
for value in values.iter_mut() {
*value = normalize_json_schema(value.clone());
}
}
}
for key in ["not", "if", "then", "else", "additionalProperties"] {
if let Some(value) = obj.get_mut(key) {
*value = normalize_json_schema(value.clone());
}
}
Value::Object(obj)
}
Value::Array(values) => {
Value::Array(values.into_iter().map(normalize_json_schema).collect())
}
other => other,
}
}
fn requires_parameters_json_schema(schema: &Value) -> bool {
match schema {
Value::Object(obj) => object_requires_parameters_json_schema(obj),
Value::Array(values) => values.iter().any(requires_parameters_json_schema),
_ => false,
}
}
fn object_requires_parameters_json_schema(obj: &Map<String, Value>) -> bool {
for (key, value) in obj {
match key.as_str() {
"type" => {
if value.is_array() {
return true;
}
}
"format" | "title" | "description" | "nullable" | "enum" | "maxItems" | "minItems"
| "required" | "minProperties" | "maxProperties" | "minLength" | "maxLength"
| "pattern" | "example" | "propertyOrdering" | "default" | "minimum" | "maximum" => {}
"properties" => {
let Some(properties) = value.as_object() else {
return true;
};
if properties.values().any(requires_parameters_json_schema) {
return true;
}
}
"items" => {
if !value.is_object() || requires_parameters_json_schema(value) {
return true;
}
}
"anyOf" => {
let Some(values) = value.as_array() else {
return true;
};
if values.iter().any(requires_parameters_json_schema) {
return true;
}
}
// JSON Schema keywords that Gemini `parameters` does not accept.
"$ref"
| "$defs"
| "definitions"
| "additionalProperties"
| "unevaluatedProperties"
| "patternProperties"
| "oneOf"
| "allOf"
| "const"
| "not"
| "if"
| "then"
| "else"
| "dependentRequired"
| "dependentSchemas"
| "contains"
| "minContains"
| "maxContains"
| "prefixItems"
| "exclusiveMinimum"
| "exclusiveMaximum"
| "multipleOf"
| "examples" => return true,
// Be conservative for unknown keywords.
_ => return true,
}
}
false
}
fn to_gemini_schema(schema: Value) -> Value {
match schema {
Value::Object(obj) => {
let mut result = Map::new();
for (key, value) in obj {
match key.as_str() {
"type" | "format" | "title" | "description" | "nullable" | "enum"
| "maxItems" | "minItems" | "required" | "minProperties" | "maxProperties"
| "minLength" | "maxLength" | "pattern" | "example" | "propertyOrdering"
| "default" | "minimum" | "maximum" => {
result.insert(key, value);
}
"properties" => {
if let Some(properties) = value.as_object() {
let converted = properties
.iter()
.map(|(name, property_schema)| {
(name.clone(), to_gemini_schema(property_schema.clone()))
})
.collect();
result.insert("properties".to_string(), Value::Object(converted));
}
}
"items" if value.is_object() => {
result.insert("items".to_string(), to_gemini_schema(value));
}
"anyOf" => {
if let Some(values) = value.as_array() {
result.insert(
"anyOf".to_string(),
Value::Array(
values
.iter()
.map(|value| to_gemini_schema(value.clone()))
.collect(),
),
);
}
}
_ => {}
}
}
Value::Object(result)
}
other => other,
}
}
pub fn build_gemini_function_declaration(
name: &str,
description: Option<&str>,
input_schema: Value,
) -> Value {
let mut declaration = Map::new();
declaration.insert("name".to_string(), json!(name));
declaration.insert("description".to_string(), json!(description.unwrap_or("")));
match build_gemini_function_parameters(input_schema) {
GeminiFunctionParameters::Schema(schema) => {
declaration.insert("parameters".to_string(), schema);
}
GeminiFunctionParameters::JsonSchema(schema) => {
declaration.insert("parametersJsonSchema".to_string(), schema);
}
}
Value::Object(declaration)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uses_schema_for_simple_openapi_subset() {
let schema = json!({
"type": "object",
"properties": {
"city": { "type": "string", "description": "Target city" }
},
"required": ["city"]
});
let result = build_gemini_function_declaration("weather", Some("Weather lookup"), schema);
assert!(result.get("parameters").is_some());
assert!(result.get("parametersJsonSchema").is_none());
assert_eq!(result["parameters"]["properties"]["city"]["type"], "string");
}
#[test]
fn uses_parameters_json_schema_for_additional_properties() {
let schema = json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"city": { "type": "string" }
},
"required": ["city"],
"additionalProperties": false
});
let result = build_gemini_function_declaration("weather", Some("Weather lookup"), schema);
assert!(result.get("parameters").is_none());
assert!(result.get("parametersJsonSchema").is_some());
assert!(result["parametersJsonSchema"].get("$schema").is_none());
assert_eq!(
result["parametersJsonSchema"]["additionalProperties"],
false
);
}
#[test]
fn uses_parameters_json_schema_for_one_of() {
let schema = json!({
"type": "object",
"properties": {
"target": {
"oneOf": [
{ "type": "string" },
{ "type": "integer" }
]
}
}
});
let result = build_gemini_function_declaration("search", Some("Search"), schema);
assert!(result.get("parameters").is_none());
assert!(result.get("parametersJsonSchema").is_some());
}
/// Regression for P2 (Vertex AI rejecting empty schemas): zero-argument
/// Anthropic tools (no `input_schema`) must produce `parameters` with an
/// explicit `type: "object"` and an empty `properties` map so the Gemini
/// upstream does not return `schema should be of type OBJECT`.
#[test]
fn empty_input_schema_produces_explicit_object_type() {
let result = build_gemini_function_declaration("ping", Some("no-arg"), json!({}));
assert_eq!(result["parameters"]["type"], "object");
assert!(result["parameters"]["properties"].is_object());
}
/// A schema that carries descriptive fields but no `type` is still a
/// zero-arg object for Gemini purposes — promote it explicitly.
#[test]
fn input_schema_missing_type_is_promoted_to_object() {
let result = build_gemini_function_declaration(
"noop",
None,
json!({ "description": "does nothing" }),
);
assert_eq!(result["parameters"]["type"], "object");
assert!(result["parameters"]["properties"].is_object());
}
/// Defensive: an atomic (non-object) schema is left untouched, because
/// forcing `type: "object"` here would corrupt primitive parameter types
/// that happen to flow through this path.
#[test]
fn non_object_schema_is_not_mutated() {
let result = build_gemini_function_declaration("bare", None, json!({ "type": "string" }));
assert_eq!(result["parameters"]["type"], "string");
assert!(result["parameters"].get("properties").is_none());
}
}
@@ -0,0 +1,389 @@
//! Gemini Native shadow state
//!
//! Keeps provider/session-scoped assistant content snapshots and tool call metadata
//! so Gemini thought signatures and tool turns can be replayed without bloating
//! the main proxy files.
use serde_json::Value;
use std::collections::{HashMap, VecDeque};
use std::sync::RwLock;
/// Composite key for a Gemini shadow session.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GeminiShadowKey {
pub provider_id: String,
pub session_id: String,
}
impl GeminiShadowKey {
pub fn new(provider_id: impl Into<String>, session_id: impl Into<String>) -> Self {
Self {
provider_id: provider_id.into(),
session_id: session_id.into(),
}
}
}
/// Gemini function call metadata captured from an assistant turn.
#[derive(Debug, Clone, PartialEq)]
pub struct GeminiToolCallMeta {
pub id: Option<String>,
pub name: String,
pub args: Value,
pub thought_signature: Option<String>,
}
impl GeminiToolCallMeta {
pub fn new(
id: Option<impl Into<String>>,
name: impl Into<String>,
args: Value,
thought_signature: Option<impl Into<String>>,
) -> Self {
Self {
id: id.map(Into::into),
name: name.into(),
args,
thought_signature: thought_signature.map(Into::into),
}
}
}
/// Stored assistant turn snapshot.
#[derive(Debug, Clone, PartialEq)]
pub struct GeminiAssistantTurn {
pub assistant_content: Value,
pub tool_calls: Vec<GeminiToolCallMeta>,
}
impl GeminiAssistantTurn {
pub fn new(assistant_content: Value, tool_calls: Vec<GeminiToolCallMeta>) -> Self {
Self {
assistant_content,
tool_calls,
}
}
}
/// Session snapshot returned by read APIs.
#[derive(Debug, Clone, PartialEq)]
pub struct GeminiShadowSessionSnapshot {
pub provider_id: String,
pub session_id: String,
pub turns: Vec<GeminiAssistantTurn>,
}
#[derive(Debug, Clone)]
struct GeminiShadowSession {
turns: VecDeque<GeminiAssistantTurn>,
}
impl GeminiShadowSession {
fn new() -> Self {
Self {
turns: VecDeque::new(),
}
}
}
#[derive(Debug, Clone)]
struct GeminiShadowInner {
sessions: HashMap<GeminiShadowKey, GeminiShadowSession>,
session_order: VecDeque<GeminiShadowKey>,
}
impl GeminiShadowInner {
fn new() -> Self {
Self {
sessions: HashMap::new(),
session_order: VecDeque::new(),
}
}
}
/// Thread-safe shadow store for Gemini Native replay state.
///
/// The store is intentionally small and explicit:
/// - sessions are keyed by `(provider_id, session_id)`
/// - each session keeps only a bounded number of recent assistant turns
/// - the oldest session is evicted first when the store is full
#[derive(Debug)]
pub struct GeminiShadowStore {
max_sessions: usize,
max_turns_per_session: usize,
inner: RwLock<GeminiShadowInner>,
}
impl Default for GeminiShadowStore {
fn default() -> Self {
Self::with_limits(200, 64)
}
}
impl GeminiShadowStore {
#[allow(dead_code)]
pub fn new() -> Self {
Self::default()
}
pub fn with_limits(max_sessions: usize, max_turns_per_session: usize) -> Self {
Self {
max_sessions: max_sessions.max(1),
max_turns_per_session: max_turns_per_session.max(1),
inner: RwLock::new(GeminiShadowInner::new()),
}
}
/// Record a Gemini assistant turn for later replay.
pub fn record_assistant_turn(
&self,
provider_id: impl Into<String>,
session_id: impl Into<String>,
assistant_content: Value,
tool_calls: Vec<GeminiToolCallMeta>,
) -> GeminiShadowSessionSnapshot {
let key = GeminiShadowKey::new(provider_id, session_id);
let turn = GeminiAssistantTurn::new(assistant_content, tool_calls);
let mut inner = self.inner.write().expect("gemini shadow lock poisoned");
Self::touch_session_order(&mut inner.session_order, &key);
let snapshot = {
let session = inner
.sessions
.entry(key.clone())
.or_insert_with(GeminiShadowSession::new);
session.turns.push_back(turn);
while session.turns.len() > self.max_turns_per_session {
session.turns.pop_front();
}
Self::snapshot_session(&key, session)
};
Self::prune_sessions(&mut inner, self.max_sessions);
snapshot
}
/// Get the latest assistant content for a provider/session pair.
#[allow(dead_code)]
pub fn latest_assistant_content(&self, provider_id: &str, session_id: &str) -> Option<Value> {
self.get_session(provider_id, session_id)
.and_then(|snapshot| {
snapshot
.turns
.last()
.map(|turn| turn.assistant_content.clone())
})
}
/// Get the latest tool calls for a provider/session pair.
#[allow(dead_code)]
pub fn latest_tool_calls(
&self,
provider_id: &str,
session_id: &str,
) -> Option<Vec<GeminiToolCallMeta>> {
self.get_session(provider_id, session_id)
.and_then(|snapshot| snapshot.turns.last().map(|turn| turn.tool_calls.clone()))
}
/// Read a full session snapshot.
pub fn get_session(
&self,
provider_id: &str,
session_id: &str,
) -> Option<GeminiShadowSessionSnapshot> {
let key = GeminiShadowKey::new(provider_id, session_id);
let mut inner = self.inner.write().expect("gemini shadow lock poisoned");
let snapshot = inner
.sessions
.get(&key)
.map(|session| Self::snapshot_session(&key, session));
if snapshot.is_some() {
Self::touch_session_order(&mut inner.session_order, &key);
}
snapshot
}
/// Remove a single session from the store.
#[allow(dead_code)]
pub fn clear_session(&self, provider_id: &str, session_id: &str) -> bool {
let key = GeminiShadowKey::new(provider_id, session_id);
let mut inner = self.inner.write().expect("gemini shadow lock poisoned");
let removed = inner.sessions.remove(&key).is_some();
if removed {
Self::remove_key_from_order(&mut inner.session_order, &key);
}
removed
}
/// Remove all sessions for a provider.
#[allow(dead_code)]
pub fn clear_provider(&self, provider_id: &str) -> usize {
let mut inner = self.inner.write().expect("gemini shadow lock poisoned");
let keys: Vec<_> = inner
.sessions
.keys()
.filter(|key| key.provider_id == provider_id)
.cloned()
.collect();
for key in &keys {
inner.sessions.remove(key);
Self::remove_key_from_order(&mut inner.session_order, key);
}
keys.len()
}
/// Number of tracked sessions.
#[allow(dead_code)]
pub fn session_count(&self) -> usize {
self.inner
.read()
.expect("gemini shadow lock poisoned")
.sessions
.len()
}
fn snapshot_session(
key: &GeminiShadowKey,
session: &GeminiShadowSession,
) -> GeminiShadowSessionSnapshot {
GeminiShadowSessionSnapshot {
provider_id: key.provider_id.clone(),
session_id: key.session_id.clone(),
turns: session.turns.iter().cloned().collect(),
}
}
fn touch_session_order(order: &mut VecDeque<GeminiShadowKey>, key: &GeminiShadowKey) {
if let Some(pos) = order.iter().position(|existing| existing == key) {
order.remove(pos);
}
order.push_back(key.clone());
}
#[allow(dead_code)]
fn remove_key_from_order(order: &mut VecDeque<GeminiShadowKey>, key: &GeminiShadowKey) {
if let Some(pos) = order.iter().position(|existing| existing == key) {
order.remove(pos);
}
}
fn prune_sessions(inner: &mut GeminiShadowInner, max_sessions: usize) {
while inner.sessions.len() > max_sessions {
let Some(evicted_key) = inner.session_order.pop_front() else {
break;
};
inner.sessions.remove(&evicted_key);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn record_and_read_latest_turn() {
let store = GeminiShadowStore::with_limits(8, 4);
let snapshot = store.record_assistant_turn(
"provider-a",
"session-1",
json!({"parts": [{"text": "hello", "thoughtSignature": "sig-1"}]}),
vec![GeminiToolCallMeta::new(
Some("call-1"),
"get_weather",
json!({"location": "Tokyo"}),
Some("sig-1"),
)],
);
assert_eq!(snapshot.provider_id, "provider-a");
assert_eq!(snapshot.session_id, "session-1");
assert_eq!(snapshot.turns.len(), 1);
let content = store
.latest_assistant_content("provider-a", "session-1")
.expect("content");
assert_eq!(content["parts"][0]["text"], "hello");
assert_eq!(content["parts"][0]["thoughtSignature"], "sig-1");
let tool_calls = store
.latest_tool_calls("provider-a", "session-1")
.expect("tool calls");
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0].id.as_deref(), Some("call-1"));
assert_eq!(tool_calls[0].name, "get_weather");
assert_eq!(tool_calls[0].args["location"], "Tokyo");
assert_eq!(tool_calls[0].thought_signature.as_deref(), Some("sig-1"));
}
#[test]
fn sessions_are_isolated_by_provider_and_session_id() {
let store = GeminiShadowStore::with_limits(8, 4);
store.record_assistant_turn("provider-a", "session-1", json!({"text": "a"}), vec![]);
store.record_assistant_turn("provider-b", "session-1", json!({"text": "b"}), vec![]);
store.record_assistant_turn("provider-a", "session-2", json!({"text": "c"}), vec![]);
assert_eq!(store.session_count(), 3);
assert_eq!(
store.latest_assistant_content("provider-a", "session-1"),
Some(json!({"text": "a"}))
);
assert_eq!(
store.latest_assistant_content("provider-b", "session-1"),
Some(json!({"text": "b"}))
);
assert_eq!(
store.latest_assistant_content("provider-a", "session-2"),
Some(json!({"text": "c"}))
);
}
#[test]
fn retains_only_latest_turns_per_session() {
let store = GeminiShadowStore::with_limits(8, 2);
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 1}), vec![]);
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 2}), vec![]);
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 3}), vec![]);
let snapshot = store
.get_session("provider-a", "session-1")
.expect("snapshot");
assert_eq!(snapshot.turns.len(), 2);
assert_eq!(snapshot.turns[0].assistant_content, json!({"idx": 2}));
assert_eq!(snapshot.turns[1].assistant_content, json!({"idx": 3}));
}
#[test]
fn evicts_oldest_session_when_capacity_is_exceeded() {
let store = GeminiShadowStore::with_limits(2, 2);
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 1}), vec![]);
store.record_assistant_turn("provider-a", "session-2", json!({"idx": 2}), vec![]);
store.record_assistant_turn("provider-a", "session-3", json!({"idx": 3}), vec![]);
assert!(store.get_session("provider-a", "session-1").is_none());
assert!(store.get_session("provider-a", "session-2").is_some());
assert!(store.get_session("provider-a", "session-3").is_some());
}
#[test]
fn clear_session_and_provider_work() {
let store = GeminiShadowStore::with_limits(8, 4);
store.record_assistant_turn("provider-a", "session-1", json!({"idx": 1}), vec![]);
store.record_assistant_turn("provider-a", "session-2", json!({"idx": 2}), vec![]);
store.record_assistant_turn("provider-b", "session-3", json!({"idx": 3}), vec![]);
assert!(store.clear_session("provider-a", "session-1"));
assert!(store.get_session("provider-a", "session-1").is_none());
let removed = store.clear_provider("provider-a");
assert_eq!(removed, 1);
assert!(store.get_session("provider-a", "session-2").is_none());
assert!(store.get_session("provider-b", "session-3").is_some());
}
}
+12
View File
@@ -18,10 +18,14 @@ mod codex;
pub mod codex_oauth_auth;
pub mod copilot_auth;
mod gemini;
pub(crate) mod gemini_schema;
pub mod gemini_shadow;
pub mod models;
pub mod streaming;
pub mod streaming_gemini;
pub mod streaming_responses;
pub mod transform;
pub mod transform_gemini;
pub mod transform_responses;
use crate::app_config::AppType;
@@ -101,6 +105,14 @@ impl ProviderType {
pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {
match app_type {
AppType::Claude => {
if get_claude_api_format(provider) == "gemini_native" {
let adapter = ClaudeAdapter::new();
return match adapter.extract_auth(provider).map(|auth| auth.strategy) {
Some(AuthStrategy::GoogleOAuth) => ProviderType::GeminiCli,
_ => ProviderType::Gemini,
};
}
// 检测是否为 GitHub Copilot
if let Some(meta) = provider.meta.as_ref() {
if meta.provider_type.as_deref() == Some("github_copilot") {
+33 -6
View File
@@ -2,7 +2,7 @@
//!
//! 实现 OpenAI SSE → Anthropic SSE 格式转换
use crate::proxy::sse::strip_sse_field;
use crate::proxy::sse::{strip_sse_field, take_sse_block};
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use serde::{Deserialize, Serialize};
@@ -85,8 +85,16 @@ struct ToolBlockState {
name: String,
started: bool,
pending_args: String,
/// 连续空白字符计数 — 用于检测 Copilot 无限换行 bug
/// 当 function call 参数中出现连续 20+ 空白字符时,强制终止流
consecutive_whitespace: usize,
/// 是否已因无限空白 bug 被中止
aborted: bool,
}
/// 无限空白 bug 的连续空白字符阈值
const INFINITE_WHITESPACE_THRESHOLD: usize = 20;
/// 创建 Anthropic SSE 流
pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
@@ -110,10 +118,7 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
Ok(bytes) => {
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
while let Some(pos) = buffer.find("\n\n") {
let line = buffer[..pos].to_string();
buffer = buffer[pos + 2..].to_string();
while let Some(line) = take_sse_block(&mut buffer) {
if line.trim().is_empty() {
continue;
}
@@ -297,9 +302,16 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
name: String::new(),
started: false,
pending_args: String::new(),
consecutive_whitespace: 0,
aborted: false,
}
});
// 如果此 tool call 已被中止(无限空白 bug),跳过后续处理
if state.aborted {
continue;
}
if let Some(id) = &tool_call.id {
state.id = id.clone();
}
@@ -328,7 +340,22 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
.as_ref()
.and_then(|f| f.arguments.clone());
let immediate_delta = if let Some(args) = args_delta {
if state.started {
// 无限空白 bug 检测:跟踪连续空白字符
for ch in args.chars() {
if ch.is_whitespace() {
state.consecutive_whitespace += 1;
} else {
state.consecutive_whitespace = 0;
}
}
if state.consecutive_whitespace >= INFINITE_WHITESPACE_THRESHOLD {
log::warn!(
"[Copilot] 检测到无限空白 bug (tool: {}), 中止此 tool call 流",
state.name
);
state.aborted = true;
None
} else if state.started {
Some(args)
} else {
state.pending_args.push_str(&args);
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,7 @@
//! 与 Chat Completions 的 delta chunk 模型完全不同,需要独立的状态机处理。
use super::transform_responses::{build_anthropic_usage_from_responses, map_responses_stop_reason};
use crate::proxy::sse::strip_sse_field;
use crate::proxy::sse::{strip_sse_field, take_sse_block};
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use serde_json::{json, Value};
@@ -122,10 +122,7 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
// SSE 事件由 \n\n 分隔
while let Some(pos) = buffer.find("\n\n") {
let block = buffer[..pos].to_string();
buffer = buffer[pos + 2..].to_string();
while let Some(block) = take_sse_block(&mut buffer) {
if block.trim().is_empty() {
continue;
}
+95 -53
View File
@@ -35,7 +35,7 @@ pub fn supports_reasoning_effort(model: &str) -> bool {
/// `low`/`medium`/`high` map 1:1; `max` maps to `xhigh`
/// (supported by mainstream GPT models). Unknown values are ignored.
/// 2. Fallback: `thinking.type` + `budget_tokens`:
/// - `adaptive` → `high` (mirrors optimizer semantics where adaptive ≈ max effort)
/// - `adaptive` → `xhigh` (adaptive = maximum reasoning effort)
/// - `enabled` with budget → `low` (<4 000) / `medium` (4 00015 999) / `high` (≥16 000)
/// - `enabled` without budget → `high` (conservative default)
/// - `disabled` / absent → `None`
@@ -57,7 +57,7 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
// --- Priority 2: thinking.type + budget_tokens fallback ---
let thinking = body.get("thinking")?;
match thinking.get("type").and_then(|t| t.as_str()) {
Some("adaptive") => Some("high"),
Some("adaptive") => Some("xhigh"),
Some("enabled") => {
let budget = thinking.get("budget_tokens").and_then(|b| b.as_u64());
match budget {
@@ -71,10 +71,8 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
}
}
/// Anthropic 请求 → OpenAI 请求
///
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value, ProxyError> {
/// Anthropic 请求 → OpenAI Chat Completions 请求
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
let mut result = json!({});
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
@@ -175,11 +173,6 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
result["tool_choice"] = v.clone();
}
// Inject prompt_cache_key for improved cache routing on OpenAI-compatible endpoints
if let Some(key) = cache_key {
result["prompt_cache_key"] = json!(key);
}
Ok(result)
}
@@ -206,6 +199,10 @@ fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
}
let mut parts = Vec::new();
let mut inherited_cache_control: Option<Value> = None;
let mut cache_control_conflict = false;
let mut saw_cache_control = false;
let mut saw_missing_cache_control = false;
messages.retain(|message| {
if message.get("role").and_then(|value| value.as_str()) != Some("system") {
return true;
@@ -226,11 +223,28 @@ fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
_ => {}
}
if let Some(cache_control) = message.get("cache_control") {
saw_cache_control = true;
match &inherited_cache_control {
None => inherited_cache_control = Some(cache_control.clone()),
Some(existing) if existing == cache_control => {}
Some(_) => cache_control_conflict = true,
}
} else {
saw_missing_cache_control = true;
}
false
});
if !parts.is_empty() {
messages.insert(0, json!({"role": "system", "content": parts.join("\n")}));
let mut merged = json!({"role": "system", "content": parts.join("\n")});
if !(cache_control_conflict || (saw_cache_control && saw_missing_cache_control)) {
if let Some(cache_control) = inherited_cache_control {
merged["cache_control"] = cache_control;
}
}
messages.insert(0, merged);
}
}
@@ -569,7 +583,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["model"], "claude-3-opus");
assert_eq!(result["max_tokens"], 1024);
assert_eq!(result["messages"][0]["role"], "user");
@@ -585,7 +599,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(
result["messages"][0]["content"],
@@ -607,36 +621,76 @@ mod tests {
}]
});
let result = anthropic_to_openai(input, None).unwrap();
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["tools"][0]["type"], "function");
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
}
#[test]
fn test_anthropic_to_openai_normalizes_fragmented_system_messages() {
fn test_anthropic_to_openai_preserves_matching_system_cache_control_when_merging() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
"system": [
{"type": "text", "text": "You are Claude Code."},
{"type": "text", "text": "Be concise."}
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "Be concise.", "cache_control": {"type": "ephemeral"}}
],
"messages": [
{"role": "system", "content": "Follow repo conventions."},
{"role": "user", "content": "Hello"}
]
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["messages"].as_array().unwrap().len(), 2);
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(
result["messages"][0]["content"],
"You are Claude Code.\nBe concise.\nFollow repo conventions."
"You are Claude Code.\nBe concise."
);
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
assert_eq!(result["messages"][1]["role"], "user");
}
#[test]
fn test_anthropic_to_openai_drops_mixed_present_absent_system_cache_control_when_merging() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
"system": [
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "Be concise."}
],
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(
result["messages"][0]["content"],
"You are Claude Code.\nBe concise."
);
assert!(result["messages"][0].get("cache_control").is_none());
}
#[test]
fn test_anthropic_to_openai_drops_conflicting_system_cache_control_when_merging() {
let input = json!({
"model": "claude-3-sonnet",
"max_tokens": 1024,
"system": [
{"type": "text", "text": "You are Claude Code.", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "Be concise.", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
],
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["messages"][0]["role"], "system");
assert_eq!(
result["messages"][0]["content"],
"You are Claude Code.\nBe concise."
);
assert!(result["messages"][0].get("cache_control").is_none());
}
#[test]
fn test_anthropic_to_openai_tool_use() {
let input = json!({
@@ -651,7 +705,7 @@ mod tests {
}]
});
let result = anthropic_to_openai(input, None).unwrap();
let result = anthropic_to_openai(input).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "assistant");
assert!(msg.get("tool_calls").is_some());
@@ -671,7 +725,7 @@ mod tests {
}]
});
let result = anthropic_to_openai(input, None).unwrap();
let result = anthropic_to_openai(input).unwrap();
let msg = &result["messages"][0];
assert_eq!(msg["role"], "tool");
assert_eq!(msg["tool_call_id"], "call_123");
@@ -743,31 +797,19 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["model"], "gpt-4o");
}
#[test]
fn test_anthropic_to_openai_with_cache_key() {
fn test_anthropic_to_openai_does_not_inject_prompt_cache_key() {
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, Some("provider-123")).unwrap();
assert_eq!(result["prompt_cache_key"], "provider-123");
}
#[test]
fn test_anthropic_to_openai_no_cache_key() {
let input = json!({
"model": "claude-3-opus",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
let result = anthropic_to_openai(input).unwrap();
assert!(result.get("prompt_cache_key").is_none());
}
@@ -793,7 +835,7 @@ mod tests {
}]
});
let result = anthropic_to_openai(input, None).unwrap();
let result = anthropic_to_openai(input).unwrap();
// System message cache_control preserved
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
// Text block cache_control preserved
@@ -1019,9 +1061,9 @@ mod tests {
}
#[test]
fn test_thinking_adaptive_maps_high() {
fn test_thinking_adaptive_maps_xhigh() {
let body = json!({"thinking": {"type": "adaptive"}});
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
assert_eq!(resolve_reasoning_effort(&body), Some("xhigh"));
}
#[test]
@@ -1047,7 +1089,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
let result = anthropic_to_openai(input).unwrap();
assert!(result.get("reasoning_effort").is_none());
}
@@ -1060,7 +1102,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["reasoning_effort"], "medium");
}
@@ -1073,7 +1115,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["reasoning_effort"], "xhigh");
}
@@ -1086,7 +1128,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["reasoning_effort"], "low");
}
@@ -1099,8 +1141,8 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["reasoning_effort"], "high");
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["reasoning_effort"], "xhigh");
}
#[test]
@@ -1111,7 +1153,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
let result = anthropic_to_openai(input).unwrap();
assert!(result.get("reasoning_effort").is_none());
}
@@ -1124,7 +1166,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
let result = anthropic_to_openai(input).unwrap();
assert!(
result.get("max_tokens").is_none(),
"{model} should not have max_tokens"
@@ -1144,7 +1186,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
let result = anthropic_to_openai(input).unwrap();
assert_eq!(result["max_tokens"], 1024);
assert!(result.get("max_completion_tokens").is_none());
}
File diff suppressed because it is too large Load Diff
@@ -194,23 +194,14 @@ pub(crate) fn map_responses_stop_reason(
incomplete_reason: Option<&str>,
) -> Option<&'static str> {
status.map(|s| match s {
"completed" => {
if has_tool_use {
"tool_use"
} else {
"end_turn"
}
}
"incomplete" => {
"completed" if has_tool_use => "tool_use",
"incomplete"
if matches!(
incomplete_reason,
Some("max_output_tokens") | Some("max_tokens")
) || incomplete_reason.is_none()
{
"max_tokens"
} else {
"end_turn"
}
) || incomplete_reason.is_none() =>
{
"max_tokens"
}
_ => "end_turn",
})
@@ -1047,7 +1038,7 @@ mod tests {
}
#[test]
fn test_responses_thinking_adaptive_sets_reasoning_high() {
fn test_responses_thinking_adaptive_sets_reasoning_xhigh() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
@@ -1056,7 +1047,7 @@ mod tests {
});
let result = anthropic_to_responses(input, None, false).unwrap();
assert_eq!(result["reasoning"]["effort"], "high");
assert_eq!(result["reasoning"]["effort"], "xhigh");
}
#[test]
+2 -5
View File
@@ -5,7 +5,7 @@
use super::session::ProxySession;
use super::usage::parser::TokenUsage;
use super::ProxyError;
use crate::proxy::sse::strip_sse_field;
use crate::proxy::sse::{strip_sse_field, take_sse_block};
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use serde_json::Value;
@@ -86,10 +86,7 @@ impl StreamHandler {
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
// 提取完整事件
while let Some(pos) = buffer.find("\n\n") {
let event_text = buffer[..pos].to_string();
buffer = buffer[pos + 2..].to_string();
while let Some(event_text) = take_sse_block(&mut buffer) {
for line in event_text.lines() {
if let Some(data) = strip_sse_field(line, "data") {
if data.trim() != "[DONE]" {
+133 -9
View File
@@ -7,11 +7,11 @@ use super::{
handler_context::{RequestContext, StreamingTimeoutConfig},
hyper_client::ProxyResponse,
server::ProxyState,
sse::strip_sse_field,
sse::{strip_sse_field, take_sse_block},
usage::parser::TokenUsage,
ProxyError,
};
use axum::http::header::HeaderMap;
use axum::http::{header::HeaderMap, HeaderName};
use axum::response::{IntoResponse, Response};
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
@@ -68,6 +68,41 @@ fn get_content_encoding(headers: &HeaderMap) -> Option<String> {
.filter(|s| !s.is_empty() && s != "identity")
}
/// RFC 2616 / RFC 7230 中定义的不应被代理继续转发的响应头。
const HOP_BY_HOP_RESPONSE_HEADERS: &[&str] = &[
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"trailers",
"transfer-encoding",
"upgrade",
];
/// 移除响应侧 hop-by-hop 头,以及 `Connection` 中点名的扩展头。
pub(crate) fn strip_hop_by_hop_response_headers(headers: &mut HeaderMap) {
let connection_listed_headers: Vec<HeaderName> = headers
.get_all(axum::http::header::CONNECTION)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.map(str::trim)
.filter(|name| !name.is_empty())
.filter_map(|name| HeaderName::from_bytes(name.as_bytes()).ok())
.collect();
for name in HOP_BY_HOP_RESPONSE_HEADERS {
headers.remove(*name);
}
for name in connection_listed_headers {
headers.remove(name);
}
}
/// 移除在重建响应体后会失真的实体头。
pub(crate) fn strip_entity_headers_for_rebuilt_body(headers: &mut HeaderMap) {
headers.remove(axum::http::header::CONTENT_ENCODING);
@@ -163,10 +198,13 @@ pub async fn handle_streaming(
);
}
let mut response_headers = response.headers().clone();
strip_hop_by_hop_response_headers(&mut response_headers);
let mut builder = axum::response::Response::builder().status(status);
// 复制响应头
for (key, value) in response.headers() {
for (key, value) in &response_headers {
builder = builder.header(key, value);
}
@@ -207,8 +245,9 @@ pub async fn handle_non_streaming(
} else {
Duration::ZERO
};
let (response_headers, status, body_bytes) =
let (mut response_headers, status, body_bytes) =
read_decoded_body(response, ctx.tag, body_timeout).await?;
strip_hop_by_hop_response_headers(&mut response_headers);
log::debug!(
"[{}] 上游响应体内容: {}",
@@ -528,7 +567,7 @@ async fn log_usage_internal(
model
};
let request_id = uuid::Uuid::new_v4().to_string();
let request_id = usage.dedup_request_id();
log::debug!(
"[{app_type}] 记录请求日志: id={request_id}, provider={provider_id}, model={model}, streaming={is_streaming}, status={status_code}, latency_ms={latency_ms}, first_token_ms={first_token_ms:?}, session={}, input={}, output={}, cache_read={}, cache_creation={}",
@@ -623,10 +662,7 @@ pub fn create_logged_passthrough_stream(
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
// 尝试解析并记录完整的 SSE 事件
while let Some(pos) = buffer.find("\n\n") {
let event_text = buffer[..pos].to_string();
buffer = buffer[pos + 2..].to_string();
while let Some(event_text) = take_sse_block(&mut buffer) {
if !event_text.trim().is_empty() {
// 提取 data 部分并尝试解析为 JSON
for line in event_text.lines() {
@@ -687,6 +723,7 @@ mod tests {
use crate::provider::ProviderMeta;
use crate::proxy::failover_switch::FailoverSwitchManager;
use crate::proxy::provider_router::ProviderRouter;
use crate::proxy::providers::gemini_shadow::GeminiShadowStore;
use crate::proxy::types::{ProxyConfig, ProxyStatus};
use rust_decimal::Decimal;
use std::collections::HashMap;
@@ -715,6 +752,90 @@ mod tests {
assert_eq!(super::strip_sse_field("id:1", "data"), None);
}
#[test]
fn test_strip_hop_by_hop_response_headers_removes_standard_headers() {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::CONNECTION,
axum::http::HeaderValue::from_static("keep-alive"),
);
headers.insert(
axum::http::header::HeaderName::from_static("keep-alive"),
axum::http::HeaderValue::from_static("timeout=5"),
);
headers.insert(
axum::http::header::TRANSFER_ENCODING,
axum::http::HeaderValue::from_static("chunked"),
);
headers.insert(
axum::http::header::HeaderName::from_static("proxy-connection"),
axum::http::HeaderValue::from_static("keep-alive"),
);
headers.insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/json"),
);
headers.insert(
axum::http::header::CONTENT_LENGTH,
axum::http::HeaderValue::from_static("12"),
);
strip_hop_by_hop_response_headers(&mut headers);
assert!(!headers.contains_key(axum::http::header::CONNECTION));
assert!(!headers.contains_key("keep-alive"));
assert!(!headers.contains_key(axum::http::header::TRANSFER_ENCODING));
assert!(!headers.contains_key("proxy-connection"));
assert_eq!(
headers.get(axum::http::header::CONTENT_TYPE),
Some(&axum::http::HeaderValue::from_static("application/json"))
);
assert_eq!(
headers.get(axum::http::header::CONTENT_LENGTH),
Some(&axum::http::HeaderValue::from_static("12"))
);
}
#[test]
fn test_strip_hop_by_hop_response_headers_removes_connection_listed_extensions() {
let mut headers = HeaderMap::new();
headers.append(
axum::http::header::CONNECTION,
axum::http::HeaderValue::from_static("x-trace-hop, x-debug-hop"),
);
headers.append(
axum::http::header::CONNECTION,
axum::http::HeaderValue::from_static("upgrade"),
);
headers.insert(
axum::http::header::HeaderName::from_static("x-trace-hop"),
axum::http::HeaderValue::from_static("trace"),
);
headers.insert(
axum::http::header::HeaderName::from_static("x-debug-hop"),
axum::http::HeaderValue::from_static("debug"),
);
headers.insert(
axum::http::header::UPGRADE,
axum::http::HeaderValue::from_static("websocket"),
);
headers.insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("text/event-stream"),
);
strip_hop_by_hop_response_headers(&mut headers);
assert!(!headers.contains_key(axum::http::header::CONNECTION));
assert!(!headers.contains_key("x-trace-hop"));
assert!(!headers.contains_key("x-debug-hop"));
assert!(!headers.contains_key(axum::http::header::UPGRADE));
assert_eq!(
headers.get(axum::http::header::CONTENT_TYPE),
Some(&axum::http::HeaderValue::from_static("text/event-stream"))
);
}
fn build_state(db: Arc<Database>) -> ProxyState {
ProxyState {
db: db.clone(),
@@ -723,6 +844,7 @@ mod tests {
start_time: Arc::new(RwLock::new(None)),
current_providers: Arc::new(RwLock::new(HashMap::new())),
provider_router: Arc::new(ProviderRouter::new(db.clone())),
gemini_shadow: Arc::new(GeminiShadowStore::default()),
app_handle: None,
failover_manager: Arc::new(FailoverSwitchManager::new(db)),
}
@@ -784,6 +906,7 @@ mod tests {
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
message_id: None,
};
log_usage_internal(
@@ -843,6 +966,7 @@ mod tests {
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
message_id: None,
};
log_usage_internal(
+5 -8
View File
@@ -10,7 +10,8 @@
use super::{
failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv,
provider_router::ProviderRouter, types::*, ProxyError,
provider_router::ProviderRouter, providers::gemini_shadow::GeminiShadowStore, types::*,
ProxyError,
};
use crate::database::Database;
use axum::{
@@ -23,7 +24,6 @@ use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::{oneshot, RwLock};
use tokio::task::JoinHandle;
use tower_http::cors::{Any, CorsLayer};
/// 代理服务器状态(共享)
#[derive(Clone)]
@@ -36,6 +36,8 @@ pub struct ProxyState {
pub current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
/// 共享的 ProviderRouter(持有熔断器状态,跨请求保持)
pub provider_router: Arc<ProviderRouter>,
/// Gemini Native shadow state,用于 thoughtSignature / tool call 回放
pub gemini_shadow: Arc<GeminiShadowStore>,
/// AppHandle,用于发射事件和更新托盘菜单
pub app_handle: Option<tauri::AppHandle>,
/// 故障转移切换管理器
@@ -69,6 +71,7 @@ impl ProxyServer {
start_time: Arc::new(RwLock::new(None)),
current_providers: Arc::new(RwLock::new(std::collections::HashMap::new())),
provider_router,
gemini_shadow: Arc::new(GeminiShadowStore::default()),
app_handle,
failover_manager,
};
@@ -275,11 +278,6 @@ impl ProxyServer {
}
fn build_router(&self) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
Router::new()
// 健康检查
.route("/health", get(handlers::health_check))
@@ -328,7 +326,6 @@ impl ProxyServer {
.route("/gemini/v1beta/*path", post(handlers::handle_gemini))
// 提高默认请求体大小限制(避免 413 Payload Too Large
.layer(DefaultBodyLimit::max(200 * 1024 * 1024))
.layer(cors)
.with_state(self.state.clone())
}
+70 -1
View File
@@ -242,6 +242,12 @@ pub fn extract_session_id(
body: &serde_json::Value,
client_format: &str,
) -> SessionIdResult {
if client_format == "claude" {
if let Some(result) = extract_claude_session(headers, body) {
return result;
}
}
// Codex 请求特殊处理
if client_format == "codex" || client_format == "openai" {
if let Some(result) = extract_codex_session(headers, body) {
@@ -258,6 +264,28 @@ pub fn extract_session_id(
generate_new_session_id()
}
/// 提取 Claude Session ID
fn extract_claude_session(
headers: &HeaderMap,
body: &serde_json::Value,
) -> Option<SessionIdResult> {
for header_name in &["x-claude-code-session-id", "claude-code-session-id"] {
if let Some(value) = headers.get(*header_name) {
if let Ok(session_id) = value.to_str() {
if !session_id.is_empty() {
return Some(SessionIdResult {
session_id: session_id.to_string(),
source: SessionIdSource::Header,
client_provided: true,
});
}
}
}
}
extract_from_metadata(body)
}
/// 提取 Codex Session ID
fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option<SessionIdResult> {
// 1. 从 headers 提取
@@ -337,7 +365,7 @@ fn extract_from_metadata(body: &serde_json::Value) -> Option<SessionIdResult> {
/// 从 user_id 解析 session_id
///
/// 格式: `user_identifier_session_actual_session_id`
fn parse_session_from_user_id(user_id: &str) -> Option<String> {
pub(super) fn parse_session_from_user_id(user_id: &str) -> Option<String> {
// 查找 "_session_" 分隔符
if let Some(pos) = user_id.find("_session_") {
let session_id = &user_id[pos + 9..]; // "_session_" 长度为 9
@@ -515,6 +543,47 @@ mod tests {
assert!(result.client_provided);
}
#[test]
fn test_extract_session_from_claude_header() {
let mut headers = HeaderMap::new();
headers.insert(
"x-claude-code-session-id",
"d937243f-2702-4f20-97b6-c9682235ab81".parse().unwrap(),
);
let body = json!({
"model": "claude-3-5-sonnet",
"messages": [{"role": "user", "content": "Hello"}]
});
let result = extract_session_id(&headers, &body, "claude");
assert_eq!(result.session_id, "d937243f-2702-4f20-97b6-c9682235ab81");
assert_eq!(result.source, SessionIdSource::Header);
assert!(result.client_provided);
}
#[test]
fn test_extract_session_from_claude_header_precedes_metadata() {
let mut headers = HeaderMap::new();
headers.insert(
"x-claude-code-session-id",
"header-session-123".parse().unwrap(),
);
let body = json!({
"model": "claude-3-5-sonnet",
"messages": [{"role": "user", "content": "Hello"}],
"metadata": {
"session_id": "my-session-123"
}
});
let result = extract_session_id(&headers, &body, "claude");
assert_eq!(result.session_id, "header-session-123");
assert_eq!(result.source, SessionIdSource::Header);
assert!(result.client_provided);
}
#[test]
fn test_extract_session_from_codex_previous_response_id() {
let headers = HeaderMap::new();
+41 -1
View File
@@ -4,6 +4,24 @@ pub(crate) fn strip_sse_field<'a>(line: &'a str, field: &str) -> Option<&'a str>
.or_else(|| line.strip_prefix(&format!("{field}:")))
}
#[inline]
pub(crate) fn take_sse_block(buffer: &mut String) -> Option<String> {
let mut best: Option<(usize, usize)> = None;
for (delimiter, len) in [("\r\n\r\n", 4usize), ("\n\n", 2usize)] {
if let Some(pos) = buffer.find(delimiter) {
if best.is_none_or(|(best_pos, _)| pos < best_pos) {
best = Some((pos, len));
}
}
}
let (pos, len) = best?;
let block = buffer[..pos].to_string();
buffer.drain(..pos + len);
Some(block)
}
/// Append raw bytes to a UTF-8 `String` buffer, correctly handling multi-byte
/// characters that are split across chunk boundaries.
///
@@ -68,7 +86,7 @@ pub(crate) fn append_utf8_safe(buffer: &mut String, remainder: &mut Vec<u8>, new
#[cfg(test)]
mod tests {
use super::{append_utf8_safe, strip_sse_field};
use super::{append_utf8_safe, strip_sse_field, take_sse_block};
#[test]
fn strip_sse_field_accepts_optional_space() {
@@ -91,6 +109,28 @@ mod tests {
assert_eq!(strip_sse_field("id:1", "data"), None);
}
#[test]
fn take_sse_block_supports_lf_delimiters() {
let mut buffer = "data: {\"ok\":true}\n\nrest".to_string();
assert_eq!(
take_sse_block(&mut buffer),
Some("data: {\"ok\":true}".to_string())
);
assert_eq!(buffer, "rest");
}
#[test]
fn take_sse_block_supports_crlf_delimiters() {
let mut buffer = "data: {\"ok\":true}\r\n\r\nrest".to_string();
assert_eq!(
take_sse_block(&mut buffer),
Some("data: {\"ok\":true}".to_string())
);
assert_eq!(buffer, "rest");
}
// ------------------------------------------------------------------
// append_utf8_safe tests
// ------------------------------------------------------------------
+27
View File
@@ -52,6 +52,14 @@ pub fn should_rectify_thinking_signature(
return true;
}
// 场景1b: Gemini/第三方渠道返回 "Thought signature is not valid"
// 错误示例: "Unable to submit request because Thought signature is not valid"
if lower.contains("thought signature")
&& (lower.contains("not valid") || lower.contains("invalid"))
{
return true;
}
// 场景2: assistant 消息必须以 thinking block 开头
// 错误示例: "must start with a thinking block"
if lower.contains("must start with a thinking block") {
@@ -280,6 +288,16 @@ mod tests {
));
}
#[test]
fn test_detect_invalid_thought_signature_message() {
assert!(should_rectify_thinking_signature(
Some(
"Unable to submit request because Thought signature is not valid.. Learn more: https://example.com/help"
),
&enabled_config()
));
}
#[test]
fn test_detect_invalid_signature_nested_json() {
// 测试嵌套 JSON 格式的错误消息(第三方渠道常见格式)
@@ -290,6 +308,15 @@ mod tests {
));
}
#[test]
fn test_detect_invalid_thought_signature_nested_json() {
let nested_error = r#"{"error":{"message":"Unable to submit request because Thought signature is not valid.. Learn more: https://example.com/help","type":"upstream_error","param":"","code":400}}"#;
assert!(should_rectify_thinking_signature(
Some(nested_error),
&enabled_config()
));
}
#[test]
fn test_detect_thinking_expected() {
assert!(should_rectify_thinking_signature(
+9 -4
View File
@@ -291,8 +291,12 @@ pub struct CopilotOptimizerConfig {
/// 确定性 Request ID(默认开启,P3 优先级)
#[serde(default = "default_true")]
pub deterministic_request_id: bool,
/// Warmup 小模型降级(默认关闭,P4 优先级,opt-in)
#[serde(default)]
/// Subagent 检测(默认开启)— 识别 Claude Code 子代理请求,
/// 设置 x-initiator=agent + x-interaction-type=conversation-subagent,避免子代理计费
#[serde(default = "default_true")]
pub subagent_detection: bool,
/// Warmup 小模型降级(默认开启 — 与参考实现对齐,避免探针请求消耗 premium quota
#[serde(default = "default_true")]
pub warmup_downgrade: bool,
/// Warmup 降级使用的模型(默认 "gpt-4o-mini"
#[serde(default = "default_warmup_model")]
@@ -300,7 +304,7 @@ pub struct CopilotOptimizerConfig {
}
fn default_warmup_model() -> String {
"gpt-4o-mini".to_string()
"gpt-5-mini".to_string()
}
impl Default for CopilotOptimizerConfig {
@@ -311,7 +315,8 @@ impl Default for CopilotOptimizerConfig {
tool_result_merging: true,
compact_detection: true,
deterministic_request_id: true,
warmup_downgrade: false,
subagent_detection: true,
warmup_downgrade: true,
warmup_model: "gpt-4o-mini".to_string(),
}
}
+4
View File
@@ -114,6 +114,7 @@ mod tests {
cache_read_tokens: 200,
cache_creation_tokens: 100,
model: None,
message_id: None,
};
let pricing = ModelPricing::from_strings("3.0", "15.0", "0.3", "3.75").unwrap();
@@ -144,6 +145,7 @@ mod tests {
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
message_id: None,
};
let pricing = ModelPricing::from_strings("3.0", "15.0", "0", "0").unwrap();
@@ -165,6 +167,7 @@ mod tests {
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
message_id: None,
};
let multiplier = Decimal::from_str("1.0").unwrap();
@@ -181,6 +184,7 @@ mod tests {
cache_read_tokens: 1,
cache_creation_tokens: 1,
model: None,
message_id: None,
};
let pricing = ModelPricing::from_strings("0.075", "0.3", "0.01875", "0.075").unwrap();
+2 -1
View File
@@ -73,7 +73,7 @@ impl<'a> UsageLogger<'a> {
});
conn.execute(
"INSERT INTO proxy_request_logs (
"INSERT OR REPLACE INTO proxy_request_logs (
request_id, provider_id, app_type, model, request_model,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
@@ -358,6 +358,7 @@ mod tests {
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
message_id: None,
};
logger.log_with_calculation(
+39 -3
View File
@@ -9,6 +9,9 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Session 日志 request_id 前缀,与 `session_usage.rs` 中的格式保持一致
pub const SESSION_REQUEST_ID_PREFIX: &str = "session:";
/// Token 使用量统计
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TokenUsage {
@@ -18,6 +21,22 @@ pub struct TokenUsage {
pub cache_creation_tokens: u32,
/// 从响应中提取的实际模型名称(如果可用)
pub model: Option<String>,
/// 从响应中提取的消息 ID(用于跨源去重)
///
/// Claude API: `msg_xxx`,与 session JSONL 中的 `message.id` 一致
#[serde(skip)]
pub message_id: Option<String>,
}
impl TokenUsage {
/// 生成与 session 日志共享的 request_id,用于跨源去重。
/// 有 message_id 时返回 `session:{id}`,否则回退到随机 UUID。
pub fn dedup_request_id(&self) -> String {
self.message_id
.as_ref()
.map(|mid| format!("{SESSION_REQUEST_ID_PREFIX}{mid}"))
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
}
}
/// API 类型
@@ -39,6 +58,10 @@ impl TokenUsage {
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let message_id = body
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(Self {
input_tokens: usage.get("input_tokens")?.as_u64()? as u32,
@@ -52,6 +75,7 @@ impl TokenUsage {
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
model,
message_id,
})
}
@@ -60,18 +84,23 @@ impl TokenUsage {
pub fn from_claude_stream_events(events: &[Value]) -> Option<Self> {
let mut usage = Self::default();
let mut model: Option<String> = None;
let mut message_id: Option<String> = None;
for event in events {
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
match event_type {
"message_start" => {
// 从 message_start 提取模型名称
if model.is_none() {
if let Some(message) = event.get("message") {
if let Some(message) = event.get("message") {
if model.is_none() {
if let Some(m) = message.get("model").and_then(|v| v.as_str()) {
model = Some(m.to_string());
}
}
if message_id.is_none() {
if let Some(id) = message.get("id").and_then(|v| v.as_str()) {
message_id = Some(id.to_string());
}
}
}
if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) {
// 从 message_start 获取 input_tokens(原生 Claude API
@@ -137,6 +166,7 @@ impl TokenUsage {
if usage.input_tokens > 0 || usage.output_tokens > 0 {
usage.model = model;
usage.message_id = message_id;
Some(usage)
} else {
None
@@ -153,6 +183,7 @@ impl TokenUsage {
cache_read_tokens: 0,
cache_creation_tokens: 0,
model: None,
message_id: None,
})
}
@@ -202,6 +233,7 @@ impl TokenUsage {
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
model,
message_id: None,
})
}
@@ -245,6 +277,7 @@ impl TokenUsage {
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
model,
message_id: None,
})
}
@@ -339,6 +372,7 @@ impl TokenUsage {
cache_read_tokens: cached_tokens,
cache_creation_tokens: 0,
model,
message_id: None,
})
}
@@ -383,6 +417,7 @@ impl TokenUsage {
.unwrap_or(0) as u32,
cache_creation_tokens: 0,
model,
message_id: None,
})
}
@@ -433,6 +468,7 @@ impl TokenUsage {
cache_read_tokens: total_cache_read,
cache_creation_tokens: 0,
model,
message_id: None,
})
} else {
None
+1 -1
View File
@@ -41,7 +41,7 @@ pub async fn fetch_models(
}
let models_url = build_models_url(base_url, is_full_url)?;
let client = crate::proxy::http_client::get_for_provider(None);
let client = crate::proxy::http_client::get();
let response = client
.get(&models_url)
+1 -1
View File
@@ -27,7 +27,7 @@ pub fn get_custom_endpoints(
}
let mut result: Vec<_> = meta.custom_endpoints.values().cloned().collect();
result.sort_by(|a, b| b.added_at.cmp(&a.added_at));
result.sort_by_key(|ep| std::cmp::Reverse(ep.added_at));
Ok(result)
}
+5 -10
View File
@@ -1099,7 +1099,7 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
// One-time auth type detection to avoid repeated detection
let auth_type = detect_gemini_auth_type(provider);
let mut env_map = json_to_env(&provider.settings_config)?;
let env_map = json_to_env(&provider.settings_config)?;
// Prepare config to write to ~/.gemini/settings.json
// Behavior:
@@ -1143,17 +1143,12 @@ pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
match auth_type {
GeminiAuthType::GoogleOfficial => {
// Google official uses OAuth, clear env
env_map.clear();
// Google Official uses OAuth, no API key validation needed.
// Write user's env vars as-is (e.g. GEMINI_MODEL, custom vars).
write_gemini_env_atomic(&env_map)?;
}
GeminiAuthType::Packycode => {
// PackyCode provider, uses API Key (strict validation on switch)
validate_gemini_settings_strict(&provider.settings_config)?;
write_gemini_env_atomic(&env_map)?;
}
GeminiAuthType::Generic => {
// Generic provider, uses API Key (strict validation on switch)
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
// API Key mode -- require GEMINI_API_KEY
validate_gemini_settings_strict(&provider.settings_config)?;
write_gemini_env_atomic(&env_map)?;
}
+10
View File
@@ -1407,6 +1407,16 @@ impl ProviderService {
// Hot-switch only when BOTH: this app is taken over AND proxy server is actually running
let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running;
// Block switching to official providers when proxy takeover is active.
// Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans.
if should_hot_switch && _provider.category.as_deref() == Some("official") {
return Err(AppError::localized(
"switch.official_blocked_by_proxy",
"代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁。请先关闭代理接管,或选择第三方供应商。",
"Cannot switch to official provider while proxy takeover is active. Using proxy with official APIs may cause account bans.",
));
}
if should_hot_switch {
// Proxy takeover mode: hot-switch only, don't write Live config
log::info!(
+29
View File
@@ -15,6 +15,7 @@ use crate::services::provider::{
use serde_json::{json, Value};
use std::str::FromStr;
use std::sync::Arc;
use tauri::Emitter;
use tokio::sync::RwLock;
/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)
@@ -375,6 +376,26 @@ impl ProxyService {
// 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)
let _ = self.db.set_live_takeover_active(true).await;
// 8) Warn if the current provider is official (risk of account ban via proxy)
if let Ok(Some(current_id)) =
crate::settings::get_effective_current_provider(&self.db, &app)
{
if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {
if provider.category.as_deref() == Some("official") {
if let Some(handle) = self.app_handle.read().await.as_ref() {
let _ = handle.emit(
"proxy-official-warning",
serde_json::json!({
"appType": app_type_str,
"providerName": provider.name,
}),
);
}
}
}
}
return Ok(());
}
@@ -1548,6 +1569,14 @@ impl ProxyService {
.map_err(|e| format!("读取供应商失败: {e}"))?
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
// Defense-in-depth: block official providers during proxy takeover
if provider.category.as_deref() == Some("official") {
return Err(
"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)"
.to_string(),
);
}
let logical_target_changed =
crate::settings::get_effective_current_provider(&self.db, &app_type_enum)
.map_err(|e| format!("读取当前供应商失败: {e}"))?
+6 -1
View File
@@ -278,7 +278,11 @@ fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppEr
continue;
}
let request_id = format!("session:{}", msg.message_id);
let request_id = format!(
"{}{}",
crate::proxy::usage::parser::SESSION_REQUEST_ID_PREFIX,
msg.message_id
);
// 跳过 output_tokens 为 0 的无意义条目
if msg.output_tokens == 0 {
@@ -379,6 +383,7 @@ fn insert_session_log_entry(
cache_read_tokens: msg.cache_read_tokens,
cache_creation_tokens: msg.cache_creation_tokens,
model: Some(msg.model.clone()),
message_id: None,
};
let pricing = find_model_pricing_for_session(&conn, &msg.model);
+11 -12
View File
@@ -297,18 +297,16 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32),
};
match event_type {
"session_meta" => {
if state.session_id.is_none() {
let payload = value.get("payload");
state.session_id = payload
.and_then(|p| {
p.get("session_id")
.or_else(|| p.get("sessionId"))
.or_else(|| p.get("id"))
})
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
"session_meta" if state.session_id.is_none() => {
let payload = value.get("payload");
state.session_id = payload
.and_then(|p| {
p.get("session_id")
.or_else(|| p.get("sessionId"))
.or_else(|| p.get("id"))
})
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
"turn_context" => {
if let Some(payload) = value.get("payload") {
@@ -474,6 +472,7 @@ fn insert_codex_session_entry(
cache_read_tokens: delta.cached_input,
cache_creation_tokens: 0,
model: Some(model.to_string()),
message_id: None,
};
let pricing = find_codex_pricing(&conn, model);
@@ -261,6 +261,7 @@ fn insert_gemini_session_entry(
cache_read_tokens: tokens.cached,
cache_creation_tokens: 0,
model: Some(model.to_string()),
message_id: None,
};
let pricing = find_gemini_pricing(&conn, model);
+11 -4
View File
@@ -680,6 +680,13 @@ impl SkillService {
found.display()
);
source = found;
} else if temp_dir.join("SKILL.md").exists() {
// 根级 Skill:仓库本身就是 skillSKILL.md 直接在解压根目录
log::info!(
"Skill directory '{}' not found, but SKILL.md exists at root, using temp_dir",
target_name,
);
source = temp_dir.clone();
} else {
let _ = fs::remove_dir_all(&temp_dir);
return Err(anyhow!(format_skill_error(
@@ -1261,7 +1268,7 @@ impl SkillService {
}
}
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
entries.sort_by_key(|entry| std::cmp::Reverse(entry.created_at));
Ok(entries)
}
@@ -1765,7 +1772,7 @@ impl SkillService {
let results: Vec<Result<Vec<DiscoverableSkill>>> =
futures::future::join_all(fetch_tasks).await;
for (repo, result) in enabled_repos.into_iter().zip(results.into_iter()) {
for (repo, result) in enabled_repos.into_iter().zip(results) {
match result {
Ok(repo_skills) => skills.extend(repo_skills),
Err(e) => log::warn!("获取仓库 {}/{} 技能失败: {}", repo.owner, repo.name, e),
@@ -1774,7 +1781,7 @@ impl SkillService {
// 去重并排序
Self::deduplicate_discoverable_skills(&mut skills);
skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
skills.sort_by_key(|skill| skill.name.to_lowercase());
Ok(skills)
}
@@ -1841,7 +1848,7 @@ impl SkillService {
}
}
skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
skills.sort_by_key(|skill| skill.name.to_lowercase());
Ok(skills)
}
+282 -56
View File
@@ -12,8 +12,10 @@ use std::time::Instant;
use crate::app_config::AppType;
use crate::error::AppError;
use crate::provider::Provider;
use crate::proxy::gemini_url::{normalize_gemini_model_id, resolve_gemini_native_url};
use crate::proxy::providers::copilot_auth;
use crate::proxy::providers::transform::anthropic_to_openai;
use crate::proxy::providers::transform_gemini::anthropic_to_gemini;
use crate::proxy::providers::transform_responses::anthropic_to_responses;
use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy};
@@ -55,8 +57,8 @@ impl Default for StreamCheckConfig {
max_retries: 2,
degraded_threshold_ms: 6000,
claude_model: "claude-haiku-4-5-20251001".to_string(),
codex_model: "gpt-5.1-codex@low".to_string(),
gemini_model: "gemini-3-pro-preview".to_string(),
codex_model: "gpt-5.4@low".to_string(),
gemini_model: "gemini-3-flash-preview".to_string(),
test_prompt: default_test_prompt(),
}
}
@@ -74,6 +76,9 @@ pub struct StreamCheckResult {
pub model_used: String,
pub tested_at: i64,
pub retry_count: u32,
/// 细粒度错误分类(如 "modelNotFound"),前端据此渲染专门的文案
#[serde(skip_serializing_if = "Option::is_none")]
pub error_category: Option<String>,
}
/// 流式健康检查服务
@@ -143,6 +148,7 @@ impl StreamCheckService {
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
retry_count: effective_config.max_retries,
error_category: None,
}))
}
@@ -218,9 +224,8 @@ impl StreamCheckService {
.or_else(|| adapter.extract_auth(provider))
.ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
let client = crate::proxy::http_client::get_for_provider(proxy_config);
// 获取 HTTP 客户端
let client = crate::proxy::http_client::get();
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
let model_to_test = Self::resolve_test_model(app_type, provider, config);
@@ -272,34 +277,12 @@ impl StreamCheckService {
};
let response_time = start.elapsed().as_millis() as u64;
let tested_at = chrono::Utc::now().timestamp();
match result {
Ok((status_code, model)) => {
let health_status =
Self::determine_status(response_time, config.degraded_threshold_ms);
Ok(StreamCheckResult {
status: health_status,
success: true,
message: "Check succeeded".to_string(),
response_time_ms: Some(response_time),
http_status: Some(status_code),
model_used: model,
tested_at,
retry_count: 0,
})
}
Err(e) => Ok(StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message: e.to_string(),
response_time_ms: Some(response_time),
http_status: None,
model_used: String::new(),
tested_at,
retry_count: 0,
}),
}
Ok(Self::build_stream_check_result(
result,
response_time,
config.degraded_threshold_ms,
&model_to_test,
))
}
/// Claude 流式检查
@@ -307,6 +290,8 @@ impl StreamCheckService {
/// 根据供应商的 api_format 选择请求格式:
/// - "anthropic" (默认): Anthropic Messages API (/v1/messages)
/// - "openai_chat": OpenAI Chat Completions API (/v1/chat/completions)
/// - "openai_responses": OpenAI Responses API (/v1/responses)
/// - "gemini_native": Gemini Native streamGenerateContent
///
/// `extra_headers` 是一个可选的供应商级自定义 header 集合(从 OpenClaw
/// 的 `settings_config.headers` 或 OpenCode 的 `settings_config.options.headers`
@@ -348,8 +333,14 @@ impl StreamCheckService {
.unwrap_or(false);
let is_openai_chat = effective_api_format == "openai_chat";
let is_openai_responses = effective_api_format == "openai_responses";
let url =
Self::resolve_claude_stream_url(base, auth.strategy, effective_api_format, is_full_url);
let is_gemini_native = effective_api_format == "gemini_native";
let url = Self::resolve_claude_stream_url(
base,
auth.strategy,
effective_api_format,
is_full_url,
model,
);
let max_tokens = if is_openai_responses { 16 } else { 1 };
@@ -371,8 +362,11 @@ impl StreamCheckService {
let body = if is_openai_responses {
anthropic_to_responses(anthropic_body, Some(&provider.id), is_codex_oauth)
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
} else if is_gemini_native {
anthropic_to_gemini(anthropic_body)
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
} else if is_openai_chat {
anthropic_to_openai(anthropic_body, Some(&provider.id))
anthropic_to_openai(anthropic_body)
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
} else {
anthropic_body
@@ -406,6 +400,23 @@ impl StreamCheckService {
.header("x-vscode-user-agent-library-version", "electron-fetch")
.header("x-request-id", &request_id)
.header("x-agent-task-id", &request_id);
} else if is_gemini_native {
request_builder = match auth.strategy {
AuthStrategy::GoogleOAuth => {
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
request_builder
.header("authorization", format!("Bearer {token}"))
.header("x-goog-api-client", "GeminiCLI/1.0")
.header("content-type", "application/json")
.header("accept", "text/event-stream")
.header("accept-encoding", "identity")
}
_ => request_builder
.header("x-goog-api-key", &auth.api_key)
.header("content-type", "application/json")
.header("accept", "text/event-stream")
.header("accept-encoding", "identity"),
};
} else if is_openai_chat || is_openai_responses {
// OpenAI-compatible targets: Bearer auth + SSE headers only
request_builder = request_builder
@@ -452,8 +463,7 @@ impl StreamCheckService {
.header("x-stainless-retry-count", "0")
.header("x-stainless-timeout", "600")
// Other headers
.header("sec-fetch-mode", "cors")
.header("connection", "keep-alive");
.header("sec-fetch-mode", "cors");
}
// 供应商自定义 headers 最后追加,允许覆盖内置默认值(例如 user-agent
@@ -476,7 +486,7 @@ impl StreamCheckService {
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
return Err(Self::http_status_error(status, error_text));
}
// 流式读取:只需首个 chunk
@@ -556,7 +566,7 @@ impl StreamCheckService {
if i == 0 && status == 404 && urls.len() > 1 {
continue;
}
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
return Err(Self::http_status_error(status, error_text));
}
let mut stream = response.bytes_stream();
@@ -588,13 +598,16 @@ impl StreamCheckService {
extra_headers: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/');
// Strip `models/` resource-name prefix from the model id — see
// `normalize_gemini_model_id` for rationale.
let normalized_model = normalize_gemini_model_id(model);
// Gemini 原生 API: /v1beta/models/{model}:streamGenerateContent?alt=sse
// 智能处理 /v1beta 路径:如果 base_url 不包含版本路径,则添加 /v1beta
// alt=sse 参数使 API 返回 SSE 格式(text/event-stream)而非 JSON 数组
let url = if base.contains("/v1beta") || base.contains("/v1/") {
format!("{base}/models/{model}:streamGenerateContent?alt=sse")
format!("{base}/models/{normalized_model}:streamGenerateContent?alt=sse")
} else {
format!("{base}/v1beta/models/{model}:streamGenerateContent?alt=sse")
format!("{base}/v1beta/models/{normalized_model}:streamGenerateContent?alt=sse")
};
// Gemini 原生请求体格式
@@ -631,7 +644,7 @@ impl StreamCheckService {
if !response.status().is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(AppError::Message(format!("HTTP {status}: {error_text}")));
return Err(Self::http_status_error(status, error_text));
}
let mut stream = response.bytes_stream();
@@ -659,9 +672,8 @@ impl StreamCheckService {
config: &StreamCheckConfig,
start: Instant,
) -> Result<StreamCheckResult, AppError> {
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
let client = crate::proxy::http_client::get_for_provider(proxy_config);
// 获取 HTTP 客户端
let client = crate::proxy::http_client::get();
let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
let model_to_test = Self::resolve_test_model(app_type, provider, config);
@@ -696,16 +708,21 @@ impl StreamCheckService {
result,
response_time,
config.degraded_threshold_ms,
&model_to_test,
))
}
/// 将 check_*_stream 的原始结果包装成 StreamCheckResult
///
/// 抽取自 check_once 的末尾逻辑,以便 OpenCode/OpenClaw 的独立分支复用。
///
/// `model_tested` 是本次探测使用的模型名,用于在失败场景下仍能把模型信息透传给前端,
/// 方便针对"模型不存在 / 已下架"这类错误渲染专门的提示。
fn build_stream_check_result(
result: Result<(u16, String), AppError>,
response_time: u64,
degraded_threshold_ms: u64,
model_tested: &str,
) -> StreamCheckResult {
let tested_at = chrono::Utc::now().timestamp();
match result {
@@ -718,20 +735,67 @@ impl StreamCheckService {
model_used: model,
tested_at,
retry_count: 0,
error_category: None,
},
Err(e) => StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message: e.to_string(),
response_time_ms: Some(response_time),
http_status: None,
model_used: String::new(),
tested_at,
retry_count: 0,
},
Err(e) => {
let (http_status, message, error_category) = match &e {
AppError::HttpStatus { status, body } => {
let category = Self::detect_error_category(*status, body);
(
Some(*status),
Self::classify_http_status(*status).to_string(),
category.map(|s| s.to_string()),
)
}
_ => (None, e.to_string(), None),
};
StreamCheckResult {
status: HealthStatus::Failed,
success: false,
message,
response_time_ms: Some(response_time),
http_status,
model_used: model_tested.to_string(),
tested_at,
retry_count: 0,
error_category,
}
}
}
}
/// 基于 HTTP 状态码和响应体识别细粒度错误分类。
///
/// 目前仅识别"模型不存在 / 已下架":各厂商该类错误通常返回 4xx,body 中会包含
/// 如 `model_not_found`OpenAI)、`does not exist`、`invalid model`、`not_found_error`
/// + `model` 字样(Anthropic)等标记。
pub(crate) fn detect_error_category(status: u16, body: &str) -> Option<&'static str> {
// 只检查 4xx;5xx 的错误信息里可能巧合出现"model"之类的词,容易误判
if !(400..500).contains(&status) {
return None;
}
let lower = body.to_lowercase();
// 必须提到 "model",避免通用 404 / 400 被误判
if !lower.contains("model") {
return None;
}
let indicators = [
"model_not_found",
"model not found",
"does not exist",
"invalid_model",
"invalid model",
"unknown_model",
"unknown model",
"is not a valid model",
"not_found_error", // Anthropic 的 type 字段
];
if indicators.iter().any(|s| lower.contains(s)) {
return Some("modelNotFound");
}
None
}
/// OpenClaw 流式检查分发器
///
/// 根据 `settings_config.api` 字段分发到对应协议的检查器。
@@ -1126,6 +1190,39 @@ impl StreamCheckService {
}
}
/// 构造 HTTP 状态码错误,截断过长的响应体
fn http_status_error(status: u16, body: String) -> AppError {
let body = if body.len() > 200 {
// 安全截断:找到 200 字节内最近的 char 边界
let mut end = 200;
while end > 0 && !body.is_char_boundary(end) {
end -= 1;
}
format!("{}", &body[..end])
} else {
body
};
AppError::HttpStatus { status, body }
}
/// 将 HTTP 状态码映射为简短的分类标签
pub(crate) fn classify_http_status(status: u16) -> &'static str {
match status {
400 => "Bad request (400)",
401 => "Auth rejected (401)",
402 => "Payment required (402)",
403 => "Access denied (403)",
404 => "Not found (404)",
429 => "Rate limited (429)",
500 => "Internal server error (500)",
502 => "Bad gateway (502)",
503 => "Service unavailable (503)",
504 => "Gateway timeout (504)",
s if (500..600).contains(&s) => "Server error",
_ => "HTTP error",
}
}
fn resolve_test_model(
app_type: &AppType,
provider: &Provider,
@@ -1228,7 +1325,19 @@ impl StreamCheckService {
auth_strategy: AuthStrategy,
api_format: &str,
is_full_url: bool,
model: &str,
) -> String {
if api_format == "gemini_native" {
// Strip an optional `models/` resource-name prefix so that model
// identifiers copied from Gemini SDK outputs (e.g.
// `models/gemini-2.5-pro`) don't produce a doubled
// `/v1beta/models/models/...` URL.
let normalized_model = normalize_gemini_model_id(model);
let endpoint =
format!("/v1beta/models/{normalized_model}:streamGenerateContent?alt=sse");
return resolve_gemini_native_url(base_url, &endpoint, is_full_url);
}
if is_full_url {
return base_url.to_string();
}
@@ -1458,6 +1567,51 @@ mod tests {
assert_eq!(effort, None);
}
#[test]
fn test_detect_model_not_found() {
// OpenAI 典型响应:404 + model_not_found 错误码
let openai_404 = r#"{"error":{"message":"The model `gpt-5.1-codex` does not exist or you do not have access to it","type":"invalid_request_error","param":null,"code":"model_not_found"}}"#;
assert_eq!(
StreamCheckService::detect_error_category(404, openai_404),
Some("modelNotFound")
);
// Anthropic 典型响应:404 + not_found_error + 提到 model
let anthropic_404 = r#"{"type":"error","error":{"type":"not_found_error","message":"model: claude-deprecated"}}"#;
assert_eq!(
StreamCheckService::detect_error_category(404, anthropic_404),
Some("modelNotFound")
);
// 400 + invalid model 也算
let bad_req = r#"{"error":{"message":"invalid model specified"}}"#;
assert_eq!(
StreamCheckService::detect_error_category(400, bad_req),
Some("modelNotFound")
);
// 通用 404(比如 Base URL 错误),body 里没有 model 字样 → 不应误判
let generic_404 = r#"{"error":"Not Found"}"#;
assert_eq!(
StreamCheckService::detect_error_category(404, generic_404),
None
);
// 5xx 就算 body 里有 "model does not exist" 也不分类(避免误判)
let server_error = r#"{"error":"model does not exist"}"#;
assert_eq!(
StreamCheckService::detect_error_category(500, server_error),
None
);
// 401 鉴权错误(body 里没有 model 字样)
let auth_err = r#"{"error":"Invalid API key"}"#;
assert_eq!(
StreamCheckService::detect_error_category(401, auth_err),
None
);
}
#[test]
fn test_get_os_name() {
let os_name = StreamCheckService::get_os_name();
@@ -1512,6 +1666,7 @@ mod tests {
AuthStrategy::Bearer,
"openai_chat",
true,
"gpt-5.4",
);
assert_eq!(url, "https://relay.example/v1/chat/completions");
@@ -1524,6 +1679,7 @@ mod tests {
AuthStrategy::GitHubCopilot,
"openai_chat",
false,
"gpt-5.4",
);
assert_eq!(url, "https://api.githubcopilot.com/chat/completions");
@@ -1536,6 +1692,7 @@ mod tests {
AuthStrategy::GitHubCopilot,
"openai_responses",
false,
"gpt-5.4",
);
assert_eq!(url, "https://api.githubcopilot.com/v1/responses");
@@ -1548,6 +1705,7 @@ mod tests {
AuthStrategy::Bearer,
"openai_chat",
false,
"gpt-5.4",
);
assert_eq!(url, "https://example.com/v1/chat/completions");
@@ -1560,6 +1718,7 @@ mod tests {
AuthStrategy::Bearer,
"openai_responses",
false,
"gpt-5.4",
);
assert_eq!(url, "https://example.com/v1/responses");
@@ -1572,11 +1731,78 @@ mod tests {
AuthStrategy::Anthropic,
"anthropic",
false,
"claude-sonnet-4-6",
);
assert_eq!(url, "https://api.anthropic.com/v1/messages");
}
#[test]
fn test_resolve_claude_stream_url_for_gemini_native() {
let url = StreamCheckService::resolve_claude_stream_url(
"https://generativelanguage.googleapis.com",
AuthStrategy::Google,
"gemini_native",
false,
"gemini-2.5-flash",
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
);
}
#[test]
fn test_resolve_claude_stream_url_for_gemini_native_full_url_openai_compat_base() {
let url = StreamCheckService::resolve_claude_stream_url(
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
AuthStrategy::Google,
"gemini_native",
true,
"gemini-2.5-flash",
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
);
}
#[test]
fn test_resolve_claude_stream_url_for_gemini_native_opaque_full_url() {
let url = StreamCheckService::resolve_claude_stream_url(
"https://relay.example/custom/generate-content",
AuthStrategy::Google,
"gemini_native",
true,
"gemini-2.5-flash",
);
assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse");
}
/// Regression: Gemini SDK outputs commonly surface model ids as the
/// resource-name form `models/gemini-2.5-pro`. Interpolating that raw
/// value used to produce `/v1beta/models/models/gemini-2.5-pro:...`
/// which the upstream rejects and the health check records as a
/// false-negative for an otherwise valid provider.
#[test]
fn test_resolve_claude_stream_url_for_gemini_native_strips_models_prefix() {
let url = StreamCheckService::resolve_claude_stream_url(
"https://generativelanguage.googleapis.com",
AuthStrategy::Google,
"gemini_native",
false,
"models/gemini-2.5-pro",
);
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse"
);
}
#[test]
fn test_resolve_codex_stream_urls_for_full_url_mode() {
let urls = StreamCheckService::resolve_codex_stream_urls(
+1 -4
View File
@@ -173,10 +173,7 @@ async fn run_worker_loop(
let started_at = Instant::now();
let mut merged_count = 1usize;
loop {
let Some(wait_for) = auto_sync_wait_duration(started_at, Instant::now()) else {
break;
};
while let Some(wait_for) = auto_sync_wait_duration(started_at, Instant::now()) {
let timeout = tokio::time::timeout(wait_for, rx.recv()).await;
match timeout {
@@ -9,10 +9,10 @@ use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
TITLE_MAX_CHARS,
};
const PROVIDER_ID: &str = "claude";
const TITLE_MAX_CHARS: usize = 80;
pub fn scan_sessions() -> Vec<SessionMeta> {
let root = get_claude_config_dir().join("projects");
@@ -11,6 +11,7 @@ use crate::session_manager::{SessionMessage, SessionMeta};
use super::utils::{
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
TITLE_MAX_CHARS,
};
const PROVIDER_ID: &str = "codex";
@@ -129,8 +130,9 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
let mut session_id: Option<String> = None;
let mut project_dir: Option<String> = None;
let mut created_at: Option<i64> = None;
let mut first_user_message: Option<String> = None;
// Extract metadata from head lines
// Extract metadata and first user message from head lines
for line in &head {
let value: Value = match serde_json::from_str(line) {
Ok(parsed) => parsed,
@@ -158,6 +160,29 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
}
}
}
// Extract first user message as title candidate
if first_user_message.is_none()
&& value.get("type").and_then(Value::as_str) == Some("response_item")
{
if let Some(payload) = value.get("payload") {
if payload.get("type").and_then(Value::as_str) == Some("message")
&& payload.get("role").and_then(Value::as_str) == Some("user")
{
let text = payload.get("content").map(extract_text).unwrap_or_default();
let trimmed = text.trim();
if !trimmed.is_empty() && !trimmed.starts_with("# AGENTS.md") {
first_user_message = Some(trimmed.to_string());
}
}
}
}
if session_id.is_some()
&& project_dir.is_some()
&& created_at.is_some()
&& first_user_message.is_some()
{
break;
}
}
// Extract last_active_at and summary from tail lines (reverse order)
@@ -190,10 +215,14 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
let session_id = session_id.or_else(|| infer_session_id_from_filename(path));
let session_id = session_id?;
let title = project_dir
.as_deref()
.and_then(path_basename)
.map(|value| value.to_string());
let title = first_user_message
.map(|t| truncate_summary(&t, TITLE_MAX_CHARS))
.or_else(|| {
project_dir
.as_deref()
.and_then(path_basename)
.map(|v| v.to_string())
});
let summary = summary.map(|text| truncate_summary(&text, 160));
@@ -261,6 +290,82 @@ mod tests {
assert!(!path.exists());
}
#[test]
fn parse_session_uses_first_user_message_as_title() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.jsonl");
std::fs::write(
&path,
concat!(
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n",
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"How do I deploy?\"}}\n",
"{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"Here is how...\"}}\n"
),
)
.expect("write");
let meta = parse_session(&path).unwrap();
assert_eq!(meta.title.as_deref(), Some("How do I deploy?"));
}
#[test]
fn parse_session_skips_agents_md_injection() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.jsonl");
std::fs::write(
&path,
concat!(
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n",
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"developer\",\"content\":\"<permissions>\"}}\n",
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# AGENTS.md instructions for /tmp/project\\n<INSTRUCTIONS>Do stuff</INSTRUCTIONS>\"}}\n",
"{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"Fix the login bug\"}}\n"
),
)
.expect("write");
let meta = parse_session(&path).unwrap();
// Should skip AGENTS.md injection and use the real user message
assert_eq!(meta.title.as_deref(), Some("Fix the login bug"));
}
#[test]
fn parse_session_falls_back_to_dir_basename() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.jsonl");
std::fs::write(
&path,
concat!(
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/my-project\"}}\n",
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"Hello\"}}\n"
),
)
.expect("write");
let meta = parse_session(&path).unwrap();
// No user message → falls back to dir basename
assert_eq!(meta.title.as_deref(), Some("my-project"));
}
#[test]
fn parse_session_truncates_long_title() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.jsonl");
let long_msg = "a".repeat(200);
std::fs::write(
&path,
format!(
"{{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"test-id\",\"cwd\":\"/tmp/p\"}}}}\n\
{{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"user\",\"content\":\"{long_msg}\"}}}}\n",
),
)
.expect("write");
let meta = parse_session(&path).unwrap();
let title = meta.title.unwrap();
assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..."
assert!(title.ends_with("..."));
}
#[test]
fn load_messages_includes_function_call_and_output() {
let temp = tempdir().expect("tempdir");
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
@@ -12,10 +13,20 @@ use crate::{
use super::utils::{
extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary,
TITLE_MAX_CHARS,
};
const PROVIDER_ID: &str = "openclaw";
/// Strip trailing `\n[message_id: ...]` metadata injected by OpenClaw gateway.
fn strip_message_id_suffix(text: &str) -> &str {
if let Some(pos) = text.rfind("\n[message_id:") {
text[..pos].trim_end()
} else {
text
}
}
pub fn scan_sessions() -> Vec<SessionMeta> {
let agents_dir = get_openclaw_dir().join("agents");
if !agents_dir.exists() {
@@ -46,22 +57,15 @@ pub fn scan_sessions() -> Vec<SessionMeta> {
Err(_) => continue,
};
let display_names = load_display_names(&sessions_dir);
for entry in session_entries.flatten() {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
continue;
}
// Skip sessions.json index file
if path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n == "sessions.json")
.unwrap_or(false)
{
continue;
}
if let Some(meta) = parse_session(&path) {
if let Some(meta) = parse_session(&path, Some(&display_names)) {
sessions.push(meta);
}
}
@@ -119,7 +123,7 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
}
pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<bool, String> {
let meta = parse_session(path).ok_or_else(|| {
let meta = parse_session(path, None).ok_or_else(|| {
format!(
"Failed to parse OpenClaw session metadata: {}",
path.display()
@@ -149,15 +153,46 @@ pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result<boo
Ok(true)
}
fn parse_session(path: &Path) -> Option<SessionMeta> {
/// Read `sessions.json` index and build a sessionId → displayName lookup map.
/// Returns an empty map if the file does not exist or cannot be parsed.
fn load_display_names(sessions_dir: &Path) -> HashMap<String, String> {
let index_path = sessions_dir.join("sessions.json");
let content = match std::fs::read_to_string(&index_path) {
Ok(c) => c,
Err(_) => return HashMap::new(),
};
let index: serde_json::Map<String, Value> = match serde_json::from_str(&content) {
Ok(m) => m,
Err(_) => return HashMap::new(),
};
let mut map = HashMap::new();
for (_key, entry) in &index {
if let (Some(id), Some(name)) = (
entry.get("sessionId").and_then(Value::as_str),
entry.get("displayName").and_then(Value::as_str),
) {
if !name.is_empty() {
map.insert(id.to_string(), name.to_string());
}
}
}
map
}
fn parse_session(
path: &Path,
display_names: Option<&HashMap<String, String>>,
) -> Option<SessionMeta> {
let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;
let mut session_id: Option<String> = None;
let mut cwd: Option<String> = None;
let mut created_at: Option<i64> = None;
let mut summary: Option<String> = None;
let mut first_user_message: Option<String> = None;
// Extract metadata and first message summary from head lines
// Extract metadata, summary, and first user message from head lines
for line in &head {
let value: Value = match serde_json::from_str(line) {
Ok(parsed) => parsed,
@@ -189,15 +224,31 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
continue;
}
// OpenClaw summary is the first message content
if event_type == "message" && summary.is_none() {
if event_type == "message" {
if let Some(message) = value.get("message") {
let text = message.get("content").map(extract_text).unwrap_or_default();
if !text.trim().is_empty() {
summary = Some(text);
let cleaned = strip_message_id_suffix(&text);
if !cleaned.trim().is_empty() {
if first_user_message.is_none()
&& message.get("role").and_then(Value::as_str) == Some("user")
{
first_user_message = Some(cleaned.trim().to_string());
}
if summary.is_none() {
summary = Some(cleaned.trim().to_string());
}
}
}
}
if session_id.is_some()
&& cwd.is_some()
&& created_at.is_some()
&& summary.is_some()
&& first_user_message.is_some()
{
break;
}
}
// Extract last_active_at from tail lines (reverse order)
@@ -221,10 +272,17 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
});
let session_id = session_id?;
let title = cwd
.as_deref()
.and_then(path_basename)
.map(|s| s.to_string());
// Title priority: displayName (from sessions.json) > first user message > dir basename
let title = display_names
.and_then(|m| m.get(&session_id))
.filter(|s| !s.is_empty())
.map(|t| truncate_summary(t, TITLE_MAX_CHARS))
.or_else(|| first_user_message.map(|t| truncate_summary(&t, TITLE_MAX_CHARS)))
.or_else(|| {
cwd.as_deref()
.and_then(path_basename)
.map(|s| s.to_string())
});
let summary = summary.map(|text| truncate_summary(&text, 160));
@@ -284,6 +342,93 @@ mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn parse_session_uses_first_user_message_as_title() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session-abc.jsonl");
std::fs::write(
&path,
concat!(
"{\"type\":\"session\",\"id\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
"{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"How do I deploy?\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n",
"{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Here is how...\"},\"timestamp\":\"2026-03-06T10:02:00Z\"}\n"
),
)
.expect("write");
let meta = parse_session(&path, None).unwrap();
assert_eq!(meta.title.as_deref(), Some("How do I deploy?"));
}
#[test]
fn parse_session_display_name_overrides_user_message() {
let temp = tempdir().expect("tempdir");
let sessions_dir = temp.path();
let path = sessions_dir.join("session-abc.jsonl");
std::fs::write(
&path,
concat!(
"{\"type\":\"session\",\"id\":\"session-abc\",\"cwd\":\"/tmp/project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
"{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"fix something\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
),
)
.expect("write session");
std::fs::write(
sessions_dir.join("sessions.json"),
r#"{
"agent:main:main": {
"sessionId": "session-abc",
"displayName": "重构登录模块"
}
}"#,
)
.expect("write index");
let display_names = load_display_names(sessions_dir);
let meta = parse_session(&path, Some(&display_names)).unwrap();
assert_eq!(meta.title.as_deref(), Some("重构登录模块"));
}
#[test]
fn parse_session_falls_back_to_dir_basename() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session-def.jsonl");
std::fs::write(
&path,
concat!(
"{\"type\":\"session\",\"id\":\"session-def\",\"cwd\":\"/tmp/my-project\",\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
"{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Hello\"},\"timestamp\":\"2026-03-06T10:01:00Z\"}\n"
),
)
.expect("write");
let meta = parse_session(&path, None).unwrap();
// No user message and no displayName → falls back to dir basename
assert_eq!(meta.title.as_deref(), Some("my-project"));
}
#[test]
fn parse_session_truncates_long_title() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session-trunc.jsonl");
let long_msg = "a".repeat(200);
std::fs::write(
&path,
format!(
"{{\"type\":\"session\",\"id\":\"session-trunc\",\"cwd\":\"/tmp/p\",\"timestamp\":\"2026-03-06T10:00:00Z\"}}\n\
{{\"type\":\"message\",\"message\":{{\"role\":\"user\",\"content\":\"{long_msg}\"}},\"timestamp\":\"2026-03-06T10:01:00Z\"}}\n",
),
)
.expect("write");
let meta = parse_session(&path, None).unwrap();
let title = meta.title.unwrap();
assert!(title.len() <= TITLE_MAX_CHARS + 3); // +3 for "..."
assert!(title.ends_with("..."));
}
#[test]
fn delete_session_updates_index_and_removes_jsonl() {
let temp = tempdir().expect("tempdir");
@@ -5,6 +5,9 @@ use std::path::Path;
use chrono::{DateTime, FixedOffset};
use serde_json::Value;
/// Maximum number of characters for session titles (shared across providers).
pub const TITLE_MAX_CHARS: usize = 80;
/// Read the first `head_n` lines and last `tail_n` lines from a file.
/// For small files (< 16 KB), reads all lines once to avoid unnecessary seeking.
pub fn read_head_tail_lines(
+77 -17
View File
@@ -20,6 +20,7 @@ pub fn launch_terminal(
"ghostty" => launch_ghostty(command, cwd),
"kitty" => launch_kitty(command, cwd),
"wezterm" => launch_wezterm(command, cwd),
"kaku" => launch_kaku(command, cwd),
"alacritty" => launch_alacritty(command, cwd),
"custom" => launch_custom(command, cwd, custom_config),
_ => Err(format!("Unsupported terminal target: {target}")),
@@ -153,25 +154,10 @@ fn launch_kitty(command: &str, cwd: Option<&str>) -> Result<(), String> {
fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
// wezterm start --cwd ... -- command
// To invoke via `open`, we use `open -na "WezTerm" --args start ...`
let full_command = build_shell_command(command, None);
let mut args = vec!["-na", "WezTerm", "--args", "start"];
if let Some(dir) = cwd {
args.push("--cwd");
args.push(dir);
}
// Invoke shell to run the command string (to handle pipes, etc)
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
args.push("--");
args.push(&shell);
args.push("-c");
args.push(&full_command);
let args = build_wezterm_compatible_args("WezTerm", command, cwd);
let status = Command::new("open")
.args(&args)
.args(args.iter().map(String::as_str))
.status()
.map_err(|e| format!("Failed to launch WezTerm: {e}"))?;
@@ -182,6 +168,54 @@ fn launch_wezterm(command: &str, cwd: Option<&str>) -> Result<(), String> {
}
}
fn launch_kaku(command: &str, cwd: Option<&str>) -> Result<(), String> {
// Kaku is a WezTerm-derived terminal and keeps a compatible `start` entrypoint.
let args = build_wezterm_compatible_args("Kaku", command, cwd);
let status = Command::new("open")
.args(args.iter().map(String::as_str))
.status()
.map_err(|e| format!("Failed to launch Kaku: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Kaku.".to_string())
}
}
fn build_wezterm_compatible_args(app_name: &str, command: &str, cwd: Option<&str>) -> Vec<String> {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
build_wezterm_compatible_args_with_shell(app_name, command, cwd, &shell)
}
fn build_wezterm_compatible_args_with_shell(
app_name: &str,
command: &str,
cwd: Option<&str>,
shell: &str,
) -> Vec<String> {
let full_command = build_shell_command(command, None);
let mut args = vec![
"-na".to_string(),
app_name.to_string(),
"--args".to_string(),
"start".to_string(),
];
if let Some(dir) = cwd {
args.push("--cwd".to_string());
args.push(dir.to_string());
}
// Invoke shell to run the command string (to handle pipes, etc)
args.push("--".to_string());
args.push(shell.to_string());
args.push("-c".to_string());
args.push(full_command);
args
}
fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> {
// Alacritty: open -na Alacritty --args --working-directory ... -e shell -c command
let full_command = build_shell_command(command, None);
@@ -305,4 +339,30 @@ mod tests {
"raw:echo foo\\\\\\\\bar\\npwd\\n"
);
}
#[test]
fn wezterm_compatible_terminals_use_start_and_cwd_arguments() {
let args = build_wezterm_compatible_args_with_shell(
"Kaku",
"claude --resume abc-123",
Some("/tmp/project dir"),
"/bin/zsh",
);
assert_eq!(
args,
vec![
"-na".to_string(),
"Kaku".to_string(),
"--args".to_string(),
"start".to_string(),
"--cwd".to_string(),
"/tmp/project dir".to_string(),
"--".to_string(),
"/bin/zsh".to_string(),
"-c".to_string(),
"claude --resume abc-123".to_string(),
]
);
}
}
+4 -1
View File
@@ -175,6 +175,8 @@ pub struct AppSettings {
pub show_in_tray: bool,
#[serde(default = "default_minimize_to_tray_on_close")]
pub minimize_to_tray_on_close: bool,
#[serde(default)]
pub use_app_window_controls: bool,
/// 是否启用 Claude 插件联动
#[serde(default)]
pub enable_claude_plugin_integration: bool,
@@ -273,7 +275,7 @@ pub struct AppSettings {
// ===== 终端设置 =====
/// 首选终端应用(可选,默认使用系统默认终端)
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" | "wezterm" | "kaku"
/// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal)
/// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty"
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -293,6 +295,7 @@ impl Default for AppSettings {
Self {
show_in_tray: true,
minimize_to_tray_on_close: true,
use_app_window_controls: false,
enable_claude_plugin_integration: false,
skip_claude_onboarding: false,
launch_on_startup: false,
+22 -2
View File
@@ -297,6 +297,9 @@ pub fn create_tray_menu(
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
menu_builder = menu_builder.item(&show_main_item).separator();
// Pre-compute proxy running state (used to disable official providers in tray menu)
let is_proxy_running = futures::executor::block_on(app_state.proxy_service.is_running());
// 每个应用类型折叠为子菜单,避免供应商过多时菜单过长
for section in TRAY_SECTIONS.iter() {
if !visible_apps.is_visible(&section.app_type) {
@@ -327,15 +330,32 @@ pub fn create_tray_menu(
};
let submenu_id = format!("submenu_{}", app_type_str);
// Check if this app is under proxy takeover (for disabling official providers)
let is_app_taken_over = is_proxy_running
&& (futures::executor::block_on(app_state.db.get_live_backup(app_type_str))
.ok()
.flatten()
.is_some()
|| app_state
.proxy_service
.detect_takeover_in_live_config_for_app(&section.app_type));
let mut submenu_builder = SubmenuBuilder::with_id(app, &submenu_id, &submenu_label);
for (id, provider) in sort_providers(&providers) {
let is_current = current_id == *id;
let is_official_blocked =
is_app_taken_over && provider.category.as_deref() == Some("official");
let label = if is_official_blocked {
format!("{} \u{26D4}", &provider.name) // ⛔ emoji
} else {
provider.name.clone()
};
let item = CheckMenuItem::with_id(
app,
format!("{}{}", section.prefix, id),
&provider.name,
true,
&label,
!is_official_blocked, // disabled when blocked
is_current,
None::<&str>,
)
+2 -254
View File
@@ -104,8 +104,7 @@ pub async fn execute_usage_script(
)
})?;
// 5. 验证请求 URL 是否安全(防止 SSRF
// 如果提供了 base_url,则验证同源;否则只做基本安全检查
// 5. 验证请求 URLHTTPS 强制 + 同源检查
validate_request_url(&request.url, base_url, is_custom_template)?;
// 6. 发送 HTTP 请求
@@ -468,19 +467,10 @@ fn validate_base_url(base_url: &str) -> Result<(), AppError> {
));
}
// 检查是否为明显的私有IP(但在 base_url 阶段不过于严格,主要在 request_url 阶段检查)
if is_suspicious_hostname(hostname) {
return Err(AppError::localized(
"usage_script.base_url_suspicious",
"base_url 包含可疑的主机名",
"base_url contains a suspicious hostname",
));
}
Ok(())
}
/// 验证请求 URL 是否安全(防止 SSRF
/// 验证请求 URL 是否安全(HTTPS 强制 + 同源检查
fn validate_request_url(
request_url: &str,
base_url: &str,
@@ -561,151 +551,11 @@ fn validate_request_url(
));
}
}
// 禁止私有 IP 地址访问(除非 base_url 本身就是私有地址,用于开发环境)
if let Some(host) = parsed_request.host_str() {
let base_host = parsed_base.host_str().unwrap_or("");
// 如果 base_url 不是私有地址,则禁止访问私有IP
if !is_private_ip(base_host) && is_private_ip(host) {
return Err(AppError::localized(
"usage_script.private_ip_blocked",
"禁止访问私有 IP 地址",
"Access to private IP addresses is blocked",
));
}
}
} else {
// 自定义模板模式:没有 base_url,需要额外的安全检查
// 禁止访问私有 IP 地址(SSRF 防护)
if let Some(host) = parsed_request.host_str() {
if is_private_ip(host) && !is_request_loopback {
return Err(AppError::localized(
"usage_script.private_ip_blocked",
"禁止访问私有 IP 地址(localhost 除外)",
"Access to private IP addresses is blocked (localhost allowed)",
));
}
}
}
Ok(())
}
/// 检查是否为私有 IP 地址
fn is_private_ip(host: &str) -> bool {
// localhost 检查
if host.eq_ignore_ascii_case("localhost") {
return true;
}
// 尝试解析为IP地址
if let Ok(ip_addr) = host.parse::<std::net::IpAddr>() {
return is_private_ip_addr(ip_addr);
}
// 如果不是IP地址,不是私有IP
false
}
/// 使用标准库API检查IP地址是否为私有地址
fn is_private_ip_addr(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(ipv4) => {
let octets = ipv4.octets();
// 0.0.0.0/8 (包括未指定地址)
if octets[0] == 0 {
return true;
}
// RFC1918 私有地址范围
// 10.0.0.0/8
if octets[0] == 10 {
return true;
}
// 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
if octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31 {
return true;
}
// 192.168.0.0/16
if octets[0] == 192 && octets[1] == 168 {
return true;
}
// 其他特殊地址
// 169.254.0.0/16 (链路本地地址)
if octets[0] == 169 && octets[1] == 254 {
return true;
}
// 127.0.0.0/8 (环回地址)
if octets[0] == 127 {
return true;
}
false
}
std::net::IpAddr::V6(ipv6) => {
// IPv6 私有地址检查 - 使用标准库方法
// ::1 (环回地址)
if ipv6.is_loopback() {
return true;
}
// 唯一本地地址 (fc00::/7)
// Rust 1.70+ 可以使用 ipv6.is_unique_local()
// 但为了兼容性,我们手动检查
let first_segment = ipv6.segments()[0];
if (first_segment & 0xfe00) == 0xfc00 {
return true;
}
// 链路本地地址 (fe80::/10)
if (first_segment & 0xffc0) == 0xfe80 {
return true;
}
// 未指定地址 ::
if ipv6.is_unspecified() {
return true;
}
false
}
}
}
/// 检查是否为可疑的主机名(只检查明显不安全的模式)
fn is_suspicious_hostname(hostname: &str) -> bool {
// 空主机名
if hostname.is_empty() {
return true;
}
// 检查明显的主机名格式问题
if hostname.contains("..") || hostname.starts_with(".") || hostname.ends_with(".") {
return true;
}
// 检查是否为纯IP地址但没有合理格式(过于宽松的检查在这里可能不够,但主要依赖后续的同源检查)
if hostname.parse::<std::net::IpAddr>().is_ok() {
// IP地址格式的,在这里不直接拒绝,让同源检查来处理
return false;
}
// 检查是否包含明显不当的字符
let suspicious_chars = ['<', '>', '"', '\'', '\n', '\r', '\t', '\0'];
if hostname.chars().any(|c| suspicious_chars.contains(&c)) {
return true;
}
false
}
/// 判断 URL 是否指向本机(localhost / loopback
fn is_loopback_host(url: &Url) -> bool {
match url.host() {
@@ -720,77 +570,6 @@ fn is_loopback_host(url: &Url) -> bool {
mod tests {
use super::*;
#[test]
fn test_private_ip_validation() {
// 测试IPv4私网地址
// RFC1918私网地址 - 应该返回true
assert!(is_private_ip("10.0.0.1"));
assert!(is_private_ip("10.255.255.254"));
assert!(is_private_ip("172.16.0.1"));
assert!(is_private_ip("172.31.255.255"));
assert!(is_private_ip("192.168.0.1"));
assert!(is_private_ip("192.168.255.255"));
// 链路本地地址 - 应该返回true
assert!(is_private_ip("169.254.0.1"));
assert!(is_private_ip("169.254.255.255"));
// 环回地址 - 应该返回true
assert!(is_private_ip("127.0.0.1"));
assert!(is_private_ip("localhost"));
// 公网172.x.x.x地址 - 应该返回false(这是修复的重点)
assert!(!is_private_ip("172.0.0.1"));
assert!(!is_private_ip("172.15.255.255"));
assert!(!is_private_ip("172.32.0.1"));
assert!(!is_private_ip("172.64.0.1"));
assert!(!is_private_ip("172.67.0.1")); // Cloudflare CDN
assert!(!is_private_ip("172.68.0.1"));
assert!(!is_private_ip("172.100.50.25"));
assert!(!is_private_ip("172.255.255.255"));
// 其他公网地址 - 应该返回false
assert!(!is_private_ip("8.8.8.8")); // Google DNS
assert!(!is_private_ip("1.1.1.1")); // Cloudflare DNS
assert!(!is_private_ip("208.67.222.222")); // OpenDNS
assert!(!is_private_ip("180.76.76.76")); // Baidu DNS
// 域名 - 应该返回false
assert!(!is_private_ip("api.example.com"));
assert!(!is_private_ip("www.google.com"));
}
#[test]
fn test_ipv6_private_validation() {
// IPv6私网地址
assert!(is_private_ip("::1")); // 环回地址
assert!(is_private_ip("fc00::1")); // 唯一本地地址
assert!(is_private_ip("fd00::1")); // 唯一本地地址
assert!(is_private_ip("fe80::1")); // 链路本地地址
assert!(is_private_ip("::")); // 未指定地址
// IPv6公网地址 - 应该返回false(修复的重点)
assert!(!is_private_ip("2001:4860:4860::8888")); // Google DNS IPv6
assert!(!is_private_ip("2606:4700:4700::1111")); // Cloudflare DNS IPv6
assert!(!is_private_ip("2404:6800:4001:c01::67")); // Google DNS IPv6 (其他格式)
assert!(!is_private_ip("2001:db8::1")); // 文档地址(非私网)
// 测试包含 ::1 子串但不是环回地址的公网地址
assert!(!is_private_ip("2001:db8::1abc")); // 包含 ::1abc 但不是环回
assert!(!is_private_ip("2606:4700::1")); // 包含 ::1 但不是环回
}
#[test]
fn test_hostname_bypass_prevention() {
// 看起来像本地,但实际是域名
assert!(!is_private_ip("127.0.0.1.evil.com"));
assert!(!is_private_ip("localhost.evil.com"));
// 0.0.0.0 应该被视为本地/阻断
assert!(is_private_ip("0.0.0.0"));
}
#[test]
fn test_https_bypass_prevention() {
// 非本地域名的 HTTP 应该被拒绝
@@ -801,37 +580,6 @@ mod tests {
);
}
#[test]
fn test_edge_cases() {
// 边界情况测试
assert!(is_private_ip("172.16.0.0")); // RFC1918起始
assert!(is_private_ip("172.31.255.255")); // RFC1918结束
assert!(is_private_ip("10.0.0.0")); // 10.0.0.0/8起始
assert!(is_private_ip("10.255.255.255")); // 10.0.0.0/8结束
assert!(is_private_ip("192.168.0.0")); // 192.168.0.0/16起始
assert!(is_private_ip("192.168.255.255")); // 192.168.0.0/16结束
// 紧邻RFC1918的公网地址 - 应该返回false
assert!(!is_private_ip("172.15.255.255")); // 172.16.0.0的前一个
assert!(!is_private_ip("172.32.0.0")); // 172.31.255.255的后一个
}
#[test]
fn test_ip_addr_parsing() {
// 测试IP地址解析功能
let ipv4_private = "10.0.0.1".parse::<std::net::IpAddr>().unwrap();
assert!(is_private_ip_addr(ipv4_private));
let ipv4_public = "172.67.0.1".parse::<std::net::IpAddr>().unwrap();
assert!(!is_private_ip_addr(ipv4_public));
let ipv6_private = "fc00::1".parse::<std::net::IpAddr>().unwrap();
assert!(is_private_ip_addr(ipv6_private));
let ipv6_public = "2001:4860:4860::8888".parse::<std::net::IpAddr>().unwrap();
assert!(!is_private_ip_addr(ipv6_public));
}
#[test]
fn test_port_comparison() {
// 测试端口比较逻辑是否正确处理默认端口和显式端口
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.12.3",
"version": "3.13.0",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
+18 -9
View File
@@ -525,7 +525,7 @@ fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
}
#[test]
fn switch_google_official_gemini_sets_oauth_security() {
fn switch_google_official_gemini_preserves_env_vars() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let home = ensure_test_home();
@@ -540,7 +540,9 @@ fn switch_google_official_gemini_sets_oauth_security() {
"google-official".to_string(),
"Google".to_string(),
json!({
"env": {}
"env": {
"GEMINI_MODEL": "gemini-2.5-pro"
}
}),
Some("https://ai.google.dev".to_string()),
);
@@ -558,23 +560,30 @@ fn switch_google_official_gemini_sets_oauth_security() {
ProviderService::switch(&state, AppType::Gemini, "google-official")
.expect("switching to Google official Gemini should succeed");
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let gemini_settings = home.join(".gemini").join("settings.json");
// Verify env vars are preserved in ~/.gemini/.env
let env_path = home.join(".gemini").join(".env");
assert!(
gemini_settings.exists(),
"Gemini settings.json should exist at {}",
gemini_settings.display()
env_path.exists(),
"Gemini .env should exist at {}",
env_path.display()
);
let env_content = std::fs::read_to_string(&env_path).expect("read gemini .env");
assert!(
env_content.contains("GEMINI_MODEL=gemini-2.5-pro"),
"GEMINI_MODEL should be preserved in .env, got: {env_content}"
);
// Verify OAuth security flag is still set correctly
let gemini_settings = home.join(".gemini").join("settings.json");
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
let gemini_value: serde_json::Value =
serde_json::from_str(&gemini_raw).expect("parse gemini settings");
assert_eq!(
gemini_value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Gemini settings json should reflect oauth-personal for Google Official"
"OAuth security flag should still be set"
);
}
+174 -9
View File
@@ -9,6 +9,10 @@ import {
Plus,
Settings,
ArrowLeft,
Minus,
Maximize2,
Minimize2,
X,
Book,
Wrench,
RefreshCw,
@@ -22,6 +26,7 @@ import {
Shield,
Cpu,
} from "lucide-react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { Provider, VisibleApps } from "@/types";
import type { EnvConflict } from "@/types/env";
import { useProvidersQuery, useSettingsQuery } from "@/lib/query";
@@ -99,9 +104,8 @@ interface WebDavSyncStatusUpdatedPayload {
error?: string;
}
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
const DEFAULT_DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
const HEADER_HEIGHT = 64; // px
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
const STORAGE_KEY = "cc-switch-last-app";
const VALID_APPS: AppId[] = [
@@ -153,12 +157,17 @@ function App() {
const [currentView, setCurrentView] = useState<View>(getInitialView);
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
const [isAddOpen, setIsAddOpen] = useState(false);
const [isWindowMaximized, setIsWindowMaximized] = useState(false);
useEffect(() => {
localStorage.setItem(VIEW_STORAGE_KEY, currentView);
}, [currentView]);
const { data: settingsData } = useSettingsQuery();
const useAppWindowControls =
isLinux() && (settingsData?.useAppWindowControls ?? false);
const dragBarHeight = useAppWindowControls ? 32 : DEFAULT_DRAG_BAR_HEIGHT;
const contentTopOffset = dragBarHeight + HEADER_HEIGHT;
const visibleApps: VisibleApps = settingsData?.visibleApps ?? {
claude: true,
codex: true,
@@ -261,7 +270,11 @@ function App() {
deleteProvider,
saveUsageScript,
setAsDefaultModel,
} = useProviderActions(activeApp, isProxyRunning);
} = useProviderActions(
activeApp,
isProxyRunning,
isProxyRunning && isCurrentAppTakeoverActive,
);
const disableOmoMutation = useDisableCurrentOmo();
const handleDisableOmo = () => {
@@ -392,6 +405,77 @@ function App() {
};
}, [queryClient, t]);
// Listen for proxy-official-warning: warn when takeover is enabled with an official provider
useEffect(() => {
let unsubscribe: (() => void) | undefined;
const setup = async () => {
unsubscribe = await listen("proxy-official-warning", (event) => {
const { providerName } = event.payload as {
appType: string;
providerName: string;
};
toast.warning(
t("notifications.proxyOfficialWarning", {
name: providerName,
defaultValue: `当前供应商 ${providerName} 是官方供应商,建议切换到第三方供应商后再使用代理接管`,
}),
{ duration: 8000 },
);
});
};
void setup();
return () => {
unsubscribe?.();
};
}, [t]);
useEffect(() => {
let active = true;
let unlistenResize: (() => void) | undefined;
const setupWindowStateSync = async () => {
try {
const currentWindow = getCurrentWindow();
const syncWindowMaximizedState = async () => {
const maximized = await currentWindow.isMaximized();
if (active) {
setIsWindowMaximized(maximized);
}
};
await syncWindowMaximizedState();
unlistenResize = await currentWindow.onResized(() => {
void syncWindowMaximizedState();
});
} catch (error) {
console.error("[App] Failed to sync window maximized state", error);
}
};
void setupWindowStateSync();
return () => {
active = false;
unlistenResize?.();
};
}, []);
useEffect(() => {
// settingsData 未加载时跳过,避免用 fallback false 覆盖 Rust 侧已设好的装饰状态
if (!settingsData) return;
const syncWindowDecorations = async () => {
try {
await getCurrentWindow().setDecorations(!useAppWindowControls);
} catch (error) {
console.error("[App] Failed to update window decorations", error);
}
};
void syncWindowDecorations();
}, [useAppWindowControls, settingsData]);
useEffect(() => {
const checkEnvOnStartup = async () => {
try {
@@ -734,6 +818,44 @@ function App() {
}
};
const notifyWindowControlError = (error: unknown) => {
toast.error(
t("notifications.windowControlFailed", {
defaultValue: "窗口控制失败:{{error}}",
error: extractErrorMessage(error),
}),
);
};
const handleWindowMinimize = async () => {
try {
await getCurrentWindow().minimize();
} catch (error) {
console.error("[App] Failed to minimize window", error);
notifyWindowControlError(error);
}
};
const handleWindowToggleMaximize = async () => {
try {
const currentWindow = getCurrentWindow();
await currentWindow.toggleMaximize();
setIsWindowMaximized(await currentWindow.isMaximized());
} catch (error) {
console.error("[App] Failed to toggle maximize", error);
notifyWindowControlError(error);
}
};
const handleWindowClose = async () => {
try {
await getCurrentWindow().close();
} catch (error) {
console.error("[App] Failed to close window", error);
notifyWindowControlError(error);
}
};
const renderContent = () => {
const content = (() => {
switch (currentView) {
@@ -880,14 +1002,57 @@ function App() {
return (
<div
className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30"
style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }}
style={{ overflowX: "hidden", paddingTop: contentTopOffset }}
>
{DRAG_BAR_HEIGHT > 0 && (
{(dragBarHeight > 0 || useAppWindowControls) && (
<div
className="fixed top-0 left-0 right-0 z-[60]"
className="fixed top-0 left-0 right-0 z-[70] flex items-center justify-end px-2"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any}
/>
style={{ WebkitAppRegion: "drag", height: dragBarHeight } as any}
>
{useAppWindowControls && (
<div
className="flex items-center gap-1"
style={{ WebkitAppRegion: "no-drag" } as any}
>
<Button
variant="ghost"
size="icon"
onClick={() => void handleWindowMinimize()}
title={t("header.windowMinimize")}
className="h-7 w-7"
>
<Minus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => void handleWindowToggleMaximize()}
title={
isWindowMaximized
? t("header.windowRestore")
: t("header.windowMaximize")
}
className="h-7 w-7"
>
{isWindowMaximized ? (
<Minimize2 className="w-4 h-4" />
) : (
<Maximize2 className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => void handleWindowClose()}
title={t("header.windowClose")}
className="h-7 w-7 hover:bg-red-500/15 hover:text-red-500"
>
<X className="w-4 h-4" />
</Button>
</div>
)}
</div>
)}
{showEnvBanner && envConflicts.length > 0 && (
<EnvWarningBanner
@@ -920,7 +1085,7 @@ function App() {
style={
{
...DRAG_REGION_STYLE,
top: DRAG_BAR_HEIGHT,
top: dragBarHeight,
height: HEADER_HEIGHT,
} as any
}
+34 -8
View File
@@ -1,5 +1,11 @@
import React, { useMemo } from "react";
import { getIcon, hasIcon, getIconMetadata } from "@/icons/extracted";
import {
getIcon,
hasIcon,
getIconMetadata,
getIconUrl,
isUrlIcon,
} from "@/icons/extracted";
import { cn } from "@/lib/utils";
interface ProviderIconProps {
@@ -19,21 +25,28 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
className,
showFallback = true,
}) => {
// 获取图标 SVG
// 获取内联 SVG 字符串
const iconSvg = useMemo(() => {
if (icon && hasIcon(icon)) {
if (icon && !isUrlIcon(icon) && hasIcon(icon)) {
return getIcon(icon);
}
return "";
}, [icon]);
// 获取图标 URLURL_ICONS 列表中的 SVG / 光栅图片)
const iconUrl = useMemo(() => {
if (icon && isUrlIcon(icon)) {
return getIconUrl(icon);
}
return "";
}, [icon]);
// 计算尺寸样式
const sizeStyle = useMemo(() => {
const sizeValue = typeof size === "number" ? `${size}px` : size;
return {
width: sizeValue,
height: sizeValue,
// 内嵌 SVG 使用 1em 作为尺寸基准,这里同步 fontSize 让图标实际跟随 size 放大
fontSize: sizeValue,
lineHeight: 1,
};
@@ -41,14 +54,11 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
// 获取有效颜色:优先使用传入的有效 color,否则从元数据获取 defaultColor
const effectiveColor = useMemo(() => {
// 只有当 color 是有效的非空字符串时才使用
if (color && typeof color === "string" && color.trim() !== "") {
return color;
}
// 否则从元数据获取 defaultColor
if (icon) {
const metadata = getIconMetadata(icon);
// 只有当 defaultColor 不是 currentColor 时才使用
if (metadata?.defaultColor && metadata.defaultColor !== "currentColor") {
return metadata.defaultColor;
}
@@ -56,7 +66,7 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
return undefined;
}, [color, icon]);
// 如果有图标,显示图标
// 内联 SVG 渲染(支持 CSS currentColor 着色)
if (iconSvg) {
return (
<span
@@ -70,6 +80,22 @@ export const ProviderIcon: React.FC<ProviderIconProps> = ({
);
}
// URL-based 图标(大型 SVG / 光栅图片):以 <img> 渲染
if (iconUrl) {
return (
<img
src={iconUrl}
alt={name}
className={cn(
"inline-flex items-center justify-center flex-shrink-0 object-contain",
className,
)}
style={{ width: sizeStyle.width, height: sizeStyle.height }}
loading="lazy"
/>
);
}
// Fallback:显示首字母
if (showFallback) {
const initials = name
@@ -154,7 +154,7 @@ export function EditProviderDialog({
}, [
open, // 修复:编辑保存后再次打开显示旧数据,依赖 open 确保每次打开时重新读取最新 provider 数据
provider?.id, // 只依赖 ID,provider 对象更新不会触发重新计算
provider?.meta, // 需要依赖 meta 以便正确初始化 testConfig 和 proxyConfig
provider?.meta, // 需要依赖 meta 以便正确初始化 testConfig
initialSettingsConfig,
]);
@@ -7,6 +7,7 @@ import {
Minus,
Play,
Plus,
ShieldAlert,
Terminal,
TestTube2,
Trash2,
@@ -36,6 +37,7 @@ interface ProviderActionsProps {
isAutoFailoverEnabled?: boolean;
isInFailoverQueue?: boolean;
onToggleFailover?: (enabled: boolean) => void;
isOfficialBlockedByProxy?: boolean;
// OpenClaw: default model
isDefaultModel?: boolean;
onSetAsDefault?: () => void;
@@ -60,6 +62,7 @@ export function ProviderActions({
isAutoFailoverEnabled = false,
isInFailoverQueue = false,
onToggleFailover,
isOfficialBlockedByProxy = false,
// OpenClaw: default model
isDefaultModel = false,
onSetAsDefault,
@@ -166,6 +169,16 @@ export function ProviderActions({
};
}
if (isOfficialBlockedByProxy) {
return {
disabled: true,
variant: "secondary" as const,
className: "opacity-40 cursor-not-allowed",
icon: <ShieldAlert className="h-4 w-4" />,
text: t("provider.blockedByProxy", { defaultValue: "已拦截" }),
};
}
if (isCurrent) {
return {
disabled: true,
@@ -175,6 +175,8 @@ export function ProviderCard({
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
const isOfficial = isOfficialProvider(provider, appId);
const isOfficialBlockedByProxy =
isProxyTakeover && (provider.category === "official" || isOfficial);
const isCopilot =
provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT ||
provider.meta?.usage_script?.templateType === "github_copilot";
@@ -424,6 +426,7 @@ export function ProviderCard({
isInConfig={isInConfig}
isTesting={isTesting}
isProxyTakeover={isProxyTakeover}
isOfficialBlockedByProxy={isOfficialBlockedByProxy}
isOmo={isAnyOmo}
onSwitch={() => onSwitch(provider)}
onEdit={() => onEdit(provider)}
@@ -113,7 +113,7 @@ interface ClaudeFormFieldsProps {
// Speed Test Endpoints
speedTestEndpoints: EndpointCandidate[];
// API Format (for third-party providers that use OpenAI Chat Completions format)
// API Format (for Claude-compatible providers that need request/response conversion)
apiFormat: ClaudeApiFormat;
onApiFormatChange: (format: ClaudeApiFormat) => void;
@@ -436,7 +436,14 @@ export function ClaudeFormFields({
? t("providerForm.apiHintResponses")
: apiFormat === "openai_chat"
? t("providerForm.apiHintOAI")
: t("providerForm.apiHint")
: apiFormat === "gemini_native"
? t("providerForm.apiHintGeminiNative")
: t("providerForm.apiHint")
}
fullUrlHint={
apiFormat === "gemini_native"
? t("providerForm.fullUrlHintGeminiNative")
: undefined
}
onManageClick={() => onEndpointModalToggle(true)}
showFullUrlToggle={true}
@@ -511,6 +518,11 @@ export function ClaudeFormFields({
defaultValue: "OpenAI Responses API (需转换)",
})}
</SelectItem>
<SelectItem value="gemini_native">
{t("providerForm.apiFormatGeminiNative", {
defaultValue: "Gemini Native generateContent (需转换)",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
@@ -1264,12 +1264,22 @@ export function OmoFormFields({
) : undefined,
maxHeightClass: "max-h-[500px]",
children: (
<Textarea
value={otherFieldsStr}
onChange={(e) => onOtherFieldsStrChange(e.target.value)}
placeholder='{ "custom_key": "value" }'
className="font-mono text-xs min-h-[60px]"
/>
<>
<Textarea
value={otherFieldsStr}
onChange={(e) => onOtherFieldsStrChange(e.target.value)}
placeholder='{ "custom_key": "value" }'
className="font-mono text-xs min-h-[60px]"
/>
{isSlim && (
<p className="mt-1 text-[10px] text-muted-foreground">
{t("omo.slimOtherFieldsHint", {
defaultValue:
"Use this area for top-level OMO Slim config such as council, fallback, multiplexer, disabled_mcps, and todoContinuation.",
})}
</p>
)}
</>
),
})}
</div>
@@ -1,19 +1,9 @@
import { useTranslation } from "react-i18next";
import { useState, useEffect } from "react";
import {
ChevronDown,
ChevronRight,
FlaskConical,
Globe,
Coins,
Eye,
EyeOff,
X,
} from "lucide-react";
import { ChevronDown, ChevronRight, FlaskConical, Coins } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
@@ -22,7 +12,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { ProviderTestConfig, ProviderProxyConfig } from "@/types";
import type { ProviderTestConfig } from "@/types";
export type PricingModelSourceOption = "inherit" | "request" | "response";
@@ -34,136 +24,31 @@ interface ProviderPricingConfig {
interface ProviderAdvancedConfigProps {
testConfig: ProviderTestConfig;
proxyConfig: ProviderProxyConfig;
pricingConfig: ProviderPricingConfig;
onTestConfigChange: (config: ProviderTestConfig) => void;
onProxyConfigChange: (config: ProviderProxyConfig) => void;
onPricingConfigChange: (config: ProviderPricingConfig) => void;
}
/** 从 ProviderProxyConfig 构建完整 URL */
function buildProxyUrl(config: ProviderProxyConfig): string {
if (!config.proxyHost) return "";
const protocol = config.proxyType || "http";
const host = config.proxyHost;
const port = config.proxyPort || (protocol === "socks5" ? 1080 : 7890);
return `${protocol}://${host}:${port}`;
}
/** 从完整 URL 解析为 ProviderProxyConfig */
function parseProxyUrl(url: string): Partial<ProviderProxyConfig> {
if (!url.trim()) {
return { proxyHost: undefined, proxyPort: undefined, proxyType: undefined };
}
try {
const parsed = new URL(url);
const protocol = parsed.protocol.replace(":", "") as
| "http"
| "https"
| "socks5";
const host = parsed.hostname;
const port = parsed.port ? parseInt(parsed.port, 10) : undefined;
return {
proxyType: protocol,
proxyHost: host || undefined,
proxyPort: port,
};
} catch {
// 尝试简单解析(不是标准 URL 格式)
const match = url.match(/^(?:(\w+):\/\/)?([^:]+)(?::(\d+))?$/);
if (match) {
return {
proxyType: (match[1] as "http" | "https" | "socks5") || "http",
proxyHost: match[2] || undefined,
proxyPort: match[3] ? parseInt(match[3], 10) : undefined,
};
}
return {};
}
}
export function ProviderAdvancedConfig({
testConfig,
proxyConfig,
pricingConfig,
onTestConfigChange,
onProxyConfigChange,
onPricingConfigChange,
}: ProviderAdvancedConfigProps) {
const { t } = useTranslation();
const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled);
const [isProxyConfigOpen, setIsProxyConfigOpen] = useState(
proxyConfig.enabled,
);
const [isPricingConfigOpen, setIsPricingConfigOpen] = useState(
pricingConfig.enabled,
);
const [showPassword, setShowPassword] = useState(false);
// 代理 URL 输入状态(仅在初始化时从 proxyConfig 构建)
const [proxyUrl, setProxyUrl] = useState(() => buildProxyUrl(proxyConfig));
// 标记是否为用户主动输入(用于区分外部更新和用户输入)
const [isUserTyping, setIsUserTyping] = useState(false);
useEffect(() => {
setIsTestConfigOpen(testConfig.enabled);
}, [testConfig.enabled]);
// 同步外部 proxyConfig.enabled 变化到展开状态
useEffect(() => {
setIsProxyConfigOpen(proxyConfig.enabled);
}, [proxyConfig.enabled]);
// 同步外部 pricingConfig.enabled 变化到展开状态
useEffect(() => {
setIsPricingConfigOpen(pricingConfig.enabled);
}, [pricingConfig.enabled]);
// 仅在外部 proxyConfig 变化且非用户输入时同步(如:重置表单、加载数据)
useEffect(() => {
if (!isUserTyping) {
const newUrl = buildProxyUrl(proxyConfig);
if (newUrl !== proxyUrl) {
setProxyUrl(newUrl);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [proxyConfig.proxyType, proxyConfig.proxyHost, proxyConfig.proxyPort]);
// 处理代理 URL 变化(用户输入时不触发 URL 重建)
const handleProxyUrlChange = (value: string) => {
setIsUserTyping(true);
setProxyUrl(value);
const parsed = parseProxyUrl(value);
onProxyConfigChange({
...proxyConfig,
...parsed,
});
};
// 输入框失焦时结束用户输入状态
const handleProxyUrlBlur = () => {
setIsUserTyping(false);
};
// 清除代理配置
const handleClearProxy = () => {
setProxyUrl("");
onProxyConfigChange({
...proxyConfig,
proxyType: undefined,
proxyHost: undefined,
proxyPort: undefined,
proxyUsername: undefined,
proxyPassword: undefined,
});
};
return (
<div className="space-y-4">
<div className="rounded-lg border border-border/50 bg-muted/20">
@@ -342,141 +227,6 @@ export function ProviderAdvancedConfig({
</div>
</div>
{/* 代理配置 */}
<div className="rounded-lg border border-border/50 bg-muted/20">
<button
type="button"
className="flex w-full items-center justify-between p-4 hover:bg-muted/30 transition-colors"
onClick={() => setIsProxyConfigOpen(!isProxyConfigOpen)}
>
<div className="flex items-center gap-3">
<Globe className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">
{t("providerAdvanced.proxyConfig", {
defaultValue: "代理配置",
})}
</span>
</div>
<div className="flex items-center gap-3">
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<Label
htmlFor="proxy-config-enabled"
className="text-sm text-muted-foreground"
>
{t("providerAdvanced.useCustomProxy", {
defaultValue: "使用单独代理",
})}
</Label>
<Switch
id="proxy-config-enabled"
checked={proxyConfig.enabled}
onCheckedChange={(checked) => {
onProxyConfigChange({ ...proxyConfig, enabled: checked });
if (checked) setIsProxyConfigOpen(true);
}}
/>
</div>
{isProxyConfigOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</div>
</button>
<div
className={cn(
"overflow-hidden transition-all duration-200",
isProxyConfigOpen
? "max-h-[500px] opacity-100"
: "max-h-0 opacity-0",
)}
>
<div className="border-t border-border/50 p-4 space-y-3">
<p className="text-sm text-muted-foreground">
{t("providerAdvanced.proxyConfigDesc", {
defaultValue:
"为此供应商配置单独的网络代理,不启用时使用系统代理或全局设置。",
})}
</p>
{/* 代理地址输入框(仿照全局代理样式) */}
<div className="flex gap-2">
<Input
placeholder="http://127.0.0.1:7890 / socks5://127.0.0.1:1080"
value={proxyUrl}
onChange={(e) => handleProxyUrlChange(e.target.value)}
onBlur={handleProxyUrlBlur}
className="font-mono text-sm flex-1"
disabled={!proxyConfig.enabled}
/>
<Button
type="button"
variant="outline"
size="icon"
disabled={!proxyConfig.enabled || !proxyUrl}
onClick={handleClearProxy}
title={t("common.clear", { defaultValue: "清除" })}
>
<X className="h-4 w-4" />
</Button>
</div>
{/* 认证信息:用户名 + 密码(可选) */}
<div className="flex gap-2">
<Input
placeholder={t("providerAdvanced.proxyUsername", {
defaultValue: "用户名(可选)",
})}
value={proxyConfig.proxyUsername || ""}
onChange={(e) =>
onProxyConfigChange({
...proxyConfig,
proxyUsername: e.target.value || undefined,
})
}
className="font-mono text-sm flex-1"
disabled={!proxyConfig.enabled}
/>
<div className="relative flex-1">
<Input
type={showPassword ? "text" : "password"}
placeholder={t("providerAdvanced.proxyPassword", {
defaultValue: "密码(可选)",
})}
value={proxyConfig.proxyPassword || ""}
onChange={(e) =>
onProxyConfigChange({
...proxyConfig,
proxyPassword: e.target.value || undefined,
})
}
className="font-mono text-sm pr-10"
disabled={!proxyConfig.enabled}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
disabled={!proxyConfig.enabled}
>
{showPassword ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
</div>
</div>
</div>
</div>
{/* 计费配置 */}
<div className="rounded-lg border border-border/50 bg-muted/20">
<button
@@ -13,7 +13,6 @@ import type {
ProviderCategory,
ProviderMeta,
ProviderTestConfig,
ProviderProxyConfig,
ClaudeApiFormat,
ClaudeApiKeyField,
} from "@/types";
@@ -192,9 +191,6 @@ export function ProviderForm({
const [testConfig, setTestConfig] = useState<ProviderTestConfig>(
() => initialData?.meta?.testConfig ?? { enabled: false },
);
const [proxyConfig, setProxyConfig] = useState<ProviderProxyConfig>(
() => initialData?.meta?.proxyConfig ?? { enabled: false },
);
const [pricingConfig, setPricingConfig] = useState<{
enabled: boolean;
costMultiplier?: string;
@@ -231,7 +227,6 @@ export function ProviderForm({
supportsFullUrl ? (initialData?.meta?.isFullUrl ?? false) : false,
);
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false });
setPricingConfig({
enabled:
initialData?.meta?.costMultiplier !== undefined ||
@@ -1075,7 +1070,6 @@ export function ProviderForm({
? selectedGitHubAccountId
: undefined,
testConfig: testConfig.enabled ? testConfig : undefined,
proxyConfig: proxyConfig.enabled ? proxyConfig : undefined,
costMultiplier: pricingConfig.enabled
? pricingConfig.costMultiplier
: undefined,
@@ -1847,10 +1841,8 @@ export function ProviderForm({
appId !== "openclaw" && (
<ProviderAdvancedConfig
testConfig={testConfig}
proxyConfig={proxyConfig}
pricingConfig={pricingConfig}
onTestConfigChange={setTestConfig}
onProxyConfigChange={setProxyConfig}
onPricingConfigChange={setPricingConfig}
/>
)}
@@ -11,6 +11,7 @@ interface EndpointFieldProps {
onChange: (value: string) => void;
placeholder: string;
hint?: string;
fullUrlHint?: string;
showManageButton?: boolean;
onManageClick?: () => void;
manageButtonLabel?: string;
@@ -26,6 +27,7 @@ export function EndpointField({
onChange,
placeholder,
hint,
fullUrlHint,
showManageButton = true,
onManageClick,
manageButtonLabel,
@@ -40,7 +42,8 @@ export function EndpointField({
});
const effectiveHint =
showFullUrlToggle && isFullUrl
? t("providerForm.fullUrlHint", {
? fullUrlHint ||
t("providerForm.fullUrlHint", {
defaultValue:
"💡 请填写完整请求 URL,并且必须开启代理后使用;代理将直接使用此 URL,不拼接路径",
})
+1 -1
View File
@@ -85,7 +85,7 @@ export function SessionItem({
{getProviderLabel(session.providerId, t)}
</TooltipContent>
</Tooltip>
<span className="text-sm font-medium truncate flex-1">
<span className="text-sm font-medium line-clamp-2 flex-1">
{searchQuery ? highlightText(title, searchQuery) : title}
</span>
<ChevronRight
+83 -57
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSessionSearch } from "@/hooks/useSessionSearch";
import { useTranslation } from "react-i18next";
import { useVirtualizer } from "@tanstack/react-virtual";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import {
@@ -69,8 +70,7 @@ export function SessionManagerPage({ appId }: { appId: string }) {
const { data, isLoading, refetch } = useSessionsQuery();
const sessions = data ?? [];
const detailRef = useRef<HTMLDivElement | null>(null);
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const messageRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const [activeMessageIndex, setActiveMessageIndex] = useState<number | null>(
null,
);
@@ -134,6 +134,20 @@ export function SessionManagerPage({ appId }: { appId: string }) {
const deleteSessionMutation = useDeleteSessionMutation();
const isDeleting = deleteSessionMutation.isPending || isBatchDeleting;
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: () => 120,
overscan: 5,
gap: 12,
});
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
}, [selectedKey]);
useEffect(() => {
const validKeys = new Set(
sessions.map((session) => getSessionKey(session)),
@@ -166,37 +180,36 @@ export function SessionManagerPage({ appId }: { appId: string }) {
}, [messages]);
const scrollToMessage = (index: number) => {
const el = messageRefs.current.get(index);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
setActiveMessageIndex(index);
setTocDialogOpen(false); // 关闭弹窗
// 清除高亮状态
setTimeout(() => setActiveMessageIndex(null), 2000);
}
virtualizer.scrollToIndex(index, { align: "center", behavior: "smooth" });
setActiveMessageIndex(index);
setTocDialogOpen(false);
setTimeout(() => setActiveMessageIndex(null), 2000);
};
// 清理定时器
useEffect(() => {
return () => {
// 这里的 setTimeout 其实无法直接清理,因为它在函数闭包里。
// 如果要严格清理,需要用 useRef 存 timer id。
// 但对于 2秒的高亮清除,通常不清理也没大问题。
// 为了代码规范,我们在组件卸载时将 activeMessageIndex 重置 (虽然 React 会处理)
};
}, []);
const handleCopy = useCallback(
async (text: string, successMessage: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(successMessage);
} catch (error) {
toast.error(
extractErrorMessage(error) ||
t("common.error", { defaultValue: "Copy failed" }),
);
}
},
[t],
);
const handleCopy = async (text: string, successMessage: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(successMessage);
} catch (error) {
toast.error(
extractErrorMessage(error) ||
t("common.error", { defaultValue: "Copy failed" }),
const handleMessageCopy = useCallback(
(content: string) => {
void handleCopy(
content,
t("sessionManager.messageCopied", { defaultValue: "已复制消息内容" }),
);
}
};
},
[handleCopy, t],
);
const handleResume = async () => {
if (!selectedSession?.resumeCommand) return;
@@ -973,9 +986,9 @@ export function SessionManagerPage({ appId }: { appId: string }) {
<CardContent className="flex-1 min-h-0 p-0">
<div className="flex h-full min-w-0">
{/* 消息列表 */}
<ScrollArea className="flex-1 min-w-0">
<div className="p-4 min-w-0">
<div className="flex items-center gap-2 mb-3">
<div className="flex-1 min-w-0 flex flex-col">
<div className="px-4 pt-4 pb-2 min-w-0">
<div className="flex items-center gap-2">
<MessageSquare className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">
{t("sessionManager.conversationHistory", {
@@ -986,7 +999,11 @@ export function SessionManagerPage({ appId }: { appId: string }) {
{messages.length}
</Badge>
</div>
</div>
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto px-4 pb-4 min-w-0"
>
{isLoadingMessages ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="size-5 animate-spin text-muted-foreground" />
@@ -999,32 +1016,41 @@ export function SessionManagerPage({ appId }: { appId: string }) {
</p>
</div>
) : (
<div className="space-y-3">
{messages.map((message, index) => (
<SessionMessageItem
key={`${message.role}-${index}`}
message={message}
index={index}
isActive={activeMessageIndex === index}
searchQuery={search}
setRef={(el) => {
if (el) messageRefs.current.set(index, el);
}}
onCopy={(content) =>
handleCopy(
content,
t("sessionManager.messageCopied", {
defaultValue: "已复制消息内容",
}),
)
}
/>
))}
<div ref={messagesEndRef} />
<div
style={{
height: virtualizer.getTotalSize(),
position: "relative",
}}
>
{virtualizer
.getVirtualItems()
.map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
>
<SessionMessageItem
message={messages[virtualRow.index]}
isActive={
activeMessageIndex === virtualRow.index
}
searchQuery={search}
onCopy={handleMessageCopy}
/>
</div>
))}
</div>
)}
</div>
</ScrollArea>
</div>
{/* 右侧目录 - 类似少数派 (大屏幕) */}
<SessionTocSidebar
+49 -10
View File
@@ -1,4 +1,5 @@
import { Copy } from "lucide-react";
import { memo, useState } from "react";
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -16,29 +17,40 @@ import {
highlightText,
} from "./utils";
const COLLAPSE_THRESHOLD = 3000;
const COLLAPSED_LENGTH = 1500;
interface SessionMessageItemProps {
message: SessionMessage;
index: number;
isActive: boolean;
searchQuery?: string;
setRef: (el: HTMLDivElement | null) => void;
onCopy: (content: string) => void;
}
export function SessionMessageItem({
export const SessionMessageItem = memo(function SessionMessageItem({
message,
isActive,
searchQuery,
setRef,
onCopy,
}: SessionMessageItemProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const isLong = message.content.length > COLLAPSE_THRESHOLD;
const hasSearchMatch =
isLong &&
!expanded &&
!!searchQuery &&
message.content.toLowerCase().includes(searchQuery.toLowerCase());
const collapsed = isLong && !expanded && !hasSearchMatch;
const displayContent = collapsed
? message.content.slice(0, COLLAPSED_LENGTH) + "…"
: message.content;
return (
<div
ref={setRef}
className={cn(
"rounded-lg border px-3 py-2.5 relative group transition-all min-w-0",
"rounded-lg border px-3 py-2.5 relative group transition-shadow min-w-0",
message.role.toLowerCase() === "user"
? "bg-primary/5 border-primary/20 ml-8"
: message.role.toLowerCase() === "assistant"
@@ -76,9 +88,36 @@ export function SessionMessageItem({
</div>
<div className="whitespace-pre-wrap break-words [overflow-wrap:anywhere] text-sm leading-relaxed min-w-0">
{searchQuery
? highlightText(message.content, searchQuery)
: message.content}
? highlightText(displayContent, searchQuery)
: displayContent}
</div>
{isLong && !hasSearchMatch && (
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-1 mt-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{expanded ? (
<>
<ChevronUp className="size-3" />
{t("sessionManager.collapseContent", {
defaultValue: "收起",
})}
</>
) : (
<>
<ChevronDown className="size-3" />
{t("sessionManager.expandContent", {
defaultValue: "展开完整内容",
})}
<span className="text-muted-foreground/60">
({Math.round(message.content.length / 1000)}k)
</span>
</>
)}
</button>
)}
</div>
);
}
});
@@ -16,6 +16,7 @@ const MACOS_TERMINALS = [
{ value: "kitty", labelKey: "settings.terminal.options.macos.kitty" },
{ value: "ghostty", labelKey: "settings.terminal.options.macos.ghostty" },
{ value: "wezterm", labelKey: "settings.terminal.options.macos.wezterm" },
{ value: "kaku", labelKey: "settings.terminal.options.macos.kaku" },
] as const;
const WINDOWS_TERMINALS = [
@@ -3,6 +3,7 @@ import type { SettingsFormState } from "@/hooks/useSettings";
import { AppWindow, MonitorUp, Power, EyeOff } from "lucide-react";
import { ToggleRow } from "@/components/ui/toggle-row";
import { AnimatePresence, motion } from "framer-motion";
import { isLinux } from "@/lib/platform";
interface WindowSettingsProps {
settings: SettingsFormState;
@@ -75,6 +76,18 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
onChange({ minimizeToTrayOnClose: value })
}
/>
{isLinux() && (
<ToggleRow
icon={<AppWindow className="h-4 w-4 text-amber-500" />}
title={t("settings.useAppWindowControls")}
description={t("settings.useAppWindowControlsDescription")}
checked={!!settings.useAppWindowControls}
onCheckedChange={(value) =>
onChange({ useAppWindowControls: value })
}
/>
)}
</div>
</section>
);
@@ -25,7 +25,7 @@ export function ModelTestConfigPanel() {
degradedThresholdMs: "6000",
claudeModel: "claude-haiku-4-5-20251001",
codexModel: "gpt-5.4@low",
geminiModel: "gemini-3-pro-preview",
geminiModel: "gemini-3-flash-preview",
testPrompt: "Who are you?",
});
+61 -19
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Table,
@@ -31,24 +31,33 @@ import {
interface RequestLogTableProps {
appType?: string;
refreshIntervalMs: number;
timeRange?: "1d" | "7d" | "30d";
}
const ONE_DAY_SECONDS = 24 * 60 * 60;
const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS;
const TIME_RANGE_SECONDS: Record<string, number> = {
"1d": ONE_DAY_SECONDS,
"7d": 7 * ONE_DAY_SECONDS,
"30d": 30 * ONE_DAY_SECONDS,
};
type TimeMode = "rolling" | "fixed";
export function RequestLogTable({
appType: dashboardAppType,
refreshIntervalMs,
timeRange = "1d",
}: RequestLogTableProps) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const rollingWindowSeconds = TIME_RANGE_SECONDS[timeRange] ?? ONE_DAY_SECONDS;
const getRollingRange = () => {
const now = Math.floor(Date.now() / 1000);
const oneDayAgo = now - ONE_DAY_SECONDS;
return { startDate: oneDayAgo, endDate: now };
return { startDate: now - rollingWindowSeconds, endDate: now };
};
const [appliedTimeMode, setAppliedTimeMode] = useState<TimeMode>("rolling");
@@ -57,9 +66,15 @@ export function RequestLogTable({
const [appliedFilters, setAppliedFilters] = useState<LogFilters>({});
const [draftFilters, setDraftFilters] = useState<LogFilters>({});
const [page, setPage] = useState(0);
const [pageInput, setPageInput] = useState("");
const pageSize = 20;
const [validationError, setValidationError] = useState<string | null>(null);
// Reset page when the dashboard time range changes
useEffect(() => {
setPage(0);
}, [timeRange]);
// When dashboard-level app filter is active (not "all"), override the local appType filter
const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all";
const effectiveFilters: LogFilters = dashboardAppTypeActive
@@ -69,7 +84,7 @@ export function RequestLogTable({
const { data: result, isLoading } = useRequestLogs({
filters: effectiveFilters,
timeMode: appliedTimeMode,
rollingWindowSeconds: ONE_DAY_SECONDS,
rollingWindowSeconds,
page,
pageSize,
options: {
@@ -131,6 +146,15 @@ export function RequestLogTable({
setPage(0);
};
const handleGoToPage = () => {
const trimmed = pageInput.trim();
if (!/^\d+$/.test(trimmed)) return;
const parsed = Number(trimmed);
if (parsed < 1 || parsed > totalPages) return;
setPage(parsed - 1);
setPageInput("");
};
const handleRefresh = () => {
const key = {
timeMode: appliedTimeMode,
@@ -561,30 +585,33 @@ export function RequestLogTable({
>
<ChevronLeft className="h-4 w-4" />
</Button>
{/* 页码按钮 */}
{(() => {
const pages: (number | string)[] = [];
if (totalPages <= 7) {
// 3 head + 3 tail + 3 neighborhood = 9 max distinct pages
if (totalPages <= 9) {
for (let i = 0; i < totalPages; i++) pages.push(i);
} else {
pages.push(0);
if (page > 2) pages.push("...");
const pageSet = new Set<number>();
for (let i = 0; i < 3; i++) pageSet.add(i);
for (let i = totalPages - 3; i < totalPages; i++)
pageSet.add(i);
for (
let i = Math.max(1, page - 1);
i <= Math.min(totalPages - 2, page + 1);
let i = Math.max(0, page - 1);
i <= Math.min(totalPages - 1, page + 1);
i++
) {
pages.push(i);
)
pageSet.add(i);
const sorted = Array.from(pageSet).sort((a, b) => a - b);
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i] - sorted[i - 1] > 1) {
pages.push(`ellipsis-${i}`);
}
pages.push(sorted[i]);
}
if (page < totalPages - 3) pages.push("...");
pages.push(totalPages - 1);
}
return pages.map((p, idx) =>
return pages.map((p) =>
typeof p === "string" ? (
<span
key={`ellipsis-${idx}`}
className="px-2 text-muted-foreground"
>
<span key={p} className="px-2 text-muted-foreground">
...
</span>
) : (
@@ -608,6 +635,21 @@ export function RequestLogTable({
>
<ChevronRight className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1 ml-2">
<Input
type="text"
value={pageInput}
onChange={(e) => setPageInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleGoToPage();
}}
placeholder={t("usage.pageInputPlaceholder")}
className="h-8 w-16 text-center text-xs"
/>
<Button variant="outline" size="sm" onClick={handleGoToPage}>
{t("usage.goToPage")}
</Button>
</div>
</div>
</div>
)}

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