Commit Graph

146 Commits

Author SHA1 Message Date
Jason 5048ed632d fix: inject stream_options.include_usage for Codex Chat streaming
Responses-to-Chat conversion did not set stream_options.include_usage,
so OpenAI-compatible upstreams (kimi/MiniMax, etc.) omitted the trailing
usage chunk in streaming mode. This caused token/cost/cache stats to be
recorded as zero for third-party streaming requests routed through the
Codex Chat path.

Inject include_usage when the request is streaming, merging into any
client-provided stream_options instead of overwriting it. Add tests for
the streaming, non-streaming, and merge cases.
2026-05-25 22:20:33 +08:00
Jason b710c6549e fix: backfill placeholder reasoning_content for bare tool-call messages
Thinking models like kimi/Moonshot and DeepSeek reject any assistant
message carrying tool_calls without a non-empty reasoning_content. When
cross-turn history recovery misses (proxy restart drops the in-memory
cache, ambiguous call_id, or a turn produced no reasoning upstream), the
converted assistant message has none and the whole request fails with
'reasoning_content is missing in assistant tool call message'.

Add a final-stage backfill that runs after all input items are
processed, so genuine trailing reasoning items still attach first and a
placeholder is only injected when none remains. Mirrors the placeholder
behavior in transform::anthropic_to_openai_with_reasoning_content.
2026-05-25 22:20:33 +08:00
Jason ead9e22b21 feat: convert Codex Chat error responses to Responses envelope
The Codex Chat-to-Responses bridge already rewrites successful upstream
responses to the Responses shape, but the error branch in
handle_codex_chat_to_responses_transform passed Chat-shaped error bodies
through untouched (MiniMax base_resp, raw OpenAI Chat error, text/plain
"Unauthorized" pages, etc.), leaving Codex clients unable to recognize the
error.

Add chat_error_to_response_error in transform_codex_chat to regularize all
upstream error shapes into the standard {error: {message, type, code, param}}
envelope, then wire it through a new handle_codex_chat_error_response that
preserves the original HTTP status code. Non-JSON error bodies (HTML, plain
text) are wrapped as Value::String and truncated to 1KB at a UTF-8 char
boundary to keep diagnostic context without flooding the response.

Also fix a pre-existing append-vs-insert pitfall in three rebuilt-body
branches (Claude transform, Codex Chat normal, Codex Chat error): http
Builder::header is append, so leaking the upstream Content-Type produced
two Content-Type headers when the rewritten body was JSON. Remove the
upstream value before writing application/json.
2026-05-25 22:20:33 +08:00
Jason 72bc912e0d feat: route Codex Chat providers through Stream Check
Codex Chat providers (apiFormat=openai_chat, e.g. DeepSeek, MiniMax, Kimi)
were incorrectly probed via /v1/responses by Stream Check, which the upstream
rejects. Detect chat routing via codex_provider_uses_chat_completions and
issue the probe against /chat/completions with a Chat-shaped body.

Also align URL fallback order with the production CodexAdapter::build_url:
origin-only base URLs (https://api.deepseek.com) must hit /v1/<endpoint>
first; bare /<endpoint> only as a fallback. The previous order made any
non-404 error on the bare path (401/400/405) flag the provider as down even
though /v1/<endpoint> would have succeeded.

- Lift origin-only detection into proxy::providers::is_origin_only_url so
  both build_url and Stream Check share a single source of truth.
- Collapse resolve_codex_stream_urls and resolve_codex_chat_stream_urls into
  a generic resolve_codex_endpoint_urls(base_url, is_full_url, endpoint),
  which also gives the Responses path a symmetric "full endpoint without
  is_full_url flag" fallback for free.
- Restrict reasoning_effort propagation in the Chat branch to OpenAI o-series
  models, mirroring transform_codex_chat's runtime check.
2026-05-25 22:20:33 +08:00
Jason 9d35709805 fix: collapse mid-stream system messages in Codex Responses to Chat conversion
MiniMax's OpenAI-compatible chat endpoint strict-rejects any non-leading
role=system message with "invalid params, chat content has invalid message
role: system (2013)". The Codex client uses role=developer (and occasionally
role=system) to inject collaboration_mode / permissions / skills blocks
mid-conversation, and responses_role_to_chat_role maps both to chat's
system role. The converted messages array therefore frequently contained
system entries past index 0, which DeepSeek and OpenAI tolerate but MiniMax
flags as 2013.

Collapse all system messages into a single leading system message before
returning the chat request. The rewrite preserves every system fragment
(joined by "\n\n" in original order) and leaves non-system messages
untouched, so it is lossless for permissive backends as well.

- Add collapse_system_messages_to_head in transform_codex_chat.rs.
- Run it on the messages vector at the end of responses_to_chat_completions
  before serializing.
- Cover the new path with two unit tests: one repros the MiniMax-shaped
  input (developer items between users) and asserts no system role past
  index 0; the other verifies non-system order is preserved and content
  is joined with "\n\n".
2026-05-25 22:20:33 +08:00
Jason 2a4651a21e feat: preserve user-selected catalog model in Codex Chat requests
When Codex client sends a model from the catalog (e.g., user selected via /model),
preserve that choice instead of always replacing with config.toml's default model.

- Check if request model exists in modelCatalog before falling back to config model
- Remove CODEX_CHAT_CLIENT_MODEL constant (no longer needed)
- Add test coverage for catalog model preservation
2026-05-25 22:20:33 +08:00
Jason 90b7f25111 - Restore Codex Chat reasoning fallback
- Add unique call-id fallback when Codex omits or rewrites previous_response_id.

- Keep ambiguous call ids unresolved to avoid cross-request history leaks.

- Cover fallback and ambiguity behavior with regression tests.
2026-05-25 22:20:33 +08:00
Jason 22fbe6f11a - Optimize Codex Chat cache stability
- Stop deriving Codex session/cache identity from `previous_response_id`.
  - Canonicalize parseable JSON string payloads in Chat and Responses tool conversions.
  - Add regression coverage for cache-sensitive conversion behavior.
2026-05-25 22:20:33 +08:00
Jason 74acf1e387 Add Codex Chat-to-Responses bridge
- Add Codex provider API format selection and model mapping for Chat-only upstreams.
- Convert Codex Responses requests to Chat Completions and rebuild Chat responses as Responses output.
- Preserve reasoning_content across non-streaming, streaming, tool calls, and previous_response_id follow-ups.
- Add a bounded Codex Chat history cache for restoring tool calls before tool outputs.
- Cover Chat bridge, compact routing, streaming, and history recovery with focused tests.
2026-05-25 22:20:33 +08:00
Jason 09f67c1b38 Handle inline thinking in Codex Chat conversion
- Split leading <think> blocks from Chat content into Responses reasoning output.
- Keep assistant text output free of inline thinking tags.
- Support streamed inline thinking blocks before normal text deltas.
- Add regression coverage for MiniMax-style inline thinking responses.
2026-05-19 11:06:12 +08:00
Jason 79d6486e16 Improve Codex Chat reasoning conversion
- Convert Chat reasoning_content and reasoning fields into Responses reasoning output items.
- Emit Responses reasoning summary SSE events for streamed Chat reasoning deltas.
- Preserve output ordering when reasoning, text, and tool calls are streamed together.
- Add regression coverage for data-only Chat SSE errors, multiple tool calls, and compact routing.
2026-05-19 10:33:24 +08:00
Jason 1c82b8a3fa Add Chat Completions routing for Codex providers
- Add a Codex API format selector and routing badge for Chat Completions providers.
- Convert Codex Responses requests to upstream Chat Completions when routing is required.
- Convert Chat Completions JSON and SSE responses back to Responses format.
- Keep generated Codex wire_api values on Responses for Codex compatibility.
- Add i18n labels, provider metadata handling, and focused conversion tests.
2026-05-19 10:23:48 +08:00
Jason 61e68d754c fix(proxy): inject only ANTHROPIC_API_KEY for managed-account Claude takeover
- Provider: add uses_managed_account_auth / is_github_copilot helpers
  to identify managed-account providers (GitHub Copilot / Codex OAuth)
- ProxyService: choose auth policy by provider type when taking over
  Claude Live config. Managed accounts drop token env keys and write
  only the ANTHROPIC_API_KEY placeholder; other providers keep the
  existing ANTHROPIC_AUTH_TOKEN fallback behavior
- Forwarder: add outbound guard that refuses to send the PROXY_MANAGED
  placeholder upstream to *.githubcopilot.com and chatgpt.com
  /backend-api/codex
- Add unit tests covering detection, injection, and the outbound guard
2026-05-18 22:46:18 +08:00
Tiancrimson 5c79cf64a4 fix(gemini-native): resolve functionResponse.name and replay thought_signature for synthesized tool call IDs (#2814)
* fix(gemini-native): resolve functionResponse.name and thought_signature replay for synthesized tool call IDs

Two related bugs in the Gemini Native format conversion layer:

1. **functionResponse.name resolution** (422 error): When Gemini's parallel
   function calls omit the id field, cc-switch synthesizes gemini_synth_*
   IDs. These are stored in the shadow store but can be lost in long sessions,
   causing subsequent tool_result blocks to fail. Fix: pre-scan all assistant
   messages in the request body to seed the tool_name_by_id map, and add a
   last-resort fallback that scans the current content array for matching
   tool_use blocks.

2. **thought_signature replay** (400 error): The Anthropic Messages format
   strips thoughtSignature from tool_use blocks, but Gemini requires it on
   every functionCall in multi-turn tool-use exchanges. Fix: build a
   thought_signature_by_id map from shadow turns and attach thoughtSignature
   when converting tool_use back to functionCall.

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

* style: run cargo fmt on transform_gemini.rs

---------

Co-authored-by: Tiancrimson <tiancrimson@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-05-18 10:25:35 +08:00
Jason 9e6a7a0d81 - Fix proxy test helper clippy warning
- Mark `should_force_identity_encoding` as test-only.

- Keep runtime forwarding behavior unchanged.

- Verified with local CI checks and no-bundle Tauri build.
2026-05-15 23:45:26 +08:00
Jason b642ef0633 fix(failover): patch P1-P3 reliability gaps surfaced by team review
- Forwarder buffers non-streaming bodies and primes streaming first
  chunk before signaling success, so body timeouts and SSE first-chunk
  failures route through the circuit breaker instead of being recorded
  as success on response-header arrival
- Atomic enable-failover: switch to P1 before persisting the flag, and
  roll back auto-added queue entries when the switch is rejected
  (e.g. official providers)
- Hot-reload circuit breaker config on per-app proxy config change
  instead of waiting for a proxy restart
- FailoverToggle / FailoverQueueManager / AutoFailoverConfigPanel
  require proxy takeover for the active app; the backend command also
  rejects enabling when takeover is off
- ProviderHealthBadge consumes the backend is_healthy flag instead of
  hardcoding the 5-failure threshold

Cleanup:
- impl From<&AppProxyConfig> for CircuitBreakerConfig and use it from
  the command layer
- Collapse three identical TabsContent blocks into a single map
2026-05-14 21:35:02 +08:00
Jason 402570ce31 fix(usage): pricing routing, SSE lifecycle, and validation hardening
* model pricing routing: extend prefix-match families (gpt-/o1-o5/
  gemini-/deepseek-/qwen-/glm-/kimi-/minimax-) with per-family dash
  thresholds so short base IDs like gpt-5 no longer mis-match
  gpt-5-mini; strip ISO and 8-digit date suffixes via UTF-8-safe
  byte matching so claude-haiku-4-5-20251001 falls back to
  claude-haiku-4-5 pricing
* SSE collector: SseUsageFinishGuard (RAII) guarantees finish() on
  early return or panic; AtomicBool fast path lets push() skip the
  Mutex once first-event time is recorded
* validation: shared validate_cost_multiplier / validate_pricing_source
  helpers across DAO and service layers; PRICING_SOURCE_RESPONSE /
  PRICING_SOURCE_REQUEST constants replace string literals; price
  fields in update_model_pricing now reject empty / non-decimal /
  negative input before INSERT
* backfill: add backfill_missing_usage_costs_for_model so a single
  price edit only scans matching rows instead of the full log table;
  startup backfill remains full-scan
* session_usage{,_codex,_gemini}: share find_model_pricing helper from
  usage_stats; metadata_modified_nanos centralizes mtime precision
* frontend: NON_NEGATIVE_DECIMAL_REGEX + isNonNegativeDecimalString
  replace three copies of the same multiplier regex; isUnpricedUsage
  surfaces zero-cost rows that have usage tokens (cached per row to
  avoid double evaluation); invalidate usageKeys.all on pricing mutate
  so backfilled rows refresh
2026-05-14 11:53:51 +08:00
Jason 206125b4e3 fix(proxy): patch P0-P3 routing/lifecycle issues across forwarder paths
* stream_check: thread Result from get_auth_headers via map_err so
  the workspace builds again
* forwarder: scope rectifier / budget-rectifier flags per-provider so
  failover can still apply rectification on the next attempt
* forwarder: categorize before record_result; route NonRetryable and
  ClientAbort through release_permit_neutral so client-side failures
  don't pollute circuit breaker or DB health
* handler_context: parse Gemini model from uri.path() and strip both
  ?query and :action verb defensively in extract_gemini_model_from_path
* forwarder + response_processor + handlers: introduce
  ActiveConnectionGuard (RAII) so active_connections decrement covers
  the full streaming body lifetime, not just response headers
* claude_desktop_config: use sort_by_key to clear the clippy gate
2026-05-14 09:23:21 +08:00
Jason 85131d37d8 refactor(proxy): extract handle_rectifier_retry_failure helper
The signature (RECT-003) and budget (RECT-012) rectifier branches each
carried ~50 lines of identical "provider error -> record + continue /
client error -> release permit + return" handling. The only piece that
varied between them was a log label ("整流" vs "budget 整流").

Move the shared logic into RequestForwarder::handle_rectifier_retry_failure
that returns Option<ForwardError> — None means "continue to the next
provider", Some(err) means "terminal failure, return to the client".
Each call site shrinks from ~50 lines to ~17, drops one level of
indentation, and the two branches now provably cannot drift apart.

forwarder.rs nets ~40 lines smaller.
2026-05-14 08:22:07 +08:00
Jason 039784af73 refactor(proxy): share auth_header_value helper across provider adapters
claude.rs and gemini.rs each defined an identical `hv` closure that wrapped
`HeaderValue::from_str` into a ProxyError::AuthError result, and codex.rs
spelled the same conversion out inline. /simplify reviewers flagged this
as drift-prone copy-paste.

Move the conversion into a single `pub fn auth_header_value` in
providers/adapter.rs and have the three adapters import it locally. Same
error wording everywhere, one place to update if HeaderValue semantics
ever change.
2026-05-14 08:21:53 +08:00
Jason 5d3d9067af feat(proxy): forward client HTTP method instead of hard-coding POST
The forwarder used to call client.post(&url) / http::Method::POST in
both the reqwest and hyper paths, and the Gemini route table only
registered POST /v1beta/*. As a result anything the Gemini SDK / CLI
sent as GET (models list, models/<id> info) hit a 404 at the router
and bypassed the local proxy's stats, rectifiers, and failover.

Thread the request method end-to-end:

- ProviderAdapter forwarder API now takes the http::Method by reference
  per attempt and dispatches client.request(method, &url) for reqwest
  and method.clone() for the hyper raw path.
- All five callers in handlers.rs (handle_messages_for_app for Claude /
  Claude Desktop, handle_chat_completions, handle_responses,
  handle_responses_compact, handle_gemini) pull the method out of the
  incoming axum::extract::Request and pass it on.
- handle_gemini tolerates an empty body (GET endpoints have none) and
  the forwarder skips serializing / sending a body for GET / HEAD —
  attaching JSON to a GET makes Gemini reject the request.
- server.rs swaps the Gemini routes to any(handle_gemini) so the same
  handler handles GET / POST / PUT / DELETE, and adds /gemini/v1/*
  for the GA path version.
2026-05-13 23:36:36 +08:00
Jason f2ae9823cb fix(proxy): move client-request counters out of per-attempt loop
Three statistics-shape issues fixed together so the dashboard reflects
client requests, not provider attempts:

1. active_connections never moved off zero — the field had no caller in
   the entire crate. Wrap forward_with_retry into a thin entry point
   that saturating_add(1) on enter and saturating_sub(1) on exit; every
   inner return path is covered automatically.

2. total_requests counted attempts, not requests. A single client call
   that failed over P1 -> P2 -> success was recorded as
   total=2 / success=1 -> 50% success rate. Move the increment and the
   last_request_at refresh into the wrapper so they fire once per
   client request regardless of how many providers were tried.

3. current_provider / current_provider_id stay inside the inner loop
   because they are intentionally per-attempt ("what am I trying right
   now?") — moving them would break the live-failover indicator.

Refactor: split forward_with_retry into a public wrapper + private
forward_with_retry_inner. Every existing `return Err(...)` inside inner
remains correct because the wrapper always runs the decrement on its
return.
2026-05-13 23:28:16 +08:00
Jason b06e0fa538 fix(proxy): wire AppProxyConfig.max_retries into request forwarder
The UI has exposed "请求失败时的重试次数 (0-10, default 3)" since the
auto-failover panel was added, but the value was silently dropped —
RequestForwarder never received it and the per-provider loop walked the
whole list regardless. From the user's perspective the setting was
inert.

Thread AppProxyConfig.max_retries through create_forwarder into
RequestForwarder, derive max_attempts = max_retries + 1 (so max_retries=0
matches the UI copy "0 retries" = single attempt), and break the loop
once attempts hit the cap. The check is placed before the circuit
breaker allow-permit so an over-cap iteration does not waste a HalfOpen
probe slot.

When auto-failover is disabled we also force max_retries to 0, mirroring
how timeouts already bypass in that mode — "no failover" should mean
"one provider, one try", not "limited retries against the same list".
2026-05-13 23:25:15 +08:00
Jason 84aa87c3dd fix(proxy): map Anthropic tool_choice to OpenAI Chat nested form
The Chat-Completions transformer used to forward tool_choice verbatim,
but the two APIs disagree on shape:

  Anthropic   "any" | {"type":"tool","name":"X"}
  OpenAI Chat "required" | {"type":"function","function":{"name":"X"}}

Pass-through made the upstream return 400 for any tool-forcing client
(Claude Code, Copilot, etc.). The Responses-API transformer already had
the equivalent map_tool_choice_to_responses helper; this commit adds a
sibling map_tool_choice_to_chat with the chat-specific *nested* function
selector and five regression tests covering string / object × any /
auto / none / tool.

The two helpers are intentionally not merged: the difference between
flat and nested function selectors is exactly what the original bug
was, so keeping them as separate self-documenting functions reduces the
chance of the same regression returning.
2026-05-13 23:20:55 +08:00
Jason cb4ecd3951 fix(proxy): refine failover decisions in forwarder
Two related changes to make per-provider failover behave correctly.

1. Bucket UpstreamError by status code in categorize_proxy_error.

   The old "every UpstreamError is Retryable" rule meant a malformed
   client request (400 / 422) would be replayed against every provider
   in the queue: errors amplified N-fold, the circuit breaker accrued
   unwarranted failure counts, and quota was burned. Now
   400 / 405 / 406 / 413 / 414 / 415 / 422 / 501 are NonRetryable since
   the request itself is wrong and no provider will accept it.
   401 / 403 / 404 / 408 / 409 / 429 / 451 and all 5xx remain Retryable
   because the next provider may carry a different key, quota, region,
   or model mapping.

2. Make the rectifier-retry path participate in failover.

   Both the signature (RECT-003) and budget (RECT-012) rectifier branches
   used to "return Err(...)" after the retry failed, short-circuiting the
   per-provider loop. A provider-side failure (5xx / Timeout /
   ForwardFailed) now records the circuit breaker, accumulates into
   last_error / last_provider, and "continue"s to the next provider —
   matching the normal Retryable arm. Client-side failures still return
   immediately since a different provider cannot fix a malformed payload.
2026-05-13 23:20:45 +08:00
Jason 9a8f52021d fix(proxy): extract Gemini request model from URI path correctly
split('/') strips the slashes, so find(|s| s.starts_with("models/")) never
matched any segment and request_model fell through to "unknown" for every
Gemini call, poisoning usage records, per-request billing, and logs.

Match the literal "models" segment and take the next one, stripping any
:action suffix and query string. The extraction is now a pub(crate) free
function so it can be unit-tested directly; seven regression tests cover
action suffixes, dotted versions, the /gemini/ proxy prefix, query
strings, the bare list endpoint, and missing-segment paths.
2026-05-13 23:12:06 +08:00
Jason c9a6afc0b7 fix(proxy): return Result from get_auth_headers to avoid panic on bad credentials
User-pasted API keys can contain control chars or CR/LF that make
HeaderValue::from_str return Err; the previous unwrap inside every
adapter turned such input into a process-wide panic instead of a request
error. The trait now returns Result<_, ProxyError>; Claude/Codex/Gemini
impls propagate ProxyError::AuthError so the client sees a 401 with the
underlying parse error instead of a crash. Adds a regression test that
pastes a CRLF-containing key and asserts AuthError.
2026-05-13 23:12:00 +08:00
Jason aa5e58d060 fix(usage): correct cache cost semantics and silence pricing warn storm
- Split CostCalculator into per-app cache semantics: Anthropic's
  input_tokens is already fresh input, while Codex/Gemini include
  cached tokens in their prompt count. The old shared formula
  double-subtracted cache_read for Claude, under-billing input cost.
- Backfill now reads cost_multiplier from the per-log snapshot column
  instead of re-querying providers.meta, so historical rows are no
  longer rewritten with the current multiplier.
- Move the "pricing not found" warn out of find_model_pricing_row;
  emit it only when a brand new log is written, and skip placeholder
  models (unknown / empty / null / none) entirely.
- Broaden model id normalization: strip namespace prefixes
  (anthropic./openai./global./bedrock.), bedrock-style -vN suffixes,
  reasoning effort suffixes (-low/-medium/-high/-xhigh/-minimal),
  Claude Desktop's claude-<non-anthropic> wrapper, dot-to-dash for
  Claude, and try a LIKE prefix match for Claude short route ids
  (e.g. claude-haiku-4-5 -> claude-haiku-4-5-20251001).
- Fall back to request_model when the stored model is missing, so
  early Codex session rows with model=unknown can still be priced.
2026-05-13 17:51:35 +08:00
Jason 4b57f7e113 feat(claude-code): role-based model mapping with display names and 1M flag
- Replace the four flat env inputs with a Sonnet/Opus/Haiku role table.
  Each row exposes ANTHROPIC_DEFAULT_*_MODEL plus a new display name
  field ANTHROPIC_DEFAULT_*_MODEL_NAME, and Sonnet/Opus gain a
  "Declare 1M" checkbox that toggles the [1M] suffix.
- Strip the [1M] context-capability marker before forwarding non-Copilot
  requests upstream. Copilot keeps its existing [1m]->-1m normalization.
- Claude Desktop import now consumes ANTHROPIC_DEFAULT_*_MODEL_NAME as
  label_override, closing the Claude Code -> Claude Desktop displayName
  pipeline; add_route's merge logic is shared between hashmap branches.
- Unify the [1M] marker as ONE_M_CONTEXT_MARKER across
  claude_desktop_config and proxy::model_mapper; rename the strip
  helper to strip_one_m_suffix_for_upstream.
- Collapse useModelState's seven duplicated useState initializers and
  the useEffect parse block into a single parseModelsFromConfig call.
- Add tests/hooks/useModelState.test.tsx and a Claude Desktop import
  test covering Kimi K2 -> label_override. i18n (en/ja/zh) updated.
2026-05-13 17:06:05 +08:00
Jason 4b384dfe55 perf(proxy): trim per-request hot-path work and db wait
- Guard debug body serialization with `log::log_enabled!`; previously
  serialized the filtered body to a throwaway String on every forward,
  even with debug logging off.
- Skip SSE parse + UTF-8 buffer loop when no usage collector and debug
  is off; the per-chunk `serde_json::from_str::<Value>` ran even in
  pure passthrough mode.
- Add cheap per-app SSE event pre-filter (string `contains`) so usage
  collectors only parse events that could contain usage (e.g. Claude
  `message_start` / `message_delta`).
- Skip non-streaming response body JSON parse when usage logging is
  disabled.
- Move `ProviderRouter::record_result` off the success response path
  via `tokio::spawn` for non-HalfOpen state; that call internally does
  `get_proxy_config_for_app` + `update_provider_health`, two SQLite
  ops that previously blocked TTFB.

Also: dedupe `usage_logging_enabled` (was duplicated in handlers.rs)
and merge `SseUsageCollector::{new, new_filtered}` into a single
constructor that takes `Option<StreamUsageEventFilter>`.
2026-05-11 15:25:48 +08:00
Jason 00a789e7a3 fix(proxy): improve cache hit rate for Codex/Responses requests
prompt_cache_key was falling back to provider.id when the client did not
supply a session, which collapsed every conversation onto a single key
and defeated upstream prefix caching. Only emit the key when a real
client-provided session/thread identity is available; otherwise let the
upstream use its default matching behaviour.

Additional fixes that affect cache stability:
- Canonicalise (sort) JSON keys in outgoing request bodies and in
  tool_call arguments / tool_result content so semantically identical
  requests produce identical byte sequences for upstream prefix caches.
- Exempt JSON Schema property maps (properties, patternProperties,
  definitions, \$defs) from the underscore-prefix filter so user-defined
  schema keys like _id and _meta survive.
- Add a [CacheTrace] debug log with stable hashes for instructions,
  tools, input and include to help diagnose cache misses.
- Thread session_id into the usage logger for request correlation.
2026-05-11 15:25:48 +08:00
Kwensiu aec055a1d1 fix(proxy): drop empty pages from Read tool input (#2472)
* fix(proxy): drop empty pages from Read tool input

* fix(proxy): preserve Read args across duplicate tool starts
2026-05-11 11:32:52 +08:00
Jason 292c117509 chore(backend): satisfy cargo fmt and clippy --all-targets
- Apply rustfmt diffs in claude_desktop_config.rs
- Allow needless_return on current_platform_paths (cfg-mirrored arms)
- Allow too_many_arguments on RequestForwarder::forward
- Replace `let mut + reassign` with struct literals in tests
  (settings, backup, provider, response_processor)
- Use Path::new instead of PathBuf::from to fix cmp_owned in misc tests
- Replace 3.14 with 3.5 in config test to avoid approx_constant lint
2026-05-09 09:04:01 +08:00
Jason 8b3ad9caf9 feat(claude-desktop): add 3P provider switching with proxy gateway
Adds a new ClaudeDesktop AppType that writes Claude Desktop's third-party
inference profile under configLibrary/, sharing _meta.json with other
launchers (Ollama-compatible) so cc-switch can coexist with them.

Two switch modes:
- direct: provider already exposes claude-* / anthropic/claude-* model
  ids on Anthropic Messages, Claude Desktop connects to it directly.
- proxy: cc-switch's local proxy acts as the inference gateway,
  presenting only claude-* route names to Claude Desktop and mapping
  them to real upstream models. Required after Anthropic restricted
  Claude Desktop to claude-family ids.

Backend:
- New module claude_desktop_config with snapshot/rollback, official seed
  bypass, /claude-desktop/v1/{models,messages} routes, and a single
  source of truth for default proxy routes.
- Gateway token persisted in SQLite, validated on every proxied request.
- get_claude_desktop_status surfaces drift signals (stale models,
  missing routes, proxy stopped, base URL mismatch, missing token).

Frontend:
- Slim ClaudeDesktopProviderForm independent from ProviderForm,
  controlled by a top-level appId guard.
- ProviderList banner consumes the status query (5s polling) and
  renders actionable diagnostics.
- ClaudeDesktopRouteToggle in the header to start/stop the local
  gateway without touching takeover state.
- Three-locale i18n synchronised.
2026-05-08 22:34:46 +08:00
Jason e15bfbfe7a fix(proxy): reuse pooled HTTPS connections for non-Anthropic backends
The hyper raw-write path preserves original header casing but rebuilds
TCP+TLS on every request — there is no connection pool — which was the
root cause of slow reverse-proxy throughput.

Only Anthropic-native requests actually need exact header-case
preservation. Route OpenAI/Copilot/Codex/Gemini/codex_oauth requests
through the pooled reqwest client (pool_max_idle_per_host=10,
tcp_keepalive=60s) instead, so warm connections get reused.

Streaming requests get a precise first-byte timeout via
tokio::time::timeout around reqwest's send() (which resolves on
response headers), with the body phase handed off to response_processor.
The streaming-detection helper now also covers Gemini SSE endpoints
and Accept: text/event-stream, not just body.stream.
2026-05-08 22:34:46 +08:00
bling-yshs f5fbcd0493 feat: return reasoning_content with tool_calls for DeepSeek models (#2543)
* feat: return reasoning_content with tool_calls for DeepSeek models

* fix: correct reasoning_content handling for DeepSeek tool_calls

* test: cover DeepSeek reasoning content round trip

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-05-07 23:03:07 +08:00
Probe 72ab8a5cfd fix(proxy): include zero usage in final message delta (#2485) 2026-05-02 17:27:52 +08:00
Jason bdc4c1e8b8 fix(proxy): derive Claude auth strategy from ANTHROPIC env var name
Anthropic SDK assigns distinct semantics to the two env vars:

- ANTHROPIC_API_KEY    -> x-api-key
- ANTHROPIC_AUTH_TOKEN -> Authorization: Bearer

The Claude adapter previously collapsed both into AuthStrategy::Anthropic
and then emitted Authorization: Bearer regardless, breaking strict
Anthropic-protocol endpoints (Anthropic official, Cloudflare AI Gateway,
OpenCode Go, DashScope) and silently overriding the user's intended auth
scheme.

- claude::extract_auth: infer strategy from env var name
  (ANTHROPIC_AUTH_TOKEN -> ClaudeAuth, ANTHROPIC_API_KEY -> Anthropic),
  matching the precedence already used by extract_key.
- claude::get_auth_headers: split the Anthropic arm so it emits
  x-api-key, while ClaudeAuth and Bearer continue to use Bearer.
- stream_check: reuse ClaudeAdapter::get_auth_headers as the single
  source of truth, replacing the prior "always Bearer + maybe x-api-key"
  double injection that produced auth conflicts and false-negative
  health checks.
- Cover each strategy -> header mapping and env-var precedence with
  new unit tests in claude.rs.

Refs #2368, #2380
2026-05-01 23:07:46 +08:00
Jason 35bce24633 fix(proxy): strip leading billing header from system content (#2350)
Claude Code injects a dynamic `x-anthropic-billing-header` line at the
start of `system` content. Its rotating `cch=` token was forwarded into
OpenAI Responses `instructions` and Chat system messages, which broke
upstream prefix prompt cache reuse — a stable ~95k-token prefix was
getting re-charged on every request.

Strip only the leading occurrence in both anthropic_to_openai and
anthropic_to_responses; later occurrences are preserved so user-authored
prompt text containing the same string is not lost.
2026-05-01 23:07:46 +08:00
xpfo 295dd9a944 fix(proxy): preserve Vertex AI full URLs (#2415) 2026-05-01 17:46:10 +08:00
magucas 693c36a12a fix(dashscope): enhance usage parsing robustness to prevent VSCode cr… (#2425)
* fix(dashscope): enhance usage parsing robustness to prevent VSCode crashes

Enhanced build_anthropic_usage_from_responses() to handle null, missing, empty,
and partial usage fields gracefully. This prevents VSCode Extension crashes with
"Cannot read properties of null (reading 'output_tokens')" when connecting to
DashScope (Alibaba Cloud Bailian) models.

Changes:
- Added defensive null checks and empty object detection
- Implemented OpenAI field name fallbacks (prompt_tokens/completion_tokens)
- Added comprehensive logging for malformed usage scenarios
- Fixed streaming SSE event handlers with null-safe usage access
- Preserved cache token fields even when input/output tokens are missing

This ensures the proxy never crashes on malformed Responses API usage objects,
returning valid Anthropic-compatible usage structures (input_tokens/output_tokens)
in all cases.

* fix(proxy): tighten Responses API usage fix per review

- Drop redundant fallback in streaming.rs Chat Completions path; the
  existing if-let-Some guard already prevents usage:null, so the extra
  layer was dead code and caused a fmt-breaking indentation issue.
- Demote partial-usage warn to debug. Streaming chunks legitimately
  arrive with partial token counts and the warn-level log was noisy.
- Rewrite CHANGELOG entry: reference #2422, broaden scope from
  DashScope-only to all api_format=openai_responses users (Codex OAuth
  is the strongest signal; DashScope compatible-mode/v1/responses is
  the original report).
- cargo fmt to clear 12 formatting differences vs main.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-04-30 22:37:54 +08:00
Jason 2ee7cb4101 fix(usage): prevent double-counting between proxy and session-log sources
Proxy writes and session-log sync wrote to proxy_request_logs with
mismatched request_ids: only Claude on a native Anthropic backend used the
shared `session:{message_id}` key. Codex/Gemini and Claude-through-OpenAI
providers always produced distinct ids, so primary-key dedup never fired
and every real request was recorded twice.

Adds a 7-dim fingerprint dedup (app_type, 4 token counts, 2xx status,
model with case-insensitive match, ±10min window) wired into three layers:

- Write path: should_skip_session_insert() blocks duplicate session rows
  before INSERT, unifying the previously-divergent Claude/Codex/Gemini
  paths through a single DedupKey-based helper.
- Read path: effective_usage_log_filter() excludes already-covered session
  rows from every aggregation query.
- Rollup path: same filter applied so usage_daily_rollups never absorbs
  duplicates.

Also adds a covering index (idx_request_logs_dedup_lookup) so the EXISTS
subquery stays index-only, and a transform.rs regression test that pins
openai_to_anthropic id preservation - the missing piece that lets
Claude+OpenAI-compatible providers reuse the session: id scheme.
2026-04-29 22:41:46 +08:00
codeasier 21e2d68d76 fix(proxy): preserve scoped reasoning_content for tool calls (#2367)
- Preserve `reasoning_content` for Kimi/Moonshot OpenAI Chat compatibility paths.
- Keep generic OpenAI-compatible requests free of non-standard `reasoning_content` fields.
- Continue skipping thinking-only assistant messages.
- Add regressions for generic skip and Kimi/Moonshot preservation behavior.
2026-04-28 17:08:59 +08:00
codeasier 6441bc5c01 fix(proxy): dedupe streaming message_delta (#2366)
- Deduplicate repeated upstream `finish_reason` chunks so only one Anthropic `message_delta` is emitted.
- Preserve late `choices: []` usage-only chunks before sending the final `message_delta`.
- Keep stream error paths from emitting successful terminal events.
- Add regressions for duplicate finish reasons, usage-only chunks, missing `[DONE]`, and truncated streams.
2026-04-28 17:08:43 +08:00
Jason fcd83ee30d fix(copilot): resolve Claude model IDs against live /models list
Copilot upstream returns model_not_supported when the client sends
dash-form Claude IDs (claude-sonnet-4-6, claude-sonnet-4-6[1m]) while
/models only accepts dot form (claude-sonnet-4.6, -1m suffix).

- Add copilot_model_map: syntax normalize (dash->dot, [1m]->-1m) plus
  live /models exact match and family-version fallback, reusing the
  existing 5 min auth cache. Returns None when the whole family is
  absent so upstream surfaces an explicit error instead of silently
  switching families.
- Wire into forwarder Copilot hook; runs before anthropic_to_openai
  conversion.
- Default Opus slot in the Copilot preset maps to Sonnet 4.6: Pro
  dropped all Opus on 2026-04-20 and Pro+ bills Opus 4.7 at 7.5x.
  Users who want real Opus can switch manually in the UI.

Refs: https://github.com/farion1231/cc-switch/issues/2016
2026-04-24 22:58:58 +08:00
涂瑜 dc04165f18 feat(tray): show cached provider usage in the system tray menu (#2184)
* feat: add Rust-side write-through usage cache

Introduce an in-memory UsageCache on AppState that the existing usage
query commands populate on success. The cache is read-only to the rest
of the app today; the next commit consumes it from the tray menu.

- New services::usage_cache module with split maps: subscription keyed
  by AppType, script keyed by (AppType, provider_id).
- AppType gains Eq + Hash so it can be used as a HashMap key.
- commands::subscription::get_subscription_quota now takes State<AppState>
  and writes through on success (signature change is invisible to the
  frontend — Tauri injects State automatically).
- commands::provider::queryProviderUsage body extracted into an inner
  async fn; the public command wraps it with write-through, covering
  Copilot, coding-plan, balance, and generic script paths uniformly.

Cache is in-memory only; auto-query interval and the upcoming tray
refresh action rebuild it after restarts.

* feat(tray): surface cached usage in the system tray menu

Read UsageCache populated by the previous commit and render it in three
places, scoped to whatever TRAY_SECTIONS covers (Claude/Codex/Gemini):

1. Inline suffix on each provider submenu item
   "AnyProvider  · 🟢 5h 18% / 7d 23%"
2. Disabled summary row per visible app under "Show Main"
   "Claude · Anthropic Official · 🟢 5h 18% / 7d 23%"
3. "Refresh all usage" menu item that triggers get_subscription_quota +
   queryProviderUsage for every applicable provider, then rebuilds the
   tray menu via the existing refresh_tray_menu path.

Color encoding uses emoji (🟢 <70% / 🟠 70-89% / 🔴 ≥90%) since Tauri 2
tray labels are plain text. Missing cache entry leaves the label
unchanged — tray never issues network requests when opened. Three new
i18n-ready strings live in TrayTexts (en/zh/ja), following the existing
pattern for tray text.

Closes #2178.

* feat(usage): bridge tray UsageCache writes to frontend React Query

Why: tray hover triggers backend-only refresh that wrote to UsageCache but
never notified the frontend, leaving main UI stale while tray showed fresh
numbers. Emit a payload-carrying event after each cache write so React Query
can setQueryData directly, keeping both views in sync without duplicate fetches.

* fix(tray): skip hidden apps on hover refresh and drop stale disabled-script cache

Address P2 findings from automated review on #2184:

1. refresh_all_usage_in_tray now filters TRAY_SECTIONS by settings.visible_apps
   before scheduling subscription/script queries, matching create_tray_menu and
   preventing wasted external API calls (and rate-limit/auth-error log noise)
   for apps the user has hidden.

2. format_usage_suffix only trusts the script cache when provider.meta.usage_script
   is still enabled; when a script is disabled/removed the cached suffix is now
   invalidated so the tray label no longer shows stale data indefinitely.

* refactor: consolidate codex provider helpers and fix test semantics

- Add Provider::is_codex_oauth() and Provider::codex_fast_mode_enabled()
  to eliminate duplicated meta extraction in claude.rs and stream_check.rs
- Fix non-codex-oauth tests to pass codex_fast_mode=false (was true, harmless
  but semantically misleading)
- Remove redundant is_dir() guard after resolve_skill_source_dir already
  guarantees the returned path is a directory

* style: apply cargo fmt

* fix(tray): reflect failed refreshes in cache and support Gemini flash-lite

Follow-up to the tray usage-display feature addressing review feedback:

- Write snapshots for both Ok(success:false) and Err paths in
  queryProviderUsage / get_subscription_quota so stale success data
  no longer persists across failed refreshes; the original Err is
  still returned to the frontend onError handler.
- Include gemini_flash_lite tier in the tray summary with label "l".
  Matches the frontend SubscriptionQuotaFooter and keeps the worst
  emoji correct when lite is the highest utilization.
- Add TIER_GEMINI_PRO / _FLASH / _FLASH_LITE constants in
  services/subscription.rs and reuse them in classify_gemini_model
  and sort_order.
- Extract Provider::has_usage_script_enabled() to remove the
  duplicated meta.usage_script chain at two call sites.
- Use db.get_provider_by_id in refresh_all_usage_in_tray instead of
  materialising the full provider map, and parallelise subscription
  and script futures via futures::future::join.
- Narrow refresh_all_usage_in_tray to each section's effective
  current provider (script if enabled, else subscription when the
  provider is official). Hover refreshes now issue at most
  TRAY_SECTIONS.len() outbound requests.
- Add 10 unit tests in tray::tests covering Claude/Codex h/w dispatch,
  Gemini p/f/l dispatch (including lite-only and lite-worst cases),
  and success/failure guards.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-04-23 16:17:15 +08:00
Jesus Díaz Rivas 10e0772d8c feat: Add Codex OAuth FAST mode toggle (#2210)
* Add Codex OAuth FAST mode toggle

* fix(codex-oauth): default FAST mode to off to avoid surprise quota burn

service_tier="priority" consumes ChatGPT subscription quota at a higher
rate. Users must now opt in explicitly rather than inherit FAST mode
silently when this feature ships.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-04-23 12:05:17 +08:00
lif 444c123ad0 [codex] Stabilize Codex OAuth cache routing (#2218)
* Stabilize Codex OAuth cache routing

Codex OAuth-backed Claude proxy requests now reuse a client-provided session identity for prompt cache routing and send Codex-like session headers when that identity exists. Generated proxy UUIDs are intentionally excluded so they do not fragment cache locality.\n\nThe same path exposed two runtime issues during validation: rustls needed an explicit process crypto provider, and Codex OAuth can return Responses SSE even when the original Claude request is non-streaming. Those are handled so cache-routed requests can complete instead of panicking or being parsed as JSON.\n\nConstraint: Official Codex uses conversation identity and Responses session headers for prompt cache routing.\nRejected: Always use generated proxy session IDs | generated IDs change per request and reduce cache reuse.\nConfidence: medium\nScope-risk: moderate\nDirective: Do not remove the client-provided-session guard unless generated session IDs become stable per conversation.\nTested: cargo test codex_oauth\nTested: Local dev app health check on 127.0.0.1:15721\nTested: Local proxy logs showed cache_read_tokens after restart\nNot-tested: Full cargo test without local cc-switch port conflict\nRelated: #2217

* feat(proxy): aggregate forced Codex OAuth SSE into JSON for non-streaming clients

Narrow override on top of #2235's streaming fallback.

Codex OAuth always forces upstream openai_responses into SSE, even
when the original Claude request is stream:false. #2235 handles this
by routing such responses through the streaming transform so the
client receives text/event-stream — that avoids the 422 that JSON
parsing would produce, and it also protects any other provider that
unexpectedly returns SSE (the response.is_sse() guard).

But for Claude SDK callers that sent stream:false, returning SSE
still violates the Anthropic non-streaming contract. This commit
adds an override on exactly one combination — non-streaming client
+ codex_oauth + openai_responses — to aggregate the upstream
Responses SSE into a synthetic Responses JSON and then run the
regular responses_to_anthropic non-streaming transform. All other
paths, including the generic response.is_sse() fallback, remain
on the streaming path from #2235.

The aggregator reuses proxy::sse::take_sse_block / strip_sse_field,
which support both \n\n and \r\n\r\n delimiters; a hand-rolled
split("\n\n") would silently fail on real HTTPS upstreams.

Tests cover the happy path, CRLF delimiters, response.failed
errors, and the missing response.completed defensive branch.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-04-23 11:15:01 +08:00
xpfo 7ddf7ffd66 fix(proxy): handle Codex OAuth responses as streaming (#2235) 2026-04-23 08:54:31 +08:00
Jason 63aa310576 feat(copilot): strip thinking blocks before forwarding to save premium quota
Copilot routes through OpenAI-compatible endpoints that reject Anthropic's
thinking and redacted_thinking blocks. Previously the request would fail
upstream, burning one premium interaction, and only then trigger
thinking_rectifier to retry. This adds a proactive strip_thinking_blocks
pass in the Copilot optimization pipeline (step 3.5, after tool_result
merging). Signature fields and top-level thinking are left alone — those
are the reactive rectifier's job on the error path.

Also fixes a default-value inconsistency where CopilotOptimizerConfig's
Default impl used "gpt-4o-mini" while the serde default function returned
"gpt-5-mini" (aligned to gpt-5-mini, matching the reference implementation).

Aligned with yuegongzi/copilot-api's /v1/messages handler behavior.
2026-04-21 11:57:06 +08:00