Compare commits

..

82 Commits

Author SHA1 Message Date
YoVinchen 60e0263cc0 fix(config): update StepFun API endpoint domain from .ai to .com 2026-04-01 16:45:59 +08:00
Bengbengbalabalabeng 72f3610ac0 docs(readme): use dynamic badges (#1806) 2026-04-01 16:09:19 +08:00
Jason 854266e3c4 docs: add ChefShop AI sponsor to all README versions
Add ChefShop AI as a new sponsor with logo and descriptions in Chinese, English, and Japanese README files.
2026-03-31 23:07:51 +08:00
Jason af8f907467 fix(proxy): serialize per-app provider switches to prevent state corruption
Concurrent failover switches for the same app could cause is_current,
local settings, and Live backup to point at different providers.

- Add SwitchLockManager with per-app mutexes (different apps still parallel)
- Unify scattered switch logic into ProxyService::hot_switch_provider
- Fix TOCTOU in set_current_provider via mutate_settings
- Add logical_target_changed to skip redundant UI refreshes
- Add tests for serialization and restore-waits-for-switch scenarios
2026-03-31 22:46:42 +08:00
Dex Miller 6a083cdd1c fix(omo): adapt to oh-my-openagent rename with backward compatibility (#1746)
* fix(omo): adapt to oh-my-openagent rename with backward compatibility

Closes https://github.com/farion1231/cc-switch/issues/1733

* fix(omo): prioritize oh-my-openagent config over legacy oh-my-opencode
2026-03-31 16:32:49 +08:00
Dex Miller c68476d0ea fix(provider): show persistent config highlight for additive-mode providers (#1747)
* fix(provider): show persistent config highlight for additive-mode providers

Closes https://github.com/farion1231/cc-switch/issues/1692

* fix(provider): limit persistent config highlight to opencode only
2026-03-31 16:21:04 +08:00
Dex Miller 96a0a37331 feat(terminal): add directory picker before launching Claude terminal (#1752)
* Add directory picker before launching Claude terminal

* fix(terminal): preserve cwd path and strip Windows verbatim prefix

- Stop trimming non-empty paths so directories with leading/trailing
  spaces on Unix are handled correctly
- Strip \\?\ extended-length prefix from canonicalized paths on Windows
  to prevent batch script cd failures

* fix(terminal): restore UNC paths when stripping Windows verbatim prefix

Handle \\?\UNC\server\share form separately from regular \\?\ prefix,
converting it back to \\server\share so network/WSL directory paths
remain valid in batch cd commands.

* fix(terminal): use pushd for UNC paths in Windows batch launcher

`cmd.exe` cannot set a UNC path (e.g. `\\wsl$\...`) as the current
directory via `cd /d`; it errors with "CMD does not support UNC paths
as current directories". Switch to `pushd` which temporarily maps the
UNC share to a drive letter.

Rename `build_windows_cd_command` → `build_windows_cwd_command` to
reflect the broader semantics. Extract `build_windows_cwd_command_str`
and `is_windows_unc_path` helpers for testability, and add unit tests
covering drive paths, UNC paths, and batch metacharacter escaping.

Also fix minor style issues: sort mod declarations alphabetically,
add missing EOF newline in lightweight.rs, add explicit type annotation
in streaming_responses test, and reformat tray menu builder chain.
2026-03-31 15:10:08 +08:00
Alexlangl 8e2ffbc845 feat: add bulk delete for session manager (#1693)
* feat: add bulk delete for session manager

* fix: address batch delete review issues

* fix: keep session list in sync after batch delete
2026-03-30 22:15:57 +08:00
Alexlangl 4f7ea76347 fix: preserve WebDAV password display and validate MKCOL 405 (#1685)
* fix: preserve WebDAV password display and validate MKCOL 405

* fix: scope WebDAV password preservation to post-save refresh
2026-03-30 22:13:21 +08:00
Dex Miller 67e074c0a7 refactor(proxy): transparent header forwarding via hyper client (#1714)
* style(frontend): reformat provider forms, constants and hooks

Apply prettier formatting across 5 frontend files. No logic changes.

Changed files:
- AddProviderDialog.tsx: reformat generic type annotation and callback
- ClaudeFormFields.tsx: consolidate multi-line useState and Collapsible props
- CodexConfigSections.tsx: expand single-line React imports to multi-line,
  collapse removeCodexTopLevelField() call
- constants.ts: merge TemplateType into single line
- useSkills.ts: expand single-line TanStack Query imports to multi-line,
  reformat uninstallSkill mutationFn chain

* deps(proxy): add hyper ecosystem crates and manual decompression libs

reqwest internally normalizes all header names to lowercase and does not
preserve insertion order, causing proxied requests to differ from the
original client requests. To achieve transparent header forwarding with
original casing and order, introduce lower-level hyper HTTP client libs.

New dependencies:
- hyper-util 0.1: TokioExecutor + legacy Client with
  preserve_header_case support for HTTP/1.1
- hyper-rustls 0.27: rustls-based TLS connector for hyper
- http 1 / http-body 1 / http-body-util 0.1: HTTP type crates for
  hyper 1.x request/response construction
- flate2 1: manual gzip/deflate decompression (replaces reqwest auto)
- brotli 7: manual brotli decompression

Changed dependencies:
- serde_json: enable preserve_order feature to keep JSON field order
- reqwest: drop gzip feature to prevent reqwest from overriding the
  client's original accept-encoding header

* refactor(proxy): use hyper client for header-case preserving forwarding

Previously the proxy used reqwest for all upstream requests. reqwest
normalizes header names to lowercase and reorders them internally,
making proxied requests distinguishable from direct CLI requests.
Some upstream providers are sensitive to these differences.

This commit replaces reqwest with a hyper-based HTTP client on the
default (non-proxy) path, achieving wire-level header fidelity:

Server layer (server.rs):
- Replace axum::serve with a manual hyper HTTP/1.1 accept loop
- Enable preserve_header_case(true) so incoming header casing is
  captured in a HeaderCaseMap extension on each request
- Bridge hyper requests to axum Router via tower::Service

New hyper client module (hyper_client.rs):
- Lazy-initialized hyper-util Client with preserve_header_case
- ProxyResponse enum wrapping both hyper::Response and reqwest::Response
  behind a unified interface (status, headers, bytes, bytes_stream)
- send_request() builds requests with ordered HeaderMap + case map

Request handlers (handlers.rs):
- Switch from (HeaderMap, Json<Value>) extractors to raw
  axum::extract::Request to preserve Extensions (containing the
  HeaderCaseMap from the accept loop)
- Pass extensions through the forwarding chain

Forwarder (forwarder.rs):
- Remove HEADER_BLACKLIST array; replace with ordered header iteration
  that preserves original header sequence and casing
- Build ordered_headers by iterating client headers, skipping only
  auth/host/content-length, and inserting auth headers at the original
  authorization position to maintain order
- Handle anthropic-beta (ensure claude-code-20250219 tag) and
  anthropic-version (passthrough or default) inline during iteration
- Remove should_force_identity_encoding() — accept-encoding is now
  transparently forwarded to upstream
- Use hyper client by default; fall back to reqwest only when an
  HTTP/SOCKS5 proxy tunnel is configured

Provider adapters (adapter.rs, claude.rs, codex.rs, gemini.rs):
- Replace add_auth_headers(RequestBuilder) -> RequestBuilder with
  get_auth_headers(AuthInfo) -> Vec<(HeaderName, HeaderValue)>
- Adapters now return header pairs instead of mutating a reqwest builder
- Claude adapter: merge Anthropic/ClaudeAuth/Bearer into single branch;
  move Copilot fingerprint headers into get_auth_headers

Response processing (response_processor.rs):
- Add manual decompression (gzip/deflate/brotli via flate2 + brotli)
  for non-streaming responses, since reqwest auto-decompression is now
  disabled to allow accept-encoding passthrough
- Add compressed-SSE warning log for streaming responses
- Accept ProxyResponse instead of reqwest::Response

HTTP client (http_client.rs):
- Disable reqwest auto-decompression (.no_gzip/.no_brotli/.no_deflate)
  on both global and per-provider clients

Streaming adapters (streaming.rs, streaming_responses.rs):
- Generalize stream error type from reqwest::Error to generic E: Error

Misc:
- log_codes.rs: add SRV-005 (ACCEPT_ERR) and SRV-006 (CONN_ERR)
- stream_check.rs: reformat copilot header lines
- transform.rs: fix trailing whitespace alignment

* fix(lint): resolve 35 clippy warnings across Rust codebase

Fix all clippy warnings reported by `cargo clippy --lib`:

- codex_config.rs: fix doc_overindented_list_items (3 spaces -> 2)
- commands/copilot.rs: inline format args in 2 log::error! calls
- commands/provider.rs: inline format args in 3 map_err closures
- proxy/hyper_client.rs: inline format arg in log::debug! call
- proxy/providers/copilot_auth.rs: inline format args in 16 locations
  (log macros, format! in headers, error constructors)
- proxy/thinking_optimizer.rs: inline format args in 2 log::info! calls
- services/skill.rs: inline format args in log::debug! call
- services/webdav_sync.rs: inline format args in 6 format! calls
  (version compat messages, download limit messages)
- services/webdav_sync/archive.rs: inline format args in 2 format! calls
- session_manager/providers/opencode.rs: inline format args in
  source_path format!

All fixes use the clippy::uninlined_format_args suggestion pattern:
  format!("msg: {}", var)  ->  format!("msg: {var}")

* deps(proxy): add raw HTTP write and native TLS cert dependencies

Add crates required for the raw TCP/TLS write path that bypasses
hyper's header encoder to preserve original header name casing:

- httparse: parse raw TCP peek bytes to capture header casings
- tokio-rustls + rustls: direct TLS connections for raw write path
- webpki-roots: Mozilla CA bundle baseline
- rustls-native-certs: load system keychain CAs (trusts proxy MITM
  certificates from Clash, mitmproxy, etc.)

* fix(proxy): address code review feedback on response handling

Fixes from PR #1714 code review:

- Extract `read_decoded_body()` and `strip_entity_headers_for_rebuilt_body()`
  in response_processor to properly clean content-encoding/content-length
  headers after decompression
- Reuse `read_decoded_body()` in handlers.rs for Claude transform path,
  ensuring compressed responses are decoded before format conversion
- Make `build_proxy_url_from_config()` public so forwarder can pass proxy
  URL to the hyper raw write path
- Add `has_system_proxy_env()` utility with test coverage
- Add 50ms backoff after accept() failures in server.rs to prevent
  tight-loop CPU spin on transient socket errors

* feat(proxy): implement raw TCP/TLS write with HTTP CONNECT tunnel

Rewrite hyper_client with a two-tier strategy for header case preservation:

Primary path (raw write):
- Peek raw TCP bytes in server.rs to capture OriginalHeaderCases before
  hyper lowercases them
- Build raw HTTP/1.1 request bytes with exact original header name casing
- Write directly to TLS stream, then use WriteFilter to let hyper parse
  the response while discarding its duplicate request writes
- Support HTTP CONNECT tunneling through upstream proxies, so header case
  is preserved even when a proxy (Clash, V2Ray) is configured

Fallback path (hyper-util Client):
- Used when OriginalHeaderCases is empty or raw write fails
- Configured with title_case_headers(true) for best-effort casing

TLS improvements:
- Load native system certificates alongside webpki roots so proxy MITM
  CAs (installed in system keychain) are trusted through CONNECT tunnels

Key types added:
- OriginalHeaderCases: maps lowercase name → original wire-casing bytes
- WriteFilter<S>: AsyncRead+AsyncWrite wrapper that discards writes
- connect_via_proxy(): HTTP CONNECT tunnel establishment
- ExtensionDebugMarker: diagnostic marker for extension chain debugging

* refactor(proxy): route requests through hyper with proxy-aware forwarding

Rework forwarder request dispatch to always prefer the hyper raw write
path (header case preservation) over reqwest:

Request routing:
- HTTP/HTTPS proxy: hyper raw write through CONNECT tunnel (case preserved)
- SOCKS5 proxy: reqwest fallback (CONNECT not supported for SOCKS5)
- No proxy: hyper raw write direct connection

Header handling improvements:
- Replace host header in-place at original position instead of
  skip-and-append, preserving client's header ordering
- Preserve client's original accept-encoding for transparent passthrough;
  only force identity encoding when transform path needs decompression
- Add should_force_identity_encoding() to centralize the decision
- Remove hardcoded 'br, gzip, deflate' override that masked client values

Proxy URL resolution (priority order):
1. Provider-specific proxy config (if enabled)
2. Global proxy URL configured in CC Switch
3. Direct connection (no proxy)

* chore(proxy): remove dead code, redundant tests and debug scaffolding

- Inline should_force_identity_encoding() (was just `needs_transform`)
  and delete its 5 test cases
- Remove ExtensionDebugMarker diagnostic type
- Remove unused has_system_proxy_env() and its test
- Remove strip_entity_headers test
- Simplify hyper path: remove redundant is_socks_proxy ternary
- Update hyper_client module doc to reflect CONNECT tunnel support

* fix(proxy): block direct-connect fallback and complete CONNECT tunnel support

* feat(hooks): improve proxy requirement warnings with specific reasons

- Remove redundant OpenAI format hint toast messages
- Add detailed reason detection for proxy requirements (OpenAI Chat, OpenAI Responses, full URL mode)
- Update i18n files with new reason-specific keys

* style(*): format code with prettier

- Remove extra whitespace in http_client.rs
- Fix formatting issues in useProviderActions.ts

* fix(proxy): post-merge fixes for forward return type and clippy warnings

- Restore forward() return type to (ProxyResponse, Option<String>)
  to pass claude_api_format through to callers
- Inline format args in log::warn! macro (clippy::uninlined_format_args)
- Suppress too_many_arguments for check_claude_stream

* refactor(proxy): preserve original header wire order and add non-streaming body timeout

- Rewrite build_raw_request to emit headers in original
  client-sent sequence instead of hash-map order
- Remove unused OriginalHeaderCases::get_all method
- Add body_timeout to read_decoded_body to prevent
  requests hanging when upstream stalls after headers
2026-03-29 20:26:15 +08:00
ruokeqx b1c7fe5563 feat: add lightweight mode (#1739) 2026-03-29 18:35:23 +08:00
makoMakoGo 210bff96c2 fix(env): detect bun global bin paths in CLI scan (#1742) 2026-03-29 15:52:37 +08:00
makoMakoGo 3b33e6921b fix: correct opencode kimi-for-coding preset (#1738) 2026-03-29 10:31:23 +08:00
Zhou Mengze 8d5f72757e fix: 修复 Copilot 作为 Claude 时 OpenAI 模型的 Responses 分流 (#1735)
* fix: route copilot claude openai models to responses

* fix(i18n): add copilotProxyHint translation key for all locales

The copilotProxyHint message was using inline defaultValue with Chinese
text, which would show Chinese to English and Japanese users. Added
proper translation keys in zh/en/ja locale files and removed the
hardcoded defaultValue fallback.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-03-29 10:29:54 +08:00
Dex Miller 8cae7b7b73 feat(proxy): add full URL mode and refactor endpoint rewriting (#1561)
* feat(proxy): add full URL mode and refactor endpoint rewriting

- Add `isFullUrl` provider meta to treat base_url as complete API endpoint
- Remove hardcoded `?beta=true` from Claude adapter, pass through from client
- Refactor forwarder endpoint rewriting with proper query string handling
- Block provider switching when proxy is required but not running
- Add full URL toggle UI in endpoint field with i18n (zh/en/ja)

* refactor(proxy): remove beta query handling

* fix(proxy): strip beta query when rewriting Claude endpoints

* feat(codex): complete full URL support

* refactor(ui): refine full URL endpoint hint
2026-03-28 15:52:02 +08:00
tgbdhs eaf83f4fbe fix(proxy): parse SSE fields with optional spaces in streaming handlers (#1664)
* fix(proxy): handle SSE fields with or without spaces

* refactor(proxy): deduplicate SSE field parsing
2026-03-25 22:06:21 +08:00
Sky 90812e7f3a feat(opencode): add StepFun Step Plan provider preset (#1668)
Add StepFun Step Plan (阶跃星辰编程计划) as an OpenCode provider preset.

StepFun Step Plan is a subscription-based coding AI service that uses a
dedicated API endpoint separate from the standard StepFun provider.

- Base URL: https://api.stepfun.com/step_plan/v1
- Model: step-3.5-flash (196B MoE, optimized for agent and coding tasks)
- Category: cn_official
- Supports tool_call, reasoning, temperature control

Ref: https://platform.stepfun.com/docs/zh/step-plan/overview
Made-with: Cursor

Co-authored-by: sky-wang-salvation <sky-wang-salvation@users.noreply.github.com>
2026-03-25 21:22:05 +08:00
Jason 8c42ec48ef docs(release): add risk notice anchor links in v3.12.3 highlights section 2026-03-24 17:53:07 +08:00
Jason 761038f0ea docs(release): add Copilot reverse proxy risk notice to v3.12.3 release notes
Add risk disclaimer section in all three languages (EN/ZH/JA) warning
users about potential GitHub ToS violations, account suspension risks,
and no long-term availability guarantee for the Copilot reverse proxy.
2026-03-24 17:46:32 +08:00
Jason 155a226e6a chore(release): consolidate v3.12.3 release notes, changelog and test fixes
Merge previously unreleased v3.12.4 content into v3.12.3:
- CHANGELOG: combine [Unreleased] into [3.12.3], clear [Unreleased]
- Release notes (zh/en/ja): add Copilot proxy, macOS signing,
  Reasoning Effort, OpenCode SQLite, Codex 1M toggle, Disable
  Auto-Upgrade toggle, and contributor thanks
- Fix test mocks for skill backup/restore hooks
- Fix schema migration test missing providers table
- Fix TempHome to save/restore CC_SWITCH_TEST_HOME env var
2026-03-24 15:26:17 +08:00
Jason 10be929f33 feat(claude): add "Disable Auto-Upgrade" checkbox to provider config editor
Add a toggle for DISABLE_AUTOUPDATER env var in CommonConfigEditor,
following the same pattern as the existing Teammates mode toggle.
2026-03-24 15:17:09 +08:00
Jason 141010332b feat(dmg): use create-dmg for styled macOS DMG installer
Tauri's built-in DMG styling relies on AppleScript/Finder access which
silently fails on CI (tauri-apps/tauri#1731). Switch to create-dmg tool
which works on GitHub Actions macOS runners.

- Replace Tauri DMG with create-dmg: background image, icon positions,
  app-drop-link, codesign, hide-extension
- Regenerate background image at 2x Retina resolution (1320x800)
- Revert tauri.conf.json dmg config (ineffective on CI)
- Reorder steps: Prepare → Notarize DMG → Verify
- Notarize and Verify now use release-assets/ path for DMG
2026-03-24 10:33:18 +08:00
Jason 15989effb4 feat(dmg): customize macOS DMG installer appearance
- Add DMG background image with drag-to-install arrow guide
- Configure window size (660x400), app and Applications icon positions
- Center icons horizontally with visual arrow between them
2026-03-24 09:51:23 +08:00
Jason d4edf30747 fix(ci): add separate DMG notarization step and build retry for macOS
Tauri only notarizes the .app bundle, not the DMG container. This caused
stapler staple to fail with "Record not found" for the DMG.

- Add "Notarize macOS DMG" step using xcrun notarytool with retry logic
- Add retry logic (3 attempts) to macOS build step for transient network failures
- Add hdiutil verify before DMG notarization submission
2026-03-24 08:45:56 +08:00
Jason 44b6eacf87 feat(ci): add macOS code signing and Apple notarization to release workflow
- Import Developer ID Application certificate into temporary keychain
- Inject APPLE_SIGNING_IDENTITY/APPLE_ID/APPLE_PASSWORD/APPLE_TEAM_ID
  into Tauri build step for automatic signing and notarization
- Staple notarization tickets to both .app and .dmg (hard-fail)
- Add verification step: codesign --verify + spctl -a + stapler validate
  for both .app and .dmg, gating the release on success
- Collect .dmg alongside .tar.gz and .zip in release assets
- Clean up temporary keychain with original default restored
- Update release notes to recommend .dmg and note Apple notarization
- Remove all xattr workarounds and "unidentified developer" warnings
  from README, README_ZH, installation guides, and FAQ (EN/ZH/JA)
2026-03-23 22:43:41 +08:00
Jason 0a301a497c fix: prevent WebDAV password from being silently cleared by unrelated saves
Two components (ProviderList, UsageScriptModal) directly spread the full
settings object from the query into settingsApi.save(), which includes
webdavSync with an empty password (cleared by get_settings_for_frontend
for security). The backend merge_settings_for_save only preserved
existing WebDAV config when the incoming field was None, not when it was
present with an empty password.

Frontend fix: strip webdavSync before saving in both components, matching
the pattern already used by useSettings hook.

Backend defense-in-depth: merge_settings_for_save now backfills the
existing password when the incoming one is empty, preventing future
regressions from similar oversights.
2026-03-23 16:47:17 +08:00
TangZhiZzz 8aa6ec784b feat(skills): 优化技能安装/卸载的缓存更新策略 (#1573)
- 修改安装、卸载、导入、ZIP安装等操作的缓存更新逻辑,从invalidateQueries改为直接setQueryData
- 为已安装和可发现技能查询添加keepPreviousData和staleTime: Infinity配置
- 修复会话管理页面布局滚动问题,添加min-h-0防止内容溢出
2026-03-23 16:05:13 +08:00
BlueOcean bd3cfb7741 fix: parse tool_use/tool_result messages and add OpenCode SQLite backend (#1401)
* fix: parse tool_use/tool_result messages and add OpenCode SQLite backend

  - Claude: reclassify user messages containing tool_result as "tool" role
  - Codex: handle function_call and function_call_output payload types
  - Gemini: support array content and toolCalls extraction, filter info/error types
  - OpenCode: add SQLite session scan, load and delete alongside legacy JSON
  - utils: extend parse_timestamp_to_ms for integer timestamps, extract tool_use/tool_result in shared extract_text

* fix: address remaining issues from tool_use/tool_result parsing commit
  - Claude: fix role misclassification for mixed user+tool_result messages (any → all)
  - OpenCode: extract duplicate part text logic into extract_part_text()
  - OpenCode: add path validation for SQLite delete to prevent foreign DB access
  - OpenCode: wrap SQLite deletion in transaction for atomicity
  - openclaw_config: remove redundant as_deref() on Option<&str>
2026-03-22 22:02:35 +08:00
makoMakoGo 117dbf1386 docs(usage): clarify pricing model ID normalization rules (#1591) 2026-03-21 22:59:28 +08:00
Jianan 72f570b99e fix: change darkMode selector to use .dark class (#1596) 2026-03-21 22:55:21 +08:00
Jason 2296c41497 chore: update MiniMax partner description from M2.5 to M2.7 2026-03-21 09:18:53 +08:00
Jason fe3f9b60de feat(proxy): resolve reasoning_effort from explicit effort with budget fallback
Replace map_thinking_to_reasoning_effort() with resolve_reasoning_effort()
that uses a two-tier priority system:

1. Explicit output_config.effort: low/medium/high map 1:1, max → xhigh
2. Fallback: thinking.type + budget_tokens thresholds (<4k → low,
   4k-16k → medium, ≥16k → high, adaptive → high)

Both Chat Completions and Responses API paths share the same helper,
ensuring consistent mapping across all OpenAI-compatible endpoints.
2026-03-20 23:36:47 +08:00
Jason 3e78fe8305 chore: update MiniMax preset model from M2.5 to M2.7 2026-03-20 21:05:56 +08:00
Jason 6f170305b8 chore: update Claude 4.6 context window to 1M (GA)
Claude Opus 4.6 and Sonnet 4.6 1M context window is now GA and no
longer requires a beta header. Update contextWindow from 200k to 1M
for all OpenClaw/OpenCode presets (27 entries in OpenClaw, 1 in
OpenCode Bedrock). Also add claude-sonnet-4-6 model pricing seed.
2026-03-20 21:00:22 +08:00
Jason 552f7abee4 refactor(ui): remove duplicate OAuth tab from AddProviderDialog
- Remove AuthCenterPanel import and OAuth TabsContent
- Narrow activeTab type from three values to "app-specific" | "universal"
- Simplify footer by removing oauth branch, reducing to two-way conditional
- Change TabsList from grid-cols-3 to grid-cols-2
- OAuth authentication remains available in settings page and CopilotAuthSection
2026-03-20 15:23:18 +08:00
Jason fd2b232f1c chore: update Xiaomi MiMo preset model from mimo-v2-flash to mimo-v2-pro 2026-03-20 08:59:41 +08:00
Jason 82c75de51c fix(copilot): unify request fingerprint across all Copilot API calls
- Make header constants in copilot_auth.rs public, add COPILOT_INTEGRATION_ID
- Unify User-Agent across 4 internal methods (eliminate "CC-Switch" leakage) and version string
- claude.rs add_auth_headers now references shared constants, adds user-agent / api-version / openai-intent
- forwarder.rs filters all 6 fixed fingerprint headers for Copilot requests to prevent reqwest append duplication
- stream_check.rs health check pipeline aligned with new fingerprint
2026-03-19 17:29:04 +08:00
Zhou Mengze 8ccfbd36d6 feat(copilot): add GitHub Copilot reverse proxy support (#930)
* refactor(toolsearch): replace binary patch with ENABLE_TOOL_SEARCH env var toggle

- Remove toolsearch_patch.rs binary patching mechanism (~590 lines)
  - Delete `toolsearch_patch.rs` and `commands/toolsearch.rs`
  - Remove auto-patch startup logic and command registration from lib.rs
  - Remove `tool_search_bypass` field from settings.rs
  - Remove frontend settings ToggleRow, useSettings hook sync logic, and API methods
  - Clean up zh/en/ja i18n keys (notifications + settings)

- Add ENABLE_TOOL_SEARCH toggle to Claude provider form
  - Add checkbox in CommonConfigEditor.tsx (alongside teammates toggle)
  - When enabled, writes `"env": { "ENABLE_TOOL_SEARCH": "true" }`
  - When disabled, removes the key; takes effect on provider switch
  - Add zh/en/ja i18n key: `claudeConfig.enableToolSearch`

Claude Code 2.1.76+ natively supports this env var, eliminating the need for binary patching.

* feat(claude): add effortLevel high toggle to provider form

- Add "high-effort thinking" checkbox to Claude provider config form
- When checked, writes `"effortLevel": "high"`; when unchecked, removes the field
- Add zh/en/ja i18n translations

* refactor(claude): remove deprecated alwaysThinking toggle

- Claude Code now enables extended thinking by default; alwaysThinkingEnabled is a no-op
- Thinking control is now handled via effortLevel (added in prior commit)
- Remove state, switch case, and checkbox UI from CommonConfigEditor
- Clean up alwaysThinking i18n keys across zh/en/ja locales

* feat(opencode): add setCacheKey: true to all provider presets

- Add setCacheKey: true to options in all 33 regular presets
- Add setCacheKey: true to OPENCODE_DEFAULT_CONFIG for custom providers
- Exclude 2 OMO presets (Oh My OpenCode / Slim) which have their own config mechanism

Closes #1523

* fix(codex): resolve 1M context window toggle causing MCP editor flicker

- Add localValueRef to short-circuit duplicate CodeMirror updateListener callbacks,
  breaking the React state → CodeMirror → stale onChange → React state feedback loop
- Use localValueRef.current in handleContextWindowToggle and handleCompactLimitChange
  to avoid stale closure reads
- Change compact limit input from type="number" to type="text" with inputMode="numeric"
  to remove unnecessary spinner buttons

* feat(codex): add 1M context window toggle utilities and i18n keys

- Add extractCodexTopLevelInt, setCodexTopLevelInt, removeCodexTopLevelField
  TOML helpers in providerConfigUtils.ts
- Add i18n keys for contextWindow1M, autoCompactLimit in zh/en/ja locales

* feat(claude): collapse model mapping fields by default

- Wrap 5 model mapping inputs in a Collapsible, collapsed by default
- Auto-expand when any model value is present (including preset-filled)
- Show hint text when collapsed explaining most users need no config
- Add zh/en/ja i18n keys for toggle label and collapsed hint
- Use variant={null} to avoid ghost button hover style clash in dark mode

* feat(claude): merge advanced fields into single collapsible section

- Merge API format, auth field, and model mapping into a unified "Advanced Options" collapsible
- Extend smart-expand logic to detect non-default values across all advanced fields
- Preserve model mapping sub-header and hint with a separator line
- Update zh/en/ja i18n keys (advancedOptionsToggle, advancedOptionsHint, modelMappingLabel, modelMappingHint)

* feat(copilot): add GitHub Copilot reverse proxy support

Add GitHub Copilot as a Claude provider variant with OAuth device code
authentication and Anthropic ↔ OpenAI format transformation.

Backend:
- Add CopilotAuthManager for GitHub OAuth device code flow
- Implement Copilot token auto-refresh (60s before expiry)
- Persist GitHub token to ~/.cc-switch/copilot_auth.json
- Add ProviderType::GitHubCopilot and AuthStrategy::GitHubCopilot
- Modify forwarder to use /chat/completions for Copilot
- Add Copilot-specific headers (Editor-Version, Editor-Plugin-Version)

Frontend:
- Add CopilotAuthSection component for OAuth UI
- Add useCopilotAuth hook for OAuth state management
- Auto-copy user code to clipboard and open browser
- Use 8-second polling interval to avoid GitHub rate limits
- Skip API Key validation for Copilot providers
- Add GitHub Copilot preset with claude-sonnet-4 model

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

* fix(copilot): remove is_expired() calls from tests

Remove references to deleted is_expired() method in test code.
Only is_expiring_soon() is needed for token refresh logic.

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

* feat(copilot): add real-time model listing from Copilot API

- Add fetch_models() to CopilotAuthManager calling GET /models endpoint
- Add copilot_get_models Tauri command
- Add copilotGetModels() frontend API wrapper
- Modify ClaudeFormFields to show model dropdown for Copilot providers
  - Fetches available models on component mount when isCopilotPreset
  - Groups models by vendor (Anthropic, OpenAI, Google, etc.)
  - Input + dropdown button combo allows both manual entry and selection
  - Non-Copilot providers keep original plain Input behavior

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

* feat(copilot): add usage query integration

- Add Copilot usage API integration (fetch_usage method)
- Add copilot_get_usage Tauri command
- Add GitHub Copilot template in usage query modal
- Unify naming: copilot → github_copilot
- Add constants management (TEMPLATE_TYPES, PROVIDER_TYPES)
- Improve error handling with detailed error messages
- Add database migration (v5 → v6) for template type update
- Add i18n translations (zh, en, ja)
- Improve type safety with TemplateType
- Apply code formatting (cargo fmt, prettier)

* 修复github 登录和注销问题 ,模型选择问题

* feat(copilot): add multi-account support for GitHub Copilot

- Add multi-account storage structure with v1 to v2 migration
- Add per-account token caching and auto-refresh
- Add new Tauri commands for account management
- Integrate account selection in Proxy forwarder
- Add account selection UI in CopilotAuthSection
- Save githubAccountId to ProviderMeta
- Add i18n translations for multi-account features (zh/en/ja)

* 修复用量查询Reset字段出现多余字符

* refactor(auth-binding): introduce generic provider auth binding primitives

- add shared authBinding types in Rust and TypeScript while keeping githubAccountId as a compatibility field\n- resolve Copilot token, models, and usage through provider-bound account lookup instead of only the implicit default account\n- fix the Unix build regression in settings.rs by restoring std::io::Write for write_all()\n- remove the accidental .github ignore entry and drop leftover Copilot form debug logs\n- keep the first migration step non-breaking by writing both authBinding and the legacy githubAccountId field from the form

* refactor(auth-service): add managed auth command surface and explicit default account state

- introduce generic managed auth commands and frontend auth API wrappers for provider-scoped login, status, account listing, removal, logout, and default-account selection\n- store an explicit Copilot default_account_id instead of relying on HashMap iteration order, and use it consistently for fallback token/model/usage resolution\n- sort managed accounts deterministically and surface default-account state to the UI\n- refactor the Copilot form hook to wrap a generic useManagedAuth implementation while preserving the existing component contract\n- add default-account controls to the Copilot auth section and extend Copilot auth status serialization/tests for the new state

* feat(auth-center): add a dedicated settings entrypoint for managed OAuth accounts

- add an Auth Center tab to Settings so managed OAuth accounts are no longer hidden inside individual provider forms\n- introduce a first AuthCenterPanel that hosts GitHub Copilot account management as the initial managed auth provider\n- keep the provider form experience intact while establishing a global account-management surface for future providers such as OpenAI\n- validate that the new settings tab works cleanly with the generic managed auth hook and existing Copilot account controls

* feat(add-provider): expose managed OAuth sources alongside universal providers

- add an OAuth tab to the Add Provider flow so managed auth sources sit beside app-specific and universal providers\n- reuse the new Auth Center panel inside the dialog, keeping account management discoverable during provider creation\n- make the dialog footer adapt to the OAuth tab so account setup does not pretend to create a provider directly\n- align the add-provider UX with the new architecture where OAuth accounts are global assets and providers bind to them later

* fix(auth-reliability): harden managed auth persistence and refresh behavior

- replace direct Copilot auth store writes with private temp-file writes and atomic rename semantics, and document the local token storage limitation\n- add per-account refresh locks plus a double-check path so concurrent requests do not stampede GitHub token refresh\n- surface legacy migration failures through auth status, expose them in the UI, and add translated copy for the new account-state labels\n- stop writing the legacy githubAccountId field from the provider form while keeping compatibility reads in place\n- add logout error recovery and Copilot model-load toasts so auth failures are no longer silently swallowed

* refactor(copilot-detection): prefer provider type before URL fallbacks

- update forwarder endpoint rewriting to treat providerType as the primary GitHub Copilot signal\n- keep githubcopilot.com string matching only as a compatibility fallback for older provider records without providerType\n- reduce one more path where Copilot behavior depended purely on URL heuristics

* fix(copilot-auth): add cancel button to error state in CopilotAuthSection

- 错误状态下仅有"重试"按钮,用户无法退出(如不可恢复的 403 未订阅错误)
- 新增"取消"按钮,复用已有的 cancelAuth 逻辑重置为 idle 状态

* 修复打包后github账号头像显示异常

* 修复github copilot 来源的模型测试报错

* feat(copilot-preset): add default model presets for GitHub Copilot

- 补充 Copilot 预设的默认模型配置,用户选完预设即可直接使用
- ANTHROPIC_MODEL: claude-opus-4.6
- ANTHROPIC_DEFAULT_HAIKU_MODEL: claude-haiku-4.5
- ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4.6
- ANTHROPIC_DEFAULT_OPUS_MODEL: claude-opus-4.6

---------

Co-authored-by: Jason <farion1231@gmail.com>
Co-authored-by: 周梦泽 <mengze.zhou@dafeng-tech.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-17 23:57:58 +08:00
Jason 36bbdc36f5 chore(release): update release notes, changelog and macOS minimum version for v3.12.3
Update CHANGELOG.md with full v3.12.3 entry, create release notes in
three languages (en/zh/ja), bump macOS minimumSystemVersion from 10.15
to 12.0 (Monterey) to match actual runtime requirements, and update
README version badges and links.
2026-03-16 08:32:05 +08:00
Jason fc08a5d364 fix(ui): replace hardcoded height calc with flex-1 to eliminate bottom blank area
All content panels used h-[calc(100vh-8rem)] which didn't match the
actual available space (CONTENT_TOP_OFFSET is 92px on Mac, 64px on
Win/Linux, not 128px), causing a visible gap at the bottom.

Replace with flex-1 min-h-0 to let panels fill the remaining space
after <main> and any sibling elements (e.g. OpenClaw health banner).
2026-03-16 00:19:29 +08:00
Jason 333c9f277b feat(skills): add restore and delete for skill backups
Introduce list/restore/delete commands for skill backups created during
uninstall. Restore copies files back to SSOT, saves the DB record, and
syncs to the current app with rollback on failure. Delete removes the
backup directory after a confirmation dialog. ConfirmDialog gains a
configurable zIndex prop to support nested dialog stacking.
2026-03-16 00:00:31 +08:00
Jason 9336001746 feat(skills): auto-backup skill files before uninstall
Create a local backup under ~/.cc-switch/skill-backups/ before removing
skill directories. The backup includes all skill files and a meta.json
with original skill metadata. Old backups are pruned to keep at most 20.
The backup path is returned to the frontend and shown in the success
toast. Bump version to 3.12.3.
2026-03-15 23:26:50 +08:00
Jason 04254d6ffe fix(skills): add missing TooltipProvider in ImportSkillsDialog to prevent white screen
The AppToggleGroup component added in 7097a0d7 uses Radix UI Tooltip
which requires a TooltipProvider context. Without it, opening the
import dialog crashes with a runtime error and renders a blank page.
2026-03-15 23:02:54 +08:00
Jason 28afbea917 fix(proxy): enable gzip compression for non-streaming proxy requests
Non-streaming requests were forced to use `Accept-Encoding: identity`,
preventing upstream response compression and increasing bandwidth usage.

Now only streaming requests conservatively keep `identity` to avoid
decompression errors on interrupted SSE streams. Non-streaming requests
let reqwest auto-negotiate gzip and transparently decompress responses.
2026-03-15 22:01:57 +08:00
Jason 81897ac17e fix: revert incorrect o-series max_completion_tokens in Responses API path
Responses API uses max_output_tokens for all models including o-series.
The o-series max_completion_tokens fix should only apply to Chat Completions API.
2026-03-15 21:06:08 +08:00
Jason bb23ab918b fix: place OpenCode model variants at top level instead of inside options (#1317)
Split the expanded model panel into two editing areas:
- "Model Properties" for top-level fields (variants, cost, etc.)
- "SDK Options" for model.options fields (provider routing, etc.)

Also guard against renaming extra fields to reserved keys (name, limit,
options) or duplicate names, and fix ModelOptionKeyInput blur desync when
a rename is rejected by the parent handler.
2026-03-15 21:06:08 +08:00
Hemilt0n f38facd430 fix(proxy): use max_completion_tokens for o1/o3 series models (#1451)
* fix(proxy): use max_completion_tokens for o1/o3 series models

When converting Anthropic requests to OpenAI format for o1/o3 series
models (like o1-mini, o3-mini), use max_completion_tokens instead of
max_tokens to avoid unsupported_parameter errors.

Fixes #1448

* fix: revert incorrect o-series max_completion_tokens in Responses API path

Responses API uses max_output_tokens for all models including o-series.
The o-series max_completion_tokens fix should only apply to Chat Completions API.

---------

Co-authored-by: Hajen Teowideo <hajen.teowideo@example.com>
Co-authored-by: Jason Young <44939412+farion1231@users.noreply.github.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-03-15 20:39:17 +08:00
Hexi 5c03de53f7 fix(provider-form): prevent duplicate submissions on rapid button clicks (#1352)
Make ProviderForm.handleSubmit async and await onSubmit so react-hook-form's
isSubmitting state is tracked. Disable submit buttons in AddProviderDialog and
EditProviderDialog while submission is in flight via onSubmittingChange callback.
2026-03-15 15:24:17 +08:00
osehun d8a7bc32db fix: restore Claude sessions in Ghostty (#1506)
Co-authored-by: SEHUN <canyonsehun@gmail.com>
2026-03-15 11:23:15 +08:00
Jason f1cad25777 chore: touch config files to normalize commit messages 2026-03-15 00:15:07 +08:00
Jason 9439153f05 feat: add Tool Search domain restriction bypass with active-installation patching
Resolve the active `claude` command from PATH and apply an equal-length
byte patch to remove the domain whitelist check. Backups are stored in
~/.cc-switch/toolsearch-backups/ (SHA-256 of path) so they survive
Claude Code version upgrades. The patch auto-reapplies on app startup
when the setting is enabled.

Frontend checks PatchResult.success and rolls back the setting on failure.
2026-03-14 23:41:36 +08:00
Jason 7097a0d710 fix: replace implicit app inference with explicit selection for Skills import and sync
Skills import previously inferred app enablement from filesystem presence,
causing incorrect multi-app activation when the same skill directory existed
under multiple app paths. Now the frontend submits explicit app selections
via ImportSkillSelection, and schema migration preserves a snapshot of
legacy app mappings to avoid lossy reconstruction.

Also adds reconciliation to sync_to_app (removes disabled/orphaned symlinks)
and MCP sync_all_enabled (removes disabled servers from live config).
2026-03-14 23:41:36 +08:00
Dex Miller f1d2c6045b fix(skill): support .skill file extension in ZIP import dialog (#1240) (#1455) 2026-03-14 22:47:07 +08:00
funnytime 9e5a3b2dc9 fix: highlight active OpenClaw provider card (#1419)
closes #1414
2026-03-14 22:45:24 +08:00
King 2466873db3 chore: remove packageManager field from package.json (#1470) 2026-03-14 22:20:41 +08:00
李悠然 3c902b4599 fix: improve the responsive design when toc title exists (#1491) 2026-03-14 21:56:37 +08:00
Jason 1582d33705 chore: bump version to v3.12.2 and add release notes 2026-03-12 23:50:33 +08:00
Jason 305c0f2e08 feat: improve empty state guidance for first-run experience
Show detailed import instructions and conditionally display common
config snippet hint for Claude/Codex/Gemini (not OpenCode/OpenClaw).
2026-03-12 23:42:15 +08:00
Jason 8e1204b1ee fix: merge Codex MCP restore backup entries during takeover
When proxy takeover is active, update_live_backup_from_provider rebuilds the Codex restore snapshot from the current provider and common-config state. We were then replacing the new mcp_servers table with the previous backup's table wholesale, which meant stopping takeover could restore stale MCP entries and silently discard MCP changes made through provider/common-config updates.

Change the backup preservation step to merge MCP entries by server id instead of replacing the entire table. New provider/common-config MCP definitions now win on conflict, while backup-only servers are retained so live-only MCP state still survives a hot-switch.

Add regression coverage for the existing preservation case and for the conflict case where both the old backup and the new provider define the same MCP server.
2026-03-12 23:15:40 +08:00
Jason 3568c98f57 fix: make Codex TOML base_url editing section-aware
Rewrite setCodexBaseUrl/extractCodexBaseUrl to understand TOML section
boundaries, ensuring base_url is written into the correct
[model_providers.<name>] section instead of being appended to file end.

- Add section-aware TOML helpers in providerConfigUtils.ts
- Extract shared update_codex_toml_field/remove_codex_toml_base_url_if
  in codex_config.rs, deduplicate proxy.rs TOML editing logic
- Replace scattered inline base_url regexes with extractCodexBaseUrl()
- Add comprehensive tests for both Rust and TypeScript implementations
2026-03-12 22:20:59 +08:00
Jason 7ca33ff901 fix: prevent common config loss during proxy takeover and stabilize snippet lifecycle
- Make sync_current_provider_for_app takeover-aware: update restore
  backup instead of overwriting live config when proxy is active
- Introduce explicit "cleared" flag for common config snippets to
  prevent auto-extraction from resurrecting user-cleared snippets
- Reorder startup: extract snippets from clean live files before
  restoring proxy takeover state
- Add one-time migration flag to skip legacy commonConfigEnabled
  migration on subsequent startups
- Add regression tests for takeover backup preservation, explicit
  clear semantics, and migration flag roundtrip
2026-03-12 17:16:22 +08:00
Jason e561084f62 Preserve common config during proxy takeover
Update takeover backup generation to rebuild effective provider settings with common config applied before saving restore snapshots.

Keep Codex mcp_servers entries when hot-switching providers under takeover so restore does not drop live-only MCP config.

Migrate legacy providers with inferred common-config usage to explicit commonConfigEnabled=true markers during startup and default imports, and cover the new behavior with proxy and provider regression tests.
2026-03-12 16:06:07 +08:00
Jason 3dad255a2a chore: bump version to v3.12.1 and add release notes 2026-03-12 00:15:05 +08:00
Jason 51825dac20 feat: add authHeader field to OpenClawProviderConfig and reuse type in form state
Add optional `authHeader` boolean to support vendor-specific auth headers
(e.g. Longcat). Refactor `resetOpenclawState` to use the shared
`OpenClawProviderConfig` type instead of an inline duplicate definition.
2026-03-11 23:56:41 +08:00
Jason ce985763f0 fix: rename OpenCode API format label from "OpenAI" to "OpenAI Responses" 2026-03-11 23:51:11 +08:00
Jason 19dca7cd2b feat: add CTok as sponsor and upgrade Gemini model to 3.1-pro 2026-03-11 23:47:08 +08:00
Jason 70632249a8 feat: add SiliconFlow as sponsor and update affiliate links
Replace SDS logos with SiliconFlow logos, add SiliconFlow sponsor entries
to all three README files (en/zh/ja), fix incorrect alt attribute, and
update all SiliconFlow provider preset apiKeyUrl to affiliate link.
2026-03-11 16:25:38 +08:00
Jason 55210d90d2 fix: update Compshare sponsor registration links to coding-plan page 2026-03-11 15:23:04 +08:00
Jason 239c6fb2d7 fix: prevent common config modal infinite reopen loop and add draft editing
The auto-open useEffect in CodexConfigEditor and GeminiConfigEditor
created an inescapable loop: commonConfigError triggered modal open,
closing the modal didn't clear the error, so the effect immediately
reopened it — locking the entire UI.

- Remove auto-open useEffect from both Codex and Gemini config editors
- Convert common config modals to draft editing (edit locally, validate
  before save) instead of persisting on every keystroke
- Add TOML/JSON pre-validation via parseCommonConfigSnippet before any
  merge operation to prevent invalid content from being persisted
- Expose clearCommonConfigError so editors can clear stale errors on
  modal close
2026-03-11 14:40:14 +08:00
Jason 4ac7e28b10 feat: add XCodeAPI as sponsor and fix incorrect alt attributes
- Add XCodeAPI sponsor entry to all three READMEs (zh, en, ja)
- Fix XCodeAPI logo alt from "Micu" to "XCodeAPI" in README_ZH.md
- Fix Crazyrouter logo alt from "AICoding" to "Crazyrouter" in all READMEs
2026-03-11 11:12:29 +08:00
Jason 47e956bb63 feat: add Micu API as sponsor and update affiliate links
Add Micu (米醋API) sponsor entry to all three README files (EN/ZH/JA)
with logo and description. Update apiKeyUrl for Micu provider presets
across claude, codex, opencode, and openclaw to use affiliate
registration link.
2026-03-11 00:14:40 +08:00
Jason 0bcbffb8a0 feat: rename UCloud provider to Compshare/优云智算 with i18n support
Add optional `nameKey` field to all preset interfaces for localized
display names. The preset selector and form now resolve `nameKey` via
i18next, falling back to `name` when unset.

- zh: 优云智算 / en+ja: Compshare
- Update model ID prefix ucloud/ → compshare/ in OpenClaw presets
- Update partner promotion copy and README sponsor descriptions
2026-03-10 23:43:23 +08:00
Jason 273a756869 fix: sync session search index with query data to refresh list after deletion
Replace useRef+useEffect async index rebuild with useMemo so the
FlexSearch index and the sessions array always reference the same data.
This ensures filtered search results update immediately when a session
is deleted via TanStack Query setQueryData.
2026-03-10 23:06:45 +08:00
Jason c2b60623a6 fix: avoid FK constraint failure when restoring provider_health during WebDAV sync
Split LOCAL_ONLY_TABLES into SYNC_SKIP_TABLES (export) and
SYNC_PRESERVE_TABLES (import). provider_health is skipped on export
but no longer restored on import, since it has a foreign key on
providers(id, app_type) that may not match the remote dataset.
Health data is ephemeral and rebuilds automatically at runtime.
2026-03-10 23:06:45 +08:00
Jason f4ad17d314 fix: align stream check toast i18n interpolation keys with translation placeholders 2026-03-10 23:06:45 +08:00
Jason 236f96b583 fix: correct X-Code API URL from www.x-code.cn to x-code.cc 2026-03-10 23:06:45 +08:00
Zhou Mengze 75b4ef2299 fix: interpolate proxy startup toast address and port (#1399)
Co-authored-by: 周梦泽 <mengze.zhou@dafeng-tech.com>
2026-03-10 21:02:13 +08:00
bigsong fab9874b2c fix: align OpenClaw tool permission profiles with upstream schema (#1355)
* fix: align OpenClaw tool permission profiles with upstream schema

* fix: remove dead i18n keys and save-blocking validation

- Remove unused `profiles.*` nested i18n keys (dead code, ToolsPanel uses flat `profileMinimal` etc.)
- Remove `invalidProfile` i18n key no longer referenced
- Remove handleSave validation that blocked saving allow/deny when legacy profile exists
- Keep the profile destructuring cleanup from the original PR

---------

Co-authored-by: Your Name <your.email@example.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-03-10 17:53:44 +08:00
liuxxxu 84668e2307 feat(openClaw form): add input type selection for models Advanced Options / 为模型高级选项添加输入类型选择 (#1368)
* feat(openClaw form): add input type selection for models Advanced Options / 为模型高级选项添加输入类型选择

* fix(i18n): add missing openclaw.inputTypes key to all locales

The new inputTypes field in OpenClawFormFields used defaultValue fallback,
causing English and Japanese users to see Chinese text.

---------

Co-authored-by: xu.liu2 <xu.liu2@brgroup.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-03-10 16:53:40 +08:00
wavever b4033fdd29 fix: add missing authHeader: true to Longcat provider preset (#1377)
Fixes issue where Longcat models were failing with 404 error due to missing
authHeader configuration. According to Longcat API documentation, this
setting is required for proper authentication.

Closes: #1376
2026-03-10 16:14:08 +08:00
hengm3467 471c0d9990 feat: add StepFun provider presets and step-3.5-flash (#1369)
* feat: add StepFun provider presets

* docs: regroup StepFun pricing entry

* docs: tweak StepFun zh label

* style: apply prettier fixes

* Revert "style: apply prettier fixes"

This reverts commit cff7bf2e65.
2026-03-10 10:27:50 +08:00
zuoliangyu a9c36381fc fix: toolbar compact mode not triggering on Windows due to left-side overflow (#1375)
The toolbar container used `justify-end` which caused content to overflow
to the left side. Browser `scrollWidth` does not account for left-side
overflow, so `useAutoCompact` could never detect it and compact mode
never activated — resulting in clipped tab labels (e.g. "aude" instead
of "Claude") at minimum window width.

Fix: remove `justify-end` from the toolbar container and use `ml-auto`
on the child element instead. This keeps the right-aligned appearance
but makes overflow go rightward, where `scrollWidth` correctly detects
it and triggers compact mode.

Co-authored-by: zuolan <zuolan1102@qq.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:30:52 +08:00
205 changed files with 19417 additions and 3431 deletions
+253 -23
View File
@@ -150,9 +150,76 @@ jobs:
fi
echo "✅ Tauri signing key prepared"
- name: Import Apple signing certificate
if: runner.os == 'macOS'
shell: bash
run: |
set -euo pipefail
# Decode .p12 certificate from base64
CERT_PATH="$RUNNER_TEMP/certificate.p12"
printf '%s' "${{ secrets.APPLE_CERTIFICATE }}" | (base64 --decode 2>/dev/null || base64 -D) > "$CERT_PATH"
# Save original default keychain for cleanup
ORIGINAL_DEFAULT_KEYCHAIN=$(security default-keychain -d user | tr -d '"' | xargs)
echo "ORIGINAL_DEFAULT_KEYCHAIN=$ORIGINAL_DEFAULT_KEYCHAIN" >> "$GITHUB_ENV"
# Create temporary keychain
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
security create-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security default-keychain -s "$KEYCHAIN_PATH"
security unlock-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" "$KEYCHAIN_PATH"
# Import certificate
security import "$CERT_PATH" \
-k "$KEYCHAIN_PATH" \
-P "${{ secrets.APPLE_CERTIFICATE_PASSWORD }}" \
-T /usr/bin/codesign \
-T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${{ secrets.KEYCHAIN_PASSWORD }}" "$KEYCHAIN_PATH"
# Dynamically resolve signing identity (must be "Developer ID Application")
IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \
| grep "Developer ID Application" | grep -oE '"[^"]+"' | head -1 | tr -d '"')
if [ -z "$IDENTITY" ]; then
echo "❌ No 'Developer ID Application' identity found — listing all identities:" >&2
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
exit 1
fi
echo "✅ Signing identity: $IDENTITY"
echo "APPLE_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV"
# Cleanup certificate file
rm -f "$CERT_PATH"
- name: Build Tauri App (macOS)
if: runner.os == 'macOS'
run: pnpm tauri build --target universal-apple-darwin
shell: bash
timeout-minutes: 60
env:
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
max_attempts=3
for attempt in $(seq 1 "$max_attempts"); do
echo "=== macOS build/notarization attempt ${attempt}/${max_attempts} ==="
if pnpm tauri build --target universal-apple-darwin; then
echo "✅ macOS build/notarization succeeded"
exit 0
fi
if [ "$attempt" -eq "$max_attempts" ]; then
echo "❌ macOS build/notarization failed after ${max_attempts} attempts" >&2
exit 1
fi
sleep_seconds=$((attempt * 60))
echo "⚠️ macOS build/notarization failed, retrying in ${sleep_seconds}s..."
sleep "$sleep_seconds"
done
- name: Build Tauri App (Windows)
if: runner.os == 'Windows'
@@ -169,7 +236,8 @@ jobs:
set -euxo pipefail
mkdir -p release-assets
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
echo "Looking for updater artifact (.tar.gz) and .app for zip..."
# Locate bundle artifacts
TAR_GZ=""; APP_PATH=""
for path in \
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
@@ -177,28 +245,150 @@ jobs:
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos" \
"src-tauri/target/release/bundle/macos"; do
if [ -d "$path" ]; then
[ -z "$TAR_GZ" ] && TAR_GZ=$(find "$path" -maxdepth 1 -name "*.tar.gz" -type f | head -1 || true)
[ -z "$TAR_GZ" ] && TAR_GZ=$(find "$path" -maxdepth 1 -name "*.tar.gz" -type f | head -1 || true)
[ -z "$APP_PATH" ] && APP_PATH=$(find "$path" -maxdepth 1 -name "*.app" -type d | head -1 || true)
fi
done
if [ -z "$TAR_GZ" ]; then
echo "No macOS .tar.gz updater artifact found" >&2
echo "No macOS .tar.gz updater artifact found" >&2
exit 1
fi
# 重命名 tar.gz 为统一格式
if [ -z "$APP_PATH" ]; then
echo "❌ No .app found" >&2
exit 1
fi
# Staple notarization ticket to .app (Tauri already notarized it)
xcrun stapler staple "$APP_PATH"
echo "✅ .app stapled"
# 1) Collect .tar.gz (updater artifact)
NEW_TAR_GZ="CC-Switch-${VERSION}-macOS.tar.gz"
cp "$TAR_GZ" "release-assets/$NEW_TAR_GZ"
[ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" "release-assets/$NEW_TAR_GZ.sig" || echo ".sig for macOS not found yet"
echo "macOS updater artifact copied: $NEW_TAR_GZ"
if [ -n "$APP_PATH" ]; then
APP_DIR=$(dirname "$APP_PATH"); APP_NAME=$(basename "$APP_PATH")
NEW_ZIP="CC-Switch-${VERSION}-macOS.zip"
cd "$APP_DIR"
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "$NEW_ZIP"
mv "$NEW_ZIP" "$GITHUB_WORKSPACE/release-assets/"
echo "macOS zip ready: $NEW_ZIP"
# 2) Collect .app as zip
NEW_ZIP="CC-Switch-${VERSION}-macOS.zip"
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "release-assets/$NEW_ZIP"
echo "macOS zip ready: $NEW_ZIP"
# 3) Create styled DMG with create-dmg (Tauri's built-in DMG styling doesn't work on CI)
if [ -z "${APPLE_SIGNING_IDENTITY:-}" ]; then
echo "❌ APPLE_SIGNING_IDENTITY is missing before DMG creation" >&2
exit 1
fi
HOMEBREW_NO_AUTO_UPDATE=1 brew install create-dmg
NEW_DMG="CC-Switch-${VERSION}-macOS.dmg"
DMG_STAGE_DIR="$RUNNER_TEMP/dmg-stage"
rm -rf "$DMG_STAGE_DIR"
mkdir -p "$DMG_STAGE_DIR"
ditto "$APP_PATH" "$DMG_STAGE_DIR/CC Switch.app"
create-dmg \
--volname "CC Switch" \
--background "src-tauri/icons/dmg-background.png" \
--window-size 660 400 \
--window-pos 200 120 \
--icon-size 80 \
--icon "CC Switch.app" 180 220 \
--hide-extension "CC Switch.app" \
--app-drop-link 480 220 \
--codesign "$APPLE_SIGNING_IDENTITY" \
--no-internet-enable \
"release-assets/$NEW_DMG" \
"$DMG_STAGE_DIR"
rm -rf "$DMG_STAGE_DIR"
echo "✅ Styled DMG created: $NEW_DMG"
- name: Notarize macOS DMG
if: runner.os == 'macOS'
shell: bash
timeout-minutes: 30
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
DMG_PATH=$(find release-assets -maxdepth 1 -name "*.dmg" -type f | head -1 || true)
if [ -z "$DMG_PATH" ]; then
echo "❌ No .dmg found in release-assets/ to notarize" >&2
exit 1
fi
echo "=== Notarizing DMG: $DMG_PATH ==="
max_attempts=3
for attempt in $(seq 1 "$max_attempts"); do
echo "=== DMG notarization attempt ${attempt}/${max_attempts} ==="
if xcrun notarytool submit "$DMG_PATH" \
--apple-id "$APPLE_ID" \
--password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait; then
echo "✅ DMG notarization succeeded"
xcrun stapler staple "$DMG_PATH"
echo "✅ DMG stapled"
break
fi
if [ "$attempt" -eq "$max_attempts" ]; then
echo "❌ DMG notarization failed after ${max_attempts} attempts" >&2
exit 1
fi
sleep_seconds=$((attempt * 60))
echo "⚠️ DMG notarization failed, retrying in ${sleep_seconds}s..."
sleep "$sleep_seconds"
done
- name: Verify macOS code signing and notarization
if: runner.os == 'macOS'
shell: bash
run: |
set -euo pipefail
# Verify .app (from Tauri bundle)
APP_PATH=""
for path in \
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
"src-tauri/target/aarch64-apple-darwin/release/bundle/macos" \
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos" \
"src-tauri/target/release/bundle/macos"; do
if [ -d "$path" ]; then
[ -z "$APP_PATH" ] && APP_PATH=$(find "$path" -maxdepth 1 -name "*.app" -type d | head -1 || true)
fi
done
if [ -z "$APP_PATH" ]; then
echo "❌ No .app found for verification" >&2
exit 1
fi
echo "=== Verifying .app: $APP_PATH ==="
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
echo "✅ codesign verification passed"
spctl -a -t exec -vv "$APP_PATH"
echo "✅ spctl assessment passed"
xcrun stapler validate "$APP_PATH"
echo "✅ .app stapler validation passed"
# Verify .dmg (from release-assets/, created by create-dmg + notarized)
DMG_PATH=$(find release-assets -maxdepth 1 -name "*.dmg" -type f | head -1 || true)
if [ -n "$DMG_PATH" ]; then
echo "=== Verifying .dmg: $DMG_PATH ==="
codesign --verify --verbose=2 "$DMG_PATH"
echo "✅ .dmg codesign verification passed"
spctl -a -t open --context context:primary-signature -vv "$DMG_PATH"
echo "✅ .dmg spctl assessment passed"
xcrun stapler validate "$DMG_PATH"
echo "✅ .dmg stapler validation passed"
else
echo "No .app found to zip (optional)" >&2
echo "No .dmg found for verification — release would ship without verified DMG" >&2
exit 1
fi
- name: Prepare Windows Assets
@@ -299,6 +489,51 @@ jobs:
echo "Collected signatures (if any alongside artifacts):"
ls -la release-assets/*.sig || echo "No signatures found"
- name: Upload release artifacts to workflow
uses: actions/upload-artifact@v4
with:
name: release-assets-${{ runner.os }}-${{ matrix.arch || runner.arch }}
path: release-assets/*
if-no-files-found: error
- name: List generated bundles (debug)
if: always()
shell: bash
run: |
echo "Listing bundles in src-tauri/target..."
find src-tauri/target -maxdepth 4 -type f -name "*.*" 2>/dev/null || true
- name: Clean up Apple signing keychain
if: runner.os == 'macOS' && always()
shell: bash
run: |
if [ -n "${ORIGINAL_DEFAULT_KEYCHAIN:-}" ]; then
security default-keychain -s "$ORIGINAL_DEFAULT_KEYCHAIN" || true
fi
if [ -f "$RUNNER_TEMP/build.keychain-db" ]; then
security delete-keychain "$RUNNER_TEMP/build.keychain-db" || true
fi
publish-release:
name: Publish GitHub Release
runs-on: ubuntu-22.04
needs: release
permissions:
contents: write
steps:
- name: Download built release artifacts
uses: actions/download-artifact@v4
with:
pattern: release-assets-*
path: release-assets
merge-multiple: true
- name: List downloaded release artifacts
shell: bash
run: |
set -euo pipefail
ls -la release-assets
- name: Upload Release Assets
uses: softprops/action-gh-release@v2
with:
@@ -312,28 +547,23 @@ jobs:
### 下载
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`Homebrew
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.dmg`(推荐)或 `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
- **Linux (x86_64)**: `CC-Switch-${{ github.ref_name }}-Linux-x86_64.AppImage` / `.deb` / `.rpm`
- **Linux (ARM64)**: `CC-Switch-${{ github.ref_name }}-Linux-arm64.AppImage` / `.deb` / `.rpm`
> `.tar.gz` 为 Tauri updater 自动更新专用,无需手动下载。
---
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
macOS 版本已通过 Apple 代码签名和公证,可直接安装使用。
files: release-assets/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: List generated bundles (debug)
if: always()
shell: bash
run: |
echo "Listing bundles in src-tauri/target..."
find src-tauri/target -maxdepth 4 -type f -name "*.*" 2>/dev/null || true
assemble-latest-json:
name: Assemble latest.json
runs-on: ubuntu-22.04
needs: release
needs: publish-release
permissions:
contents: write
steps:
+5
View File
@@ -24,3 +24,8 @@ flatpak/cc-switch.deb
flatpak-build/
flatpak-repo/
.worktrees/
.spec-workflow/
copilot-api
.history
CODEBUDDY.md
.github
+1 -1
View File
@@ -1 +1 @@
22.12.0
22.12.0
+138
View File
@@ -9,6 +9,144 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [3.12.3] - 2026-03-24
Major release adding GitHub Copilot reverse proxy support, macOS code signing & Apple notarization, intelligent reasoning effort mapping for o-series models, skill backup/restore lifecycle, proxy gzip compression, and critical fixes for WebDAV password safety, tool message parsing, and dark mode.
**Stats**: 36 commits | 107 files changed | +9,124 insertions | -802 deletions
### Added
- **GitHub Copilot Reverse Proxy**: Full GitHub Copilot integration as a Claude Code provider via OAuth Device Code flow; includes multi-account management, automatic token refresh, Anthropic ↔ OpenAI format conversion, real-time model list fetching, and usage statistics (#930)
- **Copilot Auth Center**: New Auth Center panel in Settings for managing GitHub accounts globally, with per-provider account binding via `meta.authBinding`
- **Tool Search Toggle**: Added `ENABLE_TOOL_SEARCH` env var support for Claude 2.1.76+; exposed as a checkbox in the provider Common Config editor (#930)
- **Reasoning Effort Mapping**: Two-tier `resolve_reasoning_effort()` for OpenAI o-series and GPT-5+ models — explicit `output_config.effort` takes priority, falling back to thinking `budget_tokens` thresholds (<4 000→low, 4 00016 000→medium, ≥16 000→high); covers both Chat Completions and Responses API paths with 17 unit tests
- **OpenCode SQLite Backend**: Added SQLite session storage support for OpenCode alongside existing JSON backend; dual-backend scan with SQLite priority on ID conflicts, atomic session deletion, and path validation (#1401)
- **Skill Auto-Backup**: Skill files are automatically backed up to `~/.cc-switch/skill-backups/` before uninstall, with metadata preserved in `meta.json`; old backups pruned to keep at most 20
- **Skill Backup Restore & Delete**: Added list/restore/delete commands for skill backups; restore copies files back to SSOT, saves the DB record, and syncs to the current app with rollback on failure
- **macOS Code Signing & Notarization**: CI now imports an Apple Developer ID certificate, signs the universal binary, submits for Apple notarization, and staples the ticket to both `.app` and `.dmg`; a hard-fail verification step (`codesign --verify` + `spctl -a` + `stapler validate`) gates the release for both artifacts
- **Codex 1M Context Window Toggle**: One-click checkbox in Codex config editor to set `model_context_window = 1000000` with auto-populated `model_auto_compact_token_limit = 900000`; unchecking removes both fields
- **Disable Auto-Upgrade Toggle**: Added `DISABLE_AUTOUPDATER` env var checkbox in the Claude Common Config editor to prevent Claude Code from auto-upgrading
### Changed
- **Skills Cache Strategy**: Replaced `invalidateQueries` with direct `setQueryData` updates for skill install/uninstall/import operations; added `staleTime: Infinity` with `keepPreviousData` to eliminate loading flicker (#1573)
- **Proxy Gzip Compression**: Non-streaming proxy requests now auto-negotiate gzip compression instead of forcing `identity`; streaming requests conservatively keep `identity` to avoid SSE decompression errors
- **o1/o3 Model Compatibility**: Chat Completions proxy forwarding now correctly uses `max_completion_tokens` instead of `max_tokens` for OpenAI o-series models such as o1/o3/o4-mini (#1451)
- **OpenCode Model Variants**: Placed OpenCode model variants at top level instead of inside options for better discoverability (#1317)
- **Skills Import Flow**: Replaced implicit filesystem-based app inference with explicit `ImportSkillSelection` to prevent incorrect multi-app activation; added reconciliation to remove disabled/orphaned symlinks and MCP servers from live config
- **Claude 4.6 Context Window**: Updated Claude Opus 4.6 and Sonnet 4.6 context window from 200K to 1M across OpenClaw and OpenCode presets (GA release)
- **MiniMax Model Upgrade**: Updated MiniMax presets from M2.5 to M2.7 across Claude, OpenClaw, and OpenCode configurations with updated partner descriptions in all three locales
- **Xiaomi MiMo Model Upgrade**: Updated MiMo presets from mimo-v2-flash to mimo-v2-pro across all supported applications
- **AddProviderDialog Simplification**: Removed redundant OAuth tab, reducing dialog from 3 tabs to 2 (app-specific + universal)
- **Provider Form Advanced Options Collapse**: Model mapping, API format, and other advanced fields in the Claude provider form now auto-collapse when empty; auto-expands when any value is set or when a preset fills them in
### Fixed
- **WebDAV Password Silent Clear**: Fixed WebDAV password being silently wiped when ProviderList or UsageScriptModal saved settings by stripping `webdavSync` from frontend payloads and adding backend backfill logic in `merge_settings_for_save()` to preserve existing passwords
- **Tool Message Parsing**: Fixed tool_use/tool_result message classification across Claude (tool_result content blocks), Codex (function_call/function_call_output payloads), and Gemini (array content + toolCalls extraction) session providers (#1401)
- **Dark Mode Selector**: Changed Tailwind `darkMode` from `["selector", "class"]` to `["selector", ".dark"]` to ensure correct dark mode activation (#1596)
- **Copilot Request Fingerprint**: Unified Copilot request fingerprint headers across all API call sites to prevent User-Agent leakage and stream check mismatches
- **o-series Responses API Tokens**: Kept Responses API on the correct `max_output_tokens` field for o-series models instead of incorrectly injecting `max_completion_tokens`
- **Provider Form Double Submit**: Prevented duplicate submissions on rapid button clicks in provider add/edit forms (#1352)
- **Ghostty Session Restore**: Fixed Claude session restore in Ghostty terminal (#1506)
- **Skill ZIP Import Extension**: Added `.skill` file extension support in ZIP import dialog (#1240, #1455)
- **Skill ZIP Install Target App**: ZIP skill installs now use the currently active app instead of always defaulting to Claude
- **OpenClaw Active Card Highlight**: Fixed active OpenClaw provider card not being highlighted (#1419)
- **Responsive Layout with TOC**: Improved responsive design when TOC title exists (#1491)
- **Import Skills Dialog White Screen**: Added missing TooltipProvider in ImportSkillsDialog to prevent runtime crash when opening the dialog
- **Panel Bottom Blank Area**: Replaced hardcoded `h-[calc(100vh-8rem)]` with `flex-1 min-h-0` across all content panels to eliminate bottom gap caused by mismatched offset values
### Docs
- **Pricing Model ID Normalization**: Added documentation section explaining model ID normalization rules (prefix stripping, suffix trimming, `@``-` replacement) in EN/ZH/JA user manuals (#1591)
- **macOS Signed & Notarized**: Removed all `xattr` workaround instructions and "unidentified developer" warnings from README, README_ZH, installation guides (EN/ZH/JA), and FAQ pages (EN/ZH/JA); replaced with "signed and notarized by Apple" messaging
---
## [3.12.2] - 2026-03-12
Post-v3.12.1 work focuses on Common Config safety during proxy takeover and more reliable Codex TOML editing.
**Stats**: 5 commits | 22 files changed | +1,716 insertions | -288 deletions
### Added
- **Empty State Guidance**: Improved first-run experience with detailed import instructions and a conditional Common Config snippet hint for Claude/Codex/Gemini providers
### Changed
- **Proxy Takeover Restore Flow**: Proxy takeover hot-switch and provider sync now refresh the restore backup instead of overwriting live config files, rebuilding effective provider settings with Common Config applied so rollback preserves the real user configuration
- **Codex TOML Editing Engine**: Refactored Codex `config.toml` updates onto shared section-aware TOML helpers in Rust and TypeScript, covering `base_url` and `model` field edits across provider forms and takeover cleanup
- **Common Config Initialization Lifecycle**: Startup now auto-extracts Common Config snippets from clean live configs before takeover restoration, tracks explicit "snippet cleared" state, and persists a one-time legacy migration flag to avoid repeated backfills
### Fixed
- **Common Config Loss During Takeover**: Fixed cases where proxy takeover could drop Common Config changes, overwrite live configs during sync, or produce incomplete restore snapshots when switching providers
- **Codex Restore Snapshot Preservation**: Fixed Codex takeover restore backups so existing `mcp_servers` blocks survive provider hot-switches instead of being discarded; changed MCP backup preservation from wholesale table replacement to per-server-id merge so provider/common-config MCP updates win on conflict while live-only servers are retained
- **Cleared Snippet Resurrection**: Fixed startup auto-extraction recreating Common Config snippets that users had intentionally cleared
- **Codex `base_url` Misplacement**: Fixed Codex `base_url` extraction and editing to target the active `[model_providers.<name>]` section instead of appending to the file tail or confusing `mcp_servers.*.base_url` entries for provider endpoints
---
## [3.12.1] - 2026-03-12
### Patch Release
Stability-focused patch release fixing the Common Config modal infinite reopen loop, a WebDAV sync foreign key constraint failure, several i18n interpolation issues, and a Windows toolbar compact mode bug. Also adds **StepFun** provider presets, **OpenClaw input type selection** and **authHeader** support, upgrades Gemini to **3.1-pro**, and welcomes four new sponsor partners.
**Stats**: 19 commits | 56 files changed | +1,429 insertions | -396 deletions
### Added
#### Provider Presets
- **StepFun**: Added StepFun (阶跃星辰) provider presets including the step-3.5-flash model across supported applications (#1369, thanks @hengm3467)
#### OpenClaw Enhancements
- **Input Type Selection**: Added input type selection dropdown for model Advanced Options in OpenClaw configuration form (#1368, thanks @liuxxxu)
- **authHeader Field**: Added optional `authHeader` boolean to OpenClawProviderConfig for vendor-specific auth header support (e.g. Longcat), and refactored form state to reuse the shared type
#### Sponsor Partners
- **Micu API**: Added Micu API as sponsor partner with affiliate links
- **XCodeAPI**: Added XCodeAPI as sponsor partner
- **SiliconFlow**: Added SiliconFlow (硅基流动) as sponsor partner with affiliate links
- **CTok**: Added CTok as sponsor partner
### Changed
- **UCloud → Compshare**: Renamed UCloud provider to Compshare (优云智算) with full i18n support across all three locales (EN/ZH/JA)
- **Compshare Links**: Updated Compshare sponsor registration links to coding-plan page
- **Gemini Model Upgrade**: Upgraded default Gemini model from 2.5-pro to 3.1-pro in provider presets
### Fixed
#### Common Config & UI
- **Common Config Modal Loop**: Fixed an infinite reopen loop in the Common Config modal and added draft editing support to prevent data loss during edits
- **Toolbar Compact Mode (Windows)**: Fixed toolbar compact mode not triggering on Windows due to left-side overflow (#1375, thanks @zuoliangyu)
- **Session Search Index**: Fixed session search index not syncing with query data, causing stale list display after session deletion
#### Sync & Data
- **WebDAV Provider Health FK**: Fixed foreign key constraint failure when restoring `provider_health` table during WebDAV sync
#### Provider & Preset
- **Longcat authHeader**: Added missing `authHeader: true` to Longcat provider preset (#1377, thanks @wavever)
- **OpenClaw Tool Permissions**: Aligned OpenClaw tool permission profiles with upstream schema (#1355, thanks @bigsongeth)
- **X-Code API URL**: Corrected X-Code API URL from `www.x-code.cn` to `x-code.cc`
#### i18n & Localization
- **Stream Check Toast**: Fixed stream check toast i18n interpolation keys not matching translation placeholders
- **Proxy Startup Toast**: Fixed proxy startup toast not interpolating address and port values (#1399, thanks @Mason-mengze)
- **OpenCode API Format Label**: Renamed OpenCode API format label from "OpenAI" to "OpenAI Responses" for accuracy
---
## [3.12.0] - 2026-03-09
### Feature Release
+39 -13
View File
@@ -4,10 +4,10 @@
### The All-in-One Manager for Claude Code, Codex, Gemini CLI, OpenCode & OpenClaw
[![Version](https://img.shields.io/badge/version-3.12.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -22,9 +22,9 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
[![MiniMax](assets/partners/banners/minimax-en.jpeg)](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
MiniMax-M2.5 is a SOTA large language model designed for real-world productivity. Trained in a diverse range of complex real-world digital working environments, M2.5 builds upon the coding expertise of M2.1 to extend into general office work, reaching fluency in generating and operating Word, Excel, and Powerpoint files, context switching between diverse software environments, and working across different agent and human teams. Scoring 80.2% on SWE-Bench Verified, 51.3% on Multi-SWE-Bench, and 76.3% on BrowseComp, M2.5 is also more token efficient than previous generations, having been trained to optimize its actions and output through planning.
MiniMax-M2.7 is a next-generation large language model designed for autonomous evolution and real-world productivity. Unlike traditional models, M2.7 actively participates in its own improvement through agent teams, dynamic tool use, and reinforcement learning loops. It delivers strong performance in software engineering (56.22% on SWE-Pro, 55.6% on VIBE-Pro, 57.0% on Terminal Bench 2) and excels in complex office workflows, achieving a leading 1495 ELO on GDPval-AA. With high-fidelity editing across Word, Excel, and PowerPoint, and a 97% adherence rate across 40+ complex skills, M2.7 sets a new standard for building AI-native workflows and organizations.
[Click](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) to get an exclusive 12% off the MiniMax Coding Plan!
[Click](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) to get an exclusive 12% off the MiniMax Token Plan!
---
@@ -34,6 +34,11 @@ MiniMax-M2.5 is a SOTA large language model designed for real-world productivity
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during first recharge to get 10% off.</td>
</tr>
<tr>
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
<td>Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via <a href="https://cloud.siliconflow.cn/i/drGuwc9k">this link</a> and complete real-name verification to receive ¥20 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free.</td>
</tr>
<tr>
<td width="180"><a href="https://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>
@@ -56,8 +61,8 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
</tr>
<tr>
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Ucloud" width="150"></a></td>
<td>Thanks to UCloud 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/?ytag=GPU_YY_YX_git_cc-switch">this link</a> will receive a free 5 CNY platform trial credit!</td>
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Compshare" width="150"></a></td>
<td>Thanks to Compshare for sponsoring this project! Compshare is UCloud's AI cloud platform, providing stable and comprehensive domestic and international model APIs with just one key. Featuring cost-effective monthly and 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>
@@ -71,7 +76,7 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
</tr>
<tr>
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
<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>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>
@@ -80,6 +85,26 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
<td>Thanks to SSSAiCode for sponsoring this project! SSSAiCode is a stable and reliable API relay service, dedicated to providing stable, reliable, and affordable Claude and Codex model services, <strong>offering high cost-effective official Claude service at just ¥0.5/$ equivalent</strong>, supporting monthly and pay-as-you-go billing plans with same-day fast invoicing. SSSAiCode offers a special deal for CC Switch users: register via <a href="https://www.sssaicode.com/register?ref=DCP0SM">this link</a> to enjoy $10 extra credit on every top-up!</td>
</tr>
<tr>
<td width="180"><a href="https://www.openclaudecode.cn/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
<td>Thanks to Micu API for sponsoring this project! Micu API is a global LLM relay service provider dedicated to delivering the best cost-performance ratio with high stability. Backed by a registered enterprise for core assurance, eliminating any risk of service discontinuation, with fast official invoicing support! We champion "zero cost to try": top up from as low as ¥1 with no minimum, and get fee-free refunds anytime! Micu API offers an exclusive deal for CC Switch users: register via <a href="https://www.openclaudecode.cn/register?aff=aOYQ">this link</a> and enter promo code "ccswitch" when topping up to enjoy a <strong>10% discount</strong>!</td>
</tr>
<tr>
<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>
</tr>
<tr>
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
<td>Thanks to CTok.ai for sponsoring this project! CTok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click <a href="https://ctok.ai">here</a> to register!</td>
</tr>
<tr>
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
<td>Thanks to ChefShop AI for sponsoring this project! ChefShop AI is a premium account service provider tailored for heavy AI subscription users. The platform offers official top-up and stable account services for mainstream large models including ChatGPT Plus/Pro, Claude Max, Grok Super/Heavy, and Gemini. Click <a href="https://chefshop.ai">here</a> to purchase!</td>
</tr>
</table>
</details>
@@ -106,7 +131,7 @@ Modern AI-powered coding relies on CLI tools like Claude Code, Codex, Gemini CLI
## Features
[Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.12.0-en.md)
[Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.12.3-en.md)
### Provider Management
@@ -164,9 +189,9 @@ CC Switch provides a "Shared Config Snippet" feature to pass common data (beyond
</details>
<details>
<summary><strong>macOS shows "unidentified developer" warning — how do I fix it?</strong></summary>
<summary><strong>macOS installation</strong></summary>
The author doesn't have an Apple Developer account yet (registration in progress). Close the warning, then go to **System Settings → Privacy & Security → Open Anyway**. After that, the app will open normally.
CC Switch for macOS is code-signed and notarized by Apple. You can download and install it directly — no extra steps needed. We recommend using the `.dmg` installer.
</details>
@@ -191,6 +216,7 @@ Add an official provider from the preset list. After switching to it, run the Lo
- **Local settings**: `~/.cc-switch/settings.json` (device-level UI preferences)
- **Backups**: `~/.cc-switch/backups/` (auto-rotated, keeps 10 most recent)
- **Skills**: `~/.cc-switch/skills/` (symlinked to corresponding apps by default)
- **Skill Backups**: `~/.cc-switch/skill-backups/` (created automatically before uninstall, keeps 20 most recent)
</details>
@@ -223,7 +249,7 @@ For detailed guides on every feature, check out the **[User Manual](docs/user-ma
### System Requirements
- **Windows**: Windows 10 and above
- **macOS**: macOS 10.15 (Catalina) and above
- **macOS**: macOS 12 (Monterey) and above
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ and other mainstream distributions
### Windows Users
@@ -247,9 +273,9 @@ brew upgrade --cask cc-switch
**Method 2: Manual Download**
Download `CC-Switch-v{version}-macOS.zip` from the [Releases](../../releases) page and extract to use.
Download `CC-Switch-v{version}-macOS.dmg` (recommended) or `.zip` from the [Releases](../../releases) page.
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it first, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and you'll be able to open it normally afterwards.
> **Note**: CC Switch for macOS is code-signed and notarized by Apple. You can install and open it directly.
### Arch Linux Users
+35 -9
View File
@@ -4,10 +4,10 @@
### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw のオールインワン管理ツール
[![Version](https://img.shields.io/badge/version-3.12.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -22,9 +22,9 @@
[![MiniMax](assets/partners/banners/minimax-en.jpeg)](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
MiniMax-M2.5 は、実際の生産性向上のために設計された最先端の大規模言語モデルです。多様で複雑な実環境のデジタルワークスペースでトレーニングされた M2.5 は、M2.1 のコーディング能力をベースに一般的なオフィス業務へと拡張し、Word・Excel・PowerPoint ファイルの生成と操作、多様なソフトウェア環境間のコンテキスト切り替え、異なるエージェントや人間チーム間での協働を流暢にこなします。SWE-Bench Verified で 80.2%、Multi-SWE-Bench で 51.3%、BrowseComp で 76.3% を達成し、計画的な行動と出力の最適化トレーニングにより、前世代よりもトークン効率に優れています。
MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設計された次世代大規模言語モデルです。従来のモデルとは異なり、M2.7 はエージェントチーム、動的ツール使用、強化学習ループを通じて自身の改善に積極的に参加します。ソフトウェアエンジニアリングにおいて優れた性能を発揮し(SWE-Pro で 56.22%、VIBE-Pro で 55.6%、Terminal Bench 2 で 57.0%)、複雑なオフィスワークフローにも秀でており、GDPval-AA で 1495 ELO のリーディングスコアを達成しています。Word・Excel・PowerPoint の高忠実度編集と、40 以上の複雑なスキルにわたる 97% の遵守率により、M2.7 は AI ネイティブなワークフローと組織構築の新基準を打ち立てます。
[こちら](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)から MiniMax Coding Plan の限定 12% オフを入手!
[こちら](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)から MiniMax Token Plan の限定 12% オフを入手!
---
@@ -34,6 +34,11 @@ MiniMax-M2.5 は、実際の生産性向上のために設計された最先端
<td>PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.com/register?aff=cc-switch">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td>
</tr>
<tr>
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
<td>SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。<a href="https://cloud.siliconflow.cn/i/drGuwc9k">このリンク</a>から登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥20 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。</td>
</tr>
<tr>
<td width="180"><a href="https://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>
@@ -56,8 +61,8 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
</tr>
<tr>
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Ucloud" width="150"></a></td>
<td>UCloud CompShare のご支援に感謝します!CompShare は UCloud 傘下の AI クラウドプラットフォームで、国内外の安定した包括的なモデル API を 1 つのキーだけで利用可能。月額・従量課金のコストパフォーマンスに優れた Coding Plan パッケージを提供し、公式価格の 60〜80% オフで利用できます。Claude Code、Codex および API アクセスに対応。エンタープライズ級の高同時接続、24 時間年中無休のテクニカルサポート、セルフサービス請求書発行に対応。<a href="https://www.compshare.cn/?ytag=GPU_YY_YX_git_cc-switch">こちらのリンク</a>から登録すると、無料で 5 元分のプラットフォーム体験クレジットがもらえます!</td>
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Compshare" width="150"></a></td>
<td>Compshare のご支援に感謝します!Compshare は UCloud 傘下の AI クラウドプラットフォームで、国内外の安定した包括的なモデル API を 1 つのキーだけで利用可能。月額・従量課金のコストパフォーマンスに優れた Coding Plan パッケージを提供し、公式価格の 60〜80% オフで利用できます。Claude Code、Codex および API アクセスに対応。エンタープライズ級の高同時接続、24 時間年中無休のテクニカルサポート、セルフサービス請求書発行に対応。<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">こちらのリンク</a>から登録すると、無料で 5 元分のプラットフォーム体験クレジットがもらえます!</td>
</tr>
<tr>
@@ -71,7 +76,7 @@ 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="AICoding" width="150"></a></td>
<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>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>
@@ -80,6 +85,26 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
<td>SSSAiCode のご支援に感謝します!SSSAiCode は安定性と信頼性に優れた API 中継サービスで、安定的で信頼性が高く、手頃な価格の Claude・Codex モデルサービスを提供しています。<strong>高コストパフォーマンスの公式 Claude サービスを 0.5¥/$ 換算で提供</strong>、月額制・Paygo など多様な課金方式に対応し、当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典:<a href="https://www.sssaicode.com/register?ref=DCP0SM">こちらのリンク</a>から登録すると、毎回のチャージで $10 の追加ボーナスを受けられます!</td>
</tr>
<tr>
<td width="180"><a href="https://www.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>
</tr>
<tr>
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
<td>CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href="https://ctok.ai">こちら</a>から登録してください!</td>
</tr>
<tr>
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
<td>ChefShop AI のご支援に感謝します!ChefShop AI は、AI ヘビーユーザー向けにカスタマイズされたプレミアムアカウントサービスプロバイダーです。ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy、Gemini など主流の大規模モデルの公式チャージと安定したアカウントサービスを提供しています。<a href="https://chefshop.ai">こちら</a>から購入してください!</td>
</tr>
</table>
</details>
@@ -106,7 +131,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
## 特長
[完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-notes/v3.12.0-ja.md)
[完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-notes/v3.12.3-ja.md)
### プロバイダ管理
@@ -191,6 +216,7 @@ CC Switch は「最小限の介入」という設計原則に従っています
- **ローカル設定**: `~/.cc-switch/settings.json`(デバイスレベルの UI 設定)
- **バックアップ**: `~/.cc-switch/backups/`(自動ローテーション、最新 10 件を保持)
- **Skills**: `~/.cc-switch/skills/`(デフォルトでシンボリックリンクにより対応アプリに接続)
- **Skill バックアップ**: `~/.cc-switch/skill-backups/`(アンインストール前に自動作成、最新 20 件を保持)
</details>
@@ -223,7 +249,7 @@ CC Switch は「最小限の介入」という設計原則に従っています
### システム要件
- **Windows**: Windows 10 以上
- **macOS**: macOS 10.15 (Catalina) 以上
- **macOS**: macOS 12 (Monterey) 以上
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ など主要ディストリビューション
### Windows ユーザー
+39 -13
View File
@@ -4,10 +4,10 @@
### Claude Code、Codex、Gemini CLI、OpenCode 和 OpenClaw 的全方位管理工具
[![Version](https://img.shields.io/badge/version-3.12.0-blue.svg)](https://github.com/farion1231/cc-switch/releases)
[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)
[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)
[![Downloads](https://img.shields.io/endpoint?url=https://api.pinstudios.net/api/badges/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
[![Downloads](https://img.shields.io/github/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -22,9 +22,9 @@
[![MiniMax](assets/partners/banners/minimax-zh.jpeg)](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)
MiniMax M2.5 在编程、工具调用与搜索、办公等核心生产力场景均达到或刷新行业 SOTA,拥有架构师级代码能力与高效任务拆解能力,推理速度较上一代提升 37%、token 消耗更优;100 token/s 连续工作一小时仅需 1 美金,让复杂 Agent 规模化部署经济可行,已在企业多职能场景深度落地,加速全民 Agent 时代到来
MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构建复杂 Agent Harness,并基于 Agent Teams、复杂 Skills、Tool Search Tool 等能力完成高复杂度生产力任务;其在软件工程、端到端项目交付及办公场景中表现优异,多项评测接近行业领先水平,同时具备稳定的复杂任务执行、环境交互能力以及良好的情商与身份保持能力
[点击](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)即可领取 MiniMax Coding Plan 专属 88 折优惠!
[点击此处](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link) MiniMax Token Plan 专属 88 折优惠!
---
@@ -34,6 +34,11 @@ MiniMax M2.5 在编程、工具调用与搜索、办公等核心生产力场景
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,首次充值可以享受9折优惠!</td>
</tr>
<tr>
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_zh.jpg" alt="SiliconFlow" width="150"></a></td>
<td>感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过<a href="https://cloud.siliconflow.cn/i/drGuwc9k">此链接</a>注册并完成实名认证,即可获得 ¥20 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。</td>
</tr>
<tr>
<td width="180"><a href="https://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>
@@ -57,8 +62,8 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
</tr>
<tr>
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Ucloud" width="150"></a></td>
<td>感谢优云智算赞助了本项目!优云智算是UCloud旗下AI云平台,提供稳定、全面的国内外模型API,仅一个key即可调用。主打包月、按量的高性价比 Coding Plan 套餐,基于官方2~5折优惠。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24技术支持、自助开票。通过<a href="https://www.compshare.cn/?ytag=GPU_YY_YX_git_cc-switch">此链接</a>注册的用户,可得免费5元平台体验金!</td>
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="优云智算" width="150"></a></td>
<td>感谢优云智算赞助了本项目!优云智算是UCloud旗下AI云平台,提供稳定、全面的国内外模型API,仅一个key即可调用。主打包月、按量的高性价比 Coding Plan 套餐,基于官方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>
@@ -72,7 +77,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
</tr>
<tr>
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
<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>感谢 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>
@@ -81,6 +86,26 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
<td>感谢 SSSAiCode 赞助了本项目!SSSAiCode 是一家稳定可靠的API中转站,致力于提供稳定、可靠、平价的Claude、CodeX模型服务,<strong>提供高性价比折合0.5¥/$的官方Claude服务</strong>,支持包月、Paygo多种计费方式、支持当日快速开票,SSSAiCode为本软件的用户提供特别优惠,使用<a href="https://www.sssaicode.com/register?ref=DCP0SM">此链接</a>注册每次充值均可享受10$的额外奖励!</td>
</tr>
<tr>
<td width="180"><a href="https://www.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>
</tr>
<tr>
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
<td>感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://ctok.ai">这里</a>注册!</td>
</tr>
<tr>
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
<td>感谢 厨师长AI小铺 赞助了本项目!厨师长AI小铺 是一家专为 AI 重度订阅用户量身定制的优质账号服务商。平台提供涵盖 ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy 以及 Gemini 等主流大模型的官方代充与稳定成品账号服务。点击<a href="https://chefshop.ai">这里</a>购买!</td>
</tr>
</table>
</details>
@@ -107,7 +132,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
## 功能特性
[完整更新日志](CHANGELOG.md) | [发布说明](docs/release-notes/v3.12.0-zh.md)
[完整更新日志](CHANGELOG.md) | [发布说明](docs/release-notes/v3.12.3-zh.md)
### 供应商管理
@@ -165,9 +190,9 @@ CC Switch 使用“通用配置片段”功能,在不同的供应商之间传
</details>
<details>
<summary><strong>macOS 提示"未知开发者"警告 — 如何解决?</strong></summary>
<summary><strong>macOS 安装</strong></summary>
这是由于作者没有苹果开发者账号(正在注册中)。关闭警告后,前往**系统设置 → 隐私与安全性 → 仍要打开**。之后应用即可正常打开
CC Switch macOS 版本已通过 Apple 代码签名和公证,可直接下载安装,无需额外操作。推荐使用 `.dmg` 安装包
</details>
@@ -194,6 +219,7 @@ CC Switch 使用“通用配置片段”功能,在不同的供应商之间传
- **本地设置**`~/.cc-switch/settings.json`(设备级 UI 偏好设置)
- **备份**`~/.cc-switch/backups/`(自动轮换,保留最近 10 个)
- **SKILLS**`~/.cc-switch/skills/`(默认通过软链接连接到对应应用)
- **技能备份**`~/.cc-switch/skill-backups/`(卸载前自动创建,保留最近 20 个)
</details>
@@ -226,7 +252,7 @@ CC Switch 使用“通用配置片段”功能,在不同的供应商之间传
### 系统要求
- **Windows**Windows 10 及以上
- **macOS**macOS 10.15 (Catalina) 及以上
- **macOS**macOS 12 (Monterey) 及以上
- **Linux**Ubuntu 22.04+ / Debian 11+ / Fedora 34+ 等主流发行版
### Windows 用户
@@ -250,9 +276,9 @@ brew upgrade --cask cc-switch
**方式二:手动下载**
从 [Releases](../../releases) 页面下载 `CC-Switch-v{版本号}-macOS.zip` 解压使用
从 [Releases](../../releases) 页面下载 `CC-Switch-v{版本号}-macOS.dmg`(推荐)或 `.zip`
> **注意**由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开。
> **注意**CC Switch macOS 版本已通过 Apple 代码签名和公证,可直接安装打开。
### Arch Linux 用户
Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 902 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 895 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1075.78 240.6">
<defs>
<style>
.cls-1 {
fill: #068cde;
}
.cls-2 {
fill: #02a4fd;
}
.cls-3 {
fill: #fff;
}
.cls-4 {
fill: #02a6ff;
}
</style>
</defs>
<g id="_图层_1-2" data-name="图层 1">
<path class="cls-1" d="M226.14,157.63c-3.62,0-7.24,0-10.95,0v-24.96c5.2,0,10.17,0,15.13,0,5.55-.01,8.52-4.01,10.18-8.16,1.34-3.37,1.36-7.51-1.34-11.1-3.16-4.2-7.23-5.72-12.25-5.63-3.87.07-7.74.01-11.66.01v-24.79c6.56-.43,12.93.45,19.3-1.13.4-.42.85-1.05,1.44-1.49,4.61-3.47,6.48-9.22,4.67-14.51-1.63-4.76-6.67-8.03-12.22-7.99-4.37.03-8.74,0-13.42,0,0-5.79.1-11.16-.04-16.54-.08-3.23-1.09-6.23-3.22-8.79-7.6-9.17-17.84-6.02-28.13-6.29-.39-.23-.63-1.14-.45-2.35.73-4.72.37-9.44-.24-14.13-.51-3.95-5.79-9.24-9.1-9.56-10.42-1-15.88,5.21-15.83,14.89.02,3.54,0,7.08,0,10.92h-24.44c-.76-1.03-.45-2.13-.42-3.19.15-5.2.71-10.35-1.11-15.5-2.15-6.08-11.68-9.27-17.05-5.79-5.27,3.41-7.22,7.95-6.99,13.99.14,3.56.53,7.22-.44,10.6h-23.98c-.22-.38-.37-.51-.37-.66-.05-4.56,0-9.12-.14-13.68-.15-5.18-4.72-10.76-9.31-11.57-8.81-1.55-15.64,4.23-15.64,13.24,0,4.11,0,8.21,0,12.61-4.92,0-9.32.08-13.72-.02-10.41-.22-18.81,7.79-17.84,18.57.39,4.32.06,8.7.06,13.34-5.17,0-9.9-.05-14.63.01-5.36.07-11.08,5.57-11.83,10.1-1.39,8.44,6.23,15.25,14.55,14.78,3.86-.22,7.74-.04,11.75-.04v24.92c-4.45,0-8.67-.07-12.89.02-3.2.07-6.5.62-8.75,2.93-3.6,3.69-6.1,8.11-4.12,13.5,2.13,5.8,6.42,8.43,12.98,8.43,4.21,0,8.42,0,12.83,0v24.95c-3.48,0-6.79-.12-10.08.03-3.74.18-7.43.36-10.78,2.69-4.11,2.87-6.48,8.21-5.32,12.83,1.1,4.36,7.17,9.83,11.59,9.41,4.82-.46,9.72-.1,14.69-.1,0,5.18.51,9.88-.1,14.44-1.36,10,8.64,18.06,17.22,17.5,4.62-.3,9.28-.05,14.15-.05,1.26,9.11-3.28,19.95,9.5,25.9,10.27,1.43,15.74-3.33,15.75-15.14,0-3.45,0-6.9,0-10.55h24.94c0,3.39,0,6.6,0,9.8,0,3.81.26,7.41,2.73,10.76,3.09,4.2,8.64,6.68,14,4.87,3.62-1.22,8.31-5.43,8.3-10.7,0-4.85,0-9.7,0-14.7h24.92c0,3.98-.14,7.7.03,11.41.22,4.83,1.4,9.35,5.68,12.32,5.16,3.59,12.81,2.96,17.28-2.41,3.93-7.43,2.05-14.57,2.39-21.58,5.71,0,11.1.03,16.49-.02,1.98-.02,4.05-.06,5.8-1.09,6.78-3.99,9.8-9.94,9.36-17.81-.23-4.18-.04-8.39-.04-12.56,8.59-1.68,18.23,2.96,24.73-6.3.08-.31.31-1.1.5-1.9.97-4.19,1.82-8.06-1.59-12.01-3.49-4.05-7.66-5.05-12.52-5.04ZM168.3,160.54c0,3.41-1.48,4.58-4.69,4.49-4.41-.12-8.82-.09-13.23-.05-3.15.03-4.43-1.39-4.41-4.56.06-16.85.02-33.69-.03-50.54,0-.99.44-2.13-.73-3.15-4.49,13.97-8.94,27.8-13.44,41.79h-21.8c-4.39-13.78-8.8-27.62-13.55-42.54-.15,1.94-.29,2.89-.29,3.84-.01,16.68-.08,33.36.04,50.04.03,3.7-1.18,5.38-5.05,5.16-5.51-.31-11.08.38-16.22-.44-1.24-1.59-1.21-2.95-1.21-4.26.07-27.3.18-54.6.22-81.9,0-2.16.89-3.46,2.97-3.48,9.9-.06,19.8,0,29.7.05.31,0,.63.19,1.39.44,4.22,14.29,8.51,28.79,12.8,43.3.29.05.58.1.87.15,4.53-14.59,9.07-29.17,13.66-43.95,10.35,0,20.48-.03,30.62.03,1.76.01,2.38,1.37,2.42,2.92.08,2.57.1,5.14.1,7.71-.06,24.98-.16,49.95-.15,74.93Z"/>
<rect class="cls-4" x="48.86" y="48.46" width="143.67" height="143.67" rx="10.57" ry="10.57"/>
<path class="cls-3" d="M165.55,75.28c-10.14-.06-20.27-.03-30.62-.03-4.59,14.78-9.12,29.36-13.66,43.95-.29-.05-.58-.1-.87-.15-4.29-14.51-8.58-29.01-12.8-43.3-.77-.25-1.08-.44-1.39-.44-9.9-.04-19.8-.1-29.7-.05-2.08.01-2.96,1.32-2.97,3.48-.04,27.3-.15,54.6-.22,81.9,0,1.31-.03,2.67,1.21,4.26,5.13.82,10.7.13,16.22.44,3.87.22,5.07-1.46,5.05-5.16-.12-16.68-.06-33.36-.04-50.04,0-.95.14-1.9.29-3.84,4.75,14.91,9.16,28.76,13.55,42.54h21.8c4.5-13.99,8.94-27.82,13.44-41.79,1.18,1.01.73,2.16.73,3.15.04,16.85.09,33.69.03,50.54-.01,3.17,1.26,4.59,4.41,4.56,4.41-.04,8.82-.07,13.23.05,3.21.09,4.69-1.08,4.69-4.49-.01-24.98.09-49.95.15-74.93,0-2.57-.02-5.14-.1-7.71-.05-1.55-.67-2.91-2.42-2.92Z"/>
<g>
<path class="cls-2" d="M372.64,135.48c-.13-.49-.54-.78-.71-.89-7.15-4.87-14.15-9.95-21.35-14.75-4.85-3.24-7.95-5.93-8.98-6.85-3.54-3.13-6.16-6.05-7.88-8.11,18.27.05,31.65-.03,32.44-.05.12,0,.6-.03.98-.42.22-.22.31-.45.35-.59.02-4.96.04-9.91.06-14.87,0-.78-.62-1.42-1.39-1.42-8.12.04-16.23.08-24.35.12,4.03-4.67,7.45-8.18,9.86-10.55,2.43-2.39,4.46-5.14,6.77-7.64,1.93-2.09,4.11-4.37,3.5-6.38-.2-.66-.63-1.08-.87-1.29-3.46-2.9-6.93-5.8-10.39-8.7-.41-.25-1.2-.63-2.04-.42-.82.21-1.3.88-1.47,1.12-3.4,4.75-5.17,7.07-5.2,7.12,0,0-1.32,2.51-13.36,16.27-.12.14-.48.54-.48,1.08,0,.5.31.88.49,1.07,2.96,2.73,5.93,5.46,8.89,8.2h-10.94v-37.5c0-.78-.62-1.42-1.39-1.42h-15.19c-.77,0-1.39.64-1.39,1.42v37.5h-13.87l10.91-9.45c.55-.48.65-1.31.23-1.91-1.08-1.54-2.47-3.35-4.11-5.37-1.64-2.02-3.6-4.28-5.8-6.7-2.13-2.43-4.13-4.59-5.93-6.43-1.8-1.83-3.5-3.43-5.06-4.75-.53-.45-1.31-.43-1.82.04l-10.51,9.67c-.29.27-.46.65-.46,1.05s.17.78.46,1.05c.96.88,2.1,2.06,3.39,3.49,1.33,1.47,2.71,3.04,4.15,4.7,1.44,1.66,2.87,3.36,4.26,5.04,1.41,1.71,2.71,3.27,3.89,4.68,1.17,1.39,2.11,2.54,2.83,3.44.86,1.09,1.04,1.35,1.04,1.35.02.03.03.06.05.09h-23.14c-.77,0-1.39.64-1.39,1.42v14.45c0,.78.62,1.42,1.39,1.42,10.73.14,21.47.29,32.2.43-3.44,3.44-6.64,6.34-9.41,8.73-4.74,4.08-8.53,6.9-9.53,7.64-5.15,3.8-7.72,5.7-9.46,6.47,0,0-1.99,2.06-9.47,6.03-.37.2-.63.55-.72.96-.09.41.01.84.27,1.18,3.31,4.84,6.62,9.68,9.93,14.51,2.17-1.39,5.05-3.3,8.34-5.71,1.13-.83,4.24-3.11,8.34-6.5,6.58-5.43,8.21-7.48,15.62-13.59,1.43-1.18,2.61-2.13,3.36-2.73v32.48c0,.78.62,1.42,1.39,1.42h15.19c.77,0,1.39-.64,1.39-1.42v-32.91c2.78,2.65,6.21,5.83,10.21,9.33,10.52,9.2,9.2,6.82,12.78,10.6,0,0,7.52,6.23,12.33,9.29.65.41,1.5.21,1.91-.45l8.68-14c.21-.34.27-.75.17-1.13Z"/>
<g>
<path class="cls-2" d="M481.65,98.3h-43.96c-.78,0-1.42.63-1.42,1.42v51.72c0,.78.63,1.42,1.42,1.42h43.96c.78,0,1.42-.63,1.42-1.42v-51.72c0-.78-.63-1.42-1.42-1.42ZM467.34,137.4h-15.33v-4.1h15.33v4.1ZM467.34,118.08h-15.33v-4.1h15.33v4.1Z"/>
<path class="cls-2" d="M485.91,80.91h-8.67v-6.03h6.54c.78,0,1.42-.63,1.42-1.42v-13.3c0-.78-.63-1.42-1.42-1.42h-6.54v-9.15c0-.78-.63-1.42-1.42-1.42h-12.56c-.78,0-1.42.63-1.42,1.42v9.15h-4.46v-9.15c0-.78-.63-1.42-1.42-1.42h-12.45c-.78,0-1.42.63-1.42,1.42v9.15h-5.54c-.56,0-1.04.32-1.27.79v-5.86c0-.78-.63-1.42-1.42-1.42h-48.55c-.78,0-1.42.63-1.42,1.42v12.96c0,.78.63,1.42,1.42,1.42h11.48v5.24h-9.91c-.78,0-1.42.63-1.42,1.42v76.27c0,.78.63,1.42,1.42,1.42h46.2c.78,0,1.42-.63,1.42-1.42v-54.75c.26.29.63.46,1.05.46h50.35c.78,0,1.42-.63,1.42-1.42v-12.96c0-.78-.63-1.42-1.42-1.42ZM421.7,136.6h-23.18v-3.98h23.18v3.98ZM421.7,117.28h-19.16c1.54-2.24,2.74-4.23,3.57-5.91.83-1.69,1.57-3.33,2.19-4.91,1.19-3.22,1.77-7.95,1.77-14.47v-3.02h.08v13.36c0,3.98.93,7.04,2.78,9.08,1.84,2.04,4.77,3.29,8.61,3.69l.17.03v2.16ZM442.11,80.91h-6.54c-.42,0-.79.18-1.05.46v-6.66c0-.78-.63-1.42-1.42-1.42h-9.57v-5.24h10.36c.56,0,1.04-.32,1.27-.79v6.2c0,.78.63,1.42,1.42,1.42h5.54v6.03ZM461.85,80.91h-4.46v-6.03h4.46v6.03Z"/>
</g>
<path class="cls-2" d="M608.47,127.84c-4.56-1.17-9.11-2.33-13.67-3.5-.16-20.74-.33-41.47-.49-62.21,0-.79-.63-1.43-1.42-1.43h-34.31v-10.58c0-.79-.63-1.43-1.42-1.43h-14.6c-.78,0-1.42.64-1.42,1.43v10.58h-32.96c-.78,0-1.42.64-1.42,1.43v66c0,.79.63,1.43,1.42,1.43h32.96v6.12c0,3.15.28,5.89.83,8.12.57,2.35,1.57,4.34,2.95,5.92,1.39,1.59,3.28,2.81,5.6,3.61,2.2.76,4.97,1.27,8.25,1.51,1.43.07,3.35.15,5.76.23,2.44.08,4.95.11,7.46.11s5-.02,7.33-.06c2.4-.04,4.24-.14,5.62-.29,3.19-.31,5.93-.72,8.16-1.23,3.05-.7,5.13-2.13,5.99-2.65,1.51-.92,3.64-2.22,5.55-4.66,1.81-2.3,2.48-4.4,3.28-6.89.81-2.52,1.79-5.71,1.06-9.61-.15-.82-.35-1.48-.5-1.94ZM541.15,112.97h-17.16v-9.73h17.16v9.73ZM541.15,87.12h-17.16v-9.84h17.16v9.84ZM558.59,77.28h18.51v9.84h-18.51v-9.84ZM558.59,103.24h18.51v9.73h-18.51v-9.73ZM590.3,133.5c-.06.19-.43,1.31-.83,1.94-1.12,1.76-3.83,1.83-12.61,1.89-7.06.05-.21-.03-7.33-.06-5.89-.02-7.51.05-8.56-1.22-1.02-1.24-1.31-3.54-1.44-4.56-.1-.8-.12-1.48-.11-1.95,10.48.04,20.96.08,31.44.12.02.9-.05,2.27-.56,3.83Z"/>
<path class="cls-2" d="M721.16,93.62h-39.92v-.2c6.24-3.1,12.4-6.63,18.31-10.49,6.14-4.01,12.31-8.35,18.34-12.9.35-.27.56-.69.56-1.13v-14.44c0-.78-.63-1.42-1.42-1.42h-87.02c-.78,0-1.42.63-1.42,1.42v14.32c0,.78.63,1.42,1.42,1.42h54.17c-.81.75-2.1,1.91-3.75,3.22-3.49,2.77-5.95,4.13-9.75,6.58-1.85,1.19-4.54,2.98-7.75,5.33-.03,2.76-.06,5.52-.1,8.29h-41.41c-.78,0-1.41.63-1.41,1.42v15c0,.78.63,1.42,1.41,1.42h41.41v21.77c0,1.22-.04,2.25-.11,3.05-.05.57-.06,1.11-.42,1.34-.35.22-.82.04-1.08-.04-1.08-.36-2.28-.11-3.42-.17-2.27-.12-2.51.11-5,.04-2.39-.07-4.79.16-7.17-.08-.27-.03-.93-.1-1.29.29-.44.48-.17,1.38-.04,1.75,1.43,4.35,2.86,8.69,4.29,13.04.11.5.34,1.22.92,1.83,1.16,1.24,2.98,1.26,4.12,1.25,9.2-.06,11.21-.21,11.21-.21,4.83-.36,5.78-.39,7.58-.96,1.76-.56,3.69-1.19,5.38-2.95,1.68-1.74,2.36-3.6,2.76-5.45.44-2.03.66-4.52.66-7.4v-27.11h39.92c.78,0,1.41-.63,1.41-1.42v-15c0-.78-.63-1.42-1.41-1.42Z"/>
<path class="cls-2" d="M839.47,131.72h-40.52v-59.68h33.89c.78,0,1.42-.63,1.42-1.42v-15.57c0-.78-.63-1.42-1.42-1.42h-86.74c-.78,0-1.42.63-1.42,1.42v15.57c0,.78.63,1.42,1.42,1.42h33.55v59.68h-40.41c-.78,0-1.42.63-1.42,1.42v15.35c0,.78.63,1.42,1.42,1.42h100.22c.78,0,1.42-.63,1.42-1.42v-15.35c0-.78-.63-1.42-1.42-1.42Z"/>
<path class="cls-2" d="M955.36,61.13h-39.83c.46-1.11.9-2.22,1.3-3.32.65-1.74,1.31-3.52,2-5.34.14-.37.12-.79-.06-1.14-.18-.35-.5-.62-.88-.72l-14.29-3.98c-.73-.2-1.49.2-1.73.93-2.48,7.62-5.66,15.09-9.45,22.22-3,5.64-6.11,10.87-9.28,15.62v-12.58c1.19-3.03,2.37-6.23,3.52-9.52,1.16-3.3,2.38-6.9,3.73-10.99.12-.36.09-.76-.09-1.1-.18-.34-.48-.59-.85-.7l-13.84-4.21c-.36-.11-.76-.07-1.09.11-.33.18-.58.49-.68.86-1.13,4.04-2.62,8.43-4.42,13.05-1.81,4.65-3.82,9.34-5.97,13.95-2.16,4.62-4.45,9.15-6.82,13.44-2.37,4.3-4.71,8.14-6.96,11.42-.42.61-.3,1.43.27,1.9l11.21,9.21c.31.26.72.37,1.12.31.4-.06.75-.29.97-.63l1.97-3.03v46.25c0,.78.63,1.42,1.42,1.42h15.09c.78,0,1.42-.63,1.42-1.42v-57.07l8.02,6.03c.62.47,1.5.35,1.98-.27,2.9-3.8,5.62-7.74,8.08-11.71,2.03-3.29,3.95-6.68,5.73-10.12v73.83c0,.78.63,1.42,1.42,1.42h14.98c.78,0,1.42-.63,1.42-1.42v-20.29h27.52c.78,0,1.42-.63,1.42-1.42v-15.12c0-.78-.63-1.42-1.42-1.42h-27.52v-10.01h25.11c.78,0,1.42-.63,1.42-1.42v-14.89c0-.78-.63-1.42-1.42-1.42h-25.11v-9.21h30.6c.78,0,1.42-.63,1.42-1.42v-14.66c0-.78-.63-1.42-1.42-1.42Z"/>
<path class="cls-2" d="M1074.36,137.97h-41.39v-4.55h31.04c.78,0,1.42-.63,1.42-1.42v-13.19c0-.78-.63-1.42-1.42-1.42h-2.25l8.41-9.22c.49-.53.5-1.35.02-1.89-2.83-3.22-6.98-7.17-12.38-11.75l-3.96-3.29h9.69c.78,0,1.42-.63,1.42-1.42v-10.52h8.59c.78,0,1.42-.63,1.42-1.42v-19.1c0-.78-.63-1.42-1.42-1.42h-39.96l-2.28-8.83c-.17-.67-.81-1.12-1.49-1.06l-16.17,1.36c-.42.04-.8.25-1.04.59-.24.34-.32.77-.21,1.18.61,2.31,1.17,4.58,1.68,6.75h-39.86c-.78,0-1.42.63-1.42,1.42v19.1c0,.78.63,1.42,1.42,1.42h8.47v10.52c0,.78.63,1.42,1.42,1.42h11.51c-1.07.86-2.12,1.69-3.13,2.46-1.91,1.47-3.64,2.66-5.15,3.54-1.12.66-2.24,1.28-3.31,1.84-.94.49-1.91.8-2.9.93-.38.05-.71.25-.94.55-.23.3-.33.68-.28,1.06l1.63,11.71c.1.69.68,1.21,1.38,1.22,5.55.08,11.18.06,16.74-.06,5.06-.1,10.15-.22,15.25-.36v3.26h-31.39c-.78,0-1.42.63-1.42,1.42v13.19c0,.78.63,1.42,1.42,1.42h31.39v4.55h-41.86c-.78,0-1.42.63-1.42,1.42v12.62c0,.78.63,1.42,1.42,1.42h101.32c.78,0,1.42-.63,1.42-1.42v-12.62c0-.78-.63-1.42-1.42-1.42ZM1055.75,115.62c.57.62,1.11,1.21,1.64,1.77h-24.42v-3.81c3.26-.13,6.47-.27,9.64-.4,3.37-.14,6.82-.32,10.27-.53,1.04,1.03,2.01,2.03,2.87,2.97ZM990.4,76.13v-3.3h66.73v3.3h-66.73ZM1009.78,99.75c1.14-.79,2.29-1.62,3.43-2.48,2.38-1.78,4.82-3.8,7.26-6.03h15.11l-2.47,2.47c-.29.29-.44.7-.41,1.11s.24.79.58,1.03c.92.68,1.91,1.41,2.95,2.2.21.16.43.33.66.51-5.44.39-10.57.68-15.27.87-4.02.16-7.98.26-11.83.31Z"/>
</g>
<g>
<polygon class="cls-2" points="700.41 193.09 700.29 193.09 695.84 175.52 688.42 175.52 688.42 199.6 692.65 199.6 692.65 179.03 692.76 178.9 698.35 199.6 702.12 199.6 708.05 178.9 708.05 179.03 708.05 199.6 712.28 199.6 712.28 175.52 705.2 175.52 700.41 193.09"/>
<path class="cls-2" d="M727.36,178.51c3.04.09,4.65,1.61,4.83,4.56h5.64c-.36-5.47-3.85-8.24-10.47-8.33-6.62.26-10.07,4.47-10.33,12.63.18,7.98,3.62,12.06,10.33,12.23,6.71-.09,10.2-2.78,10.47-8.07h-5.64c-.09,2.95-1.7,4.47-4.83,4.56-2.95-.17-4.52-3.08-4.7-8.72.18-5.73,1.75-8.68,4.7-8.85Z"/>
<path class="cls-2" d="M756.74,188.14c.08,2.86-.24,4.82-.98,5.86-.73,1.22-2.03,1.82-3.9,1.82s-3.21-.61-4.02-1.82c-.73-1.04-1.06-2.99-.97-5.86v-13.15h-4.87v15.1c.16,5.99,3.45,9.07,9.87,9.24,6.25-.17,9.5-3.25,9.75-9.24v-15.1h-4.87v13.15Z"/>
<polygon class="cls-2" points="782.14 188.79 792.21 188.79 792.21 184.76 782.14 184.76 782.14 179.16 792.98 179.16 792.98 175.13 776.98 175.13 776.98 199.2 793.37 199.2 793.37 195.17 782.14 195.17 782.14 188.79"/>
<rect class="cls-2" x="797.64" y="174.74" width="4.33" height="24.08"/>
<path class="cls-2" d="M822.27,188.31c-.09-1.04-.4-2.04-.94-2.99-1.34-2.6-3.8-3.9-7.38-3.9-5.19.26-7.92,3.21-8.19,8.85-.09,5.81,2.64,8.68,8.19,8.59,4.83,0,7.47-1.73,7.92-5.21h-4.7c-.45,1.48-1.52,2.17-3.22,2.08-2.06.09-3.04-1.3-2.95-4.17h11.41c0-1.13-.05-2.21-.13-3.25ZM810.99,188.18c.09-2.34,1.07-3.51,2.95-3.51,2.06,0,3.09,1.17,3.09,3.51h-6.04Z"/>
<path class="cls-2" d="M831.26,189.46c.28-3.34,1.07-5.01,2.37-5.01,1.67,0,2.6,1.08,2.79,3.25h5.3c-.19-4.24-2.84-6.45-7.96-6.63-4.93.36-7.63,3.43-8.1,9.2.37,5.78,3.07,8.75,8.1,8.93,5.02,0,7.68-2.21,7.96-6.63h-5.3c-.09,2.26-.98,3.38-2.65,3.38-1.49,0-2.33-1.8-2.51-5.41v-1.08Z"/>
<path class="cls-2" d="M927.16,189.69c.28-3.34,1.07-5.01,2.37-5.01,1.67,0,2.6,1.08,2.79,3.25h5.3c-.19-4.24-2.84-6.45-7.96-6.63-4.93.36-7.63,3.43-8.1,9.2.37,5.78,3.07,8.75,8.1,8.93,5.02,0,7.68-2.21,7.96-6.63h-5.3c-.09,2.26-.98,3.38-2.65,3.38-1.49,0-2.33-1.8-2.51-5.41v-1.08Z"/>
<path class="cls-2" d="M854.41,195.73c-1.36.26-1.97-.65-1.8-2.73v-7.81h3.37v-3.38h-3.37v-5.08c-1.56,0-3.12,0-4.69,0,0,1.69,0,3.39,0,5.08h-3.13v3.38h3.13c0,3.23-.02,6.45-.03,9.68.03.49.17,2.15,1.47,3.24.82.7,1.73.84,2.75,1,.82.13,2.18.24,3.88-.17,0-1.11,0-2.23-.01-3.34-.48.09-1,.13-1.56.13Z"/>
<path class="cls-2" d="M999.9,195.61c-1.36.26-1.97-.65-1.8-2.73v-7.81h3.37v-3.38h-3.37v-5.08c-1.56,0-3.12,0-4.69,0,0,1.69,0,3.39,0,5.08h-3.13v3.38h3.13l-.03,9.68c.03.49.17,2.15,1.47,3.24.82.7,1.73.84,2.75,1,.82.13,2.18.24,3.88-.17v-3.34c-.5.09-1.02.13-1.58.13Z"/>
<path class="cls-2" d="M863.85,184.64h-.13v-3.25h-4.7c0,.54.04,1.31.13,2.3v15.17h5.1v-9.21c.18-1.08.4-1.94.67-2.57.45-.54,1.3-.95,2.55-1.22h2.28v-4.6c-2.95-.18-4.92.95-5.91,3.39Z"/>
<path class="cls-2" d="M881.19,181.41c-5.4.26-8.23,3.21-8.49,8.85.25,5.55,3.08,8.42,8.49,8.59,5.4-.17,8.23-3.04,8.49-8.59-.25-5.64-3.08-8.59-8.49-8.85ZM881.19,195.73c-2.28,0-3.42-1.82-3.42-5.47s1.14-5.6,3.42-5.6c2.45,0,3.63,1.87,3.55,5.6,0,3.64-1.18,5.47-3.55,5.47Z"/>
<path class="cls-2" d="M908.77,185.85c-.08-.78-.14-1.32-.38-1.9-.5-1.26-1.5-1.96-1.82-2.16-1.45-.93-2.93-.77-3.33-.71-.79-.01-2.53.08-4.12,1.26-.46.34-.82.71-1.1,1.06,0-.67,0-1.34-.01-2h-5.1v17.48h5.1v-10.43c.27-2.53,1.3-3.88,3.09-4.07,2.06,0,3.09,1.36,3.09,4.07v10.43c1.56,0,3.12,0,4.68.01,0-3.79-.02-7.57-.03-11.36.01-.42,0-.99-.07-1.67Z"/>
<rect class="cls-2" x="913.06" y="174.68" width="4.81" height="4.29"/>
<rect class="cls-2" x="913.18" y="181.97" width="4.58" height="16.79"/>
<path class="cls-2" d="M950.52,188.05c-2.08-.52-3.12-1.13-3.12-1.82,0-1.04.62-1.56,1.87-1.56,1.33.09,2.04.65,2.12,1.69h4.37c-.25-3.3-2.41-4.95-6.49-4.95-4.41.35-6.7,2-6.86,4.95-.17,2.86,1.79,4.69,5.86,5.47,2.08.44,3.16,1.13,3.24,2.08,0,1.22-.75,1.82-2.24,1.82-1.58-.09-2.45-.74-2.62-1.95h-4.49c.17,3.3,2.54,4.99,7.11,5.08,4.57-.35,6.99-2.08,7.24-5.21-.67-3.47-2.66-5.34-5.99-5.6Z"/>
<path class="cls-2" d="M980.42,184.99c-.32-.09-.51-.13-.59-.13-2.84-.43-4.18-1.56-4.02-3.38.08-1.91,1.26-2.95,3.55-3.12,2.29,0,3.47,1.22,3.55,3.64h4.5c-.24-4.69-2.72-7.16-7.45-7.42-5.84.35-8.87,2.99-9.11,7.94,0,3.21,2.4,5.42,7.22,6.64.16.09.43.17.83.26,2.76.61,4.14,1.74,4.14,3.38-.08,2-1.54,3.04-4.38,3.12-2.45-.17-3.67-1.65-3.67-4.43h-4.73c-.08,5.29,2.72,7.94,8.4,7.94,6.15-.17,9.26-2.78,9.34-7.81.08-3.3-2.45-5.51-7.57-6.64Z"/>
<path class="cls-2" d="M1020.78,181.81h-3.98c0,3.43.01,6.87.02,10.3,0,.19-.07,2.13-1.58,3.1-.96.62-2.02.54-2.38.52-.37-.03-1.06-.05-1.65-.47-.81-.57-1.24-1.68-1.29-3.31v-10.15h-4.23c-.01,3.67-.03,7.34-.04,11.01-.01.42.01,1.04.21,1.75.62,2.23,2.61,4.11,4.98,4.62,2.07.45,3.82-.28,4.27-.48.4-.17.72-.36.95-.5,0,.34,0,.68.01,1.02,1.58-.01,3.15-.03,4.73-.04,0-1.2-.01-2.39-.02-3.59v-13.8Z"/>
<path class="cls-2" d="M1042.61,174.52h-4.95v9.24c-1.1-1.47-2.58-2.26-4.44-2.34-4.74.26-7.27,3.21-7.61,8.85.34,5.55,2.71,8.42,7.1,8.59,2.37,0,4.01-.87,4.95-2.6,0,.26.04.65.13,1.17v1.17h4.95c-.09-1.04-.13-2.17-.13-3.38v-20.69ZM1034.24,195.73c-2.45,0-3.64-1.82-3.55-5.47-.09-3.73,1.1-5.6,3.55-5.6,2.11.17,3.25,2.04,3.42,5.6-.17,3.47-1.31,5.29-3.42,5.47Z"/>
<rect class="cls-2" x="1046.8" y="174.52" width="4.96" height="4.29"/>
<rect class="cls-2" x="1046.92" y="181.81" width="4.71" height="16.79"/>
<path class="cls-2" d="M1063.98,181.41c-5.35.26-8.14,3.21-8.39,8.85.25,5.55,3.05,8.42,8.39,8.59,5.34-.17,8.14-3.04,8.39-8.59-.25-5.64-3.05-8.59-8.39-8.85ZM1063.98,195.73c-2.25,0-3.38-1.82-3.38-5.47s1.13-5.6,3.38-5.6c2.42,0,3.59,1.87,3.51,5.6,0,3.64-1.17,5.47-3.51,5.47Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+1
View File
@@ -19,3 +19,4 @@
"hooks": "@/hooks"
}
}
+146
View File
@@ -0,0 +1,146 @@
# CC Switch v3.12.1
> Stability Fixes, StepFun Presets, OpenClaw authHeader, and New Sponsor Partners
**[中文版 →](v3.12.1-zh.md) | [日本語版 →](v3.12.1-ja.md)**
---
## Overview
CC Switch v3.12.1 is a patch release focused on stability improvements and bug fixes. It resolves a Common Config modal infinite reopen loop, a WebDAV sync foreign key constraint failure, and several i18n interpolation issues. It also adds **StepFun** provider presets, **OpenClaw input type selection** and **authHeader** support, upgrades the default Gemini model to **3.1-pro**, and welcomes four new sponsor partners.
**Release Date**: 2026-03-12
**Update Scale**: 19 commits | 56 files changed | +1,429 / -396 lines
---
## Highlights
- **Common Config modal fix**: Resolved an infinite reopen loop in the Common Config modal and added draft editing support
- **WebDAV sync reliability**: Fixed a foreign key constraint failure when restoring `provider_health` during WebDAV sync
- **StepFun presets**: Added StepFun (阶跃星辰) provider presets including the step-3.5-flash model
- **OpenClaw enhancements**: Added input type selection for model Advanced Options and `authHeader` field for vendor-specific auth header support
- **Gemini model upgrade**: Upgraded default Gemini model to 3.1-pro in provider presets
- **New sponsors**: Welcomed Micu API, XCodeAPI, SiliconFlow, and CTok as sponsor partners
---
## New Features
### StepFun Provider Presets
Added provider presets for StepFun (阶跃星辰), a leading Chinese AI model provider.
- New preset entries for StepFun across supported applications
- Includes the step-3.5-flash model (#1369, thanks @hengm3467)
### OpenClaw Enhancements
Enhanced the OpenClaw configuration with more granular control and better vendor compatibility.
- Added input type selection dropdown for model Advanced Options (#1368, thanks @liuxxxu)
- Added optional `authHeader` boolean to `OpenClawProviderConfig` for vendor-specific auth header support (e.g. Longcat), and refactored form state to reuse the shared type
### Sponsor Partners
- **Micu API**: Added Micu API as sponsor partner with affiliate links
- **XCodeAPI**: Added XCodeAPI as sponsor partner
- **SiliconFlow**: Added SiliconFlow (硅基流动) as sponsor partner with affiliate links
- **CTok**: Added CTok as sponsor partner
---
## Changes
- **UCloud → Compshare**: Renamed UCloud provider to Compshare (优云智算) with full i18n support across all three locales (EN/ZH/JA)
- **Compshare Links**: Updated Compshare sponsor registration links to coding-plan page
- **Gemini Model Upgrade**: Upgraded default Gemini model from 2.5-pro to 3.1-pro in provider presets
---
## Bug Fixes
### Common Config & UI
- Fixed an infinite reopen loop in the Common Config modal and added draft editing support to prevent data loss during edits
- Fixed toolbar compact mode not triggering on Windows due to left-side overflow (#1375, thanks @zuoliangyu)
- Fixed session search index not syncing with query data, causing stale list display after session deletion
### Sync & Data
- Fixed foreign key constraint failure when restoring `provider_health` table during WebDAV sync
### Provider & Preset
- Added missing `authHeader: true` to Longcat provider preset (#1377, thanks @wavever)
- Aligned OpenClaw tool permission profiles with upstream schema (#1355, thanks @bigsongeth)
- Corrected X-Code API URL from `www.x-code.cn` to `x-code.cc`
### i18n & Localization
- Fixed stream check toast i18n interpolation keys not matching translation placeholders
- Fixed proxy startup toast not interpolating address and port values (#1399, thanks @Mason-mengze)
- Renamed OpenCode API format label from "OpenAI" to "OpenAI Responses" for accuracy
---
## Special Thanks
Thanks to all contributors for their contributions to this release!
@hengm3467 @liuxxxu @bigsongeth @zuoliangyu @wavever @Mason-mengze
---
## Download & Installation
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
### System Requirements
| System | Minimum Version | Architecture |
| ------- | ------------------------------- | ----------------------------------- |
| Windows | Windows 10 or later | x64 |
| macOS | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |
| Linux | See table below | x64 |
### Windows
| File | Description |
| ------------------------------------------ | ---------------------------------------------------- |
| `CC-Switch-v3.12.1-Windows.msi` | **Recommended** - MSI installer with auto-update |
| `CC-Switch-v3.12.1-Windows-Portable.zip` | Portable version, extract and run, no registry write |
### macOS
| File | Description |
| ---------------------------------- | -------------------------------------------------------------------- |
| `CC-Switch-v3.12.1-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
| `CC-Switch-v3.12.1-macOS.tar.gz` | For Homebrew installation and auto-update |
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it, then go to "System Settings" -> "Privacy & Security" -> click "Open Anyway", and it will open normally afterwards.
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
### Linux
| Distribution | Recommended Format | Installation Method |
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | Add execute permission and run directly, or use AUR |
| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+146
View File
@@ -0,0 +1,146 @@
# CC Switch v3.12.1
> 安定性修正、StepFun プリセット、OpenClaw authHeader 対応、新スポンサーパートナー
**[中文版 →](v3.12.1-zh.md) | [English →](v3.12.1-en.md)**
---
## 概要
CC Switch v3.12.1 は、安定性の改善とバグ修正に焦点を当てたパッチリリースです。共通設定モーダルの無限再オープンループ、WebDAV 同期時の外部キー制約エラー、複数の i18n 補間問題を修正しました。また、**StepFun(阶跃星辰)** プロバイダープリセットの追加、OpenClaw の**入力タイプ選択**と **authHeader** サポート、デフォルト Gemini モデルの **3.1-pro** へのアップグレード、4 つの新スポンサーパートナーの追加が含まれます。
**リリース日**: 2026-03-12
**更新規模**: 19 commits | 56 files changed | +1,429 / -396 lines
---
## ハイライト
- **共通設定モーダル修正**: 共通設定モーダルの無限再オープンループを解決し、下書き編集サポートを追加
- **WebDAV 同期の信頼性向上**: WebDAV 同期で `provider_health` 復元時の外部キー制約エラーを修正
- **StepFun プリセット**: StepFun(阶跃星辰)プロバイダープリセットを追加、step-3.5-flash モデルを含む
- **OpenClaw 強化**: モデル詳細設定に入力タイプ選択を追加、ベンダー固有の認証ヘッダーサポート用 `authHeader` フィールドを追加
- **Gemini モデルアップグレード**: プロバイダープリセットのデフォルト Gemini モデルを 3.1-pro にアップグレード
- **新スポンサー**: Micu API、XCodeAPI、SiliconFlow、CTok をスポンサーパートナーとして追加
---
## 新機能
### StepFun プロバイダープリセット
中国の主要 AI モデルプロバイダーである StepFun(阶跃星辰)のプロバイダープリセットを追加しました。
- サポート対象アプリケーション全体に StepFun プリセットエントリーを追加
- step-3.5-flash モデルを含む(#1369@hengm3467 に感謝)
### OpenClaw 強化
OpenClaw 設定をより細かく制御でき、ベンダー互換性を向上させました。
- モデル詳細設定に入力タイプ(input type)選択ドロップダウンを追加(#1368@liuxxxu に感謝)
- `OpenClawProviderConfig` にオプションの `authHeader` ブール値を追加し、ベンダー固有の認証ヘッダー(例: Longcat)をサポート。フォーム状態を共有型の再利用にリファクタリング
### スポンサーパートナー
- **Micu API**: Micu API をスポンサーパートナーとして追加、アフィリエイトリンク付き
- **XCodeAPI**: XCodeAPI をスポンサーパートナーとして追加
- **SiliconFlow**: SiliconFlow(硅基流动)をスポンサーパートナーとして追加、アフィリエイトリンク付き
- **CTok**: CTok をスポンサーパートナーとして追加
---
## 変更
- **UCloud → Compshare**: UCloud プロバイダーを Compshare(优云智算)にリネームし、3 言語(EN/ZH/JA)の完全な i18n サポートを追加
- **Compshare リンク**: Compshare スポンサー登録リンクを coding-plan ページに更新
- **Gemini モデルアップグレード**: プロバイダープリセットのデフォルト Gemini モデルを 2.5-pro から 3.1-pro にアップグレード
---
## バグ修正
### 共通設定と UI
- 共通設定モーダルの無限再オープンループを修正し、編集中のデータ損失を防ぐための下書き編集サポートを追加
- Windows でツールバーコンパクトモードが左側のオーバーフローにより機能しない問題を修正(#1375@zuoliangyu に感謝)
- セッション削除後にクエリデータと検索インデックスが同期されず、リストが更新されない問題を修正
### 同期とデータ
- WebDAV 同期で `provider_health` テーブルを復元する際の外部キー制約エラーを修正
### プロバイダーとプリセット
- Longcat プロバイダープリセットに欠落していた `authHeader: true` を追加(#1377@wavever に感謝)
- OpenClaw のツール権限プロファイルをアップストリームスキーマに合わせて修正(#1355@bigsongeth に感謝)
- X-Code API の URL を `www.x-code.cn` から `x-code.cc` に修正
### i18n とローカリゼーション
- Stream Check トーストの i18n 補間キーが翻訳プレースホルダーと一致しない問題を修正
- プロキシ起動トーストでアドレスとポート値が補間されない問題を修正(#1399@Mason-mengze に感謝)
- OpenCode の API フォーマットラベルを「OpenAI」から「OpenAI Responses」にリネームし、正確性を向上
---
## 謝辞
以下のコントリビューターの皆様、このリリースへの貢献に感謝します!
@hengm3467 @liuxxxu @bigsongeth @zuoliangyu @wavever @Mason-mengze
---
## ダウンロードとインストール
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。
### システム要件
| システム | 最小バージョン | アーキテクチャ |
| -------- | -------------------------------- | ----------------------------------- |
| Windows | Windows 10 以降 | x64 |
| macOS | macOS 10.15 (Catalina) 以降 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 下表参照 | x64 |
### Windows
| ファイル | 説明 |
| ------------------------------------------ | ---------------------------------------------------- |
| `CC-Switch-v3.12.1-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
| `CC-Switch-v3.12.1-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
### macOS
| ファイル | 説明 |
| ---------------------------------- | ----------------------------------------------------------------- |
| `CC-Switch-v3.12.1-macOS.zip` | **推奨** - 解凍して Applications にドラッグ、Universal Binary |
| `CC-Switch-v3.12.1-macOS.tar.gz` | Homebrew インストールと自動更新用 |
> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| ディストリビューション | 推奨形式 | インストール方法 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用 |
| その他のディストリビューション / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+146
View File
@@ -0,0 +1,146 @@
# CC Switch v3.12.1
> 稳定性修复、StepFun 预设、OpenClaw authHeader 支持,以及新赞助商伙伴
**[English →](v3.12.1-en.md) | [日本語版 →](v3.12.1-ja.md)**
---
## 概览
CC Switch v3.12.1 是一个以稳定性改进和 Bug 修复为主的补丁版本。修复了通用配置弹窗无限重复打开的循环问题、WebDAV 同步时的外键约束失败以及多个 i18n 插值问题。同时新增了 **StepFun(阶跃星辰)** 供应商预设、OpenClaw **输入类型选择****authHeader** 支持,将默认 Gemini 模型升级到 **3.1-pro**,并欢迎四位新赞助商伙伴加入。
**发布日期**2026-03-12
**更新规模**19 commits | 56 files changed | +1,429 / -396 lines
---
## 重点内容
- **通用配置弹窗修复**:解决了通用配置弹窗无限重复打开的循环问题,并新增草稿编辑支持
- **WebDAV 同步可靠性**:修复了 WebDAV 同步恢复 `provider_health` 时的外键约束失败
- **StepFun 预设**:新增 StepFun(阶跃星辰)供应商预设,包含 step-3.5-flash 模型
- **OpenClaw 增强**:新增模型高级选项的输入类型选择和 `authHeader` 字段,支持供应商特定的认证头
- **Gemini 模型升级**:供应商预设中的默认 Gemini 模型升级到 3.1-pro
- **新赞助商**:欢迎 Micu API、XCodeAPI、SiliconFlow、CTok 加入赞助伙伴
---
## 新功能
### StepFun 供应商预设
新增 StepFun(阶跃星辰)供应商预设,阶跃星辰是领先的中国 AI 模型提供商。
- 在各支持应用中新增 StepFun 预设条目
- 包含 step-3.5-flash 模型(#1369,感谢 @hengm3467
### OpenClaw 增强
增强 OpenClaw 配置能力,提供更细粒度的控制和更好的供应商兼容性。
- 新增模型高级选项的输入类型(input type)选择下拉框(#1368,感谢 @liuxxxu
-`OpenClawProviderConfig` 中新增可选的 `authHeader` 布尔字段,支持供应商特定的认证头(如 Longcat),并重构表单状态以复用共享类型
### 赞助商伙伴
- **Micu API**:新增 Micu API 赞助商及推广链接
- **XCodeAPI**:新增 XCodeAPI 赞助商
- **SiliconFlow**:新增 SiliconFlow(硅基流动)赞助商及推广链接
- **CTok**:新增 CTok 赞助商
---
## 变更
- **UCloud → Compshare**:将 UCloud 供应商更名为 Compshare(优云智算),支持三种语言(中/英/日)的完整国际化
- **Compshare 链接**:更新 Compshare 赞助商注册链接指向 coding-plan 页面
- **Gemini 模型升级**:供应商预设中的默认 Gemini 模型从 2.5-pro 升级到 3.1-pro
---
## Bug 修复
### 通用配置与 UI
- 修复通用配置弹窗无限重复打开的循环问题,并新增草稿编辑支持以防止编辑过程中数据丢失
- 修复 Windows 下因左侧溢出导致工具栏紧凑模式不触发的问题(#1375,感谢 @zuoliangyu
- 修复会话删除后搜索索引未与查询数据同步,导致列表显示过期的问题
### 同步与数据
- 修复 WebDAV 同步恢复 `provider_health` 表时的外键约束失败
### 供应商与预设
- 为 Longcat 供应商预设补充缺失的 `authHeader: true`#1377,感谢 @wavever
- 对齐 OpenClaw 工具权限配置与上游 schema(#1355,感谢 @bigsongeth
- 修正 X-Code API URL,从 `www.x-code.cn` 改为 `x-code.cc`
### i18n 与本地化
- 修复 Stream Check Toast 的 i18n 插值 key 与翻译占位符不匹配
- 修复代理启动 Toast 未正确插值地址和端口的问题(#1399,感谢 @Mason-mengze
- 将 OpenCode API 格式标签从 "OpenAI" 改为 "OpenAI Responses",更准确地反映实际格式
---
## 特别感谢
感谢以下贡献者为本版本做出的贡献!
@hengm3467 @liuxxxu @bigsongeth @zuoliangyu @wavever @Mason-mengze
---
## 下载与安装
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
### 系统要求
| 系统 | 最低版本 | 架构 |
| ------- | ----------------------------- | ----------------------------------- |
| Windows | Windows 10 及以上 | x64 |
| macOS | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 见下表 | x64 |
### Windows
| 文件 | 说明 |
| ------------------------------------------ | ----------------------------------- |
| `CC-Switch-v3.12.1-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.12.1-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
### macOS
| 文件 | 说明 |
| ---------------------------------- | --------------------------------------------------------- |
| `CC-Switch-v3.12.1-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
| `CC-Switch-v3.12.1-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
### HomebrewmacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| 发行版 | 推荐格式 | 安装方式 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb``sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm``sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+138
View File
@@ -0,0 +1,138 @@
# CC Switch v3.12.2
> Common Config Protection During Proxy Takeover, Snippet Lifecycle Stability, Section-Aware Codex TOML Editing
**[中文版 →](v3.12.2-zh.md) | [日本語版 →](v3.12.2-ja.md)**
---
## Overview
CC Switch v3.12.2 is a reliability-focused patch release that addresses Common Config loss during proxy takeover and improves Codex TOML editing accuracy. Proxy takeover hot-switches and provider sync now update the restore backup instead of overwriting live config files; the startup sequence has been reordered so snippets are extracted from clean live files before takeover state is restored; and Codex `base_url` editing has been refactored into a section-aware model that no longer appends to the end of the file.
**Release Date**: 2026-03-12
**Update Scale**: 5 commits | 22 files changed | +1,716 / -288 lines
---
## Highlights
- **Empty state guidance**: Provider list empty state now shows detailed import instructions with a conditional Common Config snippet hint for Claude/Codex/Gemini
- **Proxy takeover restore flow rework**: Hot-switches and provider sync now refresh the restore backup instead of overwriting live config files, preserving the full user configuration on rollback
- **Snippet lifecycle stability**: Introduced a `cleared` flag to prevent auto-extraction from resurrecting cleared snippets, and reordered startup to extract from clean state
- **Section-aware Codex TOML editing**: `base_url` and `model` field reads/writes now target the correct `[model_providers.<name>]` section
- **Codex MCP config protection**: Existing `mcp_servers` blocks in restore snapshots survive provider hot-switches via per-server-id merge instead of wholesale replacement, with provider/common-config definitions winning on conflict
---
## New Features
### Empty State Guidance
Improved the first-run experience with helpful guidance when the provider list is empty.
- Empty state page shows step-by-step import instructions
- Conditionally displays a Common Config snippet hint for Claude/Codex/Gemini providers (not shown for OpenCode/OpenClaw)
---
## Changes
### Proxy Takeover Restore Flow
The proxy takeover hot-switch and provider sync logic has been reworked to protect Common Config throughout the takeover lifecycle.
- Provider sync now updates the restore backup instead of writing directly to live config files when takeover is active
- Effective provider settings are rebuilt with Common Config applied before saving restore snapshots, so rollback restores the real user configuration
- Legacy providers with inferred common config usage are automatically marked with `commonConfigEnabled=true`
### Codex TOML Editing Engine
Codex `config.toml` update logic has been refactored onto shared section-aware TOML helpers.
- New Rust module `codex_config.rs` with `update_codex_toml_field` and `remove_codex_toml_base_url_if`
- New frontend utilities `getTomlSectionRange` / `getCodexProviderSectionName` for section-aware operations
- Inline TOML editing logic scattered across `proxy.rs` now delegates to the new module
### Common Config Initialization Lifecycle
The startup sequence has been reordered for more robust snippet extraction and migration.
- Startup now auto-extracts Common Config snippets from clean live files before restoring proxy takeover state
- Introduced a snippet `cleared` flag to track whether a user intentionally cleared a snippet
- Persisted a one-time legacy migration flag to avoid repeated `commonConfigEnabled` backfills
---
## Bug Fixes
### Common Config Loss
- Fixed multiple scenarios where Common Config could be dropped during proxy takeover: sync overwriting live files, hot-switches producing incomplete restore snapshots, and provider switches losing config changes
### Codex Restore Snapshot Preservation
- Fixed Codex takeover restore backups discarding existing `mcp_servers` blocks during provider hot-switches; changed MCP backup preservation from wholesale table replacement to per-server-id merge so provider/common-config MCP updates win on conflict while backup-only servers are retained
### Cleared Snippet Resurrection
- Fixed startup auto-extraction recreating Common Config snippets that users had intentionally cleared
### Codex `base_url` Misplacement
- Fixed Codex `base_url` extraction and editing not targeting the correct `[model_providers.<name>]` section, causing it to append to the file tail or confuse `mcp_servers.*.base_url` entries for provider endpoints
---
## Download & Installation
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
### System Requirements
| System | Minimum Version | Architecture |
| ------- | ------------------------------- | ----------------------------------- |
| Windows | Windows 10 or later | x64 |
| macOS | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |
| Linux | See table below | x64 |
### Windows
| File | Description |
| ------------------------------------------ | ---------------------------------------------------- |
| `CC-Switch-v3.12.2-Windows.msi` | **Recommended** - MSI installer with auto-update |
| `CC-Switch-v3.12.2-Windows-Portable.zip` | Portable version, extract and run, no registry write |
### macOS
| File | Description |
| ---------------------------------- | -------------------------------------------------------------------- |
| `CC-Switch-v3.12.2-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
| `CC-Switch-v3.12.2-macOS.tar.gz` | For Homebrew installation and auto-update |
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it, then go to "System Settings" -> "Privacy & Security" -> click "Open Anyway", and it will open normally afterwards.
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
### Linux
| Distribution | Recommended Format | Installation Method |
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | Add execute permission and run directly, or use AUR |
| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+138
View File
@@ -0,0 +1,138 @@
# CC Switch v3.12.2
> プロキシテイクオーバー中の共通設定保護、Snippet ライフサイクルの安定化、Codex TOML セクション対応編集
**[中文版 →](v3.12.2-zh.md) | [English →](v3.12.2-en.md)**
---
## 概要
CC Switch v3.12.2 は、信頼性を重視したパッチリリースです。プロキシテイクオーバーモードでの共通設定(Common Config)の消失問題を解決し、Codex TOML 設定の編集精度を改善しました。テイクオーバーのホットスイッチとプロバイダー同期は、ライブ設定ファイルを上書きする代わりにリストアバックアップを更新するようになりました。起動シーケンスを再整理し、テイクオーバー状態を復元する前にクリーンなライブファイルから Snippet を抽出するようにしました。また Codex の `base_url` 編集をセクション対応モデルにリファクタリングし、ファイル末尾への誤追加を防止しました。
**リリース日**: 2026-03-12
**更新規模**: 5 commits | 22 files changed | +1,716 / -288 lines
---
## ハイライト
- **空状態ガイダンスの改善**: プロバイダーリストが空の場合に詳細なインポート手順を表示し、Claude/Codex/Gemini には共通設定 Snippet のヒントを条件付きで表示
- **プロキシテイクオーバーリストアフロー刷新**: ホットスイッチとプロバイダー同期がライブ設定ファイルの上書きではなくリストアバックアップの更新を行うようになり、ロールバック時に完全なユーザー設定を保持
- **Snippet ライフサイクルの安定化**: `cleared` フラグを導入し、クリア済み Snippet の自動再抽出を防止。起動順序を調整してクリーンな状態から抽出
- **Codex TOML セクション対応編集**: `base_url``model` フィールドの読み書きが正しい `[model_providers.<name>]` セクションを対象にするように改善
- **Codex MCP 設定の保護**: プロバイダーホットスイッチ時にリストアスナップショット内の既存 `mcp_servers` ブロックが保持されるように修正。テーブル全体の置換からサーバー ID ごとのマージに変更し、プロバイダー/共通設定の MCP 定義が競合時に優先
---
## 新機能
### 空状態ガイダンスの改善
プロバイダーリストが空の場合の初回利用体験を改善しました。
- 空状態ページにプロバイダーインポートの操作ガイドを表示
- Claude/Codex/Gemini アプリケーションに共通設定 Snippet のヒントを条件付きで表示(OpenCode/OpenClaw には非表示)
---
## 変更
### プロキシテイクオーバーリストアフロー
テイクオーバーのホットスイッチとプロバイダー同期ロジックをリファクタリングし、テイクオーバーライフサイクル全体で共通設定を保護します。
- テイクオーバーがアクティブな場合、プロバイダー同期がライブ設定ファイルへの直接書き込みではなくリストアバックアップを更新
- リストアスナップショットの保存前に共通設定を適用した実効プロバイダー設定を再構築し、ロールバックで実際のユーザー設定を復元
- 共通設定の使用が推測されるレガシープロバイダーに `commonConfigEnabled=true` を自動マーク
### Codex TOML 編集エンジン
Codex `config.toml` の更新ロジックを共有のセクション対応 TOML ヘルパーにリファクタリングしました。
- Rust 側に新モジュール `codex_config.rs` を追加(`update_codex_toml_field``remove_codex_toml_base_url_if`
- フロントエンドにセクション対応ユーティリティ `getTomlSectionRange` / `getCodexProviderSectionName` を追加
- `proxy.rs` に散在していたインライン TOML 編集ロジックを新モジュールに委譲
### 共通設定初期化ライフサイクル
Snippet の抽出とマイグレーションをより堅牢にするため、起動シーケンスを再整理しました。
- 起動時にプロキシテイクオーバー状態を復元する前に、クリーンなライブファイルから共通設定 Snippet を自動抽出
- Snippet の `cleared` フラグを導入し、ユーザーが意図的にクリアしたかどうかを追跡
- 一回限りのレガシーマイグレーションフラグを永続化し、`commonConfigEnabled` のバックフィルの繰り返しを防止
---
## バグ修正
### 共通設定の消失
- プロキシテイクオーバー中に共通設定が消失する複数のシナリオを修正:同期によるライブファイルの上書き、ホットスイッチによる不完全なリストアスナップショット、プロバイダー切り替え時の設定変更の消失
### Codex リストアスナップショットの保護
- プロバイダーホットスイッチ時に Codex テイクオーバーリストアバックアップが既存の `mcp_servers` ブロックを破棄する問題を修正。MCP バックアップ保持をテーブル全体の置換からサーバー ID ごとのマージに変更し、プロバイダー/共通設定の MCP 更新が競合時に優先され、バックアップのみのサーバーも保持
### クリア済み Snippet の復活
- 起動時の自動抽出が、ユーザーが意図的にクリアした共通設定 Snippet を再作成する問題を修正
### Codex `base_url` の配置エラー
- Codex `base_url` の抽出と編集が正しい `[model_providers.<name>]` セクションを対象にせず、ファイル末尾に追加されたり `mcp_servers.*.base_url` をプロバイダーエンドポイントと誤認する問題を修正
---
## ダウンロードとインストール
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。
### システム要件
| システム | 最小バージョン | アーキテクチャ |
| -------- | -------------------------------- | ----------------------------------- |
| Windows | Windows 10 以降 | x64 |
| macOS | macOS 10.15 (Catalina) 以降 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 下表参照 | x64 |
### Windows
| ファイル | 説明 |
| ------------------------------------------ | ---------------------------------------------------- |
| `CC-Switch-v3.12.2-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
| `CC-Switch-v3.12.2-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
### macOS
| ファイル | 説明 |
| ---------------------------------- | ----------------------------------------------------------------- |
| `CC-Switch-v3.12.2-macOS.zip` | **推奨** - 解凍して Applications にドラッグ、Universal Binary |
| `CC-Switch-v3.12.2-macOS.tar.gz` | Homebrew インストールと自動更新用 |
> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| ディストリビューション | 推奨形式 | インストール方法 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用 |
| その他のディストリビューション / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+138
View File
@@ -0,0 +1,138 @@
# CC Switch v3.12.2
> 代理接管期间通用配置保护、Snippet 生命周期稳定性、Codex TOML Section 感知编辑
**[English →](v3.12.2-en.md) | [日本語版 →](v3.12.2-ja.md)**
---
## 概览
CC Switch v3.12.2 是一个以可靠性为核心的补丁版本,重点解决代理(Proxy)接管模式下通用配置(Common Config)丢失的问题,并改进了 Codex TOML 配置的编辑准确性。代理接管的热切换和供应商同步现在会更新恢复备份而非直接覆盖 live 文件;启动流程重新排序,确保先从干净的 live 文件提取 Snippet 再恢复接管状态;Codex 的 `base_url` 编辑重构为 Section 感知模式,不再错误追加到文件末尾。
**发布日期**2026-03-12
**更新规模**5 commits | 22 files changed | +1,716 / -288 lines
---
## 重点内容
- **首次使用引导优化**:供应商列表空状态显示详细的导入说明,Claude/Codex/Gemini 还会提示通用配置 Snippet 功能
- **代理接管恢复流程重构**:热切换和供应商同步现在刷新恢复备份,而非覆盖 live 配置文件,回滚时保留完整的用户配置
- **Snippet 生命周期稳定**:引入 `cleared` 标志防止已清除的 Snippet 被自动重新提取,启动顺序调整确保从干净状态提取
- **Codex TOML Section 感知编辑**`base_url``model` 字段的读写现在定位到正确的 `[model_providers.<name>]` Section
- **Codex MCP 配置保护**:热切换供应商时保留恢复快照中已有的 `mcp_servers` 配置块,按 server id 合并而非整表替换,供应商/通用配置的 MCP 定义优先
---
## 新功能
### 空状态引导优化
改善首次使用体验,当供应商列表为空时显示详细的导入说明。
- 空状态页面展示导入供应商的操作指引
- 对 Claude/Codex/Gemini 应用有条件地显示通用配置 Snippet 提示(OpenCode/OpenClaw 不显示)
---
## 变更
### 代理接管恢复流程
代理接管的热切换和供应商同步逻辑经过重构,确保通用配置在整个接管生命周期中得到保护。
- 接管活跃时,供应商同步更新恢复备份而非直接写入 live 配置文件
- 保存恢复快照前先应用通用配置,使回滚能还原真实的用户配置
- 遗留供应商中推断使用了通用配置的条目自动标记 `commonConfigEnabled=true`
### Codex TOML 编辑引擎
将 Codex `config.toml` 的更新逻辑重构到共享的 Section 感知 TOML 辅助函数上。
- Rust 端新增 `codex_config.rs` 模块,包含 `update_codex_toml_field``remove_codex_toml_base_url_if`
- 前端新增 `getTomlSectionRange` / `getCodexProviderSectionName` 等 Section 感知工具函数
- `proxy.rs` 中散落的 TOML 内联编辑逻辑统一委托给新模块
### 通用配置初始化生命周期
启动流程重新排序,通用配置 Snippet 的提取和迁移逻辑更加健壮。
- 启动时先从干净的 live 文件自动提取通用配置 Snippet,再恢复代理接管状态
- 引入 Snippet `cleared` 标志,追踪用户是否主动清除了某个 Snippet
- 持久化一次性遗留迁移标志,避免重复执行旧版 `commonConfigEnabled` 回填
---
## Bug 修复
### 通用配置丢失
- 修复代理接管期间通用配置可能被丢弃的多种场景:同步覆盖 live 文件、热切换产生不完整的恢复快照、供应商切换丢失配置变更
### Codex 恢复快照保护
- 修复 Codex 接管恢复备份在供应商热切换时丢弃已有 `mcp_servers` 配置块的问题;将 MCP 备份保留策略从整表替换改为按 server id 合并,供应商/通用配置的 MCP 定义在冲突时优先,备份中独有的服务器仍被保留
### 已清除 Snippet 复活
- 修复启动时自动提取机制重新创建用户已主动清除的通用配置 Snippet 的问题
### Codex `base_url` 位置错误
- 修复 Codex `base_url` 提取和编辑未定位到正确的 `[model_providers.<name>]` Section,导致追加到文件末尾或误将 `mcp_servers.*.base_url` 识别为供应商端点的问题
---
## 下载与安装
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
### 系统要求
| 系统 | 最低版本 | 架构 |
| ------- | ----------------------------- | ----------------------------------- |
| Windows | Windows 10 及以上 | x64 |
| macOS | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 见下表 | x64 |
### Windows
| 文件 | 说明 |
| ------------------------------------------ | ----------------------------------- |
| `CC-Switch-v3.12.2-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.12.2-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
### macOS
| 文件 | 说明 |
| ---------------------------------- | --------------------------------------------------------- |
| `CC-Switch-v3.12.2-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
| `CC-Switch-v3.12.2-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
### HomebrewmacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| 发行版 | 推荐格式 | 安装方式 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb``sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm``sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+313
View File
@@ -0,0 +1,313 @@
# CC Switch v3.12.3
> GitHub Copilot Reverse Proxy, macOS Code Signing & Notarization, Reasoning Effort Mapping, OpenCode SQLite Backend
**[中文版 →](v3.12.3-zh.md) | [日本語版 →](v3.12.3-ja.md)**
---
## Overview
CC Switch v3.12.3 is a major feature release that adds GitHub Copilot reverse proxy support with a dedicated Auth Center, introduces macOS code signing and Apple notarization for a seamless install experience, maps reasoning effort levels across providers, migrates OpenCode to a SQLite backend, enables Tool Search via the native `ENABLE_TOOL_SEARCH` environment variable toggle, and delivers a full skill backup/restore lifecycle. Additional improvements include proxy gzip compression, o-series model compatibility, Skills import rework, Ghostty terminal fix, Skills cache strategy optimization, Claude 4.6 context window update, and multiple bug fixes.
**Release Date**: 2026-03-24
**Update Scale**: 36 commits | 107 files changed | +9,124 / -802 lines
---
## Highlights
- **GitHub Copilot reverse proxy**: Full Copilot proxy support with OAuth device flow authentication, token refresh, and request fingerprint emulation ([⚠️ Risk Notice](#-risk-notice))
- **Copilot Auth Center**: Dedicated authentication management UI for GitHub Copilot OAuth flow with token status display and one-click refresh
- **macOS code signing & notarization**: macOS builds are now code-signed and notarized by Apple, eliminating the "unidentified developer" warning entirely
- **Reasoning Effort mapping**: Proxy-layer auto-mapping — explicit `output_config.effort` takes priority, falling back to `budget_tokens` thresholds (<4 000→low, 4 00016 000→medium, ≥16 000→high) for o-series and GPT-5+ models
- **OpenCode SQLite backend**: Added SQLite session storage for OpenCode alongside existing JSON backend; dual-backend scan with SQLite priority on ID conflicts
- **Codex 1M context window toggle**: One-click checkbox to set `model_context_window = 1000000` with auto-populated `model_auto_compact_token_limit`
- **Disable Auto-Upgrade toggle**: Added `DISABLE_AUTOUPDATER` env var checkbox in the Claude Common Config editor to prevent Claude Code from auto-upgrading
- **Tool Search env var toggle**: Tool Search enabled via Claude 2.1.76+ native `ENABLE_TOOL_SEARCH` environment variable in the Common Config editor — no binary patching required
- **Skill backup/restore lifecycle**: Skills are automatically backed up before uninstall; backup list with restore and delete management added
- **Proxy gzip compression**: Non-streaming proxy requests now auto-negotiate gzip compression, reducing bandwidth usage
- **o-series model compatibility**: Chat Completions proxy correctly uses `max_completion_tokens` for o1/o3/o4-mini models; Responses API kept on the correct `max_output_tokens` field
- **Skills import rework**: Replaced implicit filesystem-based app inference with explicit `ImportSkillSelection` to prevent incorrect multi-app activation
- **Ghostty terminal support**: Fixed Claude session restore in Ghostty terminal
---
## New Features
### GitHub Copilot Reverse Proxy
Added full reverse proxy support for GitHub Copilot, enabling Copilot-authenticated requests to be forwarded through CC Switch.
- Implements OAuth device flow authentication for GitHub Copilot
- Automatic token refresh and session management
- Request fingerprint emulation for seamless compatibility
- Integrated into the existing proxy infrastructure alongside Claude, Codex, and Gemini handlers
### Copilot Auth Center
A dedicated authentication management UI for GitHub Copilot.
- OAuth device flow with code display and browser-based authorization
- Token status display showing expiration and validity
- One-click token refresh without re-authentication
- Integrated into the settings panel for easy access
### Reasoning Effort Mapping
Proxy-layer auto-mapping of reasoning effort for OpenAI o-series and GPT-5+ models.
- Two-tier resolution: explicit `output_config.effort` takes priority, falling back to thinking `budget_tokens` thresholds (<4 000→low, 4 00016 000→medium, ≥16 000→high)
- Covers both Chat Completions and Responses API paths with 17 unit tests
### OpenCode SQLite Backend
Added SQLite session storage support for OpenCode alongside the existing JSON backend.
- Dual-backend scan with SQLite priority on ID conflicts
- Atomic session deletion and path validation
- JSON backend remains functional for backwards compatibility
### Codex 1M Context Window Toggle
Added a one-click toggle for Codex 1M context window in the config editor.
- Checkbox sets `model_context_window = 1000000` in `config.toml`
- Auto-populates `model_auto_compact_token_limit = 900000` when enabled
- Unchecking removes both fields cleanly
### Disable Auto-Upgrade Toggle
Added a checkbox in the Claude Common Config editor to disable Claude Code auto-upgrades.
- Sets `DISABLE_AUTOUPDATER=1` in the environment configuration when enabled
- Displayed alongside Teammates mode, Tool Search, and High Effort toggles
### Tool Search Environment Variable Toggle
Tool Search is now enabled via the native `ENABLE_TOOL_SEARCH` environment variable introduced in Claude 2.1.76+.
- Toggle available in the Common Config editor under environment variables
- Sets `ENABLE_TOOL_SEARCH=1` in the Claude environment configuration
- No binary patching required — uses Claude's built-in support
### macOS Code Signing & Notarization
macOS builds are now code-signed and notarized by Apple.
- Application signed with a valid Apple Developer certificate
- Notarized through Apple's notarization service for Gatekeeper approval
- DMG installer also signed and notarized
- Eliminates the "unidentified developer" warning on first launch
### Skill Auto-Backup on Uninstall
Skill files are now automatically backed up before uninstall to prevent accidental data loss.
- Backups stored in `~/.cc-switch/skill-backups/` with all skill files and a `meta.json` containing original metadata
- Old backups are automatically pruned to keep at most 20
- Backup path is returned to the frontend and shown in the success toast
### Skill Backup Restore & Delete
Added management commands for skill backups created during uninstall.
- List all available skill backups with metadata
- Restore copies files back to SSOT, saves the DB record, and syncs to the current app with rollback on failure
- Delete removes the backup directory after a confirmation dialog
- ConfirmDialog gains a configurable zIndex prop to support nested dialog stacking
---
## Changes
### Skills Cache Strategy Optimization
Optimized the Skills cache invalidation strategy for better performance.
- Reduced unnecessary cache refreshes during skill operations
- Improved cache coherence between skill install/uninstall and list queries
### Claude 4.6 Context Window Update
Updated Claude 4.6 model preset with the latest context window size.
- Reflects the expanded context window for Claude 4.6 models
- Updated in provider presets for accurate model information display
### MiniMax M2.7 Upgrade
- Updated MiniMax provider preset to M2.7 model variant
### Xiaomi MiMo Upgrade
- Updated Xiaomi MiMo provider preset to the latest model version
### AddProviderDialog Simplification
- Removed redundant OAuth tab, reducing dialog from 3 tabs to 2 (app-specific + universal)
### Provider Form Advanced Options Collapse
- Model mapping, API format, and other advanced fields in the Claude provider form now auto-collapse when empty
- Auto-expands when any value is set or when a preset fills them in; does not auto-collapse when manually cleared
### Proxy Gzip Compression
Non-streaming proxy requests now support gzip compression for reduced bandwidth usage.
- Non-streaming requests let reqwest auto-negotiate gzip and transparently decompress responses
- Streaming requests conservatively keep `Accept-Encoding: identity` to avoid decompression errors on interrupted SSE streams
### o1/o3 Model Compatibility
Proxy forwarding now handles OpenAI o-series model token parameters correctly.
- Chat Completions path uses `max_completion_tokens` instead of `max_tokens` for o1/o3/o4-mini models (#1451, thanks @Hemilt0n)
- Responses API path kept on the correct `max_output_tokens` field instead of incorrectly injecting `max_completion_tokens`
### OpenCode Model Variants
- Placed OpenCode model variants at top level instead of inside options for better discoverability (#1317)
### Skills Import Flow
The Skills import flow has been reworked for correctness and cleanup.
- Replaced implicit filesystem-based app inference with explicit `ImportSkillSelection` to prevent incorrect multi-app activation when the same skill directory exists under multiple app paths
- Added reconciliation to `sync_to_app` to remove disabled/orphaned symlinks
- MCP `sync_all_enabled` now removes disabled servers from live config
- Schema migration preserves a snapshot of legacy app mappings to avoid lossy reconstruction
---
## Bug Fixes
### WebDAV Password Clearing
- Fixed an issue where the WebDAV password was silently cleared when saving unrelated settings
### Tool Message Parsing
- Fixed incorrect parsing of tool-use messages in certain proxy response formats
### Dark Mode Styling
- Fixed dark mode rendering inconsistencies in UI components
### Copilot Request Fingerprint
- Fixed request fingerprint generation for Copilot proxy to match expected format
### Provider Form Double Submit
- Prevented duplicate submissions on rapid button clicks in provider add/edit forms (#1352, thanks @Hexi1997)
### Ghostty Session Restore
- Fixed Claude session restore in Ghostty terminal (#1506, thanks @canyonsehun)
### Skill ZIP Import Extension
- Added `.skill` file extension support in ZIP import dialog (#1240, #1455, thanks @yovinchen)
### Skill ZIP Install Target App
- ZIP skill installs now use the currently active app instead of always defaulting to Claude
### OpenClaw Active Card Highlight
- Fixed active OpenClaw provider card not being highlighted (#1419, thanks @funnytime75)
### Responsive Layout with TOC
- Improved responsive design when TOC title exists (#1491, thanks @West-Pavilion)
### Import Skills Dialog White Screen
- Added missing TooltipProvider in ImportSkillsDialog to prevent runtime crash when opening the dialog
### Panel Bottom Blank Area
- Replaced hardcoded `h-[calc(100vh-8rem)]` with `flex-1 min-h-0` across all content panels to eliminate bottom gap caused by mismatched offset values on different platforms
---
## Documentation
### Pricing Model ID Normalization
- Added documentation section explaining model ID normalization rules (prefix stripping, suffix trimming, `@``-` replacement) in EN/ZH/JA user manuals (#1591, thanks @makoMakoGo)
### macOS Signed Build Messaging
- Removed all `xattr` workaround instructions and "unidentified developer" warnings from README, README_ZH, installation guides (EN/ZH/JA), and FAQ pages (EN/ZH/JA); replaced with "signed and notarized by Apple" messaging
---
## ⚠️ Risk Notice
**GitHub Copilot Reverse Proxy Disclaimer**
The Copilot reverse proxy feature introduced in this release accesses GitHub Copilot services through reverse-engineered, unofficial APIs. Please be aware of the following risks before enabling this feature:
1. **Terms of Service**: This feature may violate [GitHub's Acceptable Use Policies](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies) and [Terms for Additional Products and Features](https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features), which prohibit excessive automated bulk activity, unauthorized service reproduction, and placing undue burden on servers through automated means.
2. **Account Risk**: There are documented cases of GitHub issuing warning emails to users of similar tools, citing "scripted interactions or otherwise deliberately unusual or strenuous" usage patterns. Continued use after a warning may result in temporary or permanent suspension of Copilot access.
3. **No Guarantee**: GitHub may update its detection mechanisms at any time, and usage patterns that work today may be flagged in the future.
Users enable this feature **at their own risk**. CC Switch is not responsible for any account restrictions, warnings, or service suspensions resulting from the use of this feature.
---
## Download & Installation
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
### System Requirements
| System | Minimum Version | Architecture |
| ------- | ------------------------------- | ----------------------------------- |
| Windows | Windows 10 or later | x64 |
| macOS | macOS 12 (Monterey) or later | Intel (x64) / Apple Silicon (arm64) |
| Linux | See table below | x64 |
### Windows
| File | Description |
| ------------------------------------------ | ---------------------------------------------------- |
| `CC-Switch-v3.12.3-Windows.msi` | **Recommended** - MSI installer with auto-update |
| `CC-Switch-v3.12.3-Windows-Portable.zip` | Portable version, extract and run, no registry write |
### macOS
| File | Description |
| ---------------------------------- | -------------------------------------------------------------------- |
| `CC-Switch-v3.12.3-macOS.dmg` | **Recommended** - DMG installer, drag to Applications, Universal Binary |
| `CC-Switch-v3.12.3-macOS.zip` | ZIP archive, extract and drag to Applications, Universal Binary |
| `CC-Switch-v3.12.3-macOS.tar.gz` | For Homebrew installation and auto-update |
> macOS builds are code-signed and notarized by Apple for a seamless install experience.
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
Update:
```bash
brew upgrade --cask cc-switch
```
### Linux
| Distribution | Recommended Format | Installation Method |
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | Add execute permission and run directly, or use AUR |
| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+313
View File
@@ -0,0 +1,313 @@
# CC Switch v3.12.3
> GitHub Copilot リバースプロキシ、macOS コード署名と公証、Reasoning Effort マッピング、Tool Search 環境変数トグル、Skill バックアップ/リストア、OpenCode SQLite バックエンド
**[中文版 →](v3.12.3-zh.md) | [English →](v3.12.3-en.md)**
---
## 概要
CC Switch v3.12.3 は、GitHub Copilot リバースプロキシと Copilot Auth Center を追加し、Copilot トークンを使用した Claude/OpenAI API へのアクセスを実現しました。macOS ビルドに Apple コード署名と公証を導入し、「開発元を確認できません」の警告を解消しました。Reasoning Effort マッピングにより、Claude の thinking budget を OpenAI 互換の reasoning_effort パラメータに自動変換します。Tool Search は従来のバイナリパッチ方式から Claude 2.1.76+ ネイティブの `ENABLE_TOOL_SEARCH` 環境変数トグルに移行し、共通設定エディタから切り替え可能になりました。OpenCode バックエンドを JSON から SQLite に移行し、Skill バックアップ/リストアライフサイクル、プロキシ gzip 圧縮、o シリーズモデル互換性の改善も含まれます。
**リリース日**: 2026-03-24
**更新規模**: 36 commits | 107 files changed | +9,124 / -802 lines
---
## ハイライト
- **GitHub Copilot リバースプロキシ**: Copilot トークンを使用して Claude/OpenAI API にアクセスするリバースプロキシを追加。Copilot Auth Center でトークンの取得と管理が可能([⚠️ リスクに関する注意事項](#-リスクに関する注意事項)
- **macOS コード署名と公証**: macOS ビルドが Apple のコード署名と公証に対応し、初回起動時の警告なしでインストール可能に。DMG インストーラーを新たに提供
- **Reasoning Effort マッピング**: プロキシ層での自動マッピング — 明示的な `output_config.effort` を優先し、`budget_tokens` 閾値(<4000→low, 400016000→medium, ≥16000→high)にフォールバック。o シリーズおよび GPT-5+ モデルに対応
- **Tool Search 環境変数トグル**: バイナリパッチ方式を廃止し、Claude 2.1.76+ ネイティブの `ENABLE_TOOL_SEARCH` 環境変数による切り替えに移行。共通設定エディタから設定可能
- **Skill バックアップ/リストアライフサイクル**: アンインストール前に Skill ファイルを自動バックアップ。バックアップリスト、リストア、削除の管理機能を追加
- **OpenCode SQLite バックエンド**: OpenCode に SQLite セッションストレージを追加(既存の JSON バックエンドと併存)。ID 競合時は SQLite を優先するデュアルバックエンドスキャン
- **Codex 1M コンテキストウィンドウトグル**: 設定エディタでワンクリックで `model_context_window = 1000000` を設定可能。`model_auto_compact_token_limit` も自動設定
- **自動アップグレード無効化トグル**: Claude 共通設定エディタに `DISABLE_AUTOUPDATER` 環境変数のチェックボックスを追加し、Claude Code の自動アップグレードを防止
- **プロキシ Gzip 圧縮**: 非ストリーミングプロキシリクエストが gzip 圧縮を自動ネゴシエーションし、帯域幅消費を削減
- **o シリーズモデル互換性**: Chat Completions プロキシが o1/o3/o4-mini モデルに `max_completion_tokens` を正しく使用。Responses API は正しい `max_output_tokens` フィールドを維持
- **Skills インポートの刷新**: ファイルシステムベースの暗黙的なアプリ推論を明示的な `ImportSkillSelection` に置き換え、複数アプリの誤った有効化を防止
- **Ghostty ターミナルサポート**: Ghostty ターミナルでの Claude セッション復元を修正
---
## 新機能
### GitHub Copilot リバースプロキシ
GitHub Copilot トークンを使用して Claude API および OpenAI API にアクセスするリバースプロキシ機能を追加しました。
- Copilot のアクセストークンを利用し、Claude Code や Codex などのクライアントからプロキシ経由で API リクエストを転送
- Copilot 固有のリクエストフィンガープリントとヘッダー処理に対応
- プロバイダープリセットに Copilot 用テンプレートを追加
### Copilot Auth Center
Copilot トークンの取得と管理を行う認証センターを追加しました。
- GitHub デバイスフローによるトークン取得をサポート
- トークンの有効期限管理と自動リフレッシュ
- フロントエンドから直接トークンステータスの確認と再認証が可能
### Reasoning Effort マッピング
OpenAI o シリーズおよび GPT-5+ モデル向けのプロキシ層自動マッピング機能を追加しました。
- 二段階の解決ロジック:明示的な `output_config.effort` を優先し、thinking `budget_tokens` 閾値(<4000→low, 400016000→medium, ≥16000→high)にフォールバック
- Chat Completions と Responses API の両パスをカバー、17 個のユニットテスト付き
### Tool Search 環境変数トグル
Claude CLI Tool Search の有効化/無効化を環境変数で制御する設定を追加しました。
- Claude 2.1.76+ で導入されたネイティブの `ENABLE_TOOL_SEARCH` 環境変数を使用
- 共通設定(Common Config)エディタから直接トグル可能
- 従来のバイナリパッチ方式は不要になり、CLI アップデート時の再適用も不要
### Skill アンインストール時の自動バックアップ
アンインストール前に Skill ファイルを自動バックアップし、意図しないデータ損失を防止します。
- バックアップは `~/.cc-switch/skill-backups/` に保存され、すべての skill ファイルと元のメタデータを含む `meta.json` が含まれます
- 古いバックアップは自動的にプルーニングされ、最大 20 個を保持
- バックアップパスはフロントエンドに返され、成功トーストに表示
### Skill バックアップのリストアと削除
アンインストール時に作成された Skill バックアップの管理コマンドを追加しました。
- すべての利用可能な skill バックアップをメタデータ付きで一覧表示
- リストアはファイルを SSOT にコピーし、DB レコードを保存し、現在のアプリに同期。失敗時は自動ロールバック
- 削除は確認ダイアログの後にバックアップディレクトリを削除
- ConfirmDialog にネストされたダイアログスタッキングをサポートする設定可能な zIndex プロパティを追加
### OpenCode SQLite バックエンド
OpenCode に SQLite セッションストレージサポートを追加しました(既存の JSON バックエンドと併存)。
- デュアルバックエンドスキャン、ID 競合時は SQLite を優先
- アトミックなセッション削除とパス検証
- JSON バックエンドは後方互換性のため引き続き機能
### Codex 1M コンテキストウィンドウトグル
設定エディタに Codex 1M コンテキストウィンドウのワンクリックトグルを追加しました。
- チェックボックスで `config.toml``model_context_window = 1000000` を設定
- 有効化時に `model_auto_compact_token_limit = 900000` を自動設定
- 無効化時は両フィールドをクリーンに削除
### 自動アップグレード無効化トグル
Claude 共通設定エディタに自動アップグレードを無効化するチェックボックスを追加しました。
- 有効化時に `DISABLE_AUTOUPDATER=1` 環境変数を設定し、Claude Code の自動アップグレードを防止
- Teammates モード、Tool Search、高強度思考トグルと同じ行に表示
### macOS コード署名と公証
macOS ビルドに Apple のコード署名と公証を導入しました。
- Apple Developer ID による署名と Apple 公証サービスによる公証を実施
- 初回起動時の「開発元を確認できません」警告が不要に
- DMG インストーラーを新たに提供し、ドラッグ&ドロップでのインストールに対応
- CI/CD パイプラインに署名・公証ステップを統合
---
## 変更
### Skills キャッシュ戦略の最適化
Skills のキャッシュ戦略を最適化し、パフォーマンスと信頼性を向上しました。
- キャッシュの有効期限管理とインバリデーション戦略を改善
- 不要なキャッシュ再構築を削減し、起動時間を短縮
### Claude 4.6 コンテキストウィンドウ更新
Claude 4.6 モデルのコンテキストウィンドウサイズを更新しました。
- Claude 4.6 の最新コンテキストウィンドウサイズをプリセットに反映
### MiniMax M2.7 アップグレード
MiniMax モデルプリセットを M2.7 にアップグレードしました。
- MiniMax プロバイダープリセットのモデル ID とパラメータを M2.7 に更新
### Xiaomi MiMo アップグレード
Xiaomi MiMo モデルプリセットをアップグレードしました。
- MiMo プロバイダープリセットのモデル ID とパラメータを最新版に更新
### AddProviderDialog の簡素化
- 冗長な OAuth タブを削除し、ダイアログを 3 タブから 2 タブ(アプリ固有 + ユニバーサル)に簡素化
### プロバイダーフォームの高度なオプション折りたたみ
- Claude プロバイダーフォームのモデルマッピング、API フォーマットなどの高度なフィールドが未入力時にデフォルトで折りたたまれるように変更
- プリセットが値を入力すると自動展開。手動クリア時は自動折りたたみしない
### プロキシ Gzip 圧縮
非ストリーミングプロキシリクエストが gzip 圧縮をサポートし、帯域幅消費を削減しました。
- 非ストリーミングリクエストは reqwest が gzip を自動ネゴシエーションし、レスポンスを透過的に解凍
- ストリーミングリクエストは中断された SSE ストリームの解凍エラーを避けるため、保守的に `Accept-Encoding: identity` を維持
### o1/o3 モデル互換性
プロキシ転送が OpenAI o シリーズモデルのトークンパラメータを正しく処理するようになりました。
- Chat Completions パスが o1/o3/o4-mini モデルに `max_tokens` の代わりに `max_completion_tokens` を使用 (#1451@Hemilt0n に感謝)
- Responses API パスが正しい `max_output_tokens` フィールドを維持し、`max_completion_tokens` の誤った注入を防止
### OpenCode モデルバリアント
- OpenCode のモデルバリアントを options 内部ではなくプリセットのトップレベルに配置し、発見しやすさを向上 (#1317)
### Skills インポートフロー
Skills インポートフローが正確性とクリーンアップのためにリワークされました。
- ファイルシステムベースの暗黙的なアプリ推論を明示的な `ImportSkillSelection` に置き換え、同じ skill ディレクトリが複数アプリパスに存在する場合の複数アプリ誤有効化を防止
- `sync_to_app` に調整ロジックを追加し、無効化/孤立したシンボリックリンクを削除
- MCP `sync_all_enabled` がライブ設定から無効化されたサーバーを削除するように改善
- スキーママイグレーションがレガシーアプリマッピングのスナップショットを保持し、損失のある再構築を回避
---
## バグ修正
### WebDAV パスワードの消失
- 無関係な設定保存時に WebDAV パスワードがサイレントにクリアされる問題を修正
### ツールメッセージのパース
- プロキシのツールメッセージパース処理の不具合を修正し、特定のツール呼び出しパターンでのエラーを解消
### ダークモードの表示
- ダークモードでの一部 UI コンポーネントの表示不具合を修正
### Copilot リクエストフィンガープリント
- Copilot リバースプロキシのリクエストフィンガープリント生成の不具合を修正し、認証エラーを解消
### プロバイダーフォームの二重送信
- プロバイダー追加/編集フォームでの高速連続クリックによる重複送信を防止 (#1352@Hexi1997 に感謝)
### Ghostty ターミナルセッション復元
- Ghostty ターミナルでの Claude セッション復元の失敗を修正 (#1506@canyonsehun に感謝)
### Skill ZIP インポート拡張子
- ZIP インポートダイアログが `.skill` ファイル拡張子をサポートするように修正 (#1240, #1455@yovinchen に感謝)
### Skill ZIP インストール対象アプリ
- ZIP 方式でインストールされた skill が常に Claude をデフォルトにするのではなく、現在アクティブなアプリを使用するように修正
### OpenClaw アクティブカードのハイライト
- OpenClaw の現在アクティブなプロバイダーカードがハイライト表示されない問題を修正 (#1419@funnytime75 に感謝)
### TOC 付きレスポンシブレイアウト
- TOC タイトルが存在する場合のレスポンシブデザインを改善 (#1491@West-Pavilion に感謝)
### Skills インポートダイアログの白い画面
- ImportSkillsDialog に不足していた TooltipProvider を追加し、ダイアログを開く際のランタイムクラッシュを防止
### パネル下部の空白エリア
- すべてのコンテンツパネルのハードコードされた `h-[calc(100vh-8rem)]``flex-1 min-h-0` に置き換え、異なるプラットフォーム間のオフセット値の不一致による下部のギャップを解消
---
## ドキュメント
### 料金モデル ID の正規化
- 中英日三言語のユーザーマニュアルにモデル ID 正規化ルール(プレフィックス除去、サフィックストリミング、`@``-` 置換)の説明セクションを追加 (#1591@makoMakoGo に感謝)
### macOS 署名済みメッセージの更新
- README、README_ZH、インストールガイド(EN/ZH/JA)、FAQ ページ(EN/ZH/JA)からすべての `xattr` 回避策と「開発元を確認できません」警告を削除し、「Apple のコード署名と公証済み」メッセージに置換
---
## ⚠️ リスクに関する注意事項
**GitHub Copilot リバースプロキシに関する免責事項**
本リリースで追加された Copilot リバースプロキシ機能は、リバースエンジニアリングによる非公式 API を通じて GitHub Copilot サービスにアクセスします。この機能を有効にする前に、以下のリスクをご確認ください:
1. **利用規約違反の可能性**:この機能は [GitHub 利用規約](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies)および[追加製品の利用条件](https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features)に違反する可能性があります。これらの規約では、過度な自動一括操作、サービスの無断複製、自動化手段によるサーバーへの過度な負荷が禁止されています。
2. **アカウントリスク**:類似ツールの利用者が GitHub から「スクリプト化されたインタラクション、または意図的に異常もしくは過度な使用」を指摘する警告メールを受け取った事例が報告されています。警告後も使用を継続した場合、Copilot へのアクセスが一時的または永久的に停止される可能性があります。
3. **将来の利用保証なし**:GitHub は検出メカニズムをいつでも更新する可能性があり、現在利用可能な使用パターンが将来的にフラグ付けされる可能性があります。
この機能を有効にすることで、ユーザーは**すべてのリスクを自己責任で負う**ものとします。CC Switch は、この機能の使用に起因するアカウント制限、警告、またはサービス停止について一切の責任を負いません。
---
## ダウンロードとインストール
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。
### システム要件
| システム | 最小バージョン | アーキテクチャ |
| -------- | -------------------------------- | ----------------------------------- |
| Windows | Windows 10 以降 | x64 |
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 下表参照 | x64 |
### Windows
| ファイル | 説明 |
| ------------------------------------------ | ---------------------------------------------------- |
| `CC-Switch-v3.12.3-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
| `CC-Switch-v3.12.3-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
### macOS
| ファイル | 説明 |
| ---------------------------------- | ----------------------------------------------------------------- |
| `CC-Switch-v3.12.3-macOS.dmg` | **推奨** - DMG インストーラー、ドラッグ&ドロップでインストール |
| `CC-Switch-v3.12.3-macOS.zip` | 解凍して Applications にドラッグ、Universal Binary |
| `CC-Switch-v3.12.3-macOS.tar.gz` | Homebrew インストールと自動更新用 |
> macOS 版は Apple のコード署名と公証済みで、そのままインストールしてご利用いただけます。
### Homebrew (macOS)
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| ディストリビューション | 推奨形式 | インストール方法 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 実行権限を追加して直接実行、または AUR を使用 |
| その他のディストリビューション / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
+296
View File
@@ -0,0 +1,296 @@
# CC Switch v3.12.3
> GitHub Copilot 反向代理、macOS 代码签名与公证、Reasoning Effort 映射、Tool Search 环境变量开关、Skill 备份/恢复生命周期
**[English →](v3.12.3-en.md) | [日本語版 →](v3.12.3-ja.md)**
---
## 概览
CC Switch v3.12.3 新增了 **GitHub Copilot 反向代理** 支持和 **Copilot Auth Center** 认证管理,引入了 **Reasoning Effort 映射** 实现跨供应商推理强度控制,通过 Claude 2.1.76+ 原生 `ENABLE_TOOL_SEARCH` 环境变量实现了 **Tool Search 开关**,新增了 **OpenCode SQLite 后端** 支持,并完成了 **macOS 代码签名与 Apple 公证**。同时引入了完整的 Skill 备份/恢复生命周期,改进了代理对 OpenAI o 系列模型的兼容性和 gzip 压缩支持,优化了 Skills 缓存策略,更新了 Claude 4.6 上下文窗口、MiniMax M2.7 和小米 MiMo 模型预设,并修复了 WebDAV 密码、工具消息解析、暗色模式和 Copilot 请求指纹等方面的问题。
**发布日期**2026-03-24
**更新规模**36 commits | 107 files changed | +9,124 / -802 lines
---
## 重点内容
- **GitHub Copilot 反向代理**:新增 Copilot 反向代理支持,通过 Copilot Auth Center 管理 GitHub Token 认证,实现 Copilot 模型在 Claude Code 中的无缝使用([⚠️ 风险提示](#-风险提示)
- **macOS 代码签名与公证**macOS 版本已通过 Apple 代码签名和公证,新增 DMG 安装格式,无需再手动绕过"未知开发者"警告
- **Reasoning Effort 映射**:代理层自动映射 — 显式 `output_config.effort` 优先,回退到 `budget_tokens` 阈值(<4000→low, 400016000→medium, ≥16000→high),支持 o 系列和 GPT-5+ 模型
- **Tool Search 环境变量开关**:利用 Claude 2.1.76+ 原生 `ENABLE_TOOL_SEARCH` 环境变量,在通用配置编辑器中一键启用 Tool Search
- **Skill 备份/恢复生命周期**:卸载前自动备份 Skill 文件;新增备份列表、恢复和删除管理
- **OpenCode SQLite 后端**:为 OpenCode 新增 SQLite 会话存储(与现有 JSON 后端并存),ID 冲突时 SQLite 优先的双后端扫描
- **Codex 1M 上下文窗口开关**:配置编辑器中一键设置 `model_context_window = 1000000`,自动填充 `model_auto_compact_token_limit`
- **禁用自动升级开关**:通用配置编辑器中新增 `DISABLE_AUTOUPDATER` 环境变量复选框,防止 Claude Code 自动升级
- **代理 Gzip 压缩**:非流式代理请求自动协商 gzip 压缩,减少带宽消耗
- **o 系列模型兼容性**Chat Completions 代理正确使用 `max_completion_tokens` 处理 o1/o3/o4-mini 模型
- **Skills 导入重构**:将基于文件系统的隐式应用推断替换为显式的 `ImportSkillSelection`,防止多应用错误激活
---
## 新功能
### GitHub Copilot 反向代理
新增完整的 GitHub Copilot 集成,作为 Claude Code 供应商使用。
- 通过 OAuth Device Code 流程进行 GitHub 认证
- 支持多账号管理和自动 Token 刷新
- Anthropic ↔ OpenAI 格式自动转换
- 实时获取可用模型列表和用量统计 (#930,感谢 @Mason-mengze)
### Copilot Auth Center
在设置中新增认证中心面板,全局管理 GitHub 账号。
- 支持按供应商绑定账号(通过 `meta.authBinding`
- 统一的 Token 管理和刷新机制
### Tool Search 开关
利用 Claude 2.1.76+ 原生 `ENABLE_TOOL_SEARCH` 环境变量控制 Tool Search 功能。
- 在供应商通用配置编辑器中以复选框形式暴露
- 替代了之前的二进制补丁方案,更简洁可靠 (#930,感谢 @Mason-mengze)
### Reasoning Effort 映射
新增代理层自动推理强度映射,支持 OpenAI o 系列和 GPT-5+ 模型。
- 两级解析:显式 `output_config.effort` 优先,回退到 `budget_tokens` 阈值(<4000→low, 400016000→medium, ≥16000→high
- 覆盖 Chat Completions 和 Responses API 两条路径,含 17 个单元测试
### OpenCode SQLite 后端
为 OpenCode 新增 SQLite 会话存储支持(与现有 JSON 后端并存)。
- 双后端扫描,ID 冲突时 SQLite 优先
- 原子会话删除和路径校验
- JSON 后端保持向后兼容
### Codex 1M 上下文窗口开关
在配置编辑器中新增 Codex 1M 上下文窗口一键开关。
- 复选框设置 `config.toml` 中的 `model_context_window = 1000000`
- 启用时自动填充 `model_auto_compact_token_limit = 900000`
- 关闭时干净移除两个字段
### 禁用自动升级开关
在 Claude 通用配置编辑器中新增禁用自动升级的复选框。
- 勾选后设置 `DISABLE_AUTOUPDATER=1` 环境变量,阻止 Claude Code 自动升级
- 与 Teammates 模式、Tool Search、高强度思考等开关同一排显示
### Skill 卸载自动备份
卸载 Skill 前自动备份文件,防止数据意外丢失。
- 备份存储在 `~/.cc-switch/skill-backups/`,包含所有 skill 文件和记录原始元数据的 `meta.json`
- 旧备份自动清理,最多保留 20 个
- 备份路径返回前端并在成功提示中显示
### Skill 备份恢复与删除
新增卸载时创建的 Skill 备份的管理功能。
- 列出所有可用的 skill 备份及元数据
- 恢复操作将文件拷回 SSOT,保存数据库记录,并同步到当前应用,失败时自动回滚
- 删除操作在确认对话框后移除备份目录
### macOS 代码签名与 Apple 公证
CI 流程新增完整的 macOS 代码签名和 Apple 公证支持。
- 导入 Apple Developer ID 证书,签名 Universal Binary
- 提交 Apple 公证并将票据装订到 `.app``.dmg`
- 硬性验证步骤(`codesign --verify` + `spctl -a` + `stapler validate`)把关发布
---
## 变更
### Skills 缓存策略优化
-`invalidateQueries` 替换为直接 `setQueryData` 更新,用于 skill 安装/卸载/导入操作
- 新增 `staleTime: Infinity``keepPreviousData`,消除加载闪烁 (#1573,感谢 @TangZhiZzz)
### 代理 Gzip 压缩
- 非流式请求允许 reqwest 自动协商 gzip 并透明解压响应
- 流式请求保守地保持 `Accept-Encoding: identity`,避免中断的 SSE 流解压出错
### o1/o3 模型兼容性
- Chat Completions 路径对 o1/o3/o4-mini 模型使用 `max_completion_tokens` 替代 `max_tokens` (#1451,感谢 @Hemilt0n)
- Responses API 路径保持使用正确的 `max_output_tokens` 字段
### OpenCode 模型变体
- 将 OpenCode 的模型变体放在预设顶层而非嵌套在 options 内部,提升可发现性 (#1317)
### Skills 导入流程
- 将基于文件系统的隐式应用推断替换为显式的 `ImportSkillSelection`,防止同一 skill 目录存在于多个应用路径下时错误激活多个应用
-`sync_to_app` 增加协调逻辑,移除已禁用/孤立的符号链接
- MCP `sync_all_enabled` 现在会从 live 配置中移除已禁用的服务器
### Claude 4.6 上下文窗口
- Claude Opus 4.6 和 Sonnet 4.6 上下文窗口从 200K 更新至 1M(GA 发布)
### MiniMax 模型升级
- MiniMax 预设从 M2.5 升级至 M2.7,更新三语合作伙伴描述
### 小米 MiMo 模型升级
- MiMo 预设从 mimo-v2-flash 升级至 mimo-v2-pro
### 添加供应商对话框简化
- 移除冗余的 OAuth 标签页,对话框从 3 个标签页减少到 2 个(应用专属 + 通用)
### 供应商表单高级选项折叠
- Claude 供应商表单中的模型映射、API 格式等高级字段在未填写时默认折叠
- 预设填充值后自动展开,手动清空不会自动折叠
---
## Bug 修复
### WebDAV 密码被静默清除
- 修复 ProviderList 或 UsageScriptModal 保存设置时 WebDAV 密码被静默清除的问题
- 前端 payload 中剥离 `webdavSync`,后端 `merge_settings_for_save()` 增加回填逻辑保护现有密码
### 工具消息解析
- 修复 Claudetool_result content blocks)、Codexfunction_call/function_call_output payloads)和 Geminiarray content + toolCalls extraction)的 tool_use/tool_result 消息分类 (#1401,感谢 @BlueOcean223)
### 暗色模式选择器
- 将 Tailwind `darkMode``["selector", "class"]` 改为 `["selector", ".dark"]`,确保暗色模式正确激活 (#1596,感谢 @qinxiandiqi)
### Copilot 请求指纹
- 统一所有 Copilot API 调用点的请求指纹头,防止 User-Agent 泄漏和 Stream Check 不匹配
### 供应商表单防重复提交
- 修复快速连续点击按钮时供应商添加/编辑表单重复提交的问题 (#1352,感谢 @Hexi1997)
### Ghostty 终端会话恢复
- 修复在 Ghostty 终端中恢复 Claude 会话失败的问题 (#1506,感谢 @canyonsehun)
### Skill ZIP 导入扩展名
- ZIP 导入对话框现在支持 `.skill` 文件扩展名 (#1240, #1455,感谢 @yovinchen)
### Skill ZIP 安装目标应用
- ZIP 方式安装的 skill 现在使用当前活跃应用,而非始终默认为 Claude
### OpenClaw 活跃供应商高亮
- 修复 OpenClaw 当前激活的供应商卡片未高亮显示的问题 (#1419,感谢 @funnytime75)
### 响应式布局与 TOC
- 改善存在 TOC 标题时的响应式布局 (#1491,感谢 @West-Pavilion)
### Skills 导入对话框白屏
- 在 ImportSkillsDialog 中补充缺失的 TooltipProvider,修复打开对话框时的运行时崩溃
### 面板底部空白区域
- 将所有内容面板的硬编码 `h-[calc(100vh-8rem)]` 替换为 `flex-1 min-h-0`,消除因不同平台偏移量不匹配导致的底部空白
---
## 文档
### 定价模型 ID 归一化
- 在中英日三语用户手册中新增模型 ID 归一化规则说明(前缀剥离、后缀修剪、`@``-` 替换)(#1591,感谢 @makoMakoGo)
### macOS 签名与公证说明
- 移除 README、安装指南和 FAQ 中所有 `xattr` 变通方案和"未知开发者"警告
- 替换为"已通过 Apple 代码签名和公证"的说明
---
## ⚠️ 风险提示
**GitHub Copilot 反向代理免责声明**
本版本新增的 Copilot 反向代理功能通过逆向工程的非官方 API 访问 GitHub Copilot 服务。启用此功能前,请注意以下风险:
1. **违反服务条款**:此功能可能违反 [GitHub 可接受使用政策](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies)和[附加产品条款](https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features),其中禁止过度自动化批量活动、未经授权的服务复制以及通过自动化手段对服务器施加不当负担。
2. **账号风险**:已有类似工具的用户收到 GitHub 官方警告邮件,指出其存在"脚本化交互或其他刻意的异常或高强度使用"行为。收到警告后继续使用可能导致 Copilot 访问权限被暂停甚至永久封禁。
3. **无法保证长期可用**:GitHub 可能随时更新其检测机制,当前可用的使用方式未来可能被标记。
用户启用此功能即表示**自行承担所有风险**。CC Switch 不对因使用此功能而导致的任何账号限制、警告或服务暂停承担责任。
---
## 下载与安装
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
### 系统要求
| 系统 | 最低版本 | 架构 |
| ------- | ----------------------------- | ----------------------------------- |
| Windows | Windows 10 及以上 | x64 |
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
| Linux | 见下表 | x64 |
### Windows
| 文件 | 说明 |
| ------------------------------------------ | ----------------------------------- |
| `CC-Switch-v3.12.3-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
| `CC-Switch-v3.12.3-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
### macOS
| 文件 | 说明 |
| ---------------------------------- | --------------------------------------------------------- |
| `CC-Switch-v3.12.3-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
| `CC-Switch-v3.12.3-macOS.zip` | 解压后拖入 ApplicationsUniversal Binary |
| `CC-Switch-v3.12.3-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
> macOS 版本已通过 Apple 代码签名和公证,可直接安装使用。
### HomebrewmacOS
```bash
brew tap farion1231/ccswitch
brew install --cask cc-switch
```
更新:
```bash
brew upgrade --cask cc-switch
```
### Linux
| 发行版 | 推荐格式 | 安装方式 |
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb``sudo apt install ./CC-Switch-*.deb` |
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm``sudo dnf install ./CC-Switch-*.rpm` |
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
@@ -131,21 +131,9 @@ brew upgrade --cask cc-switch
2. Extract to get `CC Switch.app`
3. Drag it to the Applications folder
### First Launch Warning
### Signed and Notarized
Since the developer does not have an Apple Developer account, a "developer cannot be verified" warning may appear on first launch:
**Recommended solution**:
Open Terminal and run the following command:
```bash
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
```
**Alternative solution (via System Settings)**:
1. Close the warning dialog
2. Open "System Settings" > "Privacy & Security"
3. Find the CC Switch prompt and click "Open Anyway"
4. Reopen the app to use it normally
CC Switch for macOS is signed and notarized by Apple. You can install and open it directly — no extra steps needed.
## Linux
@@ -33,6 +33,7 @@ Presets are pre-configured provider templates that only require an API Key to us
| Bailian | Alibaba Cloud Bailian (Qwen) |
| Kimi | Moonshot Kimi model |
| Kimi For Coding | Kimi coding-specific model |
| StepFun | StepFun model |
| ModelScope | ModelScope community |
| KAT-Coder | KAT-Coder model |
| Longcat | Longcat AI |
@@ -92,6 +93,7 @@ Presets are pre-configured provider templates that only require an API Key to us
| Bailian | Alibaba Cloud Bailian |
| Kimi k2.5 | Moonshot Kimi-k2.5 model |
| Kimi For Coding | Kimi coding-specific model |
| StepFun | StepFun model |
| ModelScope | ModelScope community |
| KAT-Coder | KAT-Coder model |
| Longcat | Longcat AI |
@@ -124,6 +126,7 @@ Presets are pre-configured provider templates that only require an API Key to us
| Qwen Coder | Qwen coding model |
| Kimi k2.5 | Moonshot Kimi-k2.5 model |
| Kimi For Coding | Kimi coding-specific model |
| StepFun | StepFun model |
| MiniMax | MiniMax model |
| MiniMax en | MiniMax (English version) |
| KAT-Coder | KAT-Coder model |
+21 -1
View File
@@ -186,6 +186,22 @@ Set prices for each model (per million tokens):
| Cache Read Price | Price per million cache hit tokens |
| Cache Creation Price | Price per million cache creation tokens |
### Model ID Normalization Rules
Before matching pricing, CC Switch normalizes the requested model ID:
- Remove everything before the last `/`
- Remove everything after `:`
- Replace `@` with `-`
When adding pricing entries, enter the normalized Model ID rather than the full raw model name from the request.
| Raw model name | Model ID to enter | Note |
|-------|-------------------|------|
| `stepfun-ai/step-3.5-flash` | `step-3.5-flash` | Removes the provider prefix |
| `moonshotai/kimi-k2-0905:exa` | `kimi-k2-0905` | Removes the prefix and the `:` suffix |
| `gpt-5.2-codex@low` | `gpt-5.2-codex-low` | Replaces `@` with `-` |
### Operations
- **Add**: Click the "Add" button to add model pricing
@@ -238,10 +254,14 @@ CC Switch includes preset official prices for common models (per million tokens)
| gemini-2.5-pro | $1.25 | $10 | $0.125 |
| gemini-2.5-flash | $0.30 | $2.50 | $0.03 |
**Chinese Provider Models (CNY)**:
**Chinese Provider Models**:
> Note: Currency follows each provider's official pricing page. StepFun is currently listed in USD.
| Model | Input | Output | Cache Read |
|-------|-------|--------|------------|
| **StepFun** | | | |
| step-3.5-flash | $0.10 | $0.30 | $0.02 |
| **DeepSeek** | | | |
| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |
| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |
+2 -16
View File
@@ -2,23 +2,9 @@
## Installation Issues
### macOS Shows "Unidentified Developer"
### macOS Installation
**Problem**: First launch shows "Cannot open because it is from an unidentified developer"
**Solution 1**: Via System Settings
1. Close the warning dialog
2. Open "System Settings" > "Privacy & Security"
3. Find the CC Switch prompt
4. Click "Open Anyway"
5. Reopen the app
**Solution 2**: Via Terminal command (recommended)
```bash
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
```
The app can be opened normally after running this command.
CC Switch for macOS is code-signed and notarized by Apple. You can download and install it directly without any additional steps. If you encounter issues, try downloading the latest version from the [Releases page](https://github.com/farion1231/cc-switch/releases).
### Windows: App Doesn't Launch After Installation
@@ -131,21 +131,9 @@ brew upgrade --cask cc-switch
2. 展開して `CC Switch.app` を取得
3. 「アプリケーション」フォルダにドラッグ
### 初回起動時の警告
### 署名・公証済み
開発者が Apple 開発者アカウントを持っていないため、初回起動時に「不明な開発者」の警告が表示される場合があります
**推奨される解決方法**
ターミナルで以下のコマンドを実行してください:
```bash
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
```
**別の解決方法(システム設定から)**:
1. 警告ダイアログを閉じる
2. 「システム設定」→「プライバシーとセキュリティ」を開く
3. CC Switch に関する表示を見つけ、「このまま開く」をクリック
4. 再度アプリを開くと正常に使用可能
CC Switch の macOS 版は Apple のコード署名と公証を受けています。追加の操作なしで直接インストールして開くことができます
## Linux
@@ -33,6 +33,7 @@
| 百炼 | アリクラウド百炼(通义千問) |
| Kimi | Moonshot Kimi モデル |
| Kimi For Coding | Kimi プログラミング専用モデル |
| StepFun | StepFun モデル |
| ModelScope | 魔搭コミュニティ |
| KAT-Coder | KAT-Coder モデル |
| Longcat | Longcat AI |
@@ -92,6 +93,7 @@
| 百炼 | アリクラウド百炼 |
| Kimi k2.5 | Moonshot Kimi-k2.5 モデル |
| Kimi For Coding | Kimi プログラミング専用モデル |
| StepFun | StepFun モデル |
| ModelScope | 魔搭コミュニティ |
| KAT-Coder | KAT-Coder モデル |
| Longcat | Longcat AI |
@@ -124,6 +126,7 @@
| Qwen Coder | 通义千問コーディングモデル |
| Kimi k2.5 | Moonshot Kimi-k2.5 モデル |
| Kimi For Coding | Kimi プログラミング専用モデル |
| StepFun | StepFun モデル |
| MiniMax | MiniMax モデル |
| MiniMax en | MiniMax(英語版) |
| KAT-Coder | KAT-Coder モデル |
+21 -1
View File
@@ -186,6 +186,22 @@ Token 使用量の変化を表示:
| キャッシュ読取価格 | 100 万キャッシュヒット Token あたりの価格 |
| キャッシュ作成価格 | 100 万キャッシュ作成 Token あたりの価格 |
### モデル ID の正規化ルール
料金を照合する前に、CC Switch はリクエスト内のモデル ID を正規化します:
- 最後の `/` より前の接頭辞を削除
- `:` 以降の接尾辞を削除
- `@``-` に置換
料金設定では、リクエスト内の完全な元のモデル名ではなく、正規化後のモデル ID を入力してください。
| 元のモデル名 | 入力するモデル ID | 説明 |
|------|------|------|
| `stepfun-ai/step-3.5-flash` | `step-3.5-flash` | プロバイダー接頭辞を削除 |
| `moonshotai/kimi-k2-0905:exa` | `kimi-k2-0905` | 接頭辞と `:` 以降を削除 |
| `gpt-5.2-codex@low` | `gpt-5.2-codex-low` | `@``-` に置換 |
### 操作
- **追加**:「追加」ボタンで新しいモデル価格を追加
@@ -238,10 +254,14 @@ CC Switch は一般的なモデルの公式価格(100 万 Token あたり)
| gemini-2.5-pro | $1.25 | $10 | $0.125 |
| gemini-2.5-flash | $0.30 | $2.50 | $0.03 |
**中国メーカーのモデル(人民元)**
**中国メーカーのモデル**
> 注: 通貨は各プロバイダーの公式料金ページに従います。StepFun は現在 USD 表記です。
| モデル | 入力 | 出力 | キャッシュ読取 |
|------|------|------|----------|
| **StepFun** | | | |
| step-3.5-flash | $0.10 | $0.30 | $0.02 |
| **DeepSeek** | | | |
| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |
| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |
+2 -16
View File
@@ -2,23 +2,9 @@
## インストールに関する問題
### macOS で「不明な開発者」と表示される
### macOS のインストール
**問題**:初回起動時に「開けません。身元不明の開発者のものです」と表示される
**解決方法 1**:システム設定から
1. 警告ダイアログを閉じる
2. 「システム設定」→「プライバシーとセキュリティ」を開く
3. CC Switch に関する表示を見つける
4. 「このまま開く」をクリック
5. 再度アプリを開く
**解決方法 2**:ターミナルコマンドから(推奨)
```bash
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
```
実行後、正常にアプリを開けるようになります。
CC Switch の macOS 版は Apple のコード署名と公証を受けています。追加の操作なしで直接ダウンロードしてインストールできます。問題が発生した場合は、[Releases ページ](https://github.com/farion1231/cc-switch/releases) から最新版をダウンロードしてください。
### Windows でインストール後に起動できない
@@ -145,21 +145,9 @@ brew upgrade --cask cc-switch
2. 解压得到 `CC Switch.app`
3. 拖动到「应用程序」文件夹
### 首次打开提示
### 已签名并公证
由于开发者没有 Apple 开发者账号,首次打开可能出现「未知开发者」警告:
**推荐解决方法**
打开终端执行以下命令:
```bash
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
```
**备选解决方法(通过系统设置)**:
1. 关闭警告弹窗
2. 打开「系统设置」→「隐私与安全性」
3. 找到 CC Switch 相关提示,点击「仍要打开」
4. 再次打开应用即可正常使用
CC Switch macOS 版本已通过 Apple 代码签名和公证,可直接安装打开,无需额外操作。
## Linux
+3 -1
View File
@@ -33,6 +33,7 @@
| 百炼 | 阿里云百炼(通义千问) |
| Kimi | Moonshot Kimi 模型 |
| Kimi For Coding | Kimi 编程专用模型 |
| StepFun | 阶跃星辰 Step模型 |
| ModelScope | 魔搭社区 |
| KAT-Coder | KAT-Coder 模型 |
| Longcat | Longcat AI |
@@ -92,6 +93,7 @@
| 百炼 | 阿里云百炼 |
| Kimi k2.5 | Moonshot Kimi-k2.5 模型 |
| Kimi For Coding | Kimi 编程专用模型 |
| StepFun | 阶跃星辰 Step模型 |
| ModelScope | 魔搭社区 |
| KAT-Coder | KAT-Coder 模型 |
| Longcat | Longcat AI |
@@ -124,6 +126,7 @@
| Qwen Coder | 通义千问编码模型 |
| Kimi k2.5 | Moonshot Kimi-k2.5 模型 |
| Kimi For Coding | Kimi 编程专用模型 |
| StepFun | 阶跃星辰 Step模型 |
| MiniMax | MiniMax 模型 |
| MiniMax en | MiniMax(英文版) |
| KAT-Coder | KAT-Coder 模型 |
@@ -352,4 +355,3 @@ CC Switch 支持两种方式导入供应商配置:
- 🔴 红色:延迟 > 1000ms(较慢)
![image-20260108005327817](../../assets/image-20260108005327817.png)
+21 -1
View File
@@ -186,6 +186,22 @@
| 缓存读取价格 | 每百万缓存命中 Token 的价格 |
| 缓存创建价格 | 每百万缓存创建 Token 的价格 |
### 模型 ID 匹配规则
在匹配定价前,CC Switch 会先对请求中的模型 ID 做标准化处理:
- 去掉最后一个 `/` 之前的前缀
- 去掉 `:` 之后的后缀
-`@` 替换为 `-`
因此,在定价配置中请填写清洗后的模型 ID,而不是请求里的完整原始模型名。
| 原始模型名 | 应填写的模型 ID | 说明 |
|------|------|------|
| `stepfun-ai/step-3.5-flash` | `step-3.5-flash` | 去掉供应商前缀 |
| `moonshotai/kimi-k2-0905:exa` | `kimi-k2-0905` | 去掉前缀和 `:` 后缀 |
| `gpt-5.2-codex@low` | `gpt-5.2-codex-low` | 将 `@` 替换为 `-` |
### 操作
- **添加**:点击「添加」按钮新增模型定价
@@ -238,10 +254,14 @@ CC Switch 预设了常用模型的官方价格(每百万 Token):
| gemini-2.5-pro | $1.25 | $10 | $0.125 |
| gemini-2.5-flash | $0.30 | $2.50 | $0.03 |
**中国厂商模型(人民币)**
**中国厂商模型**
> 注:币种遵循各供应商官方定价页面。StepFun 当前按美元列出。
| 模型 | 输入 | 输出 | 缓存读取 |
|------|------|------|----------|
| **StepFun** | | | |
| step-3.5-flash | $0.10 | $0.30 | $0.02 |
| **DeepSeek** | | | |
| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |
| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |
+2 -16
View File
@@ -2,23 +2,9 @@
## 安装问题
### macOS 提示「未知开发者」
### macOS 安装
**问题**:首次打开时提示「无法打开,因为它来自身份不明的开发者」
**解决方法一**:通过系统设置
1. 关闭警告弹窗
2. 打开「系统设置」→「隐私与安全性」
3. 找到 CC Switch 相关提示
4. 点击「仍要打开」
5. 再次打开应用
**解决方法二**:通过终端命令(推荐)
```bash
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
```
执行后即可正常打开应用。
CC Switch macOS 版本已通过 Apple 代码签名和公证,可直接下载安装,无需额外操作。如遇问题,请尝试从 [Releases 页面](https://github.com/farion1231/cc-switch/releases) 下载最新版本。
### Windows 安装后无法启动
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.12.0",
"version": "3.12.3",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
@@ -90,6 +90,5 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.1.12"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
}
+1
View File
@@ -4,3 +4,4 @@ module.exports = {
autoprefixer: {},
},
};
+5 -1
View File
@@ -6,7 +6,7 @@ const ICONS_TO_EXTRACT = {
// AI 服务商(必需)
aiProviders: [
'openai', 'anthropic', 'claude', 'google', 'gemini',
'deepseek', 'kimi', 'moonshot', 'zhipu', 'minimax',
'deepseek', 'kimi', 'moonshot', 'stepfun', 'zhipu', 'minimax',
'baidu', 'alibaba', 'tencent', 'meta', 'microsoft',
'cohere', 'perplexity', 'mistral', 'huggingface'
],
@@ -60,6 +60,9 @@ ALL_ICONS.forEach(iconName => {
fs.copyFileSync(sourceFile, targetFile);
console.log(`${iconName}.svg`);
extracted++;
} else if (fs.existsSync(targetFile)) {
console.log(`${iconName}.svg (kept local custom icon)`);
extracted++;
} else {
console.log(`${iconName}.svg (not found)`);
notFound.push(iconName);
@@ -110,6 +113,7 @@ export const iconMetadata: Record<string, IconMetadata> = {
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' },
+1 -1
View File
@@ -10,7 +10,7 @@ const KEEP_LIST = [
'openai', 'anthropic', 'claude', 'google', 'gemini', 'gemma', 'palm',
'microsoft', 'azure', 'copilot', 'meta', 'llama',
'alibaba', 'qwen', 'tencent', 'hunyuan', 'baidu', 'wenxin',
'bytedance', 'doubao', 'deepseek', 'moonshot', 'kimi',
'bytedance', 'doubao', 'deepseek', 'moonshot', 'kimi', 'stepfun',
'zhipu', 'chatglm', 'glm', 'minimax', 'mistral', 'cohere',
'perplexity', 'huggingface', 'midjourney', 'stability',
'xai', 'grok', 'yi', 'zeroone', 'ollama',
+1
View File
@@ -15,6 +15,7 @@ const KNOWN_METADATA = {
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' },
+1388 -1075
View File
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.12.0"
version = "3.12.3"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
@@ -23,7 +23,7 @@ test-hooks = []
tauri-build = { version = "2.4.0", features = [] }
[dependencies]
serde_json = "1.0"
serde_json = { version = "1.0", features = ["preserve_order"] }
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
chrono = { version = "0.4", features = ["serde"] }
@@ -39,6 +39,8 @@ dirs = "5.0"
toml = "0.8"
toml_edit = "0.22"
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
flate2 = "1"
brotli = "7"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
futures = "0.3"
async-stream = "0.3"
@@ -47,6 +49,16 @@ axum = "0.7"
tower = "0.4"
tower-http = { version = "0.5", features = ["cors"] }
hyper = { version = "1.0", features = ["full"] }
hyper-util = { version = "0.1", features = ["tokio", "http1", "client-legacy"] }
hyper-rustls = { version = "0.27", features = ["http1", "tls12", "ring", "webpki-tokio"] }
http = "1"
http-body = "1"
http-body-util = "0.1"
httparse = "1"
tokio-rustls = "0.26"
rustls = "0.23"
webpki-roots = "0.26"
rustls-native-certs = "0.8"
regex = "1.10"
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
thiserror = "2.0"
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

+9
View File
@@ -883,6 +883,7 @@ mod tests {
dir: TempDir,
original_home: Option<String>,
original_userprofile: Option<String>,
original_test_home: Option<String>,
}
impl TempHome {
@@ -890,14 +891,17 @@ mod tests {
let dir = TempDir::new().expect("failed to create temp home");
let original_home = env::var("HOME").ok();
let original_userprofile = env::var("USERPROFILE").ok();
let original_test_home = env::var("CC_SWITCH_TEST_HOME").ok();
env::set_var("HOME", dir.path());
env::set_var("USERPROFILE", dir.path());
env::set_var("CC_SWITCH_TEST_HOME", dir.path());
Self {
dir,
original_home,
original_userprofile,
original_test_home,
}
}
}
@@ -913,6 +917,11 @@ mod tests {
Some(value) => env::set_var("USERPROFILE", value),
None => env::remove_var("USERPROFILE"),
}
match &self.original_test_home {
Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value),
None => env::remove_var("CC_SWITCH_TEST_HOME"),
}
}
}
+333
View File
@@ -9,6 +9,7 @@ use crate::error::AppError;
use serde_json::Value;
use std::fs;
use std::path::Path;
use toml_edit::DocumentMut;
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
@@ -135,3 +136,335 @@ pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
validate_config_toml(&s)?;
Ok(s)
}
/// Update a field in Codex config.toml using toml_edit (syntax-preserving).
///
/// Supported fields:
/// - `"base_url"`: writes to `[model_providers.<current>].base_url` if `model_provider` exists,
/// otherwise falls back to top-level `base_url`.
/// - `"model"`: writes to top-level `model` field.
///
/// Empty value removes the field.
pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Result<String, String> {
let mut doc = toml_str
.parse::<DocumentMut>()
.map_err(|e| format!("TOML parse error: {e}"))?;
let trimmed = value.trim();
match field {
"base_url" => {
let model_provider = doc
.get("model_provider")
.and_then(|item| item.as_str())
.map(str::to_string);
if let Some(provider_key) = model_provider {
// Ensure [model_providers] table exists
if doc.get("model_providers").is_none() {
doc["model_providers"] = toml_edit::table();
}
if let Some(model_providers) = doc["model_providers"].as_table_mut() {
// Ensure [model_providers.<provider_key>] table exists
if !model_providers.contains_key(&provider_key) {
model_providers[&provider_key] = toml_edit::table();
}
if let Some(provider_table) = model_providers[&provider_key].as_table_mut() {
if trimmed.is_empty() {
provider_table.remove("base_url");
} else {
provider_table["base_url"] = toml_edit::value(trimmed);
}
return Ok(doc.to_string());
}
}
}
// Fallback: no model_provider or structure mismatch → top-level base_url
if trimmed.is_empty() {
doc.as_table_mut().remove("base_url");
} else {
doc["base_url"] = toml_edit::value(trimmed);
}
}
"model" => {
if trimmed.is_empty() {
doc.as_table_mut().remove("model");
} else {
doc["model"] = toml_edit::value(trimmed);
}
}
_ => return Err(format!("unsupported field: {field}")),
}
Ok(doc.to_string())
}
/// Remove `base_url` from the active model_provider section only if it matches `predicate`.
/// Also removes top-level `base_url` if it matches.
/// Used by proxy cleanup to strip local proxy URLs without touching user-configured URLs.
pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) -> bool) -> String {
let mut doc = match toml_str.parse::<DocumentMut>() {
Ok(doc) => doc,
Err(_) => return toml_str.to_string(),
};
let model_provider = doc
.get("model_provider")
.and_then(|item| item.as_str())
.map(str::to_string);
if let Some(provider_key) = model_provider {
if let Some(model_providers) = doc
.get_mut("model_providers")
.and_then(|v| v.as_table_mut())
{
if let Some(provider_table) = model_providers
.get_mut(provider_key.as_str())
.and_then(|v| v.as_table_mut())
{
let should_remove = provider_table
.get("base_url")
.and_then(|item| item.as_str())
.map(&predicate)
.unwrap_or(false);
if should_remove {
provider_table.remove("base_url");
}
}
}
}
// Fallback: also clean up top-level base_url if it matches
let should_remove_root = doc
.get("base_url")
.and_then(|item| item.as_str())
.map(&predicate)
.unwrap_or(false);
if should_remove_root {
doc.as_table_mut().remove("base_url");
}
doc.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base_url_writes_into_correct_model_provider_section() {
let input = r#"model_provider = "any"
model = "gpt-5.1-codex"
[model_providers.any]
name = "any"
wire_api = "responses"
"#;
let result = update_codex_toml_field(input, "base_url", "https://example.com/v1").unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
let base_url = parsed
.get("model_providers")
.and_then(|v| v.get("any"))
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str())
.expect("base_url should be in model_providers.any");
assert_eq!(base_url, "https://example.com/v1");
// Should NOT have top-level base_url
assert!(parsed.get("base_url").is_none());
// wire_api preserved
let wire_api = parsed
.get("model_providers")
.and_then(|v| v.get("any"))
.and_then(|v| v.get("wire_api"))
.and_then(|v| v.as_str());
assert_eq!(wire_api, Some("responses"));
}
#[test]
fn base_url_creates_section_when_missing() {
let input = r#"model_provider = "custom"
model = "gpt-4"
"#;
let result = update_codex_toml_field(input, "base_url", "https://custom.api/v1").unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
let base_url = parsed
.get("model_providers")
.and_then(|v| v.get("custom"))
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str())
.expect("should create section and set base_url");
assert_eq!(base_url, "https://custom.api/v1");
}
#[test]
fn base_url_falls_back_to_top_level_without_model_provider() {
let input = r#"model = "gpt-4"
"#;
let result = update_codex_toml_field(input, "base_url", "https://fallback.api/v1").unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
let base_url = parsed
.get("base_url")
.and_then(|v| v.as_str())
.expect("should set top-level base_url");
assert_eq!(base_url, "https://fallback.api/v1");
}
#[test]
fn clearing_base_url_removes_only_from_correct_section() {
let input = r#"model_provider = "any"
[model_providers.any]
name = "any"
base_url = "https://old.api/v1"
wire_api = "responses"
[mcp_servers.context7]
command = "npx"
"#;
let result = update_codex_toml_field(input, "base_url", "").unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
// base_url removed from model_providers.any
let any_section = parsed
.get("model_providers")
.and_then(|v| v.get("any"))
.expect("model_providers.any should exist");
assert!(any_section.get("base_url").is_none());
// wire_api preserved
assert_eq!(
any_section.get("wire_api").and_then(|v| v.as_str()),
Some("responses")
);
// mcp_servers untouched
assert!(parsed.get("mcp_servers").is_some());
}
#[test]
fn model_field_operates_on_top_level() {
let input = r#"model_provider = "any"
model = "gpt-4"
[model_providers.any]
name = "any"
"#;
let result = update_codex_toml_field(input, "model", "gpt-5").unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
assert_eq!(parsed.get("model").and_then(|v| v.as_str()), Some("gpt-5"));
// Clear model
let result2 = update_codex_toml_field(&result, "model", "").unwrap();
let parsed2: toml::Value = toml::from_str(&result2).unwrap();
assert!(parsed2.get("model").is_none());
}
#[test]
fn preserves_comments_and_whitespace() {
let input = r#"# My Codex config
model_provider = "any"
model = "gpt-4"
# Provider section
[model_providers.any]
name = "any"
base_url = "https://old.api/v1"
"#;
let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap();
// Comments should be preserved
assert!(result.contains("# My Codex config"));
assert!(result.contains("# Provider section"));
}
#[test]
fn does_not_misplace_when_profiles_section_follows() {
let input = r#"model_provider = "any"
[model_providers.any]
name = "any"
base_url = "https://old.api/v1"
[profiles.default]
model = "gpt-4"
"#;
let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap();
let parsed: toml::Value = toml::from_str(&result).unwrap();
// base_url in correct section
let base_url = parsed
.get("model_providers")
.and_then(|v| v.get("any"))
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str());
assert_eq!(base_url, Some("https://new.api/v1"));
// profiles section untouched
let profile_model = parsed
.get("profiles")
.and_then(|v| v.get("default"))
.and_then(|v| v.get("model"))
.and_then(|v| v.as_str());
assert_eq!(profile_model, Some("gpt-4"));
}
#[test]
fn remove_base_url_if_predicate() {
let input = r#"model_provider = "any"
[model_providers.any]
name = "any"
base_url = "http://127.0.0.1:5000/v1"
wire_api = "responses"
"#;
let result =
remove_codex_toml_base_url_if(input, |url| url.starts_with("http://127.0.0.1"));
let parsed: toml::Value = toml::from_str(&result).unwrap();
let any_section = parsed
.get("model_providers")
.and_then(|v| v.get("any"))
.unwrap();
assert!(any_section.get("base_url").is_none());
assert_eq!(
any_section.get("wire_api").and_then(|v| v.as_str()),
Some("responses")
);
}
#[test]
fn remove_base_url_if_keeps_non_matching() {
let input = r#"model_provider = "any"
[model_providers.any]
base_url = "https://production.api/v1"
"#;
let result =
remove_codex_toml_base_url_if(input, |url| url.starts_with("http://127.0.0.1"));
let parsed: toml::Value = toml::from_str(&result).unwrap();
let base_url = parsed
.get("model_providers")
.and_then(|v| v.get("any"))
.and_then(|v| v.get("base_url"))
.and_then(|v| v.as_str());
assert_eq!(base_url, Some("https://production.api/v1"));
}
}
+182
View File
@@ -0,0 +1,182 @@
use tauri::State;
use crate::commands::copilot::CopilotAuthState;
use crate::proxy::providers::copilot_auth::{GitHubAccount, GitHubDeviceCodeResponse};
const AUTH_PROVIDER_GITHUB_COPILOT: &str = "github_copilot";
#[derive(Debug, Clone, serde::Serialize)]
pub struct ManagedAuthAccount {
pub id: String,
pub provider: String,
pub login: String,
pub avatar_url: Option<String>,
pub authenticated_at: i64,
pub is_default: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ManagedAuthStatus {
pub provider: String,
pub authenticated: bool,
pub default_account_id: Option<String>,
pub migration_error: Option<String>,
pub accounts: Vec<ManagedAuthAccount>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ManagedAuthDeviceCodeResponse {
pub provider: String,
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: u64,
pub interval: u64,
}
fn ensure_auth_provider(auth_provider: &str) -> Result<&str, String> {
match auth_provider {
AUTH_PROVIDER_GITHUB_COPILOT => Ok(AUTH_PROVIDER_GITHUB_COPILOT),
_ => Err(format!("Unsupported auth provider: {auth_provider}")),
}
}
fn map_account(
provider: &str,
account: GitHubAccount,
default_account_id: Option<&str>,
) -> ManagedAuthAccount {
ManagedAuthAccount {
is_default: default_account_id == Some(account.id.as_str()),
id: account.id,
provider: provider.to_string(),
login: account.login,
avatar_url: account.avatar_url,
authenticated_at: account.authenticated_at,
}
}
fn map_device_code_response(
provider: &str,
response: GitHubDeviceCodeResponse,
) -> ManagedAuthDeviceCodeResponse {
ManagedAuthDeviceCodeResponse {
provider: provider.to_string(),
device_code: response.device_code,
user_code: response.user_code,
verification_uri: response.verification_uri,
expires_in: response.expires_in,
interval: response.interval,
}
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_start_login(
auth_provider: String,
state: State<'_, CopilotAuthState>,
) -> Result<ManagedAuthDeviceCodeResponse, String> {
let auth_provider = ensure_auth_provider(&auth_provider)?;
let auth_manager = state.0.read().await;
let response = auth_manager
.start_device_flow()
.await
.map_err(|e| e.to_string())?;
Ok(map_device_code_response(auth_provider, response))
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_poll_for_account(
auth_provider: String,
device_code: String,
state: State<'_, CopilotAuthState>,
) -> Result<Option<ManagedAuthAccount>, String> {
let auth_provider = ensure_auth_provider(&auth_provider)?;
let auth_manager = state.0.write().await;
match auth_manager.poll_for_token(&device_code).await {
Ok(account) => {
let default_account_id = auth_manager.get_status().await.default_account_id;
Ok(account
.map(|account| map_account(auth_provider, account, default_account_id.as_deref())))
}
Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => {
Ok(None)
}
Err(e) => Err(e.to_string()),
}
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_list_accounts(
auth_provider: String,
state: State<'_, CopilotAuthState>,
) -> Result<Vec<ManagedAuthAccount>, String> {
let auth_provider = ensure_auth_provider(&auth_provider)?;
let auth_manager = state.0.read().await;
let status = auth_manager.get_status().await;
let default_account_id = status.default_account_id.clone();
Ok(status
.accounts
.into_iter()
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
.collect())
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_get_status(
auth_provider: String,
state: State<'_, CopilotAuthState>,
) -> Result<ManagedAuthStatus, String> {
let auth_provider = ensure_auth_provider(&auth_provider)?;
let auth_manager = state.0.read().await;
let status = auth_manager.get_status().await;
let default_account_id = status.default_account_id.clone();
Ok(ManagedAuthStatus {
provider: auth_provider.to_string(),
authenticated: status.authenticated,
default_account_id: default_account_id.clone(),
migration_error: status.migration_error,
accounts: status
.accounts
.into_iter()
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
.collect(),
})
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_remove_account(
auth_provider: String,
account_id: String,
state: State<'_, CopilotAuthState>,
) -> Result<(), String> {
ensure_auth_provider(&auth_provider)?;
let auth_manager = state.0.write().await;
auth_manager
.remove_account(&account_id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_set_default_account(
auth_provider: String,
account_id: String,
state: State<'_, CopilotAuthState>,
) -> Result<(), String> {
ensure_auth_provider(&auth_provider)?;
let auth_manager = state.0.write().await;
auth_manager
.set_default_account(&account_id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command(rename_all = "camelCase")]
pub async fn auth_logout(
auth_provider: String,
state: State<'_, CopilotAuthState>,
) -> Result<(), String> {
ensure_auth_provider(&auth_provider)?;
let auth_manager = state.0.write().await;
auth_manager.clear_auth().await.map_err(|e| e.to_string())
}
+13 -10
View File
@@ -212,20 +212,22 @@ pub async fn set_claude_common_config_snippet(
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
let is_cleared = snippet.trim().is_empty();
if !snippet.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
}
let value = if snippet.trim().is_empty() {
None
} else {
Some(snippet)
};
let value = if is_cleared { None } else { Some(snippet) };
state
.db
.set_config_snippet("claude", value)
.map_err(|e| e.to_string())?;
state
.db
.set_config_snippet_cleared("claude", is_cleared)
.map_err(|e| e.to_string())?;
Ok(())
}
@@ -246,6 +248,7 @@ pub async fn set_common_config_snippet(
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
let is_cleared = snippet.trim().is_empty();
let old_snippet = state
.db
.get_config_snippet(&app_type)
@@ -253,11 +256,7 @@ pub async fn set_common_config_snippet(
validate_common_config_snippet(&app_type, &snippet)?;
let value = if snippet.trim().is_empty() {
None
} else {
Some(snippet)
};
let value = if is_cleared { None } else { Some(snippet) };
if matches!(app_type.as_str(), "claude" | "codex" | "gemini") {
if let Some(legacy_snippet) = old_snippet
@@ -278,6 +277,10 @@ pub async fn set_common_config_snippet(
.db
.set_config_snippet(&app_type, value)
.map_err(|e| e.to_string())?;
state
.db
.set_config_snippet_cleared(&app_type, is_cleared)
.map_err(|e| e.to_string())?;
if matches!(app_type.as_str(), "claude" | "codex" | "gemini") {
let app = AppType::from_str(&app_type).map_err(|e| e.to_string())?;
+212
View File
@@ -0,0 +1,212 @@
//! GitHub Copilot Tauri Commands
//!
//! 提供 Copilot OAuth 认证相关的 Tauri 命令,支持多账号管理。
use crate::proxy::providers::copilot_auth::{
CopilotAuthManager, CopilotAuthStatus, CopilotModel, CopilotUsageResponse, GitHubAccount,
GitHubDeviceCodeResponse,
};
use std::sync::Arc;
use tauri::State;
use tokio::sync::RwLock;
/// Copilot 认证状态
pub struct CopilotAuthState(pub Arc<RwLock<CopilotAuthManager>>);
// ==================== 设备码流程 ====================
/// 启动设备码流程
///
/// 返回设备码和用户码,用于 OAuth 认证
#[tauri::command]
pub async fn copilot_start_device_flow(
state: State<'_, CopilotAuthState>,
) -> Result<GitHubDeviceCodeResponse, String> {
let auth_manager = state.0.read().await;
auth_manager
.start_device_flow()
.await
.map_err(|e| e.to_string())
}
/// 轮询 OAuth Token(向后兼容)
///
/// 使用设备码轮询 GitHub,等待用户完成授权
/// 返回 true 表示授权成功,false 表示等待中
#[tauri::command(rename_all = "camelCase")]
pub async fn copilot_poll_for_auth(
device_code: String,
state: State<'_, CopilotAuthState>,
) -> Result<bool, String> {
let auth_manager = state.0.write().await;
match auth_manager.poll_for_token(&device_code).await {
Ok(Some(_account)) => {
log::info!("[CopilotAuth] 用户已授权");
Ok(true)
}
Ok(None) => Ok(false),
Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => {
Ok(false)
}
Err(e) => {
log::error!("[CopilotAuth] 轮询失败: {e}");
Err(e.to_string())
}
}
}
/// 轮询 OAuth Token(多账号版本)
///
/// 返回新添加的账号信息,如果授权成功
#[tauri::command(rename_all = "camelCase")]
pub async fn copilot_poll_for_account(
device_code: String,
state: State<'_, CopilotAuthState>,
) -> Result<Option<GitHubAccount>, String> {
let auth_manager = state.0.write().await;
match auth_manager.poll_for_token(&device_code).await {
Ok(account) => Ok(account),
Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => {
Ok(None)
}
Err(e) => {
log::error!("[CopilotAuth] 轮询失败: {e}");
Err(e.to_string())
}
}
}
// ==================== 多账号管理 ====================
/// 列出所有已认证的账号
#[tauri::command]
pub async fn copilot_list_accounts(
state: State<'_, CopilotAuthState>,
) -> Result<Vec<GitHubAccount>, String> {
let auth_manager = state.0.read().await;
Ok(auth_manager.list_accounts().await)
}
/// 移除指定账号
#[tauri::command(rename_all = "camelCase")]
pub async fn copilot_remove_account(
account_id: String,
state: State<'_, CopilotAuthState>,
) -> Result<(), String> {
let auth_manager = state.0.write().await;
auth_manager
.remove_account(&account_id)
.await
.map_err(|e| e.to_string())
}
/// 设置默认账号
#[tauri::command(rename_all = "camelCase")]
pub async fn copilot_set_default_account(
account_id: String,
state: State<'_, CopilotAuthState>,
) -> Result<(), String> {
let auth_manager = state.0.write().await;
auth_manager
.set_default_account(&account_id)
.await
.map_err(|e| e.to_string())
}
// ==================== 状态查询 ====================
/// 获取认证状态(包含所有账号)
#[tauri::command]
pub async fn copilot_get_auth_status(
state: State<'_, CopilotAuthState>,
) -> Result<CopilotAuthStatus, String> {
let auth_manager = state.0.read().await;
Ok(auth_manager.get_status().await)
}
/// 检查是否已认证(有任意账号)
#[tauri::command]
pub async fn copilot_is_authenticated(state: State<'_, CopilotAuthState>) -> Result<bool, String> {
let auth_manager = state.0.read().await;
Ok(auth_manager.is_authenticated().await)
}
/// 注销所有 Copilot 认证
#[tauri::command]
pub async fn copilot_logout(state: State<'_, CopilotAuthState>) -> Result<(), String> {
let auth_manager = state.0.write().await;
auth_manager.clear_auth().await.map_err(|e| e.to_string())
}
// ==================== Token 获取 ====================
/// 获取有效的 Copilot Token(向后兼容:使用第一个账号)
///
/// 内部使用,用于代理请求
#[tauri::command]
pub async fn copilot_get_token(state: State<'_, CopilotAuthState>) -> Result<String, String> {
let auth_manager = state.0.read().await;
auth_manager
.get_valid_token()
.await
.map_err(|e| e.to_string())
}
/// 获取指定账号的有效 Copilot Token
#[tauri::command(rename_all = "camelCase")]
pub async fn copilot_get_token_for_account(
account_id: String,
state: State<'_, CopilotAuthState>,
) -> Result<String, String> {
let auth_manager = state.0.read().await;
auth_manager
.get_valid_token_for_account(&account_id)
.await
.map_err(|e| e.to_string())
}
// ==================== 模型和使用量 ====================
/// 获取 Copilot 可用模型列表(向后兼容:使用第一个账号)
#[tauri::command]
pub async fn copilot_get_models(
state: State<'_, CopilotAuthState>,
) -> Result<Vec<CopilotModel>, String> {
let auth_manager = state.0.read().await;
auth_manager.fetch_models().await.map_err(|e| e.to_string())
}
/// 获取指定账号的 Copilot 可用模型列表
#[tauri::command(rename_all = "camelCase")]
pub async fn copilot_get_models_for_account(
account_id: String,
state: State<'_, CopilotAuthState>,
) -> Result<Vec<CopilotModel>, String> {
let auth_manager = state.0.read().await;
auth_manager
.fetch_models_for_account(&account_id)
.await
.map_err(|e| e.to_string())
}
/// 获取 Copilot 使用量信息(向后兼容:使用第一个账号)
#[tauri::command]
pub async fn copilot_get_usage(
state: State<'_, CopilotAuthState>,
) -> Result<CopilotUsageResponse, String> {
let auth_manager = state.0.read().await;
auth_manager.fetch_usage().await.map_err(|e| e.to_string())
}
/// 获取指定账号的 Copilot 使用量信息
#[tauri::command(rename_all = "camelCase")]
pub async fn copilot_get_usage_for_account(
account_id: String,
state: State<'_, CopilotAuthState>,
) -> Result<CopilotUsageResponse, String> {
let auth_manager = state.0.read().await;
auth_manager
.fetch_usage_for_account(&account_id)
.await
.map_err(|e| e.to_string())
}
+1 -1
View File
@@ -115,7 +115,7 @@ pub async fn open_zip_file_dialog<R: tauri::Runtime>(
let dialog = app.dialog();
let result = dialog
.file()
.add_filter("ZIP", &["zip"])
.add_filter("ZIP / Skill", &["zip", "skill"])
.blocking_pick_file();
Ok(result.map(|p| p.to_string()))
+14
View File
@@ -0,0 +1,14 @@
#[tauri::command]
pub fn enter_lightweight_mode(app: tauri::AppHandle) -> Result<(), String> {
crate::lightweight::enter_lightweight_mode(&app)
}
#[tauri::command]
pub fn exit_lightweight_mode(app: tauri::AppHandle) -> Result<(), String> {
crate::lightweight::exit_lightweight_mode(&app)
}
#[tauri::command]
pub fn is_lightweight_mode() -> bool {
crate::lightweight::is_lightweight_mode()
}
+188 -13
View File
@@ -6,7 +6,7 @@ use crate::services::ProviderService;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashMap;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use tauri::AppHandle;
use tauri::State;
@@ -500,7 +500,8 @@ fn extend_from_path_list(
/// OpenCode install.sh 路径优先级(见 https://github.com/anomalyco/opencode README:
/// $OPENCODE_INSTALL_DIR > $XDG_BIN_DIR > $HOME/bin > $HOME/.opencode/bin
/// 额外扫描 Go 安装路径(~/go/bin、$GOPATH/*/bin
/// 额外扫描 Bun 默认全局安装路径(~/.bun/bin
/// 和 Go 安装路径(~/go/bin、$GOPATH/*/bin)。
fn opencode_extra_search_paths(
home: &Path,
opencode_install_dir: Option<std::ffi::OsString>,
@@ -515,6 +516,7 @@ fn opencode_extra_search_paths(
if !home.as_os_str().is_empty() {
push_unique_path(&mut paths, home.join("bin"));
push_unique_path(&mut paths, home.join(".opencode").join("bin"));
push_unique_path(&mut paths, home.join(".bun").join("bin"));
push_unique_path(&mut paths, home.join("go").join("bin"));
}
@@ -718,8 +720,10 @@ pub async fn open_provider_terminal(
state: State<'_, crate::store::AppState>,
app: String,
#[allow(non_snake_case)] providerId: String,
cwd: Option<String>,
) -> Result<bool, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
let launch_cwd = resolve_launch_cwd(cwd)?;
// 获取提供商配置
let providers = ProviderService::list(state.inner(), app_type.clone())
@@ -734,7 +738,8 @@ pub async fn open_provider_terminal(
let env_vars = extract_env_vars_from_config(config, &app_type);
// 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名
launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?;
launch_terminal_with_env(env_vars, &providerId, launch_cwd.as_deref())
.map_err(|e| format!("启动终端失败: {e}"))?;
Ok(true)
}
@@ -789,11 +794,49 @@ fn extract_env_vars_from_config(
env_vars
}
fn resolve_launch_cwd(cwd: Option<String>) -> Result<Option<PathBuf>, String> {
let Some(raw_path) = cwd.filter(|value| !value.trim().is_empty()) else {
return Ok(None);
};
if raw_path.contains('\n') || raw_path.contains('\r') {
return Err("目录路径包含非法换行符".to_string());
}
let path = Path::new(&raw_path);
if !path.exists() {
return Err(format!("目录不存在: {raw_path}"));
}
let resolved = std::fs::canonicalize(path).map_err(|e| format!("解析目录失败: {e}"))?;
if !resolved.is_dir() {
return Err(format!("选择的路径不是文件夹: {}", resolved.display()));
}
// Strip Windows extended-length prefix that canonicalize produces,
// as it can break batch scripts and other shell commands.
// Special-case \\?\UNC\server\share -> \\server\share for network/WSL paths.
#[cfg(target_os = "windows")]
let resolved = {
let s = resolved.to_string_lossy();
if let Some(unc) = s.strip_prefix(r"\\?\UNC\") {
PathBuf::from(format!(r"\\{unc}"))
} else if let Some(stripped) = s.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
resolved
}
};
Ok(Some(resolved))
}
/// 创建临时配置文件并启动 claude 终端
/// 使用 --settings 参数传入提供商特定的 API 配置
fn launch_terminal_with_env(
env_vars: Vec<(String, String)>,
provider_id: &str,
cwd: Option<&Path>,
) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let config_file = temp_dir.join(format!(
@@ -807,19 +850,19 @@ fn launch_terminal_with_env(
#[cfg(target_os = "macos")]
{
launch_macos_terminal(&config_file)?;
launch_macos_terminal(&config_file, cwd)?;
Ok(())
}
#[cfg(target_os = "linux")]
{
launch_linux_terminal(&config_file)?;
launch_linux_terminal(&config_file, cwd)?;
Ok(())
}
#[cfg(target_os = "windows")]
{
launch_windows_terminal(&temp_dir, &config_file)?;
launch_windows_terminal(&temp_dir, &config_file, cwd)?;
return Ok(());
}
@@ -849,7 +892,7 @@ fn write_claude_config(
/// macOS: 根据用户首选终端启动
#[cfg(target_os = "macos")]
fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
fn launch_macos_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
let preferred = crate::settings::get_preferred_terminal();
@@ -858,18 +901,21 @@ fn launch_macos_terminal(config_file: &std::path::Path) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path = config_file.to_string_lossy();
let cd_command = build_shell_cd_command(cwd);
// Write the shell script to a temp file
let script_content = format!(
r#"#!/bin/bash
trap 'rm -f "{config_path}" "{script_file}"' EXIT
{cd_command}
echo "Using provider-specific claude config:"
echo "{config_path}"
claude --settings "{config_path}"
exec bash --norc --noprofile
"#,
config_path = config_path,
script_file = script_file.display()
script_file = script_file.display(),
cd_command = cd_command,
);
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
@@ -1005,7 +1051,7 @@ fn launch_macos_open_app(
/// Linux: 根据用户首选终端启动
#[cfg(target_os = "linux")]
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
@@ -1027,17 +1073,20 @@ fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
let temp_dir = std::env::temp_dir();
let script_file = temp_dir.join(format!("cc_switch_launcher_{}.sh", std::process::id()));
let config_path = config_file.to_string_lossy();
let cd_command = build_shell_cd_command(cwd);
let script_content = format!(
r#"#!/bin/bash
trap 'rm -f "{config_path}" "{script_file}"' EXIT
{cd_command}
echo "Using provider-specific claude config:"
echo "{config_path}"
claude --settings "{config_path}"
exec bash --norc --noprofile
"#,
config_path = config_path,
script_file = script_file.display()
script_file = script_file.display(),
cd_command = cd_command,
);
std::fs::write(&script_file, &script_content).map_err(|e| format!("写入启动脚本失败: {e}"))?;
@@ -1116,22 +1165,28 @@ fn which_command(cmd: &str) -> bool {
fn launch_windows_terminal(
temp_dir: &std::path::Path,
config_file: &std::path::Path,
cwd: Option<&Path>,
) -> Result<(), String> {
let preferred = crate::settings::get_preferred_terminal();
let terminal = preferred.as_deref().unwrap_or("cmd");
let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id()));
let config_path_for_batch = config_file.to_string_lossy().replace('&', "^&");
let config_path_for_batch = escape_windows_batch_value(&config_file.to_string_lossy());
let cwd_command = build_windows_cwd_command(cwd);
let content = format!(
"@echo off
{cwd_command}
echo Using provider-specific claude config:
echo {}
claude --settings \"{}\"
del \"{}\" >nul 2>&1
del \"%~f0\" >nul 2>&1
",
config_path_for_batch, config_path_for_batch, config_path_for_batch
config_path_for_batch,
config_path_for_batch,
config_path_for_batch,
cwd_command = cwd_command,
);
std::fs::write(&bat_file, &content).map_err(|e| format!("写入批处理文件失败: {e}"))?;
@@ -1162,6 +1217,55 @@ del \"%~f0\" >nul 2>&1
result
}
fn build_shell_cd_command(cwd: Option<&Path>) -> String {
cwd.map(|dir| {
format!(
"cd {} || exit 1\n",
shell_single_quote(&dir.to_string_lossy())
)
})
.unwrap_or_default()
}
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn is_windows_unc_path(path: &str) -> bool {
path.starts_with(r"\\")
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn build_windows_cwd_command_str(path: &str) -> String {
let escaped = escape_windows_batch_value(path);
if is_windows_unc_path(path) {
// `cmd.exe` cannot make a UNC path current via `cd`; `pushd` maps it first.
format!("pushd \"{escaped}\" || exit /b 1\r\n")
} else {
format!("cd /d \"{escaped}\" || exit /b 1\r\n")
}
}
#[cfg(target_os = "windows")]
fn build_windows_cwd_command(cwd: Option<&Path>) -> String {
cwd.map(|dir| build_windows_cwd_command_str(&dir.to_string_lossy()))
.unwrap_or_default()
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn escape_windows_batch_value(value: &str) -> String {
value
.replace('^', "^^")
.replace('%', "%%")
.replace('&', "^&")
.replace('|', "^|")
.replace('<', "^<")
.replace('>', "^>")
.replace('(', "^(")
.replace(')', "^)")
}
/// Windows: Run a start command with common error handling
#[cfg(target_os = "windows")]
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
@@ -1280,6 +1384,7 @@ mod tests {
assert_eq!(paths[1], PathBuf::from("/xdg/bin"));
assert!(paths.contains(&PathBuf::from("/home/tester/bin")));
assert!(paths.contains(&PathBuf::from("/home/tester/.opencode/bin")));
assert!(paths.contains(&PathBuf::from("/home/tester/.bun/bin")));
assert!(paths.contains(&PathBuf::from("/home/tester/go/bin")));
assert!(paths.contains(&PathBuf::from("/go/path1/bin")));
assert!(paths.contains(&PathBuf::from("/go/path2/bin")));
@@ -1290,7 +1395,7 @@ mod tests {
let home = PathBuf::from("/home/tester");
let same_dir = Some(std::ffi::OsString::from("/same/path"));
let paths = opencode_extra_search_paths(&home, same_dir.clone(), same_dir.clone(), None);
let paths = opencode_extra_search_paths(&home, same_dir.clone(), same_dir, None);
let count = paths
.iter()
@@ -1299,6 +1404,18 @@ mod tests {
assert_eq!(count, 1);
}
#[test]
fn opencode_extra_search_paths_deduplicates_bun_default_dir() {
let home = PathBuf::from("/home/tester");
let paths = opencode_extra_search_paths(&home, None, None, None);
let count = paths
.iter()
.filter(|path| **path == PathBuf::from("/home/tester/.bun/bin"))
.count();
assert_eq!(count, 1);
}
#[cfg(not(target_os = "windows"))]
#[test]
fn tool_executable_candidates_non_windows_uses_plain_binary_name() {
@@ -1323,4 +1440,62 @@ mod tests {
]
);
}
#[test]
fn resolve_launch_cwd_accepts_existing_directory() {
let resolved =
resolve_launch_cwd(Some(std::env::temp_dir().to_string_lossy().into_owned()))
.expect("temp dir should resolve")
.expect("temp dir should be present");
assert!(resolved.is_dir());
}
#[test]
fn resolve_launch_cwd_rejects_missing_directory() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock should be after epoch")
.as_nanos();
let missing = std::env::temp_dir().join(format!("cc-switch-missing-{unique}"));
let error = resolve_launch_cwd(Some(missing.to_string_lossy().into_owned()))
.expect_err("missing directory should fail");
assert!(error.contains("目录不存在"));
}
#[test]
fn build_shell_cd_command_quotes_spaces_and_single_quotes() {
let command = build_shell_cd_command(Some(Path::new("/tmp/project O'Brien")));
assert_eq!(command, "cd '/tmp/project O'\"'\"'Brien' || exit 1\n");
}
#[test]
fn build_windows_cwd_command_str_uses_cd_for_drive_paths() {
let command = build_windows_cwd_command_str(r"C:\work\repo");
assert_eq!(command, "cd /d \"C:\\work\\repo\" || exit /b 1\r\n");
}
#[test]
fn build_windows_cwd_command_str_uses_pushd_for_unc_paths() {
let command = build_windows_cwd_command_str(r"\\wsl$\Ubuntu\home\coder\repo");
assert_eq!(
command,
"pushd \"\\\\wsl$\\Ubuntu\\home\\coder\\repo\" || exit /b 1\r\n"
);
}
#[test]
fn build_windows_cwd_command_str_escapes_batch_metacharacters() {
let command = build_windows_cwd_command_str(r"\\server\share\100%&(test)");
assert_eq!(
command,
"pushd \"\\\\server\\share\\100%%^&^(test^)\" || exit /b 1\r\n"
);
}
}
+8
View File
@@ -1,6 +1,8 @@
#![allow(non_snake_case)]
mod auth;
mod config;
mod copilot;
mod deeplink;
mod env;
mod failover;
@@ -19,11 +21,15 @@ mod settings;
pub mod skill;
mod stream_check;
mod sync_support;
mod lightweight;
mod usage;
mod webdav_sync;
mod workspace;
pub use auth::*;
pub use config::*;
pub use copilot::*;
pub use deeplink::*;
pub use env::*;
pub use failover::*;
@@ -41,6 +47,8 @@ pub use session_manager::*;
pub use settings::*;
pub use skill::*;
pub use stream_check::*;
pub use lightweight::*;
pub use usage::*;
pub use webdav_sync::*;
pub use workspace::*;
+67 -4
View File
@@ -2,6 +2,7 @@ use indexmap::IndexMap;
use tauri::State;
use crate::app_config::AppType;
use crate::commands::copilot::CopilotAuthState;
use crate::error::AppError;
use crate::provider::Provider;
use crate::services::{
@@ -10,6 +11,11 @@ use crate::services::{
use crate::store::AppState;
use std::str::FromStr;
// 常量定义
const TEMPLATE_TYPE_GITHUB_COPILOT: &str = "github_copilot";
const COPILOT_UNIT_PREMIUM: &str = "requests";
/// 获取所有供应商
#[tauri::command]
pub fn get_providers(
state: State<'_, AppState>,
@@ -103,20 +109,22 @@ fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result
// Extract common config snippet (mirrors old startup logic in lib.rs)
if state
.db
.get_config_snippet(app_type.as_str())
.ok()
.flatten()
.is_none()
.should_auto_extract_config_snippet(app_type.as_str())?
{
match ProviderService::extract_common_config_snippet(state, app_type.clone()) {
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
let _ = state
.db
.set_config_snippet(app_type.as_str(), Some(snippet));
let _ = state
.db
.set_config_snippet_cleared(app_type.as_str(), false);
}
_ => {}
}
}
ProviderService::migrate_legacy_common_config_usage_if_needed(state, app_type.clone())?;
}
Ok(imported)
@@ -140,10 +148,65 @@ pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<
#[tauri::command]
pub async fn queryProviderUsage(
state: State<'_, AppState>,
copilot_state: State<'_, CopilotAuthState>,
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
app: String,
) -> Result<crate::provider::UsageResult, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
// 检查是否为 GitHub Copilot 模板类型,并解析绑定账号
let (is_copilot_template, copilot_account_id) = {
let providers = state
.db
.get_all_providers(app_type.as_str())
.map_err(|e| format!("Failed to get providers: {e}"))?;
let provider = providers.get(&providerId);
let is_copilot = provider
.and_then(|p| p.meta.as_ref())
.and_then(|m| m.usage_script.as_ref())
.and_then(|s| s.template_type.as_ref())
.map(|t| t == TEMPLATE_TYPE_GITHUB_COPILOT)
.unwrap_or(false);
let account_id = provider
.and_then(|p| p.meta.as_ref())
.and_then(|m| m.managed_account_id_for(TEMPLATE_TYPE_GITHUB_COPILOT));
(is_copilot, account_id)
};
if is_copilot_template {
// 使用 Copilot 专用 API
let auth_manager = copilot_state.0.read().await;
let usage = match copilot_account_id.as_deref() {
Some(account_id) => auth_manager
.fetch_usage_for_account(account_id)
.await
.map_err(|e| format!("Failed to fetch Copilot usage: {e}"))?,
None => auth_manager
.fetch_usage()
.await
.map_err(|e| format!("Failed to fetch Copilot usage: {e}"))?,
};
let premium = &usage.quota_snapshots.premium_interactions;
let used = premium.entitlement - premium.remaining;
return Ok(crate::provider::UsageResult {
success: true,
data: Some(vec![crate::provider::UsageData {
plan_name: Some(usage.copilot_plan),
remaining: Some(premium.remaining as f64),
total: Some(premium.entitlement as f64),
used: Some(used as f64),
unit: Some(COPILOT_UNIT_PREMIUM.to_string()),
is_valid: Some(true),
invalid_message: None,
extra: Some(format!("Reset: {}", usage.quota_reset_date)),
}]),
error: None,
});
}
ProviderService::query_usage(state.inner(), app_type, &providerId)
.await
.map_err(|e| e.to_string())
@@ -74,3 +74,12 @@ pub async fn delete_session(
.await
.map_err(|e| format!("Failed to delete session: {e}"))?
}
#[tauri::command]
pub async fn delete_sessions(
items: Vec<session_manager::DeleteSessionRequest>,
) -> Result<Vec<session_manager::DeleteSessionOutcome>, String> {
tauri::async_runtime::spawn_blocking(move || session_manager::delete_sessions(&items))
.await
.map_err(|e| format!("Failed to delete sessions: {e}"))
}
+74 -2
View File
@@ -6,8 +6,20 @@ fn merge_settings_for_save(
mut incoming: crate::settings::AppSettings,
existing: &crate::settings::AppSettings,
) -> crate::settings::AppSettings {
if incoming.webdav_sync.is_none() {
incoming.webdav_sync = existing.webdav_sync.clone();
match (&mut incoming.webdav_sync, &existing.webdav_sync) {
// incoming 没有 webdav → 保留现有
(None, _) => {
incoming.webdav_sync = existing.webdav_sync.clone();
}
// incoming 有 webdav 但密码为空,且现有有密码 → 填回现有密码
// get_settings_for_frontend 总是清空密码,所以通过 save_settings
// 传入的空密码意味着"保持现有"而非"用户主动清空"
(Some(incoming_sync), Some(existing_sync))
if incoming_sync.password.is_empty() && !existing_sync.password.is_empty() =>
{
incoming_sync.password = existing_sync.password.clone();
}
_ => {}
}
incoming
}
@@ -116,6 +128,66 @@ mod tests {
Some("https://dav.new.example.com")
);
}
/// Regression test: frontend always receives empty password from
/// get_settings_for_frontend(). If a component accidentally spreads
/// the full settings object into save_settings, the empty password
/// must NOT overwrite the existing one.
#[test]
fn save_settings_should_preserve_password_when_incoming_has_empty_password() {
let mut existing = AppSettings::default();
existing.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "secret".to_string(),
..WebDavSyncSettings::default()
});
// Simulate frontend sending settings with cleared password
let mut incoming = AppSettings::default();
incoming.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "".to_string(),
..WebDavSyncSettings::default()
});
let merged = merge_settings_for_save(incoming, &existing);
assert_eq!(
merged.webdav_sync.as_ref().map(|v| v.password.as_str()),
Some("secret"),
"empty password from frontend must not overwrite existing password"
);
}
/// When both incoming and existing have no password, merge should
/// work without panicking and keep the empty state.
#[test]
fn save_settings_should_handle_both_empty_passwords() {
let mut existing = AppSettings::default();
existing.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "".to_string(),
..WebDavSyncSettings::default()
});
let mut incoming = AppSettings::default();
incoming.webdav_sync = Some(WebDavSyncSettings {
base_url: "https://dav.example.com".to_string(),
username: "alice".to_string(),
password: "".to_string(),
..WebDavSyncSettings::default()
});
let merged = merge_settings_for_save(incoming, &existing);
assert_eq!(
merged.webdav_sync.as_ref().map(|v| v.password.as_str()),
Some("")
);
}
}
/// 获取开机自启状态
+39 -11
View File
@@ -6,7 +6,10 @@
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
use crate::error::format_skill_error;
use crate::services::skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
use crate::services::skill::{
DiscoverableSkill, ImportSkillSelection, Skill, SkillBackupEntry, SkillRepo, SkillService,
SkillUninstallResult,
};
use crate::store::AppState;
use std::sync::Arc;
use tauri::State;
@@ -33,6 +36,17 @@ pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result<Vec<Instal
SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_skill_backups() -> Result<Vec<SkillBackupEntry>, String> {
SkillService::list_backups().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_skill_backup(backup_id: String) -> Result<bool, String> {
SkillService::delete_backup(&backup_id).map_err(|e| e.to_string())?;
Ok(true)
}
/// 安装 Skill(新版统一安装)
///
/// 参数:
@@ -56,9 +70,22 @@ pub async fn install_skill_unified(
/// 卸载 Skill(新版统一卸载)
#[tauri::command]
pub fn uninstall_skill_unified(id: String, app_state: State<'_, AppState>) -> Result<bool, String> {
SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())?;
Ok(true)
pub fn uninstall_skill_unified(
id: String,
app_state: State<'_, AppState>,
) -> Result<SkillUninstallResult, String> {
SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn restore_skill_backup(
backup_id: String,
current_app: String,
app_state: State<'_, AppState>,
) -> Result<InstalledSkill, String> {
let app_type = parse_app_type(&current_app)?;
SkillService::restore_from_backup(&app_state.db, &backup_id, &app_type)
.map_err(|e| e.to_string())
}
/// 切换 Skill 的应用启用状态
@@ -85,10 +112,10 @@ pub fn scan_unmanaged_skills(
/// 从应用目录导入 Skills
#[tauri::command]
pub fn import_skills_from_apps(
directories: Vec<String>,
imports: Vec<ImportSkillSelection>,
app_state: State<'_, AppState>,
) -> Result<Vec<InstalledSkill>, String> {
SkillService::import_from_apps(&app_state.db, directories).map_err(|e| e.to_string())
SkillService::import_from_apps(&app_state.db, imports).map_err(|e| e.to_string())
}
// ========== 发现功能命令 ==========
@@ -192,7 +219,10 @@ pub async fn install_skill_for_app(
/// 卸载技能(兼容旧 API
#[tauri::command]
pub fn uninstall_skill(directory: String, app_state: State<'_, AppState>) -> Result<bool, String> {
pub fn uninstall_skill(
directory: String,
app_state: State<'_, AppState>,
) -> Result<SkillUninstallResult, String> {
uninstall_skill_for_app("claude".to_string(), directory, app_state)
}
@@ -202,7 +232,7 @@ pub fn uninstall_skill_for_app(
app: String,
directory: String,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
) -> Result<SkillUninstallResult, String> {
let _ = parse_app_type(&app)?; // 验证参数
// 通过 directory 找到对应的 skill id
@@ -213,9 +243,7 @@ pub fn uninstall_skill_for_app(
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
.ok_or_else(|| format!("未找到已安装的 Skill: {directory}"))?;
SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())?;
Ok(true)
SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())
}
// ========== 仓库管理命令 ==========
+146 -13
View File
@@ -1,6 +1,7 @@
//! 流式健康检查命令
use crate::app_config::AppType;
use crate::commands::copilot::CopilotAuthState;
use crate::error::AppError;
use crate::services::stream_check::{
HealthStatus, StreamCheckConfig, StreamCheckResult, StreamCheckService,
@@ -13,6 +14,7 @@ use tauri::State;
#[tauri::command]
pub async fn stream_check_provider(
state: State<'_, AppState>,
copilot_state: State<'_, CopilotAuthState>,
app_type: AppType,
provider_id: String,
) -> Result<StreamCheckResult, AppError> {
@@ -23,7 +25,23 @@ pub async fn stream_check_provider(
.get(&provider_id)
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
let result = StreamCheckService::check_with_retry(&app_type, provider, &config).await?;
let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?;
let claude_api_format_override = resolve_claude_api_format_override(
&app_type,
provider,
&config,
&copilot_state,
auth_override.as_ref(),
)
.await?;
let result = StreamCheckService::check_with_retry(
&app_type,
provider,
&config,
auth_override,
claude_api_format_override,
)
.await?;
// 记录日志
let _ =
@@ -38,6 +56,7 @@ pub async fn stream_check_provider(
#[tauri::command]
pub async fn stream_check_all_providers(
state: State<'_, AppState>,
copilot_state: State<'_, CopilotAuthState>,
app_type: AppType,
proxy_targets_only: bool,
) -> Result<Vec<(String, StreamCheckResult)>, AppError> {
@@ -67,18 +86,41 @@ pub async fn stream_check_all_providers(
}
}
let result = StreamCheckService::check_with_retry(&app_type, &provider, &config)
.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,
});
let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?;
let claude_api_format_override = resolve_claude_api_format_override(
&app_type,
&provider,
&config,
&copilot_state,
auth_override.as_ref(),
)
.await
.unwrap_or_else(|e| {
log::warn!(
"[StreamCheck] Failed to resolve Claude API format override for {}: {}",
provider.id,
e
);
None
});
let result = StreamCheckService::check_with_retry(
&app_type,
&provider,
&config,
auth_override,
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,
});
let _ = state
.db
@@ -104,3 +146,94 @@ pub fn save_stream_check_config(
) -> Result<(), AppError> {
state.db.save_stream_check_config(&config)
}
async fn resolve_copilot_auth_override(
provider: &crate::provider::Provider,
copilot_state: &State<'_, CopilotAuthState>,
) -> Result<Option<crate::proxy::providers::AuthInfo>, AppError> {
let is_copilot = provider
.meta
.as_ref()
.and_then(|meta| meta.provider_type.as_deref())
== Some("github_copilot")
|| provider
.settings_config
.pointer("/env/ANTHROPIC_BASE_URL")
.and_then(|value| value.as_str())
.map(|url| url.contains("githubcopilot.com"))
.unwrap_or(false);
if !is_copilot {
return Ok(None);
}
let auth_manager = copilot_state.0.read().await;
let account_id = provider
.meta
.as_ref()
.and_then(|meta| meta.github_account_id.clone());
let token = match account_id.as_deref() {
Some(id) => auth_manager
.get_valid_token_for_account(id)
.await
.map_err(|e| AppError::Message(format!("GitHub Copilot 认证失败: {e}")))?,
None => auth_manager
.get_valid_token()
.await
.map_err(|e| AppError::Message(format!("GitHub Copilot 认证失败: {e}")))?,
};
Ok(Some(crate::proxy::providers::AuthInfo::new(
token,
crate::proxy::providers::AuthStrategy::GitHubCopilot,
)))
}
async fn resolve_claude_api_format_override(
app_type: &AppType,
provider: &crate::provider::Provider,
config: &StreamCheckConfig,
copilot_state: &State<'_, CopilotAuthState>,
auth_override: Option<&crate::proxy::providers::AuthInfo>,
) -> Result<Option<String>, AppError> {
if *app_type != AppType::Claude {
return Ok(None);
}
let is_copilot = auth_override
.map(|auth| auth.strategy == crate::proxy::providers::AuthStrategy::GitHubCopilot)
.unwrap_or(false);
if !is_copilot {
return Ok(None);
}
let model_id = StreamCheckService::resolve_effective_test_model(app_type, provider, config);
let auth_manager = copilot_state.0.read().await;
let account_id = provider
.meta
.as_ref()
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
let vendor_result = match account_id.as_deref() {
Some(id) => {
auth_manager
.get_model_vendor_for_account(id, &model_id)
.await
}
None => auth_manager.get_model_vendor(&model_id).await,
};
let api_format = match vendor_result {
Ok(Some(vendor)) if vendor.eq_ignore_ascii_case("openai") => "openai_responses",
Ok(Some(_)) | Ok(None) => "openai_chat",
Err(err) => {
log::warn!(
"[StreamCheck] Failed to resolve Copilot model vendor for {model_id}: {err}. Falling back to chat/completions"
);
"openai_chat"
}
};
Ok(Some(api_format.to_string()))
}
+13 -5
View File
@@ -15,9 +15,8 @@ use tempfile::NamedTempFile;
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
/// Tables that are local-only (not synced via WebDAV snapshots).
/// Schema is still exported, but data rows are skipped.
const LOCAL_ONLY_TABLES: &[&str] = &[
/// Tables whose data rows are skipped when exporting for WebDAV sync.
const SYNC_SKIP_TABLES: &[&str] = &[
"proxy_request_logs",
"stream_check_logs",
"provider_health",
@@ -25,6 +24,15 @@ const LOCAL_ONLY_TABLES: &[&str] = &[
"usage_daily_rollups",
];
/// Tables whose local data is preserved (restored from local snapshot) during WebDAV import.
/// Excludes ephemeral tables like provider_health that can safely rebuild at runtime.
const SYNC_PRESERVE_TABLES: &[&str] = &[
"proxy_request_logs",
"stream_check_logs",
"proxy_live_backup",
"usage_daily_rollups",
];
/// A database backup entry for the UI
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
@@ -44,7 +52,7 @@ impl Database {
/// Export SQL for sync (WebDAV), skipping local-only tables' data
pub fn export_sql_string_for_sync(&self) -> Result<String, AppError> {
let snapshot = self.snapshot_to_memory()?;
Self::dump_sql(&snapshot, LOCAL_ONLY_TABLES)
Self::dump_sql(&snapshot, SYNC_SKIP_TABLES)
}
/// 导出为 SQLite 兼容的 SQL 文本
@@ -80,7 +88,7 @@ impl Database {
/// Import SQL generated for sync, then restore local-only tables from the
/// current device snapshot before replacing the main database.
pub(crate) fn import_sql_string_for_sync(&self, sql_raw: &str) -> Result<String, AppError> {
self.import_sql_string_inner(sql_raw, LOCAL_ONLY_TABLES)
self.import_sql_string_inner(sql_raw, SYNC_PRESERVE_TABLES)
}
fn import_sql_string_inner(
+60
View File
@@ -7,6 +7,12 @@ use crate::error::AppError;
use rusqlite::params;
impl Database {
const LEGACY_COMMON_CONFIG_MIGRATED_KEY: &'static str = "common_config_legacy_migrated_v1";
fn config_snippet_cleared_key(app_type: &str) -> String {
format!("common_config_{app_type}_cleared")
}
/// 获取设置值
pub fn get_setting(&self, key: &str) -> Result<Option<String>, AppError> {
let conn = lock_conn!(self.conn);
@@ -45,6 +51,60 @@ impl Database {
self.get_setting(&format!("common_config_{app_type}"))
}
/// 检查通用配置片段是否被用户显式清空
pub fn is_config_snippet_cleared(&self, app_type: &str) -> Result<bool, AppError> {
Ok(self
.get_setting(&Self::config_snippet_cleared_key(app_type))?
.as_deref()
== Some("true"))
}
/// 设置通用配置片段是否被显式清空
pub fn set_config_snippet_cleared(
&self,
app_type: &str,
cleared: bool,
) -> Result<(), AppError> {
let key = Self::config_snippet_cleared_key(app_type);
if cleared {
self.set_setting(&key, "true")
} else {
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
/// 当前是否允许从 live 配置自动抽取通用配置片段
pub fn should_auto_extract_config_snippet(&self, app_type: &str) -> Result<bool, AppError> {
Ok(self.get_config_snippet(app_type)?.is_none()
&& !self.is_config_snippet_cleared(app_type)?)
}
/// 检查历史通用配置迁移是否已经执行过
pub fn is_legacy_common_config_migrated(&self) -> Result<bool, AppError> {
Ok(self
.get_setting(Self::LEGACY_COMMON_CONFIG_MIGRATED_KEY)?
.as_deref()
== Some("true"))
}
/// 标记历史通用配置迁移已经执行完成
pub fn set_legacy_common_config_migrated(&self, migrated: bool) -> Result<(), AppError> {
if migrated {
self.set_setting(Self::LEGACY_COMMON_CONFIG_MIGRATED_KEY, "true")
} else {
let conn = lock_conn!(self.conn);
conn.execute(
"DELETE FROM settings WHERE key = ?1",
params![Self::LEGACY_COMMON_CONFIG_MIGRATED_KEY],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
}
/// 设置通用配置片段
pub fn set_config_snippet(
&self,
+101 -5
View File
@@ -4,7 +4,14 @@
use super::{lock_conn, Database, SCHEMA_VERSION};
use crate::error::AppError;
use rusqlite::Connection;
use rusqlite::{params, Connection};
use serde::Serialize;
#[derive(Serialize)]
struct LegacySkillMigrationRow {
directory: String,
app_type: String,
}
impl Database {
/// 创建所有数据库表
@@ -382,7 +389,7 @@ impl Database {
Self::set_user_version(conn, 5)?;
}
5 => {
log::info!("迁移数据库从 v5 到 v6(使用量聚合表)");
log::info!("迁移数据库从 v5 到 v6(使用量聚合表 + Copilot 模板类型统一");
Self::migrate_v5_to_v6(conn)?;
Self::set_user_version(conn, 6)?;
}
@@ -846,12 +853,31 @@ impl Database {
log::info!("开始迁移 skills 表到 v3 结构(统一管理架构)...");
// 1. 备份旧数据(用于日志)
// 1. 备份旧数据(用于日志和后续启动迁移
let old_count: i64 = conn
.query_row("SELECT COUNT(*) FROM skills", [], |row| row.get(0))
.unwrap_or(0);
log::info!("旧 skills 表有 {old_count} 条记录");
let mut stmt = conn
.prepare(
"SELECT directory, app_type FROM skills
WHERE installed = 1",
)
.map_err(|e| AppError::Database(format!("查询旧 skills 快照失败: {e}")))?;
let snapshot_rows: Vec<LegacySkillMigrationRow> = stmt
.query_map([], |row| {
Ok(LegacySkillMigrationRow {
directory: row.get(0)?,
app_type: row.get(1)?,
})
})
.map_err(|e| AppError::Database(format!("读取旧 skills 快照失败: {e}")))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| AppError::Database(format!("解析旧 skills 快照失败: {e}")))?;
let snapshot_json = serde_json::to_string(&snapshot_rows)
.map_err(|e| AppError::Database(format!("序列化旧 skills 快照失败: {e}")))?;
// 标记:需要在启动后从文件系统扫描并重建 Skills 数据
// 说明:v3 结构将 Skills 的 SSOT 迁移到 ~/.cc-switch/skills/
// 旧表只存“安装记录”,无法直接无损迁移到新结构,因此改为启动后扫描 app 目录导入。
@@ -859,6 +885,10 @@ impl Database {
"INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_pending', 'true')",
[],
);
let _ = conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_snapshot', ?1)",
[snapshot_json],
);
// 2. 删除旧表
conn.execute("DROP TABLE IF EXISTS skills", [])
@@ -940,8 +970,9 @@ impl Database {
Ok(())
}
/// v5 -> v6 迁移:添加使用量日聚合表
/// v5 -> v6 迁移:添加使用量日聚合表 + 统一 Copilot 模板类型
fn migrate_v5_to_v6(conn: &Connection) -> Result<(), AppError> {
// 1. 添加使用量日聚合表
conn.execute(
"CREATE TABLE IF NOT EXISTS usage_daily_rollups (
date TEXT NOT NULL,
@@ -962,7 +993,55 @@ impl Database {
)
.map_err(|e| AppError::Database(format!("创建 usage_daily_rollups 表失败: {e}")))?;
log::info!("v5 -> v6 迁移完成:已添加使用量日聚合表");
// 2. 统一 Copilot 模板类型为 github_copilot
let mut stmt = conn
.prepare("SELECT id, app_type, meta FROM providers")
.map_err(|e| AppError::Database(e.to_string()))?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.map_err(|e| AppError::Database(e.to_string()))?;
let mut updates = Vec::new();
for row in rows {
let (id, app_type, meta_str) = row.map_err(|e| AppError::Database(e.to_string()))?;
if let Ok(mut meta) = serde_json::from_str::<serde_json::Value>(&meta_str) {
let mut updated = false;
if let Some(usage_script) = meta.get_mut("usage_script") {
if let Some(template_type) = usage_script.get_mut("template_type") {
if template_type == "copilot" {
*template_type =
serde_json::Value::String("github_copilot".to_string());
updated = true;
}
}
}
if updated {
let new_meta_str = serde_json::to_string(&meta)
.map_err(|e| AppError::Database(e.to_string()))?;
updates.push((id, app_type, new_meta_str));
}
}
}
for (id, app_type, new_meta) in updates {
conn.execute(
"UPDATE providers SET meta = ?1 WHERE id = ?2 AND app_type = ?3",
params![new_meta, id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
log::info!("v5 -> v6 迁移完成:已添加使用量日聚合表,统一 copilot 模板类型");
Ok(())
}
@@ -980,6 +1059,14 @@ impl Database {
"0.50",
"6.25",
),
(
"claude-sonnet-4-6-20260217",
"Claude Sonnet 4.6",
"3",
"15",
"0.30",
"3.75",
),
// Claude 4.5 系列
(
"claude-opus-4-5-20251101",
@@ -1241,6 +1328,15 @@ impl Database {
"0.03",
"0",
),
// StepFun 系列
(
"step-3.5-flash",
"Step 3.5 Flash",
"0.10",
"0.30",
"0.02",
"0",
),
// ====== 国产模型 (CNY/1M tokens) ======
// Doubao (字节跳动)
(
+27
View File
@@ -296,6 +296,14 @@ fn schema_migration_v4_adds_pricing_model_columns() {
let conn = Connection::open_in_memory().expect("open memory db");
conn.execute_batch(
r#"
CREATE TABLE providers (
id TEXT NOT NULL,
app_type TEXT NOT NULL,
name TEXT NOT NULL,
settings_config TEXT NOT NULL DEFAULT '{}',
meta TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY (id, app_type)
);
CREATE TABLE proxy_config (app_type TEXT PRIMARY KEY);
CREATE TABLE proxy_request_logs (request_id TEXT PRIMARY KEY, model TEXT NOT NULL);
CREATE TABLE mcp_servers (
@@ -488,6 +496,25 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
matches!(pending.as_deref(), Some("true") | Some("1")),
"skills_ssot_migration_pending should be set after v2->v3 migration"
);
let snapshot: Option<String> = conn
.query_row(
"SELECT value FROM settings WHERE key = 'skills_ssot_migration_snapshot'",
[],
|r| r.get(0),
)
.ok();
let snapshot = snapshot.expect("skills migration snapshot should be recorded");
let snapshot_rows: serde_json::Value =
serde_json::from_str(&snapshot).expect("parse skills migration snapshot");
assert!(
snapshot_rows
.as_array()
.is_some_and(|rows| rows.iter().any(|row| {
row.get("directory").and_then(|v| v.as_str()) == Some("demo-skill")
&& row.get("app_type").and_then(|v| v.as_str()) == Some("claude")
})),
"skills migration snapshot should preserve legacy app mapping"
);
// v3.9+ 新增:proxy_config 三行 seed 必须存在(否则 UI 会查不到默认值)
let proxy_rows: i64 = conn
+154 -54
View File
@@ -12,6 +12,7 @@ mod error;
mod gemini_config;
mod gemini_mcp;
mod init_status;
mod lightweight;
mod mcp;
mod openclaw_config;
mod opencode_config;
@@ -25,10 +26,11 @@ mod services;
mod session_manager;
mod settings;
mod store;
mod tray;
mod usage_script;
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
pub use app_config::{AppType, InstalledSkill, McpApps, McpServer, MultiAppConfig, SkillApps};
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
pub use commands::open_provider_terminal;
pub use commands::*;
@@ -44,6 +46,7 @@ pub use mcp::{
};
pub use provider::{Provider, ProviderMeta};
pub use services::{
skill::{migrate_skills_to_ssot, ImportSkillSelection},
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService,
SkillService, SpeedtestService,
};
@@ -202,6 +205,12 @@ pub fn run() {
log::debug!(" arg[{i}]: {}", redact_url_for_log(arg));
}
if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
log::error!("退出轻量模式重建窗口失败: {e}");
}
}
// Check for deep link URL in args (mainly for Windows/Linux command line)
let mut found_deeplink = false;
for arg in &args {
@@ -560,59 +569,6 @@ pub fn run() {
}
}
// 5. Auto-extract common config snippets from live files (when snippet is missing)
for app_type in crate::app_config::AppType::all() {
// Skip if snippet already exists
if app_state
.db
.get_config_snippet(app_type.as_str())
.ok()
.flatten()
.is_some()
{
continue;
}
// Try to read the live config file for this app type
let settings =
match crate::services::provider::ProviderService::read_live_settings(
app_type.clone(),
) {
Ok(s) => s,
Err(_) => continue, // No live config file, skip silently
};
// Extract common config (strip provider-specific fields)
match crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
app_type.clone(),
&settings,
) {
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
match app_state
.db
.set_config_snippet(app_type.as_str(), Some(snippet))
{
Ok(()) => log::info!(
"✓ Auto-extracted common config snippet for {}",
app_type.as_str()
),
Err(e) => log::warn!(
"✗ Failed to save config snippet for {}: {e}",
app_type.as_str()
),
}
}
Ok(_) => log::debug!(
"○ Live config for {} has no extractable common fields",
app_type.as_str()
),
Err(e) => log::warn!(
"✗ Failed to extract config snippet for {}: {e}",
app_type.as_str()
),
}
}
// 迁移旧的 app_config_dir 配置到 Store
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
log::warn!("迁移 app_config_dir 失败: {e}");
@@ -666,6 +622,12 @@ pub fn run() {
let urls = event.urls();
log::info!("Received {} URL(s)", urls.len());
if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(&app_handle) {
log::error!("退出轻量模式重建窗口失败: {e}");
}
}
for (i, url) in urls.iter().enumerate() {
let url_str = url.as_str();
log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str));
@@ -741,6 +703,18 @@ pub fn run() {
let skill_service = SkillService::new();
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
// 初始化 CopilotAuthManager
{
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
use commands::CopilotAuthState;
use tokio::sync::RwLock;
let app_config_dir = crate::config::get_app_config_dir();
let copilot_auth_manager = CopilotAuthManager::new(app_config_dir);
app.manage(CopilotAuthState(Arc::new(RwLock::new(copilot_auth_manager))));
log::info!("✓ CopilotAuthManager initialized");
}
// 初始化全局出站代理 HTTP 客户端
{
let db = &app.state::<AppState>().db;
@@ -797,6 +771,8 @@ pub fn run() {
}
}
initialize_common_config_snippets(&state);
// 检查 settings 表中的代理状态,自动恢复代理服务
restore_proxy_state_on_startup(&state).await;
@@ -855,6 +831,7 @@ pub fn run() {
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -968,8 +945,11 @@ pub fn run() {
commands::restore_env_backup,
// Skill management (v3.10.0+ unified)
commands::get_installed_skills,
commands::get_skill_backups,
commands::delete_skill_backup,
commands::install_skill_unified,
commands::uninstall_skill_unified,
commands::restore_skill_backup,
commands::toggle_skill_app,
commands::scan_unmanaged_skills,
commands::import_skills_from_apps,
@@ -1041,6 +1021,7 @@ pub fn run() {
commands::list_sessions,
commands::get_session_messages,
commands::delete_session,
commands::delete_sessions,
commands::launch_session_terminal,
commands::get_tool_versions,
// Provider terminal
@@ -1077,6 +1058,31 @@ pub fn run() {
commands::scan_local_proxies,
// Window theme control
commands::set_window_theme,
// Generic managed auth commands
commands::auth_start_login,
commands::auth_poll_for_account,
commands::auth_list_accounts,
commands::auth_get_status,
commands::auth_remove_account,
commands::auth_set_default_account,
commands::auth_logout,
// Copilot OAuth commands (multi-account support)
commands::copilot_start_device_flow,
commands::copilot_poll_for_auth,
commands::copilot_poll_for_account,
commands::copilot_list_accounts,
commands::copilot_remove_account,
commands::copilot_set_default_account,
commands::copilot_get_auth_status,
commands::copilot_logout,
commands::copilot_is_authenticated,
commands::copilot_get_token,
commands::copilot_get_token_for_account,
commands::copilot_get_models,
commands::copilot_get_models_for_account,
commands::copilot_get_usage,
commands::copilot_get_usage_for_account,
// OMO commands
commands::read_omo_local_file,
commands::get_current_omo_provider_id,
commands::disable_current_omo,
@@ -1093,6 +1099,10 @@ pub fn run() {
commands::delete_daily_memory_file,
commands::search_daily_memory_files,
commands::open_workspace_directory,
// lightweight mode (for testing or low-resource environments)
commands::enter_lightweight_mode,
commands::exit_lightweight_mode,
commands::is_lightweight_mode,
]);
let app = builder
@@ -1143,6 +1153,10 @@ pub fn run() {
let _ = window.show();
let _ = window.set_focus();
tray::apply_tray_policy(app_handle, true);
} else if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle) {
log::error!("退出轻量模式重建窗口失败: {e}");
}
}
}
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...
@@ -1152,6 +1166,13 @@ pub fn run() {
log::info!("RunEvent::Opened with URL: {url_str}");
if url_str.starts_with("ccswitch://") {
if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle)
{
log::error!("退出轻量模式重建窗口失败: {e}");
}
}
// 解析并广播深链接事件,复用与 single_instance 相同的逻辑
match crate::deeplink::parse_deeplink_url(&url_str) {
Ok(request) => {
@@ -1305,6 +1326,85 @@ async fn restore_proxy_state_on_startup(state: &store::AppState) {
}
}
fn initialize_common_config_snippets(state: &store::AppState) {
// Auto-extract common config snippets from clean live files when snippet is missing.
// This must run before proxy takeover is restored on startup, otherwise we'd read
// proxy-placeholder configs instead of the user's actual live settings.
for app_type in crate::app_config::AppType::all() {
if !state
.db
.should_auto_extract_config_snippet(app_type.as_str())
.unwrap_or(false)
{
continue;
}
let settings = match crate::services::provider::ProviderService::read_live_settings(
app_type.clone(),
) {
Ok(s) => s,
Err(_) => continue,
};
match crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
app_type.clone(),
&settings,
) {
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
match state.db.set_config_snippet(app_type.as_str(), Some(snippet)) {
Ok(()) => {
let _ = state.db.set_config_snippet_cleared(app_type.as_str(), false);
log::info!(
"✓ Auto-extracted common config snippet for {}",
app_type.as_str()
);
}
Err(e) => log::warn!(
"✗ Failed to save config snippet for {}: {e}",
app_type.as_str()
),
}
}
Ok(_) => log::debug!(
"○ Live config for {} has no extractable common fields",
app_type.as_str()
),
Err(e) => log::warn!(
"✗ Failed to extract config snippet for {}: {e}",
app_type.as_str()
),
}
}
let should_run_legacy_migration = state
.db
.is_legacy_common_config_migrated()
.map(|done| !done)
.unwrap_or(true);
if should_run_legacy_migration {
for app_type in [
crate::app_config::AppType::Claude,
crate::app_config::AppType::Codex,
crate::app_config::AppType::Gemini,
] {
if let Err(e) = crate::services::provider::ProviderService::migrate_legacy_common_config_usage_if_needed(
state,
app_type.clone(),
) {
log::warn!(
"✗ Failed to migrate legacy common-config usage for {}: {e}",
app_type.as_str()
);
}
}
if let Err(e) = state.db.set_legacy_common_config_migrated(true) {
log::warn!("✗ Failed to persist legacy common-config migration flag: {e}");
}
}
}
// ============================================================
// 迁移错误对话框辅助函数
// ============================================================
+90
View File
@@ -0,0 +1,90 @@
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::Manager;
static LIGHTWEIGHT_MODE: AtomicBool = AtomicBool::new(false);
pub fn enter_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_skip_taskbar(true);
}
}
#[cfg(target_os = "macos")]
{
crate::tray::apply_tray_policy(app, false);
}
if let Some(window) = app.get_webview_window("main") {
window
.destroy()
.map_err(|e| format!("销毁主窗口失败: {e}"))?;
}
// else: already in lightweight mode or window not found, just set the flag
LIGHTWEIGHT_MODE.store(true, Ordering::Release);
crate::tray::refresh_tray_menu(app);
log::info!("进入轻量模式");
Ok(())
}
pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
use tauri::WebviewWindowBuilder;
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
}
#[cfg(target_os = "macos")]
{
crate::tray::apply_tray_policy(app, true);
}
LIGHTWEIGHT_MODE.store(false, Ordering::Release);
crate::tray::refresh_tray_menu(app);
log::info!("退出轻量模式");
return Ok(());
}
let window_config = app
.config()
.app
.windows
.iter()
.find(|w| w.label == "main")
.ok_or("主窗口配置未找到")?;
WebviewWindowBuilder::from_config(app, window_config)
.map_err(|e| format!("加载主窗口配置失败: {e}"))?
.visible(true)
.build()
.map_err(|e| format!("创建主窗口失败: {e}"))?;
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
}
#[cfg(target_os = "windows")]
{
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_skip_taskbar(false);
}
}
#[cfg(target_os = "macos")]
{
crate::tray::apply_tray_policy(app, true);
}
LIGHTWEIGHT_MODE.store(false, Ordering::Release);
crate::tray::refresh_tray_menu(app);
log::info!("退出轻量模式");
Ok(())
}
pub fn is_lightweight_mode() -> bool {
LIGHTWEIGHT_MODE.load(Ordering::Acquire)
}
+1 -1
View File
@@ -294,7 +294,7 @@ impl OpenClawConfigDocument {
if let Some(existing) = key_value_pairs
.iter_mut()
.find(|pair| json5_key_name(&pair.key).as_deref() == Some(key))
.find(|pair| json5_key_name(&pair.key) == Some(key))
{
existing.value = new_value;
return Ok(());
+47 -23
View File
@@ -6,6 +6,32 @@ use indexmap::IndexMap;
use serde_json::{json, Map, Value};
use std::path::PathBuf;
const STANDARD_OMO_PLUGIN_PREFIXES: [&str; 2] = ["oh-my-openagent", "oh-my-opencode"];
const SLIM_OMO_PLUGIN_PREFIXES: [&str; 1] = ["oh-my-opencode-slim"];
fn matches_plugin_prefix(plugin_name: &str, prefix: &str) -> bool {
plugin_name == prefix
|| plugin_name
.strip_prefix(prefix)
.map(|suffix| suffix.starts_with('@'))
.unwrap_or(false)
}
fn matches_any_plugin_prefix(plugin_name: &str, prefixes: &[&str]) -> bool {
prefixes
.iter()
.any(|prefix| matches_plugin_prefix(plugin_name, prefix))
}
fn canonicalize_plugin_name(plugin_name: &str) -> String {
if let Some(suffix) = plugin_name.strip_prefix("oh-my-opencode") {
if suffix.is_empty() || suffix.starts_with('@') {
return format!("oh-my-openagent{suffix}");
}
}
plugin_name.to_string()
}
pub fn get_opencode_dir() -> PathBuf {
if let Some(override_dir) = get_opencode_override_dir() {
return override_dir;
@@ -140,58 +166,56 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> {
pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
let normalized_plugin_name = canonicalize_plugin_name(plugin_name);
let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut());
match plugins {
Some(arr) => {
// Mutual exclusion: standard OMO and OMO Slim cannot coexist as plugins
if plugin_name.starts_with("oh-my-opencode")
&& !plugin_name.starts_with("oh-my-opencode-slim")
{
// Adding standard OMO -> remove all Slim variants
arr.retain(|v| {
v.as_str()
.map(|s| !s.starts_with("oh-my-opencode-slim"))
.unwrap_or(true)
});
} else if plugin_name.starts_with("oh-my-opencode-slim") {
// Adding Slim -> remove all standard OMO variants (but keep slim)
if matches_any_plugin_prefix(&normalized_plugin_name, &STANDARD_OMO_PLUGIN_PREFIXES) {
arr.retain(|v| {
v.as_str()
.map(|s| {
!s.starts_with("oh-my-opencode") || s.starts_with("oh-my-opencode-slim")
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
})
.unwrap_or(true)
});
} else if matches_any_plugin_prefix(&normalized_plugin_name, &SLIM_OMO_PLUGIN_PREFIXES)
{
arr.retain(|v| {
v.as_str()
.map(|s| {
!matches_any_plugin_prefix(s, &STANDARD_OMO_PLUGIN_PREFIXES)
&& !matches_any_plugin_prefix(s, &SLIM_OMO_PLUGIN_PREFIXES)
})
.unwrap_or(true)
});
}
let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name));
let already_exists = arr
.iter()
.any(|v| v.as_str() == Some(normalized_plugin_name.as_str()));
if !already_exists {
arr.push(Value::String(plugin_name.to_string()));
arr.push(Value::String(normalized_plugin_name));
}
}
None => {
config["plugin"] = json!([plugin_name]);
config["plugin"] = json!([normalized_plugin_name]);
}
}
write_opencode_config(&config)
}
pub fn remove_plugin_by_prefix(prefix: &str) -> Result<(), AppError> {
pub fn remove_plugins_by_prefixes(prefixes: &[&str]) -> Result<(), AppError> {
let mut config = read_opencode_config()?;
if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) {
arr.retain(|v| {
v.as_str()
.map(|s| {
if !s.starts_with(prefix) {
return true; // Keep: doesn't match prefix at all
}
let rest = &s[prefix.len()..];
rest.starts_with('-')
})
.map(|s| !matches_any_plugin_prefix(s, prefixes))
.unwrap_or(true)
});
+62
View File
@@ -191,6 +191,31 @@ pub struct ProviderProxyConfig {
pub proxy_password: Option<String>,
}
/// 认证绑定来源
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum AuthBindingSource {
/// 从 provider 自身配置读取认证信息(默认)
#[default]
ProviderConfig,
/// 使用托管账号认证(如 GitHub Copilot OAuth
ManagedAccount,
}
/// 通用认证绑定
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AuthBinding {
/// 认证来源
#[serde(default)]
pub source: AuthBindingSource,
/// 托管认证供应商标识(如 github_copilot
#[serde(rename = "authProvider", skip_serializing_if = "Option::is_none")]
pub auth_provider: Option<String>,
/// 托管账号 ID;为空表示跟随该认证供应商的默认账号
#[serde(rename = "accountId", skip_serializing_if = "Option::is_none")]
pub account_id: Option<String>,
}
/// 供应商元数据
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProviderMeta {
@@ -242,14 +267,51 @@ pub struct ProviderMeta {
/// - "openai_responses": OpenAI Responses API 格式,需要转换
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
pub api_format: Option<String>,
/// 通用认证绑定(provider_config / managed_account
///
/// 新代码应只写入该字段;githubAccountId 仅保留兼容读取。
#[serde(rename = "authBinding", skip_serializing_if = "Option::is_none")]
pub auth_binding: Option<AuthBinding>,
/// Claude 认证字段名("ANTHROPIC_AUTH_TOKEN" 或 "ANTHROPIC_API_KEY"
#[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
pub api_key_field: Option<String>,
/// 是否将 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.
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
/// 供应商类型标识(用于特殊供应商检测)
/// - "github_copilot": GitHub Copilot 供应商
#[serde(rename = "providerType", skip_serializing_if = "Option::is_none")]
pub provider_type: Option<String>,
/// GitHub Copilot 关联账号 ID(仅 github_copilot 供应商使用)
/// 用于多账号支持,关联到特定的 GitHub 账号
#[serde(rename = "githubAccountId", skip_serializing_if = "Option::is_none")]
pub github_account_id: Option<String>,
}
impl ProviderMeta {
/// 解析指定托管认证供应商绑定的账号 ID。
///
/// 新版优先读取 authBinding,旧版继续兼容 githubAccountId。
pub fn managed_account_id_for(&self, auth_provider: &str) -> Option<String> {
if let Some(binding) = self.auth_binding.as_ref() {
if binding.source == AuthBindingSource::ManagedAccount
&& binding.auth_provider.as_deref() == Some(auth_provider)
{
return binding.account_id.clone();
}
}
if auth_provider == "github_copilot" {
return self.github_account_id.clone();
}
None
}
}
impl ProviderManager {
-1
View File
@@ -68,7 +68,6 @@ pub enum ProxyError {
StreamIdleTimeout(u64),
/// 认证错误
#[allow(dead_code)]
#[error("认证失败: {0}")]
AuthError(String),
+11 -23
View File
@@ -2,15 +2,12 @@
//!
//! 处理故障转移成功后的供应商切换逻辑,包括:
//! - 去重控制(避免多个请求同时触发)
//! - 数据库更新
//! - 托盘菜单更新
//! - 前端事件发射
//! - Live 备份更新
use crate::database::Database;
use crate::error::AppError;
use std::collections::HashSet;
use std::str::FromStr;
use std::sync::Arc;
use tauri::{Emitter, Manager};
use tokio::sync::RwLock;
@@ -98,30 +95,21 @@ impl FailoverSwitchManager {
log::info!("[FO-001] 切换: {app_type} → {provider_name}");
// 1. 更新数据库 is_current
self.db.set_current_provider(app_type, provider_id)?;
let mut switched = false;
// 2. 更新本地 settings(设备级)
let app_type_enum = crate::app_config::AppType::from_str(app_type)
.map_err(|_| AppError::Message(format!("无效的应用类型: {app_type}")))?;
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))?;
// 3. 更新托盘菜单和发射事件
if let Some(app) = app_handle {
// 更新托盘菜单
if let Some(app_state) = app.try_state::<crate::store::AppState>() {
// 更新 Live 备份(确保代理停止时恢复正确配置)
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
if let Err(e) = app_state
.proxy_service
.update_live_backup_from_provider(app_type, &provider)
.await
{
log::warn!("[FO-003] Live 备份更新失败: {e}");
}
switched = app_state
.proxy_service
.hot_switch_provider(app_type, provider_id)
.await
.map_err(AppError::Message)?
.logical_target_changed;
if !switched {
return Ok(false);
}
// 重建托盘菜单
if let Ok(new_menu) = crate::tray::create_tray_menu(app, app_state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) {
@@ -142,6 +130,6 @@ impl FailoverSwitchManager {
}
}
Ok(true)
Ok(switched)
}
}
+631 -142
View File
@@ -2,13 +2,14 @@
//!
//! 负责将请求转发到上游Provider,支持故障转移
use super::hyper_client::ProxyResponse;
use super::{
body_filter::filter_private_params_with_whitelist,
error::*,
failover_switch::FailoverSwitchManager,
log_codes::fwd as log_fwd,
provider_router::ProviderRouter,
providers::{get_adapter, ProviderAdapter, ProviderType},
providers::{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,
@@ -16,68 +17,19 @@ use super::{
types::{OptimizerConfig, ProxyStatus, RectifierConfig},
ProxyError,
};
use crate::commands::CopilotAuthState;
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
use crate::{app_config::AppType, provider::Provider};
use reqwest::Response;
use http::Extensions;
use serde_json::Value;
use std::sync::Arc;
use tauri::Manager;
use tokio::sync::RwLock;
/// Headers 黑名单 - 不透传到上游的 Headers
///
/// 精简版黑名单,只过滤必须覆盖或可能导致问题的 header
/// 参考成功透传的请求,保留更多原始 header
///
/// 注意:客户端 IP 类(x-forwarded-for, x-real-ip)默认透传
const HEADER_BLACKLIST: &[&str] = &[
// 认证类(会被覆盖)
"authorization",
"x-api-key",
"x-goog-api-key",
// 连接类(由 HTTP 客户端管理)
"host",
"content-length",
"transfer-encoding",
// 编码类(会被覆盖为 identity)
"accept-encoding",
// 代理转发类(保留 x-forwarded-for 和 x-real-ip
"x-forwarded-host",
"x-forwarded-port",
"x-forwarded-proto",
"forwarded",
// CDN/云服务商特定头
"cf-connecting-ip",
"cf-ipcountry",
"cf-ray",
"cf-visitor",
"true-client-ip",
"fastly-client-ip",
"x-azure-clientip",
"x-azure-fdid",
"x-azure-ref",
"akamai-origin-hop",
"x-akamai-config-log-detail",
// 请求追踪类
"x-request-id",
"x-correlation-id",
"x-trace-id",
"x-amzn-trace-id",
"x-b3-traceid",
"x-b3-spanid",
"x-b3-parentspanid",
"x-b3-sampled",
"traceparent",
"tracestate",
// anthropic 特定头单独处理,避免重复
"anthropic-beta",
"anthropic-version",
// 客户端 IP 单独处理(默认透传)
"x-forwarded-for",
"x-real-ip",
];
pub struct ForwardResult {
pub response: Response,
pub response: ProxyResponse,
pub provider: Provider,
pub claude_api_format: Option<String>,
}
pub struct ForwardError {
@@ -146,6 +98,7 @@ impl RequestForwarder {
endpoint: &str,
body: Value,
headers: axum::http::HeaderMap,
extensions: Extensions,
providers: Vec<Provider>,
) -> Result<ForwardResult, ForwardError> {
// 获取适配器
@@ -222,11 +175,12 @@ impl RequestForwarder {
endpoint,
&provider_body,
&headers,
&extensions,
adapter.as_ref(),
)
.await
{
Ok(response) => {
Ok((response, claude_api_format)) => {
// 成功:记录成功并更新熔断器
let _ = self
.router
@@ -280,6 +234,7 @@ impl RequestForwarder {
return Ok(ForwardResult {
response,
provider: provider.clone(),
claude_api_format,
});
}
Err(e) => {
@@ -350,11 +305,12 @@ impl RequestForwarder {
endpoint,
&provider_body,
&headers,
&extensions,
adapter.as_ref(),
)
.await
{
Ok(response) => {
Ok((response, claude_api_format)) => {
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
// 记录成功
let _ = self
@@ -413,6 +369,7 @@ impl RequestForwarder {
return Ok(ForwardResult {
response,
provider: provider.clone(),
claude_api_format,
});
}
Err(retry_err) => {
@@ -547,11 +504,12 @@ impl RequestForwarder {
endpoint,
&provider_body,
&headers,
&extensions,
adapter.as_ref(),
)
.await
{
Ok(response) => {
Ok((response, claude_api_format)) => {
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
let _ = self
.router
@@ -603,6 +561,7 @@ impl RequestForwarder {
return Ok(ForwardResult {
response,
provider: provider.clone(),
claude_api_format,
});
}
Err(retry_err) => {
@@ -784,29 +743,17 @@ impl RequestForwarder {
endpoint: &str,
body: &Value,
headers: &axum::http::HeaderMap,
extensions: &Extensions,
adapter: &dyn ProviderAdapter,
) -> Result<Response, ProxyError> {
) -> Result<(ProxyResponse, Option<String>), ProxyError> {
// 使用适配器提取 base_url
let base_url = adapter.extract_base_url(provider)?;
// 检查是否需要格式转换
let needs_transform = adapter.needs_transform(provider);
let effective_endpoint =
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
// 根据 api_format 选择目标端点
let api_format = super::providers::get_claude_api_format(provider);
if api_format == "openai_responses" {
"/v1/responses"
} else {
"/v1/chat/completions"
}
} else {
endpoint
};
// 使用适配器构建 URL
let url = adapter.build_url(&base_url, effective_endpoint);
let is_full_url = provider
.meta
.as_ref()
.and_then(|meta| meta.is_full_url)
.unwrap_or(false);
// 应用模型映射(独立于格式转换)
let (mapped_body, _original_model, _mapped_model) =
@@ -815,9 +762,61 @@ impl RequestForwarder {
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
let mapped_body = normalize_thinking_type(mapped_body);
// 确定有效端点
// GitHub Copilot API 使用 /chat/completions(无 /v1 前缀)
let is_copilot = provider
.meta
.as_ref()
.and_then(|m| m.provider_type.as_deref())
== Some("github_copilot")
|| base_url.contains("githubcopilot.com");
let resolved_claude_api_format = if adapter.name() == "Claude" {
Some(
self.resolve_claude_api_format(provider, &mapped_body, is_copilot)
.await,
)
} else {
None
};
let needs_transform = match resolved_claude_api_format.as_deref() {
Some(api_format) => super::providers::claude_api_format_needs_transform(api_format),
None => adapter.needs_transform(provider),
};
let (effective_endpoint, passthrough_query) =
if needs_transform && adapter.name() == "Claude" {
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)
} else {
(
endpoint.to_string(),
split_endpoint_and_query(endpoint)
.1
.map(ToString::to_string),
)
};
let url = if is_full_url {
append_query_to_full_url(&base_url, passthrough_query.as_deref())
} else {
adapter.build_url(&base_url, &effective_endpoint)
};
// 转换请求体(如果需要)
let request_body = if needs_transform {
adapter.transform_request(mapped_body, provider)?
if adapter.name() == "Claude" {
let api_format = resolved_claude_api_format
.as_deref()
.unwrap_or_else(|| super::providers::get_claude_api_format(provider));
super::providers::transform_claude_request_for_api_format(
mapped_body,
provider,
api_format,
)?
} else {
adapter.transform_request(mapped_body, provider)?
}
} else {
mapped_body
};
@@ -825,83 +824,266 @@ impl RequestForwarder {
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
// 默认使用空白名单,过滤所有 _ 前缀字段
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
let force_identity_encoding = needs_transform
|| should_force_identity_encoding(&effective_endpoint, &filtered_body, headers);
// 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端
let proxy_config = provider.meta.as_ref().and_then(|m| m.proxy_config.as_ref());
let client = super::http_client::get_for_provider(proxy_config);
let mut request = client.post(&url);
// 获取认证头(提前准备,用于内联替换)
let auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
// GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token
if auth.strategy == AuthStrategy::GitHubCopilot {
if let Some(app_handle) = &self.app_handle {
let copilot_state = app_handle.state::<CopilotAuthState>();
let copilot_auth: tokio::sync::RwLockReadGuard<'_, CopilotAuthManager> =
copilot_state.0.read().await;
// 只有当 timeout > 0 时才设置请求超时
// Duration::ZERO 在 reqwest 中表示"立刻超时"而不是"禁用超时"
// 故障转移关闭时会传入 0,此时应该使用 client 的默认超时(600秒)
if !self.non_streaming_timeout.is_zero() {
request = request.timeout(self.non_streaming_timeout);
}
// 从 provider.meta 获取关联的 GitHub 账号 ID(多账号支持)
let account_id = provider
.meta
.as_ref()
.and_then(|m| m.managed_account_id_for("github_copilot"));
// 过滤黑名单 Headers,保护隐私并避免冲突
for (key, value) in headers {
if HEADER_BLACKLIST
.iter()
.any(|h| key.as_str().eq_ignore_ascii_case(h))
{
continue;
// 根据账号 ID 获取对应 token(向后兼容:无账号 ID 时使用第一个账号)
let token_result = match &account_id {
Some(id) => {
log::debug!("[Copilot] 使用指定账号 {id} 获取 token");
copilot_auth.get_valid_token_for_account(id).await
}
None => {
log::debug!("[Copilot] 使用默认账号获取 token");
copilot_auth.get_valid_token().await
}
};
match token_result {
Ok(token) => {
auth = AuthInfo::new(token, AuthStrategy::GitHubCopilot);
log::debug!(
"[Copilot] 成功获取 Copilot token (account={})",
account_id.as_deref().unwrap_or("default")
);
}
Err(e) => {
log::error!(
"[Copilot] 获取 Copilot token 失败 (account={}): {e}",
account_id.as_deref().unwrap_or("default")
);
return Err(ProxyError::AuthError(format!(
"GitHub Copilot 认证失败: {e}"
)));
}
}
} else {
log::error!("[Copilot] AppHandle 不可用");
return Err(ProxyError::AuthError(
"GitHub Copilot 认证不可用(无 AppHandle".to_string(),
));
}
}
request = request.header(key, value);
}
adapter.get_auth_headers(&auth)
} else {
Vec::new()
};
// 处理 anthropic-beta Header(仅 Claude
// 关键:确保包含 claude-code-20250219 标记,这是上游服务验证请求来源的依据
// 如果客户端发送的 beta 标记中没有包含 claude-code-20250219,需要补充
if adapter.name() == "Claude" {
// Copilot 指纹头名(由 get_auth_headers 注入,需在原始头中去重
let copilot_fingerprint_headers: &[&str] = if is_copilot {
&[
"user-agent",
"editor-version",
"editor-plugin-version",
"copilot-integration-id",
"x-github-api-version",
"openai-intent",
]
} else {
&[]
};
// 预计算上游 host 值(用于在原位替换 host header
let upstream_host = url
.parse::<http::Uri>()
.ok()
.and_then(|u| u.authority().map(|a| a.to_string()));
// 预计算 anthropic-beta 值(仅 Claude
let anthropic_beta_value = if adapter.name() == "Claude" {
const CLAUDE_CODE_BETA: &str = "claude-code-20250219";
let beta_value = if let Some(beta) = headers.get("anthropic-beta") {
Some(if let Some(beta) = headers.get("anthropic-beta") {
if let Ok(beta_str) = beta.to_str() {
// 检查是否已包含 claude-code-20250219
if beta_str.contains(CLAUDE_CODE_BETA) {
beta_str.to_string()
} else {
// 补充 claude-code-20250219
format!("{CLAUDE_CODE_BETA},{beta_str}")
}
} else {
CLAUDE_CODE_BETA.to_string()
}
} else {
// 如果客户端没有发送,使用默认值
CLAUDE_CODE_BETA.to_string()
};
request = request.header("anthropic-beta", &beta_value);
})
} else {
None
};
// ============================================================
// 构建有序 HeaderMap — 内联替换,保持客户端原始顺序
// ============================================================
let mut ordered_headers = http::HeaderMap::new();
let mut saw_auth = false;
let mut saw_accept_encoding = false;
let mut saw_anthropic_beta = false;
let mut saw_anthropic_version = false;
for (key, value) in headers {
let key_str = key.as_str();
// --- host — 原位替换为上游 host(保持客户端原始位置) ---
if key_str.eq_ignore_ascii_case("host") {
if let Some(ref host_val) = upstream_host {
if let Ok(hv) = http::HeaderValue::from_str(host_val) {
ordered_headers.append(key.clone(), hv);
}
}
continue;
}
// --- 连接 / 追踪 / CDN 类 — 无条件跳过 ---
if matches!(
key_str,
"content-length"
| "transfer-encoding"
| "x-forwarded-host"
| "x-forwarded-port"
| "x-forwarded-proto"
| "forwarded"
| "cf-connecting-ip"
| "cf-ipcountry"
| "cf-ray"
| "cf-visitor"
| "true-client-ip"
| "fastly-client-ip"
| "x-azure-clientip"
| "x-azure-fdid"
| "x-azure-ref"
| "akamai-origin-hop"
| "x-akamai-config-log-detail"
| "x-request-id"
| "x-correlation-id"
| "x-trace-id"
| "x-amzn-trace-id"
| "x-b3-traceid"
| "x-b3-spanid"
| "x-b3-parentspanid"
| "x-b3-sampled"
| "traceparent"
| "tracestate"
) {
continue;
}
// --- 认证类 — 用 adapter 提供的认证头替换(在原始位置) ---
if key_str.eq_ignore_ascii_case("authorization")
|| key_str.eq_ignore_ascii_case("x-api-key")
|| key_str.eq_ignore_ascii_case("x-goog-api-key")
{
if !saw_auth {
saw_auth = true;
for (ah_name, ah_value) in &auth_headers {
ordered_headers.append(ah_name.clone(), ah_value.clone());
}
}
continue;
}
// --- accept-encoding — transform / SSE 路径强制 identity,其余保留原值 ---
if key_str.eq_ignore_ascii_case("accept-encoding") {
if !saw_accept_encoding {
saw_accept_encoding = true;
if force_identity_encoding {
ordered_headers.append(
http::header::ACCEPT_ENCODING,
http::HeaderValue::from_static("identity"),
);
} else {
ordered_headers.append(key.clone(), value.clone());
}
}
continue;
}
// --- anthropic-beta — 用重建值替换(确保含 claude-code 标记) ---
if key_str.eq_ignore_ascii_case("anthropic-beta") {
if !saw_anthropic_beta {
saw_anthropic_beta = true;
if let Some(ref beta_val) = anthropic_beta_value {
if let Ok(hv) = http::HeaderValue::from_str(beta_val) {
ordered_headers.append("anthropic-beta", hv);
}
}
}
continue;
}
// --- anthropic-version — 透传客户端值 ---
if key_str.eq_ignore_ascii_case("anthropic-version") {
saw_anthropic_version = true;
ordered_headers.append(key.clone(), value.clone());
continue;
}
// --- Copilot 指纹头 — 跳过(由 auth_headers 提供) ---
if copilot_fingerprint_headers
.iter()
.any(|h| key_str.eq_ignore_ascii_case(h))
{
continue;
}
// --- 默认:透传 ---
ordered_headers.append(key.clone(), value.clone());
}
// 客户端 IP 透传(默认开启)
if let Some(xff) = headers.get("x-forwarded-for") {
if let Ok(xff_str) = xff.to_str() {
request = request.header("x-forwarded-for", xff_str);
}
}
if let Some(real_ip) = headers.get("x-real-ip") {
if let Ok(real_ip_str) = real_ip.to_str() {
request = request.header("x-real-ip", real_ip_str);
// 如果原始请求中没有认证头,在末尾追加
if !saw_auth && !auth_headers.is_empty() {
for (ah_name, ah_value) in &auth_headers {
ordered_headers.append(ah_name.clone(), ah_value.clone());
}
}
// 禁用压缩,避免 gzip 流式响应解析错误
// 参考 CCH: undici 在连接提前关闭时会对不完整的 gzip 流抛出错误
request = request.header("accept-encoding", "identity");
// 使用适配器添加认证头
if let Some(auth) = adapter.extract_auth(provider) {
request = adapter.add_auth_headers(request, &auth);
// transform / SSE 路径在缺失时补 identity;普通透传不主动补 accept-encoding
if !saw_accept_encoding && force_identity_encoding {
ordered_headers.append(
http::header::ACCEPT_ENCODING,
http::HeaderValue::from_static("identity"),
);
}
// anthropic-version 统一处理(仅 Claude):优先使用客户端的版本号,否则使用默认值
// 注意:只设置一次,避免重复
if adapter.name() == "Claude" {
let version_str = headers
.get("anthropic-version")
.and_then(|v| v.to_str().ok())
.unwrap_or("2023-06-01");
request = request.header("anthropic-version", version_str);
// 如果原始请求中没有 anthropic-beta 且有值需要添加,追加
if !saw_anthropic_beta {
if let Some(ref beta_val) = anthropic_beta_value {
if let Ok(hv) = http::HeaderValue::from_str(beta_val) {
ordered_headers.append("anthropic-beta", hv);
}
}
}
// anthropic-version:仅在缺失时补充默认值
if adapter.name() == "Claude" && !saw_anthropic_version {
ordered_headers.append(
"anthropic-version",
http::HeaderValue::from_static("2023-06-01"),
);
}
// 序列化请求体
let body_bytes = serde_json::to_vec(&filtered_body)
.map_err(|e| ProxyError::Internal(format!("Failed to serialize request body: {e}")))?;
// 确保 content-type 存在
if !ordered_headers.contains_key(http::header::CONTENT_TYPE) {
ordered_headers.insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static("application/json"),
);
}
// 输出请求信息日志
@@ -919,25 +1101,75 @@ impl RequestForwarder {
);
}
// 确定超时
let timeout = if self.non_streaming_timeout.is_zero() {
std::time::Duration::from_secs(600) // 默认 600 秒
} else {
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);
// SOCKS5 代理不支持 CONNECT 隧道,需要用 reqwest
let is_socks_proxy = upstream_proxy_url
.as_deref()
.map(|u| u.starts_with("socks5"))
.unwrap_or(false);
let uri: http::Uri = url
.parse()
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid URL '{url}': {e}")))?;
// 发送请求
let response = request.json(&filtered_body).send().await.map_err(|e| {
if e.is_timeout() {
ProxyError::Timeout(format!("请求超时: {e}"))
} else if e.is_connect() {
ProxyError::ForwardFailed(format!("连接失败: {e}"))
} else {
ProxyError::ForwardFailed(e.to_string())
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 mut request = client.post(&url);
if !self.non_streaming_timeout.is_zero() {
request = request.timeout(self.non_streaming_timeout);
}
})?;
for (key, value) in &ordered_headers {
request = request.header(key, value);
}
let reqwest_resp = request.body(body_bytes).send().await.map_err(|e| {
if e.is_timeout() {
ProxyError::Timeout(format!("请求超时: {e}"))
} else if e.is_connect() {
ProxyError::ForwardFailed(format!("连接失败: {e}"))
} else {
ProxyError::ForwardFailed(e.to_string())
}
})?;
ProxyResponse::Reqwest(reqwest_resp)
} else {
// HTTP 代理或直连:走 hyper raw write(保持 header 大小写)
// 如果有 HTTP 代理,hyper_client 会用 CONNECT 隧道穿过代理
super::hyper_client::send_request(
uri,
http::Method::POST,
ordered_headers,
extensions.clone(),
body_bytes,
timeout,
upstream_proxy_url.as_deref(),
)
.await?
};
// 检查响应状态
let status = response.status();
if status.is_success() {
Ok(response)
Ok((response, resolved_claude_api_format))
} else {
let status_code = status.as_u16();
let body_text = response.text().await.ok();
let body_text = String::from_utf8(response.bytes().await?.to_vec()).ok();
Err(ProxyError::UpstreamError {
status: status_code,
@@ -946,6 +1178,68 @@ impl RequestForwarder {
}
}
async fn resolve_claude_api_format(
&self,
provider: &Provider,
body: &Value,
is_copilot: bool,
) -> String {
if !is_copilot {
return super::providers::get_claude_api_format(provider).to_string();
}
let model = body.get("model").and_then(|value| value.as_str());
if let Some(model_id) = model {
if self
.is_copilot_openai_vendor_model(provider, model_id)
.await
{
return "openai_responses".to_string();
}
}
"openai_chat".to_string()
}
async fn is_copilot_openai_vendor_model(&self, provider: &Provider, model_id: &str) -> bool {
let Some(app_handle) = &self.app_handle else {
log::debug!("[Copilot] AppHandle unavailable, fallback to chat/completions");
return false;
};
let copilot_state = app_handle.state::<CopilotAuthState>();
let copilot_auth = copilot_state.0.read().await;
let account_id = provider
.meta
.as_ref()
.and_then(|m| m.managed_account_id_for("github_copilot"));
let vendor_result = match account_id.as_deref() {
Some(id) => {
copilot_auth
.get_model_vendor_for_account(id, model_id)
.await
}
None => copilot_auth.get_model_vendor(model_id).await,
};
match vendor_result {
Ok(Some(vendor)) => vendor.eq_ignore_ascii_case("openai"),
Ok(None) => {
log::debug!(
"[Copilot] Model vendor unavailable for {model_id}, fallback to chat/completions"
);
false
}
Err(err) => {
log::warn!(
"[Copilot] Failed to resolve model vendor for {model_id}, fallback to chat/completions: {err}"
);
false
}
}
}
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
match error {
// 网络和上游错误:都应该尝试下一个供应商
@@ -1092,6 +1386,102 @@ fn extract_json_error_message(body: &Value) -> Option<String> {
.find_map(|value| value.as_str().map(ToString::to_string))
}
fn split_endpoint_and_query(endpoint: &str) -> (&str, Option<&str>) {
endpoint
.split_once('?')
.map_or((endpoint, None), |(path, query)| (path, Some(query)))
}
fn strip_beta_query(query: Option<&str>) -> Option<String> {
let filtered = query.map(|query| {
query
.split('&')
.filter(|pair| !pair.is_empty() && !pair.starts_with("beta="))
.collect::<Vec<_>>()
.join("&")
});
match filtered.as_deref() {
Some("") | None => None,
Some(_) => filtered,
}
}
fn is_claude_messages_path(path: &str) -> bool {
matches!(path, "/v1/messages" | "/claude/v1/messages")
}
fn rewrite_claude_transform_endpoint(
endpoint: &str,
api_format: &str,
is_copilot: bool,
) -> (String, Option<String>) {
let (path, query) = split_endpoint_and_query(endpoint);
let passthrough_query = if is_claude_messages_path(path) {
strip_beta_query(query)
} else {
query.map(ToString::to_string)
};
if !is_claude_messages_path(path) {
return (endpoint.to_string(), passthrough_query);
}
let target_path = if is_copilot && api_format == "openai_responses" {
"/v1/responses"
} else if is_copilot {
"/chat/completions"
} else if api_format == "openai_responses" {
"/v1/responses"
} else {
"/v1/chat/completions"
};
let rewritten = match passthrough_query.as_deref() {
Some(query) if !query.is_empty() => format!("{target_path}?{query}"),
_ => target_path.to_string(),
};
(rewritten, passthrough_query)
}
fn append_query_to_full_url(base_url: &str, query: Option<&str>) -> String {
match query {
Some(query) if !query.is_empty() => {
if base_url.contains('?') {
format!("{base_url}&{query}")
} else {
format!("{base_url}?{query}")
}
}
_ => base_url.to_string(),
}
}
fn should_force_identity_encoding(
endpoint: &str,
body: &Value,
headers: &axum::http::HeaderMap,
) -> bool {
if body
.get("stream")
.and_then(|value| value.as_bool())
.unwrap_or(false)
{
return true;
}
if endpoint.contains("streamGenerateContent") || endpoint.contains("alt=sse") {
return true;
}
headers
.get(axum::http::header::ACCEPT)
.and_then(|value| value.to_str().ok())
.map(|accept| accept.contains("text/event-stream"))
.unwrap_or(false)
}
fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
let trimmed = normalized.trim();
@@ -1108,6 +1498,8 @@ fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
#[cfg(test)]
mod tests {
use super::*;
use axum::http::header::{HeaderValue, ACCEPT};
use axum::http::HeaderMap;
use serde_json::json;
#[test]
@@ -1174,4 +1566,101 @@ mod tests {
assert_eq!(summary, "line1 line2...");
}
#[test]
fn rewrite_claude_transform_endpoint_strips_beta_for_chat_completions() {
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
"/v1/messages?beta=true&foo=bar",
"openai_chat",
false,
);
assert_eq!(endpoint, "/v1/chat/completions?foo=bar");
assert_eq!(passthrough_query.as_deref(), Some("foo=bar"));
}
#[test]
fn rewrite_claude_transform_endpoint_strips_beta_for_responses() {
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
"/claude/v1/messages?beta=true&x-id=1",
"openai_responses",
false,
);
assert_eq!(endpoint, "/v1/responses?x-id=1");
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
}
#[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);
assert_eq!(endpoint, "/chat/completions?x-id=1");
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
}
#[test]
fn rewrite_claude_transform_endpoint_uses_copilot_responses_path() {
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
"/v1/messages?beta=true&x-id=1",
"openai_responses",
true,
);
assert_eq!(endpoint, "/v1/responses?x-id=1");
assert_eq!(passthrough_query.as_deref(), Some("x-id=1"));
}
#[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"));
assert_eq!(url, "https://relay.example/api?foo=bar&x-id=1");
}
#[test]
fn force_identity_for_stream_flag_requests() {
let headers = HeaderMap::new();
assert!(should_force_identity_encoding(
"/v1/responses",
&json!({ "stream": true }),
&headers
));
}
#[test]
fn force_identity_for_gemini_stream_endpoints() {
let headers = HeaderMap::new();
assert!(should_force_identity_encoding(
"/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse",
&json!({ "model": "gemini-2.5-pro" }),
&headers
));
}
#[test]
fn force_identity_for_sse_accept_header() {
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
assert!(should_force_identity_encoding(
"/v1/responses",
&json!({ "model": "gpt-5" }),
&headers
));
}
#[test]
fn non_streaming_requests_allow_automatic_compression() {
let headers = HeaderMap::new();
assert!(!should_force_identity_encoding(
"/v1/responses",
&json!({ "model": "gpt-5" }),
&headers
));
}
}
+112 -29
View File
@@ -18,7 +18,10 @@ use super::{
streaming_responses::create_anthropic_sse_stream_from_responses, transform,
transform_responses,
},
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
response_processor::{
create_logged_passthrough_stream, process_response, read_decoded_body,
strip_entity_headers_for_rebuilt_body, SseUsageCollector,
},
server::ProxyState,
types::*,
usage::parser::TokenUsage,
@@ -27,6 +30,7 @@ use super::{
use crate::app_config::AppType;
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use bytes::Bytes;
use http_body_util::BodyExt;
use serde_json::{json, Value};
// ============================================================================
@@ -61,12 +65,28 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
pub async fn handle_messages(
State(state): State<ProxyState>,
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
let (parts, body) = request.into_parts();
let uri = parts.uri;
let headers = parts.headers;
let extensions = parts.extensions;
let body_bytes = body
.collect()
.await
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
.to_bytes();
let body: Value = serde_json::from_slice(&body_bytes)
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
let endpoint = uri
.path_and_query()
.map(|path_and_query| path_and_query.as_str())
.unwrap_or(uri.path());
let is_stream = body
.get("stream")
.and_then(|s| s.as_bool())
@@ -77,9 +97,10 @@ pub async fn handle_messages(
let result = match forwarder
.forward_with_retry(
&AppType::Claude,
"/v1/messages",
endpoint,
body.clone(),
headers,
extensions,
ctx.get_providers(),
)
.await
@@ -95,6 +116,11 @@ pub async fn handle_messages(
};
ctx.provider = result.provider;
let api_format = result
.claude_api_format
.as_deref()
.unwrap_or_else(|| get_claude_api_format(&ctx.provider))
.to_string();
let response = result.response;
// 检查是否需要格式转换(OpenRouter 等中转服务)
@@ -103,7 +129,8 @@ pub async fn handle_messages(
// Claude 特有:格式转换处理
if needs_transform {
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
return handle_claude_transform(response, &ctx, &state, &body, is_stream, &api_format)
.await;
}
// 通用响应处理(透传模式)
@@ -114,14 +141,14 @@ pub async fn handle_messages(
///
/// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换
async fn handle_claude_transform(
response: reqwest::Response,
response: super::hyper_client::ProxyResponse,
ctx: &RequestContext,
state: &ProxyState,
_original_body: &Value,
is_stream: bool,
api_format: &str,
) -> Result<axum::response::Response, ProxyError> {
let status = response.status();
let api_format = get_claude_api_format(&ctx.provider);
if is_stream {
// 根据 api_format 选择流式转换器
@@ -199,12 +226,14 @@ async fn handle_claude_transform(
}
// 非流式响应转换 (OpenAI/Responses → Anthropic)
let response_headers = response.headers().clone();
let body_bytes = response.bytes().await.map_err(|e| {
log::error!("[Claude] 读取响应体失败: {e}");
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
})?;
let body_timeout =
if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 {
std::time::Duration::from_secs(ctx.app_config.non_streaming_timeout as u64)
} else {
std::time::Duration::ZERO
};
let (mut response_headers, _status, body_bytes) =
read_decoded_body(response, ctx.tag, body_timeout).await?;
let body_str = String::from_utf8_lossy(&body_bytes);
@@ -257,13 +286,10 @@ async fn handle_claude_transform(
// 构建响应
let mut builder = axum::response::Response::builder().status(status);
strip_entity_headers_for_rebuilt_body(&mut response_headers);
for (key, value) in response_headers.iter() {
if key.as_str().to_lowercase() != "content-length"
&& key.as_str().to_lowercase() != "transfer-encoding"
{
builder = builder.header(key, value);
}
builder = builder.header(key, value);
}
builder = builder.header("content-type", "application/json");
@@ -280,6 +306,13 @@ async fn handle_claude_transform(
})
}
fn endpoint_with_query(uri: &axum::http::Uri, endpoint: &str) -> String {
match uri.query() {
Some(query) => format!("{endpoint}?{query}"),
None => endpoint.to_string(),
}
}
// ============================================================================
// Codex API 处理器
// ============================================================================
@@ -287,11 +320,23 @@ async fn handle_claude_transform(
/// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI
pub async fn handle_chat_completions(
State(state): State<ProxyState>,
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
let (parts, req_body) = request.into_parts();
let uri = parts.uri;
let headers = parts.headers;
let extensions = parts.extensions;
let body_bytes = req_body
.collect()
.await
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
.to_bytes();
let body: Value = serde_json::from_slice(&body_bytes)
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
let endpoint = endpoint_with_query(&uri, "/chat/completions");
let is_stream = body
.get("stream")
@@ -302,9 +347,10 @@ pub async fn handle_chat_completions(
let result = match forwarder
.forward_with_retry(
&AppType::Codex,
"/chat/completions",
&endpoint,
body,
headers,
extensions,
ctx.get_providers(),
)
.await
@@ -328,11 +374,23 @@ pub async fn handle_chat_completions(
/// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传)
pub async fn handle_responses(
State(state): State<ProxyState>,
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
let (parts, req_body) = request.into_parts();
let uri = parts.uri;
let headers = parts.headers;
let extensions = parts.extensions;
let body_bytes = req_body
.collect()
.await
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
.to_bytes();
let body: Value = serde_json::from_slice(&body_bytes)
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
let endpoint = endpoint_with_query(&uri, "/responses");
let is_stream = body
.get("stream")
@@ -343,9 +401,10 @@ pub async fn handle_responses(
let result = match forwarder
.forward_with_retry(
&AppType::Codex,
"/responses",
&endpoint,
body,
headers,
extensions,
ctx.get_providers(),
)
.await
@@ -369,11 +428,23 @@ pub async fn handle_responses(
/// 处理 /v1/responses/compact 请求(OpenAI Responses Compact API - Codex CLI 透传)
pub async fn handle_responses_compact(
State(state): State<ProxyState>,
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
let (parts, req_body) = request.into_parts();
let uri = parts.uri;
let headers = parts.headers;
let extensions = parts.extensions;
let body_bytes = req_body
.collect()
.await
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
.to_bytes();
let body: Value = serde_json::from_slice(&body_bytes)
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
let endpoint = endpoint_with_query(&uri, "/responses/compact");
let is_stream = body
.get("stream")
@@ -384,9 +455,10 @@ pub async fn handle_responses_compact(
let result = match forwarder
.forward_with_retry(
&AppType::Codex,
"/responses/compact",
&endpoint,
body,
headers,
extensions,
ctx.get_providers(),
)
.await
@@ -415,9 +487,19 @@ pub async fn handle_responses_compact(
pub async fn handle_gemini(
State(state): State<ProxyState>,
uri: axum::http::Uri,
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
request: axum::extract::Request,
) -> Result<axum::response::Response, ProxyError> {
let (parts, req_body) = request.into_parts();
let headers = parts.headers;
let extensions = parts.extensions;
let body_bytes = req_body
.collect()
.await
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
.to_bytes();
let body: Value = serde_json::from_slice(&body_bytes)
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
// Gemini 的模型名称在 URI 中
let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Gemini, "Gemini", "gemini")
.await?
@@ -441,6 +523,7 @@ pub async fn handle_gemini(
endpoint,
body,
headers,
extensions,
ctx.get_providers(),
)
.await
+10 -2
View File
@@ -219,7 +219,12 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
.timeout(Duration::from_secs(600))
.connect_timeout(Duration::from_secs(30))
.pool_max_idle_per_host(10)
.tcp_keepalive(Duration::from_secs(60));
.tcp_keepalive(Duration::from_secs(60))
// 禁用 reqwest 自动解压:防止 reqwest 覆盖客户端原始 accept-encoding header。
// 响应解压由 response_processor 根据 content-encoding 手动处理。
.no_gzip()
.no_brotli()
.no_deflate();
// 有代理地址则使用代理,否则跟随系统代理
if let Some(url) = proxy_url {
@@ -332,7 +337,7 @@ pub fn mask_url(url: &str) -> String {
/// 根据供应商单独代理配置构建代理 URL
///
/// 将 ProviderProxyConfig 转换为代理 URL 字符串
fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option<String> {
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?;
@@ -387,6 +392,9 @@ pub fn build_client_for_provider(proxy_config: Option<&ProviderProxyConfig>) ->
.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()
{
+685
View File
@@ -0,0 +1,685 @@
//! Hyper-based HTTP client for proxy forwarding
//!
//! Uses raw TCP/TLS writes to preserve exact original header name casing.
//! Supports HTTP CONNECT tunneling through upstream proxies.
//! Falls back to hyper-util Client (title-case headers) when raw write is not feasible.
use super::ProxyError;
use bytes::Bytes;
use futures::stream::Stream;
use http_body_util::BodyExt;
use hyper_rustls::HttpsConnectorBuilder;
use hyper_util::{client::legacy::Client, rt::TokioExecutor};
use std::sync::OnceLock;
/// Our own header case map: maps lowercase header name → original wire-casing bytes.
///
/// This is a backup mechanism independent of hyper's internal `HeaderCaseMap` (which is
/// `pub(crate)` and cannot be directly inspected or constructed from outside hyper).
///
/// Populated in `server.rs` by peeking at raw TCP bytes before hyper parses them.
/// Used in `send_request` to manually write headers with original casing when hyper's
/// own mechanism fails.
#[derive(Clone, Debug, Default)]
pub(crate) struct OriginalHeaderCases {
/// Ordered list of (lowercase_name, original_wire_bytes) pairs.
/// Multiple entries with the same name are allowed (for repeated headers).
pub cases: Vec<(String, Vec<u8>)>,
}
impl OriginalHeaderCases {
/// Parse raw HTTP request bytes (from TcpStream::peek) to extract original header casings.
pub fn from_raw_bytes(buf: &[u8]) -> Self {
let mut headers_buf = [httparse::EMPTY_HEADER; 128];
let mut req = httparse::Request::new(&mut headers_buf);
// We don't care if parsing is partial — we just want the header names we can get
let _ = req.parse(buf);
let mut cases = Vec::new();
for header in req.headers.iter() {
if header.name.is_empty() {
break;
}
cases.push((
header.name.to_ascii_lowercase(),
header.name.as_bytes().to_vec(),
));
}
Self { cases }
}
}
type HyperClient = Client<
hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>,
http_body_util::Full<Bytes>,
>;
/// Lazily-initialized hyper client with header-case preservation enabled.
fn global_hyper_client() -> &'static HyperClient {
static CLIENT: OnceLock<HyperClient> = OnceLock::new();
CLIENT.get_or_init(|| {
let connector = HttpsConnectorBuilder::new()
.with_webpki_roots()
.https_or_http()
.enable_http1()
.build();
Client::builder(TokioExecutor::new())
.http1_preserve_header_case(true)
.http1_title_case_headers(true)
.build(connector)
})
}
/// Unified response wrapper that can hold either a hyper or reqwest response.
///
/// The hyper variant is used for the main (direct) path with header-case preservation.
/// The reqwest variant is the fallback when an upstream HTTP/SOCKS5 proxy is configured.
pub enum ProxyResponse {
Hyper(hyper::Response<hyper::body::Incoming>),
Reqwest(reqwest::Response),
}
impl ProxyResponse {
pub fn status(&self) -> http::StatusCode {
match self {
Self::Hyper(r) => r.status(),
Self::Reqwest(r) => r.status(),
}
}
pub fn headers(&self) -> &http::HeaderMap {
match self {
Self::Hyper(r) => r.headers(),
Self::Reqwest(r) => r.headers(),
}
}
/// Shortcut: extract `content-type` header value as `&str`.
pub fn content_type(&self) -> Option<&str> {
self.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
}
/// Check if the response is an SSE stream.
pub fn is_sse(&self) -> bool {
self.content_type()
.map(|ct| ct.contains("text/event-stream"))
.unwrap_or(false)
}
/// Consume the response and collect the full body into `Bytes`.
pub async fn bytes(self) -> Result<Bytes, ProxyError> {
match self {
Self::Hyper(r) => {
let collected = r.into_body().collect().await.map_err(|e| {
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
})?;
Ok(collected.to_bytes())
}
Self::Reqwest(r) => r.bytes().await.map_err(|e| {
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
}),
}
}
/// Consume the response and return a byte-chunk stream (for SSE pass-through).
pub fn bytes_stream(self) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
use futures::StreamExt;
match self {
Self::Hyper(r) => {
let body = r.into_body();
let stream = futures::stream::unfold(body, |mut body| async {
match body.frame().await {
Some(Ok(frame)) => {
if let Ok(data) = frame.into_data() {
if data.is_empty() {
Some((Ok(Bytes::new()), body))
} else {
Some((Ok(data), body))
}
} else {
Some((Ok(Bytes::new()), body))
}
}
Some(Err(e)) => Some((Err(std::io::Error::other(e.to_string())), body)),
None => None,
}
})
.filter(|result| {
futures::future::ready(!matches!(result, Ok(ref b) if b.is_empty()))
});
Box::pin(stream)
as std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>
}
Self::Reqwest(r) => {
let stream = r
.bytes_stream()
.map(|r| r.map_err(|e| std::io::Error::other(e.to_string())));
Box::pin(stream)
}
}
}
}
/// Send an HTTP request with header-case preservation.
///
/// Uses a two-tier strategy:
/// 1. Primary: raw HTTP/1.1 write via TLS stream with exact original header casing
/// (from `OriginalHeaderCases` captured by peek in server.rs), then hand off to
/// hyper for response parsing.
/// 2. Fallback: hyper-util Client with `title_case_headers(true)` when raw write
/// isn't feasible (e.g., missing original cases).
///
/// The caller is expected to include `Host` in the supplied `headers` at the
/// correct position.
///
/// `proxy_url`: optional upstream HTTP proxy URL (e.g. `http://127.0.0.1:7890`).
/// When set, the raw write path uses HTTP CONNECT tunneling through the proxy,
/// so header-case preservation works even when an upstream proxy is configured.
pub async fn send_request(
uri: http::Uri,
method: http::Method,
headers: http::HeaderMap,
original_extensions: http::Extensions,
body: Vec<u8>,
timeout: std::time::Duration,
proxy_url: Option<&str>,
) -> Result<ProxyResponse, ProxyError> {
// Extract our own OriginalHeaderCases if available
let original_cases = original_extensions.get::<OriginalHeaderCases>().cloned();
let has_cases = original_cases
.as_ref()
.map(|c| !c.cases.is_empty())
.unwrap_or(false);
log::debug!(
"[HyperClient] Sending request: uri={uri}, header_count={}, \
has_host={}, has_original_cases={has_cases}, proxy={:?}",
headers.len(),
headers.contains_key(http::header::HOST),
proxy_url,
);
if has_cases {
// Primary path: use raw write + hyper handshake for exact header casing
let result = tokio::time::timeout(
timeout,
send_raw_request(
&uri,
&method,
&headers,
original_cases.as_ref().unwrap(),
&body,
proxy_url,
),
)
.await
.map_err(|_| ProxyError::Timeout(format!("请求超时: {}s", timeout.as_secs())))?;
match result {
Ok(resp) => return Ok(resp),
Err(e) => {
if proxy_url.is_some() {
// Don't bypass configured proxy with direct connect fallback
return Err(e);
}
log::warn!("[HyperClient] Raw write failed, falling back to hyper-util: {e}");
// Fall through to hyper-util Client
}
}
}
// Fallback: hyper-util Client (title-case headers, no proxy support)
let mut req = http::Request::builder()
.method(method)
.uri(&uri)
.body(http_body_util::Full::new(Bytes::from(body)))
.map_err(|e| ProxyError::ForwardFailed(format!("Failed to build request: {e}")))?;
*req.headers_mut() = headers;
*req.extensions_mut() = original_extensions;
let client = global_hyper_client();
let resp = tokio::time::timeout(timeout, client.request(req))
.await
.map_err(|_| ProxyError::Timeout(format!("请求超时: {}s", timeout.as_secs())))?
.map_err(|e| ProxyError::ForwardFailed(format!("上游请求失败: {e}")))?;
Ok(ProxyResponse::Hyper(resp))
}
/// TCP or TLS stream returned by `connect_via_proxy`.
///
/// When the proxy URL uses `https://`, the connection to the proxy itself is
/// TLS-wrapped before sending the CONNECT request. The enum lets
/// `send_raw_request` work with either variant generically.
enum ProxyStream {
Tcp(tokio::net::TcpStream),
Tls(Box<tokio_rustls::client::TlsStream<tokio::net::TcpStream>>),
}
impl tokio::io::AsyncRead for ProxyStream {
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match self.get_mut() {
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_read(cx, buf),
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_read(cx, buf),
}
}
}
impl tokio::io::AsyncWrite for ProxyStream {
fn poll_write(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
match self.get_mut() {
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_write(cx, buf),
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_write(cx, buf),
}
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match self.get_mut() {
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_flush(cx),
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_flush(cx),
}
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
match self.get_mut() {
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_shutdown(cx),
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_shutdown(cx),
}
}
}
/// Send request via raw TCP/TLS with exact original header casing.
///
/// When `proxy_url` is provided, establishes an HTTP CONNECT tunnel through
/// the proxy first, then performs TLS + raw write through the tunnel.
/// This preserves header casing even when an upstream proxy is configured.
async fn send_raw_request(
uri: &http::Uri,
method: &http::Method,
headers: &http::HeaderMap,
original_cases: &OriginalHeaderCases,
body: &[u8],
proxy_url: Option<&str>,
) -> Result<ProxyResponse, ProxyError> {
use tokio::io::AsyncWriteExt;
let scheme = uri.scheme_str().unwrap_or("https");
let host = uri
.host()
.ok_or_else(|| ProxyError::ForwardFailed("URI has no host".into()))?;
let port = uri
.port_u16()
.unwrap_or(if scheme == "https" { 443 } else { 80 });
let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
// Build raw HTTP request bytes
let raw = build_raw_request(method, path_and_query, headers, original_cases, body);
// Establish TCP connection — either direct or through HTTP CONNECT proxy
let stream = if let Some(proxy) = proxy_url {
connect_via_proxy(proxy, host, port).await?
} else {
ProxyStream::Tcp(
tokio::net::TcpStream::connect((host, port))
.await
.map_err(|e| ProxyError::ForwardFailed(format!("TCP connect failed: {e}")))?,
)
};
if scheme == "https" {
let tls_connector = global_tls_connector();
let server_name = rustls::pki_types::ServerName::try_from(host.to_string())
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid server name: {e}")))?;
let mut tls_stream = tls_connector
.connect(server_name, stream)
.await
.map_err(|e| ProxyError::ForwardFailed(format!("TLS handshake failed: {e}")))?;
tls_stream
.write_all(&raw)
.await
.map_err(|e| ProxyError::ForwardFailed(format!("Write failed: {e}")))?;
let filtered = WriteFilter::new(tls_stream);
do_hyper_response(filtered, method.clone()).await
} else {
let mut stream = stream;
stream
.write_all(&raw)
.await
.map_err(|e| ProxyError::ForwardFailed(format!("Write failed: {e}")))?;
let filtered = WriteFilter::new(stream);
do_hyper_response(filtered, method.clone()).await
}
}
/// Establish a connection through an HTTP CONNECT proxy tunnel.
///
/// 1. Connect TCP to the proxy server (TLS-wrapped when `https://` proxy)
/// 2. Send `CONNECT host:port HTTP/1.1` with optional `Proxy-Authorization`
/// 3. Read the proxy's 200 response (407 → `AuthError`)
/// 4. Return the tunneled stream (ready for target TLS handshake + raw write)
async fn connect_via_proxy(
proxy_url: &str,
target_host: &str,
target_port: u16,
) -> Result<ProxyStream, ProxyError> {
use base64::Engine;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let parsed = url::Url::parse(proxy_url)
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid proxy URL: {e}")))?;
let proxy_host = parsed
.host_str()
.ok_or_else(|| ProxyError::ForwardFailed("Proxy URL has no host".into()))?;
let proxy_port = parsed
.port()
.unwrap_or(if parsed.scheme() == "https" { 443 } else { 80 });
// Build Proxy-Authorization header if credentials are present
let proxy_auth = if !parsed.username().is_empty() {
let password = parsed.password().unwrap_or("");
let credentials = format!("{}:{}", parsed.username(), password);
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
Some(format!("Proxy-Authorization: Basic {encoded}\r\n"))
} else {
None
};
// Connect to the proxy
let tcp = tokio::net::TcpStream::connect((proxy_host, proxy_port))
.await
.map_err(|e| ProxyError::ForwardFailed(format!("Proxy TCP connect failed: {e}")))?;
// Wrap with TLS if the proxy URL uses https://
let mut stream: ProxyStream = if parsed.scheme() == "https" {
let tls_connector = global_tls_connector();
let server_name = rustls::pki_types::ServerName::try_from(proxy_host.to_string())
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid proxy server name: {e}")))?;
let tls_stream = tls_connector
.connect(server_name, tcp)
.await
.map_err(|e| ProxyError::ForwardFailed(format!("Proxy TLS handshake failed: {e}")))?;
ProxyStream::Tls(Box::new(tls_stream))
} else {
ProxyStream::Tcp(tcp)
};
// Send CONNECT request
let mut connect_req = format!(
"CONNECT {target_host}:{target_port} HTTP/1.1\r\n\
Host: {target_host}:{target_port}\r\n"
);
if let Some(auth) = &proxy_auth {
connect_req.push_str(auth);
}
connect_req.push_str("\r\n");
stream
.write_all(connect_req.as_bytes())
.await
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT write failed: {e}")))?;
// Read the proxy's response status line
let mut reader = BufReader::new(&mut stream);
let mut status_line = String::new();
reader
.read_line(&mut status_line)
.await
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT read failed: {e}")))?;
// Expect "HTTP/1.1 200 ..." or "HTTP/1.0 200 ..."
if !status_line.contains(" 200 ") {
if status_line.contains(" 407 ") {
return Err(ProxyError::AuthError(format!(
"Proxy authentication required (407): {}",
status_line.trim()
)));
}
return Err(ProxyError::ForwardFailed(format!(
"Proxy CONNECT rejected: {}",
status_line.trim()
)));
}
// Drain remaining response headers (until empty line)
loop {
let mut line = String::new();
reader
.read_line(&mut line)
.await
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT header read: {e}")))?;
if line.trim().is_empty() {
break;
}
}
// BufReader might have buffered data; drop it to get raw stream back.
// Since CONNECT response is headers-only (no body), and we read until \r\n\r\n,
// the BufReader buffer should be empty at this point.
drop(reader);
log::debug!(
"[HyperClient] CONNECT tunnel established via {proxy_host}:{proxy_port} -> {target_host}:{target_port}"
);
Ok(stream)
}
/// Lazily-initialized TLS connector for raw connections.
///
/// Loads both webpki roots AND native system certificates so that
/// proxy MITM CAs (e.g. Clash, mitmproxy) installed in the system
/// keychain are trusted through the CONNECT tunnel.
fn global_tls_connector() -> &'static tokio_rustls::TlsConnector {
static CONNECTOR: OnceLock<tokio_rustls::TlsConnector> = OnceLock::new();
CONNECTOR.get_or_init(|| {
let mut root_store = rustls::RootCertStore::empty();
// Baseline: Mozilla/webpki roots
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
// Native system certs (includes user-installed proxy CAs)
let native = rustls_native_certs::load_native_certs();
let (added, _errors) = root_store.add_parsable_certificates(native.certs);
log::debug!("[HyperClient] TLS root store: webpki + {added} native certs");
let config = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
tokio_rustls::TlsConnector::from(std::sync::Arc::new(config))
})
}
/// Build raw HTTP/1.1 request bytes with original header casing.
fn build_raw_request(
method: &http::Method,
path_and_query: &str,
headers: &http::HeaderMap,
original_cases: &OriginalHeaderCases,
body: &[u8],
) -> Vec<u8> {
let mut raw = Vec::with_capacity(4096 + body.len());
// Request line
raw.extend_from_slice(method.as_str().as_bytes());
raw.extend_from_slice(b" ");
raw.extend_from_slice(path_and_query.as_bytes());
raw.extend_from_slice(b" HTTP/1.1\r\n");
// Headers with original casing, emitted in original wire order.
//
// Strategy:
// 1. Walk `original_cases.cases` in order — this preserves the exact
// header sequence the client sent. For each entry, emit the stored
// original-casing name plus the current value from `headers` (the
// proxy may have rewritten the value, e.g. Authorization).
// Repeated headers with the same name are handled by tracking a
// per-name value cursor so we step through `get_all()` in order.
// 2. After the original headers, append any headers that exist in
// `headers` but were not present in the original request (i.e. added
// by the proxy). These are emitted in lowercase.
//
// This replaces the old `for name in headers.keys()` loop which iterated
// in hash-map order, destroying the original header sequence.
let mut emitted: std::collections::HashSet<String> =
std::collections::HashSet::with_capacity(original_cases.cases.len());
// Per-name cursor: how many values we have already emitted for each name.
let mut value_cursor: std::collections::HashMap<String, usize> =
std::collections::HashMap::with_capacity(original_cases.cases.len());
for (lower_name, orig_name_bytes) in &original_cases.cases {
if let Ok(header_name) = http::header::HeaderName::from_bytes(lower_name.as_bytes()) {
let all_values: Vec<_> = headers.get_all(&header_name).iter().collect();
let cursor = value_cursor.entry(lower_name.clone()).or_insert(0);
if let Some(value) = all_values.get(*cursor) {
raw.extend_from_slice(orig_name_bytes);
raw.extend_from_slice(b": ");
raw.extend_from_slice(value.as_bytes());
raw.extend_from_slice(b"\r\n");
*cursor += 1;
emitted.insert(lower_name.clone());
}
}
}
// Append proxy-added headers (not present in the original request).
for name in headers.keys() {
let lower = name.as_str().to_ascii_lowercase();
if !emitted.contains(&lower) {
for value in headers.get_all(name) {
raw.extend_from_slice(name.as_str().as_bytes());
raw.extend_from_slice(b": ");
raw.extend_from_slice(value.as_bytes());
raw.extend_from_slice(b"\r\n");
}
emitted.insert(lower);
}
}
// Add Content-Length if not already present
if !headers.contains_key(http::header::CONTENT_LENGTH) {
raw.extend_from_slice(b"Content-Length: ");
raw.extend_from_slice(body.len().to_string().as_bytes());
raw.extend_from_slice(b"\r\n");
}
// End of headers + body
raw.extend_from_slice(b"\r\n");
raw.extend_from_slice(body);
raw
}
/// Use hyper's low-level client to parse the response on a stream where we've
/// already written the request.
///
/// `WriteFilter` discards any writes from hyper (it would try to send its own
/// request encoding), while passing reads through transparently.
async fn do_hyper_response<S>(
stream: WriteFilter<S>,
method: http::Method,
) -> Result<ProxyResponse, ProxyError>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let io = hyper_util::rt::TokioIo::new(stream);
let (mut sender, conn) = hyper::client::conn::http1::Builder::new()
.preserve_header_case(true)
.handshake::<_, http_body_util::Full<Bytes>>(io)
.await
.map_err(|e| ProxyError::ForwardFailed(format!("Handshake failed: {e}")))?;
// Spawn the connection driver (reads responses from the stream)
tokio::spawn(async move {
if let Err(e) = conn.await {
log::debug!("[HyperClient] raw conn driver error: {e}");
}
});
// Send a dummy request through hyper — hyper will encode this and try to write it,
// but WriteFilter discards all writes. Hyper will then read the response from the stream.
let dummy_req = http::Request::builder()
.method(method)
.uri("/")
.body(http_body_util::Full::new(Bytes::new()))
.map_err(|e| ProxyError::ForwardFailed(format!("Build dummy request: {e}")))?;
let resp = sender
.send_request(dummy_req)
.await
.map_err(|e| ProxyError::ForwardFailed(format!("Response parse failed: {e}")))?;
Ok(ProxyResponse::Hyper(resp))
}
/// A stream wrapper that discards all writes but passes reads through.
///
/// This lets hyper's connection driver think it sent a request (its encoded bytes
/// go to /dev/null), while correctly parsing the response that the upstream server
/// sends in reply to our raw-written request.
struct WriteFilter<S> {
inner: S,
}
impl<S> WriteFilter<S> {
fn new(inner: S) -> Self {
Self { inner }
}
}
impl<S: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for WriteFilter<S> {
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
// Pass reads through to the underlying stream
let inner = std::pin::Pin::new(&mut self.get_mut().inner);
inner.poll_read(cx, buf)
}
}
impl<S: Unpin> tokio::io::AsyncWrite for WriteFilter<S> {
fn poll_write(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
// Discard all writes — pretend they succeeded
std::task::Poll::Ready(Ok(buf.len()))
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
}
+2
View File
@@ -26,6 +26,8 @@ pub mod srv {
pub const STOPPED: &str = "SRV-002";
pub const STOP_TIMEOUT: &str = "SRV-003";
pub const TASK_ERROR: &str = "SRV-004";
pub const ACCEPT_ERR: &str = "SRV-005";
pub const CONN_ERR: &str = "SRV-006";
}
/// 转发器日志码
+3
View File
@@ -14,6 +14,7 @@ pub mod handler_context;
mod handlers;
mod health;
pub mod http_client;
pub mod hyper_client;
pub mod log_codes;
pub mod model_mapper;
pub mod provider_router;
@@ -22,6 +23,8 @@ pub mod response_handler;
pub mod response_processor;
pub(crate) mod server;
pub mod session;
pub(crate) mod sse;
pub(crate) mod switch_lock;
pub mod thinking_budget_rectifier;
pub mod thinking_optimizer;
pub mod thinking_rectifier;
+4 -85
View File
@@ -5,7 +5,6 @@
use super::auth::AuthInfo;
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use reqwest::RequestBuilder;
use serde_json::Value;
/// 供应商适配器 Trait
@@ -14,116 +13,36 @@ use serde_json::Value;
/// - URL 构建
/// - 认证信息提取和头部注入
/// - 请求/响应格式转换(可选)
///
/// # 示例
///
/// ```ignore
/// pub struct ClaudeAdapter;
///
/// impl ProviderAdapter for ClaudeAdapter {
/// fn name(&self) -> &'static str { "Claude" }
///
/// fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
/// // 从 provider 配置中提取 base_url
/// }
///
/// fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
/// // 从 provider 配置中提取认证信息
/// }
///
/// fn build_url(&self, base_url: &str, endpoint: &str) -> String {
/// format!("{}{}", base_url.trim_end_matches('/'), endpoint)
/// }
///
/// fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
/// // 添加认证头
/// }
/// }
/// ```
pub trait ProviderAdapter: Send + Sync {
/// 适配器名称(用于日志和调试)
fn name(&self) -> &'static str;
/// 从 Provider 配置中提取 base_url
///
/// # Arguments
/// * `provider` - Provider 配置
///
/// # Returns
/// * `Ok(String)` - 提取到的 base_url(已去除尾部斜杠)
/// * `Err(ProxyError)` - 提取失败
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError>;
/// 从 Provider 配置中提取认证信息
///
/// # Arguments
/// * `provider` - Provider 配置
///
/// # Returns
/// * `Some(AuthInfo)` - 提取到的认证信息
/// * `None` - 未找到认证信息
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo>;
/// 构建请求 URL
///
/// # Arguments
/// * `base_url` - 基础 URL
/// * `endpoint` - 请求端点(如 `/v1/messages`
///
/// # Returns
/// 完整的请求 URL
fn build_url(&self, base_url: &str, endpoint: &str) -> String;
/// 添加认证头到请求
/// Return auth headers as `(name, value)` pairs.
///
/// # Arguments
/// * `request` - reqwest RequestBuilder
/// * `auth` - 认证信息
///
/// # Returns
/// 添加了认证头的 RequestBuilder
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder;
/// The forwarder inserts these at the position of the original auth header
/// so that header order is preserved.
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)>;
/// 是否需要格式转换
///
/// 默认返回 `false`(透传模式)。
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter 旧 OpenAI 兼容接口)才返回 `true`。
///
/// # Arguments
/// * `provider` - Provider 配置
fn needs_transform(&self, _provider: &Provider) -> bool {
false
}
/// 转换请求体
///
/// 将请求体从一种格式转换为另一种格式(如 Anthropic → OpenAI)。
/// 默认实现直接返回原始请求体(透传)。
///
/// # Arguments
/// * `body` - 原始请求体
/// * `provider` - Provider 配置(用于获取模型映射等)
///
/// # Returns
/// * `Ok(Value)` - 转换后的请求体
/// * `Err(ProxyError)` - 转换失败
fn transform_request(&self, body: Value, _provider: &Provider) -> Result<Value, ProxyError> {
Ok(body)
}
/// 转换响应体
///
/// 将响应体从一种格式转换为另一种格式(如 OpenAI → Anthropic)。
/// 默认实现直接返回原始响应体(透传)。
///
/// # Arguments
/// * `body` - 原始响应体
///
/// # Returns
/// * `Ok(Value)` - 转换后的响应体
/// * `Err(ProxyError)` - 转换失败
///
/// Note: 响应转换将在 handler 层集成,目前预留接口
#[allow(dead_code)]
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
Ok(body)
+8
View File
@@ -112,6 +112,13 @@ pub enum AuthStrategy {
///
/// 用于 Gemini CLI 等需要 OAuth 的场景
GoogleOAuth,
/// GitHub Copilot 认证方式
///
/// - Header: `Authorization: Bearer <copilot_token>`
///
/// 使用动态获取的 Copilot Token(通过 GitHub OAuth 设备码流程获取)
GitHubCopilot,
}
#[cfg(test)]
@@ -226,6 +233,7 @@ mod tests {
AuthStrategy::Bearer,
AuthStrategy::Google,
AuthStrategy::GoogleOAuth,
AuthStrategy::GitHubCopilot,
];
for (i, s1) in strategies.iter().enumerate() {
+202 -49
View File
@@ -11,11 +11,11 @@
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传
//! - **GitHubCopilot**: GitHub Copilot (OAuth + Copilot Token)
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use reqwest::RequestBuilder;
/// 获取 Claude 供应商的 API 格式
///
@@ -65,6 +65,30 @@ 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")
}
pub fn transform_claude_request_for_api_format(
body: serde_json::Value,
provider: &Provider,
api_format: &str,
) -> Result<serde_json::Value, ProxyError> {
let cache_key = provider
.meta
.as_ref()
.and_then(|m| m.prompt_cache_key.as_deref())
.unwrap_or(&provider.id);
match api_format {
"openai_responses" => {
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
}
"openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)),
_ => Ok(body),
}
}
/// Claude 适配器
pub struct ClaudeAdapter;
@@ -76,10 +100,16 @@ impl ClaudeAdapter {
/// 获取供应商类型
///
/// 根据 base_url 和 auth_mode 检测具体的供应商类型:
/// - GitHubCopilot: meta.provider_type 为 github_copilot 或 base_url 包含 githubcopilot.com
/// - OpenRouter: base_url 包含 openrouter.ai
/// - ClaudeAuth: auth_mode 为 bearer_only
/// - Claude: 默认 Anthropic 官方
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
// 检测 GitHub Copilot
if self.is_github_copilot(provider) {
return ProviderType::GitHubCopilot;
}
// 检测 OpenRouter
if self.is_openrouter(provider) {
return ProviderType::OpenRouter;
@@ -93,6 +123,25 @@ impl ClaudeAdapter {
ProviderType::Claude
}
/// 检测是否为 GitHub Copilot 供应商
fn is_github_copilot(&self, provider: &Provider) -> bool {
// 方式1: 检查 meta.provider_type
if let Some(meta) = provider.meta.as_ref() {
if meta.provider_type.as_deref() == Some("github_copilot") {
return true;
}
}
// 方式2: 检查 base_url(兼容旧数据的 fallback,后续应优先依赖 providerType
if let Ok(base_url) = self.extract_base_url(provider) {
if base_url.contains("githubcopilot.com") {
return true;
}
}
false
}
/// 检测是否使用 OpenRouter
fn is_openrouter(&self, provider: &Provider) -> bool {
if let Ok(base_url) = self.extract_base_url(provider) {
@@ -244,6 +293,17 @@ impl ProviderAdapter for ClaudeAdapter {
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
let provider_type = self.provider_type(provider);
// GitHub Copilot 使用特殊的认证策略
// 实际的 token 会在代理请求时动态获取
if provider_type == ProviderType::GitHubCopilot {
// 返回一个占位符,实际 token 由 CopilotAuthManager 动态提供
return Some(AuthInfo::new(
"copilot_placeholder".to_string(),
AuthStrategy::GitHubCopilot,
));
}
let strategy = match provider_type {
ProviderType::OpenRouter => AuthStrategy::Bearer,
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
@@ -261,7 +321,7 @@ impl ProviderAdapter for ClaudeAdapter {
//
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
//
let mut base = format!(
"{}/{}",
base_url.trim_end_matches('/'),
@@ -273,42 +333,62 @@ impl ProviderAdapter for ClaudeAdapter {
base = base.replace("/v1/v1", "/v1");
}
// 为 Claude 原生 /v1/messages 端点添加 ?beta=true 参数
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
// 注意:不要为 OpenAI Chat Completions (/v1/chat/completions) 添加此参数
// 当 apiFormat="openai_chat" 时,请求会转发到 /v1/chat/completions
// 但该端点是 OpenAI 标准,不支持 ?beta=true 参数
if endpoint.contains("/v1/messages")
&& !endpoint.contains("/v1/chat/completions")
&& !endpoint.contains('?')
{
format!("{base}?beta=true")
} else {
base
}
base
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)> {
use http::{HeaderName, HeaderValue};
// 注意:anthropic-version 由 forwarder.rs 统一处理(透传客户端值或设置默认值)
// 这里不再设置 anthropic-version,避免 header 重复
let bearer = format!("Bearer {}", auth.api_key);
match auth.strategy {
// Anthropic 官方: Authorization Bearer + x-api-key
AuthStrategy::Anthropic => request
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("x-api-key", &auth.api_key),
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
AuthStrategy::ClaudeAuth => {
request.header("Authorization", format!("Bearer {}", auth.api_key))
AuthStrategy::Anthropic | AuthStrategy::ClaudeAuth | AuthStrategy::Bearer => {
vec![(
HeaderName::from_static("authorization"),
HeaderValue::from_str(&bearer).unwrap(),
)]
}
// OpenRouter: Bearer
AuthStrategy::Bearer => {
request.header("Authorization", format!("Bearer {}", auth.api_key))
AuthStrategy::GitHubCopilot => {
vec![
(
HeaderName::from_static("authorization"),
HeaderValue::from_str(&bearer).unwrap(),
),
(
HeaderName::from_static("editor-version"),
HeaderValue::from_static(super::copilot_auth::COPILOT_EDITOR_VERSION),
),
(
HeaderName::from_static("editor-plugin-version"),
HeaderValue::from_static(super::copilot_auth::COPILOT_PLUGIN_VERSION),
),
(
HeaderName::from_static("copilot-integration-id"),
HeaderValue::from_static(super::copilot_auth::COPILOT_INTEGRATION_ID),
),
(
HeaderName::from_static("user-agent"),
HeaderValue::from_static(super::copilot_auth::COPILOT_USER_AGENT),
),
(
HeaderName::from_static("x-github-api-version"),
HeaderValue::from_static(super::copilot_auth::COPILOT_API_VERSION),
),
(
HeaderName::from_static("openai-intent"),
HeaderValue::from_static("conversation-panel"),
),
]
}
_ => request,
_ => vec![],
}
}
fn needs_transform(&self, provider: &Provider) -> bool {
// GitHub Copilot 总是需要格式转换 (Anthropic → OpenAI)
if self.is_github_copilot(provider) {
return true;
}
// 根据 api_format 配置决定是否需要格式转换
// - "anthropic" (默认): 直接透传,无需转换
// - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换
@@ -324,19 +404,7 @@ impl ProviderAdapter for ClaudeAdapter {
body: serde_json::Value,
provider: &Provider,
) -> Result<serde_json::Value, ProxyError> {
// Use meta.prompt_cache_key if set by user, otherwise fall back to provider.id
let cache_key = provider
.meta
.as_ref()
.and_then(|m| m.prompt_cache_key.as_deref())
.unwrap_or(&provider.id);
match self.get_api_format(provider) {
"openai_responses" => {
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
}
_ => super::transform::anthropic_to_openai(body, Some(cache_key)),
}
transform_claude_request_for_api_format(body, provider, self.get_api_format(provider))
}
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
@@ -522,23 +590,20 @@ mod tests {
#[test]
fn test_build_url_anthropic() {
let adapter = ClaudeAdapter::new();
// /v1/messages 端点会自动添加 ?beta=true 参数
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
assert_eq!(url, "https://api.anthropic.com/v1/messages");
}
#[test]
fn test_build_url_openrouter() {
let adapter = ClaudeAdapter::new();
// /v1/messages 端点会自动添加 ?beta=true 参数
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
assert_eq!(url, "https://openrouter.ai/api/v1/messages?beta=true");
assert_eq!(url, "https://openrouter.ai/api/v1/messages");
}
#[test]
fn test_build_url_no_beta_for_other_endpoints() {
let adapter = ClaudeAdapter::new();
// 非 /v1/messages 端点不添加 ?beta=true
let url = adapter.build_url("https://api.anthropic.com", "/v1/complete");
assert_eq!(url, "https://api.anthropic.com/v1/complete");
}
@@ -546,16 +611,20 @@ mod tests {
#[test]
fn test_build_url_preserve_existing_query() {
let adapter = ClaudeAdapter::new();
// 已有查询参数时不重复添加
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
}
#[test]
fn test_build_url_no_beta_for_github_copilot() {
let adapter = ClaudeAdapter::new();
let url = adapter.build_url("https://api.githubcopilot.com", "/v1/messages");
assert_eq!(url, "https://api.githubcopilot.com/v1/messages");
}
#[test]
fn test_build_url_no_beta_for_openai_chat_completions() {
let adapter = ClaudeAdapter::new();
// OpenAI Chat Completions 端点不添加 ?beta=true
// 这是 Nvidia 等 apiFormat="openai_chat" 供应商使用的端点
let url = adapter.build_url("https://integrate.api.nvidia.com", "/v1/chat/completions");
assert_eq!(url, "https://integrate.api.nvidia.com/v1/chat/completions");
}
@@ -678,4 +747,88 @@ mod tests {
);
assert!(!adapter.needs_transform(&unknown_format));
}
#[test]
fn test_github_copilot_detection_by_url() {
let adapter = ClaudeAdapter::new();
// GitHub Copilot by base_url
let copilot = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
}
}));
assert_eq!(adapter.provider_type(&copilot), ProviderType::GitHubCopilot);
}
#[test]
fn test_github_copilot_detection_by_meta() {
let adapter = ClaudeAdapter::new();
// GitHub Copilot by meta.provider_type
let copilot_meta = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.example.com"
}
}),
ProviderMeta {
provider_type: Some("github_copilot".to_string()),
..Default::default()
},
);
assert_eq!(
adapter.provider_type(&copilot_meta),
ProviderType::GitHubCopilot
);
}
#[test]
fn test_github_copilot_auth() {
let adapter = ClaudeAdapter::new();
let copilot = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
}
}));
let auth = adapter.extract_auth(&copilot).unwrap();
assert_eq!(auth.strategy, AuthStrategy::GitHubCopilot);
}
#[test]
fn test_github_copilot_needs_transform() {
let adapter = ClaudeAdapter::new();
let copilot = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
}
}));
// GitHub Copilot always needs transform
assert!(adapter.needs_transform(&copilot));
}
#[test]
fn test_transform_claude_request_for_api_format_responses() {
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
}
}));
let body = json!({
"model": "gpt-5.4",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 128
});
let transformed =
transform_claude_request_for_api_format(body, &provider, "openai_responses").unwrap();
assert_eq!(transformed["model"], "gpt-5.4");
assert!(transformed.get("input").is_some());
assert!(transformed.get("max_output_tokens").is_some());
}
}
+6 -3
View File
@@ -9,7 +9,6 @@ use super::{AuthInfo, AuthStrategy, ProviderAdapter};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use regex::Regex;
use reqwest::RequestBuilder;
use std::sync::LazyLock;
/// 官方 Codex 客户端 User-Agent 正则
@@ -174,8 +173,12 @@ impl ProviderAdapter for CodexAdapter {
url
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
request.header("Authorization", format!("Bearer {}", auth.api_key))
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)> {
let bearer = format!("Bearer {}", auth.api_key);
vec![(
http::HeaderName::from_static("authorization"),
http::HeaderValue::from_str(&bearer).unwrap(),
)]
}
}
File diff suppressed because it is too large Load Diff
+16 -8
View File
@@ -9,7 +9,6 @@
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
use crate::provider::Provider;
use crate::proxy::error::ProxyError;
use reqwest::RequestBuilder;
/// Gemini 适配器
pub struct GeminiAdapter;
@@ -217,17 +216,26 @@ impl ProviderAdapter for GeminiAdapter {
url
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)> {
use http::{HeaderName, HeaderValue};
match auth.strategy {
// OAuth Bearer 认证
AuthStrategy::GoogleOAuth => {
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
request
.header("Authorization", format!("Bearer {token}"))
.header("x-goog-api-client", "GeminiCLI/1.0")
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"),
),
]
}
// API Key 认证
_ => request.header("x-goog-api-key", &auth.api_key),
_ => vec![(
HeaderName::from_static("x-goog-api-key"),
HeaderValue::from_str(&auth.api_key).unwrap(),
)],
}
}
}
+51 -5
View File
@@ -15,6 +15,7 @@ mod adapter;
mod auth;
mod claude;
mod codex;
pub mod copilot_auth;
mod gemini;
pub mod models;
pub mod streaming;
@@ -29,7 +30,10 @@ use serde::{Deserialize, Serialize};
// 公开导出
pub use adapter::ProviderAdapter;
pub use auth::{AuthInfo, AuthStrategy};
pub use claude::{get_claude_api_format, ClaudeAdapter};
pub use claude::{
claude_api_format_needs_transform, get_claude_api_format,
transform_claude_request_for_api_format, ClaudeAdapter,
};
pub use codex::CodexAdapter;
pub use gemini::GeminiAdapter;
@@ -52,6 +56,8 @@ pub enum ProviderType {
GeminiCli,
/// OpenRouter(已支持 Claude Code 兼容接口,默认透传;保留旧转换逻辑备用)
OpenRouter,
/// GitHub Copilot (OAuth + Copilot Token,需要 Anthropic ↔ OpenAI 转换)
GitHubCopilot,
}
impl ProviderType {
@@ -59,9 +65,11 @@ impl ProviderType {
///
/// 过去 OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式;
/// 现在默认关闭转换(因为 OpenRouter 已支持 Claude Code 兼容接口)。
/// GitHub Copilot 需要转换(Anthropic → OpenAI 格式)。
#[allow(dead_code)]
pub fn needs_transform(&self) -> bool {
match self {
ProviderType::GitHubCopilot => true,
ProviderType::OpenRouter => false,
_ => false,
}
@@ -77,6 +85,7 @@ impl ProviderType {
"https://generativelanguage.googleapis.com"
}
ProviderType::OpenRouter => "https://openrouter.ai/api",
ProviderType::GitHubCopilot => "https://api.githubcopilot.com",
}
}
@@ -87,9 +96,20 @@ impl ProviderType {
pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {
match app_type {
AppType::Claude => {
// 检测是否为 OpenRouter
// 检测是否为 GitHub Copilot
if let Some(meta) = provider.meta.as_ref() {
if meta.provider_type.as_deref() == Some("github_copilot") {
return ProviderType::GitHubCopilot;
}
}
// 检测 base_url 是否为 GitHub Copilot
let adapter = ClaudeAdapter::new();
if let Ok(base_url) = adapter.extract_base_url(provider) {
if base_url.contains("githubcopilot.com") {
return ProviderType::GitHubCopilot;
}
// 检测是否为 OpenRouter
if base_url.contains("openrouter.ai") {
return ProviderType::OpenRouter;
}
@@ -154,6 +174,7 @@ impl ProviderType {
ProviderType::Gemini => "gemini",
ProviderType::GeminiCli => "gemini_cli",
ProviderType::OpenRouter => "openrouter",
ProviderType::GitHubCopilot => "github_copilot",
}
}
}
@@ -175,6 +196,9 @@ impl std::str::FromStr for ProviderType {
"gemini" => Ok(ProviderType::Gemini),
"gemini_cli" | "gemini-cli" => Ok(ProviderType::GeminiCli),
"openrouter" => Ok(ProviderType::OpenRouter),
"github_copilot" | "github-copilot" | "githubcopilot" => {
Ok(ProviderType::GitHubCopilot)
}
_ => Err(format!("Invalid provider type: {s}")),
}
}
@@ -201,9 +225,10 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
#[allow(dead_code)]
pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn ProviderAdapter> {
match provider_type {
ProviderType::Claude | ProviderType::ClaudeAuth | ProviderType::OpenRouter => {
Box::new(ClaudeAdapter::new())
}
ProviderType::Claude
| ProviderType::ClaudeAuth
| ProviderType::OpenRouter
| ProviderType::GitHubCopilot => Box::new(ClaudeAdapter::new()),
ProviderType::Codex => Box::new(CodexAdapter::new()),
ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()),
}
@@ -239,6 +264,7 @@ mod tests {
assert!(!ProviderType::Gemini.needs_transform());
assert!(!ProviderType::GeminiCli.needs_transform());
assert!(!ProviderType::OpenRouter.needs_transform());
assert!(ProviderType::GitHubCopilot.needs_transform());
}
#[test]
@@ -267,6 +293,10 @@ mod tests {
ProviderType::OpenRouter.default_endpoint(),
"https://openrouter.ai/api"
);
assert_eq!(
ProviderType::GitHubCopilot.default_endpoint(),
"https://api.githubcopilot.com"
);
}
#[test]
@@ -303,6 +333,18 @@ mod tests {
"openrouter".parse::<ProviderType>().unwrap(),
ProviderType::OpenRouter
);
assert_eq!(
"github_copilot".parse::<ProviderType>().unwrap(),
ProviderType::GitHubCopilot
);
assert_eq!(
"github-copilot".parse::<ProviderType>().unwrap(),
ProviderType::GitHubCopilot
);
assert_eq!(
"githubcopilot".parse::<ProviderType>().unwrap(),
ProviderType::GitHubCopilot
);
assert!("invalid".parse::<ProviderType>().is_err());
}
@@ -314,6 +356,7 @@ mod tests {
assert_eq!(ProviderType::Gemini.as_str(), "gemini");
assert_eq!(ProviderType::GeminiCli.as_str(), "gemini_cli");
assert_eq!(ProviderType::OpenRouter.as_str(), "openrouter");
assert_eq!(ProviderType::GitHubCopilot.as_str(), "github_copilot");
}
#[test]
@@ -434,6 +477,9 @@ mod tests {
let adapter = get_adapter_for_provider_type(&ProviderType::OpenRouter);
assert_eq!(adapter.name(), "Claude");
let adapter = get_adapter_for_provider_type(&ProviderType::GitHubCopilot);
assert_eq!(adapter.name(), "Claude");
let adapter = get_adapter_for_provider_type(&ProviderType::Codex);
assert_eq!(adapter.name(), "Codex");
+16 -7
View File
@@ -2,6 +2,7 @@
//!
//! 实现 OpenAI SSE → Anthropic SSE 格式转换
use crate::proxy::sse::strip_sse_field;
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use serde::{Deserialize, Serialize};
@@ -87,8 +88,8 @@ struct ToolBlockState {
}
/// 创建 Anthropic SSE 流
pub fn create_anthropic_sse_stream(
stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
async_stream::stream! {
let mut buffer = String::new();
@@ -118,7 +119,7 @@ pub fn create_anthropic_sse_stream(
}
for l in line.lines() {
if let Some(data) = l.strip_prefix("data: ") {
if let Some(data) = strip_sse_field(l, "data") {
if data.trim() == "[DONE]" {
log::debug!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
let event = json!({"type": "message_stop"});
@@ -597,7 +598,9 @@ mod tests {
"data: [DONE]\n\n"
);
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
input.as_bytes().to_vec(),
))]);
let converted = create_anthropic_sse_stream(upstream);
let chunks: Vec<_> = converted.collect().await;
@@ -609,7 +612,9 @@ mod tests {
let events: Vec<Value> = merged
.split("\n\n")
.filter_map(|block| {
let data = block.lines().find_map(|line| line.strip_prefix("data: "))?;
let data = block
.lines()
.find_map(|line| strip_sse_field(line, "data"))?;
serde_json::from_str::<Value>(data).ok()
})
.collect();
@@ -683,7 +688,9 @@ mod tests {
"data: [DONE]\n\n"
);
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
input.as_bytes().to_vec(),
))]);
let converted = create_anthropic_sse_stream(upstream);
let chunks: Vec<_> = converted.collect().await;
let merged = chunks
@@ -694,7 +701,9 @@ mod tests {
let events: Vec<Value> = merged
.split("\n\n")
.filter_map(|block| {
let data = block.lines().find_map(|line| line.strip_prefix("data: "))?;
let data = block
.lines()
.find_map(|line| strip_sse_field(line, "data"))?;
serde_json::from_str::<Value>(data).ok()
})
.collect();
@@ -9,6 +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 bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use serde_json::{json, Value};
@@ -95,8 +96,8 @@ fn resolve_content_index(
///
/// 状态机跟踪: message_id, current_model, has_sent_message_start, item/content index map
/// SSE 解析支持 named events (event: + data: 行)
pub fn create_anthropic_sse_stream_from_responses(
stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send + 'static>(
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
async_stream::stream! {
let mut buffer = String::new();
@@ -108,6 +109,7 @@ pub fn create_anthropic_sse_stream_from_responses(
let mut index_by_key: HashMap<String, u32> = HashMap::new();
let mut open_indices: HashSet<u32> = HashSet::new();
let mut fallback_open_index: Option<u32> = None;
let mut current_text_index: Option<u32> = None;
let mut tool_index_by_item_id: HashMap<String, u32> = HashMap::new();
let mut last_tool_index: Option<u32> = None;
@@ -133,9 +135,9 @@ pub fn create_anthropic_sse_stream_from_responses(
let mut data_parts: Vec<String> = Vec::new();
for line in block.lines() {
if let Some(evt) = line.strip_prefix("event: ") {
if let Some(evt) = strip_sse_field(line, "event") {
event_type = Some(evt.trim().to_string());
} else if let Some(d) = line.strip_prefix("data: ") {
} else if let Some(d) = strip_sse_field(line, "data") {
data_parts.push(d.to_string());
}
}
@@ -217,12 +219,18 @@ pub fn create_anthropic_sse_stream_from_responses(
if let Some(part) = data.get("part") {
let part_type = part.get("type").and_then(|t| t.as_str());
if matches!(part_type, Some("output_text") | Some("refusal")) {
let index = resolve_content_index(
&data,
&mut next_content_index,
&mut index_by_key,
&mut fallback_open_index,
);
let index = if let Some(index) = current_text_index {
index
} else {
let index = resolve_content_index(
&data,
&mut next_content_index,
&mut index_by_key,
&mut fallback_open_index,
);
current_text_index = Some(index);
index
};
if open_indices.contains(&index) {
continue;
@@ -249,12 +257,18 @@ pub fn create_anthropic_sse_stream_from_responses(
// ================================================
"response.output_text.delta" => {
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
let index = resolve_content_index(
&data,
&mut next_content_index,
&mut index_by_key,
&mut fallback_open_index,
);
let index = if let Some(index) = current_text_index {
index
} else {
let index = resolve_content_index(
&data,
&mut next_content_index,
&mut index_by_key,
&mut fallback_open_index,
);
current_text_index = Some(index);
index
};
if !open_indices.contains(&index) {
let start_event = json!({
@@ -289,12 +303,18 @@ pub fn create_anthropic_sse_stream_from_responses(
// ================================================
"response.refusal.delta" => {
if let Some(delta) = data.get("delta").and_then(|d| d.as_str()) {
let index = resolve_content_index(
&data,
&mut next_content_index,
&mut index_by_key,
&mut fallback_open_index,
);
let index = if let Some(index) = current_text_index {
index
} else {
let index = resolve_content_index(
&data,
&mut next_content_index,
&mut index_by_key,
&mut fallback_open_index,
);
current_text_index = Some(index);
index
};
if !open_indices.contains(&index) {
let start_event = json!({
@@ -328,29 +348,7 @@ pub fn create_anthropic_sse_stream_from_responses(
// ================================================
// response.content_part.done → content_block_stop
// ================================================
"response.content_part.done" => {
let key = content_part_key(&data);
let index = if let Some(k) = key {
index_by_key.get(&k).copied()
} else {
fallback_open_index
};
if let Some(index) = index {
if !open_indices.remove(&index) {
continue;
}
let event = json!({
"type": "content_block_stop",
"index": index
});
let sse = format!("event: content_block_stop\ndata: {}\n\n",
serde_json::to_string(&event).unwrap_or_default());
yield Ok(Bytes::from(sse));
if fallback_open_index == Some(index) {
fallback_open_index = None;
}
}
}
"response.content_part.done" => {}
// ================================================
// response.output_item.added (function_call) → content_block_start (tool_use)
@@ -360,6 +358,20 @@ pub fn create_anthropic_sse_stream_from_responses(
let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
if item_type == "function_call" {
has_tool_use = true;
if let Some(index) = current_text_index.take() {
if open_indices.remove(&index) {
let stop_event = json!({
"type": "content_block_stop",
"index": index
});
let stop_sse = format!("event: content_block_stop\ndata: {}\n\n",
serde_json::to_string(&stop_event).unwrap_or_default());
yield Ok(Bytes::from(stop_sse));
}
if fallback_open_index == Some(index) {
fallback_open_index = None;
}
}
// 确保 message_start 已发送
if !has_sent_message_start {
let start_event = json!({
@@ -520,12 +532,14 @@ pub fn create_anthropic_sse_stream_from_responses(
// response.refusal.done → content_block_stop
// ================================================
"response.refusal.done" => {
let key = content_part_key(&data);
let index = if let Some(k) = key {
index_by_key.get(&k).copied()
} else {
fallback_open_index
};
let index = current_text_index.take().or_else(|| {
let key = content_part_key(&data);
if let Some(k) = key {
index_by_key.get(&k).copied()
} else {
fallback_open_index
}
});
if let Some(index) = index {
if !open_indices.remove(&index) {
continue;
@@ -552,6 +566,20 @@ pub fn create_anthropic_sse_stream_from_responses(
.or_else(|| data.get("text"))
.and_then(|d| d.as_str())
{
if let Some(index) = current_text_index.take() {
if open_indices.remove(&index) {
let stop_event = json!({
"type": "content_block_stop",
"index": index
});
let stop_sse = format!("event: content_block_stop\ndata: {}\n\n",
serde_json::to_string(&stop_event).unwrap_or_default());
yield Ok(Bytes::from(stop_sse));
}
if fallback_open_index == Some(index) {
fallback_open_index = None;
}
}
let index = resolve_content_index(
&data,
&mut next_content_index,
@@ -673,8 +701,23 @@ pub fn create_anthropic_sse_stream_from_responses(
// Lifecycle events that don't need Anthropic counterparts.
// Listed explicitly so new events trigger a match-completeness review.
"response.output_text.done"
| "response.output_item.done"
"response.output_text.done" => {
if let Some(index) = current_text_index.take() {
if open_indices.remove(&index) {
let stop_event = json!({
"type": "content_block_stop",
"index": index
});
let stop_sse = format!("event: content_block_stop\ndata: {}\n\n",
serde_json::to_string(&stop_event).unwrap_or_default());
yield Ok(Bytes::from(stop_sse));
}
if fallback_open_index == Some(index) {
fallback_open_index = None;
}
}
}
"response.output_item.done"
| "response.in_progress" => {}
// Any other unknown/future events — silently skip.
@@ -757,7 +800,9 @@ mod tests {
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":12,\"output_tokens\":3}}}\n\n"
);
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
input.as_bytes().to_vec(),
))]);
let converted = create_anthropic_sse_stream_from_responses(upstream);
let chunks: Vec<_> = converted.collect().await;
@@ -799,7 +844,9 @@ mod tests {
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":8,\"output_tokens\":4}}}\n\n"
);
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
input.as_bytes().to_vec(),
))]);
let converted = create_anthropic_sse_stream_from_responses(upstream);
let chunks: Vec<_> = converted.collect().await;
let merged = chunks
@@ -810,7 +857,9 @@ mod tests {
let events: Vec<Value> = merged
.split("\n\n")
.filter_map(|block| {
let data = block.lines().find_map(|line| line.strip_prefix("data: "))?;
let data = block
.lines()
.find_map(|line| strip_sse_field(line, "data"))?;
serde_json::from_str::<Value>(data).ok()
})
.collect();
@@ -868,7 +917,9 @@ mod tests {
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":10}}}\n\n"
);
let upstream = stream::iter(vec![Ok(Bytes::from(input.as_bytes().to_vec()))]);
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
input.as_bytes().to_vec(),
))]);
let converted = create_anthropic_sse_stream_from_responses(upstream);
let chunks: Vec<_> = converted.collect().await;
let merged = chunks
@@ -899,4 +950,83 @@ mod tests {
);
assert!(merged.contains("\"stop_reason\":\"end_turn\""));
}
#[tokio::test]
async fn test_streaming_text_parts_are_merged_into_one_text_block() {
let input = concat!(
"event: response.created\n",
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_merge\",\"model\":\"gpt-5.4\",\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}\n\n",
"event: response.content_part.added\n",
"data: {\"type\":\"response.content_part.added\",\"part\":{\"type\":\"output_text\",\"text\":\"\"},\"output_index\":0,\"content_index\":0}\n\n",
"event: response.output_text.delta\n",
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"\",\"output_index\":0,\"content_index\":0}\n\n",
"event: response.content_part.done\n",
"data: {\"type\":\"response.content_part.done\",\"output_index\":0,\"content_index\":0}\n\n",
"event: response.content_part.added\n",
"data: {\"type\":\"response.content_part.added\",\"part\":{\"type\":\"output_text\",\"text\":\"\"},\"output_index\":0,\"content_index\":1}\n\n",
"event: response.output_text.delta\n",
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"\",\"output_index\":0,\"content_index\":1}\n\n",
"event: response.content_part.done\n",
"data: {\"type\":\"response.content_part.done\",\"output_index\":0,\"content_index\":1}\n\n",
"event: response.output_text.done\n",
"data: {\"type\":\"response.output_text.done\",\"output_index\":0,\"content_index\":1}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":5,\"output_tokens\":2}}}\n\n"
);
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
input.as_bytes().to_vec(),
))]);
let converted = create_anthropic_sse_stream_from_responses(upstream);
let chunks: Vec<_> = converted.collect().await;
let events: Vec<Value> = chunks
.into_iter()
.flat_map(|chunk| {
let bytes = chunk.unwrap();
let text = String::from_utf8_lossy(bytes.as_ref()).to_string();
text.split("\n\n")
.filter_map(|block| {
block.lines().find_map(|line| {
strip_sse_field(line, "data")
.and_then(|payload| serde_json::from_str::<Value>(payload).ok())
})
})
.collect::<Vec<_>>()
})
.collect();
let text_starts = events
.iter()
.filter(|event| {
event.get("type").and_then(|v| v.as_str()) == Some("content_block_start")
&& event
.pointer("/content_block/type")
.and_then(|v| v.as_str())
== Some("text")
})
.count();
let text_stops = events
.iter()
.filter(|event| {
event.get("type").and_then(|v| v.as_str()) == Some("content_block_stop")
})
.count();
let text_deltas: Vec<String> = events
.iter()
.filter(|event| {
event.get("type").and_then(|v| v.as_str()) == Some("content_block_delta")
&& event.pointer("/delta/type").and_then(|v| v.as_str()) == Some("text_delta")
})
.filter_map(|event| {
event
.pointer("/delta/text")
.and_then(|v| v.as_str())
.map(ToString::to_string)
})
.collect();
assert_eq!(text_starts, 1);
assert_eq!(text_stops, 1);
assert_eq!(text_deltas, vec!["".to_string(), "".to_string()]);
}
}
+301 -2
View File
@@ -6,6 +6,71 @@
use crate::proxy::error::ProxyError;
use serde_json::{json, Value};
/// Detect OpenAI o-series reasoning models (o1, o3, o4-mini, etc.)
/// These models require `max_completion_tokens` instead of `max_tokens`.
pub fn is_openai_o_series(model: &str) -> bool {
model.len() > 1
&& model.starts_with('o')
&& model.as_bytes().get(1).is_some_and(|b| b.is_ascii_digit())
}
/// Detect OpenAI models that support reasoning_effort.
///
/// Supported families:
/// - o-series: o1, o3, o4-mini, etc.
/// - GPT-5+: gpt-5, gpt-5.1, gpt-5.4, gpt-5-codex, etc.
pub fn supports_reasoning_effort(model: &str) -> bool {
is_openai_o_series(model)
|| model
.to_lowercase()
.strip_prefix("gpt-")
.and_then(|rest| rest.chars().next())
.is_some_and(|c| c.is_ascii_digit() && c >= '5')
}
/// Resolve the appropriate OpenAI `reasoning_effort` from an Anthropic request body.
///
/// Priority:
/// 1. Explicit `output_config.effort` — preserves the user's intent directly.
/// `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)
/// - `enabled` with budget → `low` (<4 000) / `medium` (4 00015 999) / `high` (≥16 000)
/// - `enabled` without budget → `high` (conservative default)
/// - `disabled` / absent → `None`
pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
// --- Priority 1: explicit output_config.effort ---
if let Some(effort) = body
.pointer("/output_config/effort")
.and_then(|v| v.as_str())
{
return match effort {
"low" => Some("low"),
"medium" => Some("medium"),
"high" => Some("high"),
"max" => Some("xhigh"), // OpenAI xhigh = maximum reasoning effort
_ => None, // unknown value — do not inject
};
}
// --- 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("enabled") => {
let budget = thinking.get("budget_tokens").and_then(|b| b.as_u64());
match budget {
Some(b) if b < 4_000 => Some("low"),
Some(b) if b < 16_000 => Some("medium"),
Some(_) => Some("high"),
None => Some("high"), // enabled but no budget — assume strong reasoning
}
}
_ => None, // disabled or missing
}
}
/// Anthropic 请求 → OpenAI 请求
///
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
@@ -50,9 +115,14 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
result["messages"] = json!(messages);
// 转换参数
// 转换参数 — o-series 模型需要 max_completion_tokens
let model = body.get("model").and_then(|m| m.as_str()).unwrap_or("");
if let Some(v) = body.get("max_tokens") {
result["max_tokens"] = v.clone();
if is_openai_o_series(model) {
result["max_completion_tokens"] = v.clone();
} else {
result["max_tokens"] = v.clone();
}
}
if let Some(v) = body.get("temperature") {
result["temperature"] = v.clone();
@@ -67,6 +137,13 @@ pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value
result["stream"] = v.clone();
}
// Map Anthropic thinking → OpenAI reasoning_effort
if supports_reasoning_effort(model) {
if let Some(effort) = resolve_reasoning_effort(&body) {
result["reasoning_effort"] = json!(effort);
}
}
// 转换 tools (过滤 BatchTool)
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
let openai_tools: Vec<Value> = tools
@@ -772,4 +849,226 @@ mod tests {
assert_eq!(result["content"][1]["type"], "text");
assert_eq!(result["content"][1]["text"], "I can't do that");
}
#[test]
fn test_is_openai_o_series() {
assert!(is_openai_o_series("o1"));
assert!(is_openai_o_series("o1-preview"));
assert!(is_openai_o_series("o1-mini"));
assert!(is_openai_o_series("o3"));
assert!(is_openai_o_series("o3-mini"));
assert!(is_openai_o_series("o4-mini"));
assert!(!is_openai_o_series("gpt-4o"));
assert!(!is_openai_o_series("openai-gpt"));
assert!(!is_openai_o_series("o"));
assert!(!is_openai_o_series(""));
}
#[test]
fn test_supports_reasoning_effort() {
assert!(supports_reasoning_effort("o1"));
assert!(supports_reasoning_effort("o3-mini"));
assert!(supports_reasoning_effort("gpt-5"));
assert!(supports_reasoning_effort("gpt-5.4"));
assert!(supports_reasoning_effort("gpt-5-codex"));
assert!(!supports_reasoning_effort("gpt-4o"));
assert!(!supports_reasoning_effort("claude-sonnet-4-6"));
}
// ── resolve_reasoning_effort unit tests ──
#[test]
fn test_output_config_low_maps_to_reasoning_effort_low() {
let body = json!({"output_config": {"effort": "low"}});
assert_eq!(resolve_reasoning_effort(&body), Some("low"));
}
#[test]
fn test_output_config_medium_maps_to_reasoning_effort_medium() {
let body = json!({"output_config": {"effort": "medium"}});
assert_eq!(resolve_reasoning_effort(&body), Some("medium"));
}
#[test]
fn test_output_config_high_maps_to_reasoning_effort_high() {
let body = json!({"output_config": {"effort": "high"}});
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
}
#[test]
fn test_output_config_max_maps_to_reasoning_effort_xhigh() {
let body = json!({"output_config": {"effort": "max"}});
assert_eq!(resolve_reasoning_effort(&body), Some("xhigh"));
}
#[test]
fn test_output_config_takes_priority_over_thinking() {
// Even with thinking.adaptive present, explicit effort wins
let body = json!({
"output_config": {"effort": "low"},
"thinking": {"type": "adaptive"}
});
assert_eq!(resolve_reasoning_effort(&body), Some("low"));
}
#[test]
fn test_output_config_unknown_value_no_reasoning_effort() {
let body = json!({"output_config": {"effort": "turbo"}});
assert_eq!(resolve_reasoning_effort(&body), None);
}
#[test]
fn test_thinking_enabled_small_budget_maps_low() {
let body = json!({"thinking": {"type": "enabled", "budget_tokens": 1024}});
assert_eq!(resolve_reasoning_effort(&body), Some("low"));
}
#[test]
fn test_thinking_enabled_medium_budget_maps_medium() {
let body = json!({"thinking": {"type": "enabled", "budget_tokens": 8000}});
assert_eq!(resolve_reasoning_effort(&body), Some("medium"));
}
#[test]
fn test_thinking_enabled_large_budget_maps_high() {
let body = json!({"thinking": {"type": "enabled", "budget_tokens": 32000}});
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
}
#[test]
fn test_thinking_enabled_without_budget_maps_high() {
let body = json!({"thinking": {"type": "enabled"}});
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
}
#[test]
fn test_thinking_adaptive_maps_high() {
let body = json!({"thinking": {"type": "adaptive"}});
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
}
#[test]
fn test_thinking_disabled_no_reasoning_effort() {
let body = json!({"thinking": {"type": "disabled"}});
assert_eq!(resolve_reasoning_effort(&body), None);
}
#[test]
fn test_no_thinking_field_no_reasoning_effort() {
let body = json!({"messages": [{"role": "user", "content": "Hello"}]});
assert_eq!(resolve_reasoning_effort(&body), None);
}
// ── Integration: anthropic_to_openai with resolve_reasoning_effort ──
#[test]
fn test_non_reasoning_model_no_reasoning_effort() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 2048},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert!(result.get("reasoning_effort").is_none());
}
#[test]
fn test_reasoning_model_with_output_config_effort() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"output_config": {"effort": "medium"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["reasoning_effort"], "medium");
}
#[test]
fn test_reasoning_model_with_output_config_max() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"output_config": {"effort": "max"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["reasoning_effort"], "xhigh");
}
#[test]
fn test_reasoning_model_thinking_enabled_small_budget() {
let input = json!({
"model": "o3",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 2048},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["reasoning_effort"], "low");
}
#[test]
fn test_reasoning_model_thinking_adaptive() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "adaptive"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["reasoning_effort"], "high");
}
#[test]
fn test_reasoning_model_no_thinking_no_effort() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert!(result.get("reasoning_effort").is_none());
}
#[test]
fn test_anthropic_to_openai_o_series_max_completion_tokens() {
for model in &["o1", "o3-mini", "o4-mini"] {
let input = json!({
"model": model,
"max_tokens": 4096,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert!(
result.get("max_tokens").is_none(),
"{model} should not have max_tokens"
);
assert_eq!(
result["max_completion_tokens"], 4096,
"{model} should use max_completion_tokens"
);
}
}
#[test]
fn test_anthropic_to_openai_non_o_series_keeps_max_tokens() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_openai(input, None).unwrap();
assert_eq!(result["max_tokens"], 1024);
assert!(result.get("max_completion_tokens").is_none());
}
}
@@ -45,7 +45,7 @@ pub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result<Va
result["input"] = json!(input);
}
// max_tokens → max_output_tokens
// max_tokens → max_output_tokens (Responses API uses max_output_tokens for all models)
if let Some(v) = body.get("max_tokens") {
result["max_output_tokens"] = v.clone();
}
@@ -61,6 +61,15 @@ pub fn anthropic_to_responses(body: Value, cache_key: Option<&str>) -> Result<Va
result["stream"] = v.clone();
}
// Map Anthropic thinking → OpenAI Responses reasoning.effort
if let Some(model_name) = body.get("model").and_then(|m| m.as_str()) {
if super::transform::supports_reasoning_effort(model_name) {
if let Some(effort) = super::transform::resolve_reasoning_effort(&body) {
result["reasoning"] = json!({ "effort": effort });
}
}
}
// stop_sequences → 丢弃 (Responses API 不支持)
// 转换 tools (过滤 BatchTool)
@@ -897,4 +906,109 @@ mod tests {
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
}
#[test]
fn test_anthropic_to_responses_o_series_uses_max_output_tokens() {
// Responses API always uses max_output_tokens, even for o-series models
let input = json!({
"model": "o3-mini",
"max_tokens": 4096,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["max_output_tokens"], 4096);
assert!(result.get("max_completion_tokens").is_none());
}
#[test]
fn test_responses_output_config_max_sets_reasoning_xhigh() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"output_config": {"effort": "max"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["reasoning"]["effort"], "xhigh");
}
#[test]
fn test_responses_output_config_takes_priority_over_thinking() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"output_config": {"effort": "low"},
"thinking": {"type": "adaptive"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["reasoning"]["effort"], "low");
}
#[test]
fn test_responses_thinking_enabled_small_budget_sets_reasoning_low() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 2048},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["reasoning"]["effort"], "low");
}
#[test]
fn test_responses_thinking_enabled_medium_budget_sets_reasoning_medium() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 8000},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["reasoning"]["effort"], "medium");
}
#[test]
fn test_responses_thinking_enabled_large_budget_sets_reasoning_high() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 32000},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["reasoning"]["effort"], "high");
}
#[test]
fn test_responses_thinking_adaptive_sets_reasoning_high() {
let input = json!({
"model": "gpt-5.4",
"max_tokens": 1024,
"thinking": {"type": "adaptive"},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert_eq!(result["reasoning"]["effort"], "high");
}
#[test]
fn test_responses_non_reasoning_model_no_reasoning() {
let input = json!({
"model": "gpt-4o",
"max_tokens": 1024,
"thinking": {"type": "enabled", "budget_tokens": 2048},
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None).unwrap();
assert!(result.get("reasoning").is_none());
}
}
+22 -1
View File
@@ -5,6 +5,7 @@
use super::session::ProxySession;
use super::usage::parser::TokenUsage;
use super::ProxyError;
use crate::proxy::sse::strip_sse_field;
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use serde_json::Value;
@@ -90,7 +91,7 @@ impl StreamHandler {
buffer = buffer[pos + 2..].to_string();
for line in event_text.lines() {
if let Some(data) = line.strip_prefix("data: ") {
if let Some(data) = strip_sse_field(line, "data") {
if data.trim() != "[DONE]" {
if let Ok(json) = serde_json::from_str::<Value>(data) {
let mut guard = events.lock().await;
@@ -211,4 +212,24 @@ mod tests {
let handler = StreamHandler::new(30);
assert_eq!(handler.idle_timeout, Duration::from_secs(30));
}
#[test]
fn test_strip_sse_field_accepts_optional_space() {
assert_eq!(
super::strip_sse_field("data: {\"ok\":true}", "data"),
Some("{\"ok\":true}")
);
assert_eq!(
super::strip_sse_field("data:{\"ok\":true}", "data"),
Some("{\"ok\":true}")
);
assert_eq!(
super::strip_sse_field("event: message_start", "event"),
Some("message_start")
);
assert_eq!(
super::strip_sse_field("event:message_start", "event"),
Some("message_start")
);
}
}
+154 -30
View File
@@ -5,16 +5,19 @@
use super::{
handler_config::UsageParserConfig,
handler_context::{RequestContext, StreamingTimeoutConfig},
hyper_client::ProxyResponse,
server::ProxyState,
sse::strip_sse_field,
usage::parser::TokenUsage,
ProxyError,
};
use axum::http::header::HeaderMap;
use axum::response::{IntoResponse, Response};
use bytes::Bytes;
use futures::stream::{Stream, StreamExt};
use reqwest::header::HeaderMap;
use serde_json::Value;
use std::{
io::Read,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
@@ -23,24 +26,123 @@ use std::{
};
use tokio::sync::Mutex;
// ============================================================================
// 响应解压
// ============================================================================
/// 根据 content-encoding 解压响应体字节
///
/// reqwest 自动解压已禁用(为了透传 accept-encoding),需要手动解压。
fn decompress_body(content_encoding: &str, body: &[u8]) -> Result<Vec<u8>, std::io::Error> {
match content_encoding {
"gzip" | "x-gzip" => {
let mut decoder = flate2::read::GzDecoder::new(body);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed)?;
Ok(decompressed)
}
"deflate" => {
let mut decoder = flate2::read::DeflateDecoder::new(body);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed)?;
Ok(decompressed)
}
"br" => {
let mut decompressed = Vec::new();
brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decompressed)?;
Ok(decompressed)
}
_ => {
log::warn!("未知的 content-encoding: {content_encoding},跳过解压");
Ok(body.to_vec())
}
}
}
/// 从响应头提取 content-encoding(忽略 identity 和 chunked
fn get_content_encoding(headers: &HeaderMap) -> Option<String> {
headers
.get("content-encoding")
.and_then(|v| v.to_str().ok())
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty() && s != "identity")
}
/// 移除在重建响应体后会失真的实体头。
pub(crate) fn strip_entity_headers_for_rebuilt_body(headers: &mut HeaderMap) {
headers.remove(axum::http::header::CONTENT_ENCODING);
headers.remove(axum::http::header::CONTENT_LENGTH);
headers.remove(axum::http::header::TRANSFER_ENCODING);
}
/// 读取响应体并在需要时解压,确保 headers 与返回 body 一致。
///
/// `body_timeout`: 整包超时。当非零时用 `tokio::time::timeout` 包住 `.bytes()` 调用,
/// 防止上游发完响应头后卡住 body 导致请求永远挂住。
/// 传入 `Duration::ZERO` 表示不启用超时(故障转移关闭时)。
pub(crate) async fn read_decoded_body(
response: ProxyResponse,
tag: &str,
body_timeout: Duration,
) -> Result<(HeaderMap, http::StatusCode, Bytes), ProxyError> {
let mut headers = response.headers().clone();
let status = response.status();
let raw_bytes = if body_timeout.is_zero() {
response.bytes().await?
} else {
tokio::time::timeout(body_timeout, response.bytes())
.await
.map_err(|_| {
ProxyError::Timeout(format!(
"响应体读取超时: {}s(上游发完响应头后 body 未到达)",
body_timeout.as_secs()
))
})??
};
log::debug!(
"[{tag}] 已接收上游响应体: status={}, bytes={}, headers={}",
status.as_u16(),
raw_bytes.len(),
format_headers(&headers)
);
let mut body_bytes = raw_bytes.clone();
let mut decoded = false;
if let Some(encoding) = get_content_encoding(&headers) {
log::debug!("[{tag}] 解压非流式响应: content-encoding={encoding}");
match decompress_body(&encoding, &raw_bytes) {
Ok(decompressed) => {
body_bytes = Bytes::from(decompressed);
decoded = true;
}
Err(e) => {
log::warn!("[{tag}] 解压失败 ({encoding}): {e},使用原始数据");
}
}
}
if decoded {
strip_entity_headers_for_rebuilt_body(&mut headers);
}
Ok((headers, status, body_bytes))
}
// ============================================================================
// 公共接口
// ============================================================================
/// 检测响应是否为 SSE 流式响应
#[inline]
pub fn is_sse_response(response: &reqwest::Response) -> bool {
response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(|ct| ct.contains("text/event-stream"))
.unwrap_or(false)
pub fn is_sse_response(response: &ProxyResponse) -> bool {
response.is_sse()
}
/// 处理流式响应
pub async fn handle_streaming(
response: reqwest::Response,
response: ProxyResponse,
ctx: &RequestContext,
state: &ProxyState,
parser_config: &UsageParserConfig,
@@ -52,6 +154,15 @@ pub async fn handle_streaming(
status.as_u16(),
format_headers(response.headers())
);
// 检查流式响应是否被压缩(SSE 通常不压缩,如果压缩则 SSE 解析会失败)
if let Some(encoding) = get_content_encoding(response.headers()) {
log::warn!(
"[{}] 流式响应含 content-encoding={encoding}SSE 解析可能失败。\
accept-encoding SSE ",
ctx.tag
);
}
let mut builder = axum::response::Response::builder().status(status);
// 复制响应头
@@ -60,9 +171,7 @@ pub async fn handle_streaming(
}
// 创建字节流
let stream = response
.bytes_stream()
.map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string())));
let stream = response.bytes_stream();
// 创建使用量收集器
let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config);
@@ -86,26 +195,20 @@ pub async fn handle_streaming(
/// 处理非流式响应
pub async fn handle_non_streaming(
response: reqwest::Response,
response: ProxyResponse,
ctx: &RequestContext,
state: &ProxyState,
parser_config: &UsageParserConfig,
) -> Result<Response, ProxyError> {
let response_headers = response.headers().clone();
let status = response.status();
// 读取响应体
let body_bytes = response.bytes().await.map_err(|e| {
log::error!("[{}] 读取响应失败: {e}", ctx.tag);
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
})?;
log::debug!(
"[{}] 已接收上游响应体: status={}, bytes={}, headers={}",
ctx.tag,
status.as_u16(),
body_bytes.len(),
format_headers(&response_headers)
);
// 整包超时:仅在故障转移开启且配置值非零时生效
let body_timeout =
if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 {
Duration::from_secs(ctx.app_config.non_streaming_timeout as u64)
} else {
Duration::ZERO
};
let (response_headers, status, body_bytes) =
read_decoded_body(response, ctx.tag, body_timeout).await?;
log::debug!(
"[{}] 上游响应体内容: {}",
@@ -189,7 +292,7 @@ pub async fn handle_non_streaming(
///
/// 根据响应类型自动选择流式或非流式处理
pub async fn process_response(
response: reqwest::Response,
response: ProxyResponse,
ctx: &RequestContext,
state: &ProxyState,
parser_config: &UsageParserConfig,
@@ -527,7 +630,7 @@ pub fn create_logged_passthrough_stream(
if !event_text.trim().is_empty() {
// 提取 data 部分并尝试解析为 JSON
for line in event_text.lines() {
if let Some(data) = line.strip_prefix("data: ") {
if let Some(data) = strip_sse_field(line, "data") {
if data.trim() != "[DONE]" {
if let Ok(json_value) = serde_json::from_str::<Value>(data) {
if let Some(c) = &collector {
@@ -591,6 +694,27 @@ mod tests {
use std::sync::Arc;
use tokio::sync::RwLock;
#[test]
fn test_strip_sse_field_accepts_optional_space() {
assert_eq!(
super::strip_sse_field("data: {\"ok\":true}", "data"),
Some("{\"ok\":true}")
);
assert_eq!(
super::strip_sse_field("data:{\"ok\":true}", "data"),
Some("{\"ok\":true}")
);
assert_eq!(
super::strip_sse_field("event: message_start", "event"),
Some("message_start")
);
assert_eq!(
super::strip_sse_field("event:message_start", "event"),
Some("message_start")
);
assert_eq!(super::strip_sse_field("id:1", "data"), None);
}
fn build_state(db: Arc<Database>) -> ProxyState {
ProxyState {
db: db.clone(),
+76 -7
View File
@@ -1,6 +1,12 @@
//! HTTP代理服务器
//!
//! 基于Axum的HTTP服务器,处理代理请求
//!
//! Uses a manual hyper HTTP/1.1 accept loop with `preserve_header_case(true)` so
//! that the original header-name casing from the CLI client is captured in a
//! `HeaderCaseMap` extension. This map is later forwarded to the upstream via
//! the hyper-based HTTP client, producing wire-level header casing identical to
//! a direct (non-proxied) CLI request.
use super::{
failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv,
@@ -12,6 +18,7 @@ use axum::{
routing::{get, post},
Router,
};
use hyper_util::rt::TokioIo;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::{oneshot, RwLock};
@@ -114,15 +121,77 @@ impl ProxyServer {
// 记录启动时间
*self.state.start_time.write().await = Some(std::time::Instant::now());
// 启动服务器
// 启动服务器 — 使用手动 hyper HTTP/1.1 accept loop
// 开启 preserve_header_case 以捕获客户端请求头的原始大小写
let state = self.state.clone();
let handle = tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async {
shutdown_rx.await.ok();
})
.await
.ok();
let mut shutdown_rx = shutdown_rx;
loop {
tokio::select! {
result = listener.accept() => {
let (stream, _remote_addr) = match result {
Ok(v) => v,
Err(e) => {
log::error!("[{SRV}] accept 失败: {e}", SRV = log_srv::ACCEPT_ERR);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
continue;
}
};
let app = app.clone();
tokio::spawn(async move {
// Peek raw TCP bytes to capture original header casing
// before hyper parses (and lowercases) the header names.
let original_cases = {
let mut peek_buf = vec![0u8; 8192];
match stream.peek(&mut peek_buf).await {
Ok(n) => {
let cases = super::hyper_client::OriginalHeaderCases::from_raw_bytes(&peek_buf[..n]);
log::debug!(
"[ProxyServer] Peeked {} bytes, captured {} header casings",
n, cases.cases.len()
);
cases
}
Err(e) => {
log::debug!("[ProxyServer] peek failed (non-fatal): {e}");
super::hyper_client::OriginalHeaderCases::default()
}
}
};
// service_fn 将 axum Routertower::Service)桥接到 hyper
let service = hyper::service::service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
let mut router = app.clone();
let cases = original_cases.clone();
async move {
// 将 hyper::body::Incoming 转为 axum::body::Body,保留 extensions
let (mut parts, body) = req.into_parts();
// Insert our own header case map alongside hyper's internal one
parts.extensions.insert(cases);
let body = axum::body::Body::new(body);
let axum_req = http::Request::from_parts(parts, body);
<Router as tower::Service<http::Request<axum::body::Body>>>::call(&mut router, axum_req).await
}
});
if let Err(e) = hyper::server::conn::http1::Builder::new()
.preserve_header_case(true)
.serve_connection(TokioIo::new(stream), service)
.await
{
// Connection reset / broken pipe 等在代理场景下很常见,debug 级别
log::debug!("[{SRV}] connection error: {e}", SRV = log_srv::CONN_ERR);
}
});
}
_ = &mut shutdown_rx => {
break;
}
}
}
// 服务器停止后更新状态
state.status.write().await.running = false;
+31
View File
@@ -0,0 +1,31 @@
#[inline]
pub(crate) fn strip_sse_field<'a>(line: &'a str, field: &str) -> Option<&'a str> {
line.strip_prefix(&format!("{field}: "))
.or_else(|| line.strip_prefix(&format!("{field}:")))
}
#[cfg(test)]
mod tests {
use super::strip_sse_field;
#[test]
fn strip_sse_field_accepts_optional_space() {
assert_eq!(
strip_sse_field("data: {\"ok\":true}", "data"),
Some("{\"ok\":true}")
);
assert_eq!(
strip_sse_field("data:{\"ok\":true}", "data"),
Some("{\"ok\":true}")
);
assert_eq!(
strip_sse_field("event: message_start", "event"),
Some("message_start")
);
assert_eq!(
strip_sse_field("event:message_start", "event"),
Some("message_start")
);
assert_eq!(strip_sse_field("id:1", "data"), None);
}
}
+42
View File
@@ -0,0 +1,42 @@
//! Per-app switch lock
//!
//! 确保同一应用同时只有一个供应商切换操作在执行,
//! 防止并发切换导致 is_current 与 Live 备份不一致。
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{Mutex, OwnedMutexGuard, RwLock};
/// 每个应用类型一把互斥锁,保证同一应用的切换操作串行执行。
///
/// 不同应用之间(如 Claude 和 Codex)可以并行切换。
#[derive(Clone, Default)]
pub struct SwitchLockManager {
locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
}
impl SwitchLockManager {
pub fn new() -> Self {
Self::default()
}
/// 获取指定应用的切换锁。
///
/// 返回 `OwnedMutexGuard`,持有期间同一 `app_type` 的其他切换会排队等待。
pub async fn lock_for_app(&self, app_type: &str) -> OwnedMutexGuard<()> {
let lock = {
let locks = self.locks.read().await;
if let Some(lock) = locks.get(app_type) {
lock.clone()
} else {
drop(locks);
let mut locks = self.locks.write().await;
locks
.entry(app_type.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
};
lock.lock_owned().await
}
}
+2 -2
View File
@@ -25,7 +25,7 @@ pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
}
if model.contains("opus-4-6") || model.contains("sonnet-4-6") {
log::info!("[OPT] thinking: adaptive({})", model);
log::info!("[OPT] thinking: adaptive({model})");
body["thinking"] = json!({"type": "adaptive"});
body["output_config"] = json!({"effort": "max"});
append_beta(body, "context-1m-2025-08-07");
@@ -33,7 +33,7 @@ pub fn optimize(body: &mut Value, config: &OptimizerConfig) {
}
// legacy path
log::info!("[OPT] thinking: legacy({})", model);
log::info!("[OPT] thinking: legacy({model})");
let max_tokens = body
.get("max_tokens")
+12 -2
View File
@@ -165,8 +165,18 @@ impl McpService {
pub fn sync_all_enabled(state: &AppState) -> Result<(), AppError> {
let servers = Self::get_all_servers(state)?;
for server in servers.values() {
Self::sync_server_to_apps(state, server)?;
for app in AppType::all() {
if matches!(app, AppType::OpenClaw) {
continue;
}
for server in servers.values() {
if server.apps.is_enabled_for(&app) {
Self::sync_server_to_app(state, server, &app)?;
} else {
Self::remove_server_from_app(state, &server.id, &app)?;
}
}
}
Ok(())
+77 -24
View File
@@ -21,33 +21,41 @@ type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>);
// ── Variant descriptor ─────────────────────────────────────────
pub struct OmoVariant {
pub filename: &'static str,
pub preferred_filename: &'static str,
pub config_candidates: &'static [&'static str],
pub category: &'static str,
pub provider_prefix: &'static str,
pub plugin_name: &'static str,
pub plugin_prefix: &'static str,
pub plugin_prefixes: &'static [&'static str],
pub has_categories: bool,
pub label: &'static str,
pub import_label: &'static str,
}
pub const STANDARD: OmoVariant = OmoVariant {
filename: "oh-my-opencode.jsonc",
preferred_filename: "oh-my-openagent.jsonc",
config_candidates: &[
"oh-my-openagent.jsonc",
"oh-my-openagent.json",
"oh-my-opencode.jsonc",
"oh-my-opencode.json",
],
category: "omo",
provider_prefix: "omo-",
plugin_name: "oh-my-opencode@latest",
plugin_prefix: "oh-my-opencode",
plugin_name: "oh-my-openagent@latest",
plugin_prefixes: &["oh-my-openagent", "oh-my-opencode"],
has_categories: true,
label: "OMO",
import_label: "Imported",
};
pub const SLIM: OmoVariant = OmoVariant {
filename: "oh-my-opencode-slim.jsonc",
preferred_filename: "oh-my-opencode-slim.jsonc",
config_candidates: &["oh-my-opencode-slim.jsonc", "oh-my-opencode-slim.json"],
category: "omo-slim",
provider_prefix: "omo-slim-",
plugin_name: "oh-my-opencode-slim@latest",
plugin_prefix: "oh-my-opencode-slim",
plugin_prefixes: &["oh-my-opencode-slim"],
has_categories: false,
label: "OMO Slim",
import_label: "Imported Slim",
@@ -60,22 +68,27 @@ pub struct OmoService;
impl OmoService {
// ── Path helpers ────────────────────────────────────────
fn config_candidates(v: &OmoVariant, base_dir: &Path) -> Vec<PathBuf> {
v.config_candidates
.iter()
.map(|name| base_dir.join(name))
.collect()
}
fn find_existing_config_path(v: &OmoVariant, base_dir: &Path) -> Option<PathBuf> {
Self::config_candidates(v, base_dir)
.into_iter()
.find(|path| path.exists())
}
fn config_path(v: &OmoVariant) -> PathBuf {
get_opencode_dir().join(v.filename)
let base_dir = get_opencode_dir();
Self::find_existing_config_path(v, &base_dir)
.unwrap_or_else(|| base_dir.join(v.preferred_filename))
}
fn resolve_local_config_path(v: &OmoVariant) -> Result<PathBuf, AppError> {
let config_path = Self::config_path(v);
if config_path.exists() {
return Ok(config_path);
}
let json_path = config_path.with_extension("json");
if json_path.exists() {
return Ok(json_path);
}
Err(AppError::OmoConfigNotFound)
Self::find_existing_config_path(v, &get_opencode_dir()).ok_or(AppError::OmoConfigNotFound)
}
fn read_jsonc_object(path: &Path) -> Result<Map<String, Value>, AppError> {
@@ -123,12 +136,18 @@ impl OmoService {
// ── Public API (variant-parameterized) ─────────────────
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
let config_path = Self::config_path(v);
if config_path.exists() {
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
log::info!("{} config file deleted: {config_path:?}", v.label);
let base_dir = get_opencode_dir();
let mut deleted_paths = Vec::new();
for config_path in Self::config_candidates(v, &base_dir) {
if config_path.exists() {
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
deleted_paths.push(config_path);
}
}
crate::opencode_config::remove_plugin_by_prefix(v.plugin_prefix)?;
if !deleted_paths.is_empty() {
log::info!("{} config files deleted: {deleted_paths:?}", v.label);
}
crate::opencode_config::remove_plugins_by_prefixes(v.plugin_prefixes)?;
Ok(())
}
@@ -451,4 +470,38 @@ mod tests {
assert!(obj.contains_key("agents"));
assert!(obj.contains_key("disabled_agents"));
}
#[test]
fn test_find_existing_config_prefers_new_name_over_old() {
let dir = tempfile::tempdir().unwrap();
let old_path = dir.path().join("oh-my-opencode.jsonc");
let new_path = dir.path().join("oh-my-openagent.jsonc");
// Create both old and new files
std::fs::write(&old_path, r#"{"agents":{}}"#).unwrap();
std::fs::write(&new_path, r#"{"agents":{}}"#).unwrap();
let found = OmoService::find_existing_config_path(&STANDARD, dir.path());
assert_eq!(
found.unwrap(),
new_path,
"When both old and new config files exist, the new name (oh-my-openagent) must be preferred"
);
}
#[test]
fn test_find_existing_config_falls_back_to_old_name() {
let dir = tempfile::tempdir().unwrap();
let old_path = dir.path().join("oh-my-opencode.jsonc");
// Only old file exists
std::fs::write(&old_path, r#"{"agents":{}}"#).unwrap();
let found = OmoService::find_existing_config_path(&STANDARD, dir.path());
assert_eq!(
found.unwrap(),
old_path,
"When only the old config file exists, it should still be found"
);
}
}
+17 -6
View File
@@ -461,19 +461,18 @@ fn apply_common_config_to_settings(
}
}
pub(crate) fn write_live_with_common_config(
pub(crate) fn build_effective_settings_with_common_config(
db: &Database,
app_type: &AppType,
provider: &Provider,
) -> Result<(), AppError> {
) -> Result<Value, AppError> {
let snippet = db.get_config_snippet(app_type.as_str())?;
let mut effective_provider = provider.clone();
let mut effective_settings = provider.settings_config.clone();
if provider_uses_common_config(app_type, provider, snippet.as_deref()) {
if let Some(snippet_text) = snippet.as_deref() {
match apply_common_config_to_settings(app_type, &provider.settings_config, snippet_text)
{
Ok(settings) => effective_provider.settings_config = settings,
match apply_common_config_to_settings(app_type, &effective_settings, snippet_text) {
Ok(settings) => effective_settings = settings,
Err(err) => {
log::warn!(
"Failed to apply common config for {} provider '{}': {err}",
@@ -485,6 +484,18 @@ pub(crate) fn write_live_with_common_config(
}
}
Ok(effective_settings)
}
pub(crate) fn write_live_with_common_config(
db: &Database,
app_type: &AppType,
provider: &Provider,
) -> Result<(), AppError> {
let mut effective_provider = provider.clone();
effective_provider.settings_config =
build_effective_settings_with_common_config(db, app_type, provider)?;
write_live_snapshot(app_type, &effective_provider)
}
+64 -24
View File
@@ -28,8 +28,9 @@ pub use live::{
// Internal re-exports (pub(crate))
pub(crate) use live::sanitize_claude_settings_for_live;
pub(crate) use live::{
normalize_provider_common_config_for_storage, strip_common_config_from_live_settings,
sync_current_provider_for_app_to_live, write_live_with_common_config,
build_effective_settings_with_common_config, normalize_provider_common_config_for_storage,
strip_common_config_from_live_settings, sync_current_provider_for_app_to_live,
write_live_with_common_config,
};
// Internal re-exports
@@ -479,32 +480,12 @@ impl ProviderService {
id
);
// 获取新供应商的完整配置(用于更新备份)
let provider = providers
.get(id)
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
// Update database is_current
state.db.set_current_provider(app_type.as_str(), id)?;
// Update local settings for consistency
crate::settings::set_current_provider(&app_type, Some(id))?;
// 更新 Live 备份(确保代理关闭时恢复正确的供应商配置)
futures::executor::block_on(
state
.proxy_service
.update_live_backup_from_provider(app_type.as_str(), provider),
.hot_switch_provider(app_type.as_str(), id),
)
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
// 关键修复:接管模式下切换供应商不会写回 Live 配置,
// 需要主动清理 Claude Live 中的“模型覆盖”字段,避免仍以旧模型名发起请求。
if matches!(app_type, AppType::Claude) {
if let Err(e) = state.proxy_service.cleanup_claude_model_overrides_in_live() {
log::warn!("清理 Claude Live 模型字段失败(不影响切换结果): {e}");
}
}
.map_err(|e| AppError::Message(format!("热切换失败: {e}")))?;
// Note: No Live config write, no MCP sync
// The proxy server will route requests to the new provider via is_current
@@ -613,6 +594,46 @@ impl ProviderService {
state: &AppState,
app_type: AppType,
) -> Result<(), AppError> {
if app_type.is_additive_mode() {
return sync_current_provider_for_app_to_live(state, &app_type);
}
let current_id =
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
Some(id) => id,
None => return Ok(()),
};
let providers = state.db.get_all_providers(app_type.as_str())?;
let Some(provider) = providers.get(&current_id) else {
return Ok(());
};
let takeover_enabled =
futures::executor::block_on(state.db.get_proxy_config_for_app(app_type.as_str()))
.map(|config| config.enabled)
.unwrap_or(false);
let has_live_backup =
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
.ok()
.flatten()
.is_some();
let live_taken_over = state
.proxy_service
.detect_takeover_in_live_config_for_app(&app_type);
if takeover_enabled && (has_live_backup || live_taken_over) {
futures::executor::block_on(
state
.proxy_service
.update_live_backup_from_provider(app_type.as_str(), provider),
)
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
return Ok(());
}
sync_current_provider_for_app_to_live(state, &app_type)
}
@@ -670,6 +691,25 @@ impl ProviderService {
Ok(())
}
pub fn migrate_legacy_common_config_usage_if_needed(
state: &AppState,
app_type: AppType,
) -> Result<(), AppError> {
if app_type.is_additive_mode() {
return Ok(());
}
let Some(snippet) = state.db.get_config_snippet(app_type.as_str())? else {
return Ok(());
};
if snippet.trim().is_empty() {
return Ok(());
}
Self::migrate_legacy_common_config_usage(state, app_type, &snippet)
}
/// Extract common config snippet from current provider
///
/// Extracts the current provider's configuration and removes provider-specific fields
+670 -147
View File
@@ -7,8 +7,11 @@ use crate::config::{get_claude_settings_path, read_json_file, write_json_file};
use crate::database::Database;
use crate::provider::Provider;
use crate::proxy::server::ProxyServer;
use crate::proxy::switch_lock::SwitchLockManager;
use crate::proxy::types::*;
use crate::services::provider::write_live_with_common_config;
use crate::services::provider::{
build_effective_settings_with_common_config, write_live_with_common_config,
};
use serde_json::{json, Value};
use std::str::FromStr;
use std::sync::Arc;
@@ -17,7 +20,7 @@ use tokio::sync::RwLock;
/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)
const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED";
/// 代理接管模式下需要从 Claude Live 配置中移除的模型覆盖字段。
/// 代理接管模式下需要从 Claude Live 配置中移除的"模型覆盖"字段。
///
/// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段,
/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。
@@ -37,6 +40,12 @@ pub struct ProxyService {
server: Arc<RwLock<Option<ProxyServer>>>,
/// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新
app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,
switch_locks: SwitchLockManager,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct HotSwitchOutcome {
pub logical_target_changed: bool,
}
impl ProxyService {
@@ -45,12 +54,13 @@ impl ProxyService {
db,
server: Arc::new(RwLock::new(None)),
app_handle: Arc::new(RwLock::new(None)),
switch_locks: SwitchLockManager::new(),
}
}
/// 清理接管模式下 Claude Live 配置中的模型覆盖字段。
///
/// 这可以避免接管开启后切换供应商仍使用旧模型的问题。
/// 这可以避免"接管开启后切换供应商仍使用旧模型"的问题。
/// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。
pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {
let mut config = self.read_claude_live()?;
@@ -1098,6 +1108,11 @@ impl ProxyService {
/// 恢复指定应用的 Live 配置(若无备份则不做任何操作)
async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {
let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;
self.restore_live_config_for_app_inner(app_type).await
}
async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {
match app_type {
AppType::Claude => {
if let Ok(Some(backup)) = self.db.get_live_backup("claude").await {
@@ -1157,10 +1172,19 @@ impl ProxyService {
async fn restore_live_config_for_app_with_fallback(
&self,
app_type: &AppType,
) -> Result<(), String> {
let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;
self.restore_live_config_for_app_with_fallback_inner(app_type)
.await
}
async fn restore_live_config_for_app_with_fallback_inner(
&self,
app_type: &AppType,
) -> Result<(), String> {
let app_type_str = app_type.as_str();
// 1) 优先从 Live 备份恢复(这是原始 Live的唯一可靠来源)
// 1) 优先从 Live 备份恢复(这是"原始 Live"的唯一可靠来源)
let backup = self
.db
.get_live_backup(app_type_str)
@@ -1179,7 +1203,7 @@ impl ProxyService {
return Ok(());
}
// 2.1) 优先从 SSOT(当前供应商)重建 Live(比清理字段更可用)
// 2.1) 优先从 SSOT(当前供应商)重建 Live(比"清理字段"更可用)
match self.restore_live_from_ssot_for_app(app_type) {
Ok(true) => {
log::info!("{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)");
@@ -1356,51 +1380,9 @@ impl ProxyService {
Ok(())
}
/// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)
fn remove_local_toml_base_url(toml_str: &str) -> String {
use toml_edit::DocumentMut;
let mut doc = match toml_str.parse::<DocumentMut>() {
Ok(doc) => doc,
Err(_) => return toml_str.to_string(),
};
let model_provider = doc
.get("model_provider")
.and_then(|item| item.as_str())
.map(str::to_string);
if let Some(provider_key) = model_provider {
if let Some(model_providers) = doc
.get_mut("model_providers")
.and_then(|v| v.as_table_mut())
{
if let Some(provider_table) = model_providers
.get_mut(provider_key.as_str())
.and_then(|v| v.as_table_mut())
{
let should_remove = provider_table
.get("base_url")
.and_then(|item| item.as_str())
.map(Self::is_local_proxy_url)
.unwrap_or(false);
if should_remove {
provider_table.remove("base_url");
}
}
}
}
// 兜底:清理顶层 base_url(仅当它看起来像本地代理地址)
let should_remove_root = doc
.get("base_url")
.and_then(|item| item.as_str())
.map(Self::is_local_proxy_url)
.unwrap_or(false);
if should_remove_root {
doc.as_table_mut().remove("base_url");
}
doc.to_string()
crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)
}
fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {
@@ -1457,7 +1439,7 @@ impl ProxyService {
Ok(())
}
/// 检测 Live 配置是否处于被接管的残留状态
/// 检测 Live 配置是否处于"被接管"的残留状态
///
/// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,
/// 启动流程可以据此触发恢复逻辑。
@@ -1528,21 +1510,48 @@ impl ProxyService {
app_type: &str,
provider: &Provider,
) -> Result<(), String> {
let backup_json = match app_type {
"claude" => {
// Claude: settings_config 直接作为备份
serde_json::to_string(&provider.settings_config)
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?
let _guard = self.switch_locks.lock_for_app(app_type).await;
self.update_live_backup_from_provider_inner(app_type, provider)
.await
}
/// 仅供已持有 per-app 切换锁的调用方使用。
async fn update_live_backup_from_provider_inner(
&self,
app_type: &str,
provider: &Provider,
) -> Result<(), String> {
let app_type_enum =
AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?;
let mut effective_settings =
build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)
.map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?;
if matches!(app_type_enum, AppType::Codex) {
let existing_backup = self
.db
.get_live_backup(app_type)
.await
.map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))?;
if let Some(existing_backup) = existing_backup {
let existing_value: Value = serde_json::from_str(&existing_backup.original_config)
.map_err(|e| format!("解析 {app_type} 现有备份失败: {e}"))?;
Self::preserve_codex_mcp_servers_in_backup(
&mut effective_settings,
&existing_value,
)?;
}
"codex" => {
// Codex: settings_config 包含 {"auth": ..., "config": ...},直接使用
serde_json::to_string(&provider.settings_config)
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?
}
"gemini" => {
// Gemini: 只提取 env 字段(与原始备份格式一致)
// proxy.rs 的 read_gemini_live() 返回 {"env": {...}}
let env_backup = if let Some(env) = provider.settings_config.get("env") {
}
let backup_json = match app_type_enum {
AppType::Claude => serde_json::to_string(&effective_settings)
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?,
AppType::Codex => serde_json::to_string(&effective_settings)
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?,
AppType::Gemini => {
// Gemini takeover 仅修改 .envsettings.json(含 mcpServers)保持原样。
let env_backup = if let Some(env) = effective_settings.get("env") {
json!({ "env": env })
} else {
json!({ "env": {} })
@@ -1550,7 +1559,9 @@ impl ProxyService {
serde_json::to_string(&env_backup)
.map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?
}
_ => return Err(format!("未知的应用类型: {app_type}")),
AppType::OpenCode | AppType::OpenClaw => {
return Err(format!("未知的应用类型: {app_type}"));
}
};
self.db
@@ -1562,101 +1573,152 @@ impl ProxyService {
Ok(())
}
pub async fn hot_switch_provider(
&self,
app_type: &str,
provider_id: &str,
) -> Result<HotSwitchOutcome, String> {
let _guard = self.switch_locks.lock_for_app(app_type).await;
let app_type_enum =
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
let provider = self
.db
.get_provider_by_id(provider_id, app_type)
.map_err(|e| format!("读取供应商失败: {e}"))?
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
let logical_target_changed =
crate::settings::get_effective_current_provider(&self.db, &app_type_enum)
.map_err(|e| format!("读取当前供应商失败: {e}"))?
.as_deref()
!= Some(provider_id);
let has_backup = self
.db
.get_live_backup(app_type_enum.as_str())
.await
.map_err(|e| format!("读取 {app_type} 备份失败: {e}"))?
.is_some();
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
let should_sync_backup = has_backup || live_taken_over;
self.db
.set_current_provider(app_type_enum.as_str(), provider_id)
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
.map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
if should_sync_backup {
self.update_live_backup_from_provider_inner(app_type, &provider)
.await?;
if matches!(app_type_enum, AppType::Claude) {
if let Err(e) = self.cleanup_claude_model_overrides_in_live() {
log::warn!("清理 Claude Live 模型字段失败(不影响热切换结果): {e}");
}
}
}
if let Some(server) = self.server.read().await.as_ref() {
server
.set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
.await;
}
Ok(HotSwitchOutcome {
logical_target_changed,
})
}
#[cfg(test)]
async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {
self.switch_locks.lock_for_app(app_type).await
}
fn preserve_codex_mcp_servers_in_backup(
target_settings: &mut Value,
existing_backup: &Value,
) -> Result<(), String> {
let target_obj = target_settings
.as_object_mut()
.ok_or_else(|| "Codex 备份必须是 JSON 对象".to_string())?;
let target_config = target_obj
.get("config")
.and_then(|v| v.as_str())
.unwrap_or("");
let mut target_doc = if target_config.trim().is_empty() {
toml_edit::DocumentMut::new()
} else {
target_config
.parse::<toml_edit::DocumentMut>()
.map_err(|e| format!("解析新的 Codex config.toml 失败: {e}"))?
};
let existing_config = existing_backup
.get("config")
.and_then(|v| v.as_str())
.unwrap_or("");
if existing_config.trim().is_empty() {
target_obj.insert("config".to_string(), json!(target_doc.to_string()));
return Ok(());
}
let existing_doc = existing_config
.parse::<toml_edit::DocumentMut>()
.map_err(|e| format!("解析现有 Codex 备份失败: {e}"))?;
if let Some(existing_mcp_servers) = existing_doc.get("mcp_servers") {
match target_doc.get_mut("mcp_servers") {
Some(target_mcp_servers) => {
if let (Some(target_table), Some(existing_table)) = (
target_mcp_servers.as_table_like_mut(),
existing_mcp_servers.as_table_like(),
) {
for (server_id, server_item) in existing_table.iter() {
if target_table.get(server_id).is_none() {
target_table.insert(server_id, server_item.clone());
}
}
} else {
log::warn!(
"Codex config contains a non-table mcp_servers section; skipping backup MCP merge"
);
}
}
None => {
target_doc["mcp_servers"] = existing_mcp_servers.clone();
}
}
}
target_obj.insert("config".to_string(), json!(target_doc.to_string()));
Ok(())
}
/// 代理模式下切换供应商(热切换,不写 Live)
pub async fn switch_proxy_target(
&self,
app_type: &str,
provider_id: &str,
) -> Result<(), String> {
// 代理模式切换供应商(热切换):
// - 更新 SSOT(数据库 is_current
// - 同步本地 settings(设备级 current_provider_*
// - 若该应用正处于接管模式,则同步更新 Live 备份(用于停止代理时恢复)
let app_type_enum =
AppType::from_str(app_type).map_err(|_| format!("无效的应用类型: {app_type}"))?;
let outcome = self.hot_switch_provider(app_type, provider_id).await?;
self.db
.set_current_provider(app_type_enum.as_str(), provider_id)
.map_err(|e| format!("更新当前供应商失败: {e}"))?;
// 同步本地 settings(设备级优先)
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))
.map_err(|e| format!("更新本地当前供应商失败: {e}"))?;
// 仅在确实处于接管状态时才更新 Live 备份,避免无接管时误写覆盖 Live
let has_backup = self
.db
.get_live_backup(app_type_enum.as_str())
.await
.ok()
.flatten()
.is_some();
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);
if let Ok(Some(provider)) = self.db.get_provider_by_id(provider_id, app_type) {
// 同步更新 Live 备份(用于 stop_with_restore 恢复)
if has_backup || live_taken_over {
self.update_live_backup_from_provider(app_type, &provider)
.await?;
}
// 同步更新 ProxyStatus.active_targets(用于 UI 立即反映切换目标)
if let Some(server) = self.server.read().await.as_ref() {
server
.set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)
.await;
}
if outcome.logical_target_changed {
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
} else {
log::debug!("代理模式:{app_type} 已对齐到目标供应商 {provider_id}");
}
log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
Ok(())
}
// ==================== Live 配置读写辅助方法 ====================
/// 更新 TOML 字符串中的 base_url
/// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)
fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {
use toml_edit::DocumentMut;
let mut doc = match toml_str.parse::<DocumentMut>() {
Ok(doc) => doc,
Err(_) => return toml_str.to_string(),
};
// Codex 的 config.toml 通常是:
// model_provider = "any"
//
// [model_providers.any]
// base_url = "https://.../v1"
//
// 所以接管时要“精准”修改当前 model_provider 对应的 model_providers.<name>.base_url
// 避免写错位置导致 Codex 仍然走旧地址。
let model_provider = doc
.get("model_provider")
.and_then(|item| item.as_str())
.map(str::to_string);
if let Some(provider_key) = model_provider {
if doc.get("model_providers").is_none() {
doc["model_providers"] = toml_edit::table();
}
if let Some(model_providers) = doc["model_providers"].as_table_mut() {
if !model_providers.contains_key(&provider_key) {
model_providers[&provider_key] = toml_edit::table();
}
if let Some(provider_table) = model_providers[&provider_key].as_table_mut() {
provider_table["base_url"] = toml_edit::value(new_url);
return doc.to_string();
}
}
}
// 兜底:如果没有 model_provider 或结构不符合预期,则退回修改顶层 base_url。
doc["base_url"] = toml_edit::value(new_url);
doc.to_string()
crate::codex_config::update_codex_toml_field(toml_str, "base_url", new_url)
.unwrap_or_else(|_| toml_str.to_string())
}
fn read_claude_live(&self) -> Result<Value, String> {
@@ -1914,6 +1976,7 @@ impl ProxyService {
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::ProviderMeta;
use serial_test::serial;
use std::env;
use tempfile::TempDir;
@@ -2166,7 +2229,7 @@ model = "gpt-5.1-codex"
db.set_current_provider("claude", "a")
.expect("set current provider");
// 模拟已接管状态:存在 Live 备份(内容不重要,会被热切换更新)
// 模拟"已接管"状态:存在 Live 备份(内容不重要,会被热切换更新)
db.save_live_backup("claude", "{\"env\":{}}")
.await
.expect("seed live backup");
@@ -2191,4 +2254,464 @@ model = "gpt-5.1-codex"
let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
assert_eq!(backup.original_config, expected);
}
#[tokio::test]
#[serial]
async fn hot_switch_provider_serializes_same_app_switches() {
use tokio::time::{sleep, Duration};
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db.clone());
let provider_a = Provider::with_id(
"a".to_string(),
"A".to_string(),
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
None,
);
let provider_b = Provider::with_id(
"b".to_string(),
"B".to_string(),
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
None,
);
let provider_c = Provider::with_id(
"c".to_string(),
"C".to_string(),
json!({ "env": { "ANTHROPIC_API_KEY": "c-key" } }),
None,
);
db.save_provider("claude", &provider_a)
.expect("save provider a");
db.save_provider("claude", &provider_b)
.expect("save provider b");
db.save_provider("claude", &provider_c)
.expect("save provider c");
db.set_current_provider("claude", "a")
.expect("set current provider");
crate::settings::set_current_provider(&AppType::Claude, Some("a"))
.expect("set local current provider");
db.save_live_backup("claude", "{\"env\":{}}")
.await
.expect("seed live backup");
let guard = service.lock_switch_for_test("claude").await;
let service_for_b = service.clone();
let service_for_c = service.clone();
let switch_b = tokio::spawn(async move {
service_for_b
.hot_switch_provider("claude", "b")
.await
.expect("switch to b")
});
sleep(Duration::from_millis(20)).await;
let switch_c = tokio::spawn(async move {
service_for_c
.hot_switch_provider("claude", "c")
.await
.expect("switch to c")
});
sleep(Duration::from_millis(20)).await;
drop(guard);
let outcome_b = switch_b.await.expect("join switch b");
let outcome_c = switch_c.await.expect("join switch c");
assert!(outcome_b.logical_target_changed);
assert!(outcome_c.logical_target_changed);
assert_eq!(
crate::settings::get_effective_current_provider(&db, &AppType::Claude)
.expect("effective current"),
Some("c".to_string())
);
assert_eq!(
crate::settings::get_current_provider(&AppType::Claude).as_deref(),
Some("c")
);
assert_eq!(
db.get_current_provider("claude").expect("db current"),
Some("c".to_string())
);
let backup = db
.get_live_backup("claude")
.await
.expect("get live backup")
.expect("backup exists");
let expected = serde_json::to_string(&provider_c.settings_config).expect("serialize");
assert_eq!(backup.original_config, expected);
}
#[tokio::test]
#[serial]
async fn restore_waits_for_hot_switch_and_restores_latest_backup() {
use tokio::time::{sleep, Duration};
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db.clone());
let provider_a = Provider::with_id(
"a".to_string(),
"A".to_string(),
json!({ "env": { "ANTHROPIC_API_KEY": "a-key" } }),
None,
);
let provider_b = Provider::with_id(
"b".to_string(),
"B".to_string(),
json!({ "env": { "ANTHROPIC_API_KEY": "b-key" } }),
None,
);
db.save_provider("claude", &provider_a)
.expect("save provider a");
db.save_provider("claude", &provider_b)
.expect("save provider b");
db.set_current_provider("claude", "a")
.expect("set current provider");
crate::settings::set_current_provider(&AppType::Claude, Some("a"))
.expect("set local current provider");
db.save_live_backup(
"claude",
&serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"),
)
.await
.expect("seed live backup");
service
.write_claude_live(&json!({ "env": { "ANTHROPIC_API_KEY": "stale" } }))
.expect("seed live file");
let guard = service.lock_switch_for_test("claude").await;
let service_for_switch = service.clone();
let service_for_restore = service.clone();
let switch_to_b = tokio::spawn(async move {
service_for_switch
.hot_switch_provider("claude", "b")
.await
.expect("switch to b")
});
sleep(Duration::from_millis(20)).await;
let restore = tokio::spawn(async move {
service_for_restore
.restore_live_config_for_app_with_fallback(&AppType::Claude)
.await
.expect("restore claude live")
});
sleep(Duration::from_millis(20)).await;
drop(guard);
let outcome = switch_to_b.await.expect("join switch");
restore.await.expect("join restore");
assert!(outcome.logical_target_changed);
assert_eq!(
crate::settings::get_effective_current_provider(&db, &AppType::Claude)
.expect("effective current"),
Some("b".to_string())
);
let backup = db
.get_live_backup("claude")
.await
.expect("get live backup")
.expect("backup exists");
let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
assert_eq!(backup.original_config, expected);
assert_eq!(
service.read_claude_live().expect("read live"),
provider_b.settings_config
);
}
#[tokio::test]
#[serial]
async fn update_live_backup_from_provider_applies_claude_common_config() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
db.set_config_snippet(
"claude",
Some(
serde_json::json!({
"includeCoAuthoredBy": false
})
.to_string(),
),
)
.expect("set common config snippet");
let service = ProxyService::new(db.clone());
let mut provider = Provider::with_id(
"p1".to_string(),
"P1".to_string(),
json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "token",
"ANTHROPIC_BASE_URL": "https://claude.example"
}
}),
None,
);
provider.meta = Some(ProviderMeta {
common_config_enabled: Some(true),
..Default::default()
});
service
.update_live_backup_from_provider("claude", &provider)
.await
.expect("update live backup");
let backup = db
.get_live_backup("claude")
.await
.expect("get live backup")
.expect("backup exists");
let stored: Value =
serde_json::from_str(&backup.original_config).expect("parse backup json");
assert_eq!(
stored.get("includeCoAuthoredBy").and_then(|v| v.as_bool()),
Some(false),
"common config should be applied into Claude restore backup"
);
}
#[tokio::test]
#[serial]
async fn update_live_backup_from_provider_applies_codex_common_config() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
db.set_config_snippet(
"codex",
Some("disable_response_storage = true\n".to_string()),
)
.expect("set common config snippet");
let service = ProxyService::new(db.clone());
let mut provider = Provider::with_id(
"p1".to_string(),
"P1".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "token"
},
"config": r#"model_provider = "any"
model = "gpt-5"
[model_providers.any]
base_url = "https://codex.example/v1"
"#
}),
None,
);
provider.meta = Some(ProviderMeta {
common_config_enabled: Some(true),
..Default::default()
});
service
.update_live_backup_from_provider("codex", &provider)
.await
.expect("update live backup");
let backup = db
.get_live_backup("codex")
.await
.expect("get live backup")
.expect("backup exists");
let stored: Value =
serde_json::from_str(&backup.original_config).expect("parse backup json");
let config = stored
.get("config")
.and_then(|v| v.as_str())
.expect("config string");
assert!(
config.contains("disable_response_storage = true"),
"common config should be applied into Codex restore backup"
);
}
#[tokio::test]
#[serial]
async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db.clone());
db.save_live_backup(
"codex",
&serde_json::to_string(&json!({
"auth": {
"OPENAI_API_KEY": "old-token"
},
"config": r#"model_provider = "any"
model = "gpt-4"
[model_providers.any]
base_url = "https://old.example/v1"
[mcp_servers.echo]
command = "npx"
args = ["echo-server"]
"#
}))
.expect("serialize seed backup"),
)
.await
.expect("seed live backup");
let provider = Provider::with_id(
"p2".to_string(),
"P2".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "new-token"
},
"config": r#"model_provider = "any"
model = "gpt-5"
[model_providers.any]
base_url = "https://new.example/v1"
"#
}),
None,
);
service
.update_live_backup_from_provider("codex", &provider)
.await
.expect("update live backup");
let backup = db
.get_live_backup("codex")
.await
.expect("get live backup")
.expect("backup exists");
let stored: Value =
serde_json::from_str(&backup.original_config).expect("parse backup json");
let config = stored
.get("config")
.and_then(|v| v.as_str())
.expect("config string");
assert!(
config.contains("[mcp_servers.echo]"),
"existing Codex MCP section should survive proxy hot-switch backup update"
);
assert!(
config.contains("https://new.example/v1"),
"provider-specific base_url should still update to the new provider"
);
}
#[tokio::test]
#[serial]
async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db.clone());
db.save_live_backup(
"codex",
&serde_json::to_string(&json!({
"auth": {
"OPENAI_API_KEY": "old-token"
},
"config": r#"[mcp_servers.shared]
command = "old-command"
[mcp_servers.legacy]
command = "legacy-command"
"#
}))
.expect("serialize seed backup"),
)
.await
.expect("seed live backup");
let provider = Provider::with_id(
"p2".to_string(),
"P2".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "new-token"
},
"config": r#"[mcp_servers.shared]
command = "new-command"
[mcp_servers.latest]
command = "latest-command"
"#
}),
None,
);
service
.update_live_backup_from_provider("codex", &provider)
.await
.expect("update live backup");
let backup = db
.get_live_backup("codex")
.await
.expect("get live backup")
.expect("backup exists");
let stored: Value =
serde_json::from_str(&backup.original_config).expect("parse backup json");
let config = stored
.get("config")
.and_then(|v| v.as_str())
.expect("config string");
let parsed: toml::Value = toml::from_str(config).expect("parse merged codex config");
let mcp_servers = parsed
.get("mcp_servers")
.expect("mcp_servers should be present");
assert_eq!(
mcp_servers
.get("shared")
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str()),
Some("new-command"),
"new provider/common-config MCP definition should win on conflict"
);
assert_eq!(
mcp_servers
.get("legacy")
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str()),
Some("legacy-command"),
"backup-only MCP entries should still be preserved"
);
assert_eq!(
mcp_servers
.get("latest")
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str()),
Some("latest-command"),
"new MCP entries should remain in the restore backup"
);
}
}

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