Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21e695f68a | |||
| c678374c59 | |||
| d307df92e9 | |||
| 5092fe51ce | |||
| 34001aaffc | |||
| 780acfa7de | |||
| 6bda3b0131 | |||
| 89ff2d58d1 | |||
| 9a3d6a4e84 | |||
| fee354d09e | |||
| a5903d8600 | |||
| b7ad1c4bf8 | |||
| 0c46efe1be | |||
| 11572b1337 | |||
| 3bb17434fb | |||
| efbb52a3fc | |||
| b37a9e8f60 | |||
| 1992d6be72 | |||
| 276b2572a3 | |||
| cd8252c7d9 | |||
| 526bb60f5c | |||
| 4f8a79c273 | |||
| 6e519a7496 | |||
| a95b22dd79 | |||
| eab6bfd20c | |||
| 948d762792 | |||
| 4f355970e1 | |||
| 22ecd2d611 | |||
| a75f479576 | |||
| c701068f0c | |||
| c7efa77ad9 | |||
| 2d64d8c619 | |||
| d70e3828fe | |||
| 7bb59fa5a6 | |||
| 4f3e85fcd1 | |||
| a3598fd976 | |||
| c1aa6c3917 | |||
| daa5595f36 | |||
| 819c2e5dfe | |||
| a6d718d0fc | |||
| e776160912 | |||
| 596019505f | |||
| 8b925c2f2f | |||
| 25983f3420 | |||
| ff706e9e96 | |||
| e8b07cb2a5 | |||
| feea81e5bb | |||
| 4282856683 | |||
| 65d6929993 | |||
| 1ca01bcd10 | |||
| bc01f44514 | |||
| 3390fe7ea0 | |||
| cb01593f7d | |||
| 36a103bbe4 | |||
| 05bc14e82b | |||
| 0396cd5491 | |||
| f97347fe6e | |||
| 9ea303b224 | |||
| 4f911727d2 | |||
| edc597ab23 | |||
| 955ea26da9 | |||
| 5beb63e67d | |||
| fa17194d84 | |||
| f1118d370f | |||
| 4f5250fc4d | |||
| 5c36ae066b | |||
| f59fab6c24 | |||
| 6940a4b208 | |||
| ea6123adf7 | |||
| e96eab5278 | |||
| 2985ad2c14 | |||
| aa09c9cb62 | |||
| 27c41f7416 | |||
| 6716a4c408 | |||
| 2626eeebe6 | |||
| ab6266f745 | |||
| 1392ef6238 | |||
| 3cd9a0dec5 | |||
| 8e0e9ac319 | |||
| bda625a4f1 | |||
| 473f21971d | |||
| 03a9296c1f | |||
| 8e7d167ace | |||
| dadefdee77 | |||
| ad030da3b1 | |||
| 8047f95416 | |||
| 0527002cca | |||
| 2a24da517f | |||
| ea95f39adf | |||
| f5acef32fd | |||
| 084857ce25 | |||
| 6692343d1e | |||
| 33eafbad51 | |||
| c2337d6857 | |||
| ce538265cb | |||
| e458e77e30 | |||
| ae90b53454 | |||
| e891f5c876 | |||
| 73073454cd | |||
| 7811383b59 | |||
| c1dff06625 | |||
| 43ae1e5f2c | |||
| b4f262c7bd | |||
| 693c3872f0 | |||
| c67494bafc | |||
| 256b04999c | |||
| 25951d8132 | |||
| d66030bee6 | |||
| 5968336364 | |||
| b7499fc871 | |||
| aeaa016cae | |||
| 2a131a5572 | |||
| a04e72a267 | |||
| ce993baefa | |||
| d5328e5290 | |||
| 0fbba4267c | |||
| 8bf1660237 | |||
| afa09e127e | |||
| 0960fd7179 | |||
| 5ef72a2030 | |||
| e02a2763c2 | |||
| c9cadd6e09 | |||
| 60a9b330e5 | |||
| f4e2c28a2b | |||
| 0e6f2b395f | |||
| 41433cfab2 | |||
| 3f59ab3746 | |||
| ee69c83687 | |||
| 2683af57cb | |||
| 8f83fa2063 |
@@ -8,7 +8,7 @@ release/
|
||||
*.tsbuildinfo
|
||||
.npmrc
|
||||
CLAUDE.md
|
||||
# AGENTS.md
|
||||
AGENTS.md
|
||||
GEMINI.md
|
||||
/.claude
|
||||
/.codex
|
||||
|
||||
@@ -5,7 +5,154 @@ All notable changes to CC Switch will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
## [3.16.3] - 2026-06-14
|
||||
|
||||
Development since v3.16.2 focuses on getting usage accounting right end-to-end — billing route-takeover and format-conversion traffic by the real upstream model and pricing basis (schema v11), counting Claude Code Workflow sub-agent sessions, folding Claude Desktop into the Claude view, refreshing the model pricing seed, and reworking the usage dashboard with global provider/model filters, brand-icon toolbars, and far more resilient quota queries — while hardening the proxy (mislabeled SSE bodies, Codex image rectification, OAuth token and takeover-residue recovery, Hermes duplicate YAML keys), reworking provider configuration (a custom User-Agent override, a unified Codex advanced section, searchable preset selection, a Fable 5 tier, and refreshed Kimi/Unity2/Volcengine/MiniMax presets), and smoothing the update, About-panel, and provider-health experiences.
|
||||
|
||||
**Stats**: 59 commits | 130 files changed | +10,223 insertions | -4,232 deletions
|
||||
|
||||
### Added
|
||||
|
||||
- **Custom User-Agent Override**: Provider configs can now set a custom User-Agent that the proxy applies consistently across request forwarding, stream check, and model listing (`GET /v1/models`), so coding-plan upstreams that gate on UA no longer fail detection or return 403 while the proxy itself works. The Claude and Codex forms expose it in advanced settings with a curated presets dropdown (Claude Code / Kilo Code families that pass UA whitelists) and live non-blocking validation; stale custom UAs are dropped when switching to an official preset to avoid silently altering headers (#3671).
|
||||
- **Unified Codex Session History**: Official Codex sessions can now share a single resume-history bucket with cc-switch third-party sessions via an opt-in toggle under Settings → Codex App Enhancements, so the resume picker no longer hides them from each other. When enabled, the live `config.toml` routes official runs through a shared `custom` model_provider that mirrors the built-in OpenAI provider (`auth.json` is untouched); the toggle is forward-only by default but the enable dialog offers a checkbox to migrate existing official sessions (with per-generation backups), and the disable dialog offers a precise ledger-based restore that only reverts sessions originally recorded as `openai` while leaving sessions created during the toggle untouched.
|
||||
- **Dashboard-Wide Provider/Model Filters**: The provider and model filters move from inside the request-log table up to the top bar, applying globally to the hero summary, trend chart, request logs, and both stats tabs so you can scope the whole dashboard to a given source and model. Sources match by exact display name (so session placeholder rows like "Claude (Session)" are selectable) and models match by effective pricing model, with the model dropdown cascading from the selected source and both lists showing only options that have data in the current range.
|
||||
- **Refreshed Model Pricing Seed**: Added pricing for 9 models including Claude Fable 5, Grok 4.3, Mistral Medium 3.5 / Small 4, and Qwen 3.7 Max/Plus, and corrected 28 existing prices against current official vendor list pricing (GLM, Grok, MiMo, Doubao, Kimi, MiniMax, Mistral, Qwen) so usage cost estimates are accurate. Each change updates the seed for fresh installs and adds a guarded repair for existing databases without clobbering user-edited rows.
|
||||
- **Claude Fable 5 Model Tier**: Provider forms now expose `claude-fable-5` as a fourth model-mapping tier on both the Claude Code and Claude Desktop proxy paths, with a fable → opus → default fallback mirroring the official downgrade and the `fable-` prefix whitelisted for the Desktop 1.12603.1+ validator. A clarified four-language fallback hint warns that leaving a tier blank on third-party endpoints forwards the literal model name and 404s (#3980, #4026, #4049).
|
||||
- **Unity2.ai Partner Provider**: Added Unity2.ai, an AI API relay partner, as a preset across all seven managed apps (Claude Code, Codex, Gemini, OpenCode, OpenClaw, Claude Desktop, Hermes), each carrying the referral signup link and partner promotion copy in all four locales. Codex uses the bare base URL (the gateway exposes `/responses` at root) while OpenCode / OpenClaw / Hermes use the `/v1` chat-completions endpoint with `gpt-5.5`.
|
||||
- **Kimi K2.7 Code Model**: Added the `kimi-k2.7-code` model (in $0.95 / out $4.00 / cache-read $0.19 per 1M tokens, 256K context) and pointed all six official Moonshot Kimi presets (Claude Code, Codex, Claude Desktop, Hermes, OpenCode, OpenClaw) at it, renaming the OpenCode / OpenClaw presets to "Kimi K2.7 Code". The pricing seed applies on startup via the idempotent insert path, so existing users pick up the new pricing without a migration.
|
||||
- **Codex "Kimi For Coding" Preset Restored**: Re-added the Codex "Kimi For Coding" preset (`openai_chat`, `kimi-for-coding`, 256K context) with thinking mode enabled by default; it was previously removed because the coding endpoint rejects Codex's default `codex-cli` User-Agent with 403. It now works via proxy takeover combined with the custom User-Agent override (set to a whitelisted UA such as `claude-cli/*`).
|
||||
- **Pricing-Model Audit in Request Detail**: The request detail panel now shows the requested model and the pricing model when they differ from the response model, making route-takeover bills auditable directly from the usage UI.
|
||||
- **Preset Provider Search & Sorting**: The provider preset selector gains a searchable, sorted list with an inline search box (toggled via a magnifier icon, dismissed on ESC or outside click). Buttons use a responsive grid with consistent sizing and default icons, and search matches only provider display/raw names so URL fragments and shared category labels no longer produce noisy matches (#3975, #4183).
|
||||
- **Claude Mythos 5 Pricing**: Registered the `claude-mythos-5` model in the bundled model/pricing table (in $10 / out $50 per 1M tokens, cache read $1.00, cache write $12.50), so usage metering prices and displays it correctly (#4077).
|
||||
- **Fable 5 Verified Banner**: The Settings About page now displays a Fable 5 Verified banner beside the app name and version, marking this as a special build, with the version badge centered under the app name.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Claude Desktop Usage Folded Into Claude**: The dashboard no longer shows a standalone "Claude Desktop" bucket, which only ever displayed a partial number (Desktop chat usage never passes through the proxy and its Code-tab sessions write into the shared `~/.claude/projects` tree). Desktop proxy traffic is now folded into the `claude` view for display while still recorded under its own `app_type` for route-takeover billing audit, with the real value visible in the request detail panel.
|
||||
- **Lightweight Provider Health Check**: The provider health check no longer sends a real streaming model request (which many third-party providers blocked with 401/403/WAF, causing false negatives); it now performs a lightweight HTTP reachability probe of the provider `base_url`, treating any HTTP response as reachable and counting only DNS/connect/TLS/timeout as failure. The connectivity button is hidden for official providers (which use OAuth with an empty base URL and no reliable reachability target), the real-request confirmation dialog and test model/prompt fields are removed, and the degraded-latency threshold is set to 6s with an 8s timeout. The reachability check never resets the circuit breaker, so failover detection stays driven solely by real proxy traffic.
|
||||
- **Codex Advanced Options Section**: The Codex provider form now folds local routing, model mapping, reasoning overrides, and custom User-Agent into a single collapsible advanced section mirroring the Claude form (auto-expanding when a UA is set or local routing is on). Custom User-Agent is now also configurable for native Responses providers, where it was previously reachable only with `openai_chat` routing enabled.
|
||||
- **Usage Toolbar Refresh and Layout**: The app filter now renders brand icons (via ProviderIcon, with a grid icon for "All") instead of text tabs that wrapped awkwardly in narrow windows, and the usage hero shows the selected app's brand icon with Codex recolored to a neutral gray matching OpenAI's monochrome branding. The click-to-cycle refresh button becomes a Select with a localized "off" label, and the top-bar controls are compacted and aligned into consistent width groups with truncated long date-range labels.
|
||||
- **Faster About Panel Loading**: The Settings About panel now loads progressively: the app version badge appears the instant it resolves instead of waiting for tool probes, each tool card updates the moment its own version check finishes (probes run concurrently rather than sequentially), and results are cached for the app session with a 10-minute TTL so reopening the About tab reuses cached values and revalidates stale ones in the background instead of re-probing all six tools every time.
|
||||
- **Volcengine Ark Coding Plan Promo**: Updated the Volcengine Ark preset across all six apps with the new Coding Plan invite link (replacing the old Agent Plan / activity links) and refreshed the partner promotion copy in all four locales (two-month 75% off plus invite code 6J6FV5N2), correcting the product name from Agent Plan to Coding Plan.
|
||||
- **MiniMax Demoted to Regular Provider**: Removed the gold partner star badge and the API-key promotion banner for MiniMax by dropping the `isPartner` flag from all its presets; it stays as a regular `cn_official` provider keeping its icon and theme. The promotion copy is kept dormant so the partnership can be re-enabled with a single line.
|
||||
- **LemonData Removed, SudoCode Demoted**: Removed the LemonData provider preset entirely from all apps along with its promotion copy, icons, and sponsor listings, and demoted SudoCode from a partner to a regular `third_party` provider by dropping its `isPartner` flag and promotion copy (it keeps its icon).
|
||||
- **AtlasCloud Codex GLM 5.1 Context Window**: Declared the 200,000-token context window for the `zai-org/glm-5.1` model in the AtlasCloud Codex preset, matching the other GLM 5.1 preset entries.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Route-Takeover Traffic Billed by the Real Upstream Model**: When a request was routed to a different upstream (env model mapping, Claude Desktop routes, Copilot normalization, Codex chat override), the proxy used to attribute and price usage by whatever model the upstream echoed back, recording kimi/glm tokens as `claude-*` and overstating cost roughly 5–25×. The forwarder now captures the real outbound model, attributes usage by upstream-echo then outbound then client alias, persists the actual pricing basis on every row (schema v11), and keeps that basis through cost backfill and 30-day rollup pruning; Claude Desktop traffic is now logged under its own `app_type` so its pricing overrides apply.
|
||||
- **Usage Metering on Format-Conversion Proxy Paths**: Audited and fixed token/cache accounting across the proxy's format-conversion paths (Chat, Responses, and Gemini converted to Anthropic). The proxy now records the actually returned model, injects `stream_options.include_usage` so OpenAI-compatible upstreams emit usage in streaming, excludes `cache_read` and `cache_creation` from input on Claude←OpenAI paths to stop double-billing cache tokens, subtracts cached Gemini prompt tokens, still records fully-cached requests, and skips synthetic all-zero usage that previously inflated request counts (#2774).
|
||||
- **In-App Update No Longer Hangs on Restart**: Installing an update from within the app no longer freezes on the "restarting" screen, leaving the new version installed but requiring a manual force-quit. The download-install-restart chain now runs entirely in the backend (a new `install_update_and_restart` command) with platform-aware install ordering and single-instance-lock teardown before re-exec, instead of depending on the old WebView to keep running JS after the app bundle was already swapped; exit requests are also classified so restart requests fall through to Tauri's default flow rather than deadlocking on the window-state plugin mutex (#4069, #4074).
|
||||
- **Codex Upgrade No Longer Breaks the Install**: Upgrading Codex from the Settings "About" tab no longer leaves it throwing "Missing optional dependency @openai/codex-…" errors. The upgrade chain previously ran `codex update` first, which on an npm install is a bare reinstall that reports success even when the per-platform binary fails to land; Codex is now removed from the self-update-first path and a runnable check triggers an uninstall+reinstall self-heal (scoped to npm-managed installs) that actually re-lands the missing platform binary.
|
||||
- **Codex OAuth Auth Token Preserved on Proxy Takeover**: Enabling proxy takeover for a Codex provider no longer strips the `ANTHROPIC_AUTH_TOKEN` placeholder, which previously broke Claude Code's login on hot-switches, fresh installs, and configs already stripped by older releases. The placeholder is now injected unconditionally for managed (non-Copilot) Codex providers, including URL-only ones; GitHub Copilot behavior (API_KEY only) is unchanged (#3789, #3784).
|
||||
- **Takeover-Residue Recovery Across Config-Dir Switches**: Restarting the app after changing the config directory while proxy takeover is active no longer leaves Claude/Codex/Gemini pointed at a dead local proxy. The old instance now restores the taken-over live files before restarting, the first-run import refuses to persist a takeover placeholder as a provider, and SSOT restore validates that the current provider's config is free of placeholders before writing it back (#4076).
|
||||
- **Mislabeled SSE Bodies in Format-Transform Fallback**: Requests routed through Claude/Codex format conversion no longer fail with an opaque 422 "Failed to parse upstream response" when a MaaS gateway force-streams a `stream:false` request and returns an SSE body under a non-SSE Content-Type. The proxy now sniffs for SSE on parse failure, aggregates the chunks into a single JSON, and runs the existing converter so clients still get a valid non-stream response; remaining parse failures are enriched with content-type, encoding, and body-snippet diagnostics, and deflate decoding now tries zlib before raw (#2234).
|
||||
- **Duplicate YAML Keys in Hermes Config**: Hermes config writes no longer accumulate duplicate top-level keys (e.g. `mcp_servers`) that caused "Failed to parse Hermes config as YAML: duplicate entry with key" errors. Section replacement now strips all stale occurrences from the remainder instead of degrading into appends, the dedup safety net handles both LF and CRLF line endings, and healing keeps the last (newest) occurrence to match Hermes's own last-wins PyYAML semantics (#3267, #3633, #2973, #2529, #3310, #3762).
|
||||
- **Usage Query Resilience and Error Clarity**: Usage cards no longer flip to red on a single transient blip: queries now retry once and keep showing the last successful result for up to 10 minutes on network/timeout/5xx failures, while deterministic failures (auth, empty key, unknown provider, 4xx) surface immediately and clear the snapshot so a stale quota can't resurface after credentials change. Native balance/coding-plan/subscription timeouts were raised from 10s to 15s for slow cross-border endpoints, and coding-plan now returns explicit "API key is empty" / "Unknown coding plan provider" errors instead of a blank failure.
|
||||
- **Usage Script Provider Credential Resolution**: Custom JS-script usage queries resolved `{{apiKey}}` / `{{baseUrl}}` by guessing env fields only, so providers that store credentials elsewhere (e.g. Codex's `auth.OPENAI_API_KEY` plus `config.toml` base_url) always got empty values and failed despite being fully configured. Script queries and the test/preview now reuse the same per-app credential resolver as the native balance path, with explicit non-empty script values still taking precedence (#1479).
|
||||
- **Claude Code Workflow Sub-Agent Usage Counted**: Local (no-proxy) session-log usage accounting missed Claude Code Workflow sub-agent traffic, under-counting overall usage by roughly 4.1% (concentrated in workflow/subagent transcripts). The scanner now descends into the deeper `subagents/workflows/wf_*/` transcript directories, and the parser no longer drops billable assistant messages that lack a `stop_reason` but already incurred input/cache token cost; dedup is unchanged so no usage is double-counted.
|
||||
- **Codex Image Rectifier for /responses Text-Only Upstreams**: Codex `/responses` requests carrying images and routed to text-only OpenAI-chat models (e.g. DeepSeek `deepseek-v4-flash`) no longer fail with HTTP 400 "unknown variant `image_url`". The media rectifier now also covers the Codex adapter, scanning the responses `input` for `input_image` blocks so it can proactively strip images for known text-only models and reactively retry with images replaced on upstream image-unsupported errors.
|
||||
- **Zhipu Coding-Plan Quota Window Mislabeling**: The Zhipu coding-plan view no longer swaps the 5-hour and weekly quota buckets in the final hours of each weekly cycle. The two windows are now classified by the explicit `unit` field (3 = 5-hour, 6 = weekly) instead of by sorting reset-time ascending, which mislabeled them exactly when users check their weekly quota most; the old reset-time heuristic remains as a fallback (#3036).
|
||||
- **Duplicate Provider Terminal Sessions on macOS**: Launching a provider terminal on macOS no longer opens an extra empty window alongside the command session; Terminal.app uses `launch` (not `activate`) on cold start and Ghostty uses an initial-command so a single session opens, with a fallback retained if the AppleScript path fails (#4156).
|
||||
- **Claude Desktop Model-Mapping Placeholders**: The Claude Desktop model-mapping form previously showed mismatched example brands across the menu display name and request model columns (DeepSeek vs Kimi), implying a display name maps to an unrelated model. Both placeholders are now derived from each row's role so they stay brand-consistent, with the lightweight Haiku tier using a flash example.
|
||||
- **Popovers Behind Fullscreen Panels**: Popovers and tooltips such as the provider preset search no longer render behind fullscreen panels and appear unresponsive on click; their z-index is raised above the fullscreen overlay while staying below modal dialogs.
|
||||
- **ToggleRow Icon Shrinking**: Toggle row icons no longer shrink or distort when paired with long descriptions, keeping the icon at a fixed size next to multi-line text.
|
||||
|
||||
### Docs
|
||||
|
||||
- **Release Notes Contributor Mentions**: Restored contributor mentions in the v3.16.1 and v3.16.2 release notes across all three locales.
|
||||
|
||||
## [3.16.2] - 2026-06-07
|
||||
|
||||
Development since v3.16.1 focuses on broadening data portability and usage observability — S3-compatible cloud sync, OpenCode session usage import, and an opt-in official-subscription quota template — while hardening Codex Chat Completions routing (stream truncation, `tool_choice` / custom-tool / reasoning-token edge cases, file and audio attachments, and a Codex CLI models endpoint), strengthening proxy robustness (ephemeral ports, takeover/placeholder restore, system-message normalization, clearer upstream errors, and a text-only image fallback), fixing coding-plan quota lookups (Zhipu, MiniMax) and several Windows/macOS issues, adding the CherryIN and ZenMux providers, and refreshing the user manual.
|
||||
|
||||
**Stats**: 41 commits | 132 files changed | +11,116 insertions | -1,636 deletions
|
||||
|
||||
### Added
|
||||
|
||||
- **S3-Compatible Cloud Sync**: Cloud Sync now supports S3-compatible object storage as a second backend alongside WebDAV, using hand-rolled AWS Signature V4 signing for broad compatibility. The settings panel offers one-click presets for AWS S3, MinIO, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS, and Huawei OBS plus a custom endpoint, with connection testing, manual upload/download, and auto-sync on configuration changes (provider, endpoint, MCP, prompt, skill, settings, and proxy tables — not high-frequency data like usage logs); enabling S3 sync disables active WebDAV sync and vice versa (#1351).
|
||||
- **OpenCode Session Usage Sync**: Added OpenCode as a usage-statistics source that imports per-message token, cost, and model data from OpenCode's local SQLite database, with a new "OpenCode" app filter tab and an "OpenCode Session" data-source label. The database path respects `OPENCODE_DB` and `XDG_DATA_HOME` (defaulting to `~/.local/share/opencode` on all platforms), only finalized messages are imported, and the freshness check accounts for the WAL file so newly written sessions are not skipped (#3215).
|
||||
- **Official Subscription Quota Template**: Added an explicit, opt-in "official subscription" usage template for Claude, Codex, and Gemini official providers that queries plan quota via CLI/OAuth credentials, replacing the previous implicit auto-query. It is disabled by default and enabled from the usage-script modal with a configurable refresh interval.
|
||||
- **Unsupported Image Fallback Rectifier**: Added a proxy rectifier that replaces Anthropic image blocks with an `[Unsupported Image]` marker when the routed model is text-only (declared, or detected via a built-in model-name heuristic) or when the upstream rejects image input, so conversations are not interrupted. A new Settings toggle controls the fallback, with a separate toggle for the heuristic detection.
|
||||
- **ZenMux Token Plan Provider**: Added ZenMux as a Token Plan coding-plan provider that accepts a manually entered API key and base URL in the usage-script modal and renders its quota with USD-denominated used / limit values (#2709).
|
||||
- **CherryIN Preset**: Added the CherryIN aggregator gateway as a quick-config preset across all seven supported apps — Anthropic-format endpoint for Claude Code / Claude Desktop / OpenClaw / Hermes, `@ai-sdk/anthropic` for OpenCode, the OpenAI-compatible endpoint for Codex, and the Gemini-compatible endpoint for Gemini CLI — with the official brand icon, placed next to AiHubMix (#3643).
|
||||
- **CCSub Preset**: Added CCSub, a multi-model aggregator partner, as a quick-config preset across six apps — Claude Code, Claude Desktop, Codex, OpenCode, OpenClaw, and Hermes — with the official brand icon and the partner referral link prefilled as the API-key signup URL (`gpt-5.5` for the OpenAI-compatible Codex and OpenCode endpoints).
|
||||
- **Codex CLI Models Endpoint**: The local proxy now answers `GET /v1/models`, which Codex CLI probes at startup, returning the cc-switch-managed Codex model catalog. A stale-catalog guard parses the live `config.toml` and only serves the catalog when `model_catalog_json` still references the cc-switch-owned file, so a leftover catalog from a previous provider is not advertised (#3818).
|
||||
- **Codex Chat File and Audio Attachments**: The Codex Responses-to-Chat converter now maps `input_file` parts (carrying `file_id` or inline `file_data`) and `input_audio` parts into their Chat Completions equivalents, and emits top-level `input_*` items that were previously dropped, so file and audio attachments reach Chat-only Codex upstreams.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Usage Dashboard Hero Redesign**: Restructured the Usage Dashboard hero and summary cards into a more compact layout, consolidating the real-token total, request count, and cost into a single top row (#3426).
|
||||
- **SSSAiCode Endpoint Refresh**: Updated the SSSAiCode preset's website, signup, and API base URLs to the `sssaicodeapi.com` domain and refreshed its endpoint nodes (default `node-hk.sssaicodeapi.com`, plus `node-hk.sssaiapi.com` and `node-cf.sssaicodeapi.com`) across all seven app presets.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Codex Chat Truncated Stream Detection**: When a Chat Completions upstream ends a stream without a `finish_reason` or `[DONE]`, CC Switch no longer reports it as a normal completion — it finalizes normally only when the stream truly finished, emits an incomplete (`max_output_tokens`) response when partial output was produced, and emits a failed `stream_truncated` event when nothing was produced. Late-arriving reasoning is also attached to still-active streamed tool calls.
|
||||
- **Codex Chat `tool_choice` Without Tools**: The Responses-to-Chat converter now drops `tool_choice` and `parallel_tool_calls` whenever the resulting tools array is absent or empty, so strict OpenAI-compatible upstreams (vLLM, enterprise gateways) no longer reject the request with "When using `tool_choice`, `tools` must be set." (#3640).
|
||||
- **Codex Custom Tool Metadata Over Chat Routing**: Custom Codex tools (such as the freeform `apply_patch` tool) now preserve their full original definition — including format and grammar metadata — as a compact, order-stable JSON block in the generated Chat function description instead of a generic placeholder, keeping them usable on Chat Completions upstreams (#3644).
|
||||
- **Codex Chat `reasoning_tokens` in Usage**: The Chat-to-Responses usage conversion now always includes `output_tokens_details.reasoning_tokens` (defaulting to 0), even when a provider omits `completion_tokens_details` or returns it as a non-object, satisfying the Codex CLI's strict requirement and avoiding repeated parse failures and retries (#3514).
|
||||
- **Codex Cross-Turn Reasoning for Custom and Search Tools**: The cross-turn reasoning cache in Codex Chat history now covers the full tool-call set (`function_call`, `custom_tool_call`, `tool_search_call`) and their outputs, so `apply_patch` and tool-search calls keep their `reasoning_content` when restored via `previous_response_id`.
|
||||
- **Ephemeral Proxy Port Resolution**: When the proxy listens on port 0 (OS-assigned), takeover now starts the proxy first to learn the real port and writes it into the Live configs and database, so client URLs no longer point at a broken `:0` address; the Claude Desktop gateway URL is rejected if no concrete port has been resolved.
|
||||
- **Proxy Placeholder Backup/Restore Loop**: If a previous proxy stop left the proxy placeholders in Live, taking over again no longer overwrites a good backup with the proxy config, and restore no longer writes the placeholder back to Live — both paths detect the placeholder state and rebuild Live from the current provider, fixing cases where the proxy toggle became a no-op and clients stayed pinned to the local proxy (#3689).
|
||||
- **Official Provider Block Under Proxy Takeover**: While Local Routing takeover is active, only providers explicitly categorized as official are blocked from switching, instead of also disabling custom providers whose endpoint lives in metadata or whose fields are unfilled. The disabled Enable button now shows a lighter hint tooltip in place of the red "Blocked" badge.
|
||||
- **Localhost Listen Address Normalization**: Saving the proxy with a listen address of `localhost` now normalizes it to `127.0.0.1` before persisting, avoiding binding inconsistencies (#3016).
|
||||
- **Anthropic System Message Normalization**: For Anthropic-format providers, system-role entries inside the `messages` array are collapsed and merged into the top-level `system` field (preserving order and any existing top-level system), preventing strict upstreams from rejecting non-leading system messages; OpenAI Chat routing is untouched (#3775).
|
||||
- **Claude Desktop 1M-Context Model Routing**: Claude Desktop appends a `[1m]` marker to the model name when the 1M-context beta is active (e.g. `claude-opus-4-8[1m]`). The proxy now strips that suffix before route lookup so exact, alias, legacy, and role-keyword matching resolve correctly, fixing `route_unknown` (HTTP 400) failures when switching to a 1M-capable model mid-conversation.
|
||||
- **Codex 413 Error Clarity**: When a Codex upstream gateway rejects an oversized request with HTTP 413, the proxy now returns a dedicated message identifying it as the provider's server-side body-size limit (not a CC Switch limit) with recovery steps (run `/compact`, drop large logs or inline images, or ask the provider to raise its limit), instead of echoing the raw upstream HTML page.
|
||||
- **Proxy Panel Error Detail**: When toggling proxy takeover fails, the proxy panel toast now includes the underlying backend error detail instead of only a generic failure message (#3656).
|
||||
- **Copilot Infinite-Whitespace Threshold**: Raised the streaming infinite-whitespace abort threshold from 20 to 500 consecutive whitespace characters, so legitimate tool calls with deeply indented code arguments are no longer falsely aborted while still catching the real Copilot infinite-whitespace bug (#2647).
|
||||
- **Subscription Tier Tray Rendering**: Fixed tray and quota rendering for official subscription tiers via a unified tier-to-label mapping: Claude/Codex no longer drop the seven-day window, Gemini Pro/Flash/Flash-Lite tiers no longer leak raw machine names, and multi-window plans (e.g. Opus + Sonnet) now display the worst utilization instead of the first match.
|
||||
- **Inflated Claude Stream Input Tokens**: Some Anthropic-compatible streaming providers (e.g. Qwen, MiniMax) report the full context as `input_tokens` in `message_start`, double-counting the cached portion and artificially lowering the displayed cache hit rate. The parser now prefers a smaller positive `input_tokens` from `message_delta` and adopts the paired cache counts from the same usage block; native Claude and OpenRouter-converted paths are unchanged.
|
||||
- **Zhipu Quota Query Endpoint Routing**: The Zhipu coding-plan quota lookup was hard-coded to `api.z.ai`, so users on the mainland China preset (`open.bigmodel.cn`) could not retrieve usage when the international endpoint was unreachable. The quota request now routes to the host matching the user's configured base URL (#3702).
|
||||
- **MiniMax Balance API and Pricing**: Adapted MiniMax coding-plan quota to its new balance API (which returns remaining-percent fields instead of usage counts that broke the old parser and left the tray blank), filtered out non-coding models (e.g. video), handled plans without a weekly limit, and seeded default pricing for MiniMax M3 (#3518).
|
||||
- **GLM Coding Plan Endpoints and Model Fetch**: Corrected the ZhiPu / Z.AI GLM Coding Plan presets to the `/api/coding/paas/v4` endpoints across Codex, OpenCode, OpenClaw, and Hermes, and taught the model-list probe to query `{base}/models` for base URLs that already end in a `/v{N}` segment (keeping `/v1/models` as a fallback), so the Fetch Models button no longer 404s on versioned endpoints (#3524).
|
||||
- **Codex Model Catalog Path Portability**: Codex now writes only the relative filename `cc-switch-model-catalog.json` to `config.toml` instead of an absolute path (Codex CLI resolves it from the config directory), fixing the model catalog breaking on WSL and symlinked setups where the absolute path could not be translated (#3614).
|
||||
- **APINebula OpenCode SDK**: The APINebula OpenCode preset now loads `@ai-sdk/openai-compatible` instead of `@ai-sdk/openai`, so requests use the OpenAI Chat Completions format the relay expects rather than the Responses API.
|
||||
- **Windows Tray Icon Residue on Exit**: Quitting CC Switch on Windows could leave a dead tray icon until hovered; the app now removes the tray icon before exiting so it disappears cleanly (#3797).
|
||||
- **Windows Taskbar Icon**: Set an explicit Windows AppUserModelID at runtime and stamped the installer's desktop and start-menu shortcuts with the same ID and product icon, so CC Switch shows the correct icon and groups properly in the taskbar (#3457).
|
||||
- **Windows Subdirectory Skill Updates**: Normalized backslash path separators to forward slashes when scanning installed skills on Windows, so skills nested in subdirectories (e.g. `skills/my-skill`) are matched by the update check instead of being silently skipped (#3430).
|
||||
- **macOS Input Auto-Capitalization**: Disabled autocomplete, autocorrect, autocapitalize, and spellcheck on the shared text Input component so macOS no longer auto-capitalizes or auto-corrects the first letter typed into configuration fields (#3626).
|
||||
- **Codex VS Code Session Previews**: Codex session previews for requests sent from VS Code could show selection or open-file content instead of the prompt when a markdown heading preceded the injected request. Both the backend title and frontend preview now match the last "## My request for Codex:" heading (the IDE injects the real request as the final section) (#3593).
|
||||
- **VS Code Wording in Chinese UI**: Corrected the "Apply to Claude Code plugin" description in the Simplified and Traditional Chinese locales to write "VS Code" properly instead of "Vscode", aligning with the English and Japanese strings (#3228).
|
||||
|
||||
### Docs
|
||||
|
||||
- **User Manual Refresh**: Refreshed the README locales and the en / zh / ja user manuals to reflect all seven supported apps (adding Claude Desktop and Hermes), corrected the OpenCode config path to `~/.config/opencode/` (`opencode.json`), documented Hermes configuration files, updated the language docs to four languages, revised per-app MCP / Prompts / Skills availability, noted that export produces a timestamped SQL backup including usage logs, and documented the pricing model-ID matching rules (#3411).
|
||||
- **Codex Official Auth Preservation Guide**: Added a trilingual (en / zh / ja) guide explaining how to keep Codex official remote control and plugins working while routing model traffic to third-party APIs, and linked it from the v3.16.1 release notes.
|
||||
- **README Release-Note Links and Sponsor Markup**: Updated the Release Notes links in all README locales to point at v3.16.1 and fixed broken smart-quote characters in the README_ZH sponsor blocks so their HTML attributes render correctly (#3772).
|
||||
|
||||
## [3.16.1] - 2026-06-01
|
||||
|
||||
Development since v3.16.0 focuses on hardening Codex provider switching and Local Routing takeover: preserving official OAuth auth and model catalogs across normal switches, hot-switches, backup restore, and edit flows; restoring Codex Chat tool/plugin compatibility over Chat Completions upstreams; improving Codex proxy diagnostics and CLI discovery; and documenting DeepSeek routing.
|
||||
|
||||
**Stats**: 23 commits | 62 files changed | +5,603 insertions | -1,113 deletions
|
||||
|
||||
### Added
|
||||
|
||||
- **Codex Official Auth Preservation Setting**: Added an opt-in setting that keeps the official ChatGPT / Codex OAuth login in `auth.json` when switching third-party Codex providers, while moving third-party provider tokens into `config.toml` when enabled.
|
||||
- **Codex DeepSeek Routing Guides**: Added localized DeepSeek routing guides for Codex in English, Chinese, and Japanese, with screenshots covering provider routing requirements, Codex provider setup, and Local Routing takeover.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Codex Auth Preservation Is Opt-In**: The new official-auth preservation setting now defaults to off, so third-party Codex switches keep the legacy behavior of writing the active provider auth unless users explicitly enable preservation.
|
||||
- **Codex Provider Switch Restart Hint**: Successful Codex provider switches now tell users to restart the Codex client so catalog and config changes take effect.
|
||||
- **Codex Proxy Takeover Switching Is Serialized**: Provider switches and takeover toggles now share a per-app lock and use backup / live placeholder ownership signals instead of lagging `enabled` or server-running flags, preventing normal live writes from racing a just-activated or temporarily stopped takeover.
|
||||
- **Codex Takeover Hot-Switch Display Refresh**: Hot-switching a Codex provider while Local Routing owns Live now refreshes the proxy-safe live provider id, model, and display name while keeping endpoints pointed at the local proxy.
|
||||
- **Sponsor Ordering**: Swapped the Shengsuanyun and AICodeMirror sponsor blocks across README locales.
|
||||
- **Docs Organization**: Moved the Chinese proxy guide under `docs/guides/` and removed the obsolete working-directory plan document.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Codex Provider Edit Dialog Under Takeover**: The Codex provider edit form now shows an explicit notice and storage-aware auth / config hints clarifying that it displays the stored provider config (not the proxy-managed live `auth.json` / `config.toml`), so the official OAuth token is no longer mistaken as lost while takeover is active. The dialog also treats takeover as active regardless of whether the proxy server is currently running.
|
||||
- **Codex OAuth Auth During Proxy Takeover**: Fixed multiple preserve-mode takeover paths that could clear or overwrite the official ChatGPT / Codex OAuth `auth.json`. Takeover detection now recognizes `PROXY_MANAGED` in `config.toml`, cleanup only removes placeholder bearer tokens, config-only takeover writes are used consistently, and mis-categorized third-party providers no longer trigger the official-provider auth overwrite path. Provider sync and switching now treat the restore backup and live placeholders as the takeover-ownership signal (instead of the lagging `enabled` / proxy-running flags) and serialize switch/takeover per app, so a just-activated or proxy-stopped takeover can no longer be overwritten by a normal live write.
|
||||
- **Codex Model Catalog Data Loss**: Fixed cases where Codex `modelCatalog` could be wiped by live-config backfill, active-provider edit dialogs, provider switches, or proxy takeover-off restore. Snapshot backups now keep existing `model_catalog_json` pointers, provider-rebuilt backups regenerate the catalog projection from the database source of truth, active edit dialogs prefer the DB catalog over lossy Live reconstruction, and provider switches always refresh the generated catalog JSON.
|
||||
- **Codex Chat Tools Over Chat Completions Routing**: Restored Codex `tool_search`, loaded namespace tools, custom tools, and tool outputs when third-party Codex providers are routed through Chat Completions. Non-streaming and streaming Chat responses now map back to the right Responses item types, including native `response.custom_tool_call_input.*` events for custom-tool streaming.
|
||||
- **Codex Proxy Error Diagnostics**: Codex forwarding failures now return richer JSON errors with provider, model, endpoint, upstream status, stable `cc_switch_*` codes, and HTTP statuses aligned with the canonical `ProxyError` response mapping.
|
||||
- **Codex Native Balance / Coding-Plan Queries**: Fixed native usage and plan lookups so each app resolves the correct provider credentials instead of leaking assumptions from another app surface.
|
||||
- **Codex CLI Discovery and Catalog Projection**: Fixed third-party Codex catalog projection that could fail when the Codex CLI was not reachable through one narrow PATH lookup, by adding multi-platform CLI discovery plus a bundled GPT-5.5 model-catalog template fallback.
|
||||
- **Claude Desktop Official Provider Creation**: Fixed adding the Claude Desktop Official provider when the official category/config path was selected.
|
||||
- **Anthropic Tool Thinking History for Kimi / Moonshot**: Added Kimi and Moonshot to the Anthropic-compatible tool-thinking history normalizer so later turns can replay reasoning and tool-call context correctly.
|
||||
- **Windows Tool Version Probing**: Fixed Windows version checks that could misquote `.cmd` / `.bat` commands and decode localized command output as mojibake, causing working tools to appear as "installed but not runnable".
|
||||
|
||||
## [3.16.0] - 2026-05-29
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### The All-in-One Manager for Claude Code, Codex, Gemini CLI, OpenCode, OpenClaw & Hermes Agent
|
||||
### The All-in-One Manager for Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw & Hermes Agent
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -43,17 +43,17 @@ MiniMax-M2.7 is a next-generation large language model designed for autonomous e
|
||||
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>Thanks to Shengsuanyun for sponsoring this project! Shengsuanyun is a super factory serving AI Native Teams — an industrial-grade AI task parallel execution platform. Its model marketplace aggregates Claude, ChatGPT, Gemini, and other domestic and international LLM and multimedia model capabilities with direct supply. Absolutely no reverse engineering or dilution — platform-wide model SLA availability reaches 99.7%, with <a href="https://watch.shengsuanyun.com/status/shengsuanyun">monitoring dashboards</a> showing green across the board. It also offers enterprise-grade custom gateways for fine-grained team cost and permission management, smart routing, security protection, and BYOK (Bring Your Own Key) hosting. The platform charges on a pay-per-use and tokens plan (coming soon) basis, with invoicing available. Register via <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">this link</a> as a new user to receive ¥10 in credits plus a 10% bonus on your first top-up.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support.
|
||||
Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CC Switch users: register via <a href="https://www.aicodemirror.com/register?invitecode=9915W3">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>Thanks to Shengsuanyun for sponsoring this project! Shengsuanyun is a super factory serving AI Native Teams — an industrial-grade AI task parallel execution platform. Its model marketplace aggregates Claude, ChatGPT, Gemini, and other domestic and international LLM and multimedia model capabilities with direct supply. Absolutely no reverse engineering or dilution — platform-wide model SLA availability reaches 99.7%, with <a href="https://watch.shengsuanyun.com/status/shengsuanyun">monitoring dashboards</a> showing green across the board. It also offers enterprise-grade custom gateways for fine-grained team cost and permission management, smart routing, security protection, and BYOK (Bring Your Own Key) hosting. The platform charges on a pay-per-use and tokens plan (coming soon) basis, with invoicing available. Register via <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">this link</a> as a new user to receive ¥10 in credits plus a 10% bonus on your first top-up.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/"><img src="assets/partners/logos/pateway.png" alt="PatewayAI" width="150"></a></td>
|
||||
<td>Thanks to PatewayAI for sponsoring this project! PatewayAI is an API relay service provider built for heavy AI developers, focused on directly relaying official high-quality model APIs. It offers the full Claude lineup and the Codex series, 100% sourced from official channels — no dilution, no fakes, verification welcome. Billing is transparent and every token-level invoice can be audited line by line.
|
||||
@@ -63,7 +63,7 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||
<td>Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a full‑modal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, long‑task execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, end‑to‑end complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">this link</a> to get 500,000 tokens of free inference quota per model.<a href="https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
<td>Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a full‑modal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, long‑task execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, end‑to‑end complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">this link</a> to get 500,000 tokens of free inference quota per model.<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -106,11 +106,6 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
|
||||
<td>Thanks to Micu API for sponsoring this project! Micu API is a global LLM relay service provider dedicated to delivering the best cost-performance ratio with high stability. Backed by a registered enterprise for core assurance, eliminating any risk of service discontinuation, with fast official invoicing support! We champion "zero cost to try": top up from as low as ¥1 with no minimum, and get fee-free refunds anytime! Micu API offers an exclusive deal for CC Switch users: register via <a href="https://www.micuapi.ai/register?aff=aOYQ">this link</a> and enter promo code "ccswitch" when topping up to enjoy a <strong>10% discount</strong>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://lemondata.cc/r/FFX1ZDUP"><img src="assets/partners/logos/lemondata.png" alt="LemonData" width="150"></a></td>
|
||||
<td>Thanks to LemonData for sponsoring this project! LemonData is a high-performance AI API aggregation platform — one API key for 300+ models including GPT, Claude, Gemini, DeepSeek, and more. All models priced 30–70% below official rates with auto-failover, smart routing, and unlimited concurrency. New users get $1 free credit instantly upon registration — sign up via <a href="https://lemondata.cc/r/FFX1ZDUP">this link</a>to claim your bonus and start building right away</strong>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<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>
|
||||
@@ -146,19 +141,29 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
|
||||
<td>Atlas Cloud is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out Atlas Cloud's new <a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">coding plan</a> promotion for more budget-friendly API access!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.jpg" alt="CCSub" width="150"></a></td>
|
||||
<td>Thanks to CCSub for sponsoring this project! CCSub is a stable, affordable AI API relay platform — your drop-in replacement for a Claude.ai subscription. One API key gives you access to Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini, and DeepSeek at roughly 30% of direct API cost, with no VPN required from anywhere in the world. Compatible with Claude Code, Codex, Cursor, Cline, Continue, Windsurf, and all major AI coding tools. Register via <a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">this link</a> and get $5 free credit on sign-up.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>Thanks to Unity2.ai for sponsoring this project! Unity2.ai is a high-performance AI model API relay platform for individual developers, teams, and enterprises. Long trusted by leading companies in China, it serves over 30 billion tokens per day and supports high concurrency at the 5,000 RPM level. It offers balance-based billing, first top-up bonuses, bundle subscriptions, corporate invoicing, and dedicated support. Register via <a href="https://unity2.ai/register?source=ccs">this link</a> to get $2 in credits, plus another $10 for joining the official group — up to $12 in free credits!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
## Why CC Switch?
|
||||
|
||||
Modern AI-powered coding relies on CLI tools like Claude Code, Codex, Gemini CLI, OpenCode, and OpenClaw — but each has its own configuration format. Switching API providers means manually editing JSON, TOML, or `.env` files, and there is no unified way to manage MCP and Skills across multiple tools.
|
||||
Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, and Hermes — but each has its own configuration format. Switching API providers means manually editing JSON, TOML, or `.env` files, and there is no unified way to manage MCP and Skills across multiple tools.
|
||||
|
||||
**CC Switch** gives you a single desktop app to manage all five CLI tools. Instead of editing config files by hand, you get a visual interface to import providers with one click, switch between them instantly, with 50+ built-in provider presets, unified MCP and Skills management, and system tray quick switching — all backed by a reliable SQLite database with atomic writes that protect your configs from corruption.
|
||||
**CC Switch** gives you a single desktop app to manage all supported AI tools. Instead of editing config files by hand, you get a visual interface to import providers with one click, switch between them instantly, with 50+ built-in provider presets, unified MCP and Skills management, and system tray quick switching — all backed by a reliable SQLite database with atomic writes that protect your configs from corruption.
|
||||
|
||||
- **One App, Five CLI Tools** — Manage Claude Code, Codex, Gemini CLI, OpenCode, and OpenClaw from a single interface
|
||||
- **One App, Seven Tools** — Manage Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, and Hermes from a single interface
|
||||
- **No More Manual Editing** — 50+ provider presets including AWS Bedrock, NVIDIA NIM, and community relays; just pick and switch
|
||||
- **Unified MCP & Skills Management** — One panel to manage MCP servers and Skills across four apps with bidirectional sync
|
||||
- **Unified MCP & Skills Management** — One panel to manage MCP servers and Skills across Claude, Codex, Gemini, OpenCode, and Hermes with bidirectional sync
|
||||
- **System Tray Quick Switch** — Switch providers instantly from the tray menu, no need to open the full app
|
||||
- **Cloud Sync** — Sync provider data across devices via Dropbox, OneDrive, iCloud, or WebDAV servers
|
||||
- **Cross-Platform** — Native desktop app for Windows, macOS, and Linux, built with Tauri 2
|
||||
@@ -172,12 +177,12 @@ 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.15.0-en.md)
|
||||
[Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.16.1-en.md)
|
||||
|
||||
### Provider Management
|
||||
|
||||
- **5 CLI tools, 50+ presets** — Claude Code, Codex, Gemini CLI, OpenCode, OpenClaw; copy your key and import with one click
|
||||
- **Universal providers** — One config syncs to multiple apps (OpenCode, OpenClaw)
|
||||
- **7 supported tools, 50+ presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, Hermes; copy your key and import with one click
|
||||
- **Universal providers** — One config syncs to Claude Code, Codex, and Gemini CLI
|
||||
- One-click switching, system tray quick access, drag-and-drop sorting, import/export
|
||||
|
||||
### Proxy & Failover
|
||||
@@ -187,7 +192,7 @@ Modern AI-powered coding relies on CLI tools like Claude Code, Codex, Gemini CLI
|
||||
|
||||
### MCP, Prompts & Skills
|
||||
|
||||
- **Unified MCP panel** — Manage MCP servers across 4 apps with bidirectional sync and Deep Link import
|
||||
- **Unified MCP panel** — Manage MCP servers across Claude, Codex, Gemini, OpenCode, and Hermes with bidirectional sync and Deep Link import
|
||||
- **Prompts** — Markdown editor with cross-app sync (CLAUDE.md / AGENTS.md / GEMINI.md) and backfill protection
|
||||
- **Skills** — One-click install from GitHub repos or ZIP files, custom repository management, with symlink and file copy support
|
||||
|
||||
@@ -197,21 +202,21 @@ Modern AI-powered coding relies on CLI tools like Claude Code, Codex, Gemini CLI
|
||||
|
||||
### Session Manager & Workspace
|
||||
|
||||
- Browse, search, and restore conversation history across all apps
|
||||
- Browse, search, and restore conversation history across supported session sources
|
||||
- **Workspace editor** (OpenClaw) — Edit agent files (AGENTS.md, SOUL.md, etc.) with Markdown preview
|
||||
|
||||
### System & Platform
|
||||
|
||||
- **Cloud sync** — Custom config directory (Dropbox, OneDrive, iCloud, NAS) and WebDAV server sync
|
||||
- **Deep Link** (`ccswitch://`) — Import providers, MCP servers, prompts, and skills via URL
|
||||
- Dark / Light / System theme, auto-launch, auto-updater, atomic writes, auto-backups, i18n (zh/en/ja)
|
||||
- Dark / Light / System theme, auto-launch, auto-updater, atomic writes, auto-backups, i18n (zh/zh-TW/en/ja)
|
||||
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
<summary><strong>Which AI CLI tools does CC Switch support?</strong></summary>
|
||||
<summary><strong>Which AI tools does CC Switch support?</strong></summary>
|
||||
|
||||
CC Switch supports five tools: **Claude Code**, **Codex**, **Gemini CLI**, **OpenCode**, and **OpenClaw**. Each tool has dedicated provider presets and configuration management.
|
||||
CC Switch supports seven tools: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **OpenCode**, **OpenClaw**, and **Hermes**. Each tool has dedicated provider presets and configuration management.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -280,8 +285,8 @@ For detailed guides on every feature, check out the **[User Manual](docs/user-ma
|
||||
|
||||
- **MCP**: Click the "MCP" button → Add servers via templates or custom config → Toggle per-app sync
|
||||
- **Prompts**: Click "Prompts" → Create presets with Markdown editor → Activate to sync to live files
|
||||
- **Skills**: Click "Skills" → Browse GitHub repos → One-click install to all apps
|
||||
- **Sessions**: Click "Sessions" → Browse, search, and restore conversation history across all apps
|
||||
- **Skills**: Click "Skills" → Browse GitHub repos → One-click install to supported apps
|
||||
- **Sessions**: Click "Sessions" → Browse, search, and restore conversation history across supported session sources
|
||||
|
||||
> **Note**: On first launch, you can manually import existing CLI tool configs as the default provider.
|
||||
|
||||
@@ -372,7 +377,7 @@ Download the latest Linux build from the [Releases](../../releases) page:
|
||||
- **ProviderService**: Provider CRUD, switching, backfill, sorting
|
||||
- **McpService**: MCP server management, import/export, live file sync
|
||||
- **ProxyService**: Local proxy mode with hot-switching and format conversion
|
||||
- **SessionManager**: Conversation history browsing across all supported apps
|
||||
- **SessionManager**: Conversation history browsing across supported session sources
|
||||
- **ConfigService**: Config import/export, backup rotation
|
||||
- **SpeedtestService**: API endpoint latency measurement
|
||||
|
||||
@@ -494,7 +499,7 @@ pnpm test:unit --coverage
|
||||
│ ├── lib/
|
||||
│ │ ├── api/ # Tauri API wrapper (type-safe)
|
||||
│ │ └── query/ # TanStack Query config
|
||||
│ ├── locales/ # Translations (zh/en/ja)
|
||||
│ ├── locales/ # Translations (zh/zh-TW/en/ja)
|
||||
│ ├── config/ # Presets (providers/mcp)
|
||||
│ └── types/ # TypeScript definitions
|
||||
├── src-tauri/ # Backend (Rust)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### Der All-in-One-Manager für Claude Code, Codex, Gemini CLI, OpenCode, OpenClaw & Hermes Agent
|
||||
### Der All-in-One-Manager für Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw & Hermes Agent
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -43,17 +43,17 @@ MiniMax-M2.7 ist ein großes Sprachmodell der nächsten Generation, das auf auto
|
||||
<td>Danke an AIGoCode für die Unterstützung dieses Projekts! AIGoCode ist eine All-in-One-Plattform, die Claude Code, Codex und die neuesten Gemini-Modelle integriert und Ihnen stabile, effiziente und äußerst kostengünstige KI-Coding-Dienste bietet. Die Plattform stellt flexible Abonnementpläne bereit, birgt kein Risiko einer Kontosperrung, ermöglicht Direktzugriff ohne VPN und reagiert blitzschnell. AIGoCode hat ein besonderes Angebot für CC-Switch-Nutzer vorbereitet: Wenn Sie sich über <a href="https://aigocode.com/invite/CC-SWITCH">diesen Link</a> registrieren, erhalten Sie bei Ihrer ersten Aufladung zusätzliche 10 % Bonusguthaben!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>Danke an Shengsuanyun für die Unterstützung dieses Projekts! Shengsuanyun ist eine Superfabrik für KI-native Teams — eine Plattform zur parallelen Ausführung von KI-Aufgaben in industrieller Qualität. Ihr Modellmarktplatz bündelt die Fähigkeiten von Claude, ChatGPT, Gemini und weiteren in- und ausländischen LLM- und Multimedia-Modellen mit Direktbezug. Absolut kein Reverse Engineering und keine Verwässerung — die plattformweite Modell-SLA-Verfügbarkeit erreicht 99,7 %, und die <a href="https://watch.shengsuanyun.com/status/shengsuanyun">Monitoring-Dashboards</a> zeigen durchgehend grün an. Es bietet außerdem unternehmensgerechte, anpassbare Gateways für fein abgestufte Kosten- und Berechtigungsverwaltung im Team, intelligentes Routing, Sicherheitsschutz und BYOK-Hosting (Bring Your Own Key). Die Plattform rechnet nach Nutzung sowie über einen Token-Plan (in Kürze verfügbar) ab, und Rechnungsstellung ist möglich. Registrieren Sie sich über <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">diesen Link</a> als Neukunde und erhalten Sie ein Guthaben von ¥10 sowie 10 % Bonus auf Ihre erste Aufladung.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>Danke an AICodeMirror für die Unterstützung dieses Projekts! AICodeMirror stellt offizielle, hochstabile Relay-Dienste für Claude Code / Codex / Gemini CLI bereit, mit unternehmensgerechter Nebenläufigkeit, schneller Rechnungsstellung und rund um die Uhr verfügbarem dediziertem technischem Support.
|
||||
Offizielle Kanäle von Claude Code / Codex / Gemini zu 38 % / 2 % / 9 % des Originalpreises, mit zusätzlichen Rabatten beim Aufladen! AICodeMirror bietet besondere Vorteile für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://www.aicodemirror.com/register?invitecode=9915W3">diesen Link</a> und erhalten Sie 20 % Rabatt auf Ihre erste Aufladung; Unternehmenskunden erhalten bis zu 25 % Rabatt!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>Danke an Shengsuanyun für die Unterstützung dieses Projekts! Shengsuanyun ist eine Superfabrik für KI-native Teams — eine Plattform zur parallelen Ausführung von KI-Aufgaben in industrieller Qualität. Ihr Modellmarktplatz bündelt die Fähigkeiten von Claude, ChatGPT, Gemini und weiteren in- und ausländischen LLM- und Multimedia-Modellen mit Direktbezug. Absolut kein Reverse Engineering und keine Verwässerung — die plattformweite Modell-SLA-Verfügbarkeit erreicht 99,7 %, und die <a href="https://watch.shengsuanyun.com/status/shengsuanyun">Monitoring-Dashboards</a> zeigen durchgehend grün an. Es bietet außerdem unternehmensgerechte, anpassbare Gateways für fein abgestufte Kosten- und Berechtigungsverwaltung im Team, intelligentes Routing, Sicherheitsschutz und BYOK-Hosting (Bring Your Own Key). Die Plattform rechnet nach Nutzung sowie über einen Token-Plan (in Kürze verfügbar) ab, und Rechnungsstellung ist möglich. Registrieren Sie sich über <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">diesen Link</a> als Neukunde und erhalten Sie ein Guthaben von ¥10 sowie 10 % Bonus auf Ihre erste Aufladung.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/"><img src="assets/partners/logos/pateway.png" alt="PatewayAI" width="150"></a></td>
|
||||
<td>Danke an PatewayAI für die Unterstützung dieses Projekts! PatewayAI ist ein API-Relay-Anbieter für anspruchsvolle KI-Entwickler, der sich auf das direkte Relayen offizieller hochwertiger Modell-APIs konzentriert. Er bietet die komplette Claude-Reihe und die Codex-Serie, zu 100 % aus offiziellen Kanälen bezogen — keine Verwässerung, keine Fälschungen, Überprüfung ausdrücklich erwünscht. Die Abrechnung ist transparent, und jede Rechnung auf Token-Ebene lässt sich Zeile für Zeile prüfen.
|
||||
@@ -63,7 +63,7 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||
<td>Danke an Dola seed für die Unterstützung dieses Projekts! Dola Seed 2.0 ist ein voll-modales Allzweck-Großmodell, das von ByteDance eigenständig für den globalen Markt entwickelt wurde. Aufbauend auf einer einheitlichen multimodalen Architektur unterstützt es das gemeinsame Verstehen und Generieren von Text, Bildern, Audio und Video. Es ermöglicht von Haus aus die Zusammenarbeit von Agenten und verfügt über starke Fähigkeiten in den Bereichen Schlussfolgern, Ausführung langer Aufgaben, Werkzeugintegration und Programmierung. Es ist breit einsetzbar — etwa für intelligente Cockpits, persönliche Assistenten, Bildung, Kundensupport, Marketing, Einzelhandel und weitere Szenarien. Es überzeugt bei multimodaler Wahrnehmung, der Ende-zu-Ende-Bewältigung komplexer Aufgaben, stabiler Interaktion und Datensicherheit und ist über die ModelArk-Plattform einfach zugänglich und bereitstellbar. Registrieren Sie sich über <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">diesen Link</a> und erhalten Sie pro Modell ein kostenloses Inferenzkontingent von 500.000 Token.<a href="https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
<td>Danke an Dola seed für die Unterstützung dieses Projekts! Dola Seed 2.0 ist ein voll-modales Allzweck-Großmodell, das von ByteDance eigenständig für den globalen Markt entwickelt wurde. Aufbauend auf einer einheitlichen multimodalen Architektur unterstützt es das gemeinsame Verstehen und Generieren von Text, Bildern, Audio und Video. Es ermöglicht von Haus aus die Zusammenarbeit von Agenten und verfügt über starke Fähigkeiten in den Bereichen Schlussfolgern, Ausführung langer Aufgaben, Werkzeugintegration und Programmierung. Es ist breit einsetzbar — etwa für intelligente Cockpits, persönliche Assistenten, Bildung, Kundensupport, Marketing, Einzelhandel und weitere Szenarien. Es überzeugt bei multimodaler Wahrnehmung, der Ende-zu-Ende-Bewältigung komplexer Aufgaben, stabiler Interaktion und Datensicherheit und ist über die ModelArk-Plattform einfach zugänglich und bereitstellbar. Registrieren Sie sich über <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">diesen Link</a> und erhalten Sie pro Modell ein kostenloses Inferenzkontingent von 500.000 Token.<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -106,11 +106,6 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
|
||||
<td>Danke an Micu API für die Unterstützung dieses Projekts! Micu API ist ein globaler LLM-Relay-Anbieter, der sich der Bereitstellung des besten Preis-Leistungs-Verhältnisses bei hoher Stabilität widmet. Gestützt auf ein eingetragenes Unternehmen als Kernabsicherung wird jedes Risiko einer Diensteinstellung ausgeschlossen, mit schneller offizieller Rechnungsstellung! Wir stehen für „kostenloses Ausprobieren": Aufladungen sind schon ab ¥1 ohne Mindestbetrag möglich, und gebührenfreie Rückerstattungen sind jederzeit möglich! Micu API bietet ein exklusives Angebot für CC-Switch-Nutzer: Registrieren Sie sich über <a href="https://www.micuapi.ai/register?aff=aOYQ">diesen Link</a> und geben Sie beim Aufladen den Gutscheincode „ccswitch" ein, um <strong>10 % Rabatt</strong> zu erhalten!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://lemondata.cc/r/FFX1ZDUP"><img src="assets/partners/logos/lemondata.png" alt="LemonData" width="150"></a></td>
|
||||
<td>Danke an LemonData für die Unterstützung dieses Projekts! LemonData ist eine leistungsstarke KI-API-Aggregationsplattform — ein API-Schlüssel für mehr als 300 Modelle, darunter GPT, Claude, Gemini, DeepSeek und weitere. Alle Modelle zu Preisen 30–70 % unter den offiziellen Tarifen, mit automatischem Failover, intelligentem Routing und unbegrenzter Nebenläufigkeit. Neukunden erhalten bei der Registrierung sofort 1 $ Gratisguthaben — registrieren Sie sich über <a href="https://lemondata.cc/r/FFX1ZDUP">diesen Link</a>, um Ihren Bonus einzulösen und sofort mit dem Entwickeln zu beginnen</strong>!</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>Danke an CTok.ai für die Unterstützung dieses Projekts! CTok.ai widmet sich dem Aufbau einer Komplettlösung für KI-Programmierwerkzeuge. Wir bieten professionelle Claude-Code-Pakete und Dienste einer technischen Community, mit Unterstützung für Google Gemini und OpenAI Codex. Durch sorgfältig gestaltete Pläne und eine professionelle Tech-Community geben wir Entwicklern verlässliche Servicegarantien und kontinuierlichen technischen Support an die Hand und machen KI-gestützte Programmierung zu einem echten Produktivitätswerkzeug. Klicken Sie <a href="https://ctok.ai">hier</a>, um sich zu registrieren!</td>
|
||||
@@ -146,19 +141,29 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
|
||||
<td>Atlas Cloud ist eine vollmodale KI-Inferenzplattform, die Entwicklern über eine einzige KI-API Zugriff auf Videogenerierung, Bildgenerierung und LLM-APIs bietet. Statt mehrere Anbieterintegrationen zu verwalten, verbinden Sie sich einmal und erhalten einheitlichen Zugriff auf mehr als 300 kuratierte Modelle über alle Modalitäten hinweg. Sehen Sie sich die neue <a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">Coding-Plan</a>-Aktion von Atlas Cloud für kostengünstigeren API-Zugang an!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.jpg" alt="CCSub" width="150"></a></td>
|
||||
<td>Danke an CCSub für die Unterstützung dieses Projekts! CCSub ist eine zuverlässige und kostengünstige AI-API-Relay-Plattform — Ihr direkter Ersatz für ein Claude.ai-Abonnement. Mit einem einzigen API-Schlüssel erhalten Sie Zugriff auf Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini und DeepSeek zu etwa 30 % der Kosten der direkten API-Nutzung — ohne VPN, weltweit nutzbar. Kompatibel mit Claude Code, Codex, Cursor, Cline, Continue, Windsurf und allen gängigen AI-Coding-Tools. Registrieren Sie sich über <a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">diesen Link</a> und erhalten Sie $5 Startguthaben bei der Anmeldung.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>Danke an Unity2.ai für die Unterstützung dieses Projekts! Unity2.ai ist eine leistungsstarke AI-Modell-API-Relay-Plattform für Einzelentwickler, Teams und Unternehmen. Sie wird seit Langem von führenden Unternehmen in China genutzt, verarbeitet täglich über 30 Milliarden Tokens und unterstützt hohe Parallelität auf 5.000-RPM-Niveau. Geboten werden Guthaben-Abrechnung, Ersteinzahlungsbonus, Kombi-Abonnements, Firmenrechnungen und persönliche Betreuung. Registrieren Sie sich über <a href="https://unity2.ai/register?source=ccs">diesen Link</a> und erhalten Sie $2 Guthaben, plus weitere $10 für den Beitritt zur offiziellen Gruppe — bis zu $12 Gratis-Guthaben!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
## Warum CC Switch?
|
||||
|
||||
Modernes KI-gestütztes Programmieren stützt sich auf CLI-Werkzeuge wie Claude Code, Codex, Gemini CLI, OpenCode und OpenClaw — doch jedes hat sein eigenes Konfigurationsformat. Der Wechsel des API-Anbieters bedeutet, JSON-, TOML- oder `.env`-Dateien von Hand zu bearbeiten, und es gibt keine einheitliche Möglichkeit, MCP und Skills über mehrere Werkzeuge hinweg zu verwalten.
|
||||
Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw und Hermes — doch jedes hat sein eigenes Konfigurationsformat. Der Wechsel des API-Anbieters bedeutet, JSON-, TOML- oder `.env`-Dateien von Hand zu bearbeiten, und es gibt keine einheitliche Möglichkeit, MCP und Skills über mehrere Werkzeuge hinweg zu verwalten.
|
||||
|
||||
**CC Switch** gibt Ihnen eine einzige Desktop-App, um alle fünf CLI-Werkzeuge zu verwalten. Statt Konfigurationsdateien von Hand zu bearbeiten, erhalten Sie eine visuelle Oberfläche, um Anbieter mit einem Klick zu importieren und sofort zwischen ihnen zu wechseln — mit 50+ integrierten Anbieter-Presets, einheitlicher MCP- und Skills-Verwaltung und schnellem Umschalten über das System-Tray. Das Ganze gestützt auf eine zuverlässige SQLite-Datenbank mit atomaren Schreibvorgängen, die Ihre Konfigurationen vor Beschädigung schützen.
|
||||
**CC Switch** gibt Ihnen eine einzige Desktop-App, um alle unterstützten KI-Werkzeuge zu verwalten. Statt Konfigurationsdateien von Hand zu bearbeiten, erhalten Sie eine visuelle Oberfläche, um Anbieter mit einem Klick zu importieren und sofort zwischen ihnen zu wechseln — mit 50+ integrierten Anbieter-Presets, einheitlicher MCP- und Skills-Verwaltung und schnellem Umschalten über das System-Tray. Das Ganze gestützt auf eine zuverlässige SQLite-Datenbank mit atomaren Schreibvorgängen, die Ihre Konfigurationen vor Beschädigung schützen.
|
||||
|
||||
- **Eine App, fünf CLI-Werkzeuge** — Verwalten Sie Claude Code, Codex, Gemini CLI, OpenCode und OpenClaw über eine einzige Oberfläche
|
||||
- **Eine App, sieben Werkzeuge** — Verwalten Sie Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw und Hermes über eine einzige Oberfläche
|
||||
- **Kein manuelles Bearbeiten mehr** — 50+ Anbieter-Presets einschließlich AWS Bedrock, NVIDIA NIM und Community-Relays; einfach auswählen und umschalten
|
||||
- **Einheitliche MCP- & Skills-Verwaltung** — Ein Panel zur Verwaltung von MCP-Servern und Skills über vier Apps hinweg mit bidirektionaler Synchronisierung
|
||||
- **Einheitliche MCP- & Skills-Verwaltung** — Ein Panel zur Verwaltung von MCP-Servern und Skills für Claude, Codex, Gemini, OpenCode und Hermes mit bidirektionaler Synchronisierung
|
||||
- **Schnellumschaltung über System-Tray** — Wechseln Sie Anbieter sofort über das Tray-Menü, ohne die vollständige App öffnen zu müssen
|
||||
- **Cloud-Synchronisierung** — Synchronisieren Sie Anbieterdaten geräteübergreifend über Dropbox, OneDrive, iCloud oder WebDAV-Server
|
||||
- **Plattformübergreifend** — Native Desktop-App für Windows, macOS und Linux, gebaut mit Tauri 2
|
||||
@@ -172,12 +177,12 @@ Modernes KI-gestütztes Programmieren stützt sich auf CLI-Werkzeuge wie Claude
|
||||
|
||||
## Funktionen
|
||||
|
||||
[Vollständiges Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.15.0-en.md)
|
||||
[Vollständiges Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.16.1-en.md)
|
||||
|
||||
### Anbieterverwaltung
|
||||
|
||||
- **5 CLI-Werkzeuge, 50+ Presets** — Claude Code, Codex, Gemini CLI, OpenCode, OpenClaw; Schlüssel kopieren und mit einem Klick importieren
|
||||
- **Universelle Anbieter** — Eine Konfiguration synchronisiert sich mit mehreren Apps (OpenCode, OpenClaw)
|
||||
- **7 unterstützte Werkzeuge, 50+ Presets** — Claude Code, Claude Desktop, Codex, Gemini CLI, OpenCode, OpenClaw, Hermes; Schlüssel kopieren und mit einem Klick importieren
|
||||
- **Universelle Anbieter** — Eine Konfiguration synchronisiert sich mit Claude Code, Codex und Gemini CLI
|
||||
- Umschaltung mit einem Klick, Schnellzugriff über System-Tray, Sortierung per Drag-and-drop, Import/Export
|
||||
|
||||
### Proxy & Failover
|
||||
@@ -187,7 +192,7 @@ Modernes KI-gestütztes Programmieren stützt sich auf CLI-Werkzeuge wie Claude
|
||||
|
||||
### MCP, Prompts & Skills
|
||||
|
||||
- **Einheitliches MCP-Panel** — Verwalten Sie MCP-Server über 4 Apps hinweg mit bidirektionaler Synchronisierung und Deep-Link-Import
|
||||
- **Einheitliches MCP-Panel** — Verwalten Sie MCP-Server für Claude, Codex, Gemini, OpenCode und Hermes mit bidirektionaler Synchronisierung und Deep-Link-Import
|
||||
- **Prompts** — Markdown-Editor mit App-übergreifender Synchronisierung (CLAUDE.md / AGENTS.md / GEMINI.md) und Backfill-Schutz
|
||||
- **Skills** — Installation mit einem Klick aus GitHub-Repositorys oder ZIP-Dateien, Verwaltung eigener Repositorys, mit Unterstützung für Symlinks und Dateikopien
|
||||
|
||||
@@ -197,21 +202,21 @@ Modernes KI-gestütztes Programmieren stützt sich auf CLI-Werkzeuge wie Claude
|
||||
|
||||
### Session Manager & Workspace
|
||||
|
||||
- Durchsuchen, suchen und stellen Sie den Gesprächsverlauf über alle Apps hinweg wieder her
|
||||
- Gesprächsverlauf aus unterstützten Sitzungsquellen durchsuchen, suchen und wiederherstellen
|
||||
- **Workspace-Editor** (OpenClaw) — Bearbeiten Sie Agent-Dateien (AGENTS.md, SOUL.md usw.) mit Markdown-Vorschau
|
||||
|
||||
### System & Plattform
|
||||
|
||||
- **Cloud-Synchronisierung** — Eigenes Konfigurationsverzeichnis (Dropbox, OneDrive, iCloud, NAS) und WebDAV-Server-Synchronisierung
|
||||
- **Deep Link** (`ccswitch://`) — Importieren Sie Anbieter, MCP-Server, Prompts und Skills per URL
|
||||
- Dunkles / Helles / System-Theme, automatischer Start, automatischer Updater, atomare Schreibvorgänge, automatische Backups, i18n (zh/en/ja)
|
||||
- Dunkles / Helles / System-Theme, automatischer Start, automatischer Updater, atomare Schreibvorgänge, automatische Backups, i18n (zh/zh-TW/en/ja)
|
||||
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
<summary><strong>Welche KI-CLI-Werkzeuge unterstützt CC Switch?</strong></summary>
|
||||
<summary><strong>Welche KI-Werkzeuge unterstützt CC Switch?</strong></summary>
|
||||
|
||||
CC Switch unterstützt fünf Werkzeuge: **Claude Code**, **Codex**, **Gemini CLI**, **OpenCode** und **OpenClaw**. Jedes Werkzeug verfügt über dedizierte Anbieter-Presets und Konfigurationsverwaltung.
|
||||
CC Switch unterstützt sieben Werkzeuge: **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **OpenCode**, **OpenClaw** und **Hermes**. Jedes Werkzeug verfügt über dedizierte Anbieter-Presets und Konfigurationsverwaltung.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -280,8 +285,8 @@ Ausführliche Anleitungen zu jeder Funktion finden Sie im **[Benutzerhandbuch](d
|
||||
|
||||
- **MCP**: Klicken Sie auf die Schaltfläche „MCP" → Server über Vorlagen oder eigene Konfiguration hinzufügen → Synchronisierung pro App umschalten
|
||||
- **Prompts**: Klicken Sie auf „Prompts" → Presets mit dem Markdown-Editor erstellen → Aktivieren, um mit den Live-Dateien zu synchronisieren
|
||||
- **Skills**: Klicken Sie auf „Skills" → GitHub-Repositorys durchsuchen → mit einem Klick in allen Apps installieren
|
||||
- **Sessions**: Klicken Sie auf „Sessions" → Gesprächsverlauf über alle Apps hinweg durchsuchen, suchen und wiederherstellen
|
||||
- **Skills**: Klicken Sie auf „Skills" → GitHub-Repositorys durchsuchen → mit einem Klick in unterstützte Apps installieren
|
||||
- **Sessions**: Klicken Sie auf „Sessions" → Gesprächsverlauf aus unterstützten Sitzungsquellen durchsuchen, suchen und wiederherstellen
|
||||
|
||||
> **Hinweis**: Beim Erststart können Sie bestehende CLI-Werkzeug-Konfigurationen manuell als Standardanbieter importieren.
|
||||
|
||||
@@ -494,7 +499,7 @@ pnpm test:unit --coverage
|
||||
│ ├── lib/
|
||||
│ │ ├── api/ # Tauri-API-Wrapper (typsicher)
|
||||
│ │ └── query/ # TanStack-Query-Konfiguration
|
||||
│ ├── locales/ # Übersetzungen (zh/en/ja)
|
||||
│ ├── locales/ # Übersetzungen (zh/zh-TW/en/ja)
|
||||
│ ├── config/ # Presets (providers/mcp)
|
||||
│ └── types/ # TypeScript-Definitionen
|
||||
├── src-tauri/ # Backend (Rust)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes Agent のオールインワン管理ツール
|
||||
### Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes Agent のオールインワン管理ツール
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -43,17 +43,17 @@ MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設
|
||||
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>胜算雲(Shengsuanyun)のご支援に感謝します!胜算雲は AI ネイティブチーム向けのスーパーファクトリーであり、産業グレードの AI タスク並列実行プラットフォームです。モデルマーケットプレイスでは Claude、ChatGPT、Gemini をはじめとする国内外の LLM およびマルチメディアモデルの計算リソースを集約・直接提供。リバースエンジニアリングや品質低下は一切なく、プラットフォーム全体のモデル SLA 可用性は 99.7% に達し、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">監視ダッシュボード</a>は常時グリーン表示です。さらにエンタープライズ向けカスタムゲートウェイを提供し、チームのきめ細かなコスト・権限管理、スマートルーティング、セキュリティ保護、BYOK(自社キー持ち込み)ホスティングを実現します。従量課金およびトークンプラン(近日公開)対応で、請求書発行にも対応。<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">このリンク</a>から新規登録すると 10 元分のクレジットと初回チャージ 10% ボーナスが付与されます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定リレーサービスを提供しており、エンタープライズ級の同時接続、迅速な請求書発行、24時間年中無休の専用テクニカルサポートを備えています。
|
||||
Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / 2% / 9%、チャージ時にはさらに割引!AICodeMirror は CC Switch ユーザー向けに特別特典を用意:<a href="https://www.aicodemirror.com/register?invitecode=9915W3">このリンク</a>から登録すると初回チャージ 20% オフ、法人のお客様は最大 25% オフ!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>胜算雲(Shengsuanyun)のご支援に感謝します!胜算雲は AI ネイティブチーム向けのスーパーファクトリーであり、産業グレードの AI タスク並列実行プラットフォームです。モデルマーケットプレイスでは Claude、ChatGPT、Gemini をはじめとする国内外の LLM およびマルチメディアモデルの計算リソースを集約・直接提供。リバースエンジニアリングや品質低下は一切なく、プラットフォーム全体のモデル SLA 可用性は 99.7% に達し、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">監視ダッシュボード</a>は常時グリーン表示です。さらにエンタープライズ向けカスタムゲートウェイを提供し、チームのきめ細かなコスト・権限管理、スマートルーティング、セキュリティ保護、BYOK(自社キー持ち込み)ホスティングを実現します。従量課金およびトークンプラン(近日公開)対応で、請求書発行にも対応。<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">このリンク</a>から新規登録すると 10 元分のクレジットと初回チャージ 10% ボーナスが付与されます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/"><img src="assets/partners/logos/pateway.png" alt="PatewayAI" width="150"></a></td>
|
||||
<td>PatewayAI のご支援に感謝します!PatewayAI はヘビーな AI 開発者向けに、公式直結の高品質モデル API 中継サービスを専門に提供するプロバイダーです。Claude シリーズ全モデルおよび Codex シリーズに対応し、100% 公式ソースから直接提供。混ぜ物・水増しは一切なく、検証も歓迎します。課金は透明で、トークン単位の請求書を 1 件ずつ照合可能です。
|
||||
@@ -63,7 +63,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||
<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は ByteDance がグローバル市場向けに独自開発したフルモーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャを基盤に、テキスト・画像・音声・動画の統合的な理解と生成をサポートします。エージェント連携をネイティブに実現し、強力な推論、長時間タスクの実行、ツール統合、コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど幅広いシナリオに適用可能で、マルチモーダル認識、エンドツーエンドの複雑なタスク遂行、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて手軽に利用・デプロイできます。<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">このリンク</a>からご登録いただくと、モデルごとに 500,000 トークンの無料推論クォータを進呈します。</td>
|
||||
<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は ByteDance がグローバル市場向けに独自開発したフルモーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャを基盤に、テキスト・画像・音声・動画の統合的な理解と生成をサポートします。エージェント連携をネイティブに実現し、強力な推論、長時間タスクの実行、ツール統合、コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど幅広いシナリオに適用可能で、マルチモーダル認識、エンドツーエンドの複雑なタスク遂行、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて手軽に利用・デプロイできます。<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">このリンク</a>からご登録いただくと、モデルごとに 500,000 トークンの無料推論クォータを進呈します。<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -105,10 +105,6 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>Micu API のご支援に感謝します!Micu API は、最高のコストパフォーマンスと高い安定性を追求するグローバル大規模言語モデル中継サービスプロバイダーです。法人企業がバックアップしており、サービス停止のリスクを排除、迅速な正規請求書発行に対応!「試行コストゼロ」をモットーに、最低 1 元からチャージ可能で手数料無料、いつでも返金可能!CC Switch ユーザー向けの限定特典:<a href="https://www.micuapi.ai/register?aff=aOYQ">こちらのリンク</a>から登録し、チャージ時にプロモコード「ccswitch」を入力すると <strong>10% 割引</strong> が適用されます!</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="180"><a href="https://lemondata.cc/r/FFX1ZDUP"><img src="assets/partners/logos/lemondata.png" alt="LemonData" width="150"></a></td>
|
||||
<td>LemonData のご支援に感謝します!LemonData は高性能 AI API アグリゲーションプラットフォームで、GPT、Claude、Gemini、DeepSeek など 300 以上のモデルに 1 つの API キーでアクセス可能。全モデルが公式価格の 30〜70% オフで自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。新規ユーザーは登録だけで即座に $1 の無料クレジットを獲得 — <a href="https://lemondata.cc/r/FFX1ZDUP">こちらのリンク</a>から登録してボーナスを獲得し、すぐに開発を始めましょう!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
|
||||
@@ -145,19 +141,29 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
<td>Atlas Cloud は、1 つの API で動画・画像生成や LLM(大規模言語モデル)を利用できる全モーダル対応の AI 推論プラットフォームです。複数のベンダーを個別に管理する手間を省き、一度の接続で 300 以上の厳選されたマルチモーダルモデルにアクセスできます。より低コストで API を利用できる、開発者向けの新しい<a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">「コーディングプラン」</a>プロモーションをぜひチェックしてください!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.jpg" alt="CCSub" width="150"></a></td>
|
||||
<td>CCSub のご支援に感謝します!CCSub は安定した低価格の AI API リレープラットフォームで、Claude Code 公式サブスクリプションの強力な代替です。1 つの API キーで Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek の全モデルを公式直接利用の約 1/3 のコストでご利用いただけます。VPN 不要で世界中から直接接続可能。Claude Code、Codex、Cursor、Cline、Continue、Windsurf など主要な AI コーディングツールすべてに対応しています。<a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">こちらのリンク</a>から登録すると $5 の無料クレジットがもらえます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>Unity2.ai のご支援に感謝します!Unity2.ai は個人開発者・チーム・企業向けの高性能 AI モデル API リレープラットフォームです。中国の大手企業に長年利用されており、1 日 300 億トークン以上を処理し、5000 RPM クラスの高並列に対応しています。残高課金、初回チャージボーナス、組み合わせサブスクリプション、企業向け請求書発行、専任サポートを提供。<a href="https://unity2.ai/register?source=ccs">こちらのリンク</a>から登録すると $2 のクレジット、公式グループへの参加でさらに $10、最大 $12 の無料クレジットがもらえます!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
## CC Switch を選ぶ理由
|
||||
|
||||
最新の AI コーディングは Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw などの CLI ツールに依存していますが、各ツールの設定形式はバラバラです。API プロバイダを切り替えるたびに JSON、TOML、`.env` ファイルを手動で編集する必要があり、複数ツール間で MCP や Skills を統一的に管理する手段もありません。
|
||||
最新の AI コーディングは Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes などのツールに依存していますが、各ツールの設定形式はバラバラです。API プロバイダを切り替えるたびに JSON、TOML、`.env` ファイルを手動で編集する必要があり、複数ツール間で MCP や Skills を統一的に管理する手段もありません。
|
||||
|
||||
**CC Switch** は、5 つの CLI ツールを 1 つのデスクトップアプリで一元管理できます。設定ファイルを手作業で編集する代わりに、ワンクリックでプロバイダをインポートし、瞬時に切り替えられるビジュアルインターフェースを提供します。50 以上の組み込みプリセット、統一 MCP・Skills 管理、システムトレイからの即時切り替え機能を搭載。すべてはアトミック書き込みによる信頼性の高い SQLite データベースに支えられており、設定の破損を防ぎます。
|
||||
**CC Switch** は、対応する AI ツールを 1 つのデスクトップアプリで一元管理できます。設定ファイルを手作業で編集する代わりに、ワンクリックでプロバイダをインポートし、瞬時に切り替えられるビジュアルインターフェースを提供します。50 以上の組み込みプリセット、統一 MCP・Skills 管理、システムトレイからの即時切り替え機能を搭載。すべてはアトミック書き込みによる信頼性の高い SQLite データベースに支えられており、設定の破損を防ぎます。
|
||||
|
||||
- **1 つのアプリで 5 つの CLI ツール** -- Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw を単一インターフェースで管理
|
||||
- **1 つのアプリで 7 つのツール** -- Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes を単一インターフェースで管理
|
||||
- **手動編集は不要** -- AWS Bedrock、NVIDIA NIM、コミュニティリレーなど 50 以上のプロバイダプリセットを内蔵。選んで切り替えるだけ
|
||||
- **統一 MCP・Skills 管理** -- 1 つのパネルで 4 つのアプリの MCP サーバーと Skills を双方向同期で管理
|
||||
- **統一 MCP・Skills 管理** -- 1 つのパネルで Claude、Codex、Gemini、OpenCode、Hermes の MCP サーバーと Skills を双方向同期で管理
|
||||
- **システムトレイでクイック切り替え** -- トレイメニューから即座にプロバイダを切り替え。アプリを開く必要なし
|
||||
- **クラウド同期** -- Dropbox、OneDrive、iCloud、または WebDAV サーバー経由でデバイス間のプロバイダデータを同期
|
||||
- **クロスプラットフォーム** -- Tauri 2 で構築された Windows、macOS、Linux 対応のネイティブデスクトップアプリ
|
||||
@@ -171,12 +177,12 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
|
||||
## 特長
|
||||
|
||||
[完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-notes/v3.15.0-ja.md)
|
||||
[完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-notes/v3.16.1-ja.md)
|
||||
|
||||
### プロバイダ管理
|
||||
|
||||
- **5 つの CLI ツール、50 以上のプリセット** -- Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw。キーをコピーしてワンクリックでインポート
|
||||
- **ユニバーサルプロバイダ** -- 1 つの設定を複数アプリに同期(OpenCode、OpenClaw)
|
||||
- **7 つの対応ツール、50 以上のプリセット** -- Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes。キーをコピーしてワンクリックでインポート
|
||||
- **ユニバーサルプロバイダ** -- 1 つの設定を Claude Code、Codex、Gemini CLI に同期
|
||||
- ワンクリック切り替え、システムトレイクイックアクセス、ドラッグ&ドロップ並び替え、インポート/エクスポート
|
||||
|
||||
### プロキシ & フェイルオーバー
|
||||
@@ -186,7 +192,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
|
||||
### MCP、Prompts & Skills
|
||||
|
||||
- **統一 MCP パネル** -- 4 つのアプリの MCP サーバーを管理、双方向同期、Deep Link インポート対応
|
||||
- **統一 MCP パネル** -- Claude、Codex、Gemini、OpenCode、Hermes の MCP サーバーを管理、双方向同期、Deep Link インポート対応
|
||||
- **Prompts** -- Markdown エディタ、クロスアプリ同期(CLAUDE.md / AGENTS.md / GEMINI.md)、バックフィル保護
|
||||
- **Skills** -- GitHub リポジトリまたは ZIP ファイルからワンクリックインストール、カスタムリポジトリ管理、シンボリックリンクとファイルコピーに対応
|
||||
|
||||
@@ -196,21 +202,21 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
|
||||
### Session Manager & ワークスペース
|
||||
|
||||
- すべてのアプリの会話履歴を閲覧・検索・復元
|
||||
- 対応するセッションソースの会話履歴を閲覧・検索・復元
|
||||
- **ワークスペースエディタ**(OpenClaw)-- エージェントファイル(AGENTS.md、SOUL.md など)を Markdown プレビュー付きで編集
|
||||
|
||||
### システム & プラットフォーム
|
||||
|
||||
- **クラウド同期** -- カスタム設定ディレクトリ(Dropbox、OneDrive、iCloud、NAS)および WebDAV サーバー同期
|
||||
- **Deep Link** (`ccswitch://`) -- URL 経由でプロバイダ、MCP サーバー、Prompts、Skills をワンクリックインポート
|
||||
- ダーク / ライト / システムテーマ、自動起動、自動アップデーター、アトミック書き込み、自動バックアップ、多言語対応(中/英/日)
|
||||
- ダーク / ライト / システムテーマ、自動起動、自動アップデーター、アトミック書き込み、自動バックアップ、多言語対応(簡体中文/繁體中文/英/日)
|
||||
|
||||
## よくある質問
|
||||
|
||||
<details>
|
||||
<summary><strong>CC Switch はどの AI CLI ツールに対応していますか?</strong></summary>
|
||||
<summary><strong>CC Switch はどの AI ツールに対応していますか?</strong></summary>
|
||||
|
||||
CC Switch は **Claude Code**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw** の 5 つのツールに対応しています。各ツールに専用のプロバイダプリセットと設定管理が用意されています。
|
||||
CC Switch は **Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw**、**Hermes** の 7 つのツールに対応しています。各ツールに専用のプロバイダプリセットと設定管理が用意されています。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -279,8 +285,8 @@ CC Switch は「最小限の介入」という設計原則に従っています
|
||||
|
||||
- **MCP**: 「MCP」ボタンをクリック → テンプレートまたはカスタム設定でサーバーを追加 → アプリごとの同期をトグルで切り替え
|
||||
- **Prompts**: 「Prompts」をクリック → Markdown エディタでプリセットを作成 → 有効化してライブファイルに同期
|
||||
- **Skills**: 「Skills」をクリック → GitHub リポジトリを閲覧 → ワンクリックですべてのアプリにインストール
|
||||
- **Sessions**: 「Sessions」をクリック → すべてのアプリの会話履歴を閲覧・検索・復元
|
||||
- **Skills**: 「Skills」をクリック → GitHub リポジトリを閲覧 → 対応アプリへワンクリックでインストール
|
||||
- **Sessions**: 「Sessions」をクリック → 対応するセッションソースの会話履歴を閲覧・検索・復元
|
||||
|
||||
> **補足**: 初回起動時に、既存の CLI ツール設定を手動でインポートしてデフォルトプロバイダとして使用できます。
|
||||
|
||||
@@ -493,7 +499,7 @@ pnpm test:unit --coverage
|
||||
│ ├── lib/
|
||||
│ │ ├── api/ # Tauri API ラッパー(型安全)
|
||||
│ │ └── query/ # TanStack Query 設定
|
||||
│ ├── locales/ # 翻訳 (zh/en/ja)
|
||||
│ ├── locales/ # 翻訳 (zh/zh-TW/en/ja)
|
||||
│ ├── config/ # プリセット (providers/mcp)
|
||||
│ └── types/ # TypeScript 型定義
|
||||
├── src-tauri/ # バックエンド (Rust)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes Agent 的全方位管理工具
|
||||
### Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes Agent 的全方位管理工具
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -43,17 +43,17 @@ MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构
|
||||
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>感谢胜算云赞助了本项目!胜算云是专为AI Native Teams服务的超级工厂,工业级AI任务并行执行平台,模型商城集采直供聚合接入了Claude、Chatgpt、Gemini等海内外LLM及图片视频多媒体模型算力,绝无逆向掺水、全站模型SLA可用性高达99.7%、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">监测接口</a>日常全绿。更有企业级专属定制网关,实现团队精细化成本与权限管控,智能路由+安全防护+BYOK企业自带密钥托管。平台按量及tokens plan(即将上线)计费,可开票,使用<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">此链接</a>注册新用户可获10元模力及首充10%赠送。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。
|
||||
Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CCSwitch 的用户提供了特别福利,通过<a href="https://www.aicodemirror.com/register?invitecode=9915W3">此链接</a>注册的用户,可享受首充8折,企业客户最高可享 7.5 折!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>感谢胜算云赞助了本项目!胜算云是专为AI Native Teams服务的超级工厂,工业级AI任务并行执行平台,模型商城集采直供聚合接入了Claude、Chatgpt、Gemini等海内外LLM及图片视频多媒体模型算力,绝无逆向掺水、全站模型SLA可用性高达99.7%、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">监测接口</a>日常全绿。更有企业级专属定制网关,实现团队精细化成本与权限管控,智能路由+安全防护+BYOK企业自带密钥托管。平台按量及tokens plan(即将上线)计费,可开票,使用<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">此链接</a>注册新用户可获10元模力及首充10%赠送。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/"><img src="assets/partners/logos/pateway.png" alt="PatewayAI" width="150"></a></td>
|
||||
<td>感谢 PatewayAI 赞助了本项目!PatewayAI 是一家面向重度 AI 开发者、专注官方直连高品质模型 API 中转服务商。提供 Claude 全系列与 Codex 系列模型,100% 官方源直供,不掺假不注水,欢迎检验。计费透明,Token 级账单可逐笔核验。
|
||||
@@ -62,8 +62,8 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
|
||||
<td>感谢火山方舟Agent Plan 模型赞助了本项目!方舟Agent Plan 模型订阅套餐集成了包含Doubao-Seed、Doubao-Seedance、Doubao-Seedream等在内的字节跳动自研SOTA级模型,覆盖文本、代码、图像、视频等多模态任务。同时支持一站式接入DeepSeek V4、GLM 5.1等主流大模型。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。方舟 Agent Plan 为 CC Switch 的用户提供了专属福利:通过<a href="https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">此链接</a>订阅方舟AgentPlan,新客户首月40元起!<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">>>For developers outside Mainland China, please click here</a></td>
|
||||
<td width="180"><a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
|
||||
<td>感谢火山方舟 Agent Plan 模型赞助了本项目!方舟 Agent Plan 模型订阅套餐集成了包含 Doubao-Seed、Doubao-Seedance、Doubao-Seedream 等在内的字节跳动自研 SOTA 级模型,覆盖文本、代码、图像、视频等多模态任务。最新支持 MiniMax-M3、DeepSeek-V4 系列、GLM-5.1、Doubao-Seed-2.0 系列、Kimi-K2.6 等模型,工具不限。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。一次订阅,可以为不同任务切换合适的 AI 引擎。方舟 Coding Plan 为 CC Switch 的用户提供了专属福利:通过<a href="https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">此链接</a>订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠,再用专属邀请码 6J6FV5N2 领取奖励叠加 9.5 折,低至 9.4 元/月!<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">>>For developers outside Mainland China, please click here</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -106,19 +106,15 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>感谢 米醋API 赞助了本项目!米醋API 是一家致力于提供极致性价比与高稳定性的全球大模型中转服务商。米醋API 背后有实体企业做核心保障,杜绝跑路风险,支持极速正规开票!我们主打“试错零成本”:1 元起充低门槛,0 手续费随时退款!米醋API 为本软件的用户提供了特别优惠,使用<a href="https://www.micuapi.ai/register?aff=aOYQ">此链接</a>注册并在充值时填写"ccswitch"优惠码可享九折优惠!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://lemondata.cc/r/FFX1ZDUP"><img src="assets/partners/logos/lemondata.png" alt="LemonData" width="150"></a></td>
|
||||
<td>感谢 LemonData 赞助了本项目!LemonData 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 GPT、Claude、Gemini、DeepSeek 等 300+ 模型。所有模型定价为官方价格的 30%-70%,支持自动故障转移、智能路由和无限并发。新用户注册即获 $1 免费额度——通过<a href="https://lemondata.cc/r/FFX1ZDUP">此链接</a>注册即可领取奖励,立即开始开发!</td>
|
||||
<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://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://console.claudeapi.com/register?aff=pCLD”><img src=”assets/partners/logos/claudeapi.png” alt=”ClaudeAPI” width=”150”></a></td>
|
||||
<td>本项目由 <a href=”https://console.claudeapi.com/register?aff=pCLD”>Claude API</a> 赞助。Claude API 直连,三分钟接入 Claude Code 与 Agent 应用 新用户可领取测试额度。基于 Anthropic 官方 Key + AWS Bedrock 官方渠道,非逆向、非降智,支持 Opus / Sonnet / Haiku 全系列模型,保留 Tool Use、1M 上下文等官方能力。适合 Claude Code 深度用户、Agent 工程师与企业技术团队,支持开票和团队对接。点击<a href=”https://console.claudeapi.com/register?aff=pCLD”>这里</a>注册!</td>
|
||||
<td width="180"><a href="https://console.claudeapi.com/register?aff=pCLD"><img src="assets/partners/logos/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
|
||||
<td>本项目由 <a href="https://console.claudeapi.com/register?aff=pCLD">Claude API</a> 赞助。Claude API 直连,三分钟接入 Claude Code 与 Agent 应用 新用户可领取测试额度。基于 Anthropic 官方 Key + AWS Bedrock 官方渠道,非逆向、非降智,支持 Opus / Sonnet / Haiku 全系列模型,保留 Tool Use、1M 上下文等官方能力。适合 Claude Code 深度用户、Agent 工程师与企业技术团队,支持开票和团队对接。点击<a href="https://console.claudeapi.com/register?aff=pCLD">这里</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -146,19 +142,29 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
<td>Atlas Cloud 是一个全模态 AI 推理平台,通过单一 API 为开发者提供视频生成、图像生成及 LLM 接入。免去繁琐的多供应商对接,一次连接即可调用 300+ 款全模态精选模型。立即查看 Atlas Cloud 全新<a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">“编程计划”</a>优惠,获取更具性价比的 API 接入!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.jpg" alt="CCSub" width="150"></a></td>
|
||||
<td>感谢 CCSub 赞助本项目!CCSub 是稳定、实惠的 AI API 中转平台,是 Claude Code 官方订阅的超强平替。一个 API Key 即可调用 Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek 全系列模型,价格约为官方直连的 1/3,全球直连无需梯子。兼容 Claude Code、Codex、Cursor、Cline、Continue、Windsurf 等所有主流 AI 编程工具。通过<a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">此链接</a>注册即送 $5 体验额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>感谢 Unity2.ai 赞助了本项目!Unity2.ai 是面向个人开发者、团队和企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。支持余额计费、首充赠额、组合订阅、企业开票和专属对接。通过<a href="https://unity2.ai/register?source=ccs">此链接</a>注册可领取 $2 余额,加入官方群再送 $10 余额,最高可领 $12 免费额度!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
## 为什么选择 CC Switch?
|
||||
|
||||
现代 AI 编程依赖于 Claude Code、Codex、Gemini CLI、OpenCode 和 OpenClaw 等 CLI 工具——但每个工具都有自己的配置格式。切换 API 供应商意味着手动编辑 JSON、TOML 或 `.env` 文件,而在多个工具之间缺乏一个统一管理 MCP, SKILLS 的方式。
|
||||
现代 AI 编程依赖于 Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes 等工具——但每个工具都有自己的配置格式。切换 API 供应商意味着手动编辑 JSON、TOML 或 `.env` 文件,而在多个工具之间缺乏一个统一管理 MCP, SKILLS 的方式。
|
||||
|
||||
**CC Switch** 为你提供一个桌面应用来管理所有五个 CLI 工具。无需手动编辑配置文件,你将获得一个可视化界面,一键将供应商导入应用,一键在不同的供应商之间进行切换,内置 50+ 供应商预设、统一的 MCP, SKILLS 管理以及系统托盘即时切换功能——所有操作都基于可靠的 SQLite 数据库和原子写入机制,保护你的配置不被损坏。
|
||||
**CC Switch** 为你提供一个桌面应用来管理所有支持的 AI 工具。无需手动编辑配置文件,你将获得一个可视化界面,一键将供应商导入应用,一键在不同的供应商之间进行切换,内置 50+ 供应商预设、统一的 MCP, SKILLS 管理以及系统托盘即时切换功能——所有操作都基于可靠的 SQLite 数据库和原子写入机制,保护你的配置不被损坏。
|
||||
|
||||
- **一个应用,五个 CLI 工具** — 在单一界面中管理 Claude Code、Codex、Gemini CLI、OpenCode 和 OpenClaw
|
||||
- **一个应用,七个工具** — 在单一界面中管理 Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes
|
||||
- **告别手动编辑** — 50+ 供应商预设,包括 AWS Bedrock、NVIDIA NIM 和社区中转服务;一键即可切换
|
||||
- **统一 MCP, SKILLS 管理** — 一个面板管理四个应用的 MCP, SKILLS, 支持双向同步
|
||||
- **统一 MCP, SKILLS 管理** — 一个面板管理 Claude、Codex、Gemini、OpenCode 和 Hermes 的 MCP, SKILLS, 支持双向同步
|
||||
- **系统托盘快速切换** — 从托盘菜单即时切换供应商,无需打开完整应用
|
||||
- **云同步** — 通过 Dropbox、OneDrive、iCloud 或 WebDAV 服务器在不同设备之间同步供应商数据
|
||||
- **跨平台** — 基于 Tauri 2 构建的原生桌面应用,支持 Windows、macOS 和 Linux
|
||||
@@ -172,12 +178,12 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
|
||||
## 功能特性
|
||||
|
||||
[完整更新日志](CHANGELOG.md) | [发布说明](docs/release-notes/v3.15.0-zh.md)
|
||||
[完整更新日志](CHANGELOG.md) | [发布说明](docs/release-notes/v3.16.1-zh.md)
|
||||
|
||||
### 供应商管理
|
||||
|
||||
- **5 个 CLI 工具,50+ 预设** — Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw;复制 key 即可一键导入
|
||||
- **通用供应商** — 一份配置同步到多个应用(OpenCode、OpenClaw)
|
||||
- **7 个支持工具,50+ 预设** — Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes;复制 key 即可一键导入
|
||||
- **通用供应商** — 一份配置同步到 Claude Code、Codex 和 Gemini CLI
|
||||
- 一键切换、系统托盘快速访问、拖拽排序、导入导出
|
||||
|
||||
### 代理与故障转移
|
||||
@@ -187,7 +193,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
|
||||
### MCP、Prompts 与 Skills
|
||||
|
||||
- **统一 MCP 面板** — 管理 4 个应用的 MCP 服务器,双向同步,支持 Deep Link 导入
|
||||
- **统一 MCP 面板** — 管理 Claude、Codex、Gemini、OpenCode 和 Hermes 的 MCP 服务器,双向同步,支持 Deep Link 导入
|
||||
- **Prompts** — Markdown 编辑器,跨应用同步(CLAUDE.md / AGENTS.md / GEMINI.md),回填保护
|
||||
- **Skills** — 从 GitHub 仓库或 ZIP 文件一键安装,自定义仓库管理,支持软连接和文件复制
|
||||
|
||||
@@ -197,21 +203,21 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
|
||||
### 会话管理器与工作区
|
||||
|
||||
- 浏览、搜索和恢复全部应用对话历史
|
||||
- 浏览、搜索和恢复支持的会话来源
|
||||
- **工作区编辑器**(OpenClaw)— 编辑 Agent 文件(AGENTS.md、SOUL.md 等),支持 Markdown 预览
|
||||
|
||||
### 系统与平台
|
||||
|
||||
- **云同步** — 自定义配置目录(Dropbox、OneDrive、iCloud、坚果云、NAS)及 WebDAV 服务器同步
|
||||
- **Deep Link** (`ccswitch://`) — 通过 URL 一键导入供应商、MCP 服务器、提示词和技能
|
||||
- 深色 / 浅色 / 跟随系统主题、开机自启、自动更新、原子写入、自动备份、国际化(中/英/日)
|
||||
- 深色 / 浅色 / 跟随系统主题、开机自启、自动更新、原子写入、自动备份、国际化(简中/繁中/英/日)
|
||||
|
||||
## 常见问题
|
||||
|
||||
<details>
|
||||
<summary><strong>CC Switch 支持哪些 AI CLI 工具?</strong></summary>
|
||||
<summary><strong>CC Switch 支持哪些 AI 工具?</strong></summary>
|
||||
|
||||
CC Switch 支持五个工具:**Claude Code**、**Codex**、**Gemini CLI**、**OpenCode** 和 **OpenClaw**。每个工具都有专属的供应商预设和配置管理。
|
||||
CC Switch 支持七个工具:**Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw** 和 **Hermes**。每个工具都有专属的供应商预设和配置管理。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -282,8 +288,8 @@ CC Switch macOS 版本已通过 Apple 代码签名和公证,可直接下载安
|
||||
|
||||
- **MCP**:点击"MCP"按钮 → 通过模板或自定义配置添加服务器 → 切换各应用同步开关
|
||||
- **Prompts**:点击"Prompts" → 使用 Markdown 编辑器创建预设 → 激活后同步到 live 文件
|
||||
- **Skills**:点击"Skills" → 浏览 GitHub 仓库 → 一键安装到全部应用
|
||||
- **会话**:点击"Sessions" → 浏览和搜索和恢复全部应用对话历史
|
||||
- **Skills**:点击"Skills" → 浏览 GitHub 仓库 → 一键安装到支持的应用
|
||||
- **会话**:点击"Sessions" → 浏览、搜索和恢复支持的会话来源
|
||||
|
||||
> **注意**:首次启动可以手动导入现有 CLI 工具配置作为默认供应商。
|
||||
|
||||
@@ -496,7 +502,7 @@ pnpm test:unit --coverage
|
||||
│ ├── lib/
|
||||
│ │ ├── api/ # Tauri API 封装(类型安全)
|
||||
│ │ └── query/ # TanStack Query 配置
|
||||
│ ├── locales/ # 翻译 (zh/en/ja)
|
||||
│ ├── locales/ # 翻译 (zh/zh-TW/en/ja)
|
||||
│ ├── config/ # 预设 (providers/mcp)
|
||||
│ └── types/ # TypeScript 类型定义
|
||||
├── src-tauri/ # 后端 (Rust)
|
||||
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 88 KiB |
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* 统一供应商(Universal Provider)预设配置
|
||||
*
|
||||
* 统一供应商是跨应用共享的配置,修改后会自动同步到 Claude、Codex、Gemini 三个应用。
|
||||
* 适用于 NewAPI 等支持多种协议的 API 网关。
|
||||
*/
|
||||
|
||||
import type {
|
||||
UniversalProvider,
|
||||
UniversalProviderApps,
|
||||
UniversalProviderModels,
|
||||
} from "@/types";
|
||||
|
||||
/**
|
||||
* 统一供应商预设接口
|
||||
*/
|
||||
export interface UniversalProviderPreset {
|
||||
/** 预设名称 */
|
||||
name: string;
|
||||
/** 供应商类型标识 */
|
||||
providerType: string;
|
||||
/** 默认启用的应用 */
|
||||
defaultApps: UniversalProviderApps;
|
||||
/** 默认模型配置 */
|
||||
defaultModels: UniversalProviderModels;
|
||||
/** 网站链接 */
|
||||
websiteUrl?: string;
|
||||
/** 图标名称 */
|
||||
icon?: string;
|
||||
/** 图标颜色 */
|
||||
iconColor?: string;
|
||||
/** 描述 */
|
||||
description?: string;
|
||||
/** 是否为自定义模板(允许用户完全自定义) */
|
||||
isCustomTemplate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* NewAPI 默认模型配置
|
||||
*/
|
||||
const NEWAPI_DEFAULT_MODELS: UniversalProviderModels = {
|
||||
claude: {
|
||||
model: "claude-sonnet-4-20250514",
|
||||
haikuModel: "claude-haiku-4-20250514",
|
||||
sonnetModel: "claude-sonnet-4-20250514",
|
||||
opusModel: "claude-sonnet-4-20250514",
|
||||
},
|
||||
codex: {
|
||||
model: "gpt-4o",
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
gemini: {
|
||||
model: "gemini-2.5-pro",
|
||||
},
|
||||
};
|
||||
|
||||
const N1N_DEFAULT_MODELS: UniversalProviderModels = {
|
||||
claude: {
|
||||
model: "claude-3-5-sonnet-20240620",
|
||||
haikuModel: "claude-3-haiku-20240307",
|
||||
sonnetModel: "claude-3-5-sonnet-20240620",
|
||||
opusModel: "claude-3-opus-20240229",
|
||||
},
|
||||
codex: {
|
||||
model: "gpt-4o",
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
gemini: {
|
||||
model: "gemini-1.5-pro-latest",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一供应商预设列表
|
||||
*/
|
||||
export const universalProviderPresets: UniversalProviderPreset[] = [
|
||||
{
|
||||
name: "n1n.ai",
|
||||
providerType: "n1n",
|
||||
defaultApps: {
|
||||
claude: true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
},
|
||||
defaultModels: N1N_DEFAULT_MODELS,
|
||||
websiteUrl: "https://n1n.ai",
|
||||
icon: "openai",
|
||||
iconColor: "#000000",
|
||||
description:
|
||||
"n1n.ai - 聚合 OpenAI, Anthropic, Google 等主流大模型的一站式 AI 服务平台",
|
||||
},
|
||||
{
|
||||
name: "NewAPI",
|
||||
providerType: "newapi",
|
||||
defaultApps: {
|
||||
claude: true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
},
|
||||
defaultModels: NEWAPI_DEFAULT_MODELS,
|
||||
websiteUrl: "https://www.newapi.pro",
|
||||
icon: "newapi",
|
||||
iconColor: "#00A67E",
|
||||
description:
|
||||
"NewAPI 是一个可自部署的 API 网关,支持 Anthropic、OpenAI、Gemini 等多种协议",
|
||||
},
|
||||
{
|
||||
name: "自定义网关",
|
||||
providerType: "custom_gateway",
|
||||
defaultApps: {
|
||||
claude: true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
},
|
||||
defaultModels: NEWAPI_DEFAULT_MODELS,
|
||||
icon: "openai",
|
||||
iconColor: "#6366F1",
|
||||
description: "自定义配置的 API 网关",
|
||||
isCustomTemplate: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 根据预设创建统一供应商
|
||||
*/
|
||||
export function createUniversalProviderFromPreset(
|
||||
preset: UniversalProviderPreset,
|
||||
id: string,
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
customName?: string,
|
||||
): UniversalProvider {
|
||||
return {
|
||||
id,
|
||||
name: customName || preset.name,
|
||||
providerType: preset.providerType,
|
||||
apps: { ...preset.defaultApps },
|
||||
baseUrl,
|
||||
apiKey,
|
||||
models: JSON.parse(JSON.stringify(preset.defaultModels)), // Deep copy
|
||||
websiteUrl: preset.websiteUrl,
|
||||
icon: preset.icon,
|
||||
iconColor: preset.iconColor,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取预设的显示名称(用于 UI)
|
||||
*/
|
||||
export function getPresetDisplayName(preset: UniversalProviderPreset): string {
|
||||
return preset.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据类型查找预设
|
||||
*/
|
||||
export function findPresetByType(
|
||||
providerType: string,
|
||||
): UniversalProviderPreset | undefined {
|
||||
return universalProviderPresets.find((p) => p.providerType === providerType);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
# Using DeepSeek-Style Chat APIs in Codex: CC Switch Local Routing Guide
|
||||
|
||||
> Applies to CC Switch 3.16.0 and nearby versions. This guide is based on the repository documentation and code, and uses DeepSeek as an example of an OpenAI Chat Completions-compatible API. Screenshots are generated from the current frontend UI with de-identified sample data to avoid exposing a real API key or account balance.
|
||||
|
||||
## Why local routing is needed
|
||||
|
||||
The newer Codex CLI targets the OpenAI Responses API, while DeepSeek, Kimi, MiniMax, SiliconFlow, and many other providers expose the OpenAI Chat Completions shape, usually `/chat/completions`. These two protocols use different request bodies, streaming events, and response structures. If you put a Chat endpoint directly into Codex configuration, common results include an incorrect model list, 404/400 requests, or streaming responses that Codex cannot parse correctly.
|
||||
|
||||
CC Switch solves this by making Codex always talk to a local route and continue sending Responses API requests. The route detects whether the active provider is Chat-format, rewrites the request into Chat Completions for the upstream provider, and finally converts the Chat response back into the Responses shape that Codex understands.
|
||||
|
||||

|
||||
|
||||
The chain has four main steps:
|
||||
|
||||
1. When Codex routing is enabled, the local configuration is written as `http://127.0.0.1:15721/v1`, while `wire_api = "responses"` is kept in place.
|
||||
2. The provider's `meta.apiFormat = "openai_chat"` tells the route that the real upstream is Chat Completions.
|
||||
3. The route rewrites `/responses` or `/v1/responses` to `/chat/completions`, and converts the Responses request body into a Chat request body.
|
||||
4. After the upstream responds, the route converts the Chat JSON or SSE stream back into Responses JSON/SSE.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Prepare these three things first:
|
||||
|
||||
- CC Switch installed and able to start.
|
||||
- Codex CLI installed and run at least once, so the `~/.codex/config.toml` directory structure exists.
|
||||
- An API key from DeepSeek or another Chat Completions provider.
|
||||
|
||||
DeepSeek's official documentation currently lists the OpenAI-compatible base URL as `https://api.deepseek.com` (other providers often use a base URL with a `/v1` suffix), and the Chat API path as `/chat/completions`. CC Switch's DeepSeek preset already contains these details, so prefer the preset and do not manually assemble the endpoint path.
|
||||
|
||||
## Step 1: Add a Codex provider
|
||||
|
||||
Open CC Switch, switch to the top-level `Codex` tab, and click the plus button in the upper-right corner to add a provider.
|
||||
|
||||
Choose the built-in `DeepSeek` preset. You only need to do two things:
|
||||
|
||||
- Enter your DeepSeek API key.
|
||||
- Save the provider.
|
||||
|
||||

|
||||
|
||||
The preset already includes DeepSeek's request base URL, default model, model menu, thinking/reasoning parameters, and automatically enables `Needs Local Routing`. You can adjust the default model or model display names if needed; the protocol conversion is handled by the routing layer.
|
||||
|
||||
## Step 2: Enable local routing and route Codex
|
||||
|
||||
Go to the `Routing` page in Settings, expand `Local Routing`, and complete two toggles:
|
||||
|
||||
1. Turn on the main routing switch to start the local service. The default address is `127.0.0.1:15721`.
|
||||
2. Turn on `Codex` under `Routing Enabled`. If you only want Codex to use local routing, you can leave Claude and Gemini off.
|
||||
|
||||

|
||||
|
||||
After routing is enabled, CC Switch points Codex's live configuration to the local route and manages authentication with a placeholder. The real DeepSeek key stays in the CC Switch provider configuration and is injected by the local route while forwarding requests, so you do not need to expose the key in Codex's live configuration.
|
||||
|
||||
## Step 3: Switch providers and restart Codex
|
||||
|
||||
Return to the Codex provider list and click `Enable` on the DeepSeek provider. If you see the `Needs Routing` marker, that provider must be used while routing is running; when the route is not started, CC Switch shows a prompt saying the routing service is required.
|
||||
|
||||
After switching, restart the current Codex terminal session. This is recommended because:
|
||||
|
||||
- The Codex process may already have read the old `config.toml`.
|
||||
- After `model_catalog_json` is generated, the `/model` menu usually needs a fresh process before it refreshes.
|
||||
|
||||
Inside Codex, use `/model` to check whether the current model comes from the DeepSeek preset, such as `DeepSeek V4 Flash`. The Codex app currently does not support multi-model selection, so it defaults to the first configured model. Then send a small test prompt and confirm that the request count increases in the routing panel, or that a Codex request appears in usage/request logs.
|
||||
|
||||
## How to handle other Chat providers
|
||||
|
||||
DeepSeek, Kimi, MiniMax, SiliconFlow, and other common Chat-format providers already have presets in CC Switch, so use presets first. Only choose custom configuration for providers that are not covered by presets; in that case, fill in the API key, base URL, and models according to the provider's documentation, and set `API Format` to `OpenAI Chat Completions (requires routing)`.
|
||||
|
||||
If the upstream provider directly supports the OpenAI Responses API, you do not need to enable `Needs Local Routing`; CC Switch can connect through Responses directly without Chat conversion.
|
||||
|
||||
## FAQ
|
||||
|
||||
**Codex reports 404 or cannot find `/responses`**
|
||||
|
||||
Usually Codex routing is not enabled, or the upstream Chat base URL was written directly into Codex manually. Check whether `~/.codex/config.toml` points to `http://127.0.0.1:15721/v1`.
|
||||
|
||||
**DeepSeek upstream reports 404**
|
||||
|
||||
If you are using the built-in DeepSeek preset, first confirm that the active provider really comes from the preset and that Codex routing is enabled. Only custom providers require extra base URL checks: the base URL should be the service root, not the full endpoint path with `/chat/completions`.
|
||||
|
||||
**`/model` does not show DeepSeek models**
|
||||
|
||||
Restart Codex after saving the provider. CC Switch generates `cc-switch-model-catalog.json` and writes its path to `model_catalog_json`, but a running Codex process may not hot-load the model catalog.
|
||||
The Codex app currently does not support multi-model selection, so it uses the first configured model by default.
|
||||
|
||||
**Routing is enabled, but requests still go to the wrong provider**
|
||||
|
||||
Confirm that all three states match: the current provider under the Codex tab is DeepSeek; the local routing service is running; and the Codex toggle is enabled under `Routing Enabled`.
|
||||
|
||||
**Can I use an official OpenAI Codex account through local routing?**
|
||||
|
||||
Not recommended. CC Switch blocks switching to official providers while local routing takeover is enabled, because accessing official APIs through a proxy may create account risk. Routing is mainly intended for third-party, aggregator, or protocol-conversion scenarios.
|
||||
|
||||
## References
|
||||
|
||||
- [CC Switch User Manual: Add Provider](../user-manual/en/2-providers/2.1-add.md)
|
||||
- [CC Switch User Manual: Proxy Service](../user-manual/en/4-proxy/4.1-service.md)
|
||||
- [CC Switch User Manual: App Routing](../user-manual/en/4-proxy/4.2-routing.md)
|
||||
- [DeepSeek API Docs: Your First API Call](https://api-docs.deepseek.com/)
|
||||
- [DeepSeek API Docs: Create Chat Completion](https://api-docs.deepseek.com/api/create-chat-completion)
|
||||
- [DeepSeek API Docs: Multi-round Conversation](https://api-docs.deepseek.com/guides/multi_round_chat)
|
||||
@@ -0,0 +1,101 @@
|
||||
# Codex で DeepSeek などの Chat 形式 API を使う: CC Switch ローカルルーティングガイド
|
||||
|
||||
> 対象バージョン: CC Switch 3.16.0 およびその前後のバージョン。本記事はリポジトリ内のドキュメントとコードをもとに整理し、OpenAI Chat Completions 互換 API の例として DeepSeek を使用します。スクリーンショットは現在のフロントエンド UI から、実際の API Key やアカウント残高が漏れないよう匿名化したサンプルデータで生成しています。
|
||||
|
||||
## ローカルルーティングが必要な理由
|
||||
|
||||
新しい Codex CLI は OpenAI Responses API を前提にしています。一方で DeepSeek、Kimi、MiniMax、SiliconFlow など多くのプロバイダーが実際に公開しているのは OpenAI Chat Completions 形式、つまり `/chat/completions` です。この 2 つのプロトコルは、リクエストボディ、ストリーミングイベント、レスポンス構造が異なります。Chat エンドポイントをそのまま Codex 設定に入れると、モデル一覧が合わない、リクエストが 404/400 になる、ストリーミングレスポンスを Codex が正しく解析できない、といった問題が起きがちです。
|
||||
|
||||
CC Switch では、Codex が常にローカルルートへ接続し、Responses API のままリクエストを送るようにします。ルート内部で現在のプロバイダーが Chat 形式かどうかを判定し、必要ならリクエストを Chat Completions に書き換えて上流へ送り、最後に Chat レスポンスを Codex が理解できる Responses 形式へ戻します。
|
||||
|
||||

|
||||
|
||||
この経路は主に 4 つのステップに分かれます:
|
||||
|
||||
1. Codex ルーティングを有効にすると、ローカル設定は `http://127.0.0.1:15721/v1` に書き換えられ、`wire_api = "responses"` は維持されます。
|
||||
2. Provider の `meta.apiFormat = "openai_chat"` が、実際の上流は Chat Completions だとルートに伝えます。
|
||||
3. ルートは `/responses` または `/v1/responses` を `/chat/completions` に書き換え、Responses のリクエストボディを Chat のリクエストボディへ変換します。
|
||||
4. 上流から返ってきた後、ルートは Chat の JSON または SSE ストリームを Responses JSON/SSE へ変換して返します。
|
||||
|
||||
## 事前準備
|
||||
|
||||
先に次の 3 つを用意してください:
|
||||
|
||||
- インストール済みで起動できる CC Switch。
|
||||
- インストール済みの Codex CLI。少なくとも 1 回は実行し、`~/.codex/config.toml` のディレクトリ構造が存在していること。
|
||||
- DeepSeek または同種の Chat Completions プロバイダーの API Key。
|
||||
|
||||
DeepSeek 公式ドキュメントでは、OpenAI 互換 base URL は現在 `https://api.deepseek.com`(他のプロバイダーでは `/v1` 付きの base URL もよくあります)、Chat API のパスは `/chat/completions` と記載されています。CC Switch の DeepSeek プリセットにはこれらの情報がすでに入っているため、まずはプリセットを使い、エンドポイントパスを手で組み立てる必要はありません。
|
||||
|
||||
## Step 1: Codex プロバイダーを追加する
|
||||
|
||||
CC Switch を開き、上部の `Codex` タブへ切り替え、右上のプラスボタンからプロバイダーを追加します。
|
||||
|
||||
内蔵プリセットの `DeepSeek` を選びます。必要なのは次の 2 つだけです:
|
||||
|
||||
- DeepSeek API Key を入力する。
|
||||
- プロバイダーを保存する。
|
||||
|
||||

|
||||
|
||||
プリセットには DeepSeek のリクエスト先、デフォルトモデル、モデルメニュー、thinking/reasoning パラメータがすでに含まれており、`ローカルルーティングが必要` も自動的に有効になります。必要に応じてデフォルトモデルやモデル表示名を調整できますが、プロトコル変換はルーティング層に任せれば十分です。
|
||||
|
||||
## Step 2: ローカルルーティングを有効にして Codex をルーティングする
|
||||
|
||||
設定の `ルーティング` ページに入り、`ローカルルーティング` を展開して、次の 2 つのスイッチを設定します:
|
||||
|
||||
1. `ルーティング総スイッチ` をオンにしてローカルサービスを起動します。デフォルトアドレスは `127.0.0.1:15721` です。
|
||||
2. `ルーティング有効` で `Codex` をオンにします。Codex だけをルーティングしたい場合は、Claude と Gemini はオフのままで構いません。
|
||||
|
||||

|
||||
|
||||
ルーティングを有効にすると、CC Switch は Codex の live 設定をローカルルートへ向け、認証はプレースホルダーで管理します。実際の DeepSeek Key は CC Switch の Provider 設定内に残り、ローカルルートが転送時に注入します。そのため、Codex の live 設定に Key を露出させる必要はありません。
|
||||
|
||||
## Step 3: プロバイダーを切り替えて Codex を再起動する
|
||||
|
||||
Codex プロバイダー一覧に戻り、DeepSeek プロバイダーの `有効化` をクリックします。`ルーティングが必要` の表示が見える場合、そのプロバイダーはルーティング実行中に使う必要があります。ルーティングが起動していない場合、CC Switch は「ルーティングサービスが必要」という趣旨のメッセージを表示します。
|
||||
|
||||
切り替え後は、現在の Codex ターミナルセッションを再起動することをおすすめします。理由は次のとおりです:
|
||||
|
||||
- Codex プロセスがすでに古い `config.toml` を読み込んでいる可能性があります。
|
||||
- `model_catalog_json` の生成後、`/model` メニューの更新には通常、新しいプロセスが必要です。
|
||||
|
||||
Codex に入ったら、`/model` で現在のモデルが DeepSeek プリセット由来かどうかを確認します。たとえば `DeepSeek V4 Flash` などです。現在の Codex app は複数モデル選択に対応していないため、設定内の最初のモデルをデフォルトで使用します。その後、小さな質問を 1 つ送って、ルーティングパネルのリクエスト数が増えるか、usage / リクエストログに Codex リクエストが出るかを確認します。
|
||||
|
||||
## 他の Chat プロバイダーの場合
|
||||
|
||||
DeepSeek、Kimi、MiniMax、SiliconFlow など一般的な Chat 形式プロバイダーは CC Switch にプリセットがあるため、まずはプリセットを使ってください。プリセットにないプロバイダーだけ、カスタム設定を選びます。その場合は相手側のドキュメントに従って API Key、base URL、モデルを入力し、`API 形式` を `OpenAI Chat Completions (ルーティングが必要)` に設定します。
|
||||
|
||||
上流が OpenAI Responses API を直接サポートしている場合は、`ローカルルーティングが必要` を有効にする必要はありません。その場合、CC Switch は Responses のまま直結でき、Chat 変換は行いません。
|
||||
|
||||
## よくある質問
|
||||
|
||||
**Codex が 404 を返す、または `/responses` が見つからない**
|
||||
|
||||
多くの場合、Codex ルーティングが有効になっていないか、上流 Chat base URL を手動で Codex に直接書いています。`~/.codex/config.toml` が `http://127.0.0.1:15721/v1` を指しているか確認してください。
|
||||
|
||||
**DeepSeek 上流が 404 を返す**
|
||||
|
||||
内蔵 DeepSeek プリセットを使っている場合は、まず現在のプロバイダーが本当にプリセット由来であること、そして Codex ルーティングが有効であることを確認してください。カスタムプロバイダーを使っている場合だけ、base URL を追加で確認します。base URL はサービスのルートであり、`/chat/completions` 付きの完全なエンドポイントパスではありません。
|
||||
|
||||
**`/model` に DeepSeek モデルが表示されない**
|
||||
|
||||
プロバイダーを保存した後、Codex を再起動してください。CC Switch は `cc-switch-model-catalog.json` を生成し、そのパスを `model_catalog_json` に書き込みますが、実行中の Codex プロセスがモデルカタログをホットロードするとは限りません。
|
||||
現在の Codex app は複数モデル選択に対応していないため、設定内の最初のモデルをデフォルトで使用します。
|
||||
|
||||
**ルーティングを有効にしたのに、リクエストが別のプロバイダーへ行く**
|
||||
|
||||
次の 3 つの状態が一致しているか確認してください:Codex タブの現在のプロバイダーが DeepSeek であること、ローカルルーティングサービスが実行中であること、`ルーティング有効` で Codex スイッチがオンであること。
|
||||
|
||||
**公式 OpenAI Codex アカウントをローカルルーティング経由で使えますか**
|
||||
|
||||
おすすめしません。CC Switch はローカルルーティング有効中、公式プロバイダーへの切り替えをブロックします。プロキシ経由で公式 API にアクセスすると、アカウントリスクが発生する可能性があるためです。ルーティングは主にサードパーティ、集約サービス、またはプロトコル変換のための機能です。
|
||||
|
||||
## 参考リンク
|
||||
|
||||
- [CC Switch ユーザーマニュアル: プロバイダーの追加](../user-manual/ja/2-providers/2.1-add.md)
|
||||
- [CC Switch ユーザーマニュアル: プロキシサービス](../user-manual/ja/4-proxy/4.1-service.md)
|
||||
- [CC Switch ユーザーマニュアル: アプリケーションルーティング](../user-manual/ja/4-proxy/4.2-routing.md)
|
||||
- [DeepSeek API Docs: Your First API Call](https://api-docs.deepseek.com/)
|
||||
- [DeepSeek API Docs: Create Chat Completion](https://api-docs.deepseek.com/api/create-chat-completion)
|
||||
- [DeepSeek API Docs: Multi-round Conversation](https://api-docs.deepseek.com/guides/multi_round_chat)
|
||||
@@ -0,0 +1,101 @@
|
||||
# 在 Codex 中用 DeepSeek 这类 Chat 格式 API:CC Switch 本地路由攻略
|
||||
|
||||
> 适用版本:CC Switch 3.16.0 及附近版本。本文根据仓库内文档与代码整理,并用 DeepSeek 作为 OpenAI Chat Completions 兼容接口的示例。截图来自当前前端界面,使用去敏示例数据生成,避免泄露真实 API Key 或账户余额。
|
||||
|
||||
## 为什么需要本地路由
|
||||
|
||||
新版 Codex CLI 面向的是 OpenAI Responses API,而 DeepSeek、Kimi、MiniMax、SiliconFlow 等很多供应商实际暴露的是 OpenAI Chat Completions 形态,也就是 `/chat/completions`。这两种协议的请求体、流式事件和返回结构不同,直接把 Chat 接口填进 Codex 配置里,常见结果就是模型列表不对、请求 404/400,或者流式响应无法被 Codex 正确解析。
|
||||
|
||||
CC Switch 的做法是让 Codex 始终连本机路由,仍以 Responses API 发送请求;路由在内部识别当前供应商是否是 Chat 格式,再把请求改写成 Chat Completions 发给上游,最后把 Chat 响应转换回 Responses 形态返回给 Codex。
|
||||
|
||||

|
||||
|
||||
这条链路主要分成四步:
|
||||
|
||||
1. Codex 接管时,本地配置会被写成 `http://127.0.0.1:15721/v1`,并强制保持 `wire_api = "responses"`。
|
||||
2. Provider 的 `meta.apiFormat = "openai_chat"` 会告诉路由:真实上游是 Chat Completions。
|
||||
3. 路由把 `/responses` 或 `/v1/responses` 改写到 `/chat/completions`,并把 Responses 请求体转换成 Chat 请求体。
|
||||
4. 上游返回后,路由再把 Chat 的 JSON 或 SSE 转回 Codex 能理解的 Responses JSON/SSE。
|
||||
|
||||
## 准备工作
|
||||
|
||||
你需要先准备好三样东西:
|
||||
|
||||
- 已安装并能启动的 CC Switch。
|
||||
- 已安装 Codex CLI,并至少运行过一次,让 `~/.codex/config.toml` 目录结构存在。
|
||||
- DeepSeek 或同类 Chat Completions 供应商的 API Key。
|
||||
|
||||
DeepSeek 官方文档目前写明 OpenAI 兼容 base URL 是 `https://api.deepseek.com`(其他供应商常见的是带 `/v1` 后缀的 base URL),Chat API 路径是 `/chat/completions`;CC Switch 的 DeepSeek 预设已经按这些信息配好,请优先使用预设,不需要手动拼接口路径。
|
||||
|
||||
## 第一步:添加 Codex 供应商
|
||||
|
||||
打开 CC Switch,切到顶部的 `Codex` 标签,点击右上角的加号添加供应商。
|
||||
|
||||
选择内置预设里的 `DeepSeek`,只需要做两件事:
|
||||
|
||||
- 填入 DeepSeek API Key。
|
||||
- 保存供应商。
|
||||
|
||||

|
||||
|
||||
预设已经内置 DeepSeek 的请求地址、默认模型、模型菜单、thinking/reasoning 参数,并会自动打开 `需要本地路由映射`。你可以按需调整默认模型或模型显示名;协议转换交给路由层完成即可。
|
||||
|
||||
## 第二步:开启本地路由并接管 Codex
|
||||
|
||||
进入设置里的 `路由` 页面,展开 `本地路由`,完成两个开关:
|
||||
|
||||
1. 打开 `路由总开关`,启动本地服务。默认地址是 `127.0.0.1:15721`。
|
||||
2. 在 `路由启用` 中打开 `Codex`。如果只想让 Codex 走路由,可以保持 Claude、Gemini 关闭。
|
||||
|
||||

|
||||
|
||||
接管后,CC Switch 会把 Codex 的 live 配置指向本机路由,并用占位符管理认证。真实 DeepSeek Key 仍保存在 CC Switch 的 Provider 配置里,由本地路由在转发时注入,不需要你把 Key 暴露给 Codex live 配置。
|
||||
|
||||
## 第三步:切换供应商并重启 Codex
|
||||
|
||||
回到 Codex 供应商列表,点击 DeepSeek 供应商的 `启用`。如果看到 `需要路由` 标记,说明这个供应商必须在路由运行时使用;没有启动路由时,CC Switch 会弹出“需要路由服务才能正常使用”的提示。
|
||||
|
||||
切换后建议重启当前 Codex 终端会话。原因是:
|
||||
|
||||
- Codex 进程可能已经读取过旧的 `config.toml`。
|
||||
- `model_catalog_json` 生成后,`/model` 菜单通常需要新进程才能刷新。
|
||||
|
||||
进入 Codex 后,可以用 `/model` 查看当前模型是否来自 DeepSeek 预设,例如 `DeepSeek V4 Flash`。目前 Codex app 不支持多模型选择时,会默认使用配置里的第一个模型。随后发一个小问题,确认路由面板的请求数增长,或者在用量/请求日志里看到 Codex 请求即可。
|
||||
|
||||
## 其它 Chat 供应商怎么处理
|
||||
|
||||
DeepSeek、Kimi、MiniMax、SiliconFlow 等常见 Chat 格式供应商在 CC Switch 里已有预设,优先用预设即可。只有预设里没有的供应商,才需要选择自定义配置;这时按对方文档填 API Key、base URL 和模型,并把 `API 格式` 选为 `OpenAI Chat Completions (需开启路由)`。
|
||||
|
||||
如果上游直接支持 OpenAI Responses API,就不需要打开 `需要本地路由映射`;这时 CC Switch 可以按 Responses 直连,不做 Chat 转换。
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Codex 报 404 或找不到 `/responses`**
|
||||
|
||||
通常是没有开启 Codex 接管,或者你手动把上游 Chat base URL 直接写给了 Codex。检查 `~/.codex/config.toml` 是否指向 `http://127.0.0.1:15721/v1`。
|
||||
|
||||
**DeepSeek 上游报 404**
|
||||
|
||||
如果用的是内置 DeepSeek 预设,先确认当前供应商确实来自预设,并且 Codex 路由已启用。只有在使用自定义供应商时,才需要额外检查 base URL:它应该是服务根地址,而不是带 `/chat/completions` 的完整接口路径。
|
||||
|
||||
**`/model` 看不到 DeepSeek 模型**
|
||||
|
||||
保存供应商后重启 Codex。CC Switch 会生成 `cc-switch-model-catalog.json` 并把路径写入 `model_catalog_json`,但正在运行的 Codex 进程不一定会热加载模型目录。
|
||||
目前 Codex app 不支持多模型选择,默认使用配置的第一个模型。
|
||||
|
||||
**开了路由但请求仍走错供应商**
|
||||
|
||||
确认三处状态一致:Codex 标签下当前供应商是 DeepSeek;本地路由服务正在运行;`路由启用` 里 Codex 开关已打开。
|
||||
|
||||
**可以用官方 OpenAI Codex 账号走本地路由吗**
|
||||
|
||||
不建议。CC Switch 会在本地路由接管模式下阻止切到官方供应商,因为用代理访问官方 API 可能带来账号风险。路由主要用于第三方、聚合或协议转换场景。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [CC Switch 用户手册:添加供应商](../user-manual/zh/2-providers/2.1-add.md)
|
||||
- [CC Switch 用户手册:代理服务](../user-manual/zh/4-proxy/4.1-service.md)
|
||||
- [CC Switch 用户手册:应用路由](../user-manual/zh/4-proxy/4.2-routing.md)
|
||||
- [DeepSeek API 文档:Your First API Call](https://api-docs.deepseek.com/)
|
||||
- [DeepSeek API 文档:Create Chat Completion](https://api-docs.deepseek.com/api/create-chat-completion)
|
||||
- [DeepSeek API 文档:Multi-round Conversation](https://api-docs.deepseek.com/guides/multi_round_chat)
|
||||
@@ -0,0 +1,209 @@
|
||||
# Keep Codex Remote Control and Official Plugins While Using Third-Party APIs: CC Switch Setup Guide
|
||||
|
||||
> Applies to CC Switch v3.16.1 and later. This guide is based on the current code, user manual, and v3.16.1 release notes. Screenshots use de-identified sample data and do not include real Access Tokens or API keys.
|
||||
|
||||
## What this guide solves
|
||||
|
||||
Many Codex users want both of these at the same time:
|
||||
|
||||
1. Use models from DeepSeek, Kimi, GLM, MiniMax, SiliconFlow, or other third-party APIs, or use GPT models through an aggregator.
|
||||
2. Keep Codex official-app capabilities such as mobile remote control and official plugins.
|
||||
|
||||
Previously, when switching to a third-party provider, the old behavior wrote the third-party API key into Codex `auth.json`, which could overwrite the original official ChatGPT / Codex login cache. The third-party model worked, but features that depend on the official login state disappeared.
|
||||
|
||||
The **Codex App Enhancements** switch added in v3.16.1 solves this conflict: the official Access Token stays in `auth.json`, while third-party provider information is written to `config.toml`. Codex App can still see an official account, but actual model requests follow the third-party provider currently selected in CC Switch.
|
||||
|
||||
This behavior already existed in v3.16.0 and was enabled by default. After some users reported that they did not want this behavior, v3.16.1 turned it into an explicit switch.
|
||||
|
||||
## Quick answer
|
||||
|
||||
Recommended order:
|
||||
|
||||
1. In the CC Switch Codex panel, switch to `OpenAI Official`.
|
||||
2. Start Codex and log in once with an official ChatGPT / Codex account. A Free subscription is enough.
|
||||
3. Return to CC Switch and enable `Settings -> General -> Codex App Enhancements -> Keep official login when switching third-party providers`.
|
||||
4. Add or switch to a third-party Codex provider.
|
||||
5. If the provider uses the Chat Completions protocol, such as DeepSeek / Kimi / MiniMax, also enable local routing and route Codex through it.
|
||||
6. Restart Codex so `config.toml` and the model catalog are reloaded.
|
||||
|
||||

|
||||
|
||||
## Prerequisites
|
||||
|
||||
Prepare the following:
|
||||
|
||||
- CC Switch v3.16.1 or later.
|
||||
- Codex installed and able to start. Installing both the app and CLI is recommended.
|
||||
- An official ChatGPT / Codex account that can log in to Codex. A Free subscription is enough.
|
||||
- A third-party API key, such as DeepSeek, Kimi, GLM, MiniMax, OpenRouter, SiliconFlow, or similar.
|
||||
|
||||
Do not manually copy or share the contents of `~/.codex/auth.json`. It stores official login cache and Access Tokens, so it is sensitive.
|
||||
|
||||
## Step 1: Switch back to OpenAI Official and complete official login
|
||||
|
||||
Open CC Switch and switch to the top-level `Codex` tab. First select the `OpenAI Official` provider, or add it from the preset providers if it is missing, and make it the current provider.
|
||||
|
||||

|
||||
|
||||
Then start Codex, preferably the CLI, and follow the official login flow to sign in with your ChatGPT / Codex account. This account can be on the Free plan. In this setup, it mainly preserves the official identity required by Codex App, and does not pay for third-party model usage.
|
||||
|
||||
After login, Codex stores the official login cache in `~/.codex/auth.json`. The key point for the following steps is: do not let third-party provider switching overwrite this file again.
|
||||
|
||||
## Step 2: Enable Codex App Enhancements
|
||||
|
||||
Return to CC Switch and open:
|
||||
|
||||
```text
|
||||
Settings -> General -> Codex App Enhancements
|
||||
```
|
||||
|
||||
Enable:
|
||||
|
||||
```text
|
||||
Keep official login when switching third-party providers
|
||||
```
|
||||
|
||||
This switch is off by default because some users do not want this behavior. Enable it only when you explicitly want "third-party API + official remote control / official plugins" at the same time.
|
||||
|
||||
After it is enabled, backend switching for third-party Codex providers uses a config-only write path:
|
||||
|
||||
- `auth.json`: keeps the official ChatGPT / Codex login cache.
|
||||
- `config.toml`: stores the active third-party provider's model, endpoint, `model_provider`, and provider-scoped `experimental_bearer_token`.
|
||||
|
||||
## Step 3: Add a third-party Codex provider
|
||||
|
||||
Return to the Codex panel and click the plus button in the upper-right corner to add a provider. Prefer built-in presets such as DeepSeek, Kimi, MiniMax, GLM, or SiliconFlow.
|
||||
|
||||
Using DeepSeek as an example, after selecting the preset, you only need to enter the API key. The preset automatically configures the base URL, default model, model mapping table, and "Needs Local Routing" flag.
|
||||
|
||||

|
||||
|
||||
If your third-party provider natively supports the OpenAI Responses API, such as an aggregator that offers GPT models, local routing may not be needed.
|
||||
If it only supports OpenAI Chat Completions, which is common for DeepSeek / Kimi / MiniMax paths, local routing must be enabled so CC Switch can convert Codex Responses requests into Chat Completions requests.
|
||||
|
||||
## Step 4: Enable local routing and route Codex when needed
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
Settings -> Routing -> Local Routing
|
||||
```
|
||||
|
||||
Complete two actions:
|
||||
|
||||
1. Turn on the main routing switch to start the local service. The default address is usually `127.0.0.1:15721`.
|
||||
2. Under `Routing Enabled`, turn on `Codex`.
|
||||
|
||||

|
||||
|
||||
After takeover, Codex's live `config.toml` temporarily points to the CC Switch local route. The real third-party API key remains in the CC Switch provider configuration, and is projected into the `experimental_bearer_token` in `config.toml` when providers are switched.
|
||||
|
||||
## Step 5: Switch to the third-party provider and restart Codex
|
||||
|
||||
Return to the Codex provider list and enable the third-party provider you just added. After switching, restarting Codex is recommended for two reasons:
|
||||
|
||||
- Codex reads `config.toml` at startup.
|
||||
- The Codex `/model` menu usually needs a restart before it reloads `model_catalog_json`.
|
||||
|
||||
After restart, you can run a quick verification:
|
||||
|
||||
- In Codex App, the account information still shows the official account. This is expected.
|
||||
- In CC Switch, the current Codex provider is the third-party provider.
|
||||
- If local routing is enabled, request logs or routing stats show Codex requests going through the local route.
|
||||
- The third-party provider dashboard or balance records show actual model requests.
|
||||
|
||||
## How it works
|
||||
|
||||
Codex mainly uses two configuration files:
|
||||
|
||||
```text
|
||||
~/.codex/auth.json
|
||||
~/.codex/config.toml
|
||||
```
|
||||
|
||||
They have different responsibilities:
|
||||
|
||||
- `auth.json` stores the official ChatGPT / Codex login cache, which Codex App needs to identify the official account and enable remote control and official plugins.
|
||||
- `config.toml` stores runtime configuration such as the current model provider, base URL, model, model catalog, and provider-scoped token.
|
||||
|
||||
After `Keep official login when switching third-party providers` is enabled, CC Switch takes the third-party provider API key from the provider configuration and writes it under the current provider in `config.toml`:
|
||||
|
||||
```toml
|
||||
model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "DeepSeek"
|
||||
base_url = "https://api.deepseek.com"
|
||||
wire_api = "responses"
|
||||
experimental_bearer_token = "sk-..."
|
||||
```
|
||||
|
||||
At the same time, `auth.json` keeps the official login cache unchanged. Codex App can still identify the official account, while model requests follow the current provider and base URL in `config.toml`.
|
||||
|
||||
If the provider uses the Chat Completions protocol, CC Switch local routing adds another conversion layer:
|
||||
|
||||
```text
|
||||
Codex Responses request
|
||||
|
|
||||
CC Switch local route
|
||||
|
|
||||
Third-party Chat Completions API
|
||||
|
|
||||
Converted back to Codex Responses response
|
||||
```
|
||||
|
||||
This is why you can keep using official plugins / mobile remote control while moving model traffic to a third-party API.
|
||||
|
||||
## Side effects to understand
|
||||
|
||||
### Codex still shows the official account
|
||||
|
||||
This is the easiest part to misunderstand. After this capability is enabled, Codex App reads the official login state from `auth.json`, so it continues to display the official account.
|
||||
|
||||
That does not mean model requests are still going to official OpenAI. Actual traffic is determined by the current Codex provider in CC Switch, `config.toml`, and local routing logs.
|
||||
|
||||
### Do not use the Codex account display to judge billing
|
||||
|
||||
If you switch to DeepSeek, Codex can still display the official account, while model requests go to the DeepSeek API. Billing, quota, error codes, and data policy should all be understood according to the third-party provider. You can inspect specific request details in the usage panel.
|
||||
|
||||
### Restart Codex after changing model mappings
|
||||
|
||||
Codex reads the model catalog at startup. Even if CC Switch has generated a new model catalog, a running Codex process may not hot-load it, so restart Codex after editing model mappings.
|
||||
|
||||
### Turning the switch off returns to the old behavior
|
||||
|
||||
If `Keep official login when switching third-party providers` is turned off, third-party provider switching uses the compatibility behavior from older versions and may write `auth.json` again. If your goal is to keep official remote control and official plugins long term, keep this switch enabled.
|
||||
|
||||
## FAQ
|
||||
|
||||
**I switched to a third-party API. Why does Codex still show the official account?**
|
||||
|
||||
This is expected. Official account information comes from `auth.json`; the actual model provider comes from `config.toml` and the current provider in CC Switch.
|
||||
|
||||
**Is a Free subscription really enough?**
|
||||
|
||||
Yes. The official account is mainly used to obtain and preserve the official login state required by Codex App. Third-party model requests use the third-party API key configured in CC Switch.
|
||||
|
||||
**What should I do if official plugins or mobile remote control still do not work?**
|
||||
|
||||
Switch back to `OpenAI Official`, restart Codex, and complete official login once. Then confirm `Settings -> General -> Codex App Enhancements -> Keep official login when switching third-party providers` is enabled in CC Switch before switching back to the third-party provider.
|
||||
|
||||
**What if third-party requests return 404, the model list is wrong, or streaming responses are broken?**
|
||||
|
||||
If the provider uses Chat Completions, confirm that the provider form has `Needs Local Routing` enabled, and that `Settings -> Routing` has both the main routing switch and Codex takeover enabled.
|
||||
|
||||
**Can I switch back to OpenAI Official while local routing is enabled?**
|
||||
|
||||
Not recommended. CC Switch tries to prevent switching to official providers while local routing takeover is active, because accessing official APIs through a proxy may create account risk. Use official login only to preserve `auth.json`, and route model traffic to third-party providers.
|
||||
|
||||
**Why is this flow so complex? Can it be simplified?**
|
||||
|
||||
Because Codex App Enhancements and routing takeover can create unnecessary trouble for users who do not need them, these features are explicit switches instead of always-on behavior.
|
||||
|
||||
## References
|
||||
|
||||
- [Codex DeepSeek local routing hands-on guide](./codex-deepseek-routing-guide-en.md)
|
||||
- [Add a Codex provider: Chat Completions routing and model mapping](../user-manual/en/2-providers/2.1-add.md)
|
||||
- [Local Proxy Service](../user-manual/en/4-proxy/4.1-service.md)
|
||||
- [Local Routing](../user-manual/en/4-proxy/4.2-routing.md)
|
||||
- [CC Switch v3.16.1 Release Note](../release-notes/v3.16.1-en.md)
|
||||
@@ -0,0 +1,209 @@
|
||||
# サードパーティ API 利用時に Codex のリモート操作と公式プラグインを保持する: CC Switch 設定ガイド
|
||||
|
||||
> 対象バージョン: CC Switch v3.16.1 以降。本記事は現在のコード、ユーザーマニュアル、v3.16.1 Release Note をもとに整理しています。スクリーンショットは匿名化したサンプルデータを使用しており、実際の Access Token や API Key は含まれていません。
|
||||
|
||||
## このガイドで解決すること
|
||||
|
||||
Codex を使うとき、多くのユーザーには次の 2 つの要望があります。
|
||||
|
||||
1. DeepSeek、Kimi、GLM、MiniMax、SiliconFlow などのサードパーティ API、または中継サービス上の GPT モデルを使いたい。
|
||||
2. Codex 公式アプリのモバイルリモート操作、公式プラグインなどの機能は残したい。
|
||||
|
||||
以前は、サードパーティプロバイダーへ切り替えると、旧動作ではサードパーティ API Key が Codex の `auth.json` に書き込まれ、元の公式 ChatGPT / Codex ログインキャッシュを上書きする可能性がありました。これによりサードパーティモデルは使えるものの、公式ログイン状態に依存する機能が消えてしまうことがありました。
|
||||
|
||||
v3.16.1 で追加された **Codex アプリ拡張** スイッチは、この矛盾を解決するためのものです。公式 Access Token は `auth.json` に残し、サードパーティプロバイダー情報は `config.toml` に書き込みます。これにより Codex App は引き続き公式アカウントでログインしていると認識しつつ、実際のモデルリクエストは CC Switch で現在選択されているサードパーティプロバイダーへ流れます。
|
||||
|
||||
この機能自体は v3.16.0 から存在し、当時はデフォルトで有効でした。ただし一部のユーザーから不要というフィードバックがあったため、v3.16.1 で明示的なスイッチになりました。
|
||||
|
||||
## まず結論
|
||||
|
||||
おすすめの手順は次のとおりです。
|
||||
|
||||
1. CC Switch の Codex パネルで `OpenAI Official` に切り替える。
|
||||
2. Codex を起動し、公式 ChatGPT / Codex アカウントで一度ログインする。Free サブスクリプションでも構いません。
|
||||
3. CC Switch に戻り、`設定 → 一般 → Codex アプリ拡張 → サードパーティ切替時に公式ログインを保持` をオンにする。
|
||||
4. サードパーティ Codex プロバイダーを追加、または切り替える。
|
||||
5. そのプロバイダーが DeepSeek / Kimi / MiniMax などの Chat Completions プロトコルの場合は、ローカルルーティングも有効化し、Codex のルーティングをオンにする。
|
||||
6. Codex を再起動し、`config.toml` とモデルカタログを再読み込みさせる。
|
||||
|
||||

|
||||
|
||||
## 事前準備
|
||||
|
||||
次のものを用意してください。
|
||||
|
||||
- CC Switch v3.16.1 以降。
|
||||
- インストール済みで起動できる Codex。app と CLI の両方を入れておくことをおすすめします。
|
||||
- Codex にログインできる公式 ChatGPT / Codex アカウント。Free サブスクリプションで構いません。
|
||||
- DeepSeek、Kimi、GLM、MiniMax、OpenRouter、SiliconFlow などのサードパーティ API Key。
|
||||
|
||||
`~/.codex/auth.json` の内容を手動でコピーしたり共有したりしないでください。このファイルには公式ログインキャッシュと Access Token が保存されており、機密情報です。
|
||||
|
||||
## Step 1: OpenAI Official に戻して公式ログインを完了する
|
||||
|
||||
CC Switch を開き、上部の `Codex` タブへ切り替えます。まず `OpenAI Official` プロバイダーを選択します。存在しない場合は、プリセットプロバイダーから追加して現在のプロバイダーにしてください。
|
||||
|
||||

|
||||
|
||||
次に Codex を起動します。CLI の起動がおすすめです。Codex の公式ログインフローに従い、ChatGPT / Codex アカウントでログインします。このアカウントは Free プランでも問題ありません。この構成では、主に Codex App が必要とする公式ログイン ID を保持する役割であり、サードパーティモデルの課金には使いません。
|
||||
|
||||
ログイン後、Codex は `~/.codex/auth.json` に公式ログインキャッシュを保存します。以降の重要なポイントは、サードパーティプロバイダー切り替えでこのファイルを上書きさせないことです。
|
||||
|
||||
## Step 2: Codex アプリ拡張を有効化する
|
||||
|
||||
CC Switch に戻り、次を開きます。
|
||||
|
||||
```text
|
||||
設定 → 一般 → Codex アプリ拡張
|
||||
```
|
||||
|
||||
次のスイッチをオンにします。
|
||||
|
||||
```text
|
||||
サードパーティ切替時に公式ログインを保持
|
||||
```
|
||||
|
||||
このスイッチはデフォルトでオフです。一部のユーザーはこの機能を必要としていないためです。「サードパーティ API + 公式リモート操作 / 公式プラグイン」を同時に使いたい場合だけ有効化してください。
|
||||
|
||||
有効化すると、バックエンドで Codex サードパーティプロバイダーを切り替えるときに config-only の書き込み経路が使われます。
|
||||
|
||||
- `auth.json`: 公式 ChatGPT / Codex ログインキャッシュを保持します。
|
||||
- `config.toml`: 現在のサードパーティプロバイダーのモデル、endpoint、`model_provider`、provider-scoped `experimental_bearer_token` を書き込みます。
|
||||
|
||||
## Step 3: サードパーティ Codex プロバイダーを追加する
|
||||
|
||||
Codex パネルに戻り、右上のプラスボタンからプロバイダーを追加します。DeepSeek、Kimi、MiniMax、GLM、SiliconFlow などの内蔵プリセットを優先して使うのがおすすめです。
|
||||
|
||||
DeepSeek を例にすると、プリセットを選んだ後は API Key を入力するだけです。プリセットは base URL、デフォルトモデル、モデルマッピングテーブル、「ローカルルーティングが必要」設定を自動で構成します。
|
||||
|
||||

|
||||
|
||||
サードパーティプロバイダーが OpenAI Responses API をネイティブにサポートしている場合、たとえば GPT モデルを提供する中継サービスであれば、ローカルルーティングは不要なことがあります。
|
||||
一方で DeepSeek / Kimi / MiniMax のように OpenAI Chat Completions だけをサポートする場合は、CC Switch が Codex の Responses リクエストを Chat Completions リクエストへ変換する必要があるため、ローカルルーティングを有効化してください。
|
||||
|
||||
## Step 4: 必要に応じてローカルルーティングと Codex ルーティングを有効化する
|
||||
|
||||
次を開きます。
|
||||
|
||||
```text
|
||||
設定 → ルーティング → ローカルルーティング
|
||||
```
|
||||
|
||||
次の 2 つを行います。
|
||||
|
||||
1. `ルーティング総スイッチ` をオンにし、ローカルサービスを起動する。デフォルトアドレスは通常 `127.0.0.1:15721` です。
|
||||
2. `ルーティング有効` で `Codex` をオンにする。
|
||||
|
||||

|
||||
|
||||
ルーティング有効化後、Codex の live `config.toml` は一時的に CC Switch のローカルルートを指します。実際のサードパーティ API Key は CC Switch のプロバイダー設定内に残り、プロバイダー切り替え時に `config.toml` の `experimental_bearer_token` へ投影されます。
|
||||
|
||||
## Step 5: サードパーティプロバイダーへ切り替えて Codex を再起動する
|
||||
|
||||
Codex プロバイダー一覧に戻り、先ほど追加したサードパーティプロバイダーを有効化します。切り替え後は Codex の再起動をおすすめします。理由は 2 つあります。
|
||||
|
||||
- Codex は起動時に `config.toml` を読み込みます。
|
||||
- Codex の `/model` メニューは通常、再起動後に `model_catalog_json` を再読み込みします。
|
||||
|
||||
再起動後、簡単に確認できます。
|
||||
|
||||
- Codex App ではアカウント情報が引き続き公式アカウントとして表示される。これは期待される動作です。
|
||||
- CC Switch では現在の Codex プロバイダーがサードパーティプロバイダーになっている。
|
||||
- ローカルルーティングを有効化している場合、リクエストログまたはルーティング統計で Codex リクエストがローカルルートを通っていることを確認できる。
|
||||
- サードパーティプロバイダー側のダッシュボードや残高記録に実際のモデルリクエストが表示される。
|
||||
|
||||
## 仕組み
|
||||
|
||||
Codex の設定は主に 2 つのファイルに分かれています。
|
||||
|
||||
```text
|
||||
~/.codex/auth.json
|
||||
~/.codex/config.toml
|
||||
```
|
||||
|
||||
この 2 つは役割が異なります。
|
||||
|
||||
- `auth.json` は公式 ChatGPT / Codex ログインキャッシュを保存します。Codex App が公式アカウント、リモート操作、公式プラグインを認識するために必要なログイン材料です。
|
||||
- `config.toml` は現在のモデルプロバイダー、base URL、モデル、モデルカタログ、provider-scoped token などの実行時設定を保存します。
|
||||
|
||||
`サードパーティ切替時に公式ログインを保持` を有効化すると、CC Switch はサードパーティプロバイダー API Key をプロバイダー設定から取り出し、`config.toml` の現在の provider 配下へ書き込みます。
|
||||
|
||||
```toml
|
||||
model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "DeepSeek"
|
||||
base_url = "https://api.deepseek.com"
|
||||
wire_api = "responses"
|
||||
experimental_bearer_token = "sk-..."
|
||||
```
|
||||
|
||||
同時に、`auth.json` は公式ログインキャッシュを保持したままです。そのため Codex App 側では公式アカウントを認識でき、モデルリクエストは `config.toml` の現在の provider と base URL に従ってサードパーティ API へ向かいます。
|
||||
|
||||
プロバイダーが Chat Completions プロトコルの場合、CC Switch のローカルルーティングがさらに変換層になります。
|
||||
|
||||
```text
|
||||
Codex Responses リクエスト
|
||||
|
|
||||
CC Switch ローカルルート
|
||||
|
|
||||
サードパーティ Chat Completions API
|
||||
|
|
||||
Codex Responses レスポンスへ変換
|
||||
```
|
||||
|
||||
これにより、公式プラグイン / モバイルリモート操作を使い続けながら、モデル通信だけをサードパーティ API に切り替えられます。
|
||||
|
||||
## 理解しておくべき副作用
|
||||
|
||||
### Codex 内の表示アカウントは公式アカウントのまま
|
||||
|
||||
ここが最も誤解されやすい点です。この機能を有効化すると、Codex App は `auth.json` 内の公式ログイン状態を見るため、公式アカウント情報を表示し続けます。
|
||||
|
||||
ただし、これはモデルリクエストが公式 OpenAI に流れているという意味ではありません。実際の通信先は、CC Switch の現在の Codex プロバイダー、`config.toml`、ローカルルーティングログで判断してください。
|
||||
|
||||
### Codex のアカウント表示で課金先を判断しない
|
||||
|
||||
DeepSeek に切り替えた場合でも、Codex には公式アカウントが表示されます。しかしモデルリクエストは DeepSeek API へ送られます。課金、上限、エラーコード、データポリシーはサードパーティプロバイダー側の仕様として理解してください。具体的なリクエスト情報は使用量パネルで確認できます。
|
||||
|
||||
### モデルマッピングを変更したら Codex を再起動する
|
||||
|
||||
Codex のモデルカタログは起動時に読み込まれます。CC Switch が新しいモデルカタログを生成していても、実行中の Codex がホットロードするとは限りません。モデルマッピングを変更した後は Codex を再起動してください。
|
||||
|
||||
### スイッチをオフにすると旧動作に戻る
|
||||
|
||||
`サードパーティ切替時に公式ログインを保持` をオフにすると、サードパーティプロバイダー切り替えは旧バージョン互換の動作になり、`auth.json` が再度書き込まれる可能性があります。公式リモート操作と公式プラグインを長期的に保持したい場合は、このスイッチをオンのままにすることをおすすめします。
|
||||
|
||||
## よくある質問
|
||||
|
||||
**サードパーティ API に切り替えたのに、なぜ Codex はまだ公式アカウントを表示しますか?**
|
||||
|
||||
これは期待される動作です。公式アカウント情報は `auth.json` から取得され、実際のモデルプロバイダーは `config.toml` と CC Switch の現在のプロバイダーで決まります。
|
||||
|
||||
**Free サブスクリプションで本当に大丈夫ですか?**
|
||||
|
||||
大丈夫です。ここでの公式アカウントは、Codex App が必要とする公式ログイン状態を取得・保持するために使います。サードパーティモデルリクエストは、CC Switch に設定したサードパーティ API Key を使います。
|
||||
|
||||
**有効化しても公式プラグインやモバイルリモート操作が使えない場合は?**
|
||||
|
||||
まず `OpenAI Official` に戻し、Codex を再起動して一度公式ログインを完了してください。その後、CC Switch の `設定 → 一般 → Codex アプリ拡張 → サードパーティ切替時に公式ログインを保持` がオンになっていることを確認し、再度サードパーティプロバイダーへ切り替えてください。
|
||||
|
||||
**サードパーティリクエストが 404 になる、モデル一覧が違う、ストリーミング応答がおかしい場合は?**
|
||||
|
||||
そのプロバイダーが Chat Completions プロトコルの場合、プロバイダーフォームで `ローカルルーティングが必要` が有効になっていること、さらに `設定 → ルーティング` でルーティング総スイッチと Codex ルーティングがオンになっていることを確認してください。
|
||||
|
||||
**ローカルルーティング中に OpenAI Official へ戻せますか?**
|
||||
|
||||
おすすめしません。CC Switch は、ローカルルーティングで Codex を管理している間に公式プロバイダーへ切り替えることをできるだけ防ぎます。プロキシ経由で公式 API にアクセスすると、アカウントリスクが発生する可能性があるためです。公式ログインは `auth.json` を保持するために使い、モデル通信はサードパーティプロバイダーへ切り替えるのがおすすめです。
|
||||
|
||||
**なぜ手順がこんなに複雑なのですか?もっと簡単にできますか?**
|
||||
|
||||
Codex アプリ拡張やルーティング管理は、必要ないユーザーにとっては余計なトラブルになり得るため、常時有効ではなく明示的なスイッチになっています。
|
||||
|
||||
## 参考リンク
|
||||
|
||||
- [Codex DeepSeek ローカルルーティング実践ガイド](./codex-deepseek-routing-guide-ja.md)
|
||||
- [Codex プロバイダーの追加: Chat Completions ルーティングとモデルマッピング](../user-manual/ja/2-providers/2.1-add.md)
|
||||
- [ローカルプロキシサービス](../user-manual/ja/4-proxy/4.1-service.md)
|
||||
- [ローカルルーティング](../user-manual/ja/4-proxy/4.2-routing.md)
|
||||
- [CC Switch v3.16.1 Release Note](../release-notes/v3.16.1-ja.md)
|
||||
@@ -0,0 +1,209 @@
|
||||
# 使用第三方 API 时保留 Codex 远程操作和官方插件:CC Switch 配置攻略
|
||||
|
||||
> 适用版本:CC Switch v3.16.1 及以上。本文根据当前代码、用户手册和 v3.16.1 Release Note 整理,截图使用去敏示例数据,不包含真实 Access Token 或 API Key。
|
||||
|
||||
## 这篇攻略解决什么问题
|
||||
|
||||
很多人使用 Codex 时有两个需求:
|
||||
|
||||
1. 模型使用 DeepSeek、Kimi、GLM、MiniMax、硅基流动等第三方 API,或者在中转站使用 gpt 模型。
|
||||
2. 保留 Codex 官方 App 的手机远程操作、官方插件等能力。
|
||||
|
||||
之前切换第三方供应商时,旧行为会把第三方 API Key 写进 Codex 的 `auth.json`,从而覆盖原来的官方 ChatGPT / Codex 登录缓存。这样第三方模型能用了,但依赖官方登录态的功能会消失。
|
||||
|
||||
v3.16.1 新增的 **Codex 应用增强**开关就是为了解决这个矛盾:让官方 Access Token 继续留在 `auth.json`,而第三方供应商信息写入 `config.toml`。这样 Codex App 仍然认为你登录的是官方账号,但实际模型请求会走 CC Switch 当前选中的第三方供应商。
|
||||
|
||||
v3.16.0 就有这个功能,并且默认开启,但是部分用户反映并不想要这个功能,所以在 v3.16.1 中把这个功能做成了开关。
|
||||
|
||||
## 先看结论
|
||||
|
||||
推荐顺序是:
|
||||
|
||||
1. 在 CC Switch 的 Codex 面板切换到 `OpenAI Official`。
|
||||
2. 启动 Codex,并用官方 ChatGPT / Codex 账号登录一次,Free 订阅也可以。
|
||||
3. 回到 CC Switch,打开 `设置 → 通用 → Codex 应用增强 → 切换第三方时保留官方登录`。
|
||||
4. 添加或切换到第三方 Codex 供应商。
|
||||
5. 如果该供应商是 Chat Completions 协议,例如 DeepSeek / Kimi / MiniMax,需要同时开启本地路由并启用 Codex 接管。
|
||||
6. 重启 Codex,让 `config.toml` 和模型目录重新加载。
|
||||
|
||||

|
||||
|
||||
## 准备工作
|
||||
|
||||
你需要准备:
|
||||
|
||||
- CC Switch v3.16.1 或更新版本。
|
||||
- 已安装并能启动的 Codex(建议 app 和 cli 都安装)。
|
||||
- 一个可以登录 Codex 的官方 ChatGPT / Codex 账号,Free 订阅即可。
|
||||
- 一个第三方 API Key,例如 DeepSeek、Kimi、GLM、MiniMax、OpenRouter、硅基流动等。
|
||||
|
||||
请不要手动复制或分享 `~/.codex/auth.json` 的内容。里面保存的是官方登录缓存和 Access Token,属于敏感信息。
|
||||
|
||||
## 第一步:先切回 OpenAI Official 并完成官方登录
|
||||
|
||||
打开 CC Switch,切到顶部的 `Codex` 标签页。先选择 `OpenAI Official` 供应商(如果没有的话,就在预设供应商当中添加一个),并把它设为当前供应商。
|
||||
|
||||

|
||||
|
||||
接着启动 Codex(建议启动 cli),按 Codex 的官方登录流程登录你的 ChatGPT / Codex 账号。这个账号可以是 Free 订阅;在这个方案里,它主要负责保留 Codex 官方 App 需要识别的登录身份,不负责第三方模型的计费。
|
||||
|
||||
登录完成后,Codex 会在 `~/.codex/auth.json` 中保存官方登录缓存。后面的关键点就是:不要再让第三方供应商切换覆盖这个文件。
|
||||
|
||||
## 第二步:开启 Codex 应用增强
|
||||
|
||||
回到 CC Switch,进入:
|
||||
|
||||
```text
|
||||
设置 → 通用 → Codex 应用增强
|
||||
```
|
||||
|
||||
打开:
|
||||
|
||||
```text
|
||||
切换第三方时保留官方登录
|
||||
```
|
||||
|
||||
这个开关默认关闭,是因为部分用户并不想要这个功能。只有在你明确需要“第三方 API + 官方远程操作 / 官方插件”同时存在时,才需要开启它。
|
||||
|
||||
开启后,后端切换 Codex 第三方供应商时会走 config-only 写入路径:
|
||||
|
||||
- `auth.json`:继续保留官方 ChatGPT / Codex 登录缓存。
|
||||
- `config.toml`:写入当前第三方供应商的模型、endpoint、`model_provider` 和 provider-scoped `experimental_bearer_token`。
|
||||
|
||||
## 第三步:添加第三方 Codex 供应商
|
||||
|
||||
回到 Codex 面板,点击右上角的加号添加供应商。推荐优先使用内置预设,例如 DeepSeek、Kimi、MiniMax、GLM、SiliconFlow 等。
|
||||
|
||||
以 DeepSeek 为例,选择预设后只需要填 API Key。预设会自动配置 base URL、默认模型、模型映射表和“需要本地路由映射”。
|
||||
|
||||

|
||||
|
||||
如果你的第三方供应商原生支持 OpenAI Responses API(比如提供 gpt 模型的中转站),可以不启用本地路由。
|
||||
如果它只支持 OpenAI Chat Completions,例如常见的 DeepSeek / Kimi / MiniMax 路径,就必须启用本地路由,让 CC Switch 把 Codex 的 Responses 请求转换成 Chat Completions 请求。
|
||||
|
||||
## 第四步:需要时开启本地路由并接管 Codex
|
||||
|
||||
进入:
|
||||
|
||||
```text
|
||||
设置 → 路由 → 本地路由
|
||||
```
|
||||
|
||||
完成两件事:
|
||||
|
||||
1. 打开 `路由总开关`,启动本地服务。默认地址通常是 `127.0.0.1:15721`。
|
||||
2. 在 `路由启用` 中打开 `Codex`。
|
||||
|
||||

|
||||
|
||||
接管后,Codex 的 live `config.toml` 会临时指向 CC Switch 本地路由。真实第三方 API Key 仍然存储在 CC Switch 的供应商配置中,切换供应商时再投影到 `config.toml` 的 `experimental_bearer_token`。
|
||||
|
||||
## 第五步:切换第三方供应商并重启 Codex
|
||||
|
||||
回到 Codex 供应商列表,启用你刚添加的第三方供应商。切换完成后建议重启 Codex,原因有两个:
|
||||
|
||||
- Codex 在启动时读取 `config.toml`。
|
||||
- Codex 的 `/model` 菜单通常需要重启后才会重新加载 `model_catalog_json`。
|
||||
|
||||
重启后,你可以做一个简单验证:
|
||||
|
||||
- 在 Codex App 里,账号信息仍然显示官方账号,这是预期行为。
|
||||
- 在 CC Switch 里,当前 Codex 供应商显示为第三方供应商。
|
||||
- 如果开启了本地路由,请求日志或路由统计会看到 Codex 请求经过本地路由。
|
||||
- 第三方供应商后台或余额记录会出现实际模型请求。
|
||||
|
||||
## 背后的原理
|
||||
|
||||
Codex 的配置主要分成两个文件:
|
||||
|
||||
```text
|
||||
~/.codex/auth.json
|
||||
~/.codex/config.toml
|
||||
```
|
||||
|
||||
这两个文件承担的职责不同:
|
||||
|
||||
- `auth.json` 保存官方 ChatGPT / Codex 登录缓存,也就是 Codex App 识别官方账号、远程操作和官方插件所需的登录材料。
|
||||
- `config.toml` 保存当前模型供应商、base URL、模型、模型目录和 provider-scoped token 等运行配置。
|
||||
|
||||
开启 `切换第三方时保留官方登录` 后,CC Switch 的切换逻辑会把第三方供应商 API Key 从供应商配置中取出,写到 `config.toml` 的当前 provider 下:
|
||||
|
||||
```toml
|
||||
model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "DeepSeek"
|
||||
base_url = "https://api.deepseek.com"
|
||||
wire_api = "responses"
|
||||
experimental_bearer_token = "sk-..."
|
||||
```
|
||||
|
||||
同时,`auth.json` 保持官方登录缓存不变。于是 Codex App 侧依然能识别官方账号;而模型请求会根据 `config.toml` 的当前 provider 和 base URL 走第三方 API。
|
||||
|
||||
如果供应商是 Chat Completions 协议,CC Switch 本地路由会再做一层转换:
|
||||
|
||||
```text
|
||||
Codex Responses 请求
|
||||
↓
|
||||
CC Switch 本地路由
|
||||
↓
|
||||
第三方 Chat Completions API
|
||||
↓
|
||||
转换回 Codex Responses 响应
|
||||
```
|
||||
|
||||
这就是为什么你既能继续使用官方插件 / 手机远程操作,又能把模型流量切到第三方 API。
|
||||
|
||||
## 需要理解的副作用
|
||||
|
||||
### Codex 里显示的账号始终是官方账号
|
||||
|
||||
这是最容易误解的一点。开启该能力后,Codex App 看到的是 `auth.json` 里的官方登录态,所以它会继续显示官方账号信息。
|
||||
|
||||
但这不代表模型请求还在走官方 OpenAI。实际流量以 CC Switch 当前 Codex 供应商、`config.toml` 和本地路由日志为准。
|
||||
|
||||
### 不要用 Codex 账号信息判断计费方
|
||||
|
||||
如果你切到 DeepSeek,Codex 里仍然显示官方账号,但模型请求会走 DeepSeek API。计费、限额、错误码和数据策略都应按第三方供应商理解。可以查看设置用量面板里的具体请求信息。
|
||||
|
||||
### 修改模型映射后要重启 Codex
|
||||
|
||||
Codex 的模型目录是启动时读取的。即使 CC Switch 已经生成了新的模型目录,正在运行的 Codex 也不一定会热加载,所以修改模型映射后请重启 Codex。
|
||||
|
||||
### 关闭开关会回到旧行为
|
||||
|
||||
如果关闭 `切换第三方时保留官方登录`,第三方供应商切换会沿用兼容旧版本的行为,可能重新写入 `auth.json`。如果你的目标是长期保留官方远程操作和官方插件,建议保持该开关开启。
|
||||
|
||||
## 常见问题
|
||||
|
||||
**我已经切到第三方 API,为什么 Codex 还显示官方账号?**
|
||||
|
||||
这是预期行为。官方账号信息来自 `auth.json`,模型请求的实际供应商来自 `config.toml` 和 CC Switch 当前供应商。
|
||||
|
||||
**Free 订阅真的可以吗?**
|
||||
|
||||
可以。这里的官方账号主要用于获取并保留 Codex App 需要的官方登录态。第三方模型请求使用的是你在 CC Switch 里配置的第三方 API Key。
|
||||
|
||||
**开启后官方插件或手机远程操作还是不可用怎么办?**
|
||||
|
||||
先切回 `OpenAI Official`,重新启动 Codex 并完成一次官方登录;然后确认 CC Switch 的 `设置 → 通用 → Codex 应用增强 → 切换第三方时保留官方登录` 已开启,再切回第三方供应商。
|
||||
|
||||
**第三方请求 404、模型列表不对或流式响应异常怎么办?**
|
||||
|
||||
如果该供应商是 Chat Completions 协议,请确认供应商表单里开启了 `需要本地路由映射`,并且 `设置 → 路由` 里已经启动路由总开关、启用 Codex 接管。
|
||||
|
||||
**可以在本地路由模式下切回 OpenAI Official 吗?**
|
||||
|
||||
不建议。CC Switch 会尽量阻止在本地路由接管模式下切到官方供应商,因为用代理访问官方 API 可能带来账号风险。建议官方登录只用于保留 `auth.json`,模型流量则切到第三方供应商。
|
||||
|
||||
**为什么流程做的这么复杂?可以简化吗?**
|
||||
|
||||
因为 Codex 增强开关和路由接管等一系列功能,如果用户并不需要的话,默认打开会带来不必要的麻烦,所以都做成了开关形式。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Codex DeepSeek 本地路由实战攻略](./codex-deepseek-routing-guide-zh.md)
|
||||
- [添加 Codex 供应商:Chat Completions 路由与模型映射](../user-manual/zh/2-providers/2.1-add.md)
|
||||
- [本地代理服务](../user-manual/zh/4-proxy/4.1-service.md)
|
||||
- [本地路由](../user-manual/zh/4-proxy/4.2-routing.md)
|
||||
- [CC Switch v3.16.1 Release Note](../release-notes/v3.16.1-zh.md)
|
||||
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 185 KiB |
@@ -6,6 +6,16 @@
|
||||
|
||||
---
|
||||
|
||||
## Usage Guide
|
||||
|
||||
The two headline capabilities in this release are **Codex third-party provider Chat Completions routing** and **in-app managed CLI tool management**. If you want providers that only speak the OpenAI Chat protocol (DeepSeek, Kimi, MiniMax, etc.) to work directly in Codex, or want to install / upgrade CLI tools from one place inside the app, start with these guides:
|
||||
|
||||
- **[Using DeepSeek in Codex: local routing hands-on guide](../guides/codex-deepseek-routing-guide-en.md)** — uses the built-in DeepSeek preset to walk through adding a Codex provider, enabling local routing, and verifying request forwarding.
|
||||
- **[Add a Codex provider: Chat Completions routing and model mapping](../user-manual/en/2-providers/2.1-add.md)** — covers the "Needs Local Routing" toggle, the model mapping table, and reasoning (thinking) auto-detection.
|
||||
- **[Settings → About: managed CLI tool management](../user-manual/en/1-getting-started/1.5-settings.md)** — covers version detection, per-tool / update-all upgrades, conflict diagnostics, and source-anchored upgrade commands.
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## Only Official Channels (Please Read)
|
||||
@@ -24,15 +34,6 @@
|
||||
|
||||
---
|
||||
|
||||
## Usage Guide
|
||||
|
||||
The two headline capabilities in this release are **Codex third-party provider Chat Completions routing** and **in-app managed CLI tool management**. If you want providers that only speak the OpenAI Chat protocol (DeepSeek, Kimi, MiniMax, etc.) to work directly in Codex, or want to install / upgrade CLI tools from one place inside the app, start with these two:
|
||||
|
||||
- **[Add a Codex provider: Chat Completions routing and model mapping](../user-manual/en/2-providers/2.1-add.md)** — covers the "Needs Local Routing" toggle, the model mapping table, and reasoning (thinking) auto-detection.
|
||||
- **[Settings → About: managed CLI tool management](../user-manual/en/1-getting-started/1.5-settings.md)** — covers version detection, per-tool / update-all upgrades, conflict diagnostics, and source-anchored upgrade commands.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.16.0's development since v3.15.0 centers on **promoting third-party Codex providers to first-class citizens through Chat Completions routing**. Codex natively only speaks the OpenAI Responses API and GPT-family models; this release lets CC Switch's local proxy convert Codex's outgoing Responses requests into Chat Completions and rebuild the JSON and SSE streaming responses back into Responses shape, preserving `reasoning_content` / inline `<think>` blocks / streamed reasoning summaries / tool calls / `previous_response_id` follow-ups along the way, normalizing error envelopes, and probing Chat-format providers correctly in Stream Check. It ships 22 Chat-routing presets with explicit model catalogs (DeepSeek, Zhipu GLM, Kimi, MiniMax, StepFun, Baidu Qianfan, Bailian, ModelScope, Longcat, BaiLing, Xiaomi MiMo, Volcengine Agentplan, BytePlus, DouBao Seed, SiliconFlow, Novita AI, Nvidia, and more).
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
|
||||
---
|
||||
|
||||
## 利用ガイド
|
||||
|
||||
本リリースの主役となる 2 つの機能は、**Codex サードパーティプロバイダーの Chat Completions ルーティング**と**アプリ内の管理対象 CLI ツール管理**です。OpenAI Chat プロトコルにしか対応していないプロバイダー(DeepSeek、Kimi、MiniMax など)を Codex で直接使いたい場合、またはアプリ内で CLI ツールの一括インストール / アップグレードをしたい場合は、まずこちらをご覧ください:
|
||||
|
||||
- **[Codex で DeepSeek を使う: ローカルルーティング実践ガイド](../guides/codex-deepseek-routing-guide-ja.md)** —— DeepSeek 内蔵プリセットを例に、Codex プロバイダーの追加、ローカルルーティングの有効化、リクエスト転送の確認までを説明します。
|
||||
- **[Codex プロバイダーの追加: Chat Completions ルーティングとモデルマッピング](../user-manual/ja/2-providers/2.1-add.md)** —— 「ローカルルーティングが必要」トグル、モデルマッピングテーブル、思考能力(reasoning)の自動判別までの一連の流れを説明しています。
|
||||
- **[設定 → バージョン情報: 管理対象 CLI ツール管理](../user-manual/ja/1-getting-started/1.5-settings.md)** —— バージョン検出、個別アップグレード / 全体アップグレード、競合診断、インストール元にアンカーされたアップグレードコマンドを説明しています。
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## 唯一の公式チャネル(必ずお読みください)
|
||||
@@ -24,15 +34,6 @@
|
||||
|
||||
---
|
||||
|
||||
## 利用ガイド
|
||||
|
||||
本リリースの主役となる 2 つの機能は、**Codex サードパーティプロバイダーの Chat Completions ルーティング**と**アプリ内蔵の受託 CLI ツール管理**です。OpenAI Chat プロトコルにしか対応していないプロバイダー(DeepSeek、Kimi、MiniMax など)を Codex で直接使いたい場合、またはアプリ内で CLI ツールの一括インストール / アップグレードをしたい場合は、まずこの 2 つをご覧ください:
|
||||
|
||||
- **[Codex プロバイダーの追加: Chat Completions ルーティングとモデルマッピング](../user-manual/ja/2-providers/2.1-add.md)** —— 「ローカルルーティングが必要」トグル、モデルマッピングテーブル、思考能力(reasoning)の自動判別までの一連の流れを説明しています。
|
||||
- **[設定 → バージョン情報: 受託 CLI ツール管理](../user-manual/ja/1-getting-started/1.5-settings.md)** —— バージョン検出、個別アップグレード / 全体アップグレード、競合診断、インストール元にアンカーされたアップグレードコマンドを説明しています。
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.16.0 の v3.15.0 以降の開発のコアは、**サードパーティ Codex プロバイダーを Chat Completions ルーティングによって一等市民へ昇格させること**です。Codex はネイティブには OpenAI Responses API と GPT 系モデルしか認識しませんが、本リリースでは CC Switch のローカルプロキシが Codex の送出する Responses リクエストを Chat Completions に変換し、JSON と SSE のストリーミングレスポンスを Responses 形態へ再構築します。その道中で `reasoning_content` / インライン `<think>` ブロック / ストリーミング推論サマリー / ツール呼び出し / `previous_response_id` の継続を保持し、エラーエンベロープを正規化し、Stream Check で Chat 形式プロバイダーを正しくプローブします。あわせて明示的なモデルカタログ付きの 22 個の Chat ルーティングプリセット(DeepSeek、Zhipu GLM、Kimi、MiniMax、StepFun、Baidu Qianfan、Bailian、ModelScope、Longcat、BaiLing、Xiaomi MiMo、Volcengine Agentplan、BytePlus、DouBao Seed、SiliconFlow、Novita AI、Nvidia など)を出荷します。
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
|
||||
---
|
||||
|
||||
## 使用攻略
|
||||
|
||||
本版本最主打的两块能力是 **Codex 第三方供应商 Chat Completions 路由**与**应用内受管 CLI 工具管理**。如果你想让 DeepSeek、Kimi、MiniMax 这类只支持 OpenAI Chat 协议的供应商在 Codex 里直接可用,或者想在应用内一站式安装 / 升级 CLI 工具,建议先读这几篇:
|
||||
|
||||
- **[在 Codex 中使用 DeepSeek:本地路由实战攻略](../guides/codex-deepseek-routing-guide-zh.md)** —— 以 DeepSeek 内置预设为例,演示从添加 Codex 供应商、开启本地路由到验证请求转发的完整路径。
|
||||
- **[添加 Codex 供应商:Chat Completions 路由与模型映射](../user-manual/zh/2-providers/2.1-add.md)** —— 覆盖「需要本地路由映射」开关、模型映射表、思考能力(reasoning)自适应识别的完整流程。
|
||||
- **[设置 → 关于:受管 CLI 工具管理](../user-manual/zh/1-getting-started/1.5-settings.md)** —— 覆盖版本检测、单独升级 / 全部升级、冲突诊断、按安装来源锚定的升级命令。
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## 唯一官方渠道声明(请务必阅读)
|
||||
@@ -24,15 +34,6 @@
|
||||
|
||||
---
|
||||
|
||||
## 使用攻略
|
||||
|
||||
本版本最主打的两块能力是 **Codex 第三方供应商 Chat Completions 路由**与**应用内受管 CLI 工具管理**。如果你想让 DeepSeek、Kimi、MiniMax 这类只支持 OpenAI Chat 协议的供应商在 Codex 里直接可用,或者想在应用内一站式安装 / 升级 CLI 工具,建议先读这两篇:
|
||||
|
||||
- **[添加 Codex 供应商:Chat Completions 路由与模型映射](../user-manual/zh/2-providers/2.1-add.md)** —— 覆盖「需要本地路由映射」开关、模型映射表、思考能力(reasoning)自适应识别的完整流程。
|
||||
- **[设置 → 关于:受管 CLI 工具管理](../user-manual/zh/1-getting-started/1.5-settings.md)** —— 覆盖版本检测、单独升级 / 全部升级、冲突诊断、按安装来源锚定的升级命令。
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.16.0 自 v3.15.0 以来的开发核心,是把**第三方 Codex 供应商通过 Chat Completions 路由升级为一等公民**。Codex 原生只认 OpenAI Responses API 与 GPT 系列模型,本版本让 CC Switch 的本地代理把 Codex 发出的 Responses 请求转换为上游的 Chat Completions,再把 JSON 与 SSE 流式响应重建回 Responses 形态,沿途保留 `reasoning_content` / 内联 `<think>` 块 / 流式推理摘要 / 工具调用 / `previous_response_id` 续接状态,并把错误信封规范化、在 Stream Check 中正确探测 Chat 格式供应商。配套上货 22 个带显式模型目录的 Chat 路由预设(DeepSeek、智谱 GLM、Kimi、MiniMax、StepFun、百度千帆、百炼、ModelScope、Longcat、百灵、小米 MiMo、火山 Agentplan、BytePlus、豆包 Seed、SiliconFlow、Novita AI、Nvidia 等)。
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
# CC Switch v3.16.1
|
||||
|
||||
> Codex stability patch: because some users did not want CC Switch to change how Codex config files are written, Codex App Enhancements now has a switch and is off by default. After enabling it, you can keep using Codex mobile remote control, official plugins, and other official-app features while using third-party APIs; this release also includes a series of stability fixes.
|
||||
|
||||
**[中文版 →](v3.16.1-zh.md) | [日本語版 →](v3.16.1-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Usage Guides
|
||||
|
||||
If you want to unlock official-subscription-only Codex remote control and official plugins while using third-party APIs, or want to use DeepSeek / Kimi / GLM / MiniMax and other Chat Completions upstreams in Codex, start with these docs:
|
||||
|
||||
- **[Keep Codex remote control and official plugins while using third-party APIs](../guides/codex-official-auth-preservation-guide-en.md)**: explains how to complete official login first, enable Codex App Enhancements, keep official login state in `auth.json`, and route model traffic to third-party APIs.
|
||||
- **[Using DeepSeek in Codex: local routing hands-on guide](../guides/codex-deepseek-routing-guide-en.md)**: walks through adding a Codex provider, enabling local routing, and verifying request forwarding.
|
||||
- **[Add a Codex provider: Chat Completions routing and model mapping](../user-manual/en/2-providers/2.1-add.md)**: covers the "Needs Local Routing" option, model mapping table, and reasoning capability configuration.
|
||||
- **[Local Proxy Service](../user-manual/en/4-proxy/4.1-service.md)** and **[Local Routing](../user-manual/en/4-proxy/4.2-routing.md)**: explain the proxy service, live-config takeover, and related risk notes.
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## Only Official Channels (Please Read)
|
||||
>
|
||||
> CC Switch is a **fully free and open-source** desktop app, and we **do not charge users any fees**. Please only obtain the software through the official channels listed below:
|
||||
>
|
||||
> | Channel | Only Official |
|
||||
> | ------------------ | ------------------------------------------------------------------------------ |
|
||||
> | Website | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | Source | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | Downloads | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | Author | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | Report an Imposter | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **Any "CC Switch" website or client that asks you for payment, top-ups, or login credentials is fake.** If you have been tricked into paying, stop the transaction immediately and file a report through GitHub Issues.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.16.1 is a Codex stability patch following v3.16.0. v3.16.0 promoted third-party Codex providers to first-class citizens through Chat Completions routing; this release focuses on several high-risk edges discovered in real use: official ChatGPT / Codex OAuth login state could be overwritten while switching third-party providers or during local routing takeover, the Codex model catalog could be cleared during live backfill, hot switching, takeover shutdown restore, or editing the active provider, and Codex `tool_search`, plugin / connector namespace tools, and custom tools were not fully restored back into Responses events on the Chat Completions upstream path.
|
||||
|
||||
This release also hardens local routing takeover ownership checks. Provider switching and takeover toggles now run serially per app. When deciding whether the live files are proxy-managed, CC Switch no longer relies only on stale `enabled` state or whether the proxy service is currently running; it also checks backups and proxy placeholders in the live files. This prevents ordinary live writes from overwriting proxy-managed config immediately after takeover is enabled, while the proxy is temporarily stopped, or during hot switching.
|
||||
|
||||
**Release date**: 2026-06-01
|
||||
|
||||
**Stats**: 23 commits | 62 files changed | +5,603 insertions | -1,113 deletions
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Safer Codex OAuth and third-party provider switching**: added an optional official-auth preservation setting. When enabled, third-party provider tokens are written to `config.toml`, while official ChatGPT / Codex OAuth login stays in `auth.json`.
|
||||
- **Codex model catalogs are no longer silently wiped**: `modelCatalog` now treats the database as the source of truth, avoiding overwrites from live configs with missing catalog projections during live backfill, provider switching, takeover shutdown restore, and provider editing.
|
||||
- **Codex Chat tools / plugin routing restored**: `tool_search`, loaded namespace tools, and custom tools from Chat Completions upstreams are remapped back to Codex Responses shape; streaming custom tools now emit native `response.custom_tool_call_input.*` events.
|
||||
- **More stable local routing takeover and hot switching**: provider switching and takeover toggles are serialized per app. Hot switching refreshes provider display information in Codex live config while keeping the endpoint pointed at the local proxy.
|
||||
- **Diagnostics and platform compatibility fixes**: Codex proxy errors now include richer context; Codex CLI model-template discovery supports more platforms and falls back to a static GPT-5.5 template; Windows tool version detection fixes localized output and command quoting issues.
|
||||
|
||||
---
|
||||
|
||||
## Added
|
||||
|
||||
### Codex Official Auth Preservation Setting
|
||||
|
||||
Added an optional setting for preserving official ChatGPT / Codex OAuth login state when switching to third-party Codex providers. When enabled, CC Switch stores third-party provider API keys in the provider-scoped `experimental_bearer_token` inside Codex `config.toml` instead of overwriting the official login cache in `auth.json`.
|
||||
|
||||
Because some users do not want this feature to change how config files are written, the setting is off by default and keeps the compatibility behavior from before v3.16.0. Users who need both official Codex login and third-party providers can manually enable it under Settings -> Codex App Enhancements.
|
||||
|
||||
### Codex DeepSeek Routing Guide
|
||||
|
||||
Added Codex DeepSeek routing guides in Chinese, English, and Japanese, covering provider routing requirements, the DeepSeek Codex provider form, and screenshot-based local routing takeover instructions.
|
||||
|
||||
---
|
||||
|
||||
## Changed
|
||||
|
||||
### Codex Auth Preservation Is Now Opt-In
|
||||
|
||||
Official auth preservation is off by default. Third-party Codex provider switching therefore keeps the old behavior unless the user opts in, avoiding surprise changes to how `auth.json` / `config.toml` are written.
|
||||
|
||||
### Codex Restart Prompt After Provider Switching
|
||||
|
||||
Codex loads the model catalog and part of its config at startup. After successfully switching a Codex provider, the UI now reminds the user to restart Codex so model catalog and config changes actually take effect.
|
||||
|
||||
### Provider Switching and Takeover Toggles Are Serialized
|
||||
|
||||
Codex / Claude / Gemini provider switching and local routing takeover toggles now share a per-app lock, avoiding concurrent writes to live config and backups. Ownership checks also prioritize live backups and the `PROXY_MANAGED` placeholder instead of relying only on whether the proxy service is running.
|
||||
|
||||
### Codex Hot Switching Refreshes Display Info
|
||||
|
||||
When hot switching Codex providers during local routing takeover, CC Switch refreshes the provider id, model, and display name in the live config so the Codex client menu follows the active provider. The base URL still stays pointed at the local proxy, preventing the real upstream endpoint from leaking back into the live file.
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### Codex Provider Editor Showing Live OAuth During Takeover
|
||||
|
||||
When Codex is under local routing takeover, live `auth.json` / `config.toml` are temporarily rewritten by the proxy. Editing the active provider from those live files could incorrectly show proxy placeholders or official OAuth login as provider config. The editor now explicitly explains that it is showing the provider config stored in the database, not the proxy-managed live files; even if the proxy service is temporarily stopped, CC Switch still treats the app as under takeover when the takeover state indicates so.
|
||||
|
||||
### Codex OAuth Cleared or Overwritten During Takeover
|
||||
|
||||
Fixed multiple preserve-mode takeover paths that could clear or overwrite official ChatGPT / Codex OAuth `auth.json`. Takeover detection now recognizes `PROXY_MANAGED` in `config.toml`, cleanup only removes proxy placeholder tokens, and third-party providers misclassified as official no longer enter the official-auth overwrite path. Provider sync and switching also treat live backups and placeholders as takeover ownership signals, preventing normal live writes from overwriting proxy config right after takeover or while the proxy is paused.
|
||||
|
||||
### Codex Model Catalog Data Loss
|
||||
|
||||
Fixed cases where `modelCatalog` could be cleared during live backfill, active-provider editing, provider switching, and takeover shutdown restore. Snapshot backups preserve existing `model_catalog_json` pointers; backups rebuilt from providers regenerate catalog projections from the database source of truth; editing the active provider now prefers the database model catalog instead of trusting a live reverse-parse result that may have lost its projection.
|
||||
|
||||
Provider switching also now always refreshes the generated Codex model catalog JSON ([#3360](https://github.com/farion1231/cc-switch/pull/3360), thanks @Postroggy).
|
||||
|
||||
### Codex Chat Tools, Plugins, and Custom Tools Restored
|
||||
|
||||
Fixed Chat Completions routing for third-party Codex providers so `tool_search`, loaded MCP / connector namespace tools, and custom tools are fully restored back into Codex Responses shape. Non-streaming and streaming Chat responses now recover the correct tool type, namespace, call id, and arguments from the original Responses request; custom-tool streaming now emits native `response.custom_tool_call_input.delta` and `response.custom_tool_call_input.done` events.
|
||||
|
||||
### Fuller Codex Proxy Error Diagnostics
|
||||
|
||||
When Codex forwarding fails, CC Switch now returns JSON errors that include provider, model, endpoint, upstream HTTP status, stable `cc_switch_*` error codes, and normalized HTTP status. This makes it much clearer which provider, endpoint, and upstream error caused the failure.
|
||||
|
||||
### Codex Native Balance / Coding Plan Credential Lookup
|
||||
|
||||
Fixed native balance and Coding Plan queries using credentials from the wrong app. Each app now resolves its own provider credentials instead of carrying authentication assumptions from another app surface into the query flow ([#3355](https://github.com/farion1231/cc-switch/pull/3355), thanks @SiskonEmilia).
|
||||
|
||||
### Codex CLI Discovery and Model Catalog Template Fallback
|
||||
|
||||
Fixed a too-narrow Codex CLI discovery path for third-party Codex model catalog projection. The backend now searches common Codex CLI install locations across platforms, and falls back to a built-in GPT-5.5 model catalog template if no template can be found ([#3382](https://github.com/farion1231/cc-switch/pull/3382), thanks @chofuhoyu).
|
||||
|
||||
### Claude Desktop Official Provider Add Failure
|
||||
|
||||
Fixed an error when adding the Claude Desktop Official provider ([#3405](https://github.com/farion1231/cc-switch/pull/3405), thanks @Eunknight).
|
||||
|
||||
### Kimi / Moonshot Tool-Thinking History Normalization
|
||||
|
||||
Added Kimi / Moonshot to the Anthropic-compatible tool-thinking history normalizer. Later turns can now correctly replay reasoning and tool-call context, avoiding failures caused by history messages that do not match upstream requirements ([#3377](https://github.com/farion1231/cc-switch/pull/3377), thanks @Neon-Wang).
|
||||
|
||||
### Windows Tool Version Detection
|
||||
|
||||
Fixed incorrect quoting for `.cmd` / `.bat` version commands on Windows, and fixed localized command output being decoded as mojibake. Previously, these issues could make runnable tools appear as "installed but not runnable."
|
||||
|
||||
---
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
### Official OAuth Preservation Must Be Enabled Manually
|
||||
|
||||
If you want official ChatGPT / Codex OAuth login to stay in `auth.json` while you frequently switch third-party Codex providers, enable Codex official auth preservation in Settings. It is off by default to keep compatibility for existing users.
|
||||
|
||||
### Restart Codex After Editing Model Mappings
|
||||
|
||||
Codex reads `model_catalog_json` at startup. Even though v3.16.1 fixes model catalog wiping, Codex still needs to be restarted after you edit the model mapping table so the `/model` menu refreshes.
|
||||
|
||||
### During Takeover, the Editor Shows Stored Config, Not Live Files
|
||||
|
||||
When local routing takeover is enabled, live `auth.json` / `config.toml` temporarily point to the CC Switch proxy. The provider editor therefore shows the provider config saved in the database. This is expected; after takeover is disabled, CC Switch restores live config from backups or the database source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Risk Notice
|
||||
|
||||
This release continues the risk notices from previous versions for reverse-proxy-style features.
|
||||
|
||||
**Codex OAuth reverse proxy**: using a ChatGPT subscription's Codex OAuth through a reverse proxy may violate OpenAI's terms of service. See the [v3.13.0 release notes](v3.13.0-en.md#️-risk-notice) for details.
|
||||
|
||||
**Codex third-party provider Chat routing**: when CC Switch local proxy converts and forwards Codex requests to third-party providers, each provider may have different requirements for billing, compliance, and data retention. Read the target provider's terms before use.
|
||||
|
||||
**Claude Desktop third-party provider proxy switching**: when CC Switch's built-in proxy gateway forwards Claude Desktop requests to third-party providers, you must also follow the target provider's billing, compliance, and data-retention terms.
|
||||
|
||||
By enabling these features, users accept the related risks. CC Switch is not responsible for account restrictions, warnings, or service suspensions caused by using these features.
|
||||
|
||||
---
|
||||
|
||||
## Thanks
|
||||
|
||||
Thanks to the following contributors for fixes in v3.16.1:
|
||||
|
||||
- [#3360](https://github.com/farion1231/cc-switch/pull/3360): always update Codex model catalog JSON when switching providers, thanks @Postroggy.
|
||||
- [#3355](https://github.com/farion1231/cc-switch/pull/3355): resolve native balance / Coding Plan credentials per app, thanks @SiskonEmilia.
|
||||
- [#3405](https://github.com/farion1231/cc-switch/pull/3405): fix Claude Desktop Official provider add failure, thanks @Eunknight.
|
||||
- [#3382](https://github.com/farion1231/cc-switch/pull/3382): Codex CLI multi-platform discovery and GPT-5.5 model template fallback, thanks @chofuhoyu.
|
||||
- [#3377](https://github.com/farion1231/cc-switch/pull/3377): Kimi / Moonshot tool-thinking history normalization, thanks @Neon-Wang.
|
||||
|
||||
Thanks also to everyone who reported Codex OAuth, model catalog, local routing takeover, and Chat Completions tool-call issues after v3.16.0. Many of these fixes came directly from real-world reproduction details.
|
||||
|
||||
---
|
||||
|
||||
## Download & Install
|
||||
|
||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) and download the build for your system.
|
||||
|
||||
### System Requirements
|
||||
|
||||
| System | Minimum Version | Architecture |
|
||||
| ------- | ------------------------ | ----------------------------------- |
|
||||
| Windows | Windows 10 and later | x64 |
|
||||
| macOS | macOS 12 (Monterey)+ | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | See table below | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------------------- | ----------------------------------------------- |
|
||||
| `CC-Switch-v3.16.1-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||
| `CC-Switch-v3.16.1-Windows-Portable.zip` | Portable build, unzip and run |
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| -------------------------------- | ----------------------------------------------------- |
|
||||
| `CC-Switch-v3.16.1-macOS.dmg` | **Recommended** - DMG installer, drag to Applications |
|
||||
| `CC-Switch-v3.16.1-macOS.zip` | Unzip and drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.16.1-macOS.tar.gz` | For Homebrew install and auto-update |
|
||||
|
||||
Homebrew install:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Upgrade:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux assets are available for both **x86_64** and **ARM64** (`aarch64`). Choose the file whose architecture tag matches your machine's `uname -m` output:
|
||||
|
||||
- `CC-Switch-v3.16.1-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.16.1-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| Distribution | Recommended Format | Install Command |
|
||||
| ---------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | Make executable and run directly, or use AUR |
|
||||
| Other distributions / unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,236 @@
|
||||
# CC Switch v3.16.1
|
||||
|
||||
> Codex 安定性パッチ: 一部のユーザーから「設定ファイルの書き込み方式を変えたくない」というフィードバックがあったため、Codex アプリ拡張にスイッチを追加し、デフォルトではオフにしました。有効化すると、サードパーティ API を使いながら Codex のモバイルリモート操作、公式プラグインなどの公式アプリ機能を引き続き利用できます。本リリースには一連の安定性修正も含まれます。
|
||||
|
||||
**[English →](v3.16.1-en.md) | [中文 →](v3.16.1-zh.md)**
|
||||
|
||||
---
|
||||
|
||||
## 利用ガイド
|
||||
|
||||
サードパーティ API 利用中に、公式サブスクリプションでのみ使える Codex のリモート操作や公式プラグインを有効化したい場合、または DeepSeek / Kimi / GLM / MiniMax などの Chat Completions 上流を Codex で使いたい場合は、まず以下のドキュメントをご覧ください:
|
||||
|
||||
- **[サードパーティ API 利用時に Codex のリモート操作と公式プラグインを保持する](../guides/codex-official-auth-preservation-guide-ja.md)**: 先に公式ログインを完了し、Codex アプリ拡張を有効化して、公式ログイン状態を `auth.json` に残したままモデル通信をサードパーティ API へ切り替える手順を説明します。
|
||||
- **[Codex で DeepSeek を使う: ローカルルーティング実践ガイド](../guides/codex-deepseek-routing-guide-ja.md)**: Codex プロバイダーの追加、ローカルルーティングの有効化、リクエスト転送の確認までを説明します。
|
||||
- **[Codex プロバイダーの追加: Chat Completions ルーティングとモデルマッピング](../user-manual/ja/2-providers/2.1-add.md)**: 「ローカルルーティングが必要」設定、モデルマッピングテーブル、思考能力の設定を説明します。
|
||||
- **[ローカルプロキシサービス](../user-manual/ja/4-proxy/4.1-service.md)** と **[ローカルルーティング](../user-manual/ja/4-proxy/4.2-routing.md)**: プロキシサービス、live 設定のテイクオーバー、関連するリスク注意事項を説明します。
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## 唯一の公式チャネル(必ずお読みください)
|
||||
>
|
||||
> CC Switch は**完全に無料・オープンソース**のデスクトップアプリで、**ユーザーから料金を徴収することはありません**。本ソフトウェアは下記の公式チャネルからのみ入手してください:
|
||||
>
|
||||
> | チャネル | 唯一の公式 |
|
||||
> | ------------ | ------------------------------------------------------------------------------ |
|
||||
> | 公式サイト | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | ソースコード | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | ダウンロード | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | 偽サイト通報 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **料金請求・チャージ・認証情報の提供を求める「CC Switch」サイトやクライアントはすべて偽物です。** 支払いを誘導された場合は直ちに操作を中止し、GitHub Issues からご報告ください。
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.16.1 は v3.16.0 に続く Codex 安定性パッチです。v3.16.0 では Chat Completions ルーティングによってサードパーティ Codex プロバイダーを一等市民にしました。本リリースでは、実際の利用で見つかったいくつかの高リスクなエッジケースを中心に修正しています。具体的には、サードパーティプロバイダーの切り替えやローカルルーティングのテイクオーバー中に公式 ChatGPT / Codex OAuth ログイン状態が上書きされる問題、live バックフィル・ホットスイッチ・テイクオーバー解除時の復元・現在のプロバイダー編集で Codex モデルカタログが空になる問題、そして Chat Completions 上流パスで Codex の `tool_search`、プラグイン / コネクタの namespace ツール、カスタムツールが Responses イベントへ完全には復元されない問題です。
|
||||
|
||||
本リリースではローカルルーティングのテイクオーバー所有権判定も強化しました。プロバイダー切り替えとテイクオーバートグルはアプリごとに直列実行されます。live ファイルがプロキシ管理下にあるかを判定するとき、遅延しがちな `enabled` 状態やプロキシサービスの起動状態だけに頼らず、バックアップと live 内のプロキシプレースホルダーも確認します。これにより、テイクオーバー直後、プロキシの一時停止中、ホットスイッチ中に、通常の live 書き込みがプロキシ管理設定を上書きしてしまうことを防ぎます。
|
||||
|
||||
**リリース日**: 2026-06-01
|
||||
|
||||
**Stats**: 23 commits | 62 files changed | +5,603 insertions | -1,113 deletions
|
||||
|
||||
---
|
||||
|
||||
## ハイライト
|
||||
|
||||
- **Codex OAuth とサードパーティプロバイダー切り替えをより安全に**: 公式認証を保持する任意設定を追加しました。有効化すると、サードパーティプロバイダーの token は `config.toml` に書き込まれ、公式 ChatGPT / Codex OAuth ログインは `auth.json` に残ります。
|
||||
- **Codex モデルカタログが静かに消えないように**: `modelCatalog` はデータベースを信頼できる情報源として扱い、live バックフィル、プロバイダー切り替え、テイクオーバー解除時の復元、プロバイダー編集で、カタログ投影を失った live 設定によりデータベースが上書きされることを避けます。
|
||||
- **Codex Chat ツール / プラグインルーティングを復元**: Chat Completions 上流から返る `tool_search`、読み込み済み namespace ツール、カスタムツールを Codex Responses 形態へ再マッピングします。ストリーミングのカスタムツールはネイティブの `response.custom_tool_call_input.*` イベントを出力します。
|
||||
- **ローカルルーティングのテイクオーバーとホットスイッチがより安定**: プロバイダー切り替えとテイクオーバートグルはアプリごとに直列化されます。ホットスイッチ時は Codex live 内のプロバイダー表示情報を更新しつつ、endpoint は引き続きローカルプロキシを指します。
|
||||
- **診断とプラットフォーム互換性の修正**: Codex プロキシエラーがより豊富な文脈を返すようになりました。Codex CLI のモデルテンプレート探索はより多くのプラットフォームに対応し、静的な GPT-5.5 テンプレートのフォールバックを備えます。Windows のツールバージョン検出では、ローカライズ出力とコマンド引用符の問題を修正しました。
|
||||
|
||||
---
|
||||
|
||||
## 追加機能
|
||||
|
||||
### Codex 公式認証保持設定
|
||||
|
||||
サードパーティ Codex プロバイダーへ切り替えるときに、公式 ChatGPT / Codex OAuth ログイン状態を保持する任意設定を追加しました。有効化すると、CC Switch はサードパーティプロバイダーの API key を Codex `config.toml` の provider-scoped `experimental_bearer_token` に保存し、`auth.json` 内の公式ログインキャッシュを上書きしません。
|
||||
|
||||
一部のユーザーはこの機能によって設定ファイルの書き込み方式が変わることを望んでいないため、この設定はデフォルトでオフです。v3.16.0 以前の互換動作を維持します。公式 Codex ログインとサードパーティプロバイダーを同時に使いたい場合は、「設定 → Codex アプリ拡張」で手動で有効化できます。
|
||||
|
||||
### Codex DeepSeek ルーティングガイド
|
||||
|
||||
Codex DeepSeek ルーティングガイドを中国語 / 英語 / 日本語で追加しました。プロバイダーのルーティング要件、DeepSeek Codex プロバイダーフォームの設定、ローカルルーティングのテイクオーバー手順をスクリーンショット付きで説明します。
|
||||
|
||||
---
|
||||
|
||||
## 変更
|
||||
|
||||
### Codex 認証保持は opt-in に変更
|
||||
|
||||
公式認証保持設定はデフォルトでオフです。これにより、サードパーティ Codex プロバイダーの切り替えは従来の動作を維持し、ユーザーが気づかないうちに `auth.json` / `config.toml` の書き込み方式が変わることを避けます。
|
||||
|
||||
### Codex プロバイダー切り替え後に再起動を案内
|
||||
|
||||
Codex はモデルカタログと一部の設定をクライアント起動時に読み込みます。Codex プロバイダーの切り替えに成功した後、UI は Codex の再起動を案内し、モデルカタログと設定変更が実際に反映されるようにします。
|
||||
|
||||
### プロバイダー切り替えとテイクオーバートグルを直列化
|
||||
|
||||
Codex / Claude / Gemini のプロバイダー切り替えとローカルルーティングのテイクオーバートグルは、アプリごとのロックを共有するようになりました。これにより、2 つの処理が同時に live 設定とバックアップを書き換えることを避けます。live がプロキシ管理下にあるかの判定も、プロキシサービスが起動しているかだけでなく、live バックアップと `PROXY_MANAGED` プレースホルダーを優先して確認します。
|
||||
|
||||
### Codex ホットスイッチで表示情報を更新
|
||||
|
||||
ローカルルーティングのテイクオーバー中に Codex プロバイダーをホットスイッチすると、CC Switch は live 設定内の provider id、モデル、表示名を更新し、Codex クライアントのメニューが現在のプロバイダーに追従するようにします。同時に base URL はローカルプロキシアドレスのまま維持し、実際の上流 endpoint が live ファイルへ戻ってしまうことを防ぎます。
|
||||
|
||||
---
|
||||
|
||||
## 修正
|
||||
|
||||
### Codex テイクオーバー中の編集ダイアログが live OAuth を表示する問題
|
||||
|
||||
Codex がローカルルーティングのテイクオーバー状態にあるとき、live の `auth.json` / `config.toml` はプロキシによって一時的に書き換えられています。この live を読み続けると、現在のプロバイダー編集時にプロキシプレースホルダーや公式 OAuth ログインをプロバイダー設定として誤表示してしまいます。現在の編集ダイアログは、ここに表示されるのがプロキシ管理下の live ファイルではなく、データベースに保存されたプロバイダー設定であることを明示します。プロキシサービスが一時停止していても、そのアプリがテイクオーバー状態であればテイクオーバーとして扱います。
|
||||
|
||||
### Codex OAuth がテイクオーバー中に消去または上書きされる問題
|
||||
|
||||
公式 ChatGPT / Codex OAuth `auth.json` を消去または上書きする可能性があった複数の preserve-mode テイクオーバーパスを修正しました。テイクオーバー判定は `config.toml` 内の `PROXY_MANAGED` を認識し、クリーンアップはプロキシプレースホルダー token だけを削除します。サードパーティプロバイダーが official と誤分類されても、公式 auth の上書きパスには入りません。プロバイダー同期と切り替えでは、live バックアップとプレースホルダーをテイクオーバー所有権のシグナルとして扱い、テイクオーバー直後やプロキシ一時停止中に通常の live 書き込みがプロキシ設定を上書きすることを防ぎます。
|
||||
|
||||
### Codex モデルカタログのデータ消失
|
||||
|
||||
live バックフィル、現在のプロバイダー編集、プロバイダー切り替え、テイクオーバー解除時の復元などで `modelCatalog` が空になる問題を修正しました。スナップショットバックアップは既存の `model_catalog_json` ポインターを保持します。プロバイダーから再構築されるバックアップは、データベースの信頼できる情報源からカタログ投影を再生成します。現在のプロバイダー編集時は、投影を失っている可能性のある live の逆解析結果ではなく、データベース内のモデルカタログを優先します。
|
||||
|
||||
また、プロバイダー切り替え時には生成済みの Codex モデルカタログ JSON を常に更新するようになりました([#3360](https://github.com/farion1231/cc-switch/pull/3360)、@Postroggy に感謝)。
|
||||
|
||||
### Codex Chat ツール、プラグイン、カスタムツールの復元
|
||||
|
||||
サードパーティ Codex プロバイダーが Chat Completions ルーティングを通るとき、`tool_search`、読み込み済みの MCP / connector namespace ツール、カスタムツールを Codex Responses 形態へ完全に復元できない問題を修正しました。非ストリーミングとストリーミングの Chat レスポンスは、元の Responses リクエストに基づいて正しいツール種別、namespace、call id、引数を復元します。カスタムツールのストリーミング出力は、ネイティブの `response.custom_tool_call_input.delta` と `response.custom_tool_call_input.done` イベントを発行します。
|
||||
|
||||
### Codex プロキシエラー診断の拡充
|
||||
|
||||
Codex の転送に失敗したとき、provider、model、endpoint、上流 HTTP ステータス、安定した `cc_switch_*` エラーコード、正規化された HTTP ステータスを含む JSON エラーを返すようになりました。これにより、どのプロバイダー、どの endpoint、どの上流エラーが原因なのかを追いやすくなります。
|
||||
|
||||
### Codex ネイティブ残高 / Coding Plan の認証情報検索
|
||||
|
||||
ネイティブ残高と Coding Plan の照会時に、別アプリの認証情報を誤って使う問題を修正しました。各 app は自分自身のプロバイダー認証情報を解析し、別のアプリ面の認証前提を照会フローへ持ち込まなくなりました([#3355](https://github.com/farion1231/cc-switch/pull/3355)、@SiskonEmilia に感謝)。
|
||||
|
||||
### Codex CLI 探索とモデルカタログテンプレートのフォールバック
|
||||
|
||||
サードパーティ Codex モデルカタログ投影における Codex CLI の探索パスが狭すぎる問題を修正しました。バックエンドは複数プラットフォームの一般的な Codex CLI インストール場所を探し、それでもテンプレートが見つからない場合は内蔵の GPT-5.5 モデルカタログテンプレートへフォールバックします([#3382](https://github.com/farion1231/cc-switch/pull/3382)、@chofuhoyu に感謝)。
|
||||
|
||||
### Claude Desktop Official プロバイダー追加失敗
|
||||
|
||||
Claude Desktop Official プロバイダー追加時のエラーを修正しました([#3405](https://github.com/farion1231/cc-switch/pull/3405)、@Eunknight に感謝)。
|
||||
|
||||
### Kimi / Moonshot ツール思考履歴の正規化
|
||||
|
||||
Kimi / Moonshot を Anthropic 互換ツール思考履歴 normalizer に追加しました。後続ターンで reasoning と tool-call コンテキストを正しく再生できるようになり、履歴メッセージの形が上流要件に合わず失敗する問題を避けます([#3377](https://github.com/farion1231/cc-switch/pull/3377)、@Neon-Wang に感謝)。
|
||||
|
||||
### Windows ツールバージョン検出
|
||||
|
||||
Windows で `.cmd` / `.bat` のバージョンコマンドに誤って引用符が付く問題と、ローカライズされたコマンド出力が文字化けしてデコードされる問題を修正しました。以前は、実行可能なツールが「インストール済みだが実行できない」と表示されることがありました。
|
||||
|
||||
---
|
||||
|
||||
## アップグレード時の注意
|
||||
|
||||
### 公式 OAuth 保持は手動で有効化が必要
|
||||
|
||||
公式 ChatGPT / Codex OAuth ログインを `auth.json` に長期保持しつつ、サードパーティ Codex プロバイダーを頻繁に切り替える場合は、設定で Codex 公式認証保持を有効化してください。既存ユーザーの互換動作を維持するため、デフォルトではオフです。
|
||||
|
||||
### モデルマッピング変更後は Codex の再起動が必要
|
||||
|
||||
Codex は起動時に `model_catalog_json` を読み込みます。v3.16.1 でモデルカタログが空になる問題は修正されていますが、モデルマッピングテーブルを変更した後は、`/model` メニューを更新するために Codex の再起動が必要です。
|
||||
|
||||
### テイクオーバー中に編集するのは保存済み設定であり live ファイルではありません
|
||||
|
||||
ローカルルーティングのテイクオーバーを有効化すると、live の `auth.json` / `config.toml` は一時的に CC Switch プロキシを指します。このときプロバイダー編集で表示されるのは、データベースに保存されたプロバイダー設定です。これは期待される動作です。テイクオーバーを無効化すると、CC Switch はバックアップまたはデータベースの信頼できる情報源から live 設定を復元します。
|
||||
|
||||
---
|
||||
|
||||
## リスク通知
|
||||
|
||||
本リリースは、リバースプロキシ系機能に関する以前のリスク通知を引き続き適用します。
|
||||
|
||||
**Codex OAuth リバースプロキシ**: ChatGPT サブスクリプションの Codex OAuth をリバースプロキシ経由で使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 release notes](v3.13.0-ja.md#️-リスクに関する注意事項) を参照してください。
|
||||
|
||||
**Codex サードパーティプロバイダー Chat ルーティング**: CC Switch ローカルプロキシで Codex リクエストを変換し、サードパーティプロバイダーへ転送する場合、課金、コンプライアンス、データ保持に関する制約はプロバイダーごとに異なります。利用前に対象プロバイダーの利用規約を確認してください。
|
||||
|
||||
**Claude Desktop サードパーティプロバイダープロキシ切り替え**: CC Switch 内蔵のプロキシゲートウェイで Claude Desktop のリクエストをサードパーティプロバイダーへ転送する場合も、対象プロバイダーの課金、コンプライアンス、データ保持に関する規約に従う必要があります。
|
||||
|
||||
上記機能を有効化したユーザーは、関連するリスクを自ら負うものとします。CC Switch は、これらの機能の利用によって発生したアカウント制限、警告、サービス停止について責任を負いません。
|
||||
|
||||
---
|
||||
|
||||
## 謝辞
|
||||
|
||||
v3.16.1 で修正を届けてくださった以下のコントリビューターに感謝します:
|
||||
|
||||
- [#3360](https://github.com/farion1231/cc-switch/pull/3360): Codex プロバイダー切り替え時にモデルカタログ JSON を常に更新、@Postroggy に感謝。
|
||||
- [#3355](https://github.com/farion1231/cc-switch/pull/3355): ネイティブ残高 / Coding Plan 照会の認証情報を app ごとに解析、@SiskonEmilia に感謝。
|
||||
- [#3405](https://github.com/farion1231/cc-switch/pull/3405): Claude Desktop Official プロバイダー追加エラーを修正、@Eunknight に感謝。
|
||||
- [#3382](https://github.com/farion1231/cc-switch/pull/3382): Codex CLI の複数プラットフォーム探索と GPT-5.5 モデルテンプレートフォールバック、@chofuhoyu に感謝。
|
||||
- [#3377](https://github.com/farion1231/cc-switch/pull/3377): Kimi / Moonshot ツール思考履歴の正規化、@Neon-Wang に感謝。
|
||||
|
||||
v3.16.0 リリース後に Codex OAuth、モデルカタログ、ローカルルーティングのテイクオーバー、Chat Completions ツール呼び出しの問題を報告してくださったすべてのユーザーにも感謝します。今回の多くの修正は、実際の利用シーンから得られた再現情報に基づいています。
|
||||
|
||||
---
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から、お使いのシステムに対応するビルドをダウンロードしてください。
|
||||
|
||||
### システム要件
|
||||
|
||||
| システム | 最低バージョン | アーキテクチャ |
|
||||
| -------- | ------------------------ | --------------------------------- |
|
||||
| Windows | Windows 10 以降 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 下表を参照 | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ---------------------------------------- | --------------------------------------------------- |
|
||||
| `CC-Switch-v3.16.1-Windows.msi` | **推奨** - 自動更新対応の MSI インストーラー |
|
||||
| `CC-Switch-v3.16.1-Windows-Portable.zip` | ポータブル版、展開してそのまま実行できます |
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| -------------------------------- | ------------------------------------------------------- |
|
||||
| `CC-Switch-v3.16.1-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ |
|
||||
| `CC-Switch-v3.16.1-macOS.zip` | 展開して Applications へドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.16.1-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||
|
||||
Homebrew インストール:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux アセットは **x86_64** と **ARM64**(`aarch64`)の両方を提供します。ファイル名にアーキテクチャ識別子が含まれているため、マシンの `uname -m` 出力に合わせて選択してください:
|
||||
|
||||
- `CC-Switch-v3.16.1-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.16.1-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して直接起動、または AUR を使用 |
|
||||
| その他 / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,236 @@
|
||||
# CC Switch v3.16.1
|
||||
|
||||
> Codex 稳定性补丁:由于部分用户反映不希望改变配置文件的写入方式,因此为 Codex 增强模式添加开关并默认关闭。开启此开关后,你可以在使用第三方 API 的情况下继续使用 Codex 的手机远程操作、官方插件等功能;本版本也包含一系列稳定性修复。
|
||||
|
||||
**[English →](v3.16.1-en.md) | [日本語版 →](v3.16.1-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 使用攻略
|
||||
|
||||
如果你希望在使用第三方 API 的时候解锁官方订阅才可以使用的远程操作 Codex、解锁官方插件,或希望在 Codex 中使用 DeepSeek / Kimi / GLM / MiniMax 等 Chat Completions 上游,建议先看这些文档:
|
||||
|
||||
- **[使用第三方 API 时保留 Codex 远程操作和官方插件](../guides/codex-official-auth-preservation-guide-zh.md)**:说明如何先完成官方登录,再开启 Codex 应用增强,让官方登录态留在 `auth.json`,同时把模型流量切到第三方 API。
|
||||
- **[在 Codex 中使用 DeepSeek:本地路由实战攻略](../guides/codex-deepseek-routing-guide-zh.md)**:从添加 Codex 供应商、开启本地路由,到验证请求转发的完整路径。
|
||||
- **[添加 Codex 供应商:Chat Completions 路由与模型映射](../user-manual/zh/2-providers/2.1-add.md)**:覆盖「需要本地路由映射」、模型映射表与思考能力配置。
|
||||
- **[本地代理服务](../user-manual/zh/4-proxy/4.1-service.md)** 与 **[本地路由](../user-manual/zh/4-proxy/4.2-routing.md)**:了解代理服务、接管 live 配置、以及相关风险提示。
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## 唯一官方渠道声明(请务必阅读)
|
||||
>
|
||||
> CC Switch 是**完全免费、开源**的桌面应用,**不会向用户收取任何费用**。请仅通过下列官方渠道获取本软件:
|
||||
>
|
||||
> | 类别 | 唯一官方 |
|
||||
> | -------- | ------------------------------------------------------------------------------ |
|
||||
> | 官网 | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | 源码 | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | 下载 | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | 举报山寨 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **任何向你收费、要求充值、或索取登录凭据的"CC Switch"网站或客户端均为假冒**。如果你被诱导支付了费用,请立即停止操作并通过 GitHub Issues 反馈。
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.16.1 是 v3.16.0 之后的一版 Codex 稳定性补丁。v3.16.0 让第三方 Codex 供应商通过 Chat Completions 路由成为一等公民;这一版则主要处理真实使用中暴露出的几个高风险边角:官方 ChatGPT / Codex OAuth 登录态在第三方供应商切换或本地路由接管期间被覆盖,Codex 模型目录在 live 回填、热切换、关闭接管恢复或编辑当前供应商时被清空,以及 Codex 的 `tool_search`、插件 / 连接器命名空间、自定义工具在 Chat Completions 上游路径中没有完整恢复为 Responses 事件。
|
||||
|
||||
这版也加固了本地路由接管的所有权判断:切换供应商和开启 / 关闭接管现在按应用串行执行,判断 live 文件是否由代理接管时不再只看滞后的 `enabled` 或代理服务是否正在运行,而是结合备份和 live 中的代理占位符。这样可以避免刚开启接管、代理临时停止,或热切换时的普通 live 写入把代理托管配置覆盖掉。
|
||||
|
||||
**发布日期**:2026-06-01
|
||||
|
||||
**更新规模**:23 commits | 62 files changed | +5,603 / -1,113 lines
|
||||
|
||||
---
|
||||
|
||||
## 重点内容
|
||||
|
||||
- **Codex OAuth 与第三方供应商切换更安全**:新增可选的官方认证保留设置;开启后,第三方供应商 token 写入 `config.toml`,官方 ChatGPT / Codex OAuth 登录继续留在 `auth.json`。
|
||||
- **Codex 模型目录不再被静默清空**:`modelCatalog` 以数据库为真相来源,live 回填、供应商切换、接管关闭恢复、编辑弹窗都会避免用丢失投影的 live 配置覆盖数据库。
|
||||
- **Codex Chat 工具 / 插件路由恢复**:Chat Completions 上游返回的 `tool_search`、已加载命名空间工具、自定义工具会重新映射回 Codex Responses 形态;流式自定义工具现在发出原生 `response.custom_tool_call_input.*` 事件。
|
||||
- **本地路由接管与热切换更稳**:供应商切换和接管开关按 app 串行,热切换会刷新 Codex live 中的供应商显示信息,但 endpoint 仍保持指向本地代理。
|
||||
- **诊断与平台兼容性修复**:Codex 代理错误返回更丰富上下文;Codex CLI 模型模板发现支持更多平台并提供 GPT-5.5 静态兜底;Windows 工具版本探测修复乱码与误判。
|
||||
|
||||
---
|
||||
|
||||
## 新功能
|
||||
|
||||
### Codex 官方认证保留设置
|
||||
|
||||
新增一个可选设置,用于在切换第三方 Codex 供应商时保留官方 ChatGPT / Codex OAuth 登录态。开启后,CC Switch 会把第三方供应商的 API key 放进 Codex `config.toml` 的 provider-scoped `experimental_bearer_token`,而不是覆盖 `auth.json` 里的官方登录缓存。
|
||||
|
||||
由于部分用户不希望此功能改变配置文件的写入方式,因此该设置默认关闭,保持 v3.16.0 之前的兼容行为。需要同时使用官方 Codex 登录和第三方供应商的用户,可以在“设置 → Codex 应用增强”里手动开启。
|
||||
|
||||
### Codex DeepSeek 路由指南
|
||||
|
||||
新增中 / 英 / 日三语的 Codex DeepSeek 路由指南,包含供应商路由要求、DeepSeek Codex 供应商表单配置,以及本地路由接管的截图说明。
|
||||
|
||||
---
|
||||
|
||||
## 变更
|
||||
|
||||
### Codex 认证保留默认改为 opt-in
|
||||
|
||||
官方认证保留设置默认关闭。这样第三方 Codex 供应商切换继续沿用旧行为,避免已有用户在不知情的情况下改变 `auth.json` / `config.toml` 的写入方式。
|
||||
|
||||
### Codex 切换供应商后提示重启
|
||||
|
||||
Codex 的模型目录与部分配置在客户端启动时加载。现在成功切换 Codex 供应商后,界面会提示用户重启 Codex,让模型目录和配置变化真正生效。
|
||||
|
||||
### 供应商切换与接管开关串行化
|
||||
|
||||
Codex / Claude / Gemini 的供应商切换与本地路由接管开关现在共享 per-app 锁,避免两个流程同时修改 live 配置和备份。判断 live 是否由代理接管时,也会优先看 live 备份与 `PROXY_MANAGED` 占位符,而不是只看代理服务是否正在运行。
|
||||
|
||||
### Codex 热切换刷新显示信息
|
||||
|
||||
在本地路由接管期间热切换 Codex 供应商时,CC Switch 会刷新 live 配置中的 provider id、模型和显示名称,让 Codex 客户端菜单能跟随当前供应商;同时 base URL 仍保持本地代理地址,避免真实上游 endpoint 泄回 live 文件。
|
||||
|
||||
---
|
||||
|
||||
## 修复
|
||||
|
||||
### Codex 接管期间编辑弹窗误显示 live OAuth
|
||||
|
||||
当 Codex 处于本地路由接管状态时,live `auth.json` / `config.toml` 已被代理临时改写。编辑当前供应商如果继续读取 live,就会把代理占位符或官方 OAuth 登录误显示成供应商配置。现在编辑弹窗会明确提示:此处显示的是数据库中存储的供应商配置,而不是代理托管的 live 文件;即使代理服务暂时停止,只要该 app 仍处于接管状态,也会按接管逻辑处理。
|
||||
|
||||
### Codex OAuth 在接管期间被清空或覆盖
|
||||
|
||||
修复多条 preserve-mode 接管路径,它们此前可能清空或覆盖官方 ChatGPT / Codex OAuth `auth.json`。现在接管检测会识别 `config.toml` 里的 `PROXY_MANAGED`,清理流程只移除代理占位符 token,第三方供应商错误归类为 official 时也不会再走官方 auth 覆盖路径。供应商同步与切换会把 live 备份和占位符视为接管所有权信号,避免正常 live 写入覆盖刚接管或代理暂停时的代理配置。
|
||||
|
||||
### Codex 模型目录数据丢失
|
||||
|
||||
修复 `modelCatalog` 在 live 回填、当前供应商编辑弹窗、供应商切换、关闭接管恢复等场景被清空的问题。快照备份会保留已有 `model_catalog_json` 指针;由供应商重建的备份会从数据库真相来源重新生成目录投影;编辑当前供应商时会优先使用数据库里的模型目录,而不是信任可能已经丢失投影的 live 反解结果。
|
||||
|
||||
同时,供应商切换现在会始终刷新生成的 Codex 模型目录 JSON([#3360](https://github.com/farion1231/cc-switch/pull/3360),感谢 @Postroggy)。
|
||||
|
||||
### Codex Chat 工具、插件和自定义工具恢复
|
||||
|
||||
修复第三方 Codex 供应商走 Chat Completions 路由时,`tool_search`、已加载的 MCP / connector 命名空间工具、自定义工具无法完整恢复为 Codex Responses 形态的问题。非流式与流式 Chat 响应现在都会根据原始 Responses 请求恢复正确的工具类型、namespace、call id 与参数;自定义工具流式输出会发出原生的 `response.custom_tool_call_input.delta` 和 `response.custom_tool_call_input.done` 事件。
|
||||
|
||||
### Codex 代理错误诊断更完整
|
||||
|
||||
Codex 转发失败时,现在返回包含 provider、model、endpoint、上游 HTTP 状态、稳定 `cc_switch_*` 错误码和规范 HTTP 状态的 JSON 错误。这样排查「到底是哪个供应商、哪个 endpoint、哪种上游错误」会清楚很多。
|
||||
|
||||
### Codex 原生余额 / Coding Plan 查询凭据
|
||||
|
||||
修复原生余额与 Coding Plan 查询时跨 app 错用凭据的问题。现在每个 app 会解析自己的供应商凭据,不再把其他应用面的认证假设带进查询流程([#3355](https://github.com/farion1231/cc-switch/pull/3355),感谢 @SiskonEmilia)。
|
||||
|
||||
### Codex CLI 发现与模型目录模板兜底
|
||||
|
||||
修复第三方 Codex 模型目录投影对 Codex CLI 发现路径过窄的问题。现在后端会在多平台常见安装位置寻找 Codex CLI,并在仍找不到模板时使用内置 GPT-5.5 模型目录模板兜底([#3382](https://github.com/farion1231/cc-switch/pull/3382),感谢 @chofuhoyu)。
|
||||
|
||||
### Claude Desktop 官方供应商添加失败
|
||||
|
||||
修复添加 Claude Desktop 官方供应商时报错的问题([#3405](https://github.com/farion1231/cc-switch/pull/3405),感谢 @Eunknight)。
|
||||
|
||||
### Kimi / Moonshot 工具思考历史规范化
|
||||
|
||||
把 Kimi / Moonshot 加入 Anthropic 兼容工具思考历史 normalizer。后续轮次现在能正确重放 reasoning 与 tool-call 上下文,避免因为历史消息形态不符合上游要求而失败([#3377](https://github.com/farion1231/cc-switch/pull/3377),感谢 @Neon-Wang)。
|
||||
|
||||
### Windows 工具版本探测
|
||||
|
||||
修复 Windows 上 `.cmd` / `.bat` 版本命令被错误加引号,以及本地化命令输出被解码成乱码的问题。此前这些问题会让可运行的工具显示为「已安装但无法运行」。
|
||||
|
||||
---
|
||||
|
||||
## 升级提醒
|
||||
|
||||
### 官方 OAuth 保留需要手动开启
|
||||
|
||||
如果你希望官方 ChatGPT / Codex OAuth 登录长期保留在 `auth.json`,同时又频繁切换第三方 Codex 供应商,请在设置中开启 Codex 官方认证保留。默认关闭是为了保持老用户的兼容行为。
|
||||
|
||||
### 修改模型映射后仍需重启 Codex
|
||||
|
||||
Codex 在启动时读取 `model_catalog_json`。因此即使 v3.16.1 已修复模型目录被清空的问题,只要你修改了模型映射表,仍然需要重启 Codex 才能让 `/model` 菜单刷新。
|
||||
|
||||
### 接管期间编辑的是存储配置,不是 live 文件
|
||||
|
||||
本地路由接管开启后,live `auth.json` / `config.toml` 会临时指向 CC Switch 代理。此时编辑供应商时看到的是数据库里保存的供应商配置,属于预期行为;关闭接管后,CC Switch 会按备份或数据库真相来源恢复 live 配置。
|
||||
|
||||
---
|
||||
|
||||
## 风险提示
|
||||
|
||||
本版本继续沿用此前版本对反向代理类功能的风险提示。
|
||||
|
||||
**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#️-风险提示)。
|
||||
|
||||
**Codex 第三方供应商 Chat 路由**:通过 CC Switch 本地代理把 Codex 请求转换并转发到第三方供应商时,各供应商对计费、合规与数据留存的约束不同,请在使用前阅读目标供应商的服务条款。
|
||||
|
||||
**Claude Desktop 第三方供应商代理切换**:通过 CC Switch 内置代理网关把 Claude Desktop 的请求转到第三方供应商时,同样需要遵守目标供应商的计费、合规与数据留存约束。
|
||||
|
||||
用户启用上述功能即表示自行承担相关风险。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
感谢以下贡献者在 v3.16.1 中提交修复:
|
||||
|
||||
- [#3360](https://github.com/farion1231/cc-switch/pull/3360):Codex 供应商切换时始终更新模型目录 JSON,感谢 @Postroggy。
|
||||
- [#3355](https://github.com/farion1231/cc-switch/pull/3355):原生余额 / Coding Plan 查询按 app 解析凭据,感谢 @SiskonEmilia。
|
||||
- [#3405](https://github.com/farion1231/cc-switch/pull/3405):修复 Claude Desktop 官方供应商添加报错,感谢 @Eunknight。
|
||||
- [#3382](https://github.com/farion1231/cc-switch/pull/3382):Codex CLI 多平台发现与 GPT-5.5 模型模板兜底,感谢 @chofuhoyu。
|
||||
- [#3377](https://github.com/farion1231/cc-switch/pull/3377):Kimi / Moonshot 工具思考历史规范化,感谢 @Neon-Wang。
|
||||
|
||||
也感谢所有在 v3.16.0 发布后反馈 Codex OAuth、模型目录、本地路由接管和 Chat Completions 工具调用问题的用户。很多补丁都来自这些真实使用场景里的复现线索。
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | -------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------------- | ----------------------------------- |
|
||||
| `CC-Switch-v3.16.1-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.16.1-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| -------------------------------- | --------------------------------------------- |
|
||||
| `CC-Switch-v3.16.1-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
|
||||
| `CC-Switch-v3.16.1-macOS.zip` | 解压后拖入 Applications,Universal Binary |
|
||||
| `CC-Switch-v3.16.1-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
Homebrew 安装:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux 资产同时提供 **x86_64** 和 **ARM64**(`aarch64`)两种架构。资产文件名中包含架构标识,请按你机器的 `uname -m` 输出选择对应版本:
|
||||
|
||||
- `CC-Switch-v3.16.1-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.16.1-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| 发行版 | 推荐格式 | 安装方式 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
|
||||
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,347 @@
|
||||
# CC Switch v3.16.2
|
||||
|
||||
> Following the v3.16.1 Codex stability patch, this release mainly broadens data portability and usage observability — adding S3-compatible cloud sync, OpenCode session usage sync, and an official-subscription quota template — while continuing to harden Codex's Chat Completions routing for third-party providers, fixing a batch of Windows / macOS platform issues, adding the CherryIN and ZenMux providers, and fully refreshing the trilingual user manual.
|
||||
|
||||
**[中文版 →](v3.16.2-zh.md) | [日本語版 →](v3.16.2-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Usage Guides
|
||||
|
||||
This release adds an S3 backend for cloud sync and more usage data sources. If you want to use them, start with these docs:
|
||||
|
||||
- **[Settings](../user-manual/en/1-getting-started/1.5-settings.md)**: configure cloud sync (WebDAV / S3-compatible storage) on the settings page to back up and restore providers, MCP, prompts, skills, and other config across multiple devices.
|
||||
- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: understand the Usage Dashboard's data sources (proxy logs, Codex / Gemini / OpenCode session sync) and how the statistics are counted.
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## Only Official Channels (Please Read)
|
||||
>
|
||||
> CC Switch is a **fully free and open-source** desktop app, and we **do not charge users any fees**. Please only obtain the software through the official channels listed below:
|
||||
>
|
||||
> | Channel | Only Official |
|
||||
> | ------------------ | ------------------------------------------------------------------------------ |
|
||||
> | Website | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | Source | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | Downloads | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | Author | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | Report an Imposter | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **Any "CC Switch" website or client that asks you for payment, top-ups, or login credentials is fake.** If you have been tricked into paying, stop the transaction immediately and file a report through GitHub Issues.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.16.2 is a maintenance update following v3.16.1. After the previous release focused on the security of Codex official authentication and local routing takeover, this release concentrates on two things. First, broadening data portability and usage observability — adding S3-compatible cloud sync (a second cloud-backup backend alongside WebDAV), OpenCode session usage sync, and a quota-statistics template for official subscriptions. Second, continuing to polish the edges exposed when Codex routes third-party providers through Chat Completions — stream-truncation detection, `tool_choice` when tools is empty, custom-tool metadata, reasoning-token statistics, file / audio attachment conversion, and more.
|
||||
|
||||
This release also fixes a batch of local proxy robustness issues (ephemeral port resolution, the takeover placeholder restore loop, Anthropic `system` message normalization, the upstream 413 message, and Claude Desktop's `[1m]` model routing), addresses several Windows / macOS platform experience issues, adds the CherryIN and ZenMux providers, and fully refreshes the trilingual user manual.
|
||||
|
||||
**Release date**: 2026-06-07
|
||||
|
||||
**Stats**: 41 commits | 132 files changed | +11,116 / -1,636 lines
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **S3-compatible cloud sync**: adds S3-compatible object storage as a second cloud-backup backend alongside WebDAV, with one-click presets for AWS S3, MinIO, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS, Huawei OBS, and more.
|
||||
- **More usage data sources**: added OpenCode session usage sync, plus an official-subscription quota template for Claude / Codex / Gemini official providers (explicit toggle, off by default).
|
||||
- **Continued Codex Chat Completions routing hardening**: fixed stream-truncation misdetection, `tool_choice` rejection when tools is empty, custom-tool metadata loss, and missing reasoning-token stats, and added file / audio attachment conversion plus a `/v1/models` reachability endpoint.
|
||||
- **A more robust local proxy**: fixed ephemeral port (port 0) resolution, the takeover placeholder restore loop, Anthropic `system` message normalization, the upstream 413 message, and Claude Desktop 1M-context model routing.
|
||||
- **Platform and providers**: fixed Windows tray / taskbar icons, subdirectory skill updates, and macOS input auto-capitalization, and added the CherryIN and ZenMux providers.
|
||||
|
||||
---
|
||||
|
||||
## Added
|
||||
|
||||
### S3-Compatible Cloud Sync
|
||||
|
||||
Cloud Sync now supports S3-compatible object storage as a second backend alongside WebDAV, signing requests with a self-implemented AWS Signature V4 for the broadest possible compatibility. The settings page offers one-click presets for AWS S3, MinIO, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS, Huawei OBS, and a custom endpoint, with connection testing, manual upload / download, and auto-sync on configuration changes (the providers, endpoint, MCP, prompt, skill, settings, and proxy tables — **not** high-frequency data like usage logs). Enabling S3 sync disables a running WebDAV sync and vice versa (#1351).
|
||||
|
||||
### OpenCode Session Usage Sync
|
||||
|
||||
Added OpenCode as a usage-statistics source that reads per-message token, cost, and model data from OpenCode's local SQLite database and imports it into the usage records, with a dedicated "OpenCode" app filter tab and an "OpenCode Session" data-source label. The database path respects `OPENCODE_DB` and `XDG_DATA_HOME` (defaulting to `~/.local/share/opencode` on all platforms), only finalized messages are imported, and the freshness check includes the WAL file so just-written sessions are not skipped (#3215).
|
||||
|
||||
### Official Subscription Quota Template
|
||||
|
||||
Because some users were concerned that the IP issuing the usage query could differ from the IP issuing in-app requests, risking an account ban, the official-subscription usage template for Claude / Codex / Gemini official providers is now an explicit, opt-in template that queries plan quota via CLI / OAuth credentials, replacing the previous implicit auto-query for official providers. The template is off by default, is enabled from the usage-script modal, and supports a configurable refresh interval. When using this feature, enabling the proxy's TUN mode is recommended.
|
||||
|
||||
### Text-Only Model Image Fallback Rectifier
|
||||
|
||||
Added a proxy rectifier that replaces Anthropic image blocks with an `[Unsupported Image]` placeholder when the routed model is text-only (declared, or detected by a built-in model-name heuristic) or the upstream rejects image input, so conversations are not interrupted. The settings page provides a toggle for this fallback, plus a separate toggle for the heuristic detection (which can be turned off to avoid misjudging multimodal models).
|
||||
|
||||
### ZenMux Token Plan Provider
|
||||
|
||||
Added ZenMux as a Token Plan Coding Plan provider. You can manually enter its API key and base URL in the usage-script modal, and it renders used / quota in USD (#2709).
|
||||
|
||||
### CherryIN Preset
|
||||
|
||||
Added the CherryIN aggregator gateway as a quick-config preset across all 7 managed apps — Claude Code / Claude Desktop / OpenClaw / Hermes use the Anthropic-format endpoint (open.cherryin.net), OpenCode uses `@ai-sdk/anthropic` (`/v1`), Codex uses the OpenAI-compatible endpoint, and Gemini CLI uses the Gemini-compatible endpoint — with the official brand icon, placed next to AiHubMix (#3643).
|
||||
|
||||
### Codex CLI Reachability Endpoint `/v1/models`
|
||||
|
||||
The local proxy now responds to `GET /v1/models`, which Codex CLI probes at startup, returning the CC Switch-managed Codex model catalog. A stale-catalog guard was added: it parses the live `config.toml` and only serves the catalog when `model_catalog_json` still points at the CC Switch-owned catalog file, avoiding exposing a previous provider's leftover catalog to Codex (#3818).
|
||||
|
||||
### Codex Chat File and Audio Attachments
|
||||
|
||||
Codex's Responses→Chat conversion now maps `input_file` parts (carrying `file_id` or inline `file_data`) and `input_audio` parts into their Chat Completions equivalents, and emits top-level `input_*` items that were previously dropped, so file and audio attachments reach Chat-only Codex upstreams.
|
||||
|
||||
---
|
||||
|
||||
## Changed
|
||||
|
||||
### Usage Dashboard Hero Redesign
|
||||
|
||||
Rearranged the Usage Dashboard hero and summary cards into a more compact layout, consolidating the real-token total, request count, and cost into a single top row (#3426).
|
||||
|
||||
### SSSAiCode Endpoint Refresh
|
||||
|
||||
Updated the SSSAiCode preset's website, signup, and API base URLs to the `sssaicodeapi.com` domain, and refreshed its candidate endpoint nodes (default `node-hk.sssaicodeapi.com`, plus `node-hk.sssaiapi.com` and `node-cf.sssaicodeapi.com`) across all 7 app presets.
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### Codex Chat Stream Truncation Detection
|
||||
|
||||
When a Chat Completions upstream ends a stream without a `finish_reason` or `[DONE]`, CC Switch no longer treats it as a normal completion: it finalizes normally only when the stream truly ended; emits an incomplete (`max_output_tokens`) response when partial output was produced; and emits a failed `stream_truncated` event when nothing was produced. Late-arriving reasoning is also backfilled onto still-active streaming tool calls.
|
||||
|
||||
### Codex Chat `tool_choice` Without Tools
|
||||
|
||||
The Responses→Chat conversion now drops `tool_choice` and `parallel_tool_calls` when the final tools array is missing or empty (including when all tools are filtered out), avoiding 503/400 errors from strict OpenAI-compatible upstreams (vLLM, enterprise gateways) with "When using `tool_choice`, `tools` must be set." (#3640).
|
||||
|
||||
### Codex Custom Tool Metadata Preserved
|
||||
|
||||
Custom Codex tools (such as the freeform `apply_patch` tool) now embed their full original definition — including format and grammar metadata — as a compact, order-stable JSON block in the generated Chat function description, instead of being replaced with a generic placeholder, so they remain usable on Chat Completions upstreams (#3644).
|
||||
|
||||
### Codex Chat Usage Missing `reasoning_tokens`
|
||||
|
||||
The Chat→Responses usage conversion now always includes `output_tokens_details.reasoning_tokens` (defaulting to 0), even when a provider omits `completion_tokens_details` or returns a non-object, satisfying Codex CLI's strict requirement and avoiding repeated response-parse failures and retries (#3514).
|
||||
|
||||
### Cross-Turn Reasoning for Codex Custom / Search Tools
|
||||
|
||||
The cross-turn reasoning cache in Codex Chat history now covers the full tool-call set (`function_call`, `custom_tool_call`, `tool_search_call`) and their outputs, not just plain function calls, so `apply_patch` and tool-search calls keep their own `reasoning_content` when restored via `previous_response_id`.
|
||||
|
||||
### Ephemeral Port (port 0) Resolution
|
||||
|
||||
When the proxy is configured to listen on port 0 (OS-assigned), takeover now starts the proxy first to obtain the real port before writing live configs and the database, avoiding client URLs pointing at an invalid `:0` address; if no concrete port has been resolved yet, the Claude Desktop gateway URL is rejected outright.
|
||||
|
||||
### Proxy Placeholder Backup / Restore Loop
|
||||
|
||||
If a previous proxy stop failed to restore the original live config and left proxy placeholders in live, taking over again no longer overwrites the good backup with the proxy config, and restore no longer writes the placeholder back to live: both paths detect the placeholder state and rebuild live from the current provider as the source of truth, fixing cases where the proxy toggle became a no-op and the client was pinned to the local proxy address (#3689).
|
||||
|
||||
### Provider Switching Wrongly Blocked During Proxy Takeover
|
||||
|
||||
During local routing takeover, only providers explicitly classified as official are now blocked from switching, instead of also disabling custom providers whose endpoint lives in meta or whose fields are simply unfilled. The disabled "Enable" button now shows a lighter hint tooltip instead of the previous red "Blocked" badge.
|
||||
|
||||
### localhost Listen Address Normalization
|
||||
|
||||
When saving the proxy with a listen address of `localhost`, it is now normalized to `127.0.0.1` before persisting, avoiding binding inconsistencies (#3016).
|
||||
|
||||
### Anthropic `system` Message Normalization
|
||||
|
||||
For Anthropic-format providers, system-role entries inside the `messages` array are now collapsed and merged into the top-level `system` field (preserving original order and any existing top-level system), avoiding strict upstreams rejecting non-leading system messages; OpenAI Chat routing is unaffected (#3775).
|
||||
|
||||
### Claude Desktop 1M-Context Model Routing
|
||||
|
||||
Claude Desktop appends a `[1m]` marker to the model name when the 1M-context beta is active (e.g. `claude-opus-4-8[1m]`). The proxy now strips that suffix before route matching so exact, alias, legacy, and role-keyword matching all resolve correctly, fixing `route_unknown` (HTTP 400) failures when switching to a 1M model mid-conversation; the original model name is still kept in the `route_unknown` error for diagnostics.
|
||||
|
||||
### Codex 413 Error Message
|
||||
|
||||
When a Codex upstream gateway rejects an oversized request body with HTTP 413, the proxy now returns a dedicated message explaining that this is the provider's server-side body-size limit (not a CC Switch local limit), with actionable recovery steps (run `/compact`, remove large logs or inline images, or ask the provider to raise the limit), instead of echoing the upstream's raw HTML error page.
|
||||
|
||||
### Proxy Panel Error Detail
|
||||
|
||||
When toggling proxy takeover fails, the proxy panel toast now includes the specific error detail returned by the backend, instead of only a generic failure message (#3656).
|
||||
|
||||
### Copilot Infinite-Whitespace Threshold
|
||||
|
||||
Raised the streaming infinite-whitespace abort threshold from 20 to 500 consecutive whitespace characters, avoiding false aborts of legitimate tool calls whose arguments contain deeply indented code (Python, YAML, Rust, Markdown), while still catching the real Copilot infinite-whitespace bug (#2647).
|
||||
|
||||
### Subscription Tier Tray Rendering
|
||||
|
||||
Via a unified tier-to-label mapping, fixed rendering of official subscription tiers in the tray and quota display: Claude / Codex no longer drop the 7-day window, Gemini Pro / Flash / Flash-Lite tiers no longer leak raw machine names, and multi-window plans (e.g. Opus + Sonnet) now show the worst utilization instead of the first match.
|
||||
|
||||
### Inflated Claude Stream input_tokens
|
||||
|
||||
Some Anthropic-compatible streaming providers (e.g. Qwen, MiniMax) report the full context as `input_tokens` in `message_start`, double-counting the cached portion already reported separately and artificially lowering the displayed cache hit rate. The parser now prefers the smaller positive `input_tokens` from `message_delta` and adopts the paired cache counts from the same usage block; native Claude and OpenRouter-converted paths are unchanged.
|
||||
|
||||
### Zhipu Quota Query Endpoint Routing
|
||||
|
||||
The Zhipu Coding Plan quota query was hard-coded to `api.z.ai`, so users on the mainland preset (`open.bigmodel.cn`) could not retrieve usage when the international endpoint was unreachable. The quota request now routes to the host matching the user's configured base URL (#3702).
|
||||
|
||||
### MiniMax Balance API and Pricing
|
||||
|
||||
Adapted MiniMax Coding Plan quota to its new balance API (which returns remaining-percent fields instead of the usage counts the old parser relied on, which left tiers empty and the tray showing no usage), filtered out non-coding models (such as video), handled plans without a weekly limit, and added default pricing for the MiniMax M3 model (#3518).
|
||||
|
||||
### GLM Coding Plan Endpoints and Model Fetch
|
||||
|
||||
Fixed the Zhipu / Z.AI GLM Coding Plan presets to the `/api/coding/paas/v4` endpoints (covering Codex, OpenCode, OpenClaw, Hermes), and made the model-list probe query `{base}/models` first for base URLs that already end in a `/v{N}` version segment (keeping `/v1/models` as a fallback), so the "Fetch models" button no longer 404s on versioned endpoints (#3524).
|
||||
|
||||
### Codex Model Catalog Path Portability
|
||||
|
||||
Codex now writes only the relative filename `cc-switch-model-catalog.json` to `config.toml` instead of an absolute path (Codex CLI resolves it from the config directory), fixing the model catalog breaking on WSL and symlinked setups where the absolute path could not be translated (#3614).
|
||||
|
||||
### APINebula's OpenCode SDK
|
||||
|
||||
The APINebula OpenCode preset now loads `@ai-sdk/openai-compatible` instead of `@ai-sdk/openai`, so requests use the OpenAI Chat Completions format the relay expects, rather than the Responses API that fails against chat-completions-only upstreams.
|
||||
|
||||
### Windows Tray Icon Residue After Exit
|
||||
|
||||
On Windows, quitting CC Switch could leave a dead tray icon behind until the mouse passed over it. The app now explicitly removes the tray icon before exiting, so it disappears cleanly when the process ends (#3797).
|
||||
|
||||
### Windows Taskbar Icon
|
||||
|
||||
Sets an explicit Windows AppUserModelID at runtime and writes the same ID and product icon onto the installer's desktop and start-menu shortcuts, so CC Switch shows the correct icon and groups properly in the taskbar (#3457).
|
||||
|
||||
### Windows Update Check for Subdirectory Skills
|
||||
|
||||
When scanning installed skills on Windows, backslash path separators are now normalized to forward slashes, so skills nested in subdirectories (e.g. `skills/my-skill`) are matched by the update check instead of being silently skipped (#3430).
|
||||
|
||||
### macOS Input Auto-Capitalization
|
||||
|
||||
Disabled autocomplete, autocorrect, autocapitalize, and spellcheck on the shared text Input component, so macOS no longer auto-capitalizes or auto-corrects the first letter typed into configuration fields (#3626).
|
||||
|
||||
### Codex VS Code Session Previews
|
||||
|
||||
For Codex requests sent from VS Code, the session preview could show selection or open-file content instead of the real prompt when a markdown heading preceded the injected request. The backend title and frontend preview now both match the last "## My request for Codex:" heading (the IDE injects the real request as the final section), so the preview reflects the user's prompt (#3593).
|
||||
|
||||
### VS Code Wording in the Chinese UI
|
||||
|
||||
Corrected the "Apply to Claude Code plugin" description in Simplified and Traditional Chinese to write "VS Code" properly instead of "Vscode", aligning with the English and Japanese strings (#3228).
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
### User Manual Refresh
|
||||
|
||||
Refreshed the README localizations and the en / zh / ja user manuals to reflect all 7 managed apps (adding Claude Desktop and Hermes to the intro and overview copy), corrected the OpenCode config path to `~/.config/opencode/` (`opencode.json`), documented Hermes config files, updated the language docs to four languages, corrected per-app MCP / prompt / skill support, noted that export now produces a timestamped SQL backup that includes usage logs, and documented the pricing model-ID matching rules (#3411).
|
||||
|
||||
### Codex Official Auth Preservation Guide
|
||||
|
||||
Added a Chinese / English / Japanese guide explaining how to keep Codex official remote control and official plugins working while routing model traffic to third-party APIs, and linked it from the v3.16.1 release notes.
|
||||
|
||||
### README Links and Sponsor Markup
|
||||
|
||||
Updated the Release Notes links in each language README to v3.16.1, and fixed broken curly-quote characters in the README_ZH sponsor blocks so their HTML attributes render correctly (#3772).
|
||||
|
||||
---
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
### S3 and WebDAV Cloud Sync Are Mutually Exclusive
|
||||
|
||||
Cloud Sync runs only one backend at a time. Enabling S3 auto-sync disables a running WebDAV auto-sync and vice versa. If you previously used WebDAV, make sure both ends are aligned before switching to S3, so you don't assume the old backend is still backing up.
|
||||
|
||||
### Restart Codex After Editing Model Mappings
|
||||
|
||||
Codex reads `model_catalog_json` at startup. Even though this release rewrites the model catalog to a relative path and adds the `/v1/models` reachability endpoint, you still need to restart Codex after editing the model mapping table for the `/model` menu to refresh.
|
||||
|
||||
---
|
||||
|
||||
## Risk Notice
|
||||
|
||||
This release continues the risk notices from previous versions for reverse-proxy-style features.
|
||||
|
||||
**Codex OAuth reverse proxy**: using a ChatGPT subscription's Codex OAuth through a reverse proxy may violate OpenAI's terms of service. See the [v3.13.0 release notes](v3.13.0-en.md#️-risk-notice) for details.
|
||||
|
||||
**Codex third-party provider Chat routing**: when CC Switch local proxy converts and forwards Codex requests to third-party providers, each provider may have different requirements for billing, compliance, and data retention. Read the target provider's terms before use.
|
||||
|
||||
**Claude Desktop third-party provider proxy switching**: when CC Switch's built-in proxy gateway forwards Claude Desktop requests to third-party providers, you must also follow the target provider's billing, compliance, and data-retention terms.
|
||||
|
||||
By enabling these features, users accept the related risks. CC Switch is not responsible for account restrictions, warnings, or service suspensions caused by using these features.
|
||||
|
||||
---
|
||||
|
||||
## Thanks
|
||||
|
||||
Thanks to the following contributors for the features and fixes in v3.16.2:
|
||||
|
||||
- [#1351](https://github.com/farion1231/cc-switch/pull/1351): add S3-compatible cloud storage sync, thanks @keithyt06.
|
||||
- [#3215](https://github.com/farion1231/cc-switch/pull/3215): add OpenCode session usage sync, thanks @nothingness0db.
|
||||
- [#2709](https://github.com/farion1231/cc-switch/pull/2709): add the ZenMux Token Plan provider, thanks @Eter365.
|
||||
- [#3643](https://github.com/farion1231/cc-switch/pull/3643): add the CherryIN preset provider, thanks @zhibisora.
|
||||
- [#3818](https://github.com/farion1231/cc-switch/pull/3818): add the Codex CLI reachability `GET /v1/models` endpoint, thanks @CSberlin.
|
||||
- [#3426](https://github.com/farion1231/cc-switch/pull/3426): Usage Dashboard hero redesign, thanks @allenxu09.
|
||||
- [#3640](https://github.com/farion1231/cc-switch/pull/3640): drop `tool_choice` when tools is empty, thanks @Postroggy.
|
||||
- [#3644](https://github.com/farion1231/cc-switch/pull/3644): preserve Codex custom tool metadata in chat routing, thanks @LanternCX.
|
||||
- [#3514](https://github.com/farion1231/cc-switch/pull/3514): always include `reasoning_tokens` in Chat→Responses, thanks @yeeyzy.
|
||||
- [#3689](https://github.com/farion1231/cc-switch/pull/3689): skip backup / restore when live is already a proxy placeholder, thanks @YongmaoLuo.
|
||||
- [#3016](https://github.com/farion1231/cc-switch/pull/3016): normalize the localhost listen address, thanks @Alexlangl.
|
||||
- [#3775](https://github.com/farion1231/cc-switch/pull/3775): normalize Anthropic `system` messages, thanks @Dearli666.
|
||||
- [#3656](https://github.com/farion1231/cc-switch/pull/3656): improve error message display in the proxy panel, thanks @lzcndm.
|
||||
- [#2647](https://github.com/farion1231/cc-switch/pull/2647): raise the infinite-whitespace threshold 20 → 500, thanks @NiuBlibing.
|
||||
- [#3702](https://github.com/farion1231/cc-switch/pull/3702): route the Zhipu quota query to the configured base URL, thanks @YongmaoLuo.
|
||||
- [#3518](https://github.com/farion1231/cc-switch/pull/3518): adapt to the MiniMax new balance API and default pricing, thanks @LaoYueHanNi.
|
||||
- [#3524](https://github.com/farion1231/cc-switch/pull/3524): fix the Zhipu Coding Plan presets and model probing for versioned endpoints, thanks @makoMakoGo.
|
||||
- [#3614](https://github.com/farion1231/cc-switch/pull/3614): use a relative filename for the model catalog, thanks @steponeerror.
|
||||
- [#3797](https://github.com/farion1231/cc-switch/pull/3797): fix the Windows tray icon residue after exit, thanks @iAJue.
|
||||
- [#3457](https://github.com/farion1231/cc-switch/pull/3457): fix the Windows taskbar icon, thanks @ZhangNanNan1018.
|
||||
- [#3430](https://github.com/farion1231/cc-switch/pull/3430): normalize Windows path separators to match subdirectory skill updates, thanks @Ninthless.
|
||||
- [#3626](https://github.com/farion1231/cc-switch/pull/3626): disable macOS input auto-capitalization, thanks @ZHLHZHU.
|
||||
- [#3593](https://github.com/farion1231/cc-switch/pull/3593): fix Codex VS Code session previews, thanks @xwil1.
|
||||
- [#3228](https://github.com/farion1231/cc-switch/pull/3228): align the VS Code wording in the Chinese UI, thanks @Games55k.
|
||||
- [#3411](https://github.com/farion1231/cc-switch/pull/3411): refresh the user manual to reflect current app support, thanks @makoMakoGo.
|
||||
- [#3772](https://github.com/farion1231/cc-switch/pull/3772): fix README release-note links and sponsor markup, thanks @null-easy.
|
||||
|
||||
Thanks also to everyone who reported Codex Chat routing, local proxy takeover, usage statistics, and platform compatibility issues after v3.16.1. Many of these fixes came directly from real-world reproduction details.
|
||||
|
||||
---
|
||||
|
||||
## Download & Install
|
||||
|
||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) and download the build for your system.
|
||||
|
||||
### System Requirements
|
||||
|
||||
| System | Minimum Version | Architecture |
|
||||
| ------- | ------------------------ | ----------------------------------- |
|
||||
| Windows | Windows 10 and later | x64 |
|
||||
| macOS | macOS 12 (Monterey)+ | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | See table below | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------------------- | ------------------------------------------------ |
|
||||
| `CC-Switch-v3.16.2-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||
| `CC-Switch-v3.16.2-Windows-Portable.zip` | Portable build, unzip and run |
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| -------------------------------- | ----------------------------------------------------- |
|
||||
| `CC-Switch-v3.16.2-macOS.dmg` | **Recommended** - DMG installer, drag to Applications |
|
||||
| `CC-Switch-v3.16.2-macOS.zip` | Unzip and drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.16.2-macOS.tar.gz` | For Homebrew install and auto-update |
|
||||
|
||||
Homebrew install:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Upgrade:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux assets are available for both **x86_64** and **ARM64** (`aarch64`). Choose the file whose architecture tag matches your machine's `uname -m` output:
|
||||
|
||||
- `CC-Switch-v3.16.2-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.16.2-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| Distribution | Recommended Format | Install Command |
|
||||
| --------------------------------------- | ------------------ | --------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | Make executable and run directly, or use AUR |
|
||||
| Other distributions / unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,347 @@
|
||||
# CC Switch v3.16.2
|
||||
|
||||
> v3.16.1 の Codex 安定性パッチに続き、本リリースはデータの可搬性と用量の可観測性の拡張を主眼としています。S3 互換クラウド同期、OpenCode セッション用量同期、公式サブスクリプション残量テンプレートを追加し、Codex がサードパーティプロバイダーを Chat Completions ルーティングする際の堅牢性を引き続き強化しました。あわせて Windows / macOS のプラットフォーム問題を一括修正し、CherryIN・ZenMux プロバイダーを追加し、3 言語のユーザーマニュアルを全面的に刷新しました。
|
||||
|
||||
**[English →](v3.16.2-en.md) | [中文 →](v3.16.2-zh.md)**
|
||||
|
||||
---
|
||||
|
||||
## 利用ガイド
|
||||
|
||||
本リリースではクラウド同期の S3 バックエンドと、より多くの用量統計ソースを追加しました。利用したい場合は、まず以下のドキュメントをご覧ください:
|
||||
|
||||
- **[設定](../user-manual/ja/1-getting-started/1.5-settings.md)**: 設定ページでクラウド同期(WebDAV / S3 互換ストレージ)を構成し、プロバイダー、MCP、プロンプト、スキルなどの設定を複数デバイス間でバックアップ・復元します。
|
||||
- **[用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**: 用量ダッシュボードのデータソース(プロキシログ、Codex / Gemini / OpenCode セッション同期)と統計の数え方を確認できます。
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## 唯一の公式チャネル(必ずお読みください)
|
||||
>
|
||||
> CC Switch は**完全に無料・オープンソース**のデスクトップアプリで、**ユーザーから料金を徴収することはありません**。本ソフトウェアは下記の公式チャネルからのみ入手してください:
|
||||
>
|
||||
> | チャネル | 唯一の公式 |
|
||||
> | ------------ | ------------------------------------------------------------------------------ |
|
||||
> | 公式サイト | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | ソースコード | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | ダウンロード | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | 偽サイト通報 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **料金請求・チャージ・認証情報の提供を求める「CC Switch」サイトやクライアントはすべて偽物です。** 支払いを誘導された場合は直ちに操作を中止し、GitHub Issues からご報告ください。
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.16.2 は v3.16.1 に続くメンテナンスアップデートです。前リリースでは Codex 公式認証とローカルルーティングのテイクオーバーのセキュリティ問題に集中しましたが、本リリースは 2 点に重きを置いています。1 つ目はデータの可搬性と用量の可観測性の拡張で、S3 互換クラウド同期(WebDAV に並ぶ 2 つ目のクラウドバックアップバックエンド)、OpenCode セッション用量同期、公式サブスクリプション向けの残量統計テンプレートを追加しました。2 つ目は、Codex がサードパーティプロバイダーを Chat Completions ルーティングする際に露呈したエッジケースの継続的な改善で、ストリーム切断の判定、tools が空のときの `tool_choice`、カスタムツールのメタデータ、推論トークン統計、ファイル / 音声添付の変換などです。
|
||||
|
||||
本リリースではローカルプロキシの堅牢性に関する問題(一時ポートの解決、テイクオーバーのプレースホルダー復元ループ、Anthropic `system` メッセージの正規化、上流 413 の文言、Claude Desktop の `[1m]` モデルルーティング)を一括修正し、いくつかの Windows / macOS のプラットフォーム体験の問題に対処し、CherryIN・ZenMux の 2 プロバイダーを追加し、3 言語のユーザーマニュアルを全面的に刷新しました。
|
||||
|
||||
**リリース日**: 2026-06-07
|
||||
|
||||
**Stats**: 41 commits | 132 files changed | +11,116 / -1,636 lines
|
||||
|
||||
---
|
||||
|
||||
## ハイライト
|
||||
|
||||
- **S3 互換クラウド同期**: WebDAV に並ぶ 2 つ目のクラウドバックアップバックエンドとして S3 互換オブジェクトストレージを追加。AWS S3、MinIO、Cloudflare R2、Alibaba Cloud OSS、Tencent Cloud COS、Huawei OBS などのワンクリックプリセットを内蔵します。
|
||||
- **より多くの用量データソース**: OpenCode セッション用量同期と、Claude / Codex / Gemini 公式プロバイダー向けの公式サブスクリプション残量テンプレート(明示的なトグル、デフォルトでオフ)を追加しました。
|
||||
- **Codex Chat Completions ルーティングの継続的な強化**: ストリーム切断の誤判定、tools が空のときの `tool_choice` 拒否、カスタムツールメタデータの欠落、推論トークン統計の欠落を修正し、ファイル / 音声添付の変換と `/v1/models` 到達性エンドポイントを追加しました。
|
||||
- **より堅牢なローカルプロキシ**: 一時ポート(port 0)の解決、テイクオーバーのプレースホルダー復元ループ、Anthropic `system` メッセージの正規化、上流 413 の文言、Claude Desktop の 1M コンテキストモデルルーティングを修正しました。
|
||||
- **プラットフォームとプロバイダー**: Windows のトレイ / タスクバーアイコン、サブディレクトリスキルの更新、macOS の入力自動大文字化を修正し、CherryIN・ZenMux プロバイダーを追加しました。
|
||||
|
||||
---
|
||||
|
||||
## 追加機能
|
||||
|
||||
### S3 互換クラウド同期
|
||||
|
||||
クラウド同期は WebDAV に並ぶ 2 つ目のバックエンドとして S3 互換オブジェクトストレージに対応しました。署名は自前実装の AWS Signature V4 を用い、できるだけ多くのサービスと互換性を持たせています。設定ページでは AWS S3、MinIO、Cloudflare R2、Alibaba Cloud OSS、Tencent Cloud COS、Huawei OBS、およびカスタム endpoint のワンクリックプリセットを提供し、接続テスト、手動アップロード / ダウンロード、設定変更時の自動同期(providers、endpoint、MCP、プロンプト、スキル、設定、プロキシなどの設定テーブル。用量ログのような高頻度書き込みデータは**含みません**)に対応します。S3 同期を有効化すると、実行中の WebDAV 同期は停止し、その逆も同様です(#1351)。
|
||||
|
||||
### OpenCode セッション用量同期
|
||||
|
||||
OpenCode を用量統計のソースとして追加しました。OpenCode のローカル SQLite データベースからメッセージごとの token、コスト、モデルのデータを読み取り、用量レコードへインポートします。専用の「OpenCode」アプリフィルタタブと「OpenCode Session」データソースラベルを備えます。データベースパスは `OPENCODE_DB` と `XDG_DATA_HOME` を尊重し(全プラットフォームで既定は `~/.local/share/opencode`)、完了済みのメッセージのみをインポートし、新鮮度判定で WAL ファイルも含めるため、書き込み直後のセッションがスキップされません(#3215)。
|
||||
|
||||
### 公式サブスクリプション残量テンプレート
|
||||
|
||||
用量照会を発行する IP とアプリ内リクエストを発行する IP が異なるとアカウント停止のリスクがある、という一部ユーザーの懸念を受けて、Claude / Codex / Gemini 公式プロバイダー向けに、CLI / OAuth 認証情報でプラン残量を照会する明示的・任意の「公式サブスクリプション」用量テンプレートを追加し、これまでの公式プロバイダーに対する暗黙の自動照会を置き換えました。このテンプレートはデフォルトでオフで、用量スクリプトのモーダルから有効化でき、更新間隔を設定できます。本機能を利用する際は、プロキシの TUN モードを有効化することを推奨します。
|
||||
|
||||
### テキスト専用モデルの画像フォールバック整流器
|
||||
|
||||
ルーティング先のモデルがテキスト専用(明示的な宣言、または内蔵のモデル名ヒューリスティックで判定)の場合、または上流が画像入力を拒否する場合に、Anthropic の画像ブロックを `[Unsupported Image]` プレースホルダーへ置き換えるプロキシ整流器を追加し、会話の中断を防ぎます。設定ページにこのフォールバックのトグルを用意し、さらにヒューリスティック検出を制御する別のトグル(マルチモーダルモデルの誤判定を避けるためオフにできます)を用意しました。
|
||||
|
||||
### ZenMux Token Plan プロバイダー
|
||||
|
||||
ZenMux を Token Plan 系の Coding Plan プロバイダーとして追加しました。用量スクリプトのモーダルで API key と base URL を手動入力でき、使用量 / 残量を米ドル建てでリッチに表示します(#2709)。
|
||||
|
||||
### CherryIN プリセット
|
||||
|
||||
CherryIN アグリゲーターゲートウェイをクイック設定プリセットとして、受管 7 アプリすべてに追加しました。Claude Code / Claude Desktop / OpenClaw / Hermes は Anthropic 形式の endpoint(open.cherryin.net)、OpenCode は `@ai-sdk/anthropic`(`/v1`)、Codex は OpenAI 互換 endpoint、Gemini CLI は Gemini 互換 endpoint を使用します。公式ブランドアイコン付きで、AiHubMix の隣に配置されます(#3643)。
|
||||
|
||||
### Codex CLI 到達性エンドポイント `/v1/models`
|
||||
|
||||
ローカルプロキシは、Codex CLI が起動時にプローブする `GET /v1/models` に応答し、CC Switch が管理する Codex モデルカタログを返すようになりました。あわせて古いカタログのガードを追加: live の `config.toml` を解析し、`model_catalog_json` が CC Switch 所有のカタログファイルを指している場合のみ提供することで、前のプロバイダーが残したカタログを Codex に見せてしまうことを防ぎます(#3818)。
|
||||
|
||||
### Codex Chat のファイル・音声添付
|
||||
|
||||
Codex の Responses→Chat 変換は、`input_file`(`file_id` またはインライン `file_data` を持つ)と `input_audio` のコンテンツ部分を Chat Completions の対応形態へマッピングし、これまで破棄されていたトップレベルの `input_*` 項目も出力するようになりました。これにより、ファイルと音声の添付が Chat のみ対応の Codex 上流へ届きます。
|
||||
|
||||
---
|
||||
|
||||
## 変更
|
||||
|
||||
### 用量ダッシュボードのヒーロー再設計
|
||||
|
||||
用量ダッシュボードのヒーロー領域とサマリーカードをよりコンパクトなレイアウトに再構成し、実トークン総量、リクエスト数、コストを最上部の 1 行にまとめました(#3426)。
|
||||
|
||||
### SSSAiCode エンドポイント刷新
|
||||
|
||||
SSSAiCode プリセットの公式サイト、登録、API base URL を `sssaicodeapi.com` ドメインへ更新し、endpoint 候補ノード(既定 `node-hk.sssaicodeapi.com`、ほかに `node-hk.sssaiapi.com` と `node-cf.sssaicodeapi.com`)を全 7 アプリのプリセットで刷新しました。
|
||||
|
||||
---
|
||||
|
||||
## 修正
|
||||
|
||||
### Codex Chat ストリーム切断の判定
|
||||
|
||||
Chat Completions 上流が `finish_reason` も `[DONE]` もなくストリームを終了した場合、CC Switch はこれを正常完了として扱わなくなりました: 本当に終了したときのみ正常に締め、部分的な出力があった場合は incomplete(`max_output_tokens`)レスポンスを、何も出力されなかった場合は失敗 `stream_truncated` イベントを発行します。遅れて届いた推論も、まだアクティブなストリーミングのツール呼び出しへバックフィルされます。
|
||||
|
||||
### tools が空のときの Codex Chat `tool_choice`
|
||||
|
||||
Responses→Chat 変換は、最終的な tools 配列が欠落または空(すべてのツールがフィルタで除外された場合を含む)のときに `tool_choice` と `parallel_tool_calls` を破棄するようになりました。これにより、厳格な OpenAI 互換上流(vLLM、エンタープライズゲートウェイ)が「When using `tool_choice`, `tools` must be set.」で 503/400 を返すことを避けます(#3640)。
|
||||
|
||||
### Codex カスタムツールメタデータの保持
|
||||
|
||||
カスタム Codex ツール(自由形式の `apply_patch` ツールなど)は、汎用プレースホルダーへ置き換えられる代わりに、format と grammar のメタデータを含む完全な元定義を、生成される Chat 関数の説明にコンパクトで順序の安定した JSON ブロックとして埋め込むようになりました。これにより Chat Completions 上流でも引き続き利用できます(#3644)。
|
||||
|
||||
### Codex Chat 用量の `reasoning_tokens` 欠落
|
||||
|
||||
Chat→Responses の用量変換は、プロバイダーが `completion_tokens_details` を省略したり非オブジェクトを返したりしても、常に `output_tokens_details.reasoning_tokens`(既定 0)を含めるようになりました。これにより Codex CLI の厳格な要件を満たし、レスポンス解析の失敗と再試行の繰り返しを避けます(#3514)。
|
||||
|
||||
### Codex カスタム / 検索ツールのターン跨ぎ推論
|
||||
|
||||
Codex Chat 履歴のターン跨ぎ推論キャッシュが、通常の関数呼び出しだけでなく、ツール呼び出しの全集合(`function_call`、`custom_tool_call`、`tool_search_call`)とその出力をカバーするようになりました。これにより `apply_patch` とツール検索の呼び出しは、`previous_response_id` で復元されるときにそれぞれの `reasoning_content` を保持します。
|
||||
|
||||
### 一時ポート(port 0)の解決
|
||||
|
||||
プロキシが port 0(OS 割り当て)でリッスンするよう構成されている場合、テイクオーバーはまずプロキシを起動して実際のポートを取得してから live 設定とデータベースへ書き込むようになり、クライアント URL が無効な `:0` アドレスを指すことを避けます。具体的なポートがまだ解決されていない場合、Claude Desktop のゲートウェイ URL は拒否されます。
|
||||
|
||||
### プロキシプレースホルダーのバックアップ / 復元ループ
|
||||
|
||||
前回プロキシ停止時に元の live 設定の復元に失敗し、プロキシプレースホルダーが live に残ってしまった場合でも、再度テイクオーバーする際に正常なバックアップをプロキシ設定で上書きすることはなくなり、復元時にプレースホルダーを live へ書き戻すこともなくなりました: いずれの経路もプレースホルダー状態を検知し、現在のプロバイダーを信頼できる情報源として live を再構築します。これにより、プロキシのトグルが何もしない状態になり、クライアントがローカルプロキシアドレスに固定されてしまう問題を修正しました(#3689)。
|
||||
|
||||
### プロキシテイクオーバー中のプロバイダー切り替え誤ブロック
|
||||
|
||||
ローカルルーティングのテイクオーバー中、明示的に official と分類されたプロバイダーのみが切り替えをブロックされるようになり、endpoint が meta に存在する、またはフィールドが未入力なだけのカスタムプロバイダーまで無効化することはなくなりました。無効化された「有効化」ボタンは、以前の赤い「ブロック済み」バッジの代わりに、より軽いヒントのツールチップを表示します。
|
||||
|
||||
### localhost リッスンアドレスの正規化
|
||||
|
||||
プロキシのリッスンアドレスを `localhost` で保存した場合、永続化前に `127.0.0.1` へ正規化されるようになり、バインドの不整合を避けます(#3016)。
|
||||
|
||||
### Anthropic `system` メッセージの正規化
|
||||
|
||||
Anthropic 形式のプロバイダーでは、`messages` 配列内の system ロールのエントリを折りたたんでトップレベルの `system` フィールドへマージするようになり(元の順序と既存のトップレベル system を保持)、厳格な上流が先頭以外の system メッセージを拒否することを避けます。OpenAI Chat ルーティングは影響を受けません(#3775)。
|
||||
|
||||
### Claude Desktop 1M コンテキストモデルルーティング
|
||||
|
||||
Claude Desktop は 1M コンテキスト beta が有効なとき、モデル名に `[1m]` マーカーを付加します(例: `claude-opus-4-8[1m]`)。プロキシはルーティング照合の前にこの接尾辞を除去するようになり、完全一致・エイリアス・旧名・ロールキーワードの照合がすべて正しく解決されます。これにより、会話の途中で 1M モデルへ切り替えたときの `route_unknown`(HTTP 400)の失敗を修正しました。診断用に、`route_unknown` エラーには元のモデル名を引き続き保持します。
|
||||
|
||||
### Codex 413 エラーの文言
|
||||
|
||||
Codex 上流ゲートウェイが過大なリクエストボディを HTTP 413 で拒否したとき、プロキシはこれが CC Switch のローカル制限ではなくプロバイダーのサーバー側ボディサイズ制限であることを説明する専用メッセージを返し、実行可能な回復手順(`/compact` の実行、大きなログやインライン画像の削除、プロバイダーへの上限引き上げ依頼)を提示するようになりました。上流の生の HTML エラーページをそのまま返すことはなくなりました。
|
||||
|
||||
### プロキシパネルのエラー詳細
|
||||
|
||||
プロキシのテイクオーバー切り替えに失敗したとき、プロキシパネルのトーストは、汎用の失敗メッセージだけでなく、バックエンドが返す具体的なエラー詳細を含めるようになりました(#3656)。
|
||||
|
||||
### Copilot 無限空白検出のしきい値
|
||||
|
||||
ストリーミングの無限空白の中断しきい値を、連続する空白文字 20 から 500 へ引き上げました。これにより、引数に深くインデントされたコード(Python、YAML、Rust、Markdown)を含む正当なツール呼び出しが誤って中断されることを避けつつ、本物の Copilot 無限空白バグは引き続き捕捉します(#2647)。
|
||||
|
||||
### サブスクリプション階層のトレイ表示
|
||||
|
||||
統一された階層→ラベルのマッピングにより、トレイと残量表示における公式サブスクリプション階層の表示を修正しました: Claude / Codex は 7 日ウィンドウを取りこぼさなくなり、Gemini Pro / Flash / Flash-Lite の階層は生のマシン名を漏らさなくなり、複数ウィンドウのプラン(Opus + Sonnet など)は最初の一致ではなく最悪の利用率を表示するようになりました。
|
||||
|
||||
### Claude ストリームの input_tokens 過大計上
|
||||
|
||||
一部の Anthropic 互換ストリーミングプロバイダー(Qwen、MiniMax など)は `message_start` で完全なコンテキストを `input_tokens` として報告し、別途報告済みのキャッシュ分を二重計上して、表示上のキャッシュヒット率を不当に低下させていました。パーサーは `message_delta` のより小さい正の `input_tokens` を優先し、同じ usage ブロックのキャッシュカウントを採用するようになりました。ネイティブ Claude と OpenRouter 変換の経路は変更ありません。
|
||||
|
||||
### 智譜(Zhipu)残量照会の endpoint ルーティング
|
||||
|
||||
智譜 Coding Plan の残量照会は `api.z.ai` にハードコードされていたため、本土プリセット(`open.bigmodel.cn`)のユーザーは国際 endpoint が到達不能なときに用量を取得できませんでした。残量リクエストは、ユーザーが構成した base URL に一致するホストへルーティングされるようになりました(#3702)。
|
||||
|
||||
### MiniMax 残量 API と価格
|
||||
|
||||
MiniMax Coding Plan の残量を新しい残量 API に対応させました(新 API は、旧パーサーが依存していた用量カウント(階層が空になりトレイに用量が表示されなくなる)の代わりに、残り割合のフィールドを返します)。非コーディングモデル(動画など)を除外し、週次上限のないプランに対応し、MiniMax M3 モデルの既定価格を追加しました(#3518)。
|
||||
|
||||
### GLM Coding Plan の endpoint とモデル取得
|
||||
|
||||
智譜 / Z.AI の GLM Coding Plan プリセットを `/api/coding/paas/v4` endpoint に修正し(Codex、OpenCode、OpenClaw、Hermes をカバー)、すでに `/v{N}` のバージョンセグメントで終わる base URL については、モデル一覧プローブが `{base}/models` を先に照会するようにしました(`/v1/models` はフォールバックとして保持)。これにより「モデル取得」ボタンがバージョン付き endpoint で 404 にならなくなりました(#3524)。
|
||||
|
||||
### Codex モデルカタログパスの可搬性
|
||||
|
||||
Codex は `config.toml` に絶対パスではなく相対ファイル名 `cc-switch-model-catalog.json` のみを書き込むようになりました(Codex CLI は設定ディレクトリから解決します)。これにより、絶対パスを変換できない WSL やシンボリックリンク環境でモデルカタログが壊れる問題を修正しました(#3614)。
|
||||
|
||||
### APINebula の OpenCode SDK
|
||||
|
||||
APINebula の OpenCode プリセットは `@ai-sdk/openai` ではなく `@ai-sdk/openai-compatible` を読み込むようになり、chat-completions のみ対応の上流で失敗する Responses API ではなく、このリレーが期待する OpenAI Chat Completions 形式でリクエストを行います。
|
||||
|
||||
### Windows 終了後のトレイアイコン残留
|
||||
|
||||
Windows では CC Switch を終了すると、マウスを重ねるまで無効なトレイアイコンが残ることがありました。アプリは終了前にトレイアイコンを明示的に削除するようになり、プロセス終了とともにきれいに消えます(#3797)。
|
||||
|
||||
### Windows タスクバーアイコン
|
||||
|
||||
実行時に Windows AppUserModelID を明示的に設定し、インストーラーが生成するデスクトップとスタートメニューのショートカットに同じ ID と製品アイコンを書き込みます。これにより CC Switch がタスクバーで正しいアイコンを表示し、正しくグループ化されます(#3457)。
|
||||
|
||||
### Windows サブディレクトリスキルの更新チェック
|
||||
|
||||
Windows でインストール済みスキルをスキャンする際、バックスラッシュのパス区切りをスラッシュへ正規化するようになり、サブディレクトリにネストされたスキル(`skills/my-skill` など)が静かにスキップされず、更新チェックで一致するようになりました(#3430)。
|
||||
|
||||
### macOS の入力自動大文字化
|
||||
|
||||
共有のテキスト Input コンポーネントで autocomplete、autocorrect、autocapitalize、spellcheck を無効化し、macOS が設定フィールドに入力された最初の文字を自動で大文字化・自動修正しないようにしました(#3626)。
|
||||
|
||||
### Codex VS Code セッションプレビュー
|
||||
|
||||
VS Code から送信された Codex リクエストでは、注入されたリクエストの前に markdown 見出しがあると、セッションプレビューが本当のプロンプトではなく選択範囲や開いているファイルの内容を表示することがありました。バックエンドのタイトルとフロントエンドのプレビューはいずれも、最後の「## My request for Codex:」見出しに一致するようになり(IDE は本当のリクエストを最後のセクションとして注入します)、プレビューがユーザーのプロンプトを反映します(#3593)。
|
||||
|
||||
### 中国語 UI の VS Code 表記
|
||||
|
||||
簡体字・繁体字中国語の「Claude Code プラグインに適用」の説明を、「Vscode」ではなく正しく「VS Code」と表記するよう修正し、英語・日本語の文言と揃えました(#3228)。
|
||||
|
||||
---
|
||||
|
||||
## ドキュメント
|
||||
|
||||
### ユーザーマニュアル刷新
|
||||
|
||||
README の各言語版と en / zh / ja のユーザーマニュアルを刷新し、受管 7 アプリすべてを反映(紹介と概要の文面に Claude Desktop と Hermes を追加)、OpenCode の設定パスを `~/.config/opencode/`(`opencode.json`)に修正、Hermes の設定ファイルの説明を追加、言語ドキュメントを 4 言語に更新、アプリごとの MCP / プロンプト / スキルの対応状況を訂正、エクスポートがタイムスタンプ付きで用量ログを含む SQL バックアップを生成することを記載、価格モデル ID のマッチングルールを追記しました(#3411)。
|
||||
|
||||
### Codex 公式認証保持ガイド
|
||||
|
||||
モデル通信をサードパーティ API へ切り替えつつ、Codex の公式リモート操作と公式プラグインを動作させ続ける方法を説明する中国語 / 英語 / 日本語のガイドを追加し、v3.16.1 のリリースノートからリンクしました。
|
||||
|
||||
### README リンクとスポンサー表記
|
||||
|
||||
各言語の README のリリースノートリンクを v3.16.1 に更新し、README_ZH のスポンサーブロックで壊れていた曲線引用符文字を修正して、HTML 属性が正しくレンダリングされるようにしました(#3772)。
|
||||
|
||||
---
|
||||
|
||||
## アップグレード時の注意
|
||||
|
||||
### S3 と WebDAV のクラウド同期は排他
|
||||
|
||||
クラウド同期は同時に 1 つのバックエンドのみを実行します。S3 自動同期を有効化すると、実行中の WebDAV 自動同期は停止し、その逆も同様です。以前 WebDAV を使っていた場合は、S3 へ切り替える前に両端のデータが揃っていることを確認し、旧バックエンドがまだバックアップしていると誤解しないようにしてください。
|
||||
|
||||
### モデルマッピング変更後は Codex の再起動が必要
|
||||
|
||||
Codex は起動時に `model_catalog_json` を読み込みます。本リリースでモデルカタログを相対パスへ書き換え、`/v1/models` 到達性エンドポイントを追加しましたが、モデルマッピングテーブルを変更した後は、`/model` メニューを更新するために Codex の再起動が必要です。
|
||||
|
||||
---
|
||||
|
||||
## リスク通知
|
||||
|
||||
本リリースは、リバースプロキシ系機能に関する以前のリスク通知を引き続き適用します。
|
||||
|
||||
**Codex OAuth リバースプロキシ**: ChatGPT サブスクリプションの Codex OAuth をリバースプロキシ経由で使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 release notes](v3.13.0-ja.md#️-リスクに関する注意事項) を参照してください。
|
||||
|
||||
**Codex サードパーティプロバイダー Chat ルーティング**: CC Switch ローカルプロキシで Codex リクエストを変換し、サードパーティプロバイダーへ転送する場合、課金、コンプライアンス、データ保持に関する制約はプロバイダーごとに異なります。利用前に対象プロバイダーの利用規約を確認してください。
|
||||
|
||||
**Claude Desktop サードパーティプロバイダープロキシ切り替え**: CC Switch 内蔵のプロキシゲートウェイで Claude Desktop のリクエストをサードパーティプロバイダーへ転送する場合も、対象プロバイダーの課金、コンプライアンス、データ保持に関する規約に従う必要があります。
|
||||
|
||||
上記機能を有効化したユーザーは、関連するリスクを自ら負うものとします。CC Switch は、これらの機能の利用によって発生したアカウント制限、警告、サービス停止について責任を負いません。
|
||||
|
||||
---
|
||||
|
||||
## 謝辞
|
||||
|
||||
v3.16.2 で機能と修正を届けてくださった以下のコントリビューターに感謝します:
|
||||
|
||||
- [#1351](https://github.com/farion1231/cc-switch/pull/1351): S3 互換クラウドストレージ同期を追加、@keithyt06 に感謝。
|
||||
- [#3215](https://github.com/farion1231/cc-switch/pull/3215): OpenCode セッション用量同期を追加、@nothingness0db に感謝。
|
||||
- [#2709](https://github.com/farion1231/cc-switch/pull/2709): ZenMux Token Plan プロバイダーを追加、@Eter365 に感謝。
|
||||
- [#3643](https://github.com/farion1231/cc-switch/pull/3643): CherryIN プリセットプロバイダーを追加、@zhibisora に感謝。
|
||||
- [#3818](https://github.com/farion1231/cc-switch/pull/3818): Codex CLI 到達性確認用の `GET /v1/models` エンドポイントを追加、@CSberlin に感謝。
|
||||
- [#3426](https://github.com/farion1231/cc-switch/pull/3426): 用量ダッシュボードのヒーロー再設計、@allenxu09 に感謝。
|
||||
- [#3640](https://github.com/farion1231/cc-switch/pull/3640): tools が空のとき `tool_choice` を破棄、@Postroggy に感謝。
|
||||
- [#3644](https://github.com/farion1231/cc-switch/pull/3644): Chat ルーティングで Codex カスタムツールメタデータを保持、@LanternCX に感謝。
|
||||
- [#3514](https://github.com/farion1231/cc-switch/pull/3514): Chat→Responses で常に `reasoning_tokens` を含める、@yeeyzy に感謝。
|
||||
- [#3689](https://github.com/farion1231/cc-switch/pull/3689): live がすでにプロキシプレースホルダーのときバックアップ / 復元をスキップ、@YongmaoLuo に感謝。
|
||||
- [#3016](https://github.com/farion1231/cc-switch/pull/3016): localhost リッスンアドレスを正規化、@Alexlangl に感謝。
|
||||
- [#3775](https://github.com/farion1231/cc-switch/pull/3775): Anthropic `system` メッセージを正規化、@Dearli666 に感謝。
|
||||
- [#3656](https://github.com/farion1231/cc-switch/pull/3656): プロキシパネルのエラー表示を改善、@lzcndm に感謝。
|
||||
- [#2647](https://github.com/farion1231/cc-switch/pull/2647): 無限空白検出のしきい値を 20 → 500 へ引き上げ、@NiuBlibing に感謝。
|
||||
- [#3702](https://github.com/farion1231/cc-switch/pull/3702): 智譜の残量照会を構成済み base URL へルーティング、@YongmaoLuo に感謝。
|
||||
- [#3518](https://github.com/farion1231/cc-switch/pull/3518): MiniMax の新残量 API と既定価格に対応、@LaoYueHanNi に感謝。
|
||||
- [#3524](https://github.com/farion1231/cc-switch/pull/3524): 智譜 Coding Plan プリセットとバージョン付き endpoint のモデル探索を修正、@makoMakoGo に感謝。
|
||||
- [#3614](https://github.com/farion1231/cc-switch/pull/3614): モデルカタログを相対ファイル名に変更、@steponeerror に感謝。
|
||||
- [#3797](https://github.com/farion1231/cc-switch/pull/3797): Windows 終了後のトレイアイコン残留を修正、@iAJue に感謝。
|
||||
- [#3457](https://github.com/farion1231/cc-switch/pull/3457): Windows タスクバーアイコンを修正、@ZhangNanNan1018 に感謝。
|
||||
- [#3430](https://github.com/farion1231/cc-switch/pull/3430): Windows のパス区切りを正規化してサブディレクトリスキルの更新に対応、@Ninthless に感謝。
|
||||
- [#3626](https://github.com/farion1231/cc-switch/pull/3626): macOS の入力自動大文字化を無効化、@ZHLHZHU に感謝。
|
||||
- [#3593](https://github.com/farion1231/cc-switch/pull/3593): Codex VS Code セッションプレビューを修正、@xwil1 に感謝。
|
||||
- [#3228](https://github.com/farion1231/cc-switch/pull/3228): 中国語 UI の VS Code 表記を揃える、@Games55k に感謝。
|
||||
- [#3411](https://github.com/farion1231/cc-switch/pull/3411): 現行のアプリ対応を反映してユーザーマニュアルを刷新、@makoMakoGo に感謝。
|
||||
- [#3772](https://github.com/farion1231/cc-switch/pull/3772): README のリリースノートリンクとスポンサー表記を修正、@null-easy に感謝。
|
||||
|
||||
v3.16.1 リリース後に Codex Chat ルーティング、ローカルプロキシのテイクオーバー、用量統計、プラットフォーム互換性の問題を報告してくださったすべてのユーザーにも感謝します。今回の多くの修正は、実際の利用シーンから得られた再現情報に基づいています。
|
||||
|
||||
---
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から、お使いのシステムに対応するビルドをダウンロードしてください。
|
||||
|
||||
### システム要件
|
||||
|
||||
| システム | 最低バージョン | アーキテクチャ |
|
||||
| -------- | ------------------------ | ----------------------------------- |
|
||||
| Windows | Windows 10 以降 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 下表を参照 | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ---------------------------------------- | ------------------------------------------ |
|
||||
| `CC-Switch-v3.16.2-Windows.msi` | **推奨** - 自動更新対応の MSI インストーラー |
|
||||
| `CC-Switch-v3.16.2-Windows-Portable.zip` | ポータブル版、展開してそのまま実行できます |
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| -------------------------------- | ------------------------------------------------------ |
|
||||
| `CC-Switch-v3.16.2-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ |
|
||||
| `CC-Switch-v3.16.2-macOS.zip` | 展開して Applications へドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.16.2-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||
|
||||
Homebrew インストール:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux アセットは **x86_64** と **ARM64**(`aarch64`)の両方を提供します。ファイル名にアーキテクチャ識別子が含まれているため、マシンの `uname -m` 出力に合わせて選択してください:
|
||||
|
||||
- `CC-Switch-v3.16.2-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.16.2-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||
| --------------------------------------- | ----------- | ------------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して直接起動、または AUR を使用 |
|
||||
| その他 / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,347 @@
|
||||
# CC Switch v3.16.2
|
||||
|
||||
> 在 v3.16.1 的 Codex 稳定性补丁之后,这一版主要拓宽了数据的可携带性与用量观测能力——新增 S3 兼容云同步、OpenCode 会话用量同步、官方订阅额度模板——并继续加固 Codex 通过 Chat Completions 路由第三方供应商的稳健性,同时修复了一批 Windows / macOS 平台问题,新增 CherryIN、ZenMux 供应商,并全面刷新了三语用户手册。
|
||||
|
||||
**[English →](v3.16.2-en.md) | [日本語版 →](v3.16.2-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 使用攻略
|
||||
|
||||
这一版新增了云同步的 S3 后端和更多用量统计来源,如果你想用上,可以先看这些文档:
|
||||
|
||||
- **[设置](../user-manual/zh/1-getting-started/1.5-settings.md)**:在设置页配置云同步(WebDAV / S3 兼容存储),用于在多台设备间备份和恢复供应商、MCP、提示词、技能等配置。
|
||||
- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:了解用量看板的数据来源(代理日志、Codex / Gemini / OpenCode 会话同步)与统计口径。
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## 唯一官方渠道声明(请务必阅读)
|
||||
>
|
||||
> CC Switch 是**完全免费、开源**的桌面应用,**不会向用户收取任何费用**。请仅通过下列官方渠道获取本软件:
|
||||
>
|
||||
> | 类别 | 唯一官方 |
|
||||
> | -------- | ------------------------------------------------------------------------------ |
|
||||
> | 官网 | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | 源码 | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | 下载 | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | 举报山寨 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **任何向你收费、要求充值、或索取登录凭据的"CC Switch"网站或客户端均为假冒**。如果你被诱导支付了费用,请立即停止操作并通过 GitHub Issues 反馈。
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.16.2 是 v3.16.1 之后的一版维护更新。在上一版集中处理 Codex 官方鉴权与本地路由接管的安全问题之后,这一版把重心放在两件事上:一是拓宽数据的可携带性和用量观测——新增 S3 兼容云同步(WebDAV 之外的第二套云备份后端)、OpenCode 会话用量同步,以及面向官方订阅的额度统计模板;二是继续打磨 Codex 通过 Chat Completions 路由第三方供应商时暴露出来的边角问题——流式截断判定、空 tools 下的 `tool_choice`、自定义工具元数据、推理 token 统计、文件 / 音频附件转换等。
|
||||
|
||||
此外,本版还修复了一批本地代理的稳健性问题(临时端口解析、接管占位符还原死循环、Anthropic `system` 消息归一化、上游 413 文案、Claude Desktop 的 `[1m]` 模型路由),处理了若干 Windows / macOS 平台体验问题,并新增 CherryIN、ZenMux 两个供应商,同时全面刷新了三语用户手册。
|
||||
|
||||
**发布日期**:2026-06-07
|
||||
|
||||
**更新规模**:41 commits | 132 files changed | +11,116 / -1,636 lines
|
||||
|
||||
---
|
||||
|
||||
## 重点内容
|
||||
|
||||
- **S3 兼容云同步**:在 WebDAV 之外新增 S3 兼容对象存储作为第二套云备份后端,内置 AWS S3、MinIO、Cloudflare R2、阿里云 OSS、腾讯云 COS、华为 OBS 等一键预设。
|
||||
- **更多用量统计来源**:新增 OpenCode 会话用量同步,以及面向 Claude / Codex / Gemini 官方订阅的额度统计模板(显式开关、默认关闭)。
|
||||
- **Codex Chat Completions 路由继续加固**:修复流式截断误判、空 tools 下 `tool_choice` 被拒、自定义工具元数据丢失、推理 token 统计缺失,并支持文件 / 音频附件转换与 `/v1/models` 探活端点。
|
||||
- **本地代理更稳**:修复临时端口(port 0)解析、接管占位符还原死循环、Anthropic `system` 消息归一化、上游 413 文案,以及 Claude Desktop 1M 上下文模型路由。
|
||||
- **平台与供应商**:修复 Windows 托盘 / 任务栏图标、子目录技能更新、macOS 输入自动大写等问题,并新增 CherryIN、ZenMux 供应商。
|
||||
|
||||
---
|
||||
|
||||
## 新功能
|
||||
|
||||
### S3 兼容云同步
|
||||
|
||||
云同步现在支持 S3 兼容对象存储作为 WebDAV 之外的第二套后端,签名采用自实现的 AWS Signature V4,以兼容尽可能多的服务。设置页提供 AWS S3、MinIO、Cloudflare R2、阿里云 OSS、腾讯云 COS、华为 OBS 以及自定义 endpoint 的一键预设,支持连接测试、手动上传 / 下载,以及在配置变更时自动同步(providers、endpoint、MCP、提示词、技能、设置、代理等配置表,**不含**用量日志这类高频写入数据)。开启 S3 同步会停用正在运行的 WebDAV 同步,反之亦然([#1351](https://github.com/farion1231/cc-switch/pull/1351))。
|
||||
|
||||
### OpenCode 会话用量同步
|
||||
|
||||
新增 OpenCode 作为用量统计来源,从 OpenCode 本地 SQLite 数据库读取每条消息的 token、成本和模型数据并导入用量记录,并提供独立的「OpenCode」应用筛选页签和「OpenCode Session」数据来源标签。数据库路径会遵循 `OPENCODE_DB` 和 `XDG_DATA_HOME`(在所有平台默认 `~/.local/share/opencode`),只导入已完成的消息,并在判断新鲜度时把 WAL 文件一并计入,避免刚写入的会话被跳过([#3215](https://github.com/farion1231/cc-switch/pull/3215))。
|
||||
|
||||
### 官方订阅额度模板
|
||||
|
||||
由于部分用户担心发起用量查询的 IP 和发起应用内请求的不一致导致封号风险,因此为 Claude / Codex / Gemini 官方供应商新增一个显式、可选的「官方订阅」用量模板,通过 CLI / OAuth 凭据查询套餐额度,替代此前对官方供应商的隐式自动查询。该模板默认关闭,需要在用量脚本弹窗里开启,并可配置刷新间隔。使用此功能建议开启代理的 TUN 模式。
|
||||
|
||||
### 文本模型图片回退整流器
|
||||
|
||||
新增一个代理整流器:当路由到的模型仅支持文本(显式声明,或由内置的模型名启发式判定),或上游拒绝图片输入时,会把 Anthropic 图片块替换为 `[Unsupported Image]` 占位标记,避免对话被中断。设置页提供该回退功能的开关,并单独提供一个开关控制启发式检测(可关闭以避免误判多模态模型)。
|
||||
|
||||
### ZenMux Token Plan 供应商
|
||||
|
||||
新增 ZenMux 作为 Token Plan 类的 Coding Plan 供应商,可在用量脚本弹窗里手动填写 API key 和 base URL,并以美元口径富展示已用 / 额度([#2709](https://github.com/farion1231/cc-switch/pull/2709))。
|
||||
|
||||
### CherryIN 预设
|
||||
|
||||
新增 CherryIN 聚合网关作为快捷配置预设,覆盖全部 7 个受管应用——Claude Code / Claude Desktop / OpenClaw / Hermes 使用 Anthropic 格式端点(open.cherryin.net),OpenCode 使用 `@ai-sdk/anthropic`(`/v1`),Codex 使用 OpenAI 兼容端点,Gemini CLI 使用 Gemini 兼容端点,附带官方品牌图标,位置紧挨 AiHubMix([#3643](https://github.com/farion1231/cc-switch/pull/3643))。
|
||||
|
||||
### Codex CLI 模型探活端点 `/v1/models`
|
||||
|
||||
本地代理现在会响应 Codex CLI 启动时探测的 `GET /v1/models`,返回 CC Switch 托管的 Codex 模型目录。同时加入了过期目录守卫:解析 live 的 `config.toml`,仅当 `model_catalog_json` 仍指向 CC Switch 持有的目录文件时才提供,避免把上一个供应商遗留的目录暴露给 Codex([#3818](https://github.com/farion1231/cc-switch/pull/3818))。
|
||||
|
||||
### Codex Chat 文件与音频附件
|
||||
|
||||
Codex 的 Responses→Chat 转换现在会把 `input_file`(携带 `file_id` 或内联 `file_data`)和 `input_audio` 内容部分映射为 Chat Completions 的对应形态,并补发此前会被丢弃的顶层 `input_*` 项,让文件和音频附件能够送达只支持 Chat 的 Codex 上游。
|
||||
|
||||
---
|
||||
|
||||
## 变更
|
||||
|
||||
### 用量看板 Hero 重新设计
|
||||
|
||||
把用量看板的 Hero 区与汇总卡片重排为更紧凑的布局,将真实 token 总量、请求数和成本合并到顶部一行展示([#3426](https://github.com/farion1231/cc-switch/pull/3426))。
|
||||
|
||||
### SSSAiCode 端点刷新
|
||||
|
||||
把 SSSAiCode 预设的官网、注册和 API base URL 更新到 `sssaicodeapi.com` 域名,并刷新其端点候选节点(默认 `node-hk.sssaicodeapi.com`,另含 `node-hk.sssaiapi.com` 和 `node-cf.sssaicodeapi.com`),覆盖全部 7 个应用预设。
|
||||
|
||||
---
|
||||
|
||||
## 修复
|
||||
|
||||
### Codex Chat 流式截断判定
|
||||
|
||||
当 Chat Completions 上游在没有 `finish_reason` 或 `[DONE]` 的情况下结束流时,CC Switch 不再把它当作正常完成:只有流真正结束才正常收尾;已产出部分内容时发出 incomplete(`max_output_tokens`)响应;完全没有产出时发出失败的 `stream_truncated` 事件。晚到的推理内容也会回填到仍在进行的流式工具调用上。
|
||||
|
||||
### Codex Chat 空 tools 下的 `tool_choice`
|
||||
|
||||
Responses→Chat 转换现在会在最终 tools 数组缺失或为空(包括所有工具被过滤掉)时一并丢弃 `tool_choice` 和 `parallel_tool_calls`,避免严格的 OpenAI 兼容上游(vLLM、企业网关)以"When using `tool_choice`, `tools` must be set."报 503/400([#3640](https://github.com/farion1231/cc-switch/pull/3640))。
|
||||
|
||||
### Codex 自定义工具元数据保留
|
||||
|
||||
自定义 Codex 工具(如自由格式的 `apply_patch` 工具)现在会把完整的原始定义——包括 format 和 grammar 元数据——以紧凑、顺序稳定的 JSON 块嵌入生成的 Chat 函数描述中,而不是替换成通用占位符,从而在 Chat Completions 上游上仍可正常使用([#3644](https://github.com/farion1231/cc-switch/pull/3644))。
|
||||
|
||||
### Codex Chat 用量缺少 `reasoning_tokens`
|
||||
|
||||
Chat→Responses 的用量转换现在总会包含 `output_tokens_details.reasoning_tokens`(默认 0),即使供应商省略 `completion_tokens_details` 或返回非对象也是如此,满足 Codex CLI 的严格要求,避免反复的响应解析失败和重试([#3514](https://github.com/farion1231/cc-switch/pull/3514))。
|
||||
|
||||
### Codex 自定义工具 / 搜索工具的跨轮推理
|
||||
|
||||
Codex Chat 历史里的跨轮推理缓存现在覆盖完整的工具调用集合(`function_call`、`custom_tool_call`、`tool_search_call`)及其输出,而不再仅限普通函数调用,因此 `apply_patch` 和工具搜索调用在通过 `previous_response_id` 恢复时能保留各自的 `reasoning_content`。
|
||||
|
||||
### 临时端口(port 0)解析
|
||||
|
||||
当代理被配置为监听 0 端口(由系统分配)时,接管流程现在会先启动代理以拿到真实端口,再写入 live 配置和数据库,避免客户端 URL 指向无效的 `:0` 地址;若还没解析出具体端口,Claude Desktop 的网关 URL 会被直接拒绝。
|
||||
|
||||
### 代理占位符备份 / 恢复死循环
|
||||
|
||||
如果上一次停止代理时未能还原原始 live 配置、把代理占位符遗留在了 live 中,再次接管时不会再用代理配置覆盖掉正常备份,恢复时也不会把占位符写回 live:两条路径都会识别占位符状态并以当前供应商为真相来源重建 live,修复了代理开关变成空操作、客户端被钉死在本地代理地址的问题([#3689](https://github.com/farion1231/cc-switch/pull/3689))。
|
||||
|
||||
### 代理接管期间误拦截供应商切换
|
||||
|
||||
在本地路由接管期间,现在只有显式归类为官方的供应商会被禁止切换,而不会再把端点存在 meta 里、或字段尚未填写的自定义供应商一并禁用。被禁用的「启用」按钮现在以更轻量的提示气泡替代原先的红色「已拦截」标记。
|
||||
|
||||
### localhost 监听地址归一化
|
||||
|
||||
保存代理时如果监听地址填的是 `localhost`,现在会先归一化为 `127.0.0.1` 再持久化,避免绑定不一致([#3016](https://github.com/farion1231/cc-switch/pull/3016))。
|
||||
|
||||
### Anthropic `system` 消息归一化
|
||||
|
||||
对 Anthropic 格式的供应商,`messages` 数组里的 system 角色条目现在会被折叠并合并到顶层 `system` 字段(保留原顺序以及已有的顶层 system),避免严格上游拒绝非首位的 system 消息;OpenAI Chat 路由不受影响([#3775](https://github.com/farion1231/cc-switch/pull/3775))。
|
||||
|
||||
### Claude Desktop 1M 上下文模型路由
|
||||
|
||||
Claude Desktop 在 1M 上下文 beta 激活时会给模型名追加 `[1m]` 标记(如 `claude-opus-4-8[1m]`)。代理现在会在路由匹配前先剥掉该后缀,让精确、别名、旧名和角色关键词匹配都能正确命中,修复了对话中途切换到 1M 模型时的 `route_unknown`(HTTP 400)失败;诊断用的 `route_unknown` 错误里仍保留原始模型名。
|
||||
|
||||
### Codex 413 错误文案
|
||||
|
||||
当 Codex 上游网关以 HTTP 413 拒绝过大的请求体时,代理现在返回专门的提示,说明这是供应商服务端的请求体大小限制(而非 CC Switch 本地限制),并给出可操作的恢复步骤(运行 `/compact`、移除大段日志或内联图片,或请供应商调高限制),不再原样回显上游的 HTML 错误页。
|
||||
|
||||
### 代理面板错误详情
|
||||
|
||||
切换代理接管失败时,代理面板的提示现在会带上后端返回的具体错误详情,而不是只显示一句笼统的失败信息([#3656](https://github.com/farion1231/cc-switch/pull/3656))。
|
||||
|
||||
### Copilot 无限空白检测阈值
|
||||
|
||||
把流式无限空白的中断阈值从 20 调高到 500 个连续空白字符,避免参数里含深层缩进代码(Python、YAML、Rust、Markdown)的正常工具调用被误判中断,同时仍能捕获真正的 Copilot 无限空白 bug([#2647](https://github.com/farion1231/cc-switch/pull/2647))。
|
||||
|
||||
### 订阅档位托盘渲染
|
||||
|
||||
通过统一的档位到标签映射,修复官方订阅档位在托盘和额度展示上的渲染问题:Claude / Codex 不再漏掉 7 天窗口,Gemini Pro / Flash / Flash-Lite 档位不再泄露原始机器名,多窗口套餐(如 Opus + Sonnet)现在按最差利用率展示而非取第一个匹配。
|
||||
|
||||
### Claude 流式 input_tokens 虚高
|
||||
|
||||
部分 Anthropic 兼容的流式供应商(如 Qwen、MiniMax)会在 `message_start` 里把完整上下文当作 `input_tokens` 上报,重复计入了已经单独统计的缓存部分,导致显示的缓存命中率被人为拉低。现在解析器会优先采用 `message_delta` 中更小的正 `input_tokens`,并采用同一 usage 块里配套的缓存计数;原生 Claude 和 OpenRouter 转换路径不变。
|
||||
|
||||
### 智谱配额查询端点路由
|
||||
|
||||
智谱 Coding Plan 的配额查询此前被硬编码到 `api.z.ai`,导致使用大陆预设(`open.bigmodel.cn`)的用户在国际端点不可达时查不到用量。现在配额请求会路由到与用户所配 base URL 匹配的主机([#3702](https://github.com/farion1231/cc-switch/pull/3702))。
|
||||
|
||||
### MiniMax 余额接口与定价
|
||||
|
||||
适配 MiniMax Coding Plan 配额的新余额接口(新接口返回剩余百分比字段,而非旧解析器依赖、会导致档位为空、托盘不再显示用量的用量计数),过滤掉非编程模型(如视频),兼容无周限额的套餐,并为 MiniMax M3 模型补充了默认定价([#3518](https://github.com/farion1231/cc-switch/pull/3518))。
|
||||
|
||||
### GLM Coding Plan 端点与模型拉取
|
||||
|
||||
把智谱 / Z.AI 的 GLM Coding Plan 预设修正到 `/api/coding/paas/v4` 端点(覆盖 Codex、OpenCode、OpenClaw、Hermes),并让模型列表探测对已经以 `/v{N}` 版本段结尾的 base URL 改为先查 `{base}/models`(保留 `/v1/models` 作为兜底),让「拉取模型」按钮不再在带版本号的端点上 404([#3524](https://github.com/farion1231/cc-switch/pull/3524))。
|
||||
|
||||
### Codex 模型目录路径可移植性
|
||||
|
||||
Codex 现在只把相对文件名 `cc-switch-model-catalog.json` 写入 `config.toml`,而不是绝对路径(Codex CLI 会从配置目录解析它),修复了在 WSL 和符号链接环境下绝对路径无法转换、导致模型目录失效的问题([#3614](https://github.com/farion1231/cc-switch/pull/3614))。
|
||||
|
||||
### APINebula 的 OpenCode SDK
|
||||
|
||||
APINebula 的 OpenCode 预设现在加载 `@ai-sdk/openai-compatible` 而非 `@ai-sdk/openai`,让请求使用该中转期望的 OpenAI Chat Completions 格式,而不是只支持 chat-completions 的上游会失败的 Responses API。
|
||||
|
||||
### Windows 退出后托盘图标残留
|
||||
|
||||
在 Windows 上退出 CC Switch 可能会留下一个失效的托盘图标,直到鼠标划过才消失。现在应用会在退出前显式移除托盘图标,让它随进程结束干净消失([#3797](https://github.com/farion1231/cc-switch/pull/3797))。
|
||||
|
||||
### Windows 任务栏图标
|
||||
|
||||
在运行时显式设置 Windows AppUserModelID,并给安装器生成的桌面和开始菜单快捷方式写入相同的 ID 和产品图标,让 CC Switch 在任务栏上显示正确图标并正确归组([#3457](https://github.com/farion1231/cc-switch/pull/3457))。
|
||||
|
||||
### Windows 子目录技能的更新检查
|
||||
|
||||
在 Windows 上扫描已安装技能时,把反斜杠路径分隔符归一化为正斜杠,让嵌套在子目录里的技能(如 `skills/my-skill`)能被更新检查匹配到,而不是被静默跳过([#3430](https://github.com/farion1231/cc-switch/pull/3430))。
|
||||
|
||||
### macOS 输入自动大写
|
||||
|
||||
为共享的文本 Input 组件关闭自动完成、自动纠错、自动大写和拼写检查,让 macOS 不再对配置字段里输入的首字母自动大写或自动纠正([#3626](https://github.com/farion1231/cc-switch/pull/3626))。
|
||||
|
||||
### Codex VS Code 会话预览
|
||||
|
||||
从 VS Code 发起的 Codex 请求,其会话预览在注入请求前存在 markdown 标题时,可能显示选区或打开文件的内容而非真实提示。现在后端标题和前端预览都会匹配最后一个「## My request for Codex:」标题(IDE 把真实请求作为最后一节注入),让预览反映用户的提示([#3593](https://github.com/farion1231/cc-switch/pull/3593))。
|
||||
|
||||
### 中文界面 VS Code 文案
|
||||
|
||||
把简体和繁体中文里「应用到 Claude Code 插件」的描述改为正确书写「VS Code」而非「Vscode」,与英文、日文文案对齐([#3228](https://github.com/farion1231/cc-switch/pull/3228))。
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
### 用户手册刷新
|
||||
|
||||
刷新了 README 各语言版本以及 en / zh / ja 用户手册,使其反映全部 7 个受管应用(在介绍和总览文案里补上 Claude Desktop 与 Hermes),把 OpenCode 配置路径修正为 `~/.config/opencode/`(`opencode.json`),补充了 Hermes 配置文件说明,把语言文档更新为四种语言,订正各应用 MCP / 提示词 / 技能的支持情况,说明导出现在会生成带时间戳、含用量日志的 SQL 备份,并补充了定价模型 ID 匹配规则([#3411](https://github.com/farion1231/cc-switch/pull/3411))。
|
||||
|
||||
### Codex 官方认证保留指南
|
||||
|
||||
新增中 / 英 / 日三语指南,说明如何在把模型流量切到第三方 API 的同时,保留 Codex 官方远程操作和官方插件的可用性,并从 v3.16.1 release notes 链接到该指南。
|
||||
|
||||
### README 链接与赞助商标记
|
||||
|
||||
把各语言 README 里的 Release Notes 链接更新到 v3.16.1,并修复 README_ZH 赞助商区块里损坏的弯引号字符,让其 HTML 属性能正确渲染([#3772](https://github.com/farion1231/cc-switch/pull/3772))。
|
||||
|
||||
---
|
||||
|
||||
## 升级提醒
|
||||
|
||||
### S3 与 WebDAV 云同步互斥
|
||||
|
||||
云同步同一时间只会运行一套后端。开启 S3 自动同步会停用正在运行的 WebDAV 自动同步,反之亦然。如果你之前用的是 WebDAV,切到 S3 前请确认两端数据已对齐,避免误以为旧后端仍在备份。
|
||||
|
||||
### 修改模型映射后仍需重启 Codex
|
||||
|
||||
Codex 在启动时读取 `model_catalog_json`。即使本版已把模型目录改写为相对路径并新增了 `/v1/models` 探活端点,只要你修改了模型映射表,仍然需要重启 Codex 才能让 `/model` 菜单刷新。
|
||||
|
||||
---
|
||||
|
||||
## 风险提示
|
||||
|
||||
本版本继续沿用此前版本对反向代理类功能的风险提示。
|
||||
|
||||
**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#️-风险提示)。
|
||||
|
||||
**Codex 第三方供应商 Chat 路由**:通过 CC Switch 本地代理把 Codex 请求转换并转发到第三方供应商时,各供应商对计费、合规与数据留存的约束不同,请在使用前阅读目标供应商的服务条款。
|
||||
|
||||
**Claude Desktop 第三方供应商代理切换**:通过 CC Switch 内置代理网关把 Claude Desktop 的请求转到第三方供应商时,同样需要遵守目标供应商的计费、合规与数据留存约束。
|
||||
|
||||
用户启用上述功能即表示自行承担相关风险。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
感谢以下贡献者在 v3.16.2 中提交的功能与修复:
|
||||
|
||||
- [#1351](https://github.com/farion1231/cc-switch/pull/1351):新增 S3 兼容云存储同步,感谢 @keithyt06。
|
||||
- [#3215](https://github.com/farion1231/cc-switch/pull/3215):新增 OpenCode 会话用量同步,感谢 @nothingness0db。
|
||||
- [#2709](https://github.com/farion1231/cc-switch/pull/2709):新增 ZenMux Token Plan 供应商,感谢 @Eter365。
|
||||
- [#3643](https://github.com/farion1231/cc-switch/pull/3643):新增 CherryIN 预设供应商,感谢 @zhibisora。
|
||||
- [#3818](https://github.com/farion1231/cc-switch/pull/3818):新增 Codex CLI 探活用的 `GET /v1/models` 端点,感谢 @CSberlin。
|
||||
- [#3426](https://github.com/farion1231/cc-switch/pull/3426):用量看板 Hero 重新设计,感谢 @allenxu09。
|
||||
- [#3640](https://github.com/farion1231/cc-switch/pull/3640):空 tools 时丢弃 `tool_choice`,感谢 @Postroggy。
|
||||
- [#3644](https://github.com/farion1231/cc-switch/pull/3644):Chat 路由保留 Codex 自定义工具元数据,感谢 @LanternCX。
|
||||
- [#3514](https://github.com/farion1231/cc-switch/pull/3514):Chat→Responses 始终包含 `reasoning_tokens`,感谢 @yeeyzy。
|
||||
- [#3689](https://github.com/farion1231/cc-switch/pull/3689):live 已是代理占位符时跳过备份 / 恢复,感谢 @YongmaoLuo。
|
||||
- [#3016](https://github.com/farion1231/cc-switch/pull/3016):归一化 localhost 监听地址,感谢 @Alexlangl。
|
||||
- [#3775](https://github.com/farion1231/cc-switch/pull/3775):规范化 Anthropic `system` 消息,感谢 @Dearli666。
|
||||
- [#3656](https://github.com/farion1231/cc-switch/pull/3656):改进代理面板错误信息展示,感谢 @lzcndm。
|
||||
- [#2647](https://github.com/farion1231/cc-switch/pull/2647):调高无限空白检测阈值 20 → 500,感谢 @NiuBlibing。
|
||||
- [#3702](https://github.com/farion1231/cc-switch/pull/3702):智谱配额查询按所配 base URL 路由,感谢 @YongmaoLuo。
|
||||
- [#3518](https://github.com/farion1231/cc-switch/pull/3518):适配 MiniMax 余额查询新接口与默认定价,感谢 @LaoYueHanNi。
|
||||
- [#3524](https://github.com/farion1231/cc-switch/pull/3524):修复智谱 Coding Plan 预设与带版本号端点的模型探测,感谢 @makoMakoGo。
|
||||
- [#3614](https://github.com/farion1231/cc-switch/pull/3614):模型目录改用相对文件名,感谢 @steponeerror。
|
||||
- [#3797](https://github.com/farion1231/cc-switch/pull/3797):修复 Windows 退出后托盘图标残留,感谢 @iAJue。
|
||||
- [#3457](https://github.com/farion1231/cc-switch/pull/3457):修复 Windows 任务栏图标,感谢 @ZhangNanNan1018。
|
||||
- [#3430](https://github.com/farion1231/cc-switch/pull/3430):归一化 Windows 路径分隔符以匹配子目录技能更新,感谢 @Ninthless。
|
||||
- [#3626](https://github.com/farion1231/cc-switch/pull/3626):关闭 macOS 输入框自动大写,感谢 @ZHLHZHU。
|
||||
- [#3593](https://github.com/farion1231/cc-switch/pull/3593):修复 Codex VS Code 会话预览,感谢 @xwil1。
|
||||
- [#3228](https://github.com/farion1231/cc-switch/pull/3228):对齐中文界面 VS Code 文案,感谢 @Games55k。
|
||||
- [#3411](https://github.com/farion1231/cc-switch/pull/3411):刷新用户手册以反映当前应用支持,感谢 @makoMakoGo。
|
||||
- [#3772](https://github.com/farion1231/cc-switch/pull/3772):修复 README release note 链接与赞助商标记,感谢 @null-easy。
|
||||
|
||||
也感谢所有在 v3.16.1 发布后反馈 Codex Chat 路由、本地代理接管、用量统计和平台兼容性问题的用户,很多补丁都来自这些真实使用场景里的复现线索。
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | -------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------------- | ----------------------------------- |
|
||||
| `CC-Switch-v3.16.2-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.16.2-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| -------------------------------- | --------------------------------------------- |
|
||||
| `CC-Switch-v3.16.2-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
|
||||
| `CC-Switch-v3.16.2-macOS.zip` | 解压后拖入 Applications,Universal Binary |
|
||||
| `CC-Switch-v3.16.2-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
Homebrew 安装:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux 资产同时提供 **x86_64** 和 **ARM64**(`aarch64`)两种架构。资产文件名中包含架构标识,请按你机器的 `uname -m` 输出选择对应版本:
|
||||
|
||||
- `CC-Switch-v3.16.2-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.16.2-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| 发行版 | 推荐格式 | 安装方式 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
|
||||
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,339 @@
|
||||
# CC Switch v3.16.3
|
||||
|
||||
> 🎉 **CC Switch has passed 100,000 Stars!**
|
||||
> Thank you to every user, contributor, and Star — you are the reason it has come this far. 🙏
|
||||
|
||||
> 💎 **This release was developed with help from the Claude Fable 5 model** — it helped untangle several critical, error-prone pieces of logic: the attribution chain that bills route-takeover traffic by the real upstream model, the metering and de-duplication of cache tokens on format-conversion paths, the in-app update restart deadlock, and the migration / restore invariants of Codex unified session history. This is also why this release adds a **Fable 5 Verified** badge to the About page.
|
||||
|
||||
> After v3.16.2 broadened data portability and usage observability, this release puts the focus on "making usage billing truly accurate" — billing by the real upstream model, fixing cache double-counting on format-conversion paths, counting Claude Code Workflow sub-agent usage (schema v11), and a round of redesign for the usage dashboard (dashboard-wide provider / model filters, a brand-icon toolbar, and more resilient quota queries) — while also hardening a batch of local proxy and platform issues, adding a custom User-Agent override, a Codex unified session history toggle, and a Claude Fable 5 tier.
|
||||
|
||||
**[中文版 →](v3.16.3-zh.md) | [日本語版 →](v3.16.3-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Usage Guides
|
||||
|
||||
This release changes how usage is counted and reworks the dashboard quite a bit, so it is worth starting here:
|
||||
|
||||
- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: understand the Usage Dashboard's data sources (proxy logs, session sync) and how the statistics are counted. This release adds dashboard-wide provider / model filters and surfaces the real pricing model for route-takeover traffic.
|
||||
- **[Settings](../user-manual/en/1-getting-started/1.5-settings.md)**: the custom User-Agent override, the Codex unified session history toggle, and other switches live in the provider form's advanced options and on the settings page.
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## Only Official Channels (Please Read)
|
||||
>
|
||||
> CC Switch is a **fully free and open-source** desktop app, and we **do not charge users any fees**. Please only obtain the software through the official channels listed below:
|
||||
>
|
||||
> | Channel | Only Official |
|
||||
> | ------------------ | ------------------------------------------------------------------------------ |
|
||||
> | Website | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | Source | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | Downloads | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | Author | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | Report an Imposter | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **Any "CC Switch" website or client that asks you for payment, top-ups, or login credentials is fake.** If you have been tricked into paying, stop the transaction immediately and file a report through GitHub Issues.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.16.3 is a maintenance update following v3.16.2. After the previous release concentrated on broadening data portability and usage observability, this release puts the focus on "making usage billing truly accurate" — billing by the real upstream model rather than whatever the upstream echoes back, fixing the cache-token double-counting on format-conversion paths (Chat / Responses / Gemini converted to Anthropic), folding Claude Code Workflow sub-agent usage into the local statistics, and persisting the actual pricing basis used by each record as schema v11. The usage dashboard was reworked along with it, adding dashboard-wide provider / model filters, a brand-icon toolbar, and more resilient quota queries (retry on failure plus keeping the last successful result).
|
||||
|
||||
In addition, this release hardens a batch of local proxy robustness issues (aggregating SSE responses returned under a mislabeled Content-Type, Codex `/responses` image rectification for text-only models, recovery of Codex OAuth credentials and takeover residue, duplicate YAML keys in Hermes config), reworks the provider configuration experience (a custom User-Agent override, a unified Codex advanced section, searchable and sortable presets, a Claude Fable 5 tier), adds a Codex unified session history toggle, and fixes the in-app update hang, the Codex upgrade that broke the install, duplicate macOS terminal windows, and more.
|
||||
|
||||
**Release date**: 2026-06-14
|
||||
|
||||
**Stats**: 59 commits | 130 files changed | +10,223 / -4,232 lines
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **More accurate usage billing**: route-takeover traffic is now billed by the real upstream model (not the alias the upstream echoes back), format-conversion paths no longer count cache tokens into input twice, and Claude Code Workflow sub-agent usage is now counted — with the pricing basis persisted as schema v11.
|
||||
- **Usage dashboard redesign**: provider / model filters are promoted from inside the request-log table up to dashboard-wide filters, the app filter switches to brand icons, and quota queries gain retry-on-failure plus "keep the last successful result" so a single network blip no longer turns cards red.
|
||||
- **Custom User-Agent override**: providers can set a custom UA that applies consistently across forwarding, connectivity detection, and model listing, getting past coding-plan upstreams that gate on a UA whitelist (which is how the Codex "Kimi For Coding" preset was restored).
|
||||
- **Codex unified session history**: a new opt-in toggle lets official Codex sessions share a single resume-history bucket with third-party sessions, with optional migration of existing sessions and precise ledger-based restore.
|
||||
- **Proxy and platform hardening**: aggregating mislabeled SSE responses, Codex image rectification, takeover-residue recovery, Hermes YAML de-duplication; in-app updates no longer hang on "restarting", and Codex upgrades no longer break the install.
|
||||
|
||||
---
|
||||
|
||||
## Added
|
||||
|
||||
### Custom User-Agent Override
|
||||
|
||||
Provider configs can now set a custom User-Agent that the proxy applies consistently across request forwarding, stream check, and model listing (`GET /v1/models`), so coding-plan upstreams that gate on UA no longer fail detection or return 403 while the proxy itself works. The Claude and Codex forms expose it in advanced settings with a curated presets dropdown (Claude Code / Kilo Code families that pass UA whitelists) and live non-blocking validation; stale custom UAs are dropped when switching to an official preset to avoid silently altering headers (#3671).
|
||||
|
||||
### Unified Codex Session History
|
||||
|
||||
Official Codex sessions can now share a single resume-history bucket with cc-switch third-party sessions via an opt-in toggle under Settings → Codex App Enhancements, so the resume picker no longer hides them from each other. When enabled, the live `config.toml` routes official runs through a shared `custom` model_provider that mirrors the built-in OpenAI provider (`auth.json` is untouched); the toggle is forward-only by default but the enable dialog offers a checkbox to migrate existing official sessions (with per-generation backups), and the disable dialog offers a precise ledger-based restore that only reverts sessions originally recorded as `openai` while leaving sessions created during the toggle untouched.
|
||||
|
||||
### Dashboard-Wide Provider / Model Filters
|
||||
|
||||
The provider and model filters move from inside the request-log table up to the top bar, applying globally to the hero summary, trend chart, request logs, and both stats tabs so you can scope the whole dashboard to a given source and model. Sources match by exact display name (so session placeholder rows like "Claude (Session)" are selectable) and models match by effective pricing model, with the model dropdown cascading from the selected source and both lists showing only options that have data in the current range.
|
||||
|
||||
### Refreshed Model Pricing Seed
|
||||
|
||||
Added pricing for 9 models including Claude Fable 5, Grok 4.3, Mistral Medium 3.5 / Small 4, and Qwen 3.7 Max/Plus, and corrected 28 existing prices against current official vendor list pricing (GLM, Grok, MiMo, Doubao, Kimi, MiniMax, Mistral, Qwen) so usage cost estimates are accurate. Each change updates the seed for fresh installs and adds a guarded repair for existing databases without clobbering user-edited rows.
|
||||
|
||||
### Claude Fable 5 Model Tier
|
||||
|
||||
Provider forms now expose `claude-fable-5` as a fourth model-mapping tier on both the Claude Code and Claude Desktop proxy paths, with a fable → opus → default fallback mirroring the official downgrade and the `fable-` prefix whitelisted for the Desktop 1.12603.1+ validator. A clarified four-language fallback hint warns that leaving a tier blank on third-party endpoints forwards the literal model name and 404s (#3980, #4026, #4049).
|
||||
|
||||
### Unity2.ai Partner Provider
|
||||
|
||||
Added Unity2.ai, an AI API relay partner, as a preset across all seven managed apps (Claude Code, Codex, Gemini, OpenCode, OpenClaw, Claude Desktop, Hermes), each carrying the referral signup link and partner promotion copy in all four locales. Codex uses the bare base URL (the gateway exposes `/responses` at root) while OpenCode / OpenClaw / Hermes use the `/v1` chat-completions endpoint with `gpt-5.5`.
|
||||
|
||||
### Kimi K2.7 Code Model
|
||||
|
||||
Added the `kimi-k2.7-code` model (in $0.95 / out $4.00 / cache-read $0.19 per 1M tokens, 256K context) and pointed all six official Moonshot Kimi presets (Claude Code, Codex, Claude Desktop, Hermes, OpenCode, OpenClaw) at it, renaming the OpenCode / OpenClaw presets to "Kimi K2.7 Code". The pricing seed applies on startup via the idempotent insert path, so existing users pick up the new pricing without a migration.
|
||||
|
||||
### Codex "Kimi For Coding" Preset Restored
|
||||
|
||||
Re-added the Codex "Kimi For Coding" preset (`openai_chat`, `kimi-for-coding`, 256K context) with thinking mode enabled by default; it was previously removed because the coding endpoint rejects Codex's default `codex-cli` User-Agent with 403. It now works via proxy takeover combined with the custom User-Agent override (set to a whitelisted UA such as `claude-cli/*`).
|
||||
|
||||
### Pricing-Model Audit in Request Detail
|
||||
|
||||
The request detail panel now shows the requested model and the pricing model when they differ from the response model, making route-takeover bills auditable directly from the usage UI.
|
||||
|
||||
### Preset Provider Search & Sorting
|
||||
|
||||
The provider preset selector gains a searchable, sorted list with an inline search box (toggled via a magnifier icon, dismissed on ESC or outside click). Buttons use a responsive grid with consistent sizing and default icons, and search matches only provider display/raw names so URL fragments and shared category labels no longer produce noisy matches (#3975, #4183).
|
||||
|
||||
### Claude Mythos 5 Pricing
|
||||
|
||||
Registered the `claude-mythos-5` model in the bundled model/pricing table (in $10 / out $50 per 1M tokens, cache read $1.00, cache write $12.50), so usage metering prices and displays it correctly (#4077).
|
||||
|
||||
### Fable 5 Verified Banner
|
||||
|
||||
The Settings About page now displays a Fable 5 Verified banner beside the app name and version, marking this as a special build, with the version badge centered under the app name.
|
||||
|
||||
---
|
||||
|
||||
## Changed
|
||||
|
||||
### Claude Desktop Usage Folded Into Claude
|
||||
|
||||
The dashboard no longer shows a standalone "Claude Desktop" bucket, which only ever displayed a partial number (Desktop chat usage never passes through the proxy and its Code-tab sessions write into the shared `~/.claude/projects` tree). Desktop proxy traffic is now folded into the `claude` view for display while still recorded under its own `app_type` for route-takeover billing audit, with the real value visible in the request detail panel.
|
||||
|
||||
### Lightweight Provider Health Check
|
||||
|
||||
The provider health check no longer sends a real streaming model request (which many third-party providers blocked with 401/403/WAF, causing false negatives); it now performs a lightweight HTTP reachability probe of the provider `base_url`, treating any HTTP response as reachable and counting only DNS/connect/TLS/timeout as failure. The connectivity button is hidden for official providers (which use OAuth with an empty base URL and no reliable reachability target), the real-request confirmation dialog and test model/prompt fields are removed, and the degraded-latency threshold is set to 6s with an 8s timeout. The reachability check never resets the circuit breaker, so failover detection stays driven solely by real proxy traffic.
|
||||
|
||||
### Codex Advanced Options Section
|
||||
|
||||
The Codex provider form now folds local routing, model mapping, reasoning overrides, and custom User-Agent into a single collapsible advanced section mirroring the Claude form (auto-expanding when a UA is set or local routing is on). Custom User-Agent is now also configurable for native Responses providers, where it was previously reachable only with `openai_chat` routing enabled.
|
||||
|
||||
### Usage Toolbar Refresh and Layout
|
||||
|
||||
The app filter now renders brand icons (via ProviderIcon, with a grid icon for "All") instead of text tabs that wrapped awkwardly in narrow windows, and the usage hero shows the selected app's brand icon with Codex recolored to a neutral gray matching OpenAI's monochrome branding. The click-to-cycle refresh button becomes a Select with a localized "off" label, and the top-bar controls are compacted and aligned into consistent width groups with truncated long date-range labels.
|
||||
|
||||
### Faster About Panel Loading
|
||||
|
||||
The Settings About panel now loads progressively: the app version badge appears the instant it resolves instead of waiting for tool probes, each tool card updates the moment its own version check finishes (probes run concurrently rather than sequentially), and results are cached for the app session with a 10-minute TTL so reopening the About tab reuses cached values and revalidates stale ones in the background instead of re-probing all six tools every time.
|
||||
|
||||
### Volcengine Ark Coding Plan Promo
|
||||
|
||||
Updated the Volcengine Ark preset across all six apps with the new Coding Plan invite link (replacing the old Agent Plan / activity links) and refreshed the partner promotion copy in all four locales (two-month 75% off plus invite code 6J6FV5N2), correcting the product name from Agent Plan to Coding Plan.
|
||||
|
||||
### MiniMax Demoted to Regular Provider
|
||||
|
||||
Removed the gold partner star badge and the API-key promotion banner for MiniMax by dropping the `isPartner` flag from all its presets; it stays as a regular `cn_official` provider keeping its icon and theme. The promotion copy is kept dormant so the partnership can be re-enabled with a single line.
|
||||
|
||||
### LemonData Removed, SudoCode Demoted
|
||||
|
||||
Removed the LemonData provider preset entirely from all apps along with its promotion copy, icons, and sponsor listings, and demoted SudoCode from a partner to a regular `third_party` provider by dropping its `isPartner` flag and promotion copy (it keeps its icon).
|
||||
|
||||
### AtlasCloud Codex GLM 5.1 Context Window
|
||||
|
||||
Declared the 200,000-token context window for the `zai-org/glm-5.1` model in the AtlasCloud Codex preset, matching the other GLM 5.1 preset entries.
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### Route-Takeover Traffic Billed by the Real Upstream Model
|
||||
|
||||
When a request was routed to a different upstream (env model mapping, Claude Desktop routes, Copilot normalization, Codex chat override), the proxy used to attribute and price usage by whatever model the upstream echoed back, recording kimi/glm tokens as `claude-*` and overstating cost roughly 5–25×. The forwarder now captures the real outbound model, attributes usage by upstream-echo then outbound then client alias, persists the actual pricing basis on every row (schema v11), and keeps that basis through cost backfill and 30-day rollup pruning; Claude Desktop traffic is now logged under its own `app_type` so its pricing overrides apply.
|
||||
|
||||
### Usage Metering on Format-Conversion Proxy Paths
|
||||
|
||||
Audited and fixed token/cache accounting across the proxy's format-conversion paths (Chat, Responses, and Gemini converted to Anthropic). The proxy now records the actually returned model, injects `stream_options.include_usage` so OpenAI-compatible upstreams emit usage in streaming, excludes `cache_read` and `cache_creation` from input on Claude←OpenAI paths to stop double-billing cache tokens, subtracts cached Gemini prompt tokens, still records fully-cached requests, and skips synthetic all-zero usage that previously inflated request counts (#2774).
|
||||
|
||||
### In-App Update No Longer Hangs on Restart
|
||||
|
||||
Installing an update from within the app no longer freezes on the "restarting" screen, leaving the new version installed but requiring a manual force-quit. The download-install-restart chain now runs entirely in the backend (a new `install_update_and_restart` command) with platform-aware install ordering and single-instance-lock teardown before re-exec, instead of depending on the old WebView to keep running JS after the app bundle was already swapped; exit requests are also classified so restart requests fall through to Tauri's default flow rather than deadlocking on the window-state plugin mutex (#4069, #4074).
|
||||
|
||||
### Codex Upgrade No Longer Breaks the Install
|
||||
|
||||
Upgrading Codex from the Settings "About" tab no longer leaves it throwing "Missing optional dependency @openai/codex-…" errors. The upgrade chain previously ran `codex update` first, which on an npm install is a bare reinstall that reports success even when the per-platform binary fails to land; Codex is now removed from the self-update-first path and a runnable check triggers an uninstall+reinstall self-heal (scoped to npm-managed installs) that actually re-lands the missing platform binary.
|
||||
|
||||
### Codex OAuth Auth Token Preserved on Proxy Takeover
|
||||
|
||||
Enabling proxy takeover for a Codex provider no longer strips the `ANTHROPIC_AUTH_TOKEN` placeholder, which previously broke Claude Code's login on hot-switches, fresh installs, and configs already stripped by older releases. The placeholder is now injected unconditionally for managed (non-Copilot) Codex providers, including URL-only ones; GitHub Copilot behavior (API_KEY only) is unchanged (#3789, #3784).
|
||||
|
||||
### Takeover-Residue Recovery Across Config-Dir Switches
|
||||
|
||||
Restarting the app after changing the config directory while proxy takeover is active no longer leaves Claude/Codex/Gemini pointed at a dead local proxy. The old instance now restores the taken-over live files before restarting, the first-run import refuses to persist a takeover placeholder as a provider, and SSOT restore validates that the current provider's config is free of placeholders before writing it back (#4076).
|
||||
|
||||
### Mislabeled SSE Bodies in Format-Transform Fallback
|
||||
|
||||
Requests routed through Claude/Codex format conversion no longer fail with an opaque 422 "Failed to parse upstream response" when a MaaS gateway force-streams a `stream:false` request and returns an SSE body under a non-SSE Content-Type. The proxy now sniffs for SSE on parse failure, aggregates the chunks into a single JSON, and runs the existing converter so clients still get a valid non-stream response; remaining parse failures are enriched with content-type, encoding, and body-snippet diagnostics, and deflate decoding now tries zlib before raw (#2234).
|
||||
|
||||
### Duplicate YAML Keys in Hermes Config
|
||||
|
||||
Hermes config writes no longer accumulate duplicate top-level keys (e.g. `mcp_servers`) that caused "Failed to parse Hermes config as YAML: duplicate entry with key" errors. Section replacement now strips all stale occurrences from the remainder instead of degrading into appends, the dedup safety net handles both LF and CRLF line endings, and healing keeps the last (newest) occurrence to match Hermes's own last-wins PyYAML semantics (#3267, #3633, #2973, #2529, #3310, #3762).
|
||||
|
||||
### Usage Query Resilience and Error Clarity
|
||||
|
||||
Usage cards no longer flip to red on a single transient blip: queries now retry once and keep showing the last successful result for up to 10 minutes on network/timeout/5xx failures, while deterministic failures (auth, empty key, unknown provider, 4xx) surface immediately and clear the snapshot so a stale quota can't resurface after credentials change. Native balance/coding-plan/subscription timeouts were raised from 10s to 15s for slow cross-border endpoints, and coding-plan now returns explicit "API key is empty" / "Unknown coding plan provider" errors instead of a blank failure.
|
||||
|
||||
### Usage Script Provider Credential Resolution
|
||||
|
||||
Custom JS-script usage queries resolved `{{apiKey}}` / `{{baseUrl}}` by guessing env fields only, so providers that store credentials elsewhere (e.g. Codex's `auth.OPENAI_API_KEY` plus `config.toml` base_url) always got empty values and failed despite being fully configured. Script queries and the test/preview now reuse the same per-app credential resolver as the native balance path, with explicit non-empty script values still taking precedence (#1479).
|
||||
|
||||
### Claude Code Workflow Sub-Agent Usage Counted
|
||||
|
||||
Local (no-proxy) session-log usage accounting missed Claude Code Workflow sub-agent traffic, under-counting overall usage by roughly 4.1% (concentrated in workflow/subagent transcripts). The scanner now descends into the deeper `subagents/workflows/wf_*/` transcript directories, and the parser no longer drops billable assistant messages that lack a `stop_reason` but already incurred input/cache token cost; dedup is unchanged so no usage is double-counted.
|
||||
|
||||
### Codex Image Rectifier for /responses Text-Only Upstreams
|
||||
|
||||
Codex `/responses` requests carrying images and routed to text-only OpenAI-chat models (e.g. DeepSeek `deepseek-v4-flash`) no longer fail with HTTP 400 "unknown variant `image_url`". The media rectifier now also covers the Codex adapter, scanning the responses `input` for `input_image` blocks so it can proactively strip images for known text-only models and reactively retry with images replaced on upstream image-unsupported errors.
|
||||
|
||||
### Zhipu Coding-Plan Quota Window Mislabeling
|
||||
|
||||
The Zhipu coding-plan view no longer swaps the 5-hour and weekly quota buckets in the final hours of each weekly cycle. The two windows are now classified by the explicit `unit` field (3 = 5-hour, 6 = weekly) instead of by sorting reset-time ascending, which mislabeled them exactly when users check their weekly quota most; the old reset-time heuristic remains as a fallback (#3036).
|
||||
|
||||
### Duplicate Provider Terminal Sessions on macOS
|
||||
|
||||
Launching a provider terminal on macOS no longer opens an extra empty window alongside the command session; Terminal.app uses `launch` (not `activate`) on cold start and Ghostty uses an initial-command so a single session opens, with a fallback retained if the AppleScript path fails (#4156).
|
||||
|
||||
### Claude Desktop Model-Mapping Placeholders
|
||||
|
||||
The Claude Desktop model-mapping form previously showed mismatched example brands across the menu display name and request model columns (DeepSeek vs Kimi), implying a display name maps to an unrelated model. Both placeholders are now derived from each row's role so they stay brand-consistent, with the lightweight Haiku tier using a flash example.
|
||||
|
||||
### Popovers Behind Fullscreen Panels
|
||||
|
||||
Popovers and tooltips such as the provider preset search no longer render behind fullscreen panels and appear unresponsive on click; their z-index is raised above the fullscreen overlay while staying below modal dialogs.
|
||||
|
||||
### ToggleRow Icon Shrinking
|
||||
|
||||
Toggle row icons no longer shrink or distort when paired with long descriptions, keeping the icon at a fixed size next to multi-line text.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
### Release Notes Contributor Mentions
|
||||
|
||||
Restored contributor mentions in the v3.16.1 and v3.16.2 release notes across all three locales.
|
||||
|
||||
---
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
### Pricing Database schema v11 Auto-Migration
|
||||
|
||||
This release adds a `pricing_model` column to `proxy_request_logs` and rebuilds the rollup by `request_model` + `pricing_model`, migrating automatically on startup with no manual action required. Historical rows have their cost frozen at write time and are not recalculated (rows with `app_type="claude"` mix native and converted sources); only real but previously un-priced takeover rows stay at zero cost until pricing is supplied and then backfilled.
|
||||
|
||||
### Model Mapping Adds a Fourth Tier (Fable 5)
|
||||
|
||||
The Claude Code and Claude Desktop model mappings now have four tiers (Sonnet / Opus / Fable / Haiku). Older three-tier providers pick up the `claude-fable-5` tier after being reopened and saved; leaving that tier blank means it inherits Sonnet. Note: leaving any tier blank on third-party endpoints forwards the literal model name of that tier and may 404, so fill it in as needed.
|
||||
|
||||
### The "Kimi For Coding" Preset Needs Proxy Takeover + a Whitelisted UA
|
||||
|
||||
The restored Codex "Kimi For Coding" preset is still rejected with 403 if used with the default `codex-cli` User-Agent. To use it, enable proxy takeover and set the custom User-Agent in the provider's advanced options to a whitelisted UA (such as `claude-cli/*`).
|
||||
|
||||
### Provider Health Check Semantics Changed
|
||||
|
||||
The health check changed from "send a real model request" to "HTTP reachability probe". Note that reachable ≠ usable: a host that returns 403 is reachable but may be broken for real traffic. Failover decisions remain driven solely by real proxy traffic and are unaffected by the health check.
|
||||
|
||||
---
|
||||
|
||||
## Risk Notice
|
||||
|
||||
This release continues the risk notices from previous versions for reverse-proxy-style features.
|
||||
|
||||
**Codex OAuth reverse proxy**: using a ChatGPT subscription's Codex OAuth through a reverse proxy may violate OpenAI's terms of service. See the [v3.13.0 release notes](v3.13.0-en.md#️-risk-notice) for details.
|
||||
|
||||
**Codex third-party provider Chat routing**: when CC Switch local proxy converts and forwards Codex requests to third-party providers, each provider may have different requirements for billing, compliance, and data retention. Read the target provider's terms before use.
|
||||
|
||||
**Claude Desktop third-party provider proxy switching**: when CC Switch's built-in proxy gateway forwards Claude Desktop requests to third-party providers, you must also follow the target provider's billing, compliance, and data-retention terms.
|
||||
|
||||
By enabling these features, users accept the related risks. CC Switch is not responsible for account restrictions, warnings, or service suspensions caused by using these features.
|
||||
|
||||
---
|
||||
|
||||
## Thanks
|
||||
|
||||
Thanks to the following contributors for the features and fixes in v3.16.3:
|
||||
|
||||
- [#3789](https://github.com/farion1231/cc-switch/pull/3789): preserve Codex OAuth auth token on takeover, thanks @codeasier.
|
||||
- [#2774](https://github.com/farion1231/cc-switch/pull/2774): fix model / input-token recording on Completions→Anthropic, thanks @LaoYueHanNi.
|
||||
- [#4069](https://github.com/farion1231/cc-switch/pull/4069): fix the deadlock on relaunch after an in-app update, thanks @thisTom.
|
||||
- [#4156](https://github.com/farion1231/cc-switch/pull/4156): fix duplicate provider terminal sessions on macOS, thanks @thisTom.
|
||||
- [#3267](https://github.com/farion1231/cc-switch/pull/3267): fix duplicate YAML keys in the Hermes config, thanks @que3sui.
|
||||
- [#1479](https://github.com/farion1231/cc-switch/pull/1479): fix usage script provider credential resolution, thanks @pa001024.
|
||||
- [#3975](https://github.com/farion1231/cc-switch/pull/3975): add preset search and sorting, thanks @Nastem.
|
||||
- [#4183](https://github.com/farion1231/cc-switch/pull/4183): adjust the preset-provider button appearance and search-box position, thanks @WangJiati.
|
||||
- [#4077](https://github.com/farion1231/cc-switch/pull/4077): add claude-mythos-5 model pricing, thanks @osscv.
|
||||
|
||||
Thanks also to everyone who reported usage billing, local proxy robustness, Codex upgrade, and platform compatibility issues after v3.16.2. Many of these fixes came directly from real-world reproduction details.
|
||||
|
||||
---
|
||||
|
||||
## Download & Install
|
||||
|
||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) and download the build for your system.
|
||||
|
||||
### System Requirements
|
||||
|
||||
| System | Minimum Version | Architecture |
|
||||
| ------- | ------------------------ | ----------------------------------- |
|
||||
| Windows | Windows 10 and later | x64 |
|
||||
| macOS | macOS 12 (Monterey)+ | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | See table below | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------------------- | ------------------------------------------------ |
|
||||
| `CC-Switch-v3.16.3-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||
| `CC-Switch-v3.16.3-Windows-Portable.zip` | Portable build, unzip and run |
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| -------------------------------- | ----------------------------------------------------- |
|
||||
| `CC-Switch-v3.16.3-macOS.dmg` | **Recommended** - DMG installer, drag to Applications |
|
||||
| `CC-Switch-v3.16.3-macOS.zip` | Unzip and drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.16.3-macOS.tar.gz` | For Homebrew install and auto-update |
|
||||
|
||||
Homebrew install:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Upgrade:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux assets are available for both **x86_64** and **ARM64** (`aarch64`). Choose the file whose architecture tag matches your machine's `uname -m` output:
|
||||
|
||||
- `CC-Switch-v3.16.3-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.16.3-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| Distribution | Recommended Format | Install Command |
|
||||
| --------------------------------------- | ------------------ | --------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | Make executable and run directly, or use AUR |
|
||||
| Other distributions / unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,339 @@
|
||||
# CC Switch v3.16.3
|
||||
|
||||
> 🎉 **CC Switch が 100,000 Star を突破しました!**
|
||||
> すべてのユーザー・コントリビューター・Star をくださった方々に感謝します —— 皆さんのおかげでここまで来ました。🙏
|
||||
|
||||
> 💎 **本リリースは Claude Fable 5 モデルの協力のもとで開発されました**——重要かつ間違えやすいロジックの整理を手伝ってくれました: ルーティングテイクオーバー時に本物の上流モデルで課金する帰属チェーン、形式変換経路でのキャッシュ token の計上と重複排除、アプリ内更新の再起動デッドロック、そして Codex 統一セッション履歴の移行 / 復元の不変条件です。本リリースの「バージョン情報」ページに **Fable 5 Verified** バッジを新設したのもこのためです。
|
||||
|
||||
> v3.16.2 でデータの可搬性と使用量の可観測性を広げたのに続き、本リリースは「使用量の課金を本当に正確にする」ことに重きを置いています——本物の上流モデルで課金し、形式変換経路でのキャッシュの二重計上を修正し、Claude Code Workflow のサブ agent の使用量を統計に取り込み(schema v11)、使用量ダッシュボードを一通り刷新しました(全体に効くプロバイダー / モデルフィルタ、ブランドアイコンのツールバー、より安定した残量照会)。あわせて一連のローカルプロキシとプラットフォームの問題を補強し、カスタム User-Agent オーバーライド、Codex 統一セッション履歴のトグル、Claude Fable 5 階層を新設しました。
|
||||
|
||||
**[English →](v3.16.3-en.md) | [中文版 →](v3.16.3-zh.md)**
|
||||
|
||||
---
|
||||
|
||||
## 利用ガイド
|
||||
|
||||
本リリースでは使用量統計の数え方とダッシュボードに多くの調整を加えたため、まず以下をご覧ください:
|
||||
|
||||
- **[使用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**: 使用量ダッシュボードのデータソース(プロキシログ、セッション同期)と集計の仕組みを確認できます。本リリースで全体に効くプロバイダー / モデルフィルタを追加し、ルーティングテイクオーバー時の本物の課金モデルを表示するようにしました。
|
||||
- **[設定](../user-manual/ja/1-getting-started/1.5-settings.md)**: カスタム User-Agent オーバーライド、Codex 統一セッション履歴などのトグルは、プロバイダーフォームの高度なオプションと設定ページにあります。
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## 唯一の公式チャネル(必ずお読みください)
|
||||
>
|
||||
> CC Switch は**完全に無料・オープンソース**のデスクトップアプリで、**ユーザーから料金を徴収することはありません**。本ソフトウェアは下記の公式チャネルからのみ入手してください:
|
||||
>
|
||||
> | チャネル | 唯一の公式 |
|
||||
> | ------------ | ------------------------------------------------------------------------------ |
|
||||
> | 公式サイト | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | ソースコード | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | ダウンロード | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | 偽サイト通報 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **料金請求・チャージ・認証情報の提供を求める「CC Switch」サイトやクライアントはすべて偽物です。** 支払いを誘導された場合は直ちに操作を中止し、GitHub Issues からご報告ください。
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.16.3 は v3.16.2 に続くメンテナンスアップデートです。前リリースではデータの可搬性と使用量の可観測性の拡張に集中しましたが、本リリースは「使用量の課金を本当に正確にする」ことに重きを置いています——上流が返すエイリアスではなく本物の上流モデルで課金し、形式変換(Chat / Responses / Gemini を Anthropic へ)経路でのキャッシュ token の二重計上を修正し、Claude Code Workflow のサブ agent の使用量をローカル統計に取り込み、schema v11 で各レコードが実際に使用した課金根拠を永続化しました。使用量ダッシュボードもこれにあわせて一通り刷新し、全体に効くプロバイダー / モデルフィルタ、ブランドアイコンのツールバー、より安定した残量照会(失敗時の再試行 + 前回成功した結果の保持)を追加しました。
|
||||
|
||||
さらに本リリースでは、一連のローカルプロキシの堅牢性に関する問題(Content-Type が誤ってラベル付けされた SSE レスポンスの集約、Codex `/responses` のテキスト専用モデル向け画像整流、Codex OAuth 認証情報とテイクオーバー残留の復元、Hermes 設定の重複 YAML キー)を補強し、プロバイダー設定まわりを作り直し(カスタム User-Agent オーバーライド、Codex フォームの高度なオプションへの統合、プリセット検索とソート、Claude Fable 5 階層)、Codex 統一セッション履歴のトグルを新設し、アプリ内更新のハング、Codex のアップグレードによるインストール破損、macOS の重複ターミナルウィンドウなどの問題を修正しました。
|
||||
|
||||
**リリース日**: 2026-06-14
|
||||
|
||||
**Stats**: 59 commits | 130 files changed | +10,223 / -4,232 lines
|
||||
|
||||
---
|
||||
|
||||
## ハイライト
|
||||
|
||||
- **使用量の課金がより正確に**: ルーティングテイクオーバーのトラフィックを本物の上流モデルで課金するようになり(上流が返すエイリアスではなく)、形式変換経路でキャッシュ token を input に二重計上しなくなり、Claude Code Workflow のサブ agent の使用量も統計に取り込みました——schema v11 で課金根拠を永続化します。
|
||||
- **使用量ダッシュボードの刷新**: プロバイダー / モデルフィルタをリクエストログテーブルから全体フィルタへ引き上げ、アプリフィルタをブランドアイコンに変更し、残量照会に失敗時の再試行と「前回成功した結果の保持」を追加して、一度のネットワークのゆらぎでカードが赤くならないようにしました。
|
||||
- **カスタム User-Agent オーバーライド**: プロバイダーにカスタム UA を設定でき、転送・接続性チェック・モデル一覧の 3 か所で一貫して有効になり、UA ホワイトリストで制限する Coding Plan 上流を通過できます(これにより Codex「Kimi For Coding」プリセットを復活させました)。
|
||||
- **Codex 統一セッション履歴**: 公式 Codex セッションとサードパーティセッションが同じ resume 履歴バケットを共有できる任意のトグルを新設し、既存セッションの任意移行と台帳に基づく精密な復元を備えます。
|
||||
- **プロキシとプラットフォームの補強**: 誤ラベルの SSE レスポンスの集約、Codex 画像整流、テイクオーバー残留の復元、Hermes YAML 重複排除。アプリ内更新が「再起動中」でハングしなくなり、Codex のアップグレードでインストールを壊さなくなりました。
|
||||
|
||||
---
|
||||
|
||||
## 追加機能
|
||||
|
||||
### カスタム User-Agent オーバーライド
|
||||
|
||||
プロバイダー設定でカスタム User-Agent を設定できるようになり、プロキシがリクエスト転送、接続性チェック、モデル一覧(`GET /v1/models`)の 3 つの経路で一貫して適用します。これにより、UA ホワイトリストで制限する Coding Plan 上流で「検出は失敗 / モデル一覧は 403 なのにプロキシ本体は正常に動く」という不整合が起きなくなります。Claude と Codex のフォームはいずれも高度なオプションでこのフィールドを公開し、厳選した UA プリセットのドロップダウン(Claude Code / Kilo Code など UA ホワイトリストを通過できるファミリー)とリアルタイムで非ブロッキングな形式検証を備えます。公式プリセットへ切り替えると残っていたカスタム UA は破棄され、リクエストヘッダーを密かに変更しないようにします([#3671](https://github.com/farion1231/cc-switch/pull/3671))。
|
||||
|
||||
### Codex 統一セッション履歴
|
||||
|
||||
任意のトグル(設定 → Codex アプリ拡張)を新設し、公式 Codex セッションと CC Switch のサードパーティセッションが同じ resume 履歴バケットを共有できるようにしました。resume セレクタが両者を互いに隠さなくなります。有効化すると、live の `config.toml` は公式の実行を、内蔵 OpenAI プロバイダーをミラーした共有 `custom` model_provider へルーティングします(`auth.json` は変更しません)。デフォルトでは今後のセッションにのみ有効です。有効化ダイアログには既存の公式セッションを共有バケットへ移行できるチェックボックスがあり(世代ごとのバックアップ付き)、無効化ダイアログにはバックアップ台帳に基づく精密な復元が用意されています——バックアップ内で `openai` として記録されたセッションのみを巻き戻し、有効化中に新規作成されたセッションは決して変更しません。
|
||||
|
||||
### 使用量ダッシュボードの全体プロバイダー / モデルフィルタ
|
||||
|
||||
プロバイダーフィルタとモデルフィルタを、リクエストログテーブルの内部からトップバーへ引き上げ、Hero サマリー、トレンドグラフ、リクエストログ、2 つの統計タブ全体に効くようにしました。ダッシュボード全体を特定のソースとモデルで絞り込めます。ソースは表示名で厳密に一致するため「Claude (Session)」のようなセッションのプレースホルダー行も選択でき、モデルは有効な課金モデルで一致し、モデルのドロップダウンは選択したソースに応じてカスケードし、どちらの一覧も現在の期間にデータがある選択肢のみを表示します。
|
||||
|
||||
### モデル価格シードの刷新
|
||||
|
||||
`seed_model_pricing` の全件価格点検を実施しました: 9 個のモデルの価格を新規追加し(Claude Fable 5、Grok 4.3、Mistral Medium 3.5 / Small 4、Qwen 3.7 Max/Plus などを含む)、各ベンダー公式の定価に合わせて既存価格 28 か所を訂正し(GLM、Grok、MiMo、Doubao、Kimi、MiniMax、Mistral、Qwen)、使用量コストの見積りをより正確にしました。各変更はシード(新規インストールに影響)を更新すると同時に、`repair_current_model_pricing` に旧→新のガードを 1 件追加します(既存データベースを修復し、ユーザーが手動で編集した行は上書きしません)。
|
||||
|
||||
### Claude Fable 5 モデル階層
|
||||
|
||||
プロバイダーフォームは、Claude Code と Claude Desktop の両プロキシ経路で `claude-fable-5` を 4 つ目のモデルマッピング階層として公開するようになりました。フォールバックチェーンは fable → opus → default で、公式の降格と一致し、Claude Desktop 1.12603.1+ の検証器で `fable-` プレフィックスを許可しました。4 言語のフォールバックヒントも明確化しました: サードパーティの endpoint である階層を空のままにすると、その階層のモデル名がそのまま透過されて 404 になります([#3980](https://github.com/farion1231/cc-switch/issues/3980)、[#4026](https://github.com/farion1231/cc-switch/issues/4026)、[#4049](https://github.com/farion1231/cc-switch/issues/4049))。
|
||||
|
||||
### Unity2.ai パートナープロバイダー
|
||||
|
||||
Unity2.ai(AI API 中継のパートナー)をプリセットとして追加し、管理対象の 7 アプリすべて(Claude Code、Codex、Gemini、OpenCode、OpenClaw、Claude Desktop、Hermes)をカバーしました。各プリセットには紹介登録リンクを付け、4 言語でパートナー宣伝文を補いました。Codex は素の base URL を使用し(このゲートウェイはルートパスに `/responses` を公開)、OpenCode / OpenClaw / Hermes は `/v1` chat-completions の endpoint を使用し、`gpt-5.5` を既定モデルとします。
|
||||
|
||||
### Kimi K2.7 Code モデル
|
||||
|
||||
`kimi-k2.7-code` モデル(入力 $0.95 / 出力 $4.00 / キャッシュ読み取り $0.19、100 万 token あたり、256K コンテキスト)を新規追加し、6 つの公式 Moonshot Kimi プリセットすべて(Claude Code、Codex、Claude Desktop、Hermes、OpenCode、OpenClaw)をこれに向けました。OpenCode / OpenClaw のプリセットは「Kimi K2.7 Code」へ改名しました。価格シードは起動時の冪等な挿入経路で有効になるため、既存ユーザーは移行なしで新価格を取得できます。
|
||||
|
||||
### Codex「Kimi For Coding」プリセットの復活
|
||||
|
||||
Codex「Kimi For Coding」プリセット(`openai_chat`、`kimi-for-coding`、256K コンテキスト)を再追加し、思考モードをデフォルトで有効にしました。以前これを削除したのは、このコーディング endpoint が Codex 既定の `codex-cli` User-Agent を 403 で拒否するためでしたが、現在はプロキシテイクオーバー + カスタム User-Agent オーバーライド(ホワイトリストの UA、例 `claude-cli/*` に設定)を使えば正常に使えます。
|
||||
|
||||
### リクエスト詳細での課金モデル監査
|
||||
|
||||
リクエスト詳細パネルは、「リクエストされたモデル」「課金モデル」がレスポンスのモデルと一致しないときにそれらをすべて表示するようになり、ルーティングテイクオーバーが生む請求を使用量画面から直接照合できます。
|
||||
|
||||
### プリセットプロバイダーの検索とソート
|
||||
|
||||
プリセットプロバイダーのセレクタが、検索・ソート可能な一覧になり、インライン検索ボックスを備えました(虫眼鏡アイコンで切り替え、ESC または外側クリックで畳む)。ボタンはレスポンシブグリッドに変わってサイズが統一され、既定アイコンを表示します。検索はプロバイダーの表示名 / 生の名前のみに一致するため、URL の断片や共有のカテゴリラベルがノイズ一致を生まなくなります([#3975](https://github.com/farion1231/cc-switch/pull/3975)、[#4183](https://github.com/farion1231/cc-switch/pull/4183))。
|
||||
|
||||
### Claude Mythos 5 の価格
|
||||
|
||||
内蔵のモデル / 価格表に `claude-mythos-5` モデル(入力 $10 / 出力 $50、100 万 token あたり;キャッシュ読み取り $1.00、キャッシュ書き込み $12.50)を登録し、使用量統計が正しく課金・表示できるようにしました([#4077](https://github.com/farion1231/cc-switch/pull/4077))。
|
||||
|
||||
### Fable 5 Verified バッジ
|
||||
|
||||
設定の「バージョン情報」ページが、アプリ名とバージョンの隣に Fable 5 Verified バッジを表示し、これが特別ビルドであることを示すようになりました。バージョンバッジもアプリ名の下に中央揃えしました。
|
||||
|
||||
---
|
||||
|
||||
## 変更
|
||||
|
||||
### Claude Desktop の使用量を Claude に折りたたみ
|
||||
|
||||
ダッシュボードは独立した「Claude Desktop」バケットを表示しなくなりました——これは常に不完全な数字しか表示できませんでした(Desktop のチャット使用量はそもそもプロキシを経由せず、その Code タブのセッションは内蔵の Claude Code ランタイムが共有の `~/.claude/projects` ディレクトリへ書き込んでいるだけです)。Desktop のプロキシトラフィックは表示上 `claude` に折りたたまれますが、記帳層はルーティングテイクオーバー課金の監査のために引き続き自身の `app_type` で記録し、本物の値はリクエスト詳細パネルで確認できます。
|
||||
|
||||
### 軽量化したプロバイダーヘルスチェック
|
||||
|
||||
プロバイダーヘルスチェックは、本物のストリーミングモデルリクエストを送らなくなりました(多くのサードパーティプロバイダーが 401/403/WAF でブロックし、利用不可の誤検知を生むため)。代わりにプロバイダーの `base_url` へ軽量な HTTP 到達性プローブを 1 回行います: あらゆる HTTP レスポンスを到達可能とみなし、DNS / 接続 / TLS / タイムアウトのみを失敗とします。公式プロバイダー(OAuth を使用し、base_url が意図的に空で、信頼できる到達性ターゲットがない)は接続性チェックのボタンを隠します。従来の「本物のリクエストを送る」確認ダイアログ、テストモデル / プロンプトのフィールドは削除し、劣化レイテンシのしきい値を 6s、タイムアウトを 8s としました。この到達性チェックは決してサーキットブレーカーをリセットしません——到達可能 ≠ 利用可能(403 を返す host は到達可能でも、本物のトラフィックには壊れています)。フェイルオーバーの判定は引き続き本物のプロキシトラフィックのみで駆動されます。
|
||||
|
||||
### Codex の高度なオプション領域の統合
|
||||
|
||||
Codex プロバイダーフォームは、ローカルルーティング、モデルマッピング、推論オーバーライド、カスタム User-Agent を展開可能な高度なオプション領域に折りたたみ、Claude フォームと揃えました(UA が設定されているか、ローカルルーティングが有効なときは自動展開)。カスタム User-Agent はネイティブ Responses プロバイダーでも設定できるようになりました。以前は `openai_chat` ルーティングを有効にしたときにしか触れられませんでした。
|
||||
|
||||
### 使用量ツールバーとレイアウトの刷新
|
||||
|
||||
アプリフィルタはブランドアイコン(ProviderIcon 経由、「すべて」はグリッドアイコン)で描画するように変更し、狭いウィンドウで折り返すと見栄えの悪かったテキストタブを置き換えました。使用量 Hero も選択したアプリのブランドアイコンを表示し、Codex のテーマ色をエメラルドからニュートラルグレーへ変更して、OpenAI のモノクロブランドに合わせました。クリックで循環切り替えしていた更新ボタンは、ローカライズした「オフ」ラベルを持つドロップダウン選択に変更し、トップバーのコントロールも圧縮して幅のグループを揃え、長すぎる日付範囲のラベルは省略表示するようにしました。
|
||||
|
||||
### バージョン情報パネルの読み込みを高速化
|
||||
|
||||
設定の「バージョン情報」パネルが段階的に読み込むようになりました: アプリのバージョンバッジは解決した瞬間に表示され、ツールのプローブを待たなくなります。各ツールカードは自身のバージョン検出が完了した時点で即座に更新されます(プローブは直列ではなく並行に実行)。プローブ結果はアプリのセッション中、10 分の TTL 付きでキャッシュされるため、「バージョン情報」タブを再度開くとキャッシュ値を再利用し、期限切れの項目だけをバックグラウンドで再検証します。毎回 6 つのツールすべてを再プローブすることはなくなりました。
|
||||
|
||||
### 火山方舟 Coding Plan の宣伝更新
|
||||
|
||||
火山方舟(Volcengine Ark)プリセットを 6 アプリすべてで新しい Coding Plan 招待リンクへ更新し(旧 Agent Plan / キャンペーンリンクを置き換え)、4 言語でパートナー宣伝文を刷新しました(2 か月 75% 割引 + 招待コード 6J6FV5N2)。製品名も Agent Plan から Coding Plan へ訂正しました。
|
||||
|
||||
### MiniMax を通常プロバイダーへ降格
|
||||
|
||||
MiniMax の金色のパートナースター印と API key 宣伝バナーを削除し(全プリセットから `isPartner` フラグを除去)、引き続き通常の `cn_official` プロバイダーとしてアイコンとテーマを保持します。宣伝文は休眠状態のまま残し、必要であれば 1 行で提携関係を再有効化できます。
|
||||
|
||||
### LemonData を削除、SudoCode を降格
|
||||
|
||||
LemonData プロバイダープリセットを完全に削除し(宣伝文、アイコン、スポンサー項目もあわせて)、SudoCode をパートナーから通常の `third_party` プロバイダーへ降格しました(`isPartner` フラグと宣伝文を外し、アイコンは保持)。
|
||||
|
||||
### AtlasCloud Codex GLM 5.1 のコンテキストウィンドウ
|
||||
|
||||
AtlasCloud Codex プリセットの `zai-org/glm-5.1` モデルに 200,000 token のコンテキストウィンドウを宣言し、ほかの GLM 5.1 プリセット項目に揃えました。
|
||||
|
||||
---
|
||||
|
||||
## 修正
|
||||
|
||||
### ルーティングテイクオーバーのトラフィックを本物の上流モデルで課金
|
||||
|
||||
リクエストが別の上流へルーティングされた場合(env モデルマッピング、Claude Desktop ルーティング、Copilot 正規化、Codex chat オーバーライド)、プロキシは以前、上流が返したモデルで帰属・課金していたため、kimi / glm の token を `claude-*` として記録・課金し、コストが約 5〜25 倍に過大評価されていました。現在は転送器が本物のアウトバウンドモデルを捕捉し、「上流が返した値 → アウトバウンドモデル → クライアントのエイリアス」の順で帰属し、各行に実際に使用した課金根拠を永続化します(schema v11)。この根拠はコストのバックフィルと 30 日 rollup のプルーニングまで一貫して使われます。Claude Desktop のトラフィックも自身の `app_type` で記録されるようになり、その価格オーバーライドが正しく効くようになりました。
|
||||
|
||||
### 形式変換経路での使用量計上
|
||||
|
||||
プロキシの各形式変換経路(Chat、Responses、Gemini を Anthropic へ)での token / キャッシュの計上を監査・修正しました。プロキシは実際に返ったモデルを記録し、`stream_options.include_usage` を注入して OpenAI 互換の上流がストリーミング時に usage を吐くようにし、Claude←OpenAI 経路では `cache_read` と `cache_creation` を input から除外してキャッシュ token の二重計上を防ぎ、Gemini のキャッシュ済みプロンプト token を差し引き、完全にキャッシュヒットしたリクエストも引き続き記録し、過去にリクエスト数を水増ししていた合成の全ゼロ usage をスキップするようになりました([#2774](https://github.com/farion1231/cc-switch/pull/2774))。
|
||||
|
||||
### アプリ内更新がハングしなくなった
|
||||
|
||||
アプリ内から更新をインストールするとき、「再起動中」の画面でハングしなくなりました——以前は新版がインストール済みなのに、手動で強制終了せざるを得ない状況が起きていました。ダウンロード—インストール—再起動の一連の流れは、完全にバックエンドで実行するようになり(`install_update_and_restart` コマンドを新設)、プラットフォームごとにインストール順序を決め、再実行の前にまず単一インスタンスロックを破棄します。アプリパッケージがすでに置き換えられた後に古い WebView が JS を走らせ続けることに依存しなくなりました。終了リクエストも分類して、再起動リクエストが Tauri の既定フローに落ちるようにし、ウィンドウ状態プラグインのミューテックスでデッドロックしないようにしました([#4069](https://github.com/farion1231/cc-switch/pull/4069)、[#4074](https://github.com/farion1231/cc-switch/pull/4074))。
|
||||
|
||||
### Codex のアップグレードがインストールを壊さなくなった
|
||||
|
||||
設定の「バージョン情報」ページから Codex をアップグレードしても、「Missing optional dependency @openai/codex-…」エラーを投げなくなりました。アップグレードのチェーンは以前まず `codex update` を実行しますが、これは npm インストール下では実質的に素の再インストールであり、対応するプラットフォームのバイナリがインストールされていなくても成功を報告していました。現在は Codex を「self-update 優先」経路から除外し、runnable 検出が「アンインストール + 再インストール」の自己修復を駆動するようにしました(npm 管理のインストールに限定)。これが、欠けたプラットフォームバイナリを本当に補える唯一の修正です。
|
||||
|
||||
### テイクオーバー時に Codex OAuth 認証情報を保持
|
||||
|
||||
Codex プロバイダーでプロキシテイクオーバーを有効にするとき、`ANTHROPIC_AUTH_TOKEN` プレースホルダーを剥がさなくなりました——以前これはホットスイッチ、新規インストール、そして旧バージョンがすでに剥がしてしまった live 設定で、Claude Code のログインを壊していました。現在は管理対象(非 Copilot)の Codex プロバイダーに対して、URL のみのプロバイダーを含め無条件にこのプレースホルダーを注入します。GitHub Copilot の挙動(API_KEY のみ)は変わりません([#3789](https://github.com/farion1231/cc-switch/pull/3789)、[#3784](https://github.com/farion1231/cc-switch/issues/3784))。
|
||||
|
||||
### 設定ディレクトリをまたぐ切り替えでのテイクオーバー残留の復元
|
||||
|
||||
プロキシテイクオーバーが有効なときに設定ディレクトリを変更してアプリを再起動しても、Claude / Codex / Gemini を失効したローカルプロキシに向けたままにしなくなりました。現在は旧インスタンスが再起動の前にテイクオーバーされた live ファイルを先に復元し、初回実行のインポートはテイクオーバープレースホルダーをプロバイダーとして永続化することを拒否し、SSOT の復元も書き戻す前に、現在のプロバイダーの設定にプレースホルダーが含まれないことを検証します([#4076](https://github.com/farion1231/cc-switch/pull/4076))。
|
||||
|
||||
### 形式変換フォールバックでの誤ラベル SSE レスポンスの集約
|
||||
|
||||
Claude / Codex の形式変換を経たリクエストで、MaaS ゲートウェイが `stream:false` のリクエストを強制的にストリーミングし、非 SSE の Content-Type で SSE レスポンスボディを返したとき、難解な 422「Failed to parse upstream response」で失敗しなくなりました。プロキシは解析失敗時に SSE かどうかを検出し、チャンクを単一の JSON に集約してから既存の変換器を走らせ、クライアントが引き続き有効な非ストリーミングレスポンスを受け取れるようにします。残った解析失敗には content-type、エンコーディング、レスポンスボディの抜粋などの診断情報を付け、deflate のデコードも先に zlib を、次に素のストリームを試すように変更しました([#2234](https://github.com/farion1231/cc-switch/pull/2234))。
|
||||
|
||||
### Hermes 設定の重複 YAML キー
|
||||
|
||||
Hermes 設定の書き込みは、重複したトップレベルキー(`mcp_servers` など)を累積しなくなりました。これは「Failed to parse Hermes config as YAML: duplicate entry with key」エラーを引き起こしていました。セクションの置換は、追加へ退化する代わりに、残りのテキストから古いコピーをすべて取り除くようになりました。重複排除のセーフティネットは LF と CRLF の行末を両方処理します。修復時には最後(最新)のコピーを保持し、Hermes 自身の PyYAML ベースの「後勝ち」セマンティクスに揃えます([#3267](https://github.com/farion1231/cc-switch/pull/3267)、[#3633](https://github.com/farion1231/cc-switch/issues/3633)、[#2973](https://github.com/farion1231/cc-switch/issues/2973)、[#2529](https://github.com/farion1231/cc-switch/issues/2529)、[#3310](https://github.com/farion1231/cc-switch/issues/3310)、[#3762](https://github.com/farion1231/cc-switch/issues/3762))。
|
||||
|
||||
### 使用量照会の堅牢性とエラーの明確さ
|
||||
|
||||
使用量カードは、一度の瞬間的なゆらぎだけで赤くならなくなりました: 照会は一度再試行し、ネットワーク / タイムアウト / 5xx のような一時的な失敗下では前回成功した結果を最大 10 分間表示し続けます。一方、確定的な失敗(認証、空の key、未知のプロバイダー、4xx)は即座に表面化してスナップショットをクリアし、認証情報の変更後に古い残量が再び現れるのを防ぎます。ネイティブ残量 / Coding Plan / サブスクリプション照会のタイムアウトは、応答の遅い国際 endpoint に合わせて 10s から 15s へ引き上げました。Coding Plan も空白の失敗ではなく、明確な「API key is empty」/「Unknown coding plan provider」エラーを返すようになりました。
|
||||
|
||||
### 使用量スクリプトのプロバイダー認証情報の解決
|
||||
|
||||
カスタム JS スクリプトの使用量照会は、以前 env フィールドを推測して `{{apiKey}}` / `{{baseUrl}}` を解決していたため、認証情報を別の場所に置くアプリ(Codex の `auth.OPENAI_API_KEY` に加え `config.toml` の base_url など)は、プロバイダーが完全に設定されていても常に空の値となり失敗していました。スクリプト照会とそのテスト / プレビューは、ネイティブ残量経路と同じアプリごとの認証情報リゾルバを再利用するようになり、スクリプト内で明示的に記入した非空の値は引き続き優先されます([#1479](https://github.com/farion1231/cc-switch/pull/1479))。
|
||||
|
||||
### Claude Code Workflow サブ agent の使用量集計
|
||||
|
||||
ローカル(プロキシなし)のセッションログ使用量集計は、以前 Claude Code Workflow のサブ agent のトラフィックを取りこぼしており、全体の使用量が約 4.1% 過小評価されていました(workflow / subagent のセッションレコードに集中)。スキャナーはさらに一段深い `subagents/workflows/wf_*/` のレコードディレクトリまで掘り下げるようになり、パーサーも `stop_reason` を欠いているがすでに input / キャッシュ token のコストを生んだ assistant メッセージを捨てなくなりました。重複排除のロジックは変わらないため、重複計上はしません。
|
||||
|
||||
### Codex `/responses` のテキスト専用モデル向け画像整流
|
||||
|
||||
画像を伴い、テキストのみ対応の OpenAI-chat モデル(DeepSeek `deepseek-v4-flash` など)へルーティングされた Codex `/responses` リクエストが、HTTP 400「unknown variant `image_url`」で失敗しなくなりました。メディア整流器が Codex アダプターもカバーするようになり、responses の `input` 内の `input_image` ブロックをスキャンします。これにより、既知のテキスト専用モデルに対しては画像をプロアクティブに剥がし、上流が「画像非対応」を報告したときにも画像を置き換えて再試行できます。
|
||||
|
||||
### 智譜 Coding Plan のクォータウィンドウの誤ラベル
|
||||
|
||||
智譜 Coding Plan ビューは、各週周期の最後の数時間で 5 時間ウィンドウと週ウィンドウを取り違えてラベル付けしなくなりました。両ウィンドウは今や明示的な `unit` フィールドで分類され(3 = 5 時間、6 = 週)、リセット時刻の昇順ソートに頼らなくなりました——後者はユーザーが週クォータを最も照会するタイミングで、ちょうど両者を取り違えてラベル付けしていました。フィールドが欠けているときは引き続き従来のリセット時刻ヒューリスティックへフォールバックします([#3036](https://github.com/farion1231/cc-switch/pull/3036))。
|
||||
|
||||
### macOS の重複プロバイダーターミナルウィンドウ
|
||||
|
||||
macOS でプロバイダーターミナルを起動するとき、コマンドセッションの隣に空のウィンドウをもう 1 つ開かなくなりました。Terminal.app はコールドスタート時に `activate` ではなく `launch` を使い、Ghostty は初期コマンドを使うことで、単一のセッションだけを開きます。AppleScript 経路が失敗したときのフォールバックも残してあります([#4156](https://github.com/farion1231/cc-switch/pull/4156))。
|
||||
|
||||
### Claude Desktop モデルマッピングのプレースホルダー
|
||||
|
||||
Claude Desktop モデルマッピングフォームは、以前「メニュー表示名」と「リクエストモデル」の 2 列で一貫しないブランド例(DeepSeek vs Kimi)を使い、ある表示名が無関係なモデルへマッピングされるかのように見せていました。現在は両方のプレースホルダーが各行のロールから導かれ、ブランドの一貫性を保ち、軽量な Haiku 階層は flash の例を使います。
|
||||
|
||||
### ポップオーバーが全画面パネルに隠れる
|
||||
|
||||
プロバイダープリセット検索のようなポップオーバーやツールチップが、全画面パネルの後ろに描画されてクリックが効かないように見える問題がなくなりました。これらの z-index を全画面オーバーレイの上に引き上げつつ、モーダルダイアログよりは低く保ちました。
|
||||
|
||||
### ToggleRow アイコンの縮み
|
||||
|
||||
トグル行のアイコンは、長い説明と並んでも縮んだり歪んだりしなくなり、複数行のテキストの隣でアイコンが固定サイズを保ちます。
|
||||
|
||||
---
|
||||
|
||||
## ドキュメント
|
||||
|
||||
### Release Notes の貢献者謝辞の復元
|
||||
|
||||
v3.16.1 と v3.16.2 の release notes の貢献者謝辞を 3 言語で復元しました。
|
||||
|
||||
---
|
||||
|
||||
## アップグレード時の注意
|
||||
|
||||
### 価格ライブラリ schema v11 の自動移行
|
||||
|
||||
本リリースは `proxy_request_logs` に `pricing_model` 列を新設し、`request_model` + `pricing_model` で rollup を再構築しました。起動時に自動移行し、手動操作は不要です。過去の行のコストは書き込み時に確定済みで再計算されません(`app_type="claude"` の行はネイティブと変換の 2 種類のソースが混在しています)。本物だが当時課金されなかったテイクオーバー行のみがゼロコストのまま残り、価格が補われた後にバックフィルされます。
|
||||
|
||||
### モデルマッピングに 4 つ目の階層(Fable 5)を追加
|
||||
|
||||
Claude Code と Claude Desktop のモデルマッピングは 4 階層(Sonnet / Opus / Fable / Haiku)になりました。従来の 3 階層プロバイダーは、再度開いて保存すると `claude-fable-5` 階層が補われます。この階層を空のままにすると Sonnet を継承します。注意: サードパーティの endpoint でいずれかの階層を空のままにすると、その階層のモデル名がそのまま透過されて 404 になる可能性があるため、必要に応じて記入してください。
|
||||
|
||||
### 「Kimi For Coding」プリセットはプロキシテイクオーバー + ホワイトリスト UA が必要
|
||||
|
||||
復活した Codex「Kimi For Coding」プリセットは、既定の `codex-cli` User-Agent をそのまま使うと依然 403 になります。利用するには、プロキシテイクオーバーを有効にし、プロバイダーの高度なオプションでカスタム User-Agent をホワイトリストの UA(`claude-cli/*` など)に設定してください。
|
||||
|
||||
### プロバイダーヘルスチェックのセマンティクス変更
|
||||
|
||||
ヘルスチェックは「本物のモデルリクエストを送る」から「HTTP 到達性プローブ」へ変わりました。到達可能 ≠ 利用可能であることにご注意ください: 403 を返す host は到達可能でも、本物のトラフィックには壊れている可能性があります。フェイルオーバーの判定は引き続き本物のプロキシトラフィックのみで駆動され、ヘルスチェックの影響を受けません。
|
||||
|
||||
---
|
||||
|
||||
## リスク通知
|
||||
|
||||
本リリースは、リバースプロキシ系機能に関する以前のリスク通知を引き続き適用します。
|
||||
|
||||
**Codex OAuth リバースプロキシ**: ChatGPT サブスクリプションの Codex OAuth をリバースプロキシ経由で使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 release notes](v3.13.0-ja.md#️-リスクに関する注意事項) を参照してください。
|
||||
|
||||
**Codex サードパーティプロバイダー Chat ルーティング**: CC Switch ローカルプロキシで Codex リクエストを変換し、サードパーティプロバイダーへ転送する場合、課金・コンプライアンス・データ保持に関する制約はプロバイダーごとに異なります。利用前に対象プロバイダーの利用規約を確認してください。
|
||||
|
||||
**Claude Desktop サードパーティプロバイダープロキシ切り替え**: CC Switch 内蔵のプロキシゲートウェイで Claude Desktop のリクエストをサードパーティプロバイダーへ転送する場合も、対象プロバイダーの課金・コンプライアンス・データ保持に関する規約に従う必要があります。
|
||||
|
||||
上記機能を有効化したユーザーは、関連するリスクを自ら負うものとします。CC Switch は、これらの機能の利用によって発生したアカウント制限、警告、サービス停止について責任を負いません。
|
||||
|
||||
---
|
||||
|
||||
## 謝辞
|
||||
|
||||
v3.16.3 で機能と修正を届けてくださった以下のコントリビューターに感謝します:
|
||||
|
||||
- [#3789](https://github.com/farion1231/cc-switch/pull/3789): テイクオーバー時に Codex OAuth 認証情報を保持、@codeasier に感謝。
|
||||
- [#2774](https://github.com/farion1231/cc-switch/pull/2774): Completions を Anthropic へ変換する際に実際に返ったモデルを記録せず input token の計算が誤っていた問題を修正、@LaoYueHanNi に感謝。
|
||||
- [#4069](https://github.com/farion1231/cc-switch/pull/4069): アプリ内更新後の再起動デッドロックを修正、@thisTom に感謝。
|
||||
- [#4156](https://github.com/farion1231/cc-switch/pull/4156): macOS の重複プロバイダーターミナルウィンドウを修正、@thisTom に感謝。
|
||||
- [#3267](https://github.com/farion1231/cc-switch/pull/3267): Hermes 設定の重複 YAML キーを修正、@que3sui に感謝。
|
||||
- [#1479](https://github.com/farion1231/cc-switch/pull/1479): 使用量スクリプトのプロバイダー認証情報の解決を修正、@pa001024 に感謝。
|
||||
- [#3975](https://github.com/farion1231/cc-switch/pull/3975): プリセットプロバイダーの検索とソートを追加、@Nastem に感謝。
|
||||
- [#4183](https://github.com/farion1231/cc-switch/pull/4183): プリセットプロバイダーボタンの外観と検索ボックスの位置を調整、@WangJiati に感謝。
|
||||
- [#4077](https://github.com/farion1231/cc-switch/pull/4077): claude-mythos-5 モデルの価格を追加、@osscv に感謝。
|
||||
|
||||
v3.16.2 リリース後に使用量の課金、ローカルプロキシの堅牢性、Codex のアップグレード、プラットフォーム互換性の問題を報告してくださったすべてのユーザーにも感謝します。今回の多くの修正は、実際の利用シーンから得られた再現情報に基づいています。
|
||||
|
||||
---
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から、お使いのシステムに対応するビルドをダウンロードしてください。
|
||||
|
||||
### システム要件
|
||||
|
||||
| システム | 最低バージョン | アーキテクチャ |
|
||||
| -------- | ------------------------ | ----------------------------------- |
|
||||
| Windows | Windows 10 以降 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 下表を参照 | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ---------------------------------------- | -------------------------------------------- |
|
||||
| `CC-Switch-v3.16.3-Windows.msi` | **推奨** - 自動更新対応の MSI インストーラー |
|
||||
| `CC-Switch-v3.16.3-Windows-Portable.zip` | ポータブル版、展開してそのまま実行できます |
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| -------------------------------- | ------------------------------------------------------ |
|
||||
| `CC-Switch-v3.16.3-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ |
|
||||
| `CC-Switch-v3.16.3-macOS.zip` | 展開して Applications へドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.16.3-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||
|
||||
Homebrew インストール:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux アセットは **x86_64** と **ARM64**(`aarch64`)の両方を提供します。ファイル名にアーキテクチャ識別子が含まれているため、マシンの `uname -m` 出力に合わせて選択してください:
|
||||
|
||||
- `CC-Switch-v3.16.3-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.16.3-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||
| --------------------------------------- | ----------- | ------------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して直接起動、または AUR を使用 |
|
||||
| その他 / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,339 @@
|
||||
# CC Switch v3.16.3
|
||||
|
||||
> 🎉 **CC Switch 突破 100,000 Star!**
|
||||
> 感谢每一位用户、贡献者与 Star —— 是你们让它走到这里。🙏
|
||||
|
||||
> 💎 **本版由 Claude Fable 5 模型协助开发**——它帮忙梳理清楚了多处关键且容易出错的逻辑:路由接管时按真实上游模型计费的归因链、格式转换路径上缓存 token 的计量与去重、应用内更新的重启死锁,以及 Codex 统一会话历史的迁移 / 还原不变量。这也是本版在「关于」页新增 **Fable 5 Verified** 标识的由来。
|
||||
|
||||
> 在 v3.16.2 拓宽数据可携带性与用量观测之后,这一版把重心放在「让用量计费真正准确」——按真实上游模型计费、修正格式转换路径上的缓存双算、把 Claude Code Workflow 子 agent 的用量纳入统计(schema v11),并对用量看板做了一轮改版(全局供应商 / 模型筛选、品牌图标工具栏、更稳的额度查询);同时加固了一批本地代理与平台问题,新增自定义 User-Agent 覆盖、Codex 统一会话历史开关与 Claude Fable 5 档位。
|
||||
|
||||
**[English →](v3.16.3-en.md) | [日本語版 →](v3.16.3-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 使用攻略
|
||||
|
||||
这一版用量统计的口径和看板做了较多调整,建议先看:
|
||||
|
||||
- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:了解用量看板的数据来源(代理日志、会话同步)与统计口径,本版新增了全局的供应商 / 模型筛选,并把路由接管的真实计价模型展示了出来。
|
||||
- **[设置](../user-manual/zh/1-getting-started/1.5-settings.md)**:自定义 User-Agent 覆盖、Codex 统一会话历史等开关都在供应商表单的高级选项与设置页里。
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## 唯一官方渠道声明(请务必阅读)
|
||||
>
|
||||
> CC Switch 是**完全免费、开源**的桌面应用,**不会向用户收取任何费用**。请仅通过下列官方渠道获取本软件:
|
||||
>
|
||||
> | 类别 | 唯一官方 |
|
||||
> | -------- | ------------------------------------------------------------------------------ |
|
||||
> | 官网 | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | 源码 | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | 下载 | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | 举报山寨 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **任何向你收费、要求充值、或索取登录凭据的"CC Switch"网站或客户端均为假冒**。如果你被诱导支付了费用,请立即停止操作并通过 GitHub Issues 反馈。
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.16.3 是 v3.16.2 之后的一版维护更新。在上一版集中拓宽数据可携带性与用量观测之后,这一版把重心放在「让用量计费真正准确」这件事上——按真实上游模型计费而非上游回显、修正格式转换(Chat / Responses / Gemini 转 Anthropic)路径上的缓存 token 双算、把 Claude Code Workflow 子 agent 的用量纳入本地统计,并以 schema v11 持久化每条记录实际使用的定价依据;用量看板也随之做了一轮改版,新增全局的供应商 / 模型筛选、品牌图标工具栏,以及更稳的额度查询(失败重试 + 保留上次成功结果)。
|
||||
|
||||
此外,本版还加固了一批本地代理的稳健性问题(错标 Content-Type 的 SSE 响应聚合、Codex `/responses` 文本模型图像整流、Codex OAuth 凭据与接管残留的恢复、Hermes 配置重复 YAML 键),重做了供应商配置体验(自定义 User-Agent 覆盖、Codex 表单统一进高级选项、预设搜索与排序、Claude Fable 5 档位),新增 Codex 统一会话历史开关,并修复了应用内更新卡死、Codex 升级损坏安装、macOS 重复终端窗口等问题。
|
||||
|
||||
**发布日期**:2026-06-14
|
||||
|
||||
**更新规模**:59 commits | 130 files changed | +10,223 / -4,232 lines
|
||||
|
||||
---
|
||||
|
||||
## 重点内容
|
||||
|
||||
- **用量计费更准**:路由接管的流量现在按真实上游模型计费(而非上游回显的别名),格式转换路径不再把缓存 token 重复计入 input,Claude Code Workflow 子 agent 的用量也纳入了统计——以 schema v11 持久化定价依据。
|
||||
- **用量看板改版**:供应商 / 模型筛选从请求日志表提升为全局筛选,应用筛选改用品牌图标,额度查询加入失败重试与「保留上次成功结果」,单次网络抖动不再让卡片变红。
|
||||
- **自定义 User-Agent 覆盖**:供应商可设置自定义 UA,并在转发、连通性检测、模型列表三处一致生效,绕过按 UA 白名单放行的 Coding Plan 上游(借此恢复了 Codex「Kimi For Coding」预设)。
|
||||
- **Codex 统一会话历史**:新增可选开关,让官方 Codex 会话与第三方会话共享同一份 resume 历史桶,附带可选的存量迁移与按账本精确还原。
|
||||
- **代理与平台加固**:错标 SSE 响应聚合、Codex 图像整流、接管残留恢复、Hermes YAML 去重;应用内更新不再卡在「重启中」,Codex 升级不再把安装弄坏。
|
||||
|
||||
---
|
||||
|
||||
## 新功能
|
||||
|
||||
### 自定义 User-Agent 覆盖
|
||||
|
||||
供应商配置现在可以设置自定义 User-Agent,并由代理在请求转发、连通性检测和模型列表(`GET /v1/models`)三条路径上一致应用,因此按 UA 白名单放行的 Coding Plan 上游不会再出现「检测失败 / 模型列表 403、但代理本身却能正常工作」的不一致。Claude 和 Codex 表单都在高级选项里暴露该字段,配有精选的 UA 预设下拉(Claude Code / Kilo Code 等能通过 UA 白名单的家族)和实时、非阻塞的格式校验;切换到官方预设时会丢弃残留的自定义 UA,避免悄悄改动请求头([#3671](https://github.com/farion1231/cc-switch/pull/3671))。
|
||||
|
||||
### Codex 统一会话历史
|
||||
|
||||
新增一个可选开关(设置 → Codex 应用增强),让官方 Codex 会话与 CC Switch 的第三方会话共享同一份 resume 历史桶,resume 选择器不再把两者互相隐藏。开启后,live 的 `config.toml` 会把官方运行路由到一个镜像内建 OpenAI 供应商的共享 `custom` model_provider(`auth.json` 不动)。默认只对未来会话生效;开启弹窗提供一个勾选项,可把已有官方会话迁入共享桶(含逐代备份),关闭弹窗则提供按备份账本精确还原——只回退备份中记录为 `openai` 的会话,开启期间新建的会话永不被改动。
|
||||
|
||||
### 用量看板全局供应商 / 模型筛选
|
||||
|
||||
供应商和模型筛选从请求日志表内部提升到了顶栏,对 Hero 汇总、趋势图、请求日志和两个统计页签全局生效,可以把整个看板按某个来源和模型缩小范围。来源按展示名精确匹配(因此像「Claude (Session)」这样的会话占位行也可选),模型按有效计价模型匹配,模型下拉会随所选来源级联,且两个列表只列出当前时间范围内有数据的选项。
|
||||
|
||||
### 模型定价种子刷新
|
||||
|
||||
对 `seed_model_pricing` 做了一次全量核价:新增 9 个模型的定价(含 Claude Fable 5、Grok 4.3、Mistral Medium 3.5 / Small 4、Qwen 3.7 Max/Plus 等),并按各厂商官方 list 价订正了 28 处既有价格(GLM、Grok、MiMo、Doubao、Kimi、MiniMax、Mistral、Qwen),让用量成本估算更准确。每处改动都同时更新种子(影响全新安装)并向 `repair_current_model_pricing` 加一条旧→新守卫(修复存量数据库,且不覆盖用户手改过的行)。
|
||||
|
||||
### Claude Fable 5 模型档位
|
||||
|
||||
供应商表单现在在 Claude Code 和 Claude Desktop 两条代理路径上都暴露 `claude-fable-5` 作为第四个模型映射档位,回落链为 fable → opus → default,与官方降级一致,并为 Claude Desktop 1.12603.1+ 的校验器放行了 `fable-` 前缀。四语回落提示也做了澄清:在第三方端点上把某一档留空,会原样透传该档的字面模型名并 404([#3980](https://github.com/farion1231/cc-switch/issues/3980)、[#4026](https://github.com/farion1231/cc-switch/issues/4026)、[#4049](https://github.com/farion1231/cc-switch/issues/4049))。
|
||||
|
||||
### Unity2.ai 合作伙伴供应商
|
||||
|
||||
新增 Unity2.ai(一个 AI API 中转合作伙伴)作为预设,覆盖全部 7 个受管应用(Claude Code、Codex、Gemini、OpenCode、OpenClaw、Claude Desktop、Hermes),每个预设都带上推广注册链接,并在四种语言里补充了合作伙伴推广文案。Codex 使用裸 base URL(该网关在根路径暴露 `/responses`),OpenCode / OpenClaw / Hermes 使用 `/v1` chat-completions 端点并以 `gpt-5.5` 为预设模型。
|
||||
|
||||
### Kimi K2.7 Code 模型
|
||||
|
||||
新增 `kimi-k2.7-code` 模型(输入 $0.95 / 输出 $4.00 / 缓存读取 $0.19,每百万 token,256K 上下文),并把全部 6 个官方 Moonshot Kimi 预设(Claude Code、Codex、Claude Desktop、Hermes、OpenCode、OpenClaw)指向它,OpenCode / OpenClaw 预设更名为「Kimi K2.7 Code」。定价种子通过启动时的幂等插入路径生效,存量用户无需迁移即可获得新价。
|
||||
|
||||
### 恢复 Codex「Kimi For Coding」预设
|
||||
|
||||
重新加入 Codex「Kimi For Coding」预设(`openai_chat`、`kimi-for-coding`、256K 上下文),默认开启思考模式。此前它被移除是因为该编程端点会以 403 拒绝 Codex 默认的 `codex-cli` User-Agent;现在借助代理接管 + 自定义 User-Agent 覆盖(设为 `claude-cli/*` 等白名单 UA)即可正常使用。
|
||||
|
||||
### 请求详情的计价模型审计
|
||||
|
||||
请求详情面板现在会在「请求的模型」「计价模型」与响应模型不一致时把它们都显示出来,让路由接管产生的账单可以直接在用量界面里核对。
|
||||
|
||||
### 预设供应商搜索与排序
|
||||
|
||||
预设供应商选择器现在是一个可搜索、可排序的列表,配有内联搜索框(点放大镜图标切换,按 ESC 或点击外部收起)。按钮改为响应式网格、尺寸统一并显示默认图标,搜索只匹配供应商的展示名 / 原始名,因此 URL 片段和共享的分类标签不会再产生噪声匹配([#3975](https://github.com/farion1231/cc-switch/pull/3975)、[#4183](https://github.com/farion1231/cc-switch/pull/4183))。
|
||||
|
||||
### Claude Mythos 5 定价
|
||||
|
||||
在内置模型 / 定价表里登记 `claude-mythos-5` 模型(输入 $10 / 输出 $50,每百万 token;缓存读取 $1.00、缓存写入 $12.50),让用量统计能正确计价并展示([#4077](https://github.com/farion1231/cc-switch/pull/4077))。
|
||||
|
||||
### Fable 5 Verified 标识
|
||||
|
||||
设置「关于」页现在会在应用名与版本旁展示 Fable 5 Verified 标识,标明这是一个特别构建,版本徽标也居中到了应用名下方。
|
||||
|
||||
---
|
||||
|
||||
## 变更
|
||||
|
||||
### Claude Desktop 用量折叠进 Claude
|
||||
|
||||
看板不再展示独立的「Claude Desktop」分桶——它一直只能显示一个不完整的数字(Desktop 聊天用量根本不经过代理,而其 Code 页签的会话只是内嵌的 Claude Code 运行时写进共享的 `~/.claude/projects` 目录)。Desktop 的代理流量现在在展示上折叠进 `claude`,但记账层仍按它自己的 `app_type` 记录以便路由接管计费审计,真实值可在请求详情面板看到。
|
||||
|
||||
### 轻量化供应商健康检查
|
||||
|
||||
供应商健康检查不再发送真实的流式模型请求(很多第三方供应商会以 401/403/WAF 拦截,造成误报不可用),改为对供应商 `base_url` 做一次轻量的 HTTP 可达性探测:任何 HTTP 响应都视为可达,只有 DNS / 连接 / TLS / 超时才算失败。官方供应商(使用 OAuth、base_url 故意为空、没有可靠的可达性目标)会隐藏连通性按钮,原先「发送真实请求」的确认弹窗以及测试模型 / 提示词字段都被移除,降级延迟阈值设为 6s、超时 8s。该可达性检查永不重置熔断器——可达不等于可用(403 的 host 可达,但对真实流量是坏的),失败转移仍只由真实代理流量驱动。
|
||||
|
||||
### Codex 高级选项区整合
|
||||
|
||||
Codex 供应商表单现在把本地路由、模型映射、推理覆盖和自定义 User-Agent 折叠进一个可展开的高级选项区,与 Claude 表单一致(设置了 UA 或开启本地路由时自动展开)。自定义 User-Agent 现在对原生 Responses 供应商也可配置,此前它只有在开启 `openai_chat` 路由时才能触及。
|
||||
|
||||
### 用量工具栏与布局刷新
|
||||
|
||||
应用筛选改用品牌图标(经 ProviderIcon,「全部」用网格图标)渲染,取代在窄窗口下换行难看的文字页签;用量 Hero 也会显示所选应用的品牌图标,并把 Codex 的主题色从翠绿改为中性灰,贴合 OpenAI 的单色品牌。点击循环切换的刷新按钮改成了带本地化「关闭」标签的下拉选择,顶栏控件也压缩并对齐成统一的宽度分组,过长的日期范围标签做了截断处理。
|
||||
|
||||
### 关于面板加载更快
|
||||
|
||||
设置「关于」面板现在渐进式加载:应用版本徽标在解析完成的瞬间就显示,不再等待工具探测;每张工具卡片在自己的版本检测完成时立即更新(探测并发执行而非串行);探测结果在应用会话期内缓存并带 10 分钟 TTL,因此再次打开「关于」页签会复用缓存值、并在后台对过期项重新校验,而不是每次都把 6 个工具全部重探一遍。
|
||||
|
||||
### 火山方舟 Coding Plan 推广更新
|
||||
|
||||
把火山方舟(Volcengine Ark)预设在全部 6 个应用里更新到新的 Coding Plan 邀请链接(替换旧的 Agent Plan / 活动链接),并在四种语言里刷新了合作伙伴推广文案(两个月 75% 折扣 + 邀请码 6J6FV5N2),把产品名从 Agent Plan 订正为 Coding Plan。
|
||||
|
||||
### MiniMax 降为普通供应商
|
||||
|
||||
移除 MiniMax 的金色合作伙伴星标和 API key 推广横幅(从所有预设里删掉 `isPartner` 标志),它继续作为常规 `cn_official` 供应商保留图标与主题。推广文案保持休眠状态,必要时一行即可重新启用合作关系。
|
||||
|
||||
### 移除 LemonData、SudoCode 降级
|
||||
|
||||
彻底移除 LemonData 供应商预设(连同其推广文案、图标和赞助商条目),并把 SudoCode 从合作伙伴降为常规 `third_party` 供应商(去掉 `isPartner` 标志和推广文案,保留图标)。
|
||||
|
||||
### AtlasCloud Codex GLM 5.1 上下文窗口
|
||||
|
||||
为 AtlasCloud Codex 预设里的 `zai-org/glm-5.1` 模型声明 200,000 token 的上下文窗口,与其他 GLM 5.1 预设条目对齐。
|
||||
|
||||
---
|
||||
|
||||
## 修复
|
||||
|
||||
### 路由接管流量按真实上游模型计费
|
||||
|
||||
当请求被路由到了不同的上游(env 模型映射、Claude Desktop 路由、Copilot 归一化、Codex chat 覆盖)时,代理过去会按上游回显的模型来归因和计价,把 kimi / glm 的 token 记成、并按 `claude-*` 计价,成本被高估约 5–25 倍。现在转发器会捕获真实的出站模型,按「上游回显 → 出站模型 → 客户端别名」的顺序归因,并在每行持久化实际使用的定价依据(schema v11),该依据会贯穿成本回填和 30 天 rollup 裁剪;Claude Desktop 流量现在也记在它自己的 `app_type` 下,使其定价覆盖能正确生效。
|
||||
|
||||
### 格式转换路径的用量计量
|
||||
|
||||
审计并修复了代理各条格式转换路径(Chat、Responses、Gemini 转 Anthropic)上的 token / 缓存计量。代理现在会记录实际返回的模型,注入 `stream_options.include_usage` 让 OpenAI 兼容上游在流式时吐出 usage,在 Claude←OpenAI 路径上把 `cache_read` 和 `cache_creation` 从 input 中排除以阻止缓存 token 双计费,扣减 Gemini 的缓存提示 token,仍记录完全命中缓存的请求,并跳过过去会虚增请求数的合成全零 usage([#2774](https://github.com/farion1231/cc-switch/pull/2774))。
|
||||
|
||||
### 应用内更新不再卡死
|
||||
|
||||
从应用内安装更新时不再卡在「重启中」界面——过去会出现新版已装好、却必须手动强制退出的情况。下载—安装—重启整条链路现在完全在后端执行(新增 `install_update_and_restart` 命令),按平台决定安装顺序,并在重新执行前先销毁单实例锁,而不再依赖旧 WebView 在应用包已被替换之后继续跑 JS;退出请求也做了分类,让重启请求落到 Tauri 默认流程,而不是在窗口状态插件的互斥锁上死锁([#4069](https://github.com/farion1231/cc-switch/pull/4069)、[#4074](https://github.com/farion1231/cc-switch/pull/4074))。
|
||||
|
||||
### Codex 升级不再损坏安装
|
||||
|
||||
从设置「关于」页升级 Codex 不再让它抛出「Missing optional dependency @openai/codex-…」错误。升级链此前会先跑 `codex update`,而它在 npm 安装下其实是一次裸的重装、即便对应平台的二进制没装上也会报告成功;现在 Codex 已从「优先 self-update」路径里移除,并由一个 runnable 检测触发「卸载 + 重装」自愈(仅限 npm 管理的安装),这是唯一能真正补回缺失平台二进制的修复。
|
||||
|
||||
### 接管时保留 Codex OAuth 凭据
|
||||
|
||||
为 Codex 供应商开启代理接管时不再剥掉 `ANTHROPIC_AUTH_TOKEN` 占位符——此前这会在热切换、全新安装、以及被旧版本已剥过的 live 配置上破坏 Claude Code 的登录。现在对受管(非 Copilot)的 Codex 供应商无条件注入该占位符,包括只有 URL 的供应商;GitHub Copilot 的行为(仅 API_KEY)不变([#3789](https://github.com/farion1231/cc-switch/pull/3789)、[#3784](https://github.com/farion1231/cc-switch/issues/3784))。
|
||||
|
||||
### 跨配置目录切换的接管残留恢复
|
||||
|
||||
在代理接管激活时更改配置目录后重启应用,不再把 Claude / Codex / Gemini 留在指向已失效的本地代理上。现在旧实例会在重启前先还原被接管的 live 文件,首次运行的导入会拒绝把接管占位符当作供应商持久化,SSOT 还原也会在写回前校验当前供应商的配置里不含占位符([#4076](https://github.com/farion1231/cc-switch/pull/4076))。
|
||||
|
||||
### 格式转换兜底里错标的 SSE 响应聚合
|
||||
|
||||
经 Claude / Codex 格式转换的请求,当 MaaS 网关把一个 `stream:false` 的请求强制流式、并以非 SSE 的 Content-Type 返回 SSE 响应体时,不再以一句晦涩的 422「Failed to parse upstream response」失败。代理现在会在解析失败时嗅探 SSE、把分片聚合成单个 JSON 再跑既有转换器,让客户端仍能拿到有效的非流式响应;剩余的解析失败会附带 content-type、编码和响应体片段等诊断信息,deflate 解码也改为先尝试 zlib 再尝试裸流([#2234](https://github.com/farion1231/cc-switch/pull/2234))。
|
||||
|
||||
### Hermes 配置重复 YAML 键
|
||||
|
||||
Hermes 配置写入不再累积重复的顶层键(如 `mcp_servers`),那会导致「Failed to parse Hermes config as YAML: duplicate entry with key」错误。区段替换现在会从剩余文本里清除所有过期副本,而不是退化成追加;去重保护层同时处理 LF 和 CRLF 行尾;修复时保留最后(最新)的那份副本,与 Hermes 自身基于 PyYAML 的「后者胜」语义一致([#3267](https://github.com/farion1231/cc-switch/pull/3267)、[#3633](https://github.com/farion1231/cc-switch/issues/3633)、[#2973](https://github.com/farion1231/cc-switch/issues/2973)、[#2529](https://github.com/farion1231/cc-switch/issues/2529)、[#3310](https://github.com/farion1231/cc-switch/issues/3310)、[#3762](https://github.com/farion1231/cc-switch/issues/3762))。
|
||||
|
||||
### 用量查询韧性与错误清晰度
|
||||
|
||||
用量卡片不再因为单次瞬时抖动就变红:查询现在会重试一次,并在网络 / 超时 / 5xx 这类瞬时失败下继续展示上次成功的结果最多 10 分钟;而确定性失败(鉴权、空 key、未知供应商、4xx)会立即暴露并清空快照,避免凭据变更后陈旧额度又冒出来。原生余额 / Coding Plan / 订阅查询的超时从 10s 提高到 15s 以适配跨境慢端点,Coding Plan 也会返回明确的「API key is empty」/「Unknown coding plan provider」错误,而不是一句空白的失败。
|
||||
|
||||
### 用量脚本供应商凭据解析
|
||||
|
||||
自定义 JS 脚本的用量查询此前只靠猜测 env 字段来解析 `{{apiKey}}` / `{{baseUrl}}`,因此凭据存放在别处的应用(如 Codex 的 `auth.OPENAI_API_KEY` 加 `config.toml` 里的 base_url)总是拿到空值、即便供应商已完整配置也会失败。脚本查询及其测试 / 预览现在复用与原生余额路径相同的按应用凭据解析器,脚本里显式填写的非空值仍然优先([#1479](https://github.com/farion1231/cc-switch/pull/1479))。
|
||||
|
||||
### Claude Code Workflow 子 agent 用量统计
|
||||
|
||||
本地(无代理)的会话日志用量统计此前漏掉了 Claude Code Workflow 子 agent 的流量,整体用量被低估约 4.1%(集中在 workflow / subagent 的会话记录里)。扫描器现在会深入更深一层的 `subagents/workflows/wf_*/` 记录目录,解析器也不再丢弃那些缺少 `stop_reason`、但已经产生 input / 缓存 token 成本的 assistant 消息;去重逻辑不变,因此不会重复计数。
|
||||
|
||||
### Codex `/responses` 文本模型图像整流
|
||||
|
||||
携带图片、且被路由到只支持文本的 OpenAI-chat 模型(如 DeepSeek `deepseek-v4-flash`)的 Codex `/responses` 请求,不再以 HTTP 400「unknown variant `image_url`」失败。媒体整流器现在也覆盖 Codex 适配器,会扫描 responses 的 `input` 里的 `input_image` 块,从而既能为已知的纯文本模型主动剥掉图片,也能在上游报「不支持图片」时把图片替换后重试。
|
||||
|
||||
### 智谱 Coding Plan 配额窗口误标
|
||||
|
||||
智谱 Coding Plan 视图不再在每个周周期的最后几个小时把 5 小时窗口和周窗口标反。两个窗口现在按显式的 `unit` 字段分类(3 = 5 小时、6 = 周),而不再靠按重置时间升序排序——后者恰好在用户最常查周额度的时候把两者标反;当字段缺失时仍回退到旧的重置时间启发式([#3036](https://github.com/farion1231/cc-switch/pull/3036))。
|
||||
|
||||
### macOS 重复供应商终端窗口
|
||||
|
||||
在 macOS 上启动供应商终端时不再在命令会话旁多开一个空窗口;Terminal.app 在冷启动时改用 `launch`(而非 `activate`),Ghostty 使用初始命令,从而只打开单个会话,并在 AppleScript 路径失败时保留回退方案([#4156](https://github.com/farion1231/cc-switch/pull/4156))。
|
||||
|
||||
### Claude Desktop 模型映射占位符
|
||||
|
||||
Claude Desktop 模型映射表单此前在「菜单展示名」和「请求模型」两列用了不一致的示例品牌(DeepSeek vs Kimi),暗示一个展示名会映射到不相关的模型。现在两个占位符都由每行的角色派生,从而保持品牌一致,轻量的 Haiku 档使用 flash 示例。
|
||||
|
||||
### 弹层被全屏面板遮挡
|
||||
|
||||
像供应商预设搜索这样的弹层和提示气泡不再渲染到全屏面板后面、看起来点了没反应;它们的 z-index 被提到全屏遮罩之上,同时仍低于模态对话框。
|
||||
|
||||
### ToggleRow 图标被挤压
|
||||
|
||||
开关行的图标在配上长描述时不再被压缩或变形,让图标在多行文字旁保持固定大小。
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
### Release Notes 贡献者致谢恢复
|
||||
|
||||
恢复了 v3.16.1 与 v3.16.2 release notes 在三种语言里的贡献者致谢。
|
||||
|
||||
---
|
||||
|
||||
## 升级提醒
|
||||
|
||||
### 定价库 schema v11 自动迁移
|
||||
|
||||
本版给 `proxy_request_logs` 新增了 `pricing_model` 列、并按 `request_model` + `pricing_model` 重建了 rollup,启动时自动迁移、无需手动操作。历史行的成本在写入时已冻结、不会重算(`app_type="claude"` 的行混合了原生与转换两类来源);只有真实但当时未计价的接管行会保持零成本、待定价补齐后再回填。
|
||||
|
||||
### 模型映射新增第四档(Fable 5)
|
||||
|
||||
Claude Code 与 Claude Desktop 的模型映射现在是四档(Sonnet / Opus / Fable / Haiku)。老的三档供应商在重新打开并保存后会补上 `claude-fable-5` 档;该档留空表示继承 Sonnet。注意:在第三方端点上把任意一档留空,会原样透传该档的字面模型名并可能 404,请按需填写。
|
||||
|
||||
### 「Kimi For Coding」预设需要代理接管 + 白名单 UA
|
||||
|
||||
恢复的 Codex「Kimi For Coding」预设直接用默认的 `codex-cli` User-Agent 仍会被 403。要使用它,请开启代理接管,并在供应商高级选项里把自定义 User-Agent 设为白名单 UA(如 `claude-cli/*`)。
|
||||
|
||||
### 供应商健康检查语义变化
|
||||
|
||||
健康检查从「发送真实模型请求」改为「HTTP 可达性探测」。请注意可达 ≠ 可用:一个返回 403 的 host 是可达的,但对真实流量可能是坏的。失败转移的判定仍只由真实代理流量驱动,不受健康检查影响。
|
||||
|
||||
---
|
||||
|
||||
## 风险提示
|
||||
|
||||
本版本继续沿用此前版本对反向代理类功能的风险提示。
|
||||
|
||||
**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#️-风险提示)。
|
||||
|
||||
**Codex 第三方供应商 Chat 路由**:通过 CC Switch 本地代理把 Codex 请求转换并转发到第三方供应商时,各供应商对计费、合规与数据留存的约束不同,请在使用前阅读目标供应商的服务条款。
|
||||
|
||||
**Claude Desktop 第三方供应商代理切换**:通过 CC Switch 内置代理网关把 Claude Desktop 的请求转到第三方供应商时,同样需要遵守目标供应商的计费、合规与数据留存约束。
|
||||
|
||||
用户启用上述功能即表示自行承担相关风险。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
感谢以下贡献者在 v3.16.3 中提交的功能与修复:
|
||||
|
||||
- [#3789](https://github.com/farion1231/cc-switch/pull/3789):接管时保留 Codex OAuth 凭据,感谢 @codeasier。
|
||||
- [#2774](https://github.com/farion1231/cc-switch/pull/2774):修复 Completions 转 Anthropic 时不记录实际返回模型、input token 计算错误,感谢 @LaoYueHanNi。
|
||||
- [#4069](https://github.com/farion1231/cc-switch/pull/4069):修复应用内更新后重启死锁,感谢 @thisTom。
|
||||
- [#4156](https://github.com/farion1231/cc-switch/pull/4156):修复 macOS 重复供应商终端窗口,感谢 @thisTom。
|
||||
- [#3267](https://github.com/farion1231/cc-switch/pull/3267):修复 Hermes 配置重复 YAML 键,感谢 @que3sui。
|
||||
- [#1479](https://github.com/farion1231/cc-switch/pull/1479):修复用量脚本供应商凭据解析,感谢 @pa001024。
|
||||
- [#3975](https://github.com/farion1231/cc-switch/pull/3975):新增预设供应商搜索与排序,感谢 @Nastem。
|
||||
- [#4183](https://github.com/farion1231/cc-switch/pull/4183):调整预设供应商按钮外观与搜索框位置,感谢 @WangJiati。
|
||||
- [#4077](https://github.com/farion1231/cc-switch/pull/4077):新增 claude-mythos-5 模型定价,感谢 @osscv。
|
||||
|
||||
也感谢所有在 v3.16.2 发布后反馈用量计费、本地代理稳健性、Codex 升级与平台兼容性问题的用户,很多补丁都来自这些真实使用场景里的复现线索。
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | -------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------------- | ----------------------------------- |
|
||||
| `CC-Switch-v3.16.3-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.16.3-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| -------------------------------- | --------------------------------------------- |
|
||||
| `CC-Switch-v3.16.3-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
|
||||
| `CC-Switch-v3.16.3-macOS.zip` | 解压后拖入 Applications,Universal Binary |
|
||||
| `CC-Switch-v3.16.3-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
Homebrew 安装:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux 资产同时提供 **x86_64** 和 **ARM64**(`aarch64`)两种架构。资产文件名中包含架构标识,请按你机器的 `uname -m` 输出选择对应版本:
|
||||
|
||||
- `CC-Switch-v3.16.3-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.16.3-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| 发行版 | 推荐格式 | 安装方式 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
|
||||
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -1,6 +1,6 @@
|
||||
# CC Switch User Manual / 用户手册 / ユーザーマニュアル
|
||||
|
||||
> Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw
|
||||
> Claude Code / Claude Desktop / Codex / Gemini CLI / OpenCode / OpenClaw / Hermes
|
||||
|
||||
## Language / 语言 / 言語
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
|
||||
## Version / 版本 / バージョン
|
||||
|
||||
- Documentation version: v3.15.0
|
||||
- Last updated: 2026-05-16
|
||||
- Compatible with CC Switch v3.15.0+
|
||||
- Documentation version: v3.16.0
|
||||
- Last updated: 2026-05-29
|
||||
- Compatible with CC Switch v3.16.0+
|
||||
|
||||
## Links
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## What is CC Switch
|
||||
|
||||
CC Switch is a cross-platform desktop application designed for developers who use AI coding tools. It helps you centrally manage configurations for **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **OpenCode**, **OpenClaw**, and **Hermes**.
|
||||
CC Switch is a cross-platform desktop application designed for developers who use AI tools. It helps you centrally manage configurations for **Claude Code**, **Claude Desktop**, **Codex**, **Gemini CLI**, **OpenCode**, **OpenClaw**, and **Hermes**.
|
||||
|
||||
## What Problems Does It Solve
|
||||
|
||||
@@ -45,7 +45,7 @@ CC Switch solves these problems through a unified interface.
|
||||
| **Codex** | OpenAI's code generation tool |
|
||||
| **Gemini CLI** | Google's AI command-line tool |
|
||||
| **OpenCode** | Open-source AI coding terminal tool |
|
||||
| **OpenClaw** | Open-source AI coding assistant (multi-provider gateway) |
|
||||
| **OpenClaw** | Open-source AI assistant (multi-provider gateway) |
|
||||
| **Hermes** | Hermes Agent provider, MCP, Skills, and Memory management |
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| 2 | Settings Button | Open the settings page (shortcut `Cmd/Ctrl + ,`) |
|
||||
| 3 | Proxy Toggle | Start/stop the local proxy service |
|
||||
| 4 | App Switcher | Switch between Claude / Claude Desktop / Codex / Gemini / OpenCode / OpenClaw / Hermes |
|
||||
| 5 | Feature Area | Skills / Prompts / MCP entry points |
|
||||
| 5 | Feature Area | App-specific feature entry points |
|
||||
| 6 | Add Button | Add a new provider |
|
||||
|
||||
### App Switcher
|
||||
@@ -33,9 +33,9 @@ After switching, the provider list displays the configurations for the selected
|
||||
|
||||
| Button | Function | Visibility |
|
||||
|--------|----------|------------|
|
||||
| Skills | Skill extension management | Always visible |
|
||||
| Prompts | System prompt management | Always visible |
|
||||
| MCP | MCP server management | Always visible |
|
||||
| Skills | Skill extension management | Claude / Codex / Gemini / OpenCode / Hermes |
|
||||
| Prompts | System prompt management | Claude / Codex / Gemini / OpenCode |
|
||||
| MCP | MCP server management | Claude / Codex / Gemini / OpenCode / Hermes |
|
||||
|
||||
## Provider Cards
|
||||
|
||||
@@ -113,13 +113,14 @@ CC Switch displays an icon in the system tray, providing quick access to operati
|
||||
|
||||
### Multi-language Support
|
||||
|
||||
The tray menu supports three languages, automatically switching based on settings:
|
||||
The tray menu supports four languages, automatically switching based on settings:
|
||||
|
||||
| Language | Open Main Window | Quit |
|
||||
|----------|-----------------|------|
|
||||
| Chinese | Open Main Window | Quit |
|
||||
| Simplified Chinese | 打开主界面 | 退出 |
|
||||
| Traditional Chinese | 開啟主介面 | 退出 |
|
||||
| English | Open main window | Quit |
|
||||
| Japanese | Open main window | Quit |
|
||||
| Japanese | メインウィンドウを開く | 終了 |
|
||||
|
||||
### Lightweight Mode
|
||||
|
||||
@@ -147,7 +148,7 @@ The settings page is divided into multiple tabs:
|
||||
|
||||
### General Tab
|
||||
|
||||
- Language settings (Chinese/English/Japanese)
|
||||
- Language settings (Simplified Chinese/Traditional Chinese/English/Japanese)
|
||||
- Theme settings (System/Light/Dark)
|
||||
- Window behavior (launch on startup, close behavior)
|
||||
|
||||
|
||||
@@ -9,11 +9,12 @@ This section describes how to configure CC Switch according to your preferences.
|
||||
|
||||
## Language Settings
|
||||
|
||||
CC Switch supports three languages:
|
||||
CC Switch supports four languages:
|
||||
|
||||
| Language | Description |
|
||||
|----------|-------------|
|
||||
| Simplified Chinese | Default language |
|
||||
| Traditional Chinese | Traditional Chinese interface |
|
||||
| English | English interface |
|
||||
| Japanese | Japanese interface |
|
||||
|
||||
@@ -124,8 +125,9 @@ You can customize each CLI tool's configuration directory:
|
||||
| Claude Directory | `~/.claude/` | Claude Code configuration directory |
|
||||
| Codex Directory | `~/.codex/` | Codex configuration directory |
|
||||
| Gemini Directory | `~/.gemini/` | Gemini CLI configuration directory |
|
||||
| OpenCode Directory | `~/.opencode/` | OpenCode configuration directory |
|
||||
| OpenCode Directory | `~/.config/opencode/` | OpenCode configuration directory |
|
||||
| OpenClaw Directory | `~/.openclaw/` | OpenClaw configuration directory |
|
||||
| Hermes Directory | `~/.hermes/` | Hermes configuration directory |
|
||||
|
||||
> **Note**: After changing directories, the app must be restarted, and the corresponding CLI tools must also be configured to use the same directory.
|
||||
|
||||
@@ -133,14 +135,15 @@ You can customize each CLI tool's configuration directory:
|
||||
|
||||
### Export Configuration
|
||||
|
||||
Click the "Export" button to save a backup file containing:
|
||||
Click the "Export" button to save a SQL backup file containing:
|
||||
|
||||
- All provider configurations
|
||||
- MCP server configurations
|
||||
- Prompt presets
|
||||
- Usage logs
|
||||
- App settings
|
||||
|
||||
The backup file is in JSON format and can be viewed with a text editor.
|
||||
The exported file name format is `cc-switch-export-{timestamp}.sql`.
|
||||
|
||||
### Import Configuration
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ Codex presets fall into two groups by upstream protocol.
|
||||
|-------------|-------------|
|
||||
| DeepSeek | DeepSeek models |
|
||||
| Zhipu GLM / GLM en | Zhipu AI GLM models |
|
||||
| Kimi | Moonshot Kimi models |
|
||||
| Kimi / Kimi For Coding | Moonshot Kimi models |
|
||||
| MiniMax / MiniMax en | MiniMax models |
|
||||
| StepFun / StepFun en | StepFun Step models |
|
||||
| Baidu Qianfan Coding Plan | Baidu Qianfan coding plan |
|
||||
|
||||
@@ -103,15 +103,16 @@ Each MCP server can independently control which apps it is enabled for.
|
||||
| Claude | Sync to Claude Code | `~/.claude.json`'s `mcpServers` |
|
||||
| Codex | Sync to Codex | `~/.codex/config.toml`'s `[mcp_servers]` |
|
||||
| Gemini | Sync to Gemini CLI | `~/.gemini/settings.json`'s `mcpServers` |
|
||||
| OpenCode | Sync to OpenCode | `~/.opencode/config.json`'s `mcpServers` |
|
||||
| OpenCode | Sync to OpenCode | `~/.config/opencode/opencode.json`'s `mcp` |
|
||||
| Hermes | Sync to Hermes | `~/.hermes/config.yaml`'s `mcp_servers` |
|
||||
|
||||
> **Note**: OpenClaw does not currently support MCP server management. MCP functionality is currently only supported for Claude, Codex, Gemini, and OpenCode.
|
||||
> **Note**: OpenClaw and Claude Desktop do not currently support CC Switch MCP sync. MCP functionality is supported for Claude, Codex, Gemini, OpenCode, and Hermes.
|
||||
|
||||
### Toggle Implementation
|
||||
|
||||
When enabling an app's toggle, CC Switch will:
|
||||
|
||||
1. **Update database**: Set the server's `apps.claude/codex/gemini/opencode` status to `true`
|
||||
1. **Update database**: Set the server's `apps.claude/codex/gemini/opencode/hermes` status to `true`
|
||||
2. **Sync to live configuration**: Write the server configuration to the corresponding app's configuration file
|
||||
3. **Take effect immediately**: The new MCP server is automatically loaded the next time the CLI tool starts
|
||||
|
||||
@@ -128,7 +129,8 @@ MCP server sync only executes when the corresponding app is installed:
|
||||
- **Claude**: Requires `~/.claude/` directory or `~/.claude.json` file to exist
|
||||
- **Codex**: Requires `~/.codex/` directory to exist
|
||||
- **Gemini**: Requires `~/.gemini/` directory to exist
|
||||
- **OpenCode**: Requires `~/.opencode/` directory to exist
|
||||
- **OpenCode**: Requires `~/.config/opencode/` directory to exist
|
||||
- **Hermes**: Requires `~/.hermes/` directory to exist
|
||||
|
||||
> **Tip**: If a CLI tool is not installed, enabling its toggle will not cause an error, but the configuration will not be written.
|
||||
|
||||
@@ -154,7 +156,7 @@ After deletion, the configuration is removed from all app configuration files.
|
||||
If you have already configured MCP servers in CLI tools, you can import them into CC Switch:
|
||||
|
||||
1. Click the "Import" button
|
||||
2. Select the app to import from (Claude/Codex/Gemini/OpenCode)
|
||||
2. Select the app to import from (Claude/Codex/Gemini/OpenCode/Hermes)
|
||||
3. CC Switch reads the existing configuration and imports it
|
||||
|
||||
## Configuration File Formats
|
||||
|
||||
@@ -81,8 +81,7 @@ After activation, the prompt is written to the corresponding app's file:
|
||||
| Claude | `~/.claude/CLAUDE.md` |
|
||||
| Codex | `~/.codex/AGENTS.md` |
|
||||
| Gemini | `~/.gemini/GEMINI.md` |
|
||||
| OpenCode | `~/.opencode/AGENTS.md` |
|
||||
| OpenClaw | `~/.openclaw/AGENTS.md` |
|
||||
| OpenCode | `~/.config/opencode/AGENTS.md` |
|
||||
|
||||
## Edit a Preset
|
||||
|
||||
@@ -141,7 +140,6 @@ Prompts are managed separately per app:
|
||||
- When switched to Codex, Codex's presets are shown
|
||||
- When switched to Gemini, Gemini's presets are shown
|
||||
- When switched to OpenCode, OpenCode's presets are shown
|
||||
- When switched to OpenClaw, OpenClaw's presets are shown
|
||||
|
||||
To use the same prompt across multiple apps, you need to create them separately.
|
||||
|
||||
|
||||
@@ -12,18 +12,17 @@ Skills exist as folders containing:
|
||||
|
||||
## Supported Applications
|
||||
|
||||
Skills are supported across all four applications:
|
||||
Skills are supported across five applications:
|
||||
|
||||
- **Claude Code**
|
||||
- **Codex**
|
||||
- **Gemini CLI**
|
||||
- **OpenCode**
|
||||
- **Hermes**
|
||||
|
||||
## Open the Skills Page
|
||||
|
||||
Click the **Skills** button in the top navigation bar.
|
||||
|
||||
> Note: The Skills button is visible in all app modes.
|
||||
Click the **Skills** button in the top navigation bar when the selected app supports Skills.
|
||||
|
||||
## Page Overview
|
||||
|
||||
@@ -93,7 +92,8 @@ Click the "Refresh" button to re-scan repositories for the latest skills.
|
||||
| Claude | `~/.claude/skills/` |
|
||||
| Codex | `~/.codex/skills/` |
|
||||
| Gemini | `~/.gemini/skills/` |
|
||||
| OpenCode | `~/.opencode/skills/` |
|
||||
| OpenCode | `~/.config/opencode/skills/` |
|
||||
| Hermes | `~/.hermes/skills/` |
|
||||
|
||||
### Installation Contents
|
||||
|
||||
@@ -119,7 +119,7 @@ Installation copies the skill folder to your local machine:
|
||||
### Uninstall Effect
|
||||
|
||||
- **Automatic backup**: Before deletion, the skill is backed up to `~/.cc-switch/skill-backups/`
|
||||
- Removes the skill from all app directories (Claude, Codex, Gemini, OpenCode)
|
||||
- Removes the skill from all app directories (Claude, Codex, Gemini, OpenCode, Hermes)
|
||||
- Removes the skill from the SSOT directory (`~/.cc-switch/skills/`)
|
||||
- Deletes the skill record from the database
|
||||
|
||||
|
||||
@@ -215,9 +215,11 @@ Set prices for each model (per million tokens):
|
||||
|
||||
Before matching pricing, CC Switch normalizes the requested model ID:
|
||||
|
||||
- Remove everything before the last `/`
|
||||
- Remove everything after `:`
|
||||
- Remove everything before the last `/` and convert to lowercase
|
||||
- Remove everything after `:` and trim a trailing `[1m]`
|
||||
- Replace `@` with `-`
|
||||
- Remove common wrapper prefixes, version suffixes, and date suffixes (`-YYYY-MM-DD`, `-YYYYMMDD`)
|
||||
- Some model families can match a short ID to a versioned pricing entry
|
||||
|
||||
When adding pricing entries, enter the normalized Model ID rather than the full raw model name from the request.
|
||||
|
||||
@@ -226,6 +228,10 @@ When adding pricing entries, enter the normalized Model ID rather than the full
|
||||
| `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 `-` |
|
||||
| `OpenAI/GPT-5.5-2026-05-14` | `gpt-5.5` | Removes the prefix and date suffix |
|
||||
| `anthropic/claude-opus-4.8` | `claude-opus-4-8` | Removes the prefix and matches dotted format |
|
||||
| `global.anthropic.claude-opus-4-8-v1:0` | `claude-opus-4-8` | Removes wrapper prefix, version suffix, and `:` suffix |
|
||||
| `claude-haiku-4-5` | `claude-haiku-4-5-20251001` | Matches a short ID to versioned pricing |
|
||||
|
||||
### Operations
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Customizable location in settings (for cloud sync).
|
||||
├── settings.json # Device-level settings
|
||||
├── skills/ # Skill SSOT directory
|
||||
├── skill-backups/ # Skill backups (created on uninstall)
|
||||
└── db_backup_*.db # Database backups
|
||||
└── backups/ # Database backups
|
||||
```
|
||||
|
||||
### Database Contents
|
||||
@@ -51,7 +51,8 @@ Customizable location in settings (for cloud sync).
|
||||
"codexConfigDir": null,
|
||||
"geminiConfigDir": null,
|
||||
"opencodeConfigDir": null,
|
||||
"openclawConfigDir": null
|
||||
"openclawConfigDir": null,
|
||||
"hermesConfigDir": null
|
||||
}
|
||||
```
|
||||
|
||||
@@ -196,17 +197,41 @@ GEMINI_MODEL=gemini-pro
|
||||
|
||||
### Configuration Directory
|
||||
|
||||
Default: `~/.opencode/`
|
||||
Default: `~/.config/opencode/`
|
||||
|
||||
### Key Files
|
||||
|
||||
```
|
||||
~/.opencode/
|
||||
├── config.json # Main configuration file
|
||||
~/.config/opencode/
|
||||
├── opencode.json # Main configuration file
|
||||
├── AGENTS.md # System prompt
|
||||
└── skills/ # Skills directory
|
||||
└── ...
|
||||
```
|
||||
## Hermes Configuration
|
||||
|
||||
### Configuration Directory
|
||||
|
||||
Default: `~/.hermes/`
|
||||
|
||||
### Key Files
|
||||
|
||||
```
|
||||
~/.hermes/
|
||||
├── config.yaml # Main settings, providers, and MCP configuration
|
||||
├── .env # API keys and secrets
|
||||
├── SOUL.md # Profile identity/persona
|
||||
├── memories/
|
||||
│ ├── MEMORY.md # Agent memory
|
||||
│ └── USER.md # User profile memory
|
||||
├── skills/ # Active skills directory
|
||||
├── state.db # SQLite session database
|
||||
└── sessions/ # Gateway transcripts and optional JSON snapshots
|
||||
```
|
||||
|
||||
### config.yaml
|
||||
|
||||
Hermes uses YAML configuration. CC Switch writes MCP servers to `mcp_servers`, writes editable provider entries to `custom_providers`, reads read-only entries from Hermes' `providers` dict, and updates `model.provider` / `model.default` when switching providers.
|
||||
|
||||
## OpenClaw Configuration
|
||||
|
||||
@@ -219,7 +244,6 @@ Default: `~/.openclaw/`
|
||||
```
|
||||
~/.openclaw/
|
||||
├── openclaw.json # Main configuration file (JSON5 format)
|
||||
├── AGENTS.md # System prompt
|
||||
└── skills/ # Skills directory
|
||||
└── ...
|
||||
```
|
||||
@@ -246,18 +270,17 @@ OpenClaw uses a JSON5 format configuration file with the following main sections
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: "sk-..."
|
||||
},
|
||||
// Agent default model configuration
|
||||
// Agent default configuration
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "provider/model"
|
||||
}
|
||||
},
|
||||
workspace: "~/.openclaw/workspace"
|
||||
}
|
||||
},
|
||||
// Tool configuration
|
||||
tools: {},
|
||||
// Workspace file configuration
|
||||
workspace: {}
|
||||
tools: {}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -267,7 +290,7 @@ OpenClaw uses a JSON5 format configuration file with the following main sections
|
||||
| `env` | Environment variable configuration |
|
||||
| `agents.defaults` | Agent default model settings |
|
||||
| `tools` | Tool configuration |
|
||||
| `workspace` | Workspace file management |
|
||||
| `agents.defaults.workspace` | Workspace directory path |
|
||||
|
||||
## Configuration Priority
|
||||
|
||||
|
||||
@@ -106,15 +106,15 @@ CC Switch User Manual
|
||||
|
||||
## Version Information
|
||||
|
||||
- Documentation version: v3.15.0
|
||||
- Last updated: 2026-05-16
|
||||
- Applicable to CC Switch v3.15.0+
|
||||
- Documentation version: v3.16.0
|
||||
- Last updated: 2026-05-29
|
||||
- Applicable to CC Switch v3.16.0+
|
||||
|
||||
### v3.15.0 Highlights
|
||||
### v3.16.0 Highlights
|
||||
|
||||
- **First-class Claude Desktop panel**: supports third-party providers, direct / model mapping modes, Copilot / Codex OAuth reuse, and 3P profile writing. See [2.6 Claude Desktop](./2-providers/2.6-claude-desktop.md)
|
||||
- **Role-based model mapping**: adapts Claude Desktop model validation with Sonnet / Opus / Haiku routes and `supports1m`
|
||||
- **Claude Desktop local routing**: provides a local gateway at `127.0.0.1:15721/claude-desktop` for providers that need conversion
|
||||
- **Codex Chat Completions routing**: route Chat-only providers such as DeepSeek, Kimi, GLM, and MiniMax through Codex. See [2.1 Add Provider](./2-providers/2.1-add.md)
|
||||
- **Managed CLI tool lifecycle**: install, update, update all, and diagnose Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes from Settings / About. See [1.5 Personalization](./1-getting-started/1.5-settings.md)
|
||||
- **Provider and model refresh**: new partner presets, refreshed default models and pricing, Claude Opus 4.8 defaults, and GPT 5.5 defaults where applicable
|
||||
- **Routing support badges**: Claude Code / Codex provider cards indicate whether a provider can be served through Local Routing
|
||||
- **Codex OAuth live model discovery**: ChatGPT Codex providers fetch available models from the ChatGPT backend on demand
|
||||
- **Filter-driven Usage Hero**: shows cache-normalized real total tokens and cache hit rate, updating with date / provider / model filters — see [4.4 Usage Statistics](./4-proxy/4.4-usage.md)
|
||||
@@ -124,7 +124,7 @@ CC Switch User Manual
|
||||
- **Per-App Tray Submenus**: Claude / Codex / Gemini submenus show the current provider and available usage summaries — see [2.2 Switch Provider](./2-providers/2.2-switch.md)
|
||||
- **Skills Discovery & Batch Updates**: SHA-256 update detection, batch updates, skills.sh public registry search — see [3.3 Skills Management](./3-extensions/3.3-skills.md)
|
||||
- **Full URL Endpoint Mode**: Advanced option to treat `base_url` as the full upstream endpoint — see [2.1 Add Provider](./2-providers/2.1-add.md)
|
||||
- **OpenCode / OpenClaw Stream Check Coverage**: Stream Check covers Claude / Codex / Gemini / OpenCode / OpenClaw — see [4.5 Model Test](./4-proxy/4.5-model-test.md)
|
||||
- **OpenCode / OpenClaw / Hermes Stream Check Coverage**: Stream Check covers Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes — see [4.5 Model Test](./4-proxy/4.5-model-test.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## CC Switch とは
|
||||
|
||||
CC Switch はクロスプラットフォームのデスクトップアプリケーションで、AI プログラミングツールを使用する開発者向けに設計されています。**Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw**、**Hermes** などの管理対象アプリの設定を統一的に管理できます。
|
||||
CC Switch はクロスプラットフォームのデスクトップアプリケーションで、AI ツールを使用する開発者向けに設計されています。**Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw**、**Hermes** などの管理対象アプリの設定を統一的に管理できます。
|
||||
|
||||
## どのような問題を解決するか
|
||||
|
||||
@@ -45,7 +45,7 @@ CC Switch は統一されたインターフェースでこれらの問題を解
|
||||
| **Codex** | OpenAI のコード生成ツール |
|
||||
| **Gemini CLI** | Google の AI コマンドラインツール |
|
||||
| **OpenCode** | オープンソース AI プログラミングターミナルツール |
|
||||
| **OpenClaw** | オープンソース AI プログラミングアシスタント(マルチプロバイダーゲートウェイ) |
|
||||
| **OpenClaw** | オープンソース AI アシスタント(マルチプロバイダーゲートウェイ) |
|
||||
| **Hermes** | Hermes Agent のプロバイダー、MCP、Skills、Memory 管理 |
|
||||
|
||||
## 対応プラットフォーム
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| ② | 設定ボタン | 設定ページを開く(ショートカット `Cmd/Ctrl + ,`) |
|
||||
| ③ | プロキシスイッチ | ローカルプロキシサービスの起動/停止 |
|
||||
| ④ | アプリ切り替え | Claude / Claude Desktop / Codex / Gemini / OpenCode / OpenClaw / Hermes を切り替え |
|
||||
| ⑤ | 機能エリア | Skills / Prompts / MCP の入口 |
|
||||
| ⑤ | 機能エリア | 現在のアプリで利用できる機能入口 |
|
||||
| ⑥ | 追加ボタン | 新しいプロバイダーを追加 |
|
||||
|
||||
### アプリ切り替え
|
||||
@@ -33,9 +33,9 @@
|
||||
|
||||
| ボタン | 機能 | 表示条件 |
|
||||
|------|------|----------|
|
||||
| Skills | スキル拡張管理 | 常に表示 |
|
||||
| Prompts | システムプロンプト管理 | 常に表示 |
|
||||
| MCP | MCP サーバー管理 | 常に表示 |
|
||||
| Skills | スキル拡張管理 | Claude / Codex / Gemini / OpenCode / Hermes |
|
||||
| Prompts | システムプロンプト管理 | Claude / Codex / Gemini / OpenCode |
|
||||
| MCP | MCP サーバー管理 | Claude / Codex / Gemini / OpenCode / Hermes |
|
||||
|
||||
## プロバイダーカード
|
||||
|
||||
@@ -113,11 +113,12 @@ CC Switch はシステムトレイにアイコンを表示し、クイック操
|
||||
|
||||
### 多言語対応
|
||||
|
||||
トレイメニューは 3 つの言語に対応し、設定に応じて自動的に切り替わります:
|
||||
トレイメニューは 4 つの言語に対応し、設定に応じて自動的に切り替わります:
|
||||
|
||||
| 言語 | メインウィンドウを開く | 終了 |
|
||||
|------|-----------|------|
|
||||
| 中文 | 打开主界面 | 退出 |
|
||||
| 簡体中文 | 打开主界面 | 退出 |
|
||||
| 繁體中文 | 開啟主介面 | 退出 |
|
||||
| English | Open main window | Quit |
|
||||
| 日本語 | メインウィンドウを開く | 終了 |
|
||||
|
||||
@@ -147,7 +148,7 @@ CC Switch はシステムトレイにアイコンを表示し、クイック操
|
||||
|
||||
### 一般タブ
|
||||
|
||||
- 言語設定(中文/English/日本語)
|
||||
- 言語設定(簡体中文/繁體中文/English/日本語)
|
||||
- テーマ設定(システムに合わせる/ライト/ダーク)
|
||||
- ウィンドウ動作(起動時に自動実行、閉じる動作)
|
||||
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
|
||||
## 言語設定
|
||||
|
||||
CC Switch は 3 つの言語に対応しています:
|
||||
CC Switch は 4 つの言語に対応しています:
|
||||
|
||||
| 言語 | 説明 |
|
||||
| -------- | -------- |
|
||||
| 簡体中文 | デフォルト言語 |
|
||||
| 繁體中文 | 繁体字中国語インターフェース |
|
||||
| English | 英語インターフェース |
|
||||
| 日本語 | 日本語インターフェース |
|
||||
|
||||
@@ -124,8 +125,9 @@ CC Switch 自体のデータの保存場所で、デフォルトは `~/.cc-switc
|
||||
| Claude ディレクトリ | `~/.claude/` | Claude Code 設定ディレクトリ |
|
||||
| Codex ディレクトリ | `~/.codex/` | Codex 設定ディレクトリ |
|
||||
| Gemini ディレクトリ | `~/.gemini/` | Gemini CLI 設定ディレクトリ |
|
||||
| OpenCode ディレクトリ | `~/.opencode/` | OpenCode 設定ディレクトリ |
|
||||
| OpenCode ディレクトリ | `~/.config/opencode/` | OpenCode 設定ディレクトリ |
|
||||
| OpenClaw ディレクトリ | `~/.openclaw/` | OpenClaw 設定ディレクトリ |
|
||||
| Hermes ディレクトリ | `~/.hermes/` | Hermes 設定ディレクトリ |
|
||||
|
||||
> **注意**:ディレクトリを変更した後はアプリの再起動が必要で、対応する CLI ツールも同じディレクトリを設定する必要があります。
|
||||
|
||||
@@ -133,14 +135,15 @@ CC Switch 自体のデータの保存場所で、デフォルトは `~/.cc-switc
|
||||
|
||||
### 設定のエクスポート
|
||||
|
||||
「エクスポート」ボタンをクリックして、以下の内容を含むバックアップファイルを保存します:
|
||||
「エクスポート」ボタンをクリックして、以下の内容を含む SQL バックアップファイルを保存します:
|
||||
|
||||
- すべてのプロバイダー設定
|
||||
- MCP サーバー設定
|
||||
- Prompts プリセット
|
||||
- 使用量ログ
|
||||
- アプリ設定
|
||||
|
||||
バックアップファイルは JSON 形式で、テキストエディタで確認できます。
|
||||
エクスポートされるファイル名の形式は `cc-switch-export-{timestamp}.sql` です。
|
||||
|
||||
### 設定のインポート
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ Codex プリセットは上流プロトコルにより 2 種類に分かれま
|
||||
|----------|------|
|
||||
| DeepSeek | DeepSeek モデル |
|
||||
| Zhipu GLM / GLM en | Zhipu AI の GLM モデル |
|
||||
| Kimi | Moonshot Kimi モデル |
|
||||
| Kimi / Kimi For Coding | Moonshot Kimi モデル |
|
||||
| MiniMax / MiniMax en | MiniMax モデル |
|
||||
| StepFun / StepFun en | StepFun Step モデル |
|
||||
| Baidu Qianfan Coding Plan | 百度千帆コーディングプラン |
|
||||
|
||||
@@ -103,15 +103,16 @@ SSE プロトコルでサーバーと通信し、リアルタイムプッシュ
|
||||
| Claude | Claude Code に同期 | `~/.claude.json` の `mcpServers` |
|
||||
| Codex | Codex に同期 | `~/.codex/config.toml` の `[mcp_servers]` |
|
||||
| Gemini | Gemini CLI に同期 | `~/.gemini/settings.json` の `mcpServers` |
|
||||
| OpenCode | OpenCode に同期 | `~/.opencode/config.json` の `mcpServers` |
|
||||
| OpenCode | OpenCode に同期 | `~/.config/opencode/opencode.json` の `mcp` |
|
||||
| Hermes | Hermes に同期 | `~/.hermes/config.yaml` の `mcp_servers` |
|
||||
|
||||
> **注意**:OpenClaw は現在 MCP サーバー管理に対応していません。MCP 機能は現在 Claude、Codex、Gemini、OpenCode の 4 つのアプリのみサポートしています。
|
||||
> **注意**:OpenClaw と Claude Desktop は現在 CC Switch MCP 同期に対応していません。MCP 機能は Claude、Codex、Gemini、OpenCode、Hermes に対応しています。
|
||||
|
||||
### スイッチの動作
|
||||
|
||||
あるアプリのスイッチをオンにすると、CC Switch は以下を実行します:
|
||||
|
||||
1. **データベースの更新**:サーバーの `apps.claude/codex/gemini/opencode` のステータスを `true` に設定
|
||||
1. **データベースの更新**:サーバーの `apps.claude/codex/gemini/opencode/hermes` のステータスを `true` に設定
|
||||
2. **Live 設定に同期**:サーバー設定を対応アプリの設定ファイルに書き込み
|
||||
3. **即時反映**:次回 CLI ツール起動時に新しい MCP サーバーが自動的にロード
|
||||
|
||||
@@ -128,7 +129,8 @@ MCP サーバーの同期は、対応アプリがインストールされてい
|
||||
- **Claude**:`~/.claude/` ディレクトリまたは `~/.claude.json` ファイルが存在する必要あり
|
||||
- **Codex**:`~/.codex/` ディレクトリが存在する必要あり
|
||||
- **Gemini**:`~/.gemini/` ディレクトリが存在する必要あり
|
||||
- **OpenCode**:`~/.opencode/` ディレクトリが存在する必要あり
|
||||
- **OpenCode**:`~/.config/opencode/` ディレクトリが存在する必要あり
|
||||
- **Hermes**:`~/.hermes/` ディレクトリが存在する必要あり
|
||||
|
||||
> **ヒント**:CLI ツールがインストールされていない場合、対応するスイッチをオンにしてもエラーにはなりませんが、設定は書き込まれません。
|
||||
|
||||
@@ -154,7 +156,7 @@ MCP サーバーの同期は、対応アプリがインストールされてい
|
||||
CLI ツールで既に MCP サーバーを設定している場合、CC Switch にインポートできます:
|
||||
|
||||
1. 「インポート」ボタンをクリック
|
||||
2. インポートするアプリを選択(Claude/Codex/Gemini/OpenCode)
|
||||
2. インポートするアプリを選択(Claude/Codex/Gemini/OpenCode/Hermes)
|
||||
3. CC Switch が既存の設定を読み取ってインポート
|
||||
|
||||
## 設定ファイル形式
|
||||
|
||||
@@ -81,8 +81,7 @@ CC Switch を使用すると:
|
||||
| Claude | `~/.claude/CLAUDE.md` |
|
||||
| Codex | `~/.codex/AGENTS.md` |
|
||||
| Gemini | `~/.gemini/GEMINI.md` |
|
||||
| OpenCode | `~/.opencode/AGENTS.md` |
|
||||
| OpenClaw | `~/.openclaw/AGENTS.md` |
|
||||
| OpenCode | `~/.config/opencode/AGENTS.md` |
|
||||
|
||||
## プリセットの編集
|
||||
|
||||
@@ -141,7 +140,6 @@ Prompts はアプリごとに個別に管理されます:
|
||||
- Codex に切り替えると、Codex のプリセットが表示
|
||||
- Gemini に切り替えると、Gemini のプリセットが表示
|
||||
- OpenCode に切り替えると、OpenCode のプリセットが表示
|
||||
- OpenClaw に切り替えると、OpenClaw のプリセットが表示
|
||||
|
||||
複数のアプリで同じプロンプトを使用する場合は、それぞれで作成する必要があります。
|
||||
|
||||
|
||||
@@ -12,18 +12,17 @@ Skills は再利用可能な機能拡張で、AI ツールに特定分野の専
|
||||
|
||||
## 対応アプリ
|
||||
|
||||
Skills 機能は以下の 4 つのアプリに対応しています:
|
||||
Skills 機能は以下の 5 つのアプリに対応しています:
|
||||
|
||||
- **Claude Code**
|
||||
- **Codex**
|
||||
- **Gemini CLI**
|
||||
- **OpenCode**
|
||||
- **Hermes**
|
||||
|
||||
## Skills ページを開く
|
||||
|
||||
上部ナビゲーションバーの **Skills** ボタンをクリックします。
|
||||
|
||||
> 注意:Skills ボタンはすべてのアプリモードで表示されます。
|
||||
選択中のアプリが Skills に対応している場合、上部ナビゲーションバーの **Skills** ボタンをクリックします。
|
||||
|
||||
## ページ概要
|
||||
|
||||
@@ -93,7 +92,8 @@ CC Switch は強力な検索とフィルタリング機能を提供していま
|
||||
| Claude | `~/.claude/skills/` |
|
||||
| Codex | `~/.codex/skills/` |
|
||||
| Gemini | `~/.gemini/skills/` |
|
||||
| OpenCode | `~/.opencode/skills/` |
|
||||
| OpenCode | `~/.config/opencode/skills/` |
|
||||
| Hermes | `~/.hermes/skills/` |
|
||||
|
||||
### インストール内容
|
||||
|
||||
@@ -119,7 +119,7 @@ CC Switch は強力な検索とフィルタリング機能を提供していま
|
||||
### アンインストールの効果
|
||||
|
||||
- **自動バックアップ**:削除前にスキルが `~/.cc-switch/skill-backups/` にバックアップされる
|
||||
- すべてのアプリディレクトリ(Claude、Codex、Gemini、OpenCode)からスキルを削除
|
||||
- すべてのアプリディレクトリ(Claude、Codex、Gemini、OpenCode、Hermes)からスキルを削除
|
||||
- SSOT ディレクトリ(`~/.cc-switch/skills/`)からスキルを削除
|
||||
- データベースからスキルレコードを削除
|
||||
|
||||
|
||||
@@ -215,9 +215,11 @@ Token 使用量の変化を表示:
|
||||
|
||||
料金を照合する前に、CC Switch はリクエスト内のモデル ID を正規化します:
|
||||
|
||||
- 最後の `/` より前の接頭辞を削除
|
||||
- `:` 以降の接尾辞を削除
|
||||
- 最後の `/` より前の接頭辞を削除し、小文字に変換
|
||||
- `:` 以降の接尾辞を削除し、末尾の `[1m]` を削除
|
||||
- `@` を `-` に置換
|
||||
- 一般的なラッパー接頭辞、バージョン接尾辞、日付接尾辞(`-YYYY-MM-DD`、`-YYYYMMDD`)を削除
|
||||
- 一部のモデルファミリーでは、短い ID からバージョン付き価格エントリに照合できます
|
||||
|
||||
料金設定では、リクエスト内の完全な元のモデル名ではなく、正規化後のモデル ID を入力してください。
|
||||
|
||||
@@ -226,6 +228,10 @@ Token 使用量の変化を表示:
|
||||
| `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` | `@` を `-` に置換 |
|
||||
| `OpenAI/GPT-5.5-2026-05-14` | `gpt-5.5` | 接頭辞と日付接尾辞を削除 |
|
||||
| `anthropic/claude-opus-4.8` | `claude-opus-4-8` | 接頭辞を削除し、ドット形式に照合 |
|
||||
| `global.anthropic.claude-opus-4-8-v1:0` | `claude-opus-4-8` | ラッパー接頭辞、バージョン接尾辞、`:` 以降を削除 |
|
||||
| `claude-haiku-4-5` | `claude-haiku-4-5-20251001` | 短い ID からバージョン付き価格に照合 |
|
||||
|
||||
### 操作
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
├── settings.json # デバイスレベルの設定
|
||||
├── skills/ # スキル SSOT ディレクトリ
|
||||
├── skill-backups/ # スキルバックアップ(アンインストール時に作成)
|
||||
└── db_backup_*.db # データベースバックアップ
|
||||
└── backups/ # データベースバックアップ
|
||||
```
|
||||
|
||||
### データベースの内容
|
||||
@@ -51,7 +51,8 @@
|
||||
"codexConfigDir": null,
|
||||
"geminiConfigDir": null,
|
||||
"opencodeConfigDir": null,
|
||||
"openclawConfigDir": null
|
||||
"openclawConfigDir": null,
|
||||
"hermesConfigDir": null
|
||||
}
|
||||
```
|
||||
|
||||
@@ -196,17 +197,41 @@ GEMINI_MODEL=gemini-pro
|
||||
|
||||
### 設定ディレクトリ
|
||||
|
||||
デフォルト:`~/.opencode/`
|
||||
デフォルト:`~/.config/opencode/`
|
||||
|
||||
### 主要ファイル
|
||||
|
||||
```
|
||||
~/.opencode/
|
||||
├── config.json # メイン設定ファイル
|
||||
~/.config/opencode/
|
||||
├── opencode.json # メイン設定ファイル
|
||||
├── AGENTS.md # システムプロンプト
|
||||
└── skills/ # スキルディレクトリ
|
||||
└── ...
|
||||
```
|
||||
## Hermes の設定
|
||||
|
||||
### 設定ディレクトリ
|
||||
|
||||
デフォルト:`~/.hermes/`
|
||||
|
||||
### 主要ファイル
|
||||
|
||||
```
|
||||
~/.hermes/
|
||||
├── config.yaml # メイン設定、プロバイダー、MCP 設定
|
||||
├── .env # API キーとシークレット
|
||||
├── SOUL.md # Profile identity/persona
|
||||
├── memories/
|
||||
│ ├── MEMORY.md # エージェント記憶
|
||||
│ └── USER.md # ユーザープロファイル記憶
|
||||
├── skills/ # 有効なスキルディレクトリ
|
||||
├── state.db # SQLite セッションデータベース
|
||||
└── sessions/ # Gateway transcript と任意の JSON snapshot
|
||||
```
|
||||
|
||||
### config.yaml
|
||||
|
||||
Hermes は YAML 設定を使用します。CC Switch は MCP サーバーを `mcp_servers` に書き込み、編集可能なプロバイダーエントリを `custom_providers` に書き込み、Hermes の `providers` dict にある読み取り専用エントリを読み取り、プロバイダー切り替え時に `model.provider` / `model.default` を更新します。
|
||||
|
||||
## OpenClaw の設定
|
||||
|
||||
@@ -219,7 +244,6 @@ GEMINI_MODEL=gemini-pro
|
||||
```
|
||||
~/.openclaw/
|
||||
├── openclaw.json # メイン設定ファイル(JSON5 形式)
|
||||
├── AGENTS.md # システムプロンプト
|
||||
└── skills/ # スキルディレクトリ
|
||||
└── ...
|
||||
```
|
||||
@@ -246,18 +270,17 @@ OpenClaw は JSON5 形式の設定ファイルを使用し、主に以下のセ
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: "sk-..."
|
||||
},
|
||||
// Agent デフォルトモデル設定
|
||||
// Agent デフォルト設定
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "provider/model"
|
||||
}
|
||||
},
|
||||
workspace: "~/.openclaw/workspace"
|
||||
}
|
||||
},
|
||||
// ツール設定
|
||||
tools: {},
|
||||
// ワークスペースファイル設定
|
||||
workspace: {}
|
||||
tools: {}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -267,7 +290,7 @@ OpenClaw は JSON5 形式の設定ファイルを使用し、主に以下のセ
|
||||
| `env` | 環境変数設定 |
|
||||
| `agents.defaults` | Agent デフォルトモデル設定 |
|
||||
| `tools` | ツール設定 |
|
||||
| `workspace` | ワークスペースファイル管理 |
|
||||
| `agents.defaults.workspace` | ワークスペースディレクトリパス |
|
||||
|
||||
## 設定の優先順位
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ chmod +x CC-Switch-*.AppImage
|
||||
- バージョンの非互換性
|
||||
|
||||
**解決方法**:
|
||||
1. ファイルが CC Switch からエクスポートされた JSON ファイルであることを確認
|
||||
1. ファイルが CC Switch からエクスポートされた SQL バックアップファイルであることを確認
|
||||
2. ファイル内容が完全であるか確認
|
||||
3. テキストエディタで開いてフォーマットを確認
|
||||
|
||||
|
||||
@@ -106,15 +106,15 @@ CC Switch ユーザーマニュアル
|
||||
|
||||
## バージョン情報
|
||||
|
||||
- ドキュメントバージョン:v3.15.0
|
||||
- 最終更新:2026-05-16
|
||||
- CC Switch v3.15.0+ 対応
|
||||
- ドキュメントバージョン:v3.16.0
|
||||
- 最終更新:2026-05-29
|
||||
- CC Switch v3.16.0+ 対応
|
||||
|
||||
### v3.15.0 の注目機能
|
||||
### v3.16.0 の注目機能
|
||||
|
||||
- **Claude Desktop の一等管理パネル**:サードパーティプロバイダー、直結 / モデルマッピングの 2 モード、Copilot / Codex OAuth 再利用、3P profile 書き込みに対応 — 詳細は [2.6 Claude Desktop](./2-providers/2.6-claude-desktop.md)
|
||||
- **役割別モデルマッピング**:Sonnet / Opus / Haiku ルートと `supports1m` フラグで Claude Desktop のモデル検証に対応
|
||||
- **Claude Desktop ローカルルーティング**:変換が必要なプロバイダー向けに `127.0.0.1:15721/claude-desktop` のローカルゲートウェイを提供
|
||||
- **Codex Chat Completions ルーティング**:DeepSeek、Kimi、GLM、MiniMax など Chat 専用プロバイダーを Codex で利用可能 — 詳細は [2.1 プロバイダーの追加](./2-providers/2.1-add.md)
|
||||
- **管理対象 CLI ツールのライフサイクル**:設定 / About で Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes のインストール、更新、一括更新、診断に対応 — 詳細は [1.5 個人設定](./1-getting-started/1.5-settings.md)
|
||||
- **プロバイダーとモデルマトリクス更新**:提携プリセットを追加し、既定モデルと価格表を更新。Claude Opus は 4.8、該当する GPT 既定値は 5.5 に更新
|
||||
- **ルーティング対応バッジ**:Claude Code / Codex のプロバイダーカードで Local Routing 対応可否を確認可能
|
||||
- **Codex OAuth ライブモデル検出**:ChatGPT Codex 系プロバイダーは必要に応じて ChatGPT バックエンドから利用可能モデルを取得
|
||||
- **フィルター連動 Usage Hero**:キャッシュ正規化後の実消費 Token とキャッシュヒット率を表示し、日付 / プロバイダー / モデルフィルターに追従 — 詳細は [4.4 使用量統計](./4-proxy/4.4-usage.md)
|
||||
@@ -124,7 +124,7 @@ CC Switch ユーザーマニュアル
|
||||
- **アプリ別トレイサブメニュー**:Claude / Codex / Gemini のサブメニューで現在のプロバイダーと使用量サマリーを確認可能 — 詳細は [2.2 プロバイダーの切り替え](./2-providers/2.2-switch.md)
|
||||
- **Skills の発見と一括更新**:SHA-256 ハッシュによる更新検出、一括更新、skills.sh 公式レジストリ検索 — 詳細は [3.3 Skills スキル管理](./3-extensions/3.3-skills.md)
|
||||
- **完全URLエンドポイントモード**:高度なオプションで `base_url` を完全なアップストリームエンドポイントとして扱う — 詳細は [2.1 プロバイダーの追加](./2-providers/2.1-add.md)
|
||||
- **OpenCode / OpenClaw ストリームチェック対応**:Stream Check は Claude / Codex / Gemini / OpenCode / OpenClaw をカバー — 詳細は [4.5 モデルテスト](./4-proxy/4.5-model-test.md)
|
||||
- **OpenCode / OpenClaw / Hermes ストリームチェック対応**:Stream Check は Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes をカバー — 詳細は [4.5 モデルテスト](./4-proxy/4.5-model-test.md)
|
||||
|
||||
## コントリビュート
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 什么是 CC Switch
|
||||
|
||||
CC Switch 是一款跨平台桌面应用,专为使用 AI 编程工具的开发者设计。它帮助你统一管理 **Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw** 和 **Hermes** 等受管应用的配置。
|
||||
CC Switch 是一款跨平台桌面应用,专为使用 AI 工具的开发者设计。它帮助你统一管理 **Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw** 和 **Hermes** 等受管应用的配置。
|
||||
|
||||
## 解决什么问题
|
||||
|
||||
@@ -45,7 +45,7 @@ CC Switch 通过统一的界面解决这些问题。
|
||||
| **Codex** | OpenAI 的代码生成工具 |
|
||||
| **Gemini CLI** | Google 的 AI 命令行工具 |
|
||||
| **OpenCode** | 开源 AI 编程终端工具 |
|
||||
| **OpenClaw** | 开源 AI 编程助手(多供应商网关) |
|
||||
| **OpenClaw** | 开源 AI 助手(多供应商网关) |
|
||||
| **Hermes** | Hermes Agent,支持供应商、MCP、Skills 和 Memory 管理 |
|
||||
|
||||
## 支持的平台
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| ② | 设置按钮 | 打开设置页面(快捷键 `Cmd/Ctrl + ,`) |
|
||||
| ③ | 代理开关 | 启动/停止本地代理服务 |
|
||||
| ④ | 应用切换器 | 切换 Claude / Claude Desktop / Codex / Gemini / OpenCode / OpenClaw / Hermes |
|
||||
| ⑤ | 功能区 | Skills / Prompts / MCP 入口 |
|
||||
| ⑤ | 功能区 | 当前应用支持的功能入口 |
|
||||
| ⑥ | 添加按钮 | 添加新供应商 |
|
||||
|
||||
### 应用切换器
|
||||
@@ -33,9 +33,9 @@
|
||||
|
||||
| 按钮 | 功能 | 可见条件 |
|
||||
|------|------|----------|
|
||||
| Skills | 技能扩展管理 | 始终可见 |
|
||||
| Prompts | 系统提示词管理 | 始终可见 |
|
||||
| MCP | MCP 服务器管理 | 始终可见 |
|
||||
| Skills | 技能扩展管理 | Claude / Codex / Gemini / OpenCode / Hermes |
|
||||
| Prompts | 系统提示词管理 | Claude / Codex / Gemini / OpenCode |
|
||||
| MCP | MCP 服务器管理 | Claude / Codex / Gemini / OpenCode / Hermes |
|
||||
|
||||
## 供应商卡片
|
||||
|
||||
@@ -113,11 +113,12 @@ CC Switch 在系统托盘显示图标,提供快速操作入口。
|
||||
|
||||
### 多语言支持
|
||||
|
||||
托盘菜单支持三种语言,根据设置自动切换:
|
||||
托盘菜单支持四种语言,根据设置自动切换:
|
||||
|
||||
| 语言 | 打开主界面 | 退出 |
|
||||
|------|-----------|------|
|
||||
| 中文 | 打开主界面 | 退出 |
|
||||
| 简体中文 | 打开主界面 | 退出 |
|
||||
| 繁體中文 | 開啟主介面 | 退出 |
|
||||
| English | Open main window | Quit |
|
||||
| 日本語 | メインウィンドウを開く | 終了 |
|
||||
|
||||
@@ -147,7 +148,7 @@ CC Switch 在系统托盘显示图标,提供快速操作入口。
|
||||
|
||||
### 通用 Tab
|
||||
|
||||
- 语言设置(中文/English/日本語)
|
||||
- 语言设置(简体中文/繁體中文/English/日本語)
|
||||
- 主题设置(跟随系统/浅色/深色)
|
||||
- 窗口行为(开机自启、关闭行为)
|
||||
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
|
||||
## 语言设置
|
||||
|
||||
CC Switch 支持三种语言:
|
||||
CC Switch 支持四种语言:
|
||||
|
||||
| 语言 | 说明 |
|
||||
| -------- | -------- |
|
||||
| 简体中文 | 默认语言 |
|
||||
| 繁體中文 | 繁体中文界面 |
|
||||
| English | 英文界面 |
|
||||
| 日本語 | 日文界面 |
|
||||
|
||||
@@ -124,8 +125,9 @@ CC Switch 自身数据的存储位置,默认为 `~/.cc-switch/`。
|
||||
| Claude 目录 | `~/.claude/` | Claude Code 配置目录 |
|
||||
| Codex 目录 | `~/.codex/` | Codex 配置目录 |
|
||||
| Gemini 目录 | `~/.gemini/` | Gemini CLI 配置目录 |
|
||||
| OpenCode 目录 | `~/.opencode/` | OpenCode 配置目录 |
|
||||
| OpenCode 目录 | `~/.config/opencode/` | OpenCode 配置目录 |
|
||||
| OpenClaw 目录 | `~/.openclaw/` | OpenClaw 配置目录 |
|
||||
| Hermes 目录 | `~/.hermes/` | Hermes 配置目录 |
|
||||
|
||||
> ⚠️ **注意**:修改目录后需要重启应用,且对应的 CLI 工具也需要配置相同的目录。
|
||||
|
||||
@@ -133,14 +135,15 @@ CC Switch 自身数据的存储位置,默认为 `~/.cc-switch/`。
|
||||
|
||||
### 导出配置
|
||||
|
||||
点击「导出」按钮,保存包含以下内容的备份文件:
|
||||
点击「导出」按钮,保存包含以下内容的 SQL 备份文件:
|
||||
|
||||
- 所有供应商配置
|
||||
- MCP 服务器配置
|
||||
- Prompts 预设
|
||||
- 用量日志
|
||||
- 应用设置
|
||||
|
||||
备份文件格式为 JSON,可以用文本编辑器查看。
|
||||
导出文件名格式为 `cc-switch-export-{timestamp}.sql`。
|
||||
|
||||
### 导入配置
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ Codex 预设按上游协议分两类。
|
||||
|----------|------|
|
||||
| DeepSeek | DeepSeek 模型 |
|
||||
| 智谱 GLM / GLM en | 智谱 AI 的 GLM 模型 |
|
||||
| Kimi | Moonshot Kimi 模型 |
|
||||
| Kimi / Kimi For Coding | Moonshot Kimi 模型 |
|
||||
| MiniMax / MiniMax en | MiniMax 模型 |
|
||||
| StepFun / StepFun en | 阶跃星辰 Step 模型 |
|
||||
| 百度千帆 Coding Plan | 百度千帆编程套餐 |
|
||||
|
||||
@@ -103,15 +103,16 @@ MCP (Model Context Protocol) 是一种协议,允许 AI 工具访问外部数
|
||||
| Claude | 同步到 Claude Code | `~/.claude.json` 的 `mcpServers` |
|
||||
| Codex | 同步到 Codex | `~/.codex/config.toml` 的 `[mcp_servers]` |
|
||||
| Gemini | 同步到 Gemini CLI | `~/.gemini/settings.json` 的 `mcpServers` |
|
||||
| OpenCode | 同步到 OpenCode | `~/.opencode/config.json` 的 `mcpServers` |
|
||||
| OpenCode | 同步到 OpenCode | `~/.config/opencode/opencode.json` 的 `mcp` |
|
||||
| Hermes | 同步到 Hermes | `~/.hermes/config.yaml` 的 `mcp_servers` |
|
||||
|
||||
> ⚠️ **注意**:OpenClaw 暂不支持 MCP 服务器管理。MCP 功能目前仅支持 Claude、Codex、Gemini 和 OpenCode 四个应用。
|
||||
> ⚠️ **注意**:OpenClaw 和 Claude Desktop 暂不支持 CC Switch MCP 同步。MCP 功能支持 Claude、Codex、Gemini、OpenCode 和 Hermes。
|
||||
|
||||
### 开关实现机制
|
||||
|
||||
当开启某个应用的开关时,CC Switch 会:
|
||||
|
||||
1. **更新数据库**:将服务器的 `apps.claude/codex/gemini/opencode` 状态设为 `true`
|
||||
1. **更新数据库**:将服务器的 `apps.claude/codex/gemini/opencode/hermes` 状态设为 `true`
|
||||
2. **同步到 Live 配置**:将服务器配置写入对应应用的配置文件
|
||||
3. **即时生效**:下次启动 CLI 工具时自动加载新的 MCP 服务器
|
||||
|
||||
@@ -128,7 +129,8 @@ MCP 服务器同步仅在对应应用已安装时执行:
|
||||
- **Claude**:需存在 `~/.claude/` 目录或 `~/.claude.json` 文件
|
||||
- **Codex**:需存在 `~/.codex/` 目录
|
||||
- **Gemini**:需存在 `~/.gemini/` 目录
|
||||
- **OpenCode**:需存在 `~/.opencode/` 目录
|
||||
- **OpenCode**:需存在 `~/.config/opencode/` 目录
|
||||
- **Hermes**:需存在 `~/.hermes/` 目录
|
||||
|
||||
> 💡 **提示**:如果某个 CLI 工具未安装,开启对应开关不会报错,但配置不会写入。
|
||||
|
||||
@@ -154,7 +156,7 @@ MCP 服务器同步仅在对应应用已安装时执行:
|
||||
如果你已经在 CLI 工具中配置了 MCP 服务器,可以导入到 CC Switch:
|
||||
|
||||
1. 点击「导入」按钮
|
||||
2. 选择要导入的应用(Claude/Codex/Gemini/OpenCode)
|
||||
2. 选择要导入的应用(Claude/Codex/Gemini/OpenCode/Hermes)
|
||||
3. CC Switch 会读取现有配置并导入
|
||||
|
||||
## 配置文件格式
|
||||
|
||||
@@ -81,8 +81,7 @@ Prompts 功能用于管理系统提示词预设。系统提示词会影响 AI
|
||||
| Claude | `~/.claude/CLAUDE.md` |
|
||||
| Codex | `~/.codex/AGENTS.md` |
|
||||
| Gemini | `~/.gemini/GEMINI.md` |
|
||||
| OpenCode | `~/.opencode/AGENTS.md` |
|
||||
| OpenClaw | `~/.openclaw/AGENTS.md` |
|
||||
| OpenCode | `~/.config/opencode/AGENTS.md` |
|
||||
|
||||
## 编辑预设
|
||||
|
||||
@@ -141,7 +140,6 @@ Prompts 是按应用分开管理的:
|
||||
- 切换到 Codex 时,显示 Codex 的预设
|
||||
- 切换到 Gemini 时,显示 Gemini 的预设
|
||||
- 切换到 OpenCode 时,显示 OpenCode 的预设
|
||||
- 切换到 OpenClaw 时,显示 OpenClaw 的预设
|
||||
|
||||
如需在多个应用使用相同的提示词,需要分别创建。
|
||||
|
||||
|
||||
@@ -12,18 +12,17 @@ Skills 是可复用的能力扩展,让 AI 工具获得特定领域的专业能
|
||||
|
||||
## 支持的应用
|
||||
|
||||
Skills 功能支持所有四种应用:
|
||||
Skills 功能支持五种应用:
|
||||
|
||||
- **Claude Code**
|
||||
- **Codex**
|
||||
- **Gemini CLI**
|
||||
- **OpenCode**
|
||||
- **Hermes**
|
||||
|
||||
## 打开 Skills 页面
|
||||
|
||||
点击顶部导航栏的 **Skills** 按钮。
|
||||
|
||||
> 注意:Skills 按钮在所有应用模式下均可见。
|
||||
当当前应用支持 Skills 时,点击顶部导航栏的 **Skills** 按钮。
|
||||
|
||||
## 页面概览
|
||||
|
||||
@@ -93,7 +92,8 @@ CC Switch 提供强大的搜索和过滤功能:
|
||||
| Claude | `~/.claude/skills/` |
|
||||
| Codex | `~/.codex/skills/` |
|
||||
| Gemini | `~/.gemini/skills/` |
|
||||
| OpenCode | `~/.opencode/skills/` |
|
||||
| OpenCode | `~/.config/opencode/skills/` |
|
||||
| Hermes | `~/.hermes/skills/` |
|
||||
|
||||
### 安装内容
|
||||
|
||||
@@ -119,7 +119,7 @@ CC Switch 提供强大的搜索和过滤功能:
|
||||
### 卸载效果
|
||||
|
||||
- **自动备份**:删除前,技能会被备份到 `~/.cc-switch/skill-backups/`
|
||||
- 从所有应用目录(Claude、Codex、Gemini、OpenCode)移除技能
|
||||
- 从所有应用目录(Claude、Codex、Gemini、OpenCode、Hermes)移除技能
|
||||
- 从 SSOT 目录(`~/.cc-switch/skills/`)移除技能
|
||||
- 从数据库删除技能记录
|
||||
|
||||
|
||||
@@ -215,9 +215,11 @@ v3.15.0 起,用量页顶部改为筛选驱动的 Hero 卡。切换日期范围
|
||||
|
||||
在匹配定价前,CC Switch 会先对请求中的模型 ID 做标准化处理:
|
||||
|
||||
- 去掉最后一个 `/` 之前的前缀
|
||||
- 去掉 `:` 之后的后缀
|
||||
- 去掉最后一个 `/` 之前的前缀,并转成小写
|
||||
- 去掉 `:` 之后的后缀,去掉末尾的 `[1m]`
|
||||
- 将 `@` 替换为 `-`
|
||||
- 去掉常见包装前缀、版本后缀、日期后缀(`-YYYY-MM-DD`、`-YYYYMMDD`)
|
||||
- 部分模型族支持短 ID 匹配带版本的定价项
|
||||
|
||||
因此,在定价配置中请填写清洗后的模型 ID,而不是请求里的完整原始模型名。
|
||||
|
||||
@@ -226,6 +228,10 @@ v3.15.0 起,用量页顶部改为筛选驱动的 Hero 卡。切换日期范围
|
||||
| `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` | 将 `@` 替换为 `-` |
|
||||
| `OpenAI/GPT-5.5-2026-05-14` | `gpt-5.5` | 去掉前缀和日期后缀 |
|
||||
| `anthropic/claude-opus-4.8` | `claude-opus-4-8` | 去掉前缀并匹配点号格式 |
|
||||
| `global.anthropic.claude-opus-4-8-v1:0` | `claude-opus-4-8` | 去掉包装前缀、版本后缀和 `:` 后缀 |
|
||||
| `claude-haiku-4-5` | `claude-haiku-4-5-20251001` | 短 ID 匹配带版本定价 |
|
||||
|
||||
### 操作
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
├── settings.json # 设备级设置
|
||||
├── skills/ # 技能 SSOT 目录
|
||||
├── skill-backups/ # 技能备份(卸载时创建)
|
||||
└── db_backup_*.db # 数据库备份
|
||||
└── backups/ # 数据库备份
|
||||
```
|
||||
|
||||
### 数据库内容
|
||||
@@ -51,7 +51,8 @@
|
||||
"codexConfigDir": null,
|
||||
"geminiConfigDir": null,
|
||||
"opencodeConfigDir": null,
|
||||
"openclawConfigDir": null
|
||||
"openclawConfigDir": null,
|
||||
"hermesConfigDir": null
|
||||
}
|
||||
```
|
||||
|
||||
@@ -196,17 +197,41 @@ GEMINI_MODEL=gemini-pro
|
||||
|
||||
### 配置目录
|
||||
|
||||
默认:`~/.opencode/`
|
||||
默认:`~/.config/opencode/`
|
||||
|
||||
### 主要文件
|
||||
|
||||
```
|
||||
~/.opencode/
|
||||
├── config.json # 主配置文件
|
||||
~/.config/opencode/
|
||||
├── opencode.json # 主配置文件
|
||||
├── AGENTS.md # 系统提示词
|
||||
└── skills/ # 技能目录
|
||||
└── ...
|
||||
```
|
||||
## Hermes 配置
|
||||
|
||||
### 配置目录
|
||||
|
||||
默认:`~/.hermes/`
|
||||
|
||||
### 主要文件
|
||||
|
||||
```
|
||||
~/.hermes/
|
||||
├── config.yaml # 主设置、供应商与 MCP 配置
|
||||
├── .env # API keys 与 secrets
|
||||
├── SOUL.md # Profile 身份/人格
|
||||
├── memories/
|
||||
│ ├── MEMORY.md # Agent 记忆
|
||||
│ └── USER.md # 用户画像记忆
|
||||
├── skills/ # 活跃技能目录
|
||||
├── state.db # SQLite 会话数据库
|
||||
└── sessions/ # Gateway 转录与可选 JSON 快照
|
||||
```
|
||||
|
||||
### config.yaml
|
||||
|
||||
Hermes 使用 YAML 配置。CC Switch 将 MCP 服务器写入 `mcp_servers`,将可编辑的供应商条目写入 `custom_providers`,读取 Hermes `providers` 字典中的只读条目,并在切换供应商时更新 `model.provider` / `model.default`。
|
||||
|
||||
## OpenClaw 配置
|
||||
|
||||
@@ -219,7 +244,6 @@ GEMINI_MODEL=gemini-pro
|
||||
```
|
||||
~/.openclaw/
|
||||
├── openclaw.json # 主配置文件(JSON5 格式)
|
||||
├── AGENTS.md # 系统提示词
|
||||
└── skills/ # 技能目录
|
||||
└── ...
|
||||
```
|
||||
@@ -246,18 +270,17 @@ OpenClaw 使用 JSON5 格式配置文件,主要包含以下部分:
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: "sk-..."
|
||||
},
|
||||
// Agent 默认模型配置
|
||||
// Agent 默认配置
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "provider/model"
|
||||
}
|
||||
},
|
||||
workspace: "~/.openclaw/workspace"
|
||||
}
|
||||
},
|
||||
// 工具配置
|
||||
tools: {},
|
||||
// 工作区文件配置
|
||||
workspace: {}
|
||||
tools: {}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -267,7 +290,7 @@ OpenClaw 使用 JSON5 格式配置文件,主要包含以下部分:
|
||||
| `env` | 环境变量配置 |
|
||||
| `agents.defaults` | Agent 默认模型设置 |
|
||||
| `tools` | 工具配置 |
|
||||
| `workspace` | 工作区文件管理 |
|
||||
| `agents.defaults.workspace` | 工作区目录路径 |
|
||||
|
||||
## 配置优先级
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ chmod +x CC-Switch-*.AppImage
|
||||
- 版本不兼容
|
||||
|
||||
**解决方法**:
|
||||
1. 确认文件是 CC Switch 导出的 JSON 文件
|
||||
1. 确认文件是 CC Switch 导出的 SQL 备份文件
|
||||
2. 检查文件内容是否完整
|
||||
3. 尝试用文本编辑器打开检查格式
|
||||
|
||||
|
||||
@@ -106,15 +106,15 @@
|
||||
|
||||
## 版本信息
|
||||
|
||||
- 文档版本:v3.15.0
|
||||
- 最后更新:2026-05-16
|
||||
- 适用于 CC Switch v3.15.0+
|
||||
- 文档版本:v3.16.0
|
||||
- 最后更新:2026-05-29
|
||||
- 适用于 CC Switch v3.16.0+
|
||||
|
||||
### v3.15.0 亮点
|
||||
### v3.16.0 亮点
|
||||
|
||||
- **Claude Desktop 一等管理面板**:支持第三方供应商、直连 / 模型映射两种模式、Copilot / Codex OAuth 复用与 3P profile 写入 — 详见 [2.6 Claude Desktop](./2-providers/2.6-claude-desktop.md)
|
||||
- **按角色的模型映射**:用 Sonnet / Opus / Haiku 路由和 `supports1m` 标志适配 Claude Desktop 的模型校验
|
||||
- **Claude Desktop 本地路由**:通过 `127.0.0.1:15721/claude-desktop` 为需要转换的供应商提供本地网关
|
||||
- **Codex Chat Completions 路由**:DeepSeek、Kimi、GLM、MiniMax 等仅支持 Chat 协议的供应商可通过 Codex 使用 — 详见 [2.1 添加供应商](./2-providers/2.1-add.md)
|
||||
- **托管 CLI 工具生命周期**:在设置 / 关于页安装、升级、全部升级并诊断 Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes — 详见 [1.5 个性化配置](./1-getting-started/1.5-settings.md)
|
||||
- **供应商与模型矩阵刷新**:新增合作方预设,刷新默认模型与计费矩阵,Claude Opus 默认升级到 4.8,适用场景下 GPT 默认升级到 5.5
|
||||
- **路由支持徽章**:Claude Code / Codex 供应商卡片会标明是否支持 Local Routing,便于选择可代理的供应商
|
||||
- **Codex OAuth 实时模型发现**:ChatGPT Codex 类供应商按需从 ChatGPT 后端拉取最新模型列表
|
||||
- **用量看板筛选驱动 Hero**:展示缓存归一化后的真实总 token 与缓存命中率,并跟随日期 / 供应商 / 模型筛选实时更新 — 详见 [4.4 用量统计](./4-proxy/4.4-usage.md)
|
||||
@@ -124,7 +124,7 @@
|
||||
- **托盘按应用分级菜单**:Claude / Codex / Gemini 独立子菜单,标题展示当前供应商与可用用量摘要 — 详见 [2.2 切换供应商](./2-providers/2.2-switch.md)
|
||||
- **Skills 发现与批量更新**:SHA-256 更新检测、批量更新、skills.sh 公共注册表搜索 — 详见 [3.3 Skills 技能管理](./3-extensions/3.3-skills.md)
|
||||
- **完整 URL 端点模式**:高级选项支持将 base_url 视作完整上游端点 — 详见 [2.1 添加供应商](./2-providers/2.1-add.md)
|
||||
- **OpenCode / OpenClaw 流式检测覆盖**:Stream Check 面板覆盖 Claude / Codex / Gemini / OpenCode / OpenClaw — 详见 [4.5 模型检查](./4-proxy/4.5-model-test.md)
|
||||
- **OpenCode / OpenClaw / Hermes 流式检测覆盖**:Stream Check 面板覆盖 Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes — 详见 [4.5 模型检查](./4-proxy/4.5-model-test.md)
|
||||
|
||||
## 贡献
|
||||
|
||||
|
||||
@@ -1,597 +0,0 @@
|
||||
# CC-Switch "工作目录" 功能 — 实施方案
|
||||
|
||||
## Context
|
||||
|
||||
CC-Switch 管理 5 个 CLI 工具(Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw)的供应商、MCP 服务器、Skills、提示词配置。当前所有启用状态是全局的——用户在不同项目间切换时需要手动 toggle。
|
||||
|
||||
本功能允许用户注册多个工作目录(项目文件夹),切换目录时自动保存/恢复各实体的启用状态。**不做数据隔离**——所有实体共享全局池,仅 "谁是激活的" 按目录区分。
|
||||
|
||||
---
|
||||
|
||||
## 一、需要按目录区分的实体(完整清单)
|
||||
|
||||
| 实体 | 当前状态字段 | 存储方式 | 需要区分? | 理由 |
|
||||
|------|-------------|---------|-----------|------|
|
||||
| **Provider** | `is_current` | per `(id, app_type)` | **YES** | 不同项目用不同供应商 |
|
||||
| **Provider (Failover)** | `in_failover_queue` | per `(id, app_type)` | **YES** | 备用供应商队列跟随主供应商配置 |
|
||||
| **MCP Server** | `enabled_claude/codex/gemini/opencode` | per `id`, 4列 | **YES** | 不同项目需要不同 MCP 工具 |
|
||||
| **Skill** | `enabled_claude/codex/gemini/opencode` | per `id`, 4列 | **YES** | 不同项目需要不同 Skills |
|
||||
| **Prompt** | `enabled` | per `(id, app_type)`, 单选 | **YES** | 不同项目用不同系统提示词 |
|
||||
| Proxy Config | `enabled`, thresholds | per `app_type` | NO | 基础设施级别,非项目相关 |
|
||||
| Settings | key-value | flat table | NO | 全局用户偏好 |
|
||||
| Provider Health | failures, errors | runtime | **CLEAR** | 切换时清除,重新计算 |
|
||||
| Common Config | `common_config_{app}` | settings table | NO | 全局模板,非项目相关 |
|
||||
| Usage/Logs | historical | various tables | NO | 历史数据,不应分区 |
|
||||
|
||||
> 原计划遗漏了 **Failover Queue** 和 **Provider Health 清除**。
|
||||
|
||||
---
|
||||
|
||||
## 二、数据库变更(Schema v8 → v9)
|
||||
|
||||
### 新增 5 张表
|
||||
|
||||
```sql
|
||||
-- 1. 工作目录注册表
|
||||
CREATE TABLE IF NOT EXISTS working_directories (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
name TEXT,
|
||||
is_current BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- 2. Provider 状态快照 (is_current + in_failover_queue)
|
||||
-- 每个目录保存所有 provider 的两个状态标志
|
||||
CREATE TABLE IF NOT EXISTS dir_provider_state (
|
||||
dir_id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
is_current BOOLEAN NOT NULL DEFAULT 0,
|
||||
in_failover_queue BOOLEAN NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (dir_id, app_type, provider_id)
|
||||
);
|
||||
|
||||
-- 3. MCP 启用状态快照 (直接镜像 4 列,不做行展开)
|
||||
CREATE TABLE IF NOT EXISTS dir_mcp_state (
|
||||
dir_id TEXT NOT NULL,
|
||||
mcp_id TEXT NOT NULL,
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (dir_id, mcp_id)
|
||||
);
|
||||
|
||||
-- 4. Skill 启用状态快照 (直接镜像 4 列)
|
||||
CREATE TABLE IF NOT EXISTS dir_skill_state (
|
||||
dir_id TEXT NOT NULL,
|
||||
skill_id TEXT NOT NULL,
|
||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
|
||||
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (dir_id, skill_id)
|
||||
);
|
||||
|
||||
-- 5. Prompt 启用状态快照 (每个 app_type 只存激活的 prompt_id)
|
||||
CREATE TABLE IF NOT EXISTS dir_prompt_state (
|
||||
dir_id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
prompt_id TEXT NOT NULL,
|
||||
PRIMARY KEY (dir_id, app_type)
|
||||
);
|
||||
```
|
||||
|
||||
### 设计决策说明
|
||||
|
||||
**MCP/Skill 用 4 列镜像而非 `(entity_id, app_type, enabled)` 行展开**:
|
||||
- 与主表 `mcp_servers` / `skills` 结构一致,snapshot/apply 代码直接 copy 4 列
|
||||
- 避免 4 倍行膨胀(每个 MCP 服务器 1 行 vs 4 行)
|
||||
- 未来增加新 app 时,两边同步加列即可
|
||||
|
||||
**Prompt 只存 `(dir_id, app_type, prompt_id)`**:
|
||||
- 每个 app_type 最多一个 enabled prompt,不需要存 boolean
|
||||
- 无记录 = 该 app 无激活 prompt
|
||||
|
||||
**Provider 合并 `is_current` + `in_failover_queue`**:
|
||||
- 两个标志都是 per `(app_type, provider_id)` 的状态
|
||||
- 存在同一表中避免多表 JOIN
|
||||
|
||||
### 迁移脚本
|
||||
|
||||
在 `schema.rs` 中:
|
||||
- `create_tables_on_conn()` 添加 5 个 CREATE TABLE
|
||||
- 新增 `migrate_v8_to_v9(conn)`: 创建 5 张表 + 插入 `__default__` 行
|
||||
- `SCHEMA_VERSION` 升至 9
|
||||
- 迁移循环添加 `7 => ...` 后加 `8 => { Self::migrate_v8_to_v9(conn)?; Self::set_user_version(conn, 9)?; }`
|
||||
|
||||
```rust
|
||||
fn migrate_v8_to_v9(conn: &Connection) -> Result<(), AppError> {
|
||||
// 创建 5 张表(使用 IF NOT EXISTS,幂等)
|
||||
// ...
|
||||
// 插入 __default__ 虚拟目录,代表"全局默认"状态
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO working_directories (id, path, name, is_current, created_at) \
|
||||
VALUES ('__default__', '__default__', NULL, 0, ?1)",
|
||||
[crate::database::get_unix_timestamp()?],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、后端实现
|
||||
|
||||
### 3.1 DAO 层 — `src-tauri/src/database/dao/working_dir.rs`
|
||||
|
||||
所有方法都是 `impl Database` 块,遵循现有 DAO 模式。
|
||||
|
||||
**关键方法签名**(需要 `_on_conn` 变体支持事务):
|
||||
|
||||
```rust
|
||||
// ═══ 工作目录 CRUD ═══
|
||||
pub fn list_working_directories(&self) -> Result<Vec<WorkingDirectory>, AppError>
|
||||
pub fn add_working_directory(&self, id: &str, path: &str, name: Option<&str>) -> Result<(), AppError>
|
||||
pub fn delete_working_directory(&self, id: &str) -> Result<(), AppError>
|
||||
pub fn rename_working_directory(&self, id: &str, name: &str) -> Result<(), AppError>
|
||||
pub fn get_current_working_directory(&self) -> Result<Option<WorkingDirectory>, AppError>
|
||||
|
||||
// 使用 _on_conn 变体,在 Service 层的事务中调用
|
||||
fn set_current_working_directory_on_conn(conn: &Connection, id: &str) -> Result<(), AppError>
|
||||
|
||||
// ═══ 快照写入 ═══ (都有 _on_conn 变体)
|
||||
fn snapshot_providers_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError>
|
||||
fn snapshot_mcp_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError>
|
||||
fn snapshot_skills_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError>
|
||||
fn snapshot_prompts_on_conn(conn: &Connection, dir_id: &str) -> Result<(), AppError>
|
||||
|
||||
// ═══ 快照恢复 ═══ (都有 _on_conn 变体, 返回 bool = 是否有快照)
|
||||
fn apply_provider_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result<bool, AppError>
|
||||
fn apply_mcp_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result<bool, AppError>
|
||||
fn apply_skill_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result<bool, AppError>
|
||||
fn apply_prompt_snapshot_on_conn(conn: &Connection, dir_id: &str) -> Result<bool, AppError>
|
||||
```
|
||||
|
||||
**snapshot_providers 实现思路**:
|
||||
```sql
|
||||
-- 先清除旧快照
|
||||
DELETE FROM dir_provider_state WHERE dir_id = ?1;
|
||||
-- 从主表复制当前状态
|
||||
INSERT INTO dir_provider_state (dir_id, app_type, provider_id, is_current, in_failover_queue)
|
||||
SELECT ?1, app_type, id, is_current, in_failover_queue
|
||||
FROM providers
|
||||
WHERE is_current = 1 OR in_failover_queue = 1;
|
||||
```
|
||||
|
||||
**apply_provider_snapshot 实现思路**:
|
||||
```sql
|
||||
-- 检查是否有快照
|
||||
SELECT COUNT(*) FROM dir_provider_state WHERE dir_id = ?1; -- 如果 0,返回 false
|
||||
|
||||
-- 在事务中:先清除主表所有 is_current 和 in_failover_queue
|
||||
UPDATE providers SET is_current = 0;
|
||||
UPDATE providers SET in_failover_queue = 0;
|
||||
|
||||
-- 从快照恢复
|
||||
UPDATE providers SET is_current = 1
|
||||
WHERE (id, app_type) IN (SELECT provider_id, app_type FROM dir_provider_state WHERE dir_id = ?1 AND is_current = 1);
|
||||
|
||||
UPDATE providers SET in_failover_queue = 1
|
||||
WHERE (id, app_type) IN (SELECT provider_id, app_type FROM dir_provider_state WHERE dir_id = ?1 AND in_failover_queue = 1);
|
||||
```
|
||||
|
||||
**snapshot_mcp / snapshot_skills 实现思路**(直接镜像 4 列):
|
||||
```sql
|
||||
DELETE FROM dir_mcp_state WHERE dir_id = ?1;
|
||||
INSERT INTO dir_mcp_state (dir_id, mcp_id, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode)
|
||||
SELECT ?1, id, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
|
||||
FROM mcp_servers;
|
||||
```
|
||||
|
||||
**apply_mcp_snapshot 实现思路**:
|
||||
```sql
|
||||
-- 先全部禁用
|
||||
UPDATE mcp_servers SET enabled_claude = 0, enabled_codex = 0, enabled_gemini = 0, enabled_opencode = 0;
|
||||
|
||||
-- 从快照恢复
|
||||
UPDATE mcp_servers SET
|
||||
enabled_claude = (SELECT enabled_claude FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id),
|
||||
enabled_codex = (SELECT enabled_codex FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id),
|
||||
enabled_gemini = (SELECT enabled_gemini FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id),
|
||||
enabled_opencode = (SELECT enabled_opencode FROM dir_mcp_state WHERE dir_id = ?1 AND mcp_id = mcp_servers.id)
|
||||
WHERE id IN (SELECT mcp_id FROM dir_mcp_state WHERE dir_id = ?1);
|
||||
```
|
||||
|
||||
### 3.2 Service 层 — `src-tauri/src/services/working_dir.rs`
|
||||
|
||||
```rust
|
||||
use crate::store::AppState;
|
||||
use crate::error::AppError;
|
||||
use crate::database::lock_conn;
|
||||
use crate::app_config::AppType;
|
||||
use crate::services::{McpService, ProviderService, SkillService};
|
||||
use crate::config::write_text_file;
|
||||
use crate::prompt_files::prompt_file_path;
|
||||
|
||||
pub struct WorkingDirService;
|
||||
|
||||
impl WorkingDirService {
|
||||
/// 核心切换逻辑
|
||||
pub fn switch(state: &AppState, target_dir_id: &str) -> Result<(), AppError> {
|
||||
// ═══ 前置检查 ═══
|
||||
// 1. 检查代理接管状态,若活跃则拒绝切换
|
||||
// 使用 db.is_live_takeover_active() 或同步检查 proxy_config.live_takeover_active
|
||||
// (因为 ProxyService::is_running() 是 async,而此函数是 sync)
|
||||
Self::check_proxy_not_active(state)?;
|
||||
|
||||
// ═══ Phase 1: 回填 Prompt ═══
|
||||
// 在 snapshot 之前,将 live 文件内容回填到当前 enabled prompt
|
||||
// 这样即使用户手动编辑了 live 文件,内容也不会丢失
|
||||
Self::backfill_prompt_content(state)?;
|
||||
|
||||
// ═══ Phase 2: 数据库操作(事务) ═══
|
||||
{
|
||||
let conn = lock_conn!(state.db.conn);
|
||||
conn.execute("BEGIN IMMEDIATE", [])?;
|
||||
|
||||
let result = (|| -> Result<(), AppError> {
|
||||
// 获取当前工作目录
|
||||
let current = Self::get_current_dir_id_on_conn(&conn)?;
|
||||
|
||||
// 保存当前状态到旧目录
|
||||
if let Some(old_id) = ¤t {
|
||||
Database::snapshot_providers_on_conn(&conn, old_id)?;
|
||||
Database::snapshot_mcp_on_conn(&conn, old_id)?;
|
||||
Database::snapshot_skills_on_conn(&conn, old_id)?;
|
||||
Database::snapshot_prompts_on_conn(&conn, old_id)?;
|
||||
} else {
|
||||
// 无当前目录 = 全局模式,保存到 __default__
|
||||
Database::snapshot_providers_on_conn(&conn, "__default__")?;
|
||||
Database::snapshot_mcp_on_conn(&conn, "__default__")?;
|
||||
Database::snapshot_skills_on_conn(&conn, "__default__")?;
|
||||
Database::snapshot_prompts_on_conn(&conn, "__default__")?;
|
||||
}
|
||||
|
||||
// 加载目标目录快照(如果有的话)
|
||||
// 如果无快照(首次进入),保持主表不变
|
||||
Database::apply_provider_snapshot_on_conn(&conn, target_dir_id)?;
|
||||
Database::apply_mcp_snapshot_on_conn(&conn, target_dir_id)?;
|
||||
Database::apply_skill_snapshot_on_conn(&conn, target_dir_id)?;
|
||||
Database::apply_prompt_snapshot_on_conn(&conn, target_dir_id)?;
|
||||
|
||||
// 更新 is_current 标记
|
||||
Database::set_current_working_directory_on_conn(&conn, target_dir_id)?;
|
||||
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(()) => conn.execute("COMMIT", [])?,
|
||||
Err(e) => {
|
||||
let _ = conn.execute("ROLLBACK", []);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
// conn 锁在此处释放
|
||||
|
||||
// ═══ Phase 3: 同步 live 配置文件 ═══
|
||||
Self::sync_all_live(state)?;
|
||||
|
||||
// ═══ Phase 4: 清除 Provider Health ═══
|
||||
state.db.clear_all_provider_health()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 回填 live prompt 文件内容到 DB(切换前调用)
|
||||
fn backfill_prompt_content(state: &AppState) -> Result<(), AppError> {
|
||||
for app in AppType::all() {
|
||||
let path = prompt_file_path(&app)?;
|
||||
if !path.exists() { continue; }
|
||||
let live_content = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
if live_content.trim().is_empty() { continue; }
|
||||
|
||||
let mut prompts = state.db.get_prompts(app.as_str())?;
|
||||
if let Some((_, prompt)) = prompts.iter_mut().find(|(_, p)| p.enabled) {
|
||||
prompt.content = live_content;
|
||||
prompt.updated_at = Some(get_unix_timestamp()?);
|
||||
state.db.save_prompt(app.as_str(), prompt)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将 DB 中的 enabled prompt 内容写入 live 文件(切换后调用)
|
||||
/// 注意:不做回填!只写入。区别于 PromptService::enable_prompt()
|
||||
fn write_prompts_to_live(state: &AppState) -> Result<(), AppError> {
|
||||
for app in AppType::all() {
|
||||
let path = prompt_file_path(&app)?;
|
||||
let prompts = state.db.get_prompts(app.as_str())?;
|
||||
if let Some(prompt) = prompts.values().find(|p| p.enabled) {
|
||||
write_text_file(&path, &prompt.content)?;
|
||||
}
|
||||
// 无 enabled prompt 时不清空文件(保留现状)
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 同步所有 live 配置(Provider + MCP + Skill + Prompt)
|
||||
fn sync_all_live(state: &AppState) -> Result<(), AppError> {
|
||||
// 1. Provider → live files
|
||||
ProviderService::sync_current_to_live(state)?;
|
||||
// sync_current_to_live 内部已调用 McpService::sync_all_enabled()
|
||||
|
||||
// 2. Skills → app dirs (循环每个 app)
|
||||
for app in AppType::all() {
|
||||
let _ = SkillService::sync_to_app(&state.db, &app);
|
||||
}
|
||||
|
||||
// 3. Prompts → live files
|
||||
Self::write_prompts_to_live(state)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查代理是否活跃(同步检查数据库标志)
|
||||
fn check_proxy_not_active(state: &AppState) -> Result<(), AppError> {
|
||||
// 检查 proxy_config 表中 live_takeover_active 列
|
||||
// 如果有任何 app 的 live_takeover_active = 1,拒绝切换
|
||||
let conn = lock_conn!(state.db.conn);
|
||||
let active: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM proxy_config WHERE live_takeover_active = 1)",
|
||||
[], |r| r.get(0)
|
||||
).unwrap_or(false);
|
||||
|
||||
if active {
|
||||
return Err(AppError::Message(
|
||||
"代理接管模式运行中,请先停止代理再切换工作目录".into()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Command 层 — `src-tauri/src/commands/working_dir.rs`
|
||||
|
||||
遵循现有模式:`State<'_, AppState>` + `Result<T, String>` + `.map_err(|e| e.to_string())`。
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub fn list_working_directories(state: State<'_, AppState>) -> Result<Vec<WorkingDirectory>, String>
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_working_directory(state: State<'_, AppState>, path: String, name: Option<String>) -> Result<WorkingDirectory, String>
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_working_directory(state: State<'_, AppState>, id: String) -> Result<(), String>
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rename_working_directory(state: State<'_, AppState>, id: String, name: String) -> Result<(), String>
|
||||
|
||||
#[tauri::command]
|
||||
pub fn switch_working_directory(state: State<'_, AppState>, id: String) -> Result<(), String>
|
||||
// 调用 WorkingDirService::switch()
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_current_working_directory(state: State<'_, AppState>) -> Result<Option<WorkingDirectory>, String>
|
||||
```
|
||||
|
||||
### 3.4 需修改的现有文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `src-tauri/src/database/schema.rs` | 添加 5 个 CREATE TABLE + `migrate_v8_to_v9()` |
|
||||
| `src-tauri/src/database/mod.rs` | `SCHEMA_VERSION = 9` + 迁移循环加 `8 => ...` + `pub mod working_dir` in dao |
|
||||
| `src-tauri/src/database/dao/mod.rs` | 添加 `pub mod working_dir;` |
|
||||
| `src-tauri/src/services/mod.rs` | 添加 `pub mod working_dir;` + `pub use working_dir::WorkingDirService;` |
|
||||
| `src-tauri/src/commands/mod.rs` | 添加 `mod working_dir;` + `pub use working_dir::*;` |
|
||||
| `src-tauri/src/lib.rs` | invoke_handler 注册 6 个新命令 |
|
||||
|
||||
### 3.5 可能需要新增的 DAO 辅助方法
|
||||
|
||||
`src-tauri/src/database/dao/failover.rs`:
|
||||
```rust
|
||||
/// 清除所有 provider_health 记录(切换目录时调用)
|
||||
pub fn clear_all_provider_health(&self) -> Result<(), AppError>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、前端实现
|
||||
|
||||
### 4.1 API — `src/lib/api/workingDir.ts`
|
||||
|
||||
```typescript
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface WorkingDirectory {
|
||||
id: string;
|
||||
path: string;
|
||||
name?: string;
|
||||
isCurrent: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export const workingDirApi = {
|
||||
list: () => invoke<WorkingDirectory[]>("list_working_directories"),
|
||||
add: (path: string, name?: string) =>
|
||||
invoke<WorkingDirectory>("add_working_directory", { path, name }),
|
||||
delete: (id: string) => invoke<void>("delete_working_directory", { id }),
|
||||
rename: (id: string, name: string) =>
|
||||
invoke<void>("rename_working_directory", { id, name }),
|
||||
switch: (id: string) => invoke<void>("switch_working_directory", { id }),
|
||||
getCurrent: () =>
|
||||
invoke<WorkingDirectory | null>("get_current_working_directory"),
|
||||
};
|
||||
```
|
||||
|
||||
### 4.2 组件 — `src/components/WorkingDirSwitcher.tsx`
|
||||
|
||||
**位置**:Header toolbar,靠近 AppSwitcher。
|
||||
|
||||
**功能**:
|
||||
- 下拉菜单显示已注册目录列表
|
||||
- 当前目录高亮
|
||||
- "浏览…" 按钮调用 Tauri 文件夹选择对话框
|
||||
- 右键菜单:重命名、删除
|
||||
- "__default__(全局)" 选项恢复到全局状态
|
||||
- 切换后 invalidate 所有相关 React Query
|
||||
|
||||
**切换后的 Query Invalidation**:
|
||||
```typescript
|
||||
// 需要验证实际的 queryKey 名称
|
||||
queryClient.invalidateQueries({ queryKey: ["providers"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["mcp-servers"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["installed-skills"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["prompts"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["workingDirectories"] });
|
||||
```
|
||||
|
||||
### 4.3 i18n
|
||||
|
||||
三个文件都需更新:
|
||||
- `src/i18n/locales/zh.json`
|
||||
- `src/i18n/locales/en.json`
|
||||
- `src/i18n/locales/ja.json`
|
||||
|
||||
---
|
||||
|
||||
## 五、切换流程时序
|
||||
|
||||
```
|
||||
用户选择目录 B
|
||||
│
|
||||
├── 1. check_proxy_not_active()
|
||||
│ → 如果代理接管中,返回错误,终止
|
||||
│
|
||||
├── 2. backfill_prompt_content()
|
||||
│ → 读 live prompt 文件 → 更新 DB 中已启用 prompt 的 content
|
||||
│ → 保护用户手动编辑的 prompt 不丢失
|
||||
│
|
||||
├── 3. BEGIN TRANSACTION
|
||||
│ ├── snapshot(old_dir / __default__)
|
||||
│ │ ├── providers → dir_provider_state (is_current + in_failover_queue)
|
||||
│ │ ├── mcp_servers → dir_mcp_state (4 列直接复制)
|
||||
│ │ ├── skills → dir_skill_state (4 列直接复制)
|
||||
│ │ └── prompts → dir_prompt_state (enabled prompt_id)
|
||||
│ │
|
||||
│ ├── apply(target_dir)
|
||||
│ │ ├── dir_provider_state → providers
|
||||
│ │ ├── dir_mcp_state → mcp_servers
|
||||
│ │ ├── dir_skill_state → skills
|
||||
│ │ └── dir_prompt_state → prompts
|
||||
│ │
|
||||
│ └── set_current_working_directory(target_dir)
|
||||
│
|
||||
├── COMMIT
|
||||
│
|
||||
├── 4. sync_all_live()
|
||||
│ ├── ProviderService::sync_current_to_live(state)
|
||||
│ │ └── 内部已调用 McpService::sync_all_enabled()
|
||||
│ ├── for app in AppType::all() { SkillService::sync_to_app(&db, &app) }
|
||||
│ └── write_prompts_to_live() ← 无回填,直接写
|
||||
│
|
||||
└── 5. clear_all_provider_health()
|
||||
→ 清除运行时熔断器状态
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、边界情况处理
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|---------|
|
||||
| **首次进入目录(无快照)** | `apply_*_snapshot()` 返回 false,主表保持不变。用户调整后,下次切走时自动保存。 |
|
||||
| **全局模式 → 目录** | 自动将当前状态 snapshot 到 `__default__` 虚拟目录。`__default__` 在 v9 迁移中预创建。 |
|
||||
| **目录 → 全局模式** | 用户选择 `__default__`,恢复全局状态。 |
|
||||
| **新增 MCP/Skill/Provider** | 新实体在 dir_*_state 中无记录。apply 时只更新有记录的实体,新增的保持 DB 默认值。 |
|
||||
| **删除 MCP/Skill/Provider** | dir_*_state 中对应记录在 apply 时找不到主表行,UPDATE 影响 0 行,静默跳过。 |
|
||||
| **删除工作目录** | 级联删除 dir_*_state 中所有 `dir_id` 匹配的行。若为当前目录,回退到 `__default__`。 |
|
||||
| **代理接管中切换** | `check_proxy_not_active()` 检测到 `live_takeover_active = 1`,拒绝切换并提示用户先停止代理。 |
|
||||
| **切换中途崩溃** | 事务保护 DB 操作的原子性。最坏情况:DB 已更新但 live 文件未同步。下次启动可添加恢复检查(Phase 2 优化)。 |
|
||||
| **用户手动编辑了 prompt 文件** | `backfill_prompt_content()` 在切换前读取 live 文件回填到 DB,保护手动修改。 |
|
||||
|
||||
---
|
||||
|
||||
## 七、实施顺序
|
||||
|
||||
### Phase 1: 数据库
|
||||
1. `database/schema.rs` — 5 个 CREATE TABLE + `migrate_v8_to_v9()`
|
||||
2. `database/mod.rs` — `SCHEMA_VERSION = 9` + 迁移分支
|
||||
3. `database/dao/working_dir.rs` — 全部 DAO 方法(`_on_conn` 变体)
|
||||
4. `database/dao/failover.rs` — 新增 `clear_all_provider_health()`
|
||||
5. `database/dao/mod.rs` — 注册模块
|
||||
|
||||
### Phase 2: 服务 + 命令
|
||||
6. `services/working_dir.rs` — `WorkingDirService::switch()` 等
|
||||
7. `commands/working_dir.rs` — 6 个 Tauri 命令
|
||||
8. `services/mod.rs` — 注册模块
|
||||
9. `commands/mod.rs` — 注册模块
|
||||
10. `lib.rs` — invoke_handler 注册
|
||||
|
||||
### Phase 3: 前端
|
||||
11. `src/lib/api/workingDir.ts` — API 封装
|
||||
12. `src/types.ts` — WorkingDirectory 类型
|
||||
13. `src/components/WorkingDirSwitcher.tsx` — UI 组件
|
||||
14. `src/App.tsx` — 集成到 header toolbar
|
||||
15. `src/i18n/locales/{zh,en,ja}.json` — 国际化
|
||||
|
||||
### Phase 4: 优化(可选)
|
||||
16. 启动恢复检查(DB 状态 vs live 文件一致性)
|
||||
17. 托盘菜单显示当前工作目录
|
||||
|
||||
---
|
||||
|
||||
## 八、关键文件索引
|
||||
|
||||
### 新增文件(5 个)
|
||||
- `src-tauri/src/database/dao/working_dir.rs`
|
||||
- `src-tauri/src/services/working_dir.rs`
|
||||
- `src-tauri/src/commands/working_dir.rs`
|
||||
- `src/lib/api/workingDir.ts`
|
||||
- `src/components/WorkingDirSwitcher.tsx`
|
||||
|
||||
### 必须修改的文件(7 个)
|
||||
- `src-tauri/src/database/schema.rs` — CREATE TABLE + 迁移
|
||||
- `src-tauri/src/database/mod.rs` — 版本号 + 迁移循环
|
||||
- `src-tauri/src/database/dao/mod.rs` — 模块注册
|
||||
- `src-tauri/src/database/dao/failover.rs` — clear_all_provider_health
|
||||
- `src-tauri/src/services/mod.rs` — 模块注册
|
||||
- `src-tauri/src/commands/mod.rs` — 模块注册
|
||||
- `src-tauri/src/lib.rs` — invoke_handler
|
||||
|
||||
### 必须修改的前端文件(4 个)
|
||||
- `src/App.tsx` — 集成 WorkingDirSwitcher
|
||||
- `src/types.ts` — WorkingDirectory 接口
|
||||
- `src/i18n/locales/zh.json` — 中文
|
||||
- `src/i18n/locales/en.json` — 英文
|
||||
- `src/i18n/locales/ja.json` — 日文
|
||||
|
||||
### 参考文件(理解现有模式)
|
||||
- `src-tauri/src/services/mcp.rs` — `sync_all_enabled()` (line 165)
|
||||
- `src-tauri/src/services/skill.rs` — `sync_to_app()` (line 1707)
|
||||
- `src-tauri/src/services/provider/mod.rs` — `sync_current_to_live()` (line 1552)
|
||||
- `src-tauri/src/services/prompt.rs` — `enable_prompt()` (line 73) — 理解回填逻辑
|
||||
- `src-tauri/src/prompt_files.rs` — prompt 文件路径
|
||||
- `src-tauri/src/config.rs` — `write_text_file()` (line 176)
|
||||
|
||||
---
|
||||
|
||||
## 九、验证计划
|
||||
|
||||
### 后端验证
|
||||
1. `cargo test` — DAO 层单元测试(使用 `Database::memory()`)
|
||||
- 快照/恢复往返一致性
|
||||
- 新增/删除实体后的 apply 行为
|
||||
- `__default__` 全局状态保护
|
||||
- 事务回滚测试
|
||||
2. 手动测试 — 启动应用,创建两个目录,切换并验证 live 文件变化
|
||||
|
||||
### 前端验证
|
||||
1. `pnpm typecheck` — TypeScript 类型检查
|
||||
2. `pnpm lint` — ESLint 检查
|
||||
3. 手动 UI 测试 — 工作目录切换器交互、query invalidation 后数据刷新
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.16.0",
|
||||
"version": "3.16.3",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -735,7 +735,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.16.0"
|
||||
version = "3.16.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -749,6 +749,7 @@ dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"flate2",
|
||||
"futures",
|
||||
"hmac",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -798,6 +799,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
"webpki-roots 0.26.11",
|
||||
"windows-sys 0.61.2",
|
||||
"winreg 0.52.0",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.16.0"
|
||||
version = "3.16.3"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
@@ -77,6 +77,7 @@ indexmap = { version = "2", features = ["serde"] }
|
||||
rust_decimal = "1.33"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
json5 = "0.4"
|
||||
json-five = "0.3.1"
|
||||
|
||||
@@ -88,6 +89,7 @@ webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.52"
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_UI_Shell"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.5"
|
||||
|
||||
@@ -60,6 +60,16 @@ pub const DEFAULT_PROXY_ROUTES: &[ClaudeDesktopDefaultRoute] = &[
|
||||
env_key: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
supports_1m: true,
|
||||
},
|
||||
// fable 置于末尾:next_catalog_safe_route_id 给非安全品牌 route 借用合法
|
||||
// 角色名时仍按 sonnet→opus→haiku 顺序分配(向后兼容既有 catalog),不会把
|
||||
// 无关品牌模型借用成 fable 顶配档名。UI 行序由前端 ROLE_ORDER 独立控制为
|
||||
// Sonnet/Opus/Fable/Haiku(所有 proxy 路径都经 normalizeProxyRows 重排),
|
||||
// 与此处物理顺序无关。
|
||||
ClaudeDesktopDefaultRoute {
|
||||
route_id: "claude-fable-5",
|
||||
env_key: "ANTHROPIC_DEFAULT_FABLE_MODEL",
|
||||
supports_1m: true,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -238,11 +248,16 @@ pub fn is_claude_safe_model_id(model: &str) -> bool {
|
||||
|
||||
// 角色前缀后必须还有实际模型标识,拒绝 claude-sonnet- 这类退化值
|
||||
// (否则会写入 profile 并触发 Claude Desktop fail-all 拒收整组)。
|
||||
["sonnet-", "opus-", "haiku-"].iter().any(|prefix| {
|
||||
route_tail
|
||||
.strip_prefix(prefix)
|
||||
.is_some_and(|rest| !rest.is_empty())
|
||||
})
|
||||
// Claude Desktop 1.12603.1+ 的 fail-all validator 角色白名单已纳入 fable
|
||||
// (app.asar 内 ["sonnet","opus","haiku","fable","mythos"]),故 claude-fable-*
|
||||
// 可安全写入 profile。mythos 官方未公开发布,暂不暴露给用户。
|
||||
["sonnet-", "opus-", "haiku-", "fable-"]
|
||||
.iter()
|
||||
.any(|prefix| {
|
||||
route_tail
|
||||
.strip_prefix(prefix)
|
||||
.is_some_and(|rest| !rest.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn inference_model_json(spec: &InferenceModelSpec) -> Value {
|
||||
@@ -664,7 +679,7 @@ pub fn model_list_response(provider: &Provider) -> Result<Value, AppError> {
|
||||
}
|
||||
|
||||
pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<Value, AppError> {
|
||||
let requested = body
|
||||
let requested_raw = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
@@ -677,6 +692,7 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
|
||||
"Claude Desktop request is missing the model field",
|
||||
)
|
||||
})?;
|
||||
let requested = strip_one_m_suffix_for_route_lookup(&requested_raw);
|
||||
|
||||
let routes = proxy_model_routes(provider)?;
|
||||
let upstream_model = routes
|
||||
@@ -685,30 +701,43 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
|
||||
.or_else(|| {
|
||||
routes
|
||||
.iter()
|
||||
.find(|r| is_compatible_opus_route_alias(&r.route_id, &requested))
|
||||
.find(|r| is_compatible_opus_route_alias(&r.route_id, requested))
|
||||
})
|
||||
.map(|route| route.upstream_model.clone())
|
||||
.or_else(|| legacy_raw_route_upstream_model(provider, &requested))
|
||||
.or_else(|| legacy_raw_route_upstream_model(provider, requested))
|
||||
.or_else(|| {
|
||||
// 角色关键词回落:Claude Desktop 的部分调用(如子 agent)会请求带发布
|
||||
// 日期后缀的完整官方名(claude-haiku-4-5-20251001),与 manifest 暴露的
|
||||
// 简短 route_id(claude-haiku-4-5)不精确相等。按 opus/haiku/sonnet 归类
|
||||
// 到同档已配置路由,对齐 Claude Code model_mapper 的宽松匹配。
|
||||
// 仅对 Claude Desktop 认可的安全模型名回落(排除 [1m] 标记等非法形式)。
|
||||
if !is_claude_safe_model_id(&requested) {
|
||||
// 简短 route_id(claude-haiku-4-5)不精确相等。按 opus/haiku/fable/sonnet
|
||||
// 归类到同档已配置路由,对齐 Claude Code model_mapper 的宽松匹配。
|
||||
// 匹配前已剥离本地 [1m] 标记;这里仍只对 Claude Desktop 认可的
|
||||
// 安全模型名回落,避免非 Claude route 被误映射。
|
||||
if !is_claude_safe_model_id(requested) {
|
||||
return None;
|
||||
}
|
||||
let role = claude_role_keyword(&requested)?;
|
||||
let role = claude_role_keyword(requested)?;
|
||||
routes
|
||||
.iter()
|
||||
.find(|route| claude_role_keyword(&route.route_id) == Some(role))
|
||||
// 老用户只配了 Sonnet/Opus/Haiku 三档时,fable 请求降级到 opus 档,
|
||||
// 与官方安全分类器的降级方向一致,避免 route_unknown 硬错误。
|
||||
// 用户一旦显式配置 fable 档,上面的精确角色匹配会优先命中。
|
||||
.or_else(|| {
|
||||
(role == "fable")
|
||||
.then(|| {
|
||||
routes
|
||||
.iter()
|
||||
.find(|route| claude_role_keyword(&route.route_id) == Some("opus"))
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
.map(|route| route.upstream_model.clone())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"claude_desktop.provider.route_unknown",
|
||||
format!("Claude Desktop 模型路由未配置: {requested}"),
|
||||
format!("Claude Desktop model route is not configured: {requested}"),
|
||||
format!("Claude Desktop 模型路由未配置: {requested_raw}"),
|
||||
format!("Claude Desktop model route is not configured: {requested_raw}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -719,6 +748,18 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
fn strip_one_m_suffix_for_route_lookup(model: &str) -> &str {
|
||||
let trimmed = model.trim();
|
||||
let marker = ONE_M_CONTEXT_MARKER.as_bytes();
|
||||
let bytes = trimmed.as_bytes();
|
||||
if bytes.len() >= marker.len()
|
||||
&& bytes[bytes.len() - marker.len()..].eq_ignore_ascii_case(marker)
|
||||
{
|
||||
return trimmed[..trimmed.len() - marker.len()].trim_end();
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
|
||||
fn legacy_raw_route_upstream_model(provider: &Provider, requested: &str) -> Option<String> {
|
||||
provider
|
||||
.meta
|
||||
@@ -740,15 +781,17 @@ fn is_compatible_opus_route_alias(route_id: &str, requested: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// 按角色关键词(opus / haiku / sonnet)归类一个 Claude 模型名/route_id。
|
||||
/// 按角色关键词(opus / haiku / fable / sonnet)归类一个 Claude 模型名/route_id。
|
||||
/// 仅在命中明确角色词时返回 Some,未知模型返回 None(不回落,保持精确报错语义)。
|
||||
/// 与前端 `routeRoleFromId` 同序(opus → haiku → sonnet)。
|
||||
/// 与前端 `routeRoleFromId` 同序(opus → haiku → fable → sonnet)。
|
||||
fn claude_role_keyword(model: &str) -> Option<&'static str> {
|
||||
let normalized = model.to_ascii_lowercase();
|
||||
if normalized.contains("opus") {
|
||||
Some("opus")
|
||||
} else if normalized.contains("haiku") {
|
||||
Some("haiku")
|
||||
} else if normalized.contains("fable") {
|
||||
Some("fable")
|
||||
} else if normalized.contains("sonnet") {
|
||||
Some("sonnet")
|
||||
} else {
|
||||
@@ -873,6 +916,11 @@ pub fn proxy_gateway_base_url_from_db(db: &Database) -> Result<String, AppError>
|
||||
// get_proxy_config is async-tagged but its body is fully synchronous (rusqlite
|
||||
// under a Mutex), so block_on cannot deadlock the calling thread.
|
||||
let config = futures::executor::block_on(db.get_proxy_config())?;
|
||||
if config.listen_port == 0 {
|
||||
return Err(AppError::Config(
|
||||
"Claude Desktop 代理地址需要真实监听端口;请先启动本地代理或使用固定端口".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(format!(
|
||||
"{}{}",
|
||||
proxy_origin_from_parts(&config.listen_address, config.listen_port),
|
||||
@@ -1290,6 +1338,12 @@ mod tests {
|
||||
Database::memory().expect("memory db")
|
||||
}
|
||||
|
||||
fn set_proxy_port(db: &Database, port: u16) {
|
||||
let mut config = crate::proxy::types::ProxyConfig::default();
|
||||
config.listen_port = port;
|
||||
futures::executor::block_on(db.update_proxy_config(config)).expect("update proxy config");
|
||||
}
|
||||
|
||||
fn direct_provider(id: &str) -> Provider {
|
||||
let mut provider = Provider::with_id(
|
||||
id.to_string(),
|
||||
@@ -1310,6 +1364,19 @@ mod tests {
|
||||
provider
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_gateway_base_url_rejects_unresolved_ephemeral_port() {
|
||||
let db = test_db();
|
||||
set_proxy_port(&db, 0);
|
||||
|
||||
let err = proxy_gateway_base_url_from_db(&db)
|
||||
.expect_err("unresolved ephemeral port should not produce a :0 URL");
|
||||
assert!(
|
||||
err.to_string().contains("真实监听端口"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
fn official_provider() -> Provider {
|
||||
let mut provider = Provider::with_id(
|
||||
CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID.to_string(),
|
||||
@@ -1612,6 +1679,127 @@ mod tests {
|
||||
assert!(err.to_string().contains("gpt-5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_proxy_maps_fable_to_opus_tier() {
|
||||
// issue #4026/#4049:老用户只配 Sonnet/Opus/Haiku 三档、未显式配置
|
||||
// fable 档时,fable 请求按官方分类器降级方向回落到 opus 档兜底。
|
||||
let mut provider = proxy_provider("proxy");
|
||||
provider
|
||||
.meta
|
||||
.as_mut()
|
||||
.expect("meta")
|
||||
.claude_desktop_model_routes = std::collections::HashMap::from([
|
||||
(
|
||||
"claude-opus-4-8".to_string(),
|
||||
ClaudeDesktopModelRoute {
|
||||
model: "upstream-opus".to_string(),
|
||||
label_override: None,
|
||||
supports_1m: Some(true),
|
||||
},
|
||||
),
|
||||
(
|
||||
"claude-sonnet-4-6".to_string(),
|
||||
ClaudeDesktopModelRoute {
|
||||
model: "upstream-sonnet".to_string(),
|
||||
label_override: None,
|
||||
supports_1m: Some(true),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
let mapped = map_proxy_request_model(
|
||||
json!({"model": "claude-fable-5", "messages": []}),
|
||||
&provider,
|
||||
)
|
||||
.expect("fable should fall back to the opus tier");
|
||||
assert_eq!(mapped["model"], json!("upstream-opus"));
|
||||
|
||||
// 带 [1m] 标记与日期后缀的形态也应命中同一回落。
|
||||
let mapped_one_m = map_proxy_request_model(
|
||||
json!({"model": "claude-fable-5[1m]", "messages": []}),
|
||||
&provider,
|
||||
)
|
||||
.expect("fable with [1m] marker should fall back to the opus tier");
|
||||
assert_eq!(mapped_one_m["model"], json!("upstream-opus"));
|
||||
|
||||
let mapped_dated = map_proxy_request_model(
|
||||
json!({"model": "claude-fable-5-20260609", "messages": []}),
|
||||
&provider,
|
||||
)
|
||||
.expect("dated fable alias should fall back to the opus tier");
|
||||
assert_eq!(mapped_dated["model"], json!("upstream-opus"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_proxy_fable_without_opus_route_still_errors() {
|
||||
// 没有 opus 档可回落时保持精确报错语义,不静默落到其他档。
|
||||
let mut provider = proxy_provider("proxy");
|
||||
provider
|
||||
.meta
|
||||
.as_mut()
|
||||
.expect("meta")
|
||||
.claude_desktop_model_routes = std::collections::HashMap::from([(
|
||||
"claude-sonnet-4-6".to_string(),
|
||||
ClaudeDesktopModelRoute {
|
||||
model: "upstream-sonnet".to_string(),
|
||||
label_override: None,
|
||||
supports_1m: Some(true),
|
||||
},
|
||||
)]);
|
||||
|
||||
let err = map_proxy_request_model(
|
||||
json!({"model": "claude-fable-5", "messages": []}),
|
||||
&provider,
|
||||
)
|
||||
.expect_err("fable without an opus route should fail");
|
||||
assert!(err.to_string().contains("claude-fable-5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_proxy_maps_fable_to_dedicated_route() {
|
||||
// Desktop 1.12603.1+ fail-all 校验已放行 claude-fable-5,用户可显式配置
|
||||
// 独立 fable 档;此时 fable 请求精确命中 fable 档,不再降级到 opus。
|
||||
let mut provider = proxy_provider("proxy");
|
||||
provider
|
||||
.meta
|
||||
.as_mut()
|
||||
.expect("meta")
|
||||
.claude_desktop_model_routes = std::collections::HashMap::from([
|
||||
(
|
||||
"claude-opus-4-8".to_string(),
|
||||
ClaudeDesktopModelRoute {
|
||||
model: "upstream-opus".to_string(),
|
||||
label_override: None,
|
||||
supports_1m: Some(true),
|
||||
},
|
||||
),
|
||||
(
|
||||
"claude-fable-5".to_string(),
|
||||
ClaudeDesktopModelRoute {
|
||||
model: "upstream-fable".to_string(),
|
||||
label_override: None,
|
||||
supports_1m: Some(true),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
// 精确匹配优先命中 fable 档
|
||||
let mapped = map_proxy_request_model(
|
||||
json!({"model": "claude-fable-5", "messages": []}),
|
||||
&provider,
|
||||
)
|
||||
.expect("explicit fable route should match");
|
||||
assert_eq!(mapped["model"], json!("upstream-fable"));
|
||||
|
||||
// 带日期后缀经角色关键词回落仍归 fable 档,而非降级 opus
|
||||
let mapped_dated = map_proxy_request_model(
|
||||
json!({"model": "claude-fable-5-20260609", "messages": []}),
|
||||
&provider,
|
||||
)
|
||||
.expect("dated fable alias should map via fable role keyword");
|
||||
assert_eq!(mapped_dated["model"], json!("upstream-fable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_proxy_accepts_opus_4_7_4_8_alias_during_rollout() {
|
||||
let mut provider = proxy_provider("proxy");
|
||||
@@ -1820,15 +2008,48 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_proxy_rejects_1m_suffix_route() {
|
||||
let provider = proxy_provider("proxy");
|
||||
fn claude_desktop_proxy_strips_1m_suffix_before_route_lookup() {
|
||||
let mut provider = proxy_provider("proxy");
|
||||
provider
|
||||
.meta
|
||||
.as_mut()
|
||||
.expect("meta")
|
||||
.claude_desktop_model_routes = std::collections::HashMap::from([
|
||||
(
|
||||
"claude-sonnet-4-6".to_string(),
|
||||
ClaudeDesktopModelRoute {
|
||||
model: "upstream-sonnet".to_string(),
|
||||
label_override: None,
|
||||
supports_1m: Some(true),
|
||||
},
|
||||
),
|
||||
(
|
||||
"claude-opus-4-8".to_string(),
|
||||
ClaudeDesktopModelRoute {
|
||||
model: "upstream-opus".to_string(),
|
||||
label_override: None,
|
||||
supports_1m: Some(true),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
let err = map_proxy_request_model(
|
||||
let mapped = map_proxy_request_model(
|
||||
json!({"model": "claude-opus-4-8[1m]", "messages": []}),
|
||||
&provider,
|
||||
)
|
||||
.expect("compact 1M suffix should map to Opus route");
|
||||
assert_eq!(mapped["model"], json!("upstream-opus"));
|
||||
|
||||
let mapped = map_proxy_request_model(
|
||||
json!({"model": "claude-sonnet-4-6 [1M]", "messages": []}),
|
||||
&provider,
|
||||
)
|
||||
.expect_err("1M suffix route should not be accepted");
|
||||
assert!(err.to_string().contains("claude-sonnet-4-6 [1M]"));
|
||||
.expect("spaced uppercase 1M suffix should map to Sonnet route");
|
||||
assert_eq!(mapped["model"], json!("upstream-sonnet"));
|
||||
|
||||
let err = map_proxy_request_model(json!({"model": "gpt-5[1m]", "messages": []}), &provider)
|
||||
.expect_err("non-Claude route should still fail after stripping 1M suffix");
|
||||
assert!(err.to_string().contains("gpt-5[1m]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1947,12 +2168,13 @@ mod tests {
|
||||
let direct = direct_provider("direct");
|
||||
assert!(is_compatible_direct_provider(&direct));
|
||||
|
||||
let claude_official = Provider::with_id(
|
||||
let mut claude_official = Provider::with_id(
|
||||
"claude-official".to_string(),
|
||||
"Claude Official".to_string(),
|
||||
json!({"env": {}}),
|
||||
Some("https://www.anthropic.com/claude-code".to_string()),
|
||||
);
|
||||
claude_official.category = Some("official".to_string());
|
||||
assert!(!is_compatible_direct_provider(&claude_official));
|
||||
|
||||
let mut openai_format = direct_provider("openai");
|
||||
|
||||
@@ -10,7 +10,8 @@ use crate::config::{atomic_write, copy_file, get_app_config_dir};
|
||||
use crate::database::{is_official_seed_id, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::settings::{
|
||||
CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration,
|
||||
CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration,
|
||||
CodexThirdPartyHistoryProviderBucketMigration,
|
||||
};
|
||||
use chrono::{Local, Utc};
|
||||
use rusqlite::{backup::Backup, params_from_iter, Connection};
|
||||
@@ -24,7 +25,25 @@ use std::time::{Duration, SystemTime};
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
const MIGRATION_NAME: &str = "codex-history-provider-migration-v1";
|
||||
const OFFICIAL_UNIFY_MIGRATION_NAME: &str = "codex-official-history-unify-v1";
|
||||
/// 还原操作自身的备份目录(与迁移备份分开,保持迁移账本目录纯净)。
|
||||
const OFFICIAL_UNIFY_RESTORE_BACKUP_NAME: &str = "codex-official-history-unify-restore-v1";
|
||||
const CODEX_STATE_DB_FILENAME: &str = "state_5.sqlite";
|
||||
/// SQLite 变量上限保守值,IN 列表按此分块。
|
||||
const STATE_DB_ID_CHUNK: usize = 500;
|
||||
|
||||
/// 串行化官方历史的迁移与还原:开启迁移(启动重试 + 设置保存后台任务)和
|
||||
/// 关闭还原可能在毫秒级先后被触发,对同一批 jsonl / state DB 双向改写。
|
||||
static CODEX_OFFICIAL_HISTORY_OP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn lock_codex_official_history_op() -> std::sync::MutexGuard<'static, ()> {
|
||||
CODEX_OFFICIAL_HISTORY_OP_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
/// Codex 内建默认 provider id:config.toml 没有 `model_provider` 键时会话归入此桶。
|
||||
/// 官方订阅(ChatGPT OAuth / OpenAI API key)的历史会话都记录这个 id。
|
||||
const OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID: &str = "openai";
|
||||
const LEGACY_CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch";
|
||||
// If a Codex preset ever used a temporary routing key, keep that old key here
|
||||
// so local history can be bucketed under the current custom provider id.
|
||||
@@ -120,7 +139,7 @@ pub fn maybe_migrate_codex_third_party_history_provider_bucket(
|
||||
});
|
||||
}
|
||||
|
||||
let backup_root = migration_backup_root();
|
||||
let backup_root = migration_backup_root(MIGRATION_NAME);
|
||||
let codex_dir = get_codex_config_dir();
|
||||
let migrated_jsonl_files =
|
||||
migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root)?;
|
||||
@@ -157,7 +176,7 @@ pub fn maybe_migrate_codex_provider_template_bucket(
|
||||
});
|
||||
}
|
||||
|
||||
let backup_root = migration_backup_root();
|
||||
let backup_root = migration_backup_root(MIGRATION_NAME);
|
||||
let outcome = migrate_codex_provider_templates_to_custom(db, &backup_root)?;
|
||||
crate::settings::mark_codex_provider_template_migrated(CodexProviderTemplateMigration {
|
||||
completed_at: Utc::now().to_rfc3339(),
|
||||
@@ -167,6 +186,475 @@ pub fn maybe_migrate_codex_provider_template_bucket(
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// 统一会话开关的存量迁移:把官方会话(内建 "openai" 桶)迁入共享 "custom" 桶。
|
||||
///
|
||||
/// 仅当用户在开启弹窗里勾选了"迁入既有官方会话"(`unify_codex_migrate_existing`)
|
||||
/// 且本轮未完成时执行;开关关闭时标记与勾选意愿都会被清除(见 `save_settings`),
|
||||
/// 重新开启并再次勾选即可补迁关闭期间产生的官方会话。
|
||||
/// custom 桶里官方与第三方会话无法区分,自动逻辑绝不反向搬回;
|
||||
/// 用户可在关闭开关时选择按备份账本精确还原(见 `restore_codex_official_history_from_backups`)。
|
||||
/// 迁移前 jsonl / state DB 均备份到 `~/.cc-switch/backups/codex-official-history-unify-v1/`。
|
||||
pub fn maybe_migrate_codex_official_history_to_unified_bucket(
|
||||
) -> Result<CodexHistoryProviderBucketMigrationOutcome, AppError> {
|
||||
if !crate::settings::unify_codex_session_history() {
|
||||
return Ok(CodexHistoryProviderBucketMigrationOutcome {
|
||||
skipped_reason: Some("unify_toggle_off".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if !crate::settings::unify_codex_migrate_existing_requested() {
|
||||
return Ok(CodexHistoryProviderBucketMigrationOutcome {
|
||||
skipped_reason: Some("stock_migration_not_requested".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let _op_guard = lock_codex_official_history_op();
|
||||
let codex_dir = get_codex_config_dir();
|
||||
// marker 绑定迁移时的 Codex 目录:切换 codex_config_dir 后旧 marker 不再
|
||||
// 挡住新目录的迁移(迁移幂等,重跑无害)。
|
||||
let codex_dir_key = canonical_dir_string(&codex_dir);
|
||||
if crate::settings::is_codex_official_history_unify_migrated_for_dir(&codex_dir_key) {
|
||||
return Ok(CodexHistoryProviderBucketMigrationOutcome {
|
||||
skipped_reason: Some("already_migrated".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
// live 必须已实际路由到共享 custom 桶才允许迁移:官方配置的注入可能被拒
|
||||
// (已有显式 model_provider / 形态冲突的 custom 表,见
|
||||
// `inject_codex_unified_session_bucket`),代理接管期间的 live 也不带统一
|
||||
// 路由(注入只进备份)。这些状态下新会话仍落 "openai" 桶,迁移只会把
|
||||
// 历史搬进当前 live 看不见的桶里。开关与迁移意愿保持不动,待 live 真正
|
||||
// 统一后(下次切换 / 接管释放后的启动重试)再迁。
|
||||
if !codex_config_text_routes_custom(&read_codex_config_text().unwrap_or_default()) {
|
||||
return Ok(CodexHistoryProviderBucketMigrationOutcome {
|
||||
skipped_reason: Some("live_not_unified".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
let source_provider_ids: BTreeSet<String> =
|
||||
std::iter::once(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID.to_string()).collect();
|
||||
let backup_root = migration_backup_root(OFFICIAL_UNIFY_MIGRATION_NAME);
|
||||
let migrated_jsonl_files =
|
||||
migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root)?;
|
||||
let migrated_state_rows =
|
||||
migrate_codex_state_dbs(&codex_dir, &source_provider_ids, &backup_root)?;
|
||||
// 备份代际记录来源目录,restore 据此只取当前目录的账本。
|
||||
write_backup_generation_meta(&backup_root, &codex_dir_key)?;
|
||||
|
||||
let outcome = CodexHistoryProviderBucketMigrationOutcome {
|
||||
source_provider_ids: source_provider_ids.into_iter().collect(),
|
||||
migrated_jsonl_files,
|
||||
migrated_state_rows,
|
||||
skipped_reason: None,
|
||||
};
|
||||
|
||||
// 条件写入在 settings 写锁内原子完成:"迁移期间开关被关掉"时不写完成标记,
|
||||
// 避免下一次开启被标记挡住而漏迁"关闭期间"新产生的 openai 桶会话。
|
||||
// 与关闭路径(update_settings + 清标记)共用同一把锁,无检查-写入窗口。
|
||||
let marker_written = crate::settings::mark_codex_official_history_unify_migrated_if_enabled(
|
||||
CodexOfficialHistoryUnifyMigration {
|
||||
completed_at: Utc::now().to_rfc3339(),
|
||||
target_provider_id: CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string(),
|
||||
migrated_jsonl_files,
|
||||
migrated_state_rows,
|
||||
codex_config_dir: Some(codex_dir_key),
|
||||
},
|
||||
)?;
|
||||
if !marker_written {
|
||||
return Ok(CodexHistoryProviderBucketMigrationOutcome {
|
||||
skipped_reason: Some("toggle_disabled_during_migration".to_string()),
|
||||
..outcome
|
||||
});
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// live config.toml 是否路由到共享 custom 桶(会话分桶只看这个实态:
|
||||
/// base_url / 接管与否都不影响 session_meta 记录的 model_provider)。
|
||||
fn codex_config_text_routes_custom(config_text: &str) -> bool {
|
||||
config_text
|
||||
.parse::<DocumentMut>()
|
||||
.ok()
|
||||
.and_then(|doc| {
|
||||
doc.get("model_provider")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(|id| id.trim() == CC_SWITCH_CODEX_MODEL_PROVIDER_ID)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 目录的规范化字符串形式,用作 marker / 备份代际的目录身份。
|
||||
/// canonicalize 失败(目录尚不存在等)时退回原始路径字符串。
|
||||
fn canonical_dir_string(dir: &Path) -> String {
|
||||
fs::canonicalize(dir)
|
||||
.unwrap_or_else(|_| dir.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// 在备份代际根目录写入 meta.json,记录这批备份来自哪个 Codex 目录。
|
||||
/// 代际目录不存在(本轮没有任何文件被迁移)时跳过。
|
||||
fn write_backup_generation_meta(backup_root: &Path, codex_dir_key: &str) -> Result<(), AppError> {
|
||||
if !backup_root.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let payload = serde_json::json!({ "codexConfigDir": codex_dir_key });
|
||||
let bytes =
|
||||
serde_json::to_vec_pretty(&payload).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
atomic_write(&backup_root.join("meta.json"), &bytes)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CodexOfficialHistoryRestoreOutcome {
|
||||
pub restored_jsonl_files: usize,
|
||||
pub restored_state_rows: usize,
|
||||
pub skipped_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// 统一会话开关迁移备份的父目录(其下每次迁移一个时间戳代际目录)。
|
||||
fn official_history_unify_backup_parent() -> PathBuf {
|
||||
get_app_config_dir()
|
||||
.join("backups")
|
||||
.join(OFFICIAL_UNIFY_MIGRATION_NAME)
|
||||
}
|
||||
|
||||
/// 是否存在可用于还原的迁移备份(给前端决定要不要显示"恢复备份"勾选)。
|
||||
/// 与 restore 的账本收集共用同一目录匹配口径:只认属于当前 Codex 目录的
|
||||
/// 代际,避免切换 codex_config_dir 后弹出注定空跑的勾选。
|
||||
/// 精确账本内容仍在真正还原时才解析。
|
||||
pub fn has_codex_official_history_unify_backup() -> bool {
|
||||
has_official_history_unify_backup_for_dir(
|
||||
&official_history_unify_backup_parent(),
|
||||
&canonical_dir_string(&get_codex_config_dir()),
|
||||
)
|
||||
}
|
||||
|
||||
fn has_official_history_unify_backup_for_dir(ledger_parent: &Path, codex_dir_key: &str) -> bool {
|
||||
let Ok(entries) = fs::read_dir(ledger_parent) else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|entry| {
|
||||
let generation = entry.path();
|
||||
generation.is_dir() && backup_generation_matches_dir(&generation, codex_dir_key)
|
||||
})
|
||||
}
|
||||
|
||||
/// 关闭统一会话开关时的可选还原:按迁移备份账本,把当时迁入共享 custom 桶的
|
||||
/// 官方会话精确翻回 "openai" 桶。
|
||||
///
|
||||
/// 备份是唯一可信的归属证据:备份里 model_provider=="openai" 的会话必定源自
|
||||
/// 官方桶。开启期间新产生的会话不在任何备份里,**永不触碰**——它们可能来自
|
||||
/// 第三方,方向无法判定(产品决策:宁可留在第三方历史)。
|
||||
/// 扫描全部备份代际取并集,多次开关循环后仍能还原早期迁入的会话;
|
||||
/// 还原前改动目标先备份到独立的 restore 目录(保持迁移账本目录纯净),
|
||||
/// 且只改写当前仍为 custom 的目标,重复执行无害。
|
||||
pub fn restore_codex_official_history_from_backups(
|
||||
) -> Result<CodexOfficialHistoryRestoreOutcome, AppError> {
|
||||
let _op_guard = lock_codex_official_history_op();
|
||||
// 开关已(重新)开启时拒绝还原:live 正路由 custom,把账本会话翻回
|
||||
// openai 桶等于亲手制造分裂。覆盖"关闭保存成功后用户立刻重新开启,
|
||||
// 还原排在重开迁移之后才拿到 op lock"的时序。
|
||||
if crate::settings::unify_codex_session_history() {
|
||||
return Ok(CodexOfficialHistoryRestoreOutcome {
|
||||
skipped_reason: Some("unify_toggle_on".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let config_text = read_codex_config_text().unwrap_or_default();
|
||||
restore_codex_official_history_inner(
|
||||
&get_codex_config_dir(),
|
||||
&official_history_unify_backup_parent(),
|
||||
&migration_backup_root(OFFICIAL_UNIFY_RESTORE_BACKUP_NAME),
|
||||
&config_text,
|
||||
)
|
||||
}
|
||||
|
||||
fn restore_codex_official_history_inner(
|
||||
codex_dir: &Path,
|
||||
ledger_parent: &Path,
|
||||
restore_backup_root: &Path,
|
||||
config_text: &str,
|
||||
) -> Result<CodexOfficialHistoryRestoreOutcome, AppError> {
|
||||
let codex_dir_key = canonical_dir_string(codex_dir);
|
||||
let (official_session_ids, official_thread_ids) =
|
||||
collect_official_ledger(ledger_parent, &codex_dir_key)?;
|
||||
if official_session_ids.is_empty() && official_thread_ids.is_empty() {
|
||||
return Ok(CodexOfficialHistoryRestoreOutcome {
|
||||
skipped_reason: Some("no_backup_ledger".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
collect_jsonl_files(&codex_dir.join("sessions"), &mut files, 0, 8);
|
||||
collect_jsonl_files(&codex_dir.join("archived_sessions"), &mut files, 0, 4);
|
||||
let mut restored_jsonl_files = 0;
|
||||
for file_path in files {
|
||||
if rewrite_codex_session_file_lines(&file_path, codex_dir, restore_backup_root, |line| {
|
||||
rewrite_codex_session_meta_line_for_restore(line, &official_session_ids)
|
||||
})? {
|
||||
restored_jsonl_files += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut restored_state_rows = 0;
|
||||
for db_path in codex_state_db_paths(codex_dir, config_text) {
|
||||
restored_state_rows += restore_codex_state_db_official_threads(
|
||||
&db_path,
|
||||
codex_dir,
|
||||
&official_thread_ids,
|
||||
restore_backup_root,
|
||||
)?;
|
||||
}
|
||||
|
||||
if restored_jsonl_files == 0 && restored_state_rows == 0 {
|
||||
// 账本非空但没有任何"当前仍为 custom"的目标(如重复还原):
|
||||
// 以 reason 告知前端,避免误报"已还原 0 项"为成功。
|
||||
return Ok(CodexOfficialHistoryRestoreOutcome {
|
||||
skipped_reason: Some("nothing_to_restore".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
Ok(CodexOfficialHistoryRestoreOutcome {
|
||||
restored_jsonl_files,
|
||||
restored_state_rows,
|
||||
skipped_reason: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 从备份代际收集官方会话账本:jsonl 备份里 session_meta 为 "openai" 的
|
||||
/// 会话 id + state DB 备份里 model_provider 为 "openai" 的 thread id。
|
||||
/// 只采纳 meta.json 目录与当前 Codex 目录一致的代际,避免切换
|
||||
/// codex_config_dir 后拿旧目录的账本作用到新目录。
|
||||
/// 还原操作自身的备份(restore 目录)天然不会混入:那些副本里的 id 都是
|
||||
/// custom,解析后贡献为空。
|
||||
fn collect_official_ledger(
|
||||
ledger_parent: &Path,
|
||||
codex_dir_key: &str,
|
||||
) -> Result<(HashSet<String>, BTreeSet<String>), AppError> {
|
||||
let mut session_ids = HashSet::new();
|
||||
let mut thread_ids = BTreeSet::new();
|
||||
let entries = match fs::read_dir(ledger_parent) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return Ok((session_ids, thread_ids)),
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let generation = entry.path();
|
||||
if !generation.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if !backup_generation_matches_dir(&generation, codex_dir_key) {
|
||||
continue;
|
||||
}
|
||||
let mut backup_files = Vec::new();
|
||||
collect_jsonl_files(&generation.join("jsonl"), &mut backup_files, 0, 10);
|
||||
for backup_file in backup_files {
|
||||
collect_official_session_ids_from_backup(&backup_file, &mut session_ids);
|
||||
}
|
||||
let mut backup_dbs = Vec::new();
|
||||
collect_files_with_extension(&generation.join("state"), "sqlite", &mut backup_dbs, 0, 4);
|
||||
for backup_db in backup_dbs {
|
||||
collect_official_thread_ids_from_backup(&backup_db, &mut thread_ids);
|
||||
}
|
||||
}
|
||||
Ok((session_ids, thread_ids))
|
||||
}
|
||||
|
||||
/// 备份代际是否属于指定 Codex 目录。无 meta.json 或解析失败时宽容接受:
|
||||
/// 早期版本的备份没有 meta,而那个时期不存在切目录场景;误纳的代价也被
|
||||
/// "按会话 id 精确匹配 + 仅改写 custom"双重条件兜底。
|
||||
fn backup_generation_matches_dir(generation: &Path, codex_dir_key: &str) -> bool {
|
||||
let Ok(text) = fs::read_to_string(generation.join("meta.json")) else {
|
||||
return true;
|
||||
};
|
||||
serde_json::from_str::<Value>(&text)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("codexConfigDir")
|
||||
.and_then(Value::as_str)
|
||||
.map(|dir| dir == codex_dir_key)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
fn collect_official_session_ids_from_backup(path: &Path, session_ids: &mut HashSet<String>) {
|
||||
let Ok(content) = fs::read_to_string(path) else {
|
||||
log::debug!("Failed to read unify backup file {}", path.display());
|
||||
return;
|
||||
};
|
||||
for line in content.lines() {
|
||||
if !line.contains("\"session_meta\"") || !line.contains("\"model_provider\"") {
|
||||
continue;
|
||||
}
|
||||
let Ok(value) = serde_json::from_str::<Value>(line) else {
|
||||
continue;
|
||||
};
|
||||
if value.get("type").and_then(Value::as_str) != Some("session_meta") {
|
||||
continue;
|
||||
}
|
||||
let Some(payload) = value.get("payload") else {
|
||||
continue;
|
||||
};
|
||||
if payload.get("model_provider").and_then(Value::as_str)
|
||||
!= Some(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(session_id) = payload.get("id").and_then(Value::as_str) {
|
||||
session_ids.insert(session_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_official_thread_ids_from_backup(db_path: &Path, thread_ids: &mut BTreeSet<String>) {
|
||||
let conn =
|
||||
match Connection::open_with_flags(db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) {
|
||||
Ok(conn) => conn,
|
||||
Err(err) => {
|
||||
log::debug!(
|
||||
"Failed to open unify backup state DB {}: {err}",
|
||||
db_path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let has_threads = Database::table_exists(&conn, "threads").unwrap_or(false)
|
||||
&& Database::has_column(&conn, "threads", "model_provider").unwrap_or(false);
|
||||
if !has_threads {
|
||||
return;
|
||||
}
|
||||
let Ok(mut stmt) = conn.prepare("SELECT id FROM threads WHERE model_provider = ?1") else {
|
||||
return;
|
||||
};
|
||||
let Ok(rows) = stmt.query_map([OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID], |row| {
|
||||
row.get::<_, String>(0)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
for thread_id in rows.flatten() {
|
||||
thread_ids.insert(thread_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_files_with_extension(
|
||||
dir: &Path,
|
||||
extension: &str,
|
||||
files: &mut Vec<PathBuf>,
|
||||
depth: u8,
|
||||
max_depth: u8,
|
||||
) {
|
||||
if depth > max_depth || !dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_files_with_extension(&path, extension, files, depth + 1, max_depth);
|
||||
} else if path.extension().and_then(|ext| ext.to_str()) == Some(extension) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rewrite_codex_session_meta_line_for_restore(
|
||||
line: &str,
|
||||
official_session_ids: &HashSet<String>,
|
||||
) -> Option<String> {
|
||||
if !line.contains("\"session_meta\"") || !line.contains("\"model_provider\"") {
|
||||
return None;
|
||||
}
|
||||
let mut value: Value = serde_json::from_str(line).ok()?;
|
||||
if value.get("type").and_then(Value::as_str) != Some("session_meta") {
|
||||
return None;
|
||||
}
|
||||
let payload = value.get_mut("payload")?.as_object_mut()?;
|
||||
if payload.get("model_provider")?.as_str()? != CC_SWITCH_CODEX_MODEL_PROVIDER_ID {
|
||||
return None;
|
||||
}
|
||||
let session_id = payload.get("id")?.as_str()?;
|
||||
if !official_session_ids.contains(session_id) {
|
||||
return None;
|
||||
}
|
||||
payload.insert(
|
||||
"model_provider".to_string(),
|
||||
Value::String(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID.to_string()),
|
||||
);
|
||||
serde_json::to_string(&value).ok()
|
||||
}
|
||||
|
||||
fn restore_codex_state_db_official_threads(
|
||||
db_path: &Path,
|
||||
codex_dir: &Path,
|
||||
official_thread_ids: &BTreeSet<String>,
|
||||
backup_root: &Path,
|
||||
) -> Result<usize, AppError> {
|
||||
if !db_path.exists() || official_thread_ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut conn = Connection::open(db_path)
|
||||
.map_err(|e| AppError::Database(format!("打开 Codex state DB 失败: {e}")))?;
|
||||
conn.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|e| AppError::Database(format!("设置 Codex state DB busy_timeout 失败: {e}")))?;
|
||||
|
||||
if !Database::table_exists(&conn, "threads")?
|
||||
|| !Database::has_column(&conn, "threads", "model_provider")?
|
||||
{
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let ids: Vec<&String> = official_thread_ids.iter().collect();
|
||||
let mut matching_rows: i64 = 0;
|
||||
for chunk in ids.chunks(STATE_DB_ID_CHUNK) {
|
||||
let placeholders = placeholders(chunk.len());
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(*) FROM threads WHERE model_provider = ? AND id IN ({placeholders})"
|
||||
);
|
||||
let mut values = Vec::with_capacity(chunk.len() + 1);
|
||||
values.push(CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
|
||||
values.extend(chunk.iter().map(|id| (*id).clone()));
|
||||
let count: i64 = conn
|
||||
.query_row(&count_sql, params_from_iter(values.iter()), |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.map_err(|e| AppError::Database(format!("统计 Codex state DB 待还原行失败: {e}")))?;
|
||||
matching_rows += count;
|
||||
}
|
||||
if matching_rows == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
backup_codex_state_db(db_path, codex_dir, backup_root, &conn)?;
|
||||
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|e| AppError::Database(format!("开启 Codex state DB 还原事务失败: {e}")))?;
|
||||
let mut changed = 0;
|
||||
for chunk in ids.chunks(STATE_DB_ID_CHUNK) {
|
||||
let placeholders = placeholders(chunk.len());
|
||||
let update_sql = format!(
|
||||
"UPDATE threads SET model_provider = ? WHERE model_provider = ? AND id IN ({placeholders})"
|
||||
);
|
||||
let mut values = Vec::with_capacity(chunk.len() + 2);
|
||||
values.push(OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID.to_string());
|
||||
values.push(CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
|
||||
values.extend(chunk.iter().map(|id| (*id).clone()));
|
||||
changed += tx
|
||||
.execute(&update_sql, params_from_iter(values.iter()))
|
||||
.map_err(|e| AppError::Database(format!("还原 Codex state DB provider 失败: {e}")))?;
|
||||
}
|
||||
tx.commit()
|
||||
.map_err(|e| AppError::Database(format!("提交 Codex state DB 还原事务失败: {e}")))?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn migrate_codex_provider_templates_to_custom(
|
||||
db: &Database,
|
||||
backup_root: &Path,
|
||||
@@ -257,10 +745,10 @@ fn insert_known_cc_switch_legacy_source_id(ids: &mut BTreeSet<String>, provider_
|
||||
}
|
||||
}
|
||||
|
||||
fn migration_backup_root() -> PathBuf {
|
||||
fn migration_backup_root(migration_name: &str) -> PathBuf {
|
||||
get_app_config_dir()
|
||||
.join("backups")
|
||||
.join(MIGRATION_NAME)
|
||||
.join(migration_name)
|
||||
.join(Local::now().format("%Y%m%d_%H%M%S").to_string())
|
||||
}
|
||||
|
||||
@@ -524,6 +1012,17 @@ fn rewrite_codex_session_file_for_provider_bucket(
|
||||
codex_dir: &Path,
|
||||
source_provider_ids: &HashSet<String>,
|
||||
backup_root: &Path,
|
||||
) -> Result<bool, AppError> {
|
||||
rewrite_codex_session_file_lines(path, codex_dir, backup_root, |line| {
|
||||
rewrite_codex_session_meta_line(line, source_provider_ids)
|
||||
})
|
||||
}
|
||||
|
||||
fn rewrite_codex_session_file_lines(
|
||||
path: &Path,
|
||||
codex_dir: &Path,
|
||||
backup_root: &Path,
|
||||
rewrite_line: impl Fn(&str) -> Option<String>,
|
||||
) -> Result<bool, AppError> {
|
||||
let metadata_before = fs::metadata(path).map_err(|e| AppError::io(path, e))?;
|
||||
let modified_before = metadata_before.modified().ok();
|
||||
@@ -537,7 +1036,7 @@ fn rewrite_codex_session_file_for_provider_bucket(
|
||||
.strip_suffix('\n')
|
||||
.map(|line| (line, "\n"))
|
||||
.unwrap_or((segment, ""));
|
||||
if let Some(next_line) = rewrite_codex_session_meta_line(line, source_provider_ids) {
|
||||
if let Some(next_line) = rewrite_line(line) {
|
||||
rewritten.push_str(&next_line);
|
||||
changed = true;
|
||||
} else {
|
||||
@@ -820,6 +1319,39 @@ mod tests {
|
||||
values.iter().map(|value| value.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_custom_routed_codex_config_for_unify_gate() {
|
||||
// 注入产物(官方 + 统一开关)
|
||||
assert!(codex_config_text_routes_custom(
|
||||
r#"model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "OpenAI"
|
||||
requires_openai_auth = true
|
||||
supports_websockets = true
|
||||
wire_api = "responses"
|
||||
"#
|
||||
));
|
||||
// 第三方供应商的常规 custom 路由(带 base_url)同样算已统一
|
||||
assert!(codex_config_text_routes_custom(
|
||||
r#"model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "AIHubMix"
|
||||
base_url = "https://aihubmix.example/v1"
|
||||
"#
|
||||
));
|
||||
// 注入被拒的形态:显式 openai 路由 / 无 model_provider(接管期间、空配置)
|
||||
assert!(!codex_config_text_routes_custom(
|
||||
"model_provider = \"openai\"\n"
|
||||
));
|
||||
assert!(!codex_config_text_routes_custom(
|
||||
"base_url = \"http://127.0.0.1:15721/codex\"\n"
|
||||
));
|
||||
assert!(!codex_config_text_routes_custom(""));
|
||||
assert!(!codex_config_text_routes_custom("not toml ["));
|
||||
}
|
||||
|
||||
fn migrate_provider_templates_for_test(
|
||||
db: &Database,
|
||||
) -> (
|
||||
@@ -1092,6 +1624,333 @@ base_url = "https://proxy.example/v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simulates_official_history_unify_migration_end_to_end() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let codex_dir = dir.path().join(".codex");
|
||||
let backup_root = dir.path().join("backup");
|
||||
fs::create_dir_all(&codex_dir).expect("create codex dir");
|
||||
|
||||
let source_provider_ids = source_ids(&[OFFICIAL_OPENAI_CODEX_MODEL_PROVIDER_ID]);
|
||||
|
||||
let session_dir = codex_dir.join("sessions/2026/06/12");
|
||||
fs::create_dir_all(&session_dir).expect("create session dir");
|
||||
let session_path = session_dir.join("official-sim.jsonl");
|
||||
fs::write(
|
||||
&session_path,
|
||||
concat!(
|
||||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"openai\"}}\n",
|
||||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s2\",\"model_provider\":\"custom\"}}\n",
|
||||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s3\",\"model_provider\":\"my-private-relay\"}}\n",
|
||||
"{\"type\":\"response_item\",\"payload\":{\"text\":\"openai\"}}\n",
|
||||
),
|
||||
)
|
||||
.expect("write session");
|
||||
|
||||
let migrated_jsonl =
|
||||
migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root)
|
||||
.expect("migrate jsonl");
|
||||
assert_eq!(migrated_jsonl, 1);
|
||||
let session_text = fs::read_to_string(&session_path).expect("read session");
|
||||
assert_eq!(
|
||||
session_text
|
||||
.matches("\"model_provider\":\"custom\"")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(!session_text.contains("\"model_provider\":\"openai\""));
|
||||
assert!(session_text.contains("\"model_provider\":\"my-private-relay\""));
|
||||
assert!(
|
||||
session_text.contains("{\"type\":\"response_item\",\"payload\":{\"text\":\"openai\"}}")
|
||||
);
|
||||
assert!(backup_root
|
||||
.join("jsonl/sessions/2026/06/12/official-sim.jsonl")
|
||||
.exists());
|
||||
|
||||
// 第二次执行应当无事可做(幂等)
|
||||
let rerun = migrate_codex_jsonl_files(&codex_dir, &source_provider_ids, &backup_root)
|
||||
.expect("rerun migrate jsonl");
|
||||
assert_eq!(rerun, 0);
|
||||
|
||||
let state_db_path = codex_dir.join(CODEX_STATE_DB_FILENAME);
|
||||
let conn = Connection::open(&state_db_path).expect("open state db");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
model_provider TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO threads (id, model_provider) VALUES
|
||||
('openai-thread', 'openai'),
|
||||
('custom-thread', 'custom'),
|
||||
('manual-thread', 'my-private-relay');",
|
||||
)
|
||||
.expect("seed state db");
|
||||
drop(conn);
|
||||
|
||||
let migrated_state_rows = migrate_codex_state_db_provider_bucket(
|
||||
&state_db_path,
|
||||
&codex_dir,
|
||||
&source_provider_ids,
|
||||
&backup_root,
|
||||
)
|
||||
.expect("migrate state db");
|
||||
assert_eq!(migrated_state_rows, 1);
|
||||
|
||||
let conn = Connection::open(&state_db_path).expect("reopen state db");
|
||||
let count_provider = |provider_id: &str| -> i64 {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM threads WHERE model_provider = ?1",
|
||||
[provider_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count provider")
|
||||
};
|
||||
assert_eq!(count_provider("custom"), 2);
|
||||
assert_eq!(count_provider("openai"), 0);
|
||||
assert_eq!(count_provider("my-private-relay"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restores_only_ledgered_official_sessions_from_backups() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let codex_dir = dir.path().join(".codex");
|
||||
let ledger_parent = dir.path().join("ledger");
|
||||
let restore_backup_root = dir.path().join("restore-backup");
|
||||
|
||||
// 备份账本:一个代际,jsonl 备份里 s1 是 openai;state 备份里 t1 是 openai
|
||||
let generation = ledger_parent.join("20260612_010101");
|
||||
let backup_session_dir = generation.join("jsonl/sessions/2026/06/01");
|
||||
fs::create_dir_all(&backup_session_dir).expect("create backup session dir");
|
||||
fs::write(
|
||||
backup_session_dir.join("official.jsonl"),
|
||||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"openai\"}}\n",
|
||||
)
|
||||
.expect("write backup session");
|
||||
let backup_state_dir = generation.join("state");
|
||||
fs::create_dir_all(&backup_state_dir).expect("create backup state dir");
|
||||
let backup_db = Connection::open(backup_state_dir.join(CODEX_STATE_DB_FILENAME))
|
||||
.expect("open backup db");
|
||||
backup_db
|
||||
.execute_batch(
|
||||
"CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT NOT NULL);
|
||||
INSERT INTO threads (id, model_provider) VALUES ('t1', 'openai');",
|
||||
)
|
||||
.expect("seed backup db");
|
||||
drop(backup_db);
|
||||
|
||||
// 当前数据:s1(账本内,custom)应还原;s2(开启期间新会话,不在账本)
|
||||
// 与 s3(手工 relay)必须原样保留
|
||||
let session_dir = codex_dir.join("sessions/2026/06/01");
|
||||
fs::create_dir_all(&session_dir).expect("create session dir");
|
||||
let official_path = session_dir.join("official.jsonl");
|
||||
fs::write(
|
||||
&official_path,
|
||||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"custom\"}}\n",
|
||||
)
|
||||
.expect("write official session");
|
||||
let on_period_dir = codex_dir.join("sessions/2026/06/12");
|
||||
fs::create_dir_all(&on_period_dir).expect("create on-period dir");
|
||||
let on_period_path = on_period_dir.join("on-period.jsonl");
|
||||
fs::write(
|
||||
&on_period_path,
|
||||
concat!(
|
||||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s2\",\"model_provider\":\"custom\"}}\n",
|
||||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s3\",\"model_provider\":\"my-private-relay\"}}\n",
|
||||
),
|
||||
)
|
||||
.expect("write on-period session");
|
||||
|
||||
let state_db_path = codex_dir.join(CODEX_STATE_DB_FILENAME);
|
||||
let conn = Connection::open(&state_db_path).expect("open state db");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT NOT NULL);
|
||||
INSERT INTO threads (id, model_provider) VALUES
|
||||
('t1', 'custom'),
|
||||
('t2', 'custom'),
|
||||
('t3', 'openai');",
|
||||
)
|
||||
.expect("seed state db");
|
||||
drop(conn);
|
||||
|
||||
// 代际 meta 指向当前 Codex 目录:精确匹配分支生效(而非无 meta 的宽容分支)
|
||||
fs::write(
|
||||
generation.join("meta.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"codexConfigDir": canonical_dir_string(&codex_dir)
|
||||
}))
|
||||
.expect("serialize meta"),
|
||||
)
|
||||
.expect("write meta");
|
||||
|
||||
let outcome = restore_codex_official_history_inner(
|
||||
&codex_dir,
|
||||
&ledger_parent,
|
||||
&restore_backup_root,
|
||||
"",
|
||||
)
|
||||
.expect("restore");
|
||||
assert_eq!(outcome.restored_jsonl_files, 1);
|
||||
assert_eq!(outcome.restored_state_rows, 1);
|
||||
assert!(outcome.skipped_reason.is_none());
|
||||
|
||||
let official_text = fs::read_to_string(&official_path).expect("read official");
|
||||
assert!(official_text.contains("\"model_provider\":\"openai\""));
|
||||
let on_period_text = fs::read_to_string(&on_period_path).expect("read on-period");
|
||||
assert!(on_period_text.contains("\"id\":\"s2\",\"model_provider\":\"custom\""));
|
||||
assert!(on_period_text.contains("\"model_provider\":\"my-private-relay\""));
|
||||
|
||||
let conn = Connection::open(&state_db_path).expect("reopen state db");
|
||||
let provider_of = |thread_id: &str| -> String {
|
||||
conn.query_row(
|
||||
"SELECT model_provider FROM threads WHERE id = ?1",
|
||||
[thread_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("thread provider")
|
||||
};
|
||||
assert_eq!(provider_of("t1"), "openai");
|
||||
assert_eq!(provider_of("t2"), "custom");
|
||||
assert_eq!(provider_of("t3"), "openai");
|
||||
drop(conn);
|
||||
|
||||
// 还原前的现场已备份到独立目录
|
||||
assert!(restore_backup_root
|
||||
.join("jsonl/sessions/2026/06/01/official.jsonl")
|
||||
.exists());
|
||||
assert!(restore_backup_root
|
||||
.join("state")
|
||||
.join(CODEX_STATE_DB_FILENAME)
|
||||
.exists());
|
||||
|
||||
// 幂等:第二次还原无事可做
|
||||
let rerun = restore_codex_official_history_inner(
|
||||
&codex_dir,
|
||||
&ledger_parent,
|
||||
&dir.path().join("restore-backup-2"),
|
||||
"",
|
||||
)
|
||||
.expect("rerun restore");
|
||||
assert_eq!(rerun.restored_jsonl_files, 0);
|
||||
assert_eq!(rerun.restored_state_rows, 0);
|
||||
assert_eq!(rerun.skipped_reason.as_deref(), Some("nothing_to_restore"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_ignores_backup_generations_from_other_codex_dirs() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let codex_dir = dir.path().join(".codex");
|
||||
let ledger_parent = dir.path().join("ledger");
|
||||
|
||||
// 账本代际属于另一个 Codex 目录
|
||||
let generation = ledger_parent.join("20260612_010101");
|
||||
let backup_session_dir = generation.join("jsonl/sessions/2026/06/01");
|
||||
fs::create_dir_all(&backup_session_dir).expect("create backup session dir");
|
||||
fs::write(
|
||||
backup_session_dir.join("official.jsonl"),
|
||||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"openai\"}}\n",
|
||||
)
|
||||
.expect("write backup session");
|
||||
fs::write(
|
||||
generation.join("meta.json"),
|
||||
"{\n \"codexConfigDir\": \"/some/other/codex-dir\"\n}",
|
||||
)
|
||||
.expect("write meta");
|
||||
|
||||
let session_dir = codex_dir.join("sessions/2026/06/01");
|
||||
fs::create_dir_all(&session_dir).expect("create session dir");
|
||||
let session_path = session_dir.join("official.jsonl");
|
||||
fs::write(
|
||||
&session_path,
|
||||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"custom\"}}\n",
|
||||
)
|
||||
.expect("write session");
|
||||
|
||||
let outcome = restore_codex_official_history_inner(
|
||||
&codex_dir,
|
||||
&ledger_parent,
|
||||
&dir.path().join("restore-backup"),
|
||||
"",
|
||||
)
|
||||
.expect("restore");
|
||||
assert_eq!(outcome.skipped_reason.as_deref(), Some("no_backup_ledger"));
|
||||
let text = fs::read_to_string(&session_path).expect("read session");
|
||||
assert!(text.contains("\"model_provider\":\"custom\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_probe_only_counts_generations_for_current_dir() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let ledger_parent = dir.path().join("ledger");
|
||||
let codex_dir_key = "/current/codex-dir";
|
||||
|
||||
// 空父目录 / 父目录不存在:无备份
|
||||
assert!(!has_official_history_unify_backup_for_dir(
|
||||
&ledger_parent,
|
||||
codex_dir_key
|
||||
));
|
||||
|
||||
// 只有其他目录的代际:不算有备份
|
||||
let other = ledger_parent.join("20260612_010101");
|
||||
fs::create_dir_all(&other).expect("create generation");
|
||||
fs::write(
|
||||
other.join("meta.json"),
|
||||
"{\n \"codexConfigDir\": \"/some/other/codex-dir\"\n}",
|
||||
)
|
||||
.expect("write meta");
|
||||
assert!(!has_official_history_unify_backup_for_dir(
|
||||
&ledger_parent,
|
||||
codex_dir_key
|
||||
));
|
||||
|
||||
// 无 meta 的早期代际:宽容接受(与 restore 的账本口径一致)
|
||||
fs::create_dir_all(ledger_parent.join("20260612_020202")).expect("create legacy gen");
|
||||
assert!(has_official_history_unify_backup_for_dir(
|
||||
&ledger_parent,
|
||||
codex_dir_key
|
||||
));
|
||||
|
||||
// 精确匹配当前目录的代际
|
||||
fs::remove_dir_all(ledger_parent.join("20260612_020202")).expect("remove legacy gen");
|
||||
let matched = ledger_parent.join("20260612_030303");
|
||||
fs::create_dir_all(&matched).expect("create matched gen");
|
||||
fs::write(
|
||||
matched.join("meta.json"),
|
||||
format!("{{\n \"codexConfigDir\": \"{codex_dir_key}\"\n}}"),
|
||||
)
|
||||
.expect("write matched meta");
|
||||
assert!(has_official_history_unify_backup_for_dir(
|
||||
&ledger_parent,
|
||||
codex_dir_key
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_skips_when_no_backup_ledger_exists() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let codex_dir = dir.path().join(".codex");
|
||||
let session_dir = codex_dir.join("sessions/2026/06/01");
|
||||
fs::create_dir_all(&session_dir).expect("create session dir");
|
||||
fs::write(
|
||||
session_dir.join("session.jsonl"),
|
||||
"{\"type\":\"session_meta\",\"payload\":{\"id\":\"s1\",\"model_provider\":\"custom\"}}\n",
|
||||
)
|
||||
.expect("write session");
|
||||
|
||||
let outcome = restore_codex_official_history_inner(
|
||||
&codex_dir,
|
||||
&dir.path().join("missing-ledger"),
|
||||
&dir.path().join("restore-backup"),
|
||||
"",
|
||||
)
|
||||
.expect("restore");
|
||||
assert_eq!(outcome.skipped_reason.as_deref(), Some("no_backup_ledger"));
|
||||
assert_eq!(outcome.restored_jsonl_files, 0);
|
||||
assert_eq!(outcome.restored_state_rows, 0);
|
||||
|
||||
let text = fs::read_to_string(session_dir.join("session.jsonl")).expect("read session");
|
||||
assert!(text.contains("\"model_provider\":\"custom\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_only_codex_session_meta_provider_ids() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
|
||||
@@ -249,8 +249,8 @@ fn finish_lifecycle_output(output: &std::process::Output) -> Result<(), String>
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = decode_command_output(&output.stderr);
|
||||
let stdout = decode_command_output(&output.stdout);
|
||||
let raw = if stderr.trim().is_empty() {
|
||||
stdout.trim()
|
||||
} else {
|
||||
@@ -271,6 +271,81 @@ fn last_lines(text: &str, n: usize) -> String {
|
||||
lines[start..].join("\n")
|
||||
}
|
||||
|
||||
fn decode_command_output(bytes: &[u8]) -> String {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
decode_windows_command_output(bytes)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
String::from_utf8_lossy(bytes).into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn decode_windows_command_output(bytes: &[u8]) -> String {
|
||||
if bytes.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if let Ok(text) = std::str::from_utf8(bytes) {
|
||||
return text.to_string();
|
||||
}
|
||||
|
||||
use windows_sys::Win32::Globalization::{GetACP, GetOEMCP, MultiByteToWideChar};
|
||||
|
||||
fn decode_codepage(bytes: &[u8], codepage: u32) -> Option<String> {
|
||||
if codepage == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let input_len = i32::try_from(bytes.len()).ok()?;
|
||||
unsafe {
|
||||
let wide_len = MultiByteToWideChar(
|
||||
codepage,
|
||||
0,
|
||||
bytes.as_ptr(),
|
||||
input_len,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
);
|
||||
if wide_len <= 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut wide = vec![0u16; wide_len as usize];
|
||||
let written = MultiByteToWideChar(
|
||||
codepage,
|
||||
0,
|
||||
bytes.as_ptr(),
|
||||
input_len,
|
||||
wide.as_mut_ptr(),
|
||||
wide_len,
|
||||
);
|
||||
if written <= 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(String::from_utf16_lossy(&wide[..written as usize]))
|
||||
}
|
||||
}
|
||||
|
||||
let oem_cp = unsafe { GetOEMCP() };
|
||||
if let Some(decoded) = decode_codepage(bytes, oem_cp) {
|
||||
return decoded;
|
||||
}
|
||||
|
||||
let ansi_cp = unsafe { GetACP() };
|
||||
if ansi_cp != oem_cp {
|
||||
if let Some(decoded) = decode_codepage(bytes, ansi_cp) {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
|
||||
String::from_utf8_lossy(bytes).into_owned()
|
||||
}
|
||||
|
||||
fn normalize_requested_tools(tools: &[String]) -> Vec<&'static str> {
|
||||
let set: std::collections::HashSet<&str> = tools.iter().map(|s| s.as_str()).collect();
|
||||
VALID_TOOLS
|
||||
@@ -936,8 +1011,8 @@ fn try_get_version(tool: &str) -> ShellProbe {
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
let stdout = decode_command_output(&out.stdout).trim().to_string();
|
||||
let stderr = decode_command_output(&out.stderr).trim().to_string();
|
||||
if out.status.success() {
|
||||
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||
if raw.is_empty() {
|
||||
@@ -1051,8 +1126,8 @@ fn try_get_version_wsl(
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
let stdout = decode_command_output(&out.stdout).trim().to_string();
|
||||
let stderr = decode_command_output(&out.stderr).trim().to_string();
|
||||
if out.status.success() {
|
||||
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||
if raw.is_empty() {
|
||||
@@ -1431,8 +1506,43 @@ fn build_tool_search_paths(tool: &str) -> Vec<std::path::PathBuf> {
|
||||
search_paths
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_windows_command_script(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_windows_tool_version_command(
|
||||
tool_path: &Path,
|
||||
new_path: &str,
|
||||
) -> std::io::Result<std::process::Output> {
|
||||
use std::process::Command;
|
||||
|
||||
if is_windows_command_script(tool_path) {
|
||||
let path = tool_path.to_string_lossy();
|
||||
let command = format!("call {} --version", win_quote_path_for_batch(&path));
|
||||
let mut cmd = Command::new("cmd");
|
||||
return cmd
|
||||
.args(["/D", "/S", "/C"])
|
||||
.raw_arg(&command)
|
||||
.env("PATH", new_path)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
}
|
||||
|
||||
Command::new(tool_path)
|
||||
.arg("--version")
|
||||
.env("PATH", new_path)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
}
|
||||
|
||||
/// 扫描常见路径查找 CLI(PATH 主命令未命中时的兜底单探)。
|
||||
fn scan_cli_version(tool: &str) -> ShellProbe {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use std::process::Command;
|
||||
|
||||
let search_paths = build_tool_search_paths(tool);
|
||||
@@ -1458,13 +1568,7 @@ fn scan_cli_version(tool: &str) -> ShellProbe {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let output = {
|
||||
Command::new("cmd")
|
||||
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
|
||||
.env("PATH", &new_path)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
};
|
||||
let output = run_windows_tool_version_command(&tool_path, &new_path);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let output = {
|
||||
@@ -1475,8 +1579,8 @@ fn scan_cli_version(tool: &str) -> ShellProbe {
|
||||
};
|
||||
|
||||
if let Ok(out) = output {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
let stdout = decode_command_output(&out.stdout).trim().to_string();
|
||||
let stderr = decode_command_output(&out.stderr).trim().to_string();
|
||||
if out.status.success() {
|
||||
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||
if !raw.is_empty() {
|
||||
@@ -1588,7 +1692,7 @@ fn resolve_path_default(tool: &str) -> Option<std::path::PathBuf> {
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let raw = String::from_utf8_lossy(&out.stdout);
|
||||
let raw = decode_command_output(&out.stdout);
|
||||
// 不能死取第一行:交互式 .zshrc 可能先打印欢迎语(如 "🚀 Welcome back"),
|
||||
// command -v 的真实路径在其后;取第一个 `/` 开头的行才稳。
|
||||
let first = first_abs_path_line(&raw)?;
|
||||
@@ -1607,7 +1711,7 @@ fn resolve_path_default(tool: &str) -> Option<std::path::PathBuf> {
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let raw = String::from_utf8_lossy(&out.stdout);
|
||||
let raw = decode_command_output(&out.stdout);
|
||||
let first = raw.lines().next()?.trim();
|
||||
if first.is_empty() {
|
||||
return None;
|
||||
@@ -1619,6 +1723,7 @@ fn resolve_path_default(tool: &str) -> Option<std::path::PathBuf> {
|
||||
/// `build_tool_search_paths`,但不在首个命中处停止——而是对每个去重后的真实
|
||||
/// 可执行文件都跑一次 `--version`,从而能发现"升级写入 A 处、PATH 实际用 B 处"。
|
||||
fn enumerate_tool_installations(tool: &str) -> Vec<ToolInstallation> {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use std::process::Command;
|
||||
|
||||
let search_paths = build_tool_search_paths(tool);
|
||||
@@ -1648,14 +1753,7 @@ fn enumerate_tool_installations(tool: &str) -> Vec<ToolInstallation> {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let output = {
|
||||
use std::os::windows::process::CommandExt;
|
||||
Command::new("cmd")
|
||||
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
|
||||
.env("PATH", &new_path)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
};
|
||||
let output = run_windows_tool_version_command(&tool_path, &new_path);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let output = Command::new(&tool_path)
|
||||
.arg("--version")
|
||||
@@ -1664,14 +1762,14 @@ fn enumerate_tool_installations(tool: &str) -> Vec<ToolInstallation> {
|
||||
|
||||
let (version, runnable, error) = match output {
|
||||
Ok(out) if out.status.success() => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
let stdout = decode_command_output(&out.stdout).trim().to_string();
|
||||
let stderr = decode_command_output(&out.stderr).trim().to_string();
|
||||
let raw = if stdout.is_empty() { stderr } else { stdout };
|
||||
(Some(extract_version(&raw)), true, None)
|
||||
}
|
||||
Ok(out) => {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
let stderr = decode_command_output(&out.stderr).trim().to_string();
|
||||
let stdout = decode_command_output(&out.stdout).trim().to_string();
|
||||
let detail = if stderr.is_empty() { stdout } else { stderr };
|
||||
let detail = detail.trim();
|
||||
let error = if detail.is_empty() {
|
||||
@@ -1887,10 +1985,19 @@ fn anchored_official_update_command(tool: &str, bin_path: &str) -> Option<String
|
||||
official_update_args(tool).map(|args| format!("{} {args}", win_quote_path_for_batch(bin_path)))
|
||||
}
|
||||
|
||||
/// 哪些工具的"官方 self-update"优先于包管理器升级(生成 `<tool> update || <pkg-mgr>`)。
|
||||
///
|
||||
/// **codex 刻意不在此列**:`codex update` 在 npm 安装上只是裸 `npm install -g
|
||||
/// @openai/codex`(无 `@latest` / `--include=optional` / 不先卸载),却只检查 exit code、
|
||||
/// 无条件打印 “Update ran successfully”。当 npm 把平台二进制 optional 依赖
|
||||
/// `@openai/codex-<triple>` 漏装时它仍 **exit 0 假成功**,使外层 `||` 兜底被短路、损坏被
|
||||
/// 成功 toast 掩盖(用户报告的 “Missing optional dependency” 即源于此)。因此 codex 一律走
|
||||
/// npm 锚定升级;真正损坏(`runnable=false`)时由 `installs_anchored_command` 的门控改用
|
||||
/// `codex_repair_command` 的 uninstall+install 自愈,而非交给 codex 自身的 self-update。
|
||||
fn prefers_official_update(tool: &str, shell: LifecycleCommandShell) -> bool {
|
||||
match shell {
|
||||
LifecycleCommandShell::Posix => {
|
||||
matches!(tool, "claude" | "codex" | "opencode" | "openclaw")
|
||||
matches!(tool, "claude" | "opencode" | "openclaw")
|
||||
}
|
||||
LifecycleCommandShell::WindowsBatch => {
|
||||
matches!(
|
||||
@@ -1899,12 +2006,66 @@ fn prefers_official_update(tool: &str, shell: LifecycleCommandShell) -> bool {
|
||||
// 安装方式探测失败弹交互 prompt(spawn npm.cmd 没传 shell:true);静默
|
||||
// lifecycle 没有 stdin 会挂死,Windows 先锚到包管理器路径,等上游修了
|
||||
// 再把 opencode 加回这里。
|
||||
"claude" | "codex" | "openclaw"
|
||||
"claude" | "openclaw"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex 平台分发包损坏的自愈命令。Codex 的 npm 包是「主包 `@openai/codex`(纯 JS
|
||||
/// launcher)+ 平台二进制 optional 依赖 `@openai/codex-<triple>`」的分发模式(同 esbuild/swc)。
|
||||
/// 当平台二进制缺失时 codex 跑不起来——`enumerate_tool_installations` 跑 `--version` 会拿到
|
||||
/// “Missing optional dependency” 的非 0 退出,标记 `runnable=false`。此状态下普通
|
||||
/// `npm i -g @pkg@latest` 是 **no-op**:npm 视 optional 依赖缺失为非致命,reify 又认为主包已是
|
||||
/// 最新(外加半损坏留下的空 nested `node_modules` 残骸强化「tree 已满足」判断),不会补回平台
|
||||
/// 二进制。唯一实测可靠的修复是先 `uninstall` 清掉残骸、再 `install` 装回完整的主包 + 平台二进制
|
||||
/// (实测输出 `added 2 packages`)。
|
||||
///
|
||||
/// 锚定到与 codex 入口同目录的 npm(与升级路径一致,不依赖 GUI 非登录进程的 PATH)。`|| true`
|
||||
/// 让 uninstall 失败(如 nvm 上对半损坏包静默返回非 0)不触发外层 `set -e` 中止,但随后的
|
||||
/// install 若失败仍会被 `set -e` 捕获并上报给前端 toast。
|
||||
///
|
||||
/// **仅对会锚定到 sibling npm 的 node 管理器来源(nvm/fnm/mise/homebrew npm)生效**:
|
||||
/// `runnable=false` 是宽信号(权限 / node 版本 / 任意 `--version` 失败皆可触发),非 npm
|
||||
/// 全局安装各有自己的二进制分发与修复方式,无脑套 npm uninstall+install 会出错——Homebrew
|
||||
/// formula(real 在 `Cellar/`)本应 `brew upgrade codex`,npm 够不到它反而旁路装第二份 npm
|
||||
/// 全局 codex;Volta/Bun 本应 `volta install`/`bun add`,且 `~/.bun/bin` 下没有 npm、
|
||||
/// `sibling_bin` 会拼出不存在的路径;system/未知来源无可靠 sibling npm。这些来源一律返回
|
||||
/// None,让上游继续走 source-specific 的 `anchored_command_from_paths`。白名单与
|
||||
/// `package_manager_anchored_command_from_paths` 的 sibling-npm 分支对齐。
|
||||
/// 刻意**不**额外用 `inst.error` 文本确认「确系缺二进制」:enumerate 只保留 stderr 末尾 4 行,
|
||||
/// 而 codex.js 抛错的 "Missing optional dependency" 行会被尾部 node stack `at ...` 行挤出窗口
|
||||
/// (实测用户原始错误即如此),强加该条件反而漏修真实缺包;对 npm 全局安装,uninstall+install
|
||||
/// 对各类损坏都是合理且不会更糟的修复。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn codex_repair_command(bin_path: &str, real: &str) -> Option<String> {
|
||||
// brew formula(real 在 Cellar)→ 不归 npm 管,交回 anchored 走 brew upgrade。
|
||||
if brew_formula_from_path(real).is_some() {
|
||||
return None;
|
||||
}
|
||||
// 只认会落到 sibling npm 的 node 管理器来源;volta/bun/system/未知交回 anchored。
|
||||
if !matches!(
|
||||
infer_install_source(Path::new(bin_path)),
|
||||
"nvm" | "fnm" | "mise" | "homebrew"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let npm = sibling_bin(bin_path, "npm")?;
|
||||
let npm = quote_path_if_spaced(&npm);
|
||||
let pkg = "@openai/codex";
|
||||
Some(format!(
|
||||
"{npm} uninstall -g {pkg} || true; {npm} i -g {pkg}@latest"
|
||||
))
|
||||
}
|
||||
|
||||
/// Windows 暂不做平台分发自愈:Windows 上 codex 的破坏模式不同(EPERM 文件锁 / 版本 bump
|
||||
/// 残留,见 openai/codex#21872、#19824),且 `.bat` 链的错误处理与 POSIX `set -e` 语义不同,
|
||||
/// 需要单独设计;先在本问题实际发生的 POSIX 平台落地。返回 None → 上游走正常锚定命令。
|
||||
#[cfg(target_os = "windows")]
|
||||
fn codex_repair_command(_bin_path: &str, _real: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn package_manager_anchored_command_from_paths(
|
||||
tool: &str,
|
||||
@@ -2092,6 +2253,17 @@ fn default_install(installs: &[ToolInstallation]) -> Option<&ToolInstallation> {
|
||||
fn installs_anchored_command(tool: &str, installs: &[ToolInstallation]) -> Option<String> {
|
||||
let inst = default_install(installs)?;
|
||||
let real = inst.real.to_string_lossy();
|
||||
// Codex 平台分发包损坏自愈:主包在但平台二进制缺失时 codex 跑不起来
|
||||
// (runnable=false),此时正常锚定的 `npm i -g @latest` 是 no-op 修不好——改用
|
||||
// uninstall+install 重装补回平台二进制。**但仅限会锚定到 sibling npm 的 node 管理器
|
||||
// 来源**(codex_repair_command 内按 source/real 收窄,brew/volta/bun/system 交回下方
|
||||
// source-specific 锚定,避免误用 npm 重装)。runnable=true 的正常升级也走下方普通锚定
|
||||
// 路径(且因 codex 不在 prefers_official_update,不会再跑会假成功掩盖损坏的 `codex update`)。
|
||||
if tool == "codex" && !inst.runnable {
|
||||
if let Some(cmd) = codex_repair_command(&inst.path, &real) {
|
||||
return Some(cmd);
|
||||
}
|
||||
}
|
||||
anchored_command_from_paths(tool, &inst.path, &real)
|
||||
}
|
||||
|
||||
@@ -2522,29 +2694,58 @@ exec bash --norc --noprofile
|
||||
result
|
||||
}
|
||||
|
||||
/// macOS: Terminal.app
|
||||
/// Escape a value as an AppleScript string literal.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
fn applescript_string_literal(value: &str) -> String {
|
||||
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
|
||||
}
|
||||
|
||||
let applescript = format!(
|
||||
r#"tell application "Terminal"
|
||||
activate
|
||||
do script "bash '{}'"
|
||||
/// Build the launcher command literal used by AppleScript.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn applescript_launcher_command(script_file: &std::path::Path) -> String {
|
||||
applescript_string_literal(&format!(
|
||||
"bash {}",
|
||||
shell_single_quote(&script_file.to_string_lossy())
|
||||
))
|
||||
}
|
||||
|
||||
/// macOS: Terminal.app AppleScript.
|
||||
/// A cold `activate` creates a default empty window before `do script` opens the command session.
|
||||
/// Use `launch` for cold starts so `do script` can create the only new session without reusing restored windows.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn build_macos_terminal_applescript(script_file: &std::path::Path) -> String {
|
||||
format!(
|
||||
r#"set launcher_script to {launcher}
|
||||
set was_running to application "Terminal" is running
|
||||
tell application "Terminal"
|
||||
if was_running then
|
||||
activate
|
||||
do script launcher_script
|
||||
else
|
||||
launch
|
||||
do script launcher_script
|
||||
activate
|
||||
end if
|
||||
end tell"#,
|
||||
script_file.display()
|
||||
);
|
||||
launcher = applescript_launcher_command(script_file)
|
||||
)
|
||||
}
|
||||
|
||||
/// Run AppleScript through `osascript -e` with shared error handling.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn run_terminal_osascript(applescript: &str, terminal_label: &str) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(&applescript)
|
||||
.arg(applescript)
|
||||
.output()
|
||||
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stderr = decode_command_output(&output.stderr);
|
||||
return Err(format!(
|
||||
"Terminal.app 执行失败 (exit code: {:?}): {}",
|
||||
"{terminal_label} 执行失败 (exit code: {:?}): {}",
|
||||
output.status.code(),
|
||||
stderr
|
||||
));
|
||||
@@ -2553,11 +2754,20 @@ end tell"#,
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// macOS: Terminal.app
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_terminal_app(script_file: &std::path::Path) -> Result<(), String> {
|
||||
run_terminal_osascript(
|
||||
&build_macos_terminal_applescript(script_file),
|
||||
"Terminal.app",
|
||||
)
|
||||
}
|
||||
|
||||
/// macOS: iTerm2
|
||||
#[cfg(target_os = "macos")]
|
||||
fn build_macos_iterm2_applescript(script_file: &std::path::Path) -> String {
|
||||
format!(
|
||||
r#"set launcher_script to "bash '{}'"
|
||||
r#"set launcher_script to {launcher}
|
||||
set was_running to application "iTerm" is running
|
||||
tell application "iTerm"
|
||||
if was_running then
|
||||
@@ -2585,63 +2795,59 @@ tell application "iTerm"
|
||||
write text launcher_script
|
||||
end tell
|
||||
end tell"#,
|
||||
script_file.display()
|
||||
launcher = applescript_launcher_command(script_file)
|
||||
)
|
||||
}
|
||||
|
||||
/// macOS: iTerm2
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let applescript = build_macos_iterm2_applescript(script_file);
|
||||
|
||||
let output = Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(&applescript)
|
||||
.output()
|
||||
.map_err(|e| format!("执行 osascript 失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"iTerm2 执行失败 (exit code: {:?}): {}",
|
||||
output.status.code(),
|
||||
stderr
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
run_terminal_osascript(&build_macos_iterm2_applescript(script_file), "iTerm2")
|
||||
}
|
||||
|
||||
/// macOS: Ghostty — use --quit-after-last-window-closed to avoid cloning existing tabs
|
||||
/// Keep the launcher path inside a `bash -c` string.
|
||||
/// A bare `.sh` passed through `open --args` may also be opened as a document.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn build_macos_dash_c_command(script_file: &std::path::Path) -> String {
|
||||
format!(
|
||||
"exec bash {}",
|
||||
shell_single_quote(&script_file.to_string_lossy())
|
||||
)
|
||||
}
|
||||
|
||||
/// macOS: Ghostty.
|
||||
/// Warm starts use AppleScript to create one command window.
|
||||
/// Cold starts use `initial-command` so the first default surface runs the launcher.
|
||||
/// Do not use `initial-window=false` plus `new window`: cold launch can still create the default window first.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn build_macos_ghostty_applescript(script_file: &std::path::Path) -> String {
|
||||
format!(
|
||||
r#"set launcher_command to {launcher}
|
||||
set was_running to application "Ghostty" is running
|
||||
if was_running then
|
||||
tell application "Ghostty"
|
||||
new window with configuration {{command:launcher_command}}
|
||||
end tell
|
||||
else
|
||||
do shell script "open -na Ghostty --args --quit-after-last-window-closed=true " & quoted form of ("--initial-command=" & launcher_command)
|
||||
end if
|
||||
"#,
|
||||
launcher = applescript_launcher_command(script_file)
|
||||
)
|
||||
}
|
||||
|
||||
/// macOS: Ghostty
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_ghostty(script_file: &std::path::Path) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("open")
|
||||
.args([
|
||||
"-na",
|
||||
"Ghostty",
|
||||
"--args",
|
||||
"--quit-after-last-window-closed=true",
|
||||
"-e",
|
||||
"bash",
|
||||
])
|
||||
.arg(script_file)
|
||||
.output()
|
||||
.map_err(|e| format!("启动 Ghostty 失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"Ghostty 启动失败 (exit code: {:?}): {}",
|
||||
output.status.code(),
|
||||
stderr
|
||||
));
|
||||
match run_terminal_osascript(&build_macos_ghostty_applescript(script_file), "Ghostty") {
|
||||
Ok(()) => Ok(()),
|
||||
Err(applescript_error) => {
|
||||
log::warn!(
|
||||
"Ghostty AppleScript launch failed, falling back to open -na: {applescript_error}"
|
||||
);
|
||||
launch_macos_open_app("Ghostty", script_file, true)
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// macOS: 使用 open -na 启动支持 --args 参数的终端(Alacritty/Kitty/WezTerm/Kaku)
|
||||
@@ -2659,14 +2865,17 @@ fn launch_macos_open_app(
|
||||
if use_e_flag {
|
||||
cmd.arg("-e");
|
||||
}
|
||||
cmd.arg("bash").arg(script_file);
|
||||
// Keep the script path inside `bash -c`; a trailing bare `.sh` can be opened as a document.
|
||||
cmd.arg("bash")
|
||||
.arg("-c")
|
||||
.arg(build_macos_dash_c_command(script_file));
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| format!("启动 {app_name} 失败: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stderr = decode_command_output(&output.stderr);
|
||||
return Err(format!(
|
||||
"{} 启动失败 (exit code: {:?}): {}",
|
||||
app_name,
|
||||
@@ -2718,7 +2927,7 @@ fn launch_macos_warp(script_file: &std::path::Path) -> Result<(), String> {
|
||||
|
||||
let output = cmd.output().map_err(|e| format!("启动 Warp 失败: {e}"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stderr = decode_command_output(&output.stderr);
|
||||
return Err(format!(
|
||||
"Warp 启动失败 (exit code: {:?}): {}",
|
||||
output.status.code(),
|
||||
@@ -2961,7 +3170,7 @@ fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), S
|
||||
.map_err(|e| format!("启动 {} 失败: {e}", terminal_name))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stderr = decode_command_output(&output.stderr);
|
||||
return Err(format!(
|
||||
"{} 启动失败 (exit code: {:?}): {}",
|
||||
terminal_name,
|
||||
@@ -3911,8 +4120,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn codex_nvm_anchors_to_that_npm() {
|
||||
// Codex 官方 self-update 只在支持的 release 上生效;失败时仍写回同一个
|
||||
// node 的 npm,而非 PATH 第一个 npm。
|
||||
// Codex 不走 self-update(`codex update` 在 npm 安装上只是裸 `npm install -g`,
|
||||
// 却会假成功掩盖平台二进制漏装)——直接锚定到同一个 node 的 npm,而非 PATH
|
||||
// 第一个 npm。损坏时的 uninstall+install 自愈见 codex_missing_platform_binary_*。
|
||||
let cmd = anchored_command_from_paths(
|
||||
"codex",
|
||||
"/Users/me/.nvm/versions/node/v22.14.0/bin/codex",
|
||||
@@ -3920,7 +4130,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/codex update || /Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
|
||||
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3940,9 +4150,25 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volta_uses_volta_install() {
|
||||
fn volta_self_update_chain_anchors_to_volta() {
|
||||
// `~/.volta/bin` 通常不在 GUI 非登录 `bash -c` 的 PATH 里,且用户可能
|
||||
// PATH 上还有另一份 volta → 必须绝对路径锚定到命令行命中的这一份。
|
||||
// 用 openclaw(仍在 prefers_official_update)覆盖 volta 分支的 self-update 链;
|
||||
// codex 已改为不 self-update(见 codex_volta_anchors_to_volta_install)。
|
||||
let cmd = anchored_command_from_paths(
|
||||
"openclaw",
|
||||
"/Users/me/.volta/bin/openclaw",
|
||||
"/Users/me/.volta/tools/image/packages/openclaw/lib/node_modules/openclaw",
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("/Users/me/.volta/bin/openclaw update --yes || /Users/me/.volta/bin/volta install openclaw")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_volta_anchors_to_volta_install() {
|
||||
// codex 锚定到命令行命中的那份 volta,但不 self-update:纯 `volta install`。
|
||||
let cmd = anchored_command_from_paths(
|
||||
"codex",
|
||||
"/Users/me/.volta/bin/codex",
|
||||
@@ -3950,7 +4176,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("/Users/me/.volta/bin/codex update || /Users/me/.volta/bin/volta install @openai/codex")
|
||||
Some("/Users/me/.volta/bin/volta install @openai/codex")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3978,7 +4204,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("'/Users/my name/.volta/bin/codex' update || '/Users/my name/.volta/bin/volta' install @openai/codex")
|
||||
Some("'/Users/my name/.volta/bin/volta' install @openai/codex")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4046,7 +4272,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some(
|
||||
"/Users/me/.local/share/fnm_multishells/12345_abc/bin/codex update || /Users/me/.local/share/fnm_multishells/12345_abc/bin/npm i -g @openai/codex@latest"
|
||||
"/Users/me/.local/share/fnm_multishells/12345_abc/bin/npm i -g @openai/codex@latest"
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -4060,7 +4286,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("'/Users/my name/.nvm/versions/node/v22/bin/codex' update || '/Users/my name/.nvm/versions/node/v22/bin/npm' i -g @openai/codex@latest")
|
||||
Some("'/Users/my name/.nvm/versions/node/v22/bin/npm' i -g @openai/codex@latest")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4157,6 +4383,78 @@ mod tests {
|
||||
assert!(default_install(&installs).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_missing_platform_binary_self_heals_via_uninstall_install() {
|
||||
// 平台二进制缺失 → `codex --version` 报 "Missing optional dependency" 退出非 0
|
||||
// → enumerate 标记 runnable=false。此状态下普通 `npm i -g @latest` 是 no-op 修不好,
|
||||
// 升级路径改用 uninstall+install 重装补回平台二进制(`|| true` 让 uninstall 在
|
||||
// set -e 下对半损坏包返回非 0 时仍继续 install)。
|
||||
let mut broken = inst("/Users/me/.nvm/versions/node/v22.14.0/bin/codex", true);
|
||||
broken.runnable = false;
|
||||
assert_eq!(
|
||||
installs_anchored_command("codex", &[broken]).as_deref(),
|
||||
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm uninstall -g @openai/codex || true; /Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_runnable_uses_plain_npm_not_self_heal() {
|
||||
// 正常(runnable=true)的 codex 升级:锚定 npm,既不重装、也不跑会假成功
|
||||
// 掩盖损坏的 `codex update`。
|
||||
let healthy = inst("/Users/me/.nvm/versions/node/v22.14.0/bin/codex", true);
|
||||
let cmd = installs_anchored_command("codex", &[healthy]);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
|
||||
);
|
||||
assert!(!cmd.unwrap().contains("uninstall"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_broken_homebrew_formula_uses_brew_not_npm_repair() {
|
||||
// brew formula 装的坏 codex(real 在 Cellar):自愈门控必须收窄放行,回落到
|
||||
// `brew upgrade codex`——若误走 npm 重装,npm 够不到 Cellar 那份、反而旁路
|
||||
// 装第二份 npm 全局 codex 制造双安装。
|
||||
let broken = ToolInstallation {
|
||||
path: "/opt/homebrew/bin/codex".to_string(),
|
||||
version: None,
|
||||
runnable: false,
|
||||
error: None,
|
||||
source: "homebrew".to_string(),
|
||||
is_path_default: true,
|
||||
real: std::path::PathBuf::from("/opt/homebrew/Cellar/codex/1.2.3/bin/codex"),
|
||||
};
|
||||
assert_eq!(
|
||||
installs_anchored_command("codex", &[broken]).as_deref(),
|
||||
Some("/opt/homebrew/bin/brew upgrade codex")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_broken_volta_uses_volta_install_not_npm_repair() {
|
||||
// volta 装的坏 codex:回落到 `volta install`,不走 npm 重装。
|
||||
let mut broken = inst("/Users/me/.volta/bin/codex", true);
|
||||
broken.runnable = false;
|
||||
assert_eq!(
|
||||
installs_anchored_command("codex", &[broken]).as_deref(),
|
||||
Some("/Users/me/.volta/bin/volta install @openai/codex")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_broken_bun_uses_bun_add_not_phantom_npm() {
|
||||
// bun 装的坏 codex:回落到 `bun add`,且**绝不**拼出 `~/.bun/bin/npm`
|
||||
// (bun 目录下没有 npm,那条路径不存在、执行会直接失败)。
|
||||
let mut broken = inst("/Users/me/.bun/bin/codex", true);
|
||||
broken.runnable = false;
|
||||
let cmd = installs_anchored_command("codex", &[broken]);
|
||||
assert_eq!(
|
||||
cmd.as_deref(),
|
||||
Some("/Users/me/.bun/bin/bun add -g @openai/codex@latest")
|
||||
);
|
||||
assert!(!cmd.unwrap().contains("npm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_abs_path_line_skips_shell_noise() {
|
||||
// 交互式 .zshrc 先打印欢迎语(如 powerlevel10k / 自定义提示),
|
||||
@@ -4284,8 +4582,9 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
static_fallback_command("codex"),
|
||||
"codex update || npm i -g @openai/codex@latest"
|
||||
"npm i -g @openai/codex@latest"
|
||||
);
|
||||
assert!(!static_fallback_command("codex").contains("codex update"));
|
||||
assert_eq!(
|
||||
static_fallback_command("gemini"),
|
||||
"npm i -g @google/gemini-cli@latest"
|
||||
@@ -4595,6 +4894,124 @@ mod tests {
|
||||
assert!(running_branch.contains("create tab with default profile"));
|
||||
}
|
||||
|
||||
/// Terminal `activate` creates a default empty window on cold start; `launch` does not.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn terminal_applescript_cold_start_uses_launch_before_do_script() {
|
||||
let script = build_macos_terminal_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
|
||||
|
||||
assert!(
|
||||
script.contains(r#"set was_running to application "Terminal" is running"#),
|
||||
"missing was_running detection:\n{script}"
|
||||
);
|
||||
// Cold launches avoid `activate` until after `do script`, so no default empty window is created first.
|
||||
assert!(
|
||||
script.contains(
|
||||
"else\n launch\n do script launcher_script\n activate"
|
||||
),
|
||||
"cold start should launch before activating:\n{script}"
|
||||
);
|
||||
// Already-running launches should create a fresh session.
|
||||
assert!(
|
||||
script.contains(
|
||||
"if was_running then\n activate\n do script launcher_script\n"
|
||||
),
|
||||
"already-running branch should use bare do script:\n{script}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Restored windows should not receive the launcher command.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn terminal_applescript_does_not_hijack_restored_windows() {
|
||||
let script = build_macos_terminal_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
|
||||
assert!(
|
||||
!script.contains(" in window 1"),
|
||||
"should not inject into an existing/restored Terminal window:\n{script}"
|
||||
);
|
||||
assert!(
|
||||
!script.contains("count of windows"),
|
||||
"should not infer restored-window safety from window count:\n{script}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Ghostty cold starts use `initial-command`; warm starts use the scripting dictionary.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn ghostty_applescript_cold_start_uses_initial_command() {
|
||||
let script = build_macos_ghostty_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
|
||||
|
||||
// Warm launches execute through the AppleScript command property, not `open -na ... -e`.
|
||||
assert!(
|
||||
script.contains(r#"set launcher_command to "bash '/tmp/cc_switch_launcher.sh'""#),
|
||||
"missing launcher_command:\n{script}"
|
||||
);
|
||||
assert!(script.contains("if was_running then"));
|
||||
assert!(script.contains("new window with configuration {command:launcher_command}"));
|
||||
assert!(
|
||||
!script.contains(" --args -e"),
|
||||
"should not execute through open -na -e:\n{script}"
|
||||
);
|
||||
// Cold launches make Ghostty's first default surface execute the launcher.
|
||||
assert!(script.contains(r#"set was_running to application "Ghostty" is running"#));
|
||||
assert!(
|
||||
script.contains(
|
||||
r#"do shell script "open -na Ghostty --args --quit-after-last-window-closed=true " & quoted form of ("--initial-command=" & launcher_command)"#
|
||||
),
|
||||
"cold start should use initial-command:\n{script}"
|
||||
);
|
||||
assert!(
|
||||
!script.contains("--initial-window=false"),
|
||||
"should not rely on initial-window=false:\n{script}"
|
||||
);
|
||||
assert!(
|
||||
!script.contains("delay 0.5"),
|
||||
"should not rely on a fixed delay:\n{script}"
|
||||
);
|
||||
assert!(
|
||||
!script.contains("old_ids"),
|
||||
"should not track default windows for closing:\n{script}"
|
||||
);
|
||||
assert!(
|
||||
!script.contains("close window"),
|
||||
"should not close a default window:\n{script}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn dash_c_command_wraps_script_path_inside_quoted_arg() {
|
||||
// The script path must stay inside the `-c` string, not as a bare argv.
|
||||
let s = build_macos_dash_c_command(Path::new("/tmp/cc_switch_launcher_1.sh"));
|
||||
assert_eq!(s, "exec bash '/tmp/cc_switch_launcher_1.sh'");
|
||||
|
||||
// Spaces and single quotes must stay shell-safe too.
|
||||
let s2 = build_macos_dash_c_command(Path::new("/Users/me/it's dir/x.sh"));
|
||||
assert_eq!(s2, r#"exec bash '/Users/me/it'"'"'s dir/x.sh'"#);
|
||||
}
|
||||
|
||||
/// AppleScript launchers need both shell-path quoting and AppleScript string quoting.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn applescript_builders_safely_quote_special_paths() {
|
||||
// First shell-quote the path, then wrap the whole command as an AppleScript string.
|
||||
let expected = r#""bash '/Users/me/it'\"'\"'s dir/x.sh'""#;
|
||||
let p = Path::new("/Users/me/it's dir/x.sh");
|
||||
assert_eq!(applescript_launcher_command(p), expected);
|
||||
assert!(
|
||||
build_macos_terminal_applescript(p).contains(expected),
|
||||
"Terminal did not quote safely"
|
||||
);
|
||||
assert!(
|
||||
build_macos_iterm2_applescript(p).contains(expected),
|
||||
"iTerm2 did not quote safely"
|
||||
);
|
||||
assert!(
|
||||
build_macos_ghostty_applescript(p).contains(expected),
|
||||
"Ghostty did not quote safely"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_windows_cwd_command_str_uses_cd_for_drive_paths() {
|
||||
let command = build_windows_cwd_command_str(r"C:\work\repo");
|
||||
|
||||
@@ -29,6 +29,7 @@ mod subscription;
|
||||
mod sync_support;
|
||||
|
||||
mod lightweight;
|
||||
mod s3_sync;
|
||||
mod usage;
|
||||
mod webdav_sync;
|
||||
mod workspace;
|
||||
@@ -61,6 +62,7 @@ pub use stream_check::*;
|
||||
pub use subscription::*;
|
||||
|
||||
pub use lightweight::*;
|
||||
pub use s3_sync::*;
|
||||
pub use usage::*;
|
||||
pub use webdav_sync::*;
|
||||
pub use workspace::*;
|
||||
|
||||
@@ -14,12 +14,18 @@ pub async fn fetch_models_for_config(
|
||||
api_key: String,
|
||||
is_full_url: Option<bool>,
|
||||
models_url: Option<String>,
|
||||
custom_user_agent: Option<String>,
|
||||
) -> Result<Vec<FetchedModel>, String> {
|
||||
// 与转发 / 检测路径共用 parse_custom_user_agent:非法 UA 静默忽略(不阻断取模型)。
|
||||
let user_agent = crate::provider::parse_custom_user_agent(custom_user_agent.as_deref())
|
||||
.ok()
|
||||
.flatten();
|
||||
model_fetch::fetch_models(
|
||||
&base_url,
|
||||
&api_key,
|
||||
is_full_url.unwrap_or(false),
|
||||
models_url.as_deref(),
|
||||
user_agent,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::str::FromStr;
|
||||
const TEMPLATE_TYPE_GITHUB_COPILOT: &str = "github_copilot";
|
||||
const TEMPLATE_TYPE_TOKEN_PLAN: &str = "token_plan";
|
||||
const TEMPLATE_TYPE_BALANCE: &str = "balance";
|
||||
const TEMPLATE_TYPE_OFFICIAL_SUBSCRIPTION: &str = "official_subscription";
|
||||
const COPILOT_UNIT_PREMIUM: &str = "requests";
|
||||
|
||||
/// 获取所有供应商
|
||||
@@ -219,6 +220,17 @@ pub fn import_claude_desktop_providers_from_claude(
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ensure_claude_desktop_official_provider(state: State<'_, AppState>) -> Result<bool, String> {
|
||||
state
|
||||
.db
|
||||
.ensure_official_seed_by_id(
|
||||
crate::database::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID,
|
||||
AppType::ClaudeDesktop,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool {
|
||||
let Some(env) = provider
|
||||
.settings_config
|
||||
@@ -398,6 +410,50 @@ pub async fn queryProviderUsage(
|
||||
inner
|
||||
}
|
||||
|
||||
/// Resolve `(base_url, api_key)` for native usage queries, delegating to the
|
||||
/// per-app resolver on `Provider`. Missing provider → empty credentials.
|
||||
fn resolve_native_credentials(app_type: &AppType, provider: Option<&Provider>) -> (String, String) {
|
||||
provider
|
||||
.map(|p| p.resolve_usage_credentials(app_type))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn resolve_coding_plan_credentials(
|
||||
app_type: &AppType,
|
||||
provider: Option<&Provider>,
|
||||
usage_script: Option<&crate::provider::UsageScript>,
|
||||
) -> (String, String) {
|
||||
let is_zenmux = usage_script
|
||||
.and_then(|s| s.coding_plan_provider.as_deref())
|
||||
.map(|provider| provider.eq_ignore_ascii_case("zenmux"))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_zenmux {
|
||||
return resolve_native_credentials(app_type, provider);
|
||||
}
|
||||
|
||||
let script_base_url = usage_script
|
||||
.and_then(|s| s.base_url.as_deref())
|
||||
.unwrap_or("")
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let script_api_key = usage_script
|
||||
.and_then(|s| s.api_key.as_deref())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if !script_base_url.is_empty() && !script_api_key.is_empty() {
|
||||
return (script_base_url, script_api_key);
|
||||
}
|
||||
|
||||
let native = resolve_native_credentials(app_type, provider);
|
||||
if !native.0.is_empty() && !native.1.is_empty() {
|
||||
native
|
||||
} else {
|
||||
(script_base_url, script_api_key)
|
||||
}
|
||||
}
|
||||
|
||||
async fn query_provider_usage_inner(
|
||||
state: &AppState,
|
||||
copilot_state: &CopilotAuthState,
|
||||
@@ -455,25 +511,10 @@ async fn query_provider_usage_inner(
|
||||
|
||||
// ── Coding Plan 专用路径 ──
|
||||
if template_type == TEMPLATE_TYPE_TOKEN_PLAN {
|
||||
// 从供应商配置中提取 API Key 和 Base URL
|
||||
let settings_config = provider
|
||||
.map(|p| &p.settings_config)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let env = settings_config.get("env");
|
||||
let base_url = env
|
||||
.and_then(|e| e.get("ANTHROPIC_BASE_URL"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let api_key = env
|
||||
.and_then(|e| {
|
||||
e.get("ANTHROPIC_AUTH_TOKEN")
|
||||
.or_else(|| e.get("ANTHROPIC_API_KEY"))
|
||||
})
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let (base_url, api_key) =
|
||||
resolve_coding_plan_credentials(&app_type, provider, usage_script);
|
||||
|
||||
let quota = crate::services::coding_plan::get_coding_plan_quota(base_url, api_key)
|
||||
let quota = crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query coding plan: {e}"))?;
|
||||
|
||||
@@ -486,6 +527,19 @@ async fn query_provider_usage_inner(
|
||||
});
|
||||
}
|
||||
|
||||
// ZenMux 的 tier 携带 USD 额度信息,需要编码为 JSON extra
|
||||
let has_usd = quota
|
||||
.tiers
|
||||
.first()
|
||||
.map(|t| t.used_value_usd.is_some())
|
||||
.unwrap_or(false);
|
||||
let plan_label = quota
|
||||
.credential_message
|
||||
.as_deref()
|
||||
.and_then(|msg| msg.split(' ').next())
|
||||
.map(|tier| format!("ZenMux·{}", tier.to_uppercase()));
|
||||
let mut first_tier = true;
|
||||
|
||||
let data: Vec<crate::provider::UsageData> = quota
|
||||
.tiers
|
||||
.iter()
|
||||
@@ -493,6 +547,26 @@ async fn query_provider_usage_inner(
|
||||
let total = 100.0;
|
||||
let used = tier.utilization;
|
||||
let remaining = total - used;
|
||||
let extra = if has_usd {
|
||||
let mut extra_json = serde_json::json!({
|
||||
"resetsAt": tier.resets_at,
|
||||
});
|
||||
if let Some(v) = tier.used_value_usd {
|
||||
extra_json["usedValueUsd"] = serde_json::json!(v);
|
||||
}
|
||||
if let Some(v) = tier.max_value_usd {
|
||||
extra_json["maxValueUsd"] = serde_json::json!(v);
|
||||
}
|
||||
if first_tier {
|
||||
if let Some(ref label) = plan_label {
|
||||
extra_json["planLabel"] = serde_json::json!(label);
|
||||
}
|
||||
first_tier = false;
|
||||
}
|
||||
Some(extra_json.to_string())
|
||||
} else {
|
||||
tier.resets_at.clone()
|
||||
};
|
||||
crate::provider::UsageData {
|
||||
plan_name: Some(tier.name.clone()),
|
||||
remaining: Some(remaining),
|
||||
@@ -501,7 +575,7 @@ async fn query_provider_usage_inner(
|
||||
unit: Some("%".to_string()),
|
||||
is_valid: Some(true),
|
||||
invalid_message: None,
|
||||
extra: tier.resets_at.clone(),
|
||||
extra,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -515,28 +589,58 @@ async fn query_provider_usage_inner(
|
||||
|
||||
// ── 官方余额查询路径 ──
|
||||
if template_type == TEMPLATE_TYPE_BALANCE {
|
||||
let settings_config = provider
|
||||
.map(|p| &p.settings_config)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let env = settings_config.get("env");
|
||||
let base_url = env
|
||||
.and_then(|e| e.get("ANTHROPIC_BASE_URL"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let api_key = env
|
||||
.and_then(|e| {
|
||||
e.get("ANTHROPIC_AUTH_TOKEN")
|
||||
.or_else(|| e.get("ANTHROPIC_API_KEY"))
|
||||
})
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
// 按 app 区分的凭据存储格式提取 Base URL 与 API Key
|
||||
let (base_url, api_key) = resolve_native_credentials(&app_type, provider);
|
||||
|
||||
return crate::services::balance::get_balance(base_url, api_key)
|
||||
return crate::services::balance::get_balance(&base_url, &api_key)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query balance: {e}"));
|
||||
}
|
||||
|
||||
// ── 官方订阅额度查询路径 ──
|
||||
if template_type == TEMPLATE_TYPE_OFFICIAL_SUBSCRIPTION {
|
||||
if !usage_script.map(|s| s.enabled).unwrap_or(false) {
|
||||
return Ok(crate::provider::UsageResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some("Usage query is disabled".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
let quota = crate::services::subscription::get_subscription_quota(app_type.as_str())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query subscription quota: {e}"))?;
|
||||
|
||||
if !quota.success {
|
||||
return Ok(crate::provider::UsageResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: quota.error.or(quota.credential_message),
|
||||
});
|
||||
}
|
||||
|
||||
let data: Vec<crate::provider::UsageData> = quota
|
||||
.tiers
|
||||
.iter()
|
||||
.map(|tier| crate::provider::UsageData {
|
||||
plan_name: Some(tier.name.clone()),
|
||||
remaining: Some(100.0 - tier.utilization),
|
||||
total: Some(100.0),
|
||||
used: Some(tier.utilization),
|
||||
unit: Some("%".to_string()),
|
||||
is_valid: Some(true),
|
||||
invalid_message: None,
|
||||
extra: tier.resets_at.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
return Ok(crate::provider::UsageResult {
|
||||
success: true,
|
||||
data: if data.is_empty() { None } else { Some(data) },
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 通用 JS 脚本路径 ──
|
||||
ProviderService::query_usage(state, app_type, provider_id)
|
||||
.await
|
||||
@@ -957,3 +1061,104 @@ mod import_claude_desktop_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod native_query_credentials_tests {
|
||||
use super::{resolve_coding_plan_credentials, resolve_native_credentials};
|
||||
use crate::app_config::AppType;
|
||||
use crate::provider::{Provider, UsageScript};
|
||||
use serde_json::json;
|
||||
|
||||
fn usage_script(
|
||||
coding_plan_provider: Option<&str>,
|
||||
base_url: Option<&str>,
|
||||
api_key: Option<&str>,
|
||||
) -> UsageScript {
|
||||
UsageScript {
|
||||
enabled: true,
|
||||
language: "javascript".to_string(),
|
||||
code: String::new(),
|
||||
timeout: Some(10),
|
||||
api_key: api_key.map(str::to_string),
|
||||
base_url: base_url.map(str::to_string),
|
||||
access_token: None,
|
||||
user_id: None,
|
||||
template_type: Some("token_plan".to_string()),
|
||||
auto_query_interval: None,
|
||||
coding_plan_provider: coding_plan_provider.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegates_to_provider_for_codex() {
|
||||
let provider = Provider::with_id(
|
||||
"test".to_string(),
|
||||
"Test".to_string(),
|
||||
json!({
|
||||
"auth": { "OPENAI_API_KEY": "sk-codex" },
|
||||
"config": "model_provider = \"deepseek\"\n\
|
||||
[model_providers.deepseek]\n\
|
||||
base_url = \"https://api.deepseek.com\"\n",
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let (base_url, api_key) = resolve_native_credentials(&AppType::Codex, Some(&provider));
|
||||
assert_eq!(base_url, "https://api.deepseek.com");
|
||||
assert_eq!(api_key, "sk-codex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_provider_yields_empty() {
|
||||
let (base_url, api_key) = resolve_native_credentials(&AppType::Codex, None);
|
||||
assert!(base_url.is_empty());
|
||||
assert!(api_key.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zenmux_coding_plan_uses_script_credentials_first() {
|
||||
let provider = Provider::with_id(
|
||||
"test".to_string(),
|
||||
"Test".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://provider.zenmux.example/v1",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-provider"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let script = usage_script(
|
||||
Some("zenmux"),
|
||||
Some("https://script.zenmux.example/api/usage/"),
|
||||
Some("sk-script"),
|
||||
);
|
||||
|
||||
let (base_url, api_key) =
|
||||
resolve_coding_plan_credentials(&AppType::Claude, Some(&provider), Some(&script));
|
||||
|
||||
assert_eq!(base_url, "https://script.zenmux.example/api/usage");
|
||||
assert_eq!(api_key, "sk-script");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zenmux_coding_plan_falls_back_to_provider_credentials() {
|
||||
let provider = Provider::with_id(
|
||||
"test".to_string(),
|
||||
"Test".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://provider.zenmux.example/v1",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-provider"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let script = usage_script(Some("zenmux"), Some("https://script.zenmux.example"), None);
|
||||
|
||||
let (base_url, api_key) =
|
||||
resolve_coding_plan_credentials(&AppType::Claude, Some(&provider), Some(&script));
|
||||
|
||||
assert_eq!(base_url, "https://provider.zenmux.example/v1");
|
||||
assert_eq!(api_key, "sk-provider");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::sync_support::{
|
||||
attach_warning, post_sync_warning_from_result, run_post_import_sync,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::services::s3_sync as s3_sync_service;
|
||||
use crate::settings::{self, S3SyncSettings};
|
||||
use crate::store::AppState;
|
||||
|
||||
fn persist_sync_error(settings: &mut S3SyncSettings, error: &AppError, source: &str) {
|
||||
settings.status.last_error = Some(error.to_string());
|
||||
settings.status.last_error_source = Some(source.to_string());
|
||||
let _ = settings::update_s3_sync_status(settings.status.clone());
|
||||
}
|
||||
|
||||
fn s3_not_configured_error() -> String {
|
||||
AppError::localized(
|
||||
"s3.sync.not_configured",
|
||||
"未配置 S3 同步",
|
||||
"S3 sync is not configured.",
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn s3_sync_disabled_error() -> String {
|
||||
AppError::localized("s3.sync.disabled", "S3 同步未启用", "S3 sync is disabled.").to_string()
|
||||
}
|
||||
|
||||
fn require_enabled_s3_settings() -> Result<S3SyncSettings, String> {
|
||||
let settings = settings::get_s3_sync_settings().ok_or_else(s3_not_configured_error)?;
|
||||
if !settings.enabled {
|
||||
return Err(s3_sync_disabled_error());
|
||||
}
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
fn resolve_secret_for_request(
|
||||
mut incoming: S3SyncSettings,
|
||||
existing: Option<S3SyncSettings>,
|
||||
preserve_empty_secret: bool,
|
||||
) -> S3SyncSettings {
|
||||
if let Some(existing_settings) = existing {
|
||||
if preserve_empty_secret && incoming.secret_access_key.is_empty() {
|
||||
incoming.secret_access_key = existing_settings.secret_access_key;
|
||||
}
|
||||
}
|
||||
incoming
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn s3_sync_mutex() -> &'static tokio::sync::Mutex<()> {
|
||||
s3_sync_service::sync_mutex()
|
||||
}
|
||||
|
||||
async fn run_with_s3_lock<T, Fut>(operation: Fut) -> Result<T, AppError>
|
||||
where
|
||||
Fut: std::future::Future<Output = Result<T, AppError>>,
|
||||
{
|
||||
s3_sync_service::run_with_sync_lock(operation).await
|
||||
}
|
||||
|
||||
fn map_sync_result<T, F>(result: Result<T, AppError>, on_error: F) -> Result<T, String>
|
||||
where
|
||||
F: FnOnce(&AppError),
|
||||
{
|
||||
match result {
|
||||
Ok(value) => Ok(value),
|
||||
Err(err) => {
|
||||
on_error(&err);
|
||||
Err(err.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_test_connection(
|
||||
settings: S3SyncSettings,
|
||||
#[allow(non_snake_case)] preserveEmptyPassword: Option<bool>,
|
||||
) -> Result<Value, String> {
|
||||
let preserve_empty = preserveEmptyPassword.unwrap_or(true);
|
||||
let resolved =
|
||||
resolve_secret_for_request(settings, settings::get_s3_sync_settings(), preserve_empty);
|
||||
s3_sync_service::check_connection(&resolved)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"message": "S3 connection ok"
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_sync_upload(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
let db = state.db.clone();
|
||||
let mut settings = require_enabled_s3_settings()?;
|
||||
|
||||
let result = run_with_s3_lock(s3_sync_service::upload(&db, &mut settings)).await;
|
||||
map_sync_result(result, |error| {
|
||||
persist_sync_error(&mut settings, error, "manual")
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_sync_download(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
let db = state.db.clone();
|
||||
let db_for_sync = db.clone();
|
||||
let mut settings = require_enabled_s3_settings()?;
|
||||
let _auto_sync_suppression = crate::services::s3_auto_sync::AutoSyncSuppressionGuard::new();
|
||||
|
||||
let sync_result = run_with_s3_lock(s3_sync_service::download(&db, &mut settings)).await;
|
||||
let mut result = map_sync_result(sync_result, |error| {
|
||||
persist_sync_error(&mut settings, error, "manual")
|
||||
})?;
|
||||
|
||||
// Post-download sync is best-effort: snapshot restore has already succeeded.
|
||||
let warning = post_sync_warning_from_result(
|
||||
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(db_for_sync))
|
||||
.await
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
if let Some(msg) = warning.as_ref() {
|
||||
log::warn!("[S3] post-download sync warning: {msg}");
|
||||
}
|
||||
result = attach_warning(result, warning);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_sync_save_settings(
|
||||
settings: S3SyncSettings,
|
||||
#[allow(non_snake_case)] passwordTouched: Option<bool>,
|
||||
) -> Result<Value, String> {
|
||||
let password_touched = passwordTouched.unwrap_or(false);
|
||||
let existing = settings::get_s3_sync_settings();
|
||||
let mut sync_settings =
|
||||
resolve_secret_for_request(settings, existing.clone(), !password_touched);
|
||||
|
||||
// Preserve server-owned fields that the frontend does not manage
|
||||
if let Some(existing_settings) = existing {
|
||||
sync_settings.status = existing_settings.status;
|
||||
}
|
||||
|
||||
sync_settings.normalize();
|
||||
sync_settings.validate().map_err(|e| e.to_string())?;
|
||||
settings::set_s3_sync_settings(Some(sync_settings)).map_err(|e| e.to_string())?;
|
||||
Ok(json!({ "success": true }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_sync_fetch_remote_info() -> Result<Value, String> {
|
||||
let settings = require_enabled_s3_settings()?;
|
||||
let info = s3_sync_service::fetch_remote_info(&settings)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(info.unwrap_or(json!({ "empty": true })))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
map_sync_result, persist_sync_error, require_enabled_s3_settings,
|
||||
resolve_secret_for_request, run_with_s3_lock, s3_sync_mutex,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::settings::{AppSettings, S3SyncSettings};
|
||||
use serial_test::serial;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn s3_sync_mutex_is_singleton() {
|
||||
let a = s3_sync_mutex() as *const _;
|
||||
let b = s3_sync_mutex() as *const _;
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn s3_sync_mutex_serializes_concurrent_access() {
|
||||
let guard = s3_sync_mutex().lock().await;
|
||||
let acquired = Arc::new(AtomicBool::new(false));
|
||||
let acquired_bg = Arc::clone(&acquired);
|
||||
|
||||
let waiter = tokio::spawn(async move {
|
||||
let _inner_guard = s3_sync_mutex().lock().await;
|
||||
acquired_bg.store(true, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(40)).await;
|
||||
assert!(!acquired.load(Ordering::SeqCst));
|
||||
|
||||
drop(guard);
|
||||
tokio::time::timeout(Duration::from_secs(1), waiter)
|
||||
.await
|
||||
.expect("background task should complete after lock release")
|
||||
.expect("background task should not panic");
|
||||
|
||||
assert!(acquired.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn map_sync_result_runs_error_handler_after_lock_release() {
|
||||
let result =
|
||||
run_with_s3_lock(async { Err::<(), AppError>(AppError::Config("boom".to_string())) })
|
||||
.await;
|
||||
|
||||
let mut lock_released = false;
|
||||
let mapped = map_sync_result(result, |_| {
|
||||
lock_released = s3_sync_mutex().try_lock().is_ok();
|
||||
});
|
||||
|
||||
assert!(mapped.is_err());
|
||||
assert!(lock_released);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_secret_for_request_preserves_existing_when_requested() {
|
||||
let incoming = S3SyncSettings {
|
||||
region: "us-east-1".to_string(),
|
||||
bucket: "my-bucket".to_string(),
|
||||
access_key_id: "AKID".to_string(),
|
||||
secret_access_key: String::new(),
|
||||
..S3SyncSettings::default()
|
||||
};
|
||||
let existing = Some(S3SyncSettings {
|
||||
secret_access_key: "SECRET".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
});
|
||||
let resolved = resolve_secret_for_request(incoming, existing, true);
|
||||
assert_eq!(resolved.secret_access_key, "SECRET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_secret_for_request_allows_explicit_empty_secret() {
|
||||
let incoming = S3SyncSettings {
|
||||
region: "us-east-1".to_string(),
|
||||
bucket: "my-bucket".to_string(),
|
||||
access_key_id: "AKID".to_string(),
|
||||
secret_access_key: String::new(),
|
||||
..S3SyncSettings::default()
|
||||
};
|
||||
let existing = Some(S3SyncSettings {
|
||||
secret_access_key: "SECRET".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
});
|
||||
let resolved = resolve_secret_for_request(incoming, existing, false);
|
||||
assert!(resolved.secret_access_key.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn persist_sync_error_updates_status_without_overwriting_credentials() {
|
||||
let test_home = std::env::temp_dir().join("cc-switch-s3-sync-error-status-test");
|
||||
let _ = std::fs::remove_dir_all(&test_home);
|
||||
std::fs::create_dir_all(&test_home).expect("create test home");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
|
||||
|
||||
crate::settings::update_settings(AppSettings::default()).expect("reset settings");
|
||||
let mut current = S3SyncSettings {
|
||||
enabled: true,
|
||||
region: "us-east-1".to_string(),
|
||||
bucket: "my-bucket".to_string(),
|
||||
access_key_id: "AKID".to_string(),
|
||||
secret_access_key: "SECRET".to_string(),
|
||||
remote_root: "cc-switch-sync".to_string(),
|
||||
profile: "default".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
};
|
||||
crate::settings::set_s3_sync_settings(Some(current.clone())).expect("seed s3 settings");
|
||||
|
||||
persist_sync_error(
|
||||
&mut current,
|
||||
&crate::error::AppError::Config("boom".to_string()),
|
||||
"manual",
|
||||
);
|
||||
|
||||
let after = crate::settings::get_s3_sync_settings().expect("read s3 settings");
|
||||
assert_eq!(after.region, "us-east-1");
|
||||
assert_eq!(after.bucket, "my-bucket");
|
||||
assert_eq!(after.access_key_id, "AKID");
|
||||
assert_eq!(after.secret_access_key, "SECRET");
|
||||
assert_eq!(after.remote_root, "cc-switch-sync");
|
||||
assert_eq!(after.profile, "default");
|
||||
assert!(
|
||||
after
|
||||
.status
|
||||
.last_error
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("boom"),
|
||||
"status error should be updated"
|
||||
);
|
||||
assert_eq!(after.status.last_error_source.as_deref(), Some("manual"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn require_enabled_s3_settings_rejects_disabled_config() {
|
||||
let test_home = std::env::temp_dir().join("cc-switch-s3-sync-enabled-disabled-test");
|
||||
let _ = std::fs::remove_dir_all(&test_home);
|
||||
std::fs::create_dir_all(&test_home).expect("create test home");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
|
||||
|
||||
crate::settings::update_settings(AppSettings::default()).expect("reset settings");
|
||||
crate::settings::set_s3_sync_settings(Some(S3SyncSettings {
|
||||
enabled: false,
|
||||
region: "us-east-1".to_string(),
|
||||
bucket: "my-bucket".to_string(),
|
||||
access_key_id: "AKID".to_string(),
|
||||
secret_access_key: "SECRET".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
}))
|
||||
.expect("seed disabled s3 settings");
|
||||
|
||||
let err = require_enabled_s3_settings().expect_err("disabled settings should fail");
|
||||
assert!(
|
||||
err.contains("disabled") || err.contains("未启用"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn require_enabled_s3_settings_returns_settings_when_enabled() {
|
||||
let test_home = std::env::temp_dir().join("cc-switch-s3-sync-enabled-ok-test");
|
||||
let _ = std::fs::remove_dir_all(&test_home);
|
||||
std::fs::create_dir_all(&test_home).expect("create test home");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
|
||||
|
||||
crate::settings::update_settings(AppSettings::default()).expect("reset settings");
|
||||
crate::settings::set_s3_sync_settings(Some(S3SyncSettings {
|
||||
enabled: true,
|
||||
region: "us-east-1".to_string(),
|
||||
bucket: "my-bucket".to_string(),
|
||||
access_key_id: "AKID".to_string(),
|
||||
secret_access_key: "SECRET".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
}))
|
||||
.expect("seed enabled s3 settings");
|
||||
|
||||
let settings = require_enabled_s3_settings().expect("enabled settings should be accepted");
|
||||
assert!(settings.enabled);
|
||||
assert_eq!(settings.region, "us-east-1");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
fn merge_settings_for_save(
|
||||
mut incoming: crate::settings::AppSettings,
|
||||
@@ -21,24 +22,25 @@ fn merge_settings_for_save(
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if incoming.local_migrations.is_none() {
|
||||
incoming.local_migrations = existing.local_migrations.clone();
|
||||
} else if let (Some(incoming_migrations), Some(existing_migrations)) =
|
||||
(&mut incoming.local_migrations, &existing.local_migrations)
|
||||
{
|
||||
if incoming_migrations
|
||||
.codex_third_party_history_provider_bucket_v1
|
||||
.is_none()
|
||||
match (&mut incoming.s3_sync, &existing.s3_sync) {
|
||||
// incoming 没有 s3 → 保留现有
|
||||
(None, _) => {
|
||||
incoming.s3_sync = existing.s3_sync.clone();
|
||||
}
|
||||
// incoming 有 s3 但密钥为空,且现有有密钥 → 填回现有密钥
|
||||
(Some(incoming_sync), Some(existing_sync))
|
||||
if incoming_sync.secret_access_key.is_empty()
|
||||
&& !existing_sync.secret_access_key.is_empty() =>
|
||||
{
|
||||
incoming_migrations.codex_third_party_history_provider_bucket_v1 = existing_migrations
|
||||
.codex_third_party_history_provider_bucket_v1
|
||||
.clone();
|
||||
}
|
||||
if incoming_migrations.codex_provider_template_v1.is_none() {
|
||||
incoming_migrations.codex_provider_template_v1 =
|
||||
existing_migrations.codex_provider_template_v1.clone();
|
||||
incoming_sync.secret_access_key = existing_sync.secret_access_key.clone();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// local_migrations 是纯后端状态(迁移完成标记),前端没有合法的修改场景,
|
||||
// 无条件取现有值。若按 incoming 透传:后端清掉 marker(如关闭统一会话
|
||||
// 开关)后、前端 query 缓存刷新前的一次全量保存会把旧 marker 重放回来,
|
||||
// 重新开启时被"复活"的标记挡住而漏迁。
|
||||
incoming.local_migrations = existing.local_migrations.clone();
|
||||
incoming
|
||||
}
|
||||
|
||||
@@ -50,13 +52,117 @@ pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
|
||||
|
||||
/// 保存设置
|
||||
#[tauri::command]
|
||||
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
|
||||
pub async fn save_settings(
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
settings: crate::settings::AppSettings,
|
||||
) -> Result<bool, String> {
|
||||
let existing = crate::settings::get_settings();
|
||||
let merged = merge_settings_for_save(settings, &existing);
|
||||
let unify_codex_changed =
|
||||
merged.unify_codex_session_history != existing.unify_codex_session_history;
|
||||
let unify_codex_enabled = merged.unify_codex_session_history;
|
||||
crate::settings::update_settings(merged).map_err(|e| e.to_string())?;
|
||||
|
||||
// 统一会话开关变更时立即重写当前官方 Codex 供应商的 live 配置,
|
||||
// 不必等下一次切换才生效。
|
||||
if unify_codex_changed {
|
||||
// live 重写失败时回滚设置并把保存整体报失败:若设置保持已切换状态,
|
||||
// live 仍跑旧桶,后续的历史迁移/还原会让会话再次分裂(开启=历史
|
||||
// 迁走而新会话仍写 openai 桶;关闭=会话还原而 live 仍写 custom)。
|
||||
// 报错让前端 saved=false 短路还原;回滚是整次保存的事务语义
|
||||
// (本开关的保存只携带开关相关字段)。
|
||||
if let Err(err) =
|
||||
crate::services::provider::reapply_current_codex_official_live(state.inner())
|
||||
{
|
||||
log::warn!("统一 Codex 会话历史开关变更后重写 live 配置失败,回滚设置: {err}");
|
||||
if let Err(rollback_err) = crate::settings::update_settings(existing) {
|
||||
log::error!("回滚统一会话开关设置失败: {rollback_err}");
|
||||
}
|
||||
return Err(format!(
|
||||
"统一 Codex 会话历史开关未生效(live 配置重写失败): {err}"
|
||||
));
|
||||
}
|
||||
|
||||
if unify_codex_enabled {
|
||||
// 后台执行存量迁移(openai 桶 → custom 桶;仅当用户勾选了迁入既有
|
||||
// 会话,函数内部自门控)。大会话目录可能要读数秒,不能阻塞设置保存;
|
||||
// 失败时不写完成标记,下次启动自动重试。
|
||||
tauri::async_runtime::spawn_blocking(|| {
|
||||
match crate::codex_history_migration::maybe_migrate_codex_official_history_to_unified_bucket() {
|
||||
Ok(outcome) => {
|
||||
if let Some(reason) = outcome.skipped_reason {
|
||||
log::debug!("○ Codex official history unify migration skipped: {reason}");
|
||||
} else {
|
||||
log::info!(
|
||||
"✓ Codex official history unify migration completed: jsonl_files={}, state_rows={}",
|
||||
outcome.migrated_jsonl_files,
|
||||
outcome.migrated_state_rows
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("✗ Codex official history unify migration failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 清除标记与迁移意愿,让重新开启并再次勾选时能补迁
|
||||
// 关闭期间落入 openai 桶的官方会话。
|
||||
if let Err(err) = crate::settings::clear_codex_official_history_unify_migration() {
|
||||
log::warn!("清除统一会话迁移标记失败: {err}");
|
||||
}
|
||||
if let Err(err) = crate::settings::clear_codex_unify_migrate_existing() {
|
||||
log::warn!("清除统一会话迁移意愿失败: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodexUnifyHistoryRestoreResult {
|
||||
pub restored_jsonl_files: usize,
|
||||
pub restored_state_rows: usize,
|
||||
/// 还原被跳过的原因(如当前目录没有账本),前端据此提示而非报"成功 0 项"。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub skipped_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// 是否存在统一会话开关的迁移备份(决定关闭弹窗里是否显示"恢复备份"勾选)。
|
||||
#[tauri::command]
|
||||
pub async fn has_codex_unify_history_backup() -> Result<bool, String> {
|
||||
Ok(crate::codex_history_migration::has_codex_official_history_unify_backup())
|
||||
}
|
||||
|
||||
/// 按迁移备份账本把当时迁入共享桶的官方会话还原回 "openai" 桶。
|
||||
/// 由关闭统一会话开关的确认弹窗触发;幂等,可安全重试。
|
||||
#[tauri::command]
|
||||
pub async fn restore_codex_unified_history() -> Result<CodexUnifyHistoryRestoreResult, String> {
|
||||
let outcome = tauri::async_runtime::spawn_blocking(|| {
|
||||
crate::codex_history_migration::restore_codex_official_history_from_backups()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(reason) = &outcome.skipped_reason {
|
||||
log::debug!("○ Codex official history restore skipped: {reason}");
|
||||
} else {
|
||||
log::info!(
|
||||
"✓ Codex official history restored from backups: jsonl_files={}, state_rows={}",
|
||||
outcome.restored_jsonl_files,
|
||||
outcome.restored_state_rows
|
||||
);
|
||||
}
|
||||
|
||||
Ok(CodexUnifyHistoryRestoreResult {
|
||||
restored_jsonl_files: outcome.restored_jsonl_files,
|
||||
restored_state_rows: outcome.restored_state_rows,
|
||||
skipped_reason: outcome.skipped_reason,
|
||||
})
|
||||
}
|
||||
|
||||
/// 重启应用程序(当 app_config_dir 变更后使用)
|
||||
#[tauri::command]
|
||||
pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
|
||||
@@ -65,11 +171,80 @@ pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
|
||||
// 在后台延迟重启,让函数有时间返回响应
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
// app.restart() 走 RESTART_EXIT_CODE 路径,ExitRequested 处理器会直接
|
||||
// 放行给 Tauri 默认 re-exec,不执行代理/Live 清理。但本命令用于
|
||||
// app_config_dir 变更后的重启:新实例会切到新数据库,拿不到旧库里的
|
||||
// Live 备份,无法恢复被接管的 Live 配置。因此必须趁旧实例的事件循环
|
||||
// 仍存活,在这里同步完成恢复(保留代理状态,新实例启动时自动重新接管)。
|
||||
crate::cleanup_before_exit(&app).await;
|
||||
app.restart();
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 下载并安装应用更新,然后由后端直接重启应用。
|
||||
///
|
||||
/// macOS 更新会原地替换 `.app` bundle。如果先返回前端、再让旧 WebView 调
|
||||
/// `process.relaunch()`,旧进程可能已经处在 bundle 被替换后的不稳定窗口期。
|
||||
/// 这里把退出清理、安装和重启串在同一个后端流程中,避免依赖旧前端继续执行。
|
||||
#[tauri::command]
|
||||
pub async fn install_update_and_restart(app: AppHandle) -> Result<bool, String> {
|
||||
let updater = app
|
||||
.updater_builder()
|
||||
.build()
|
||||
.map_err(|e| format!("初始化更新器失败: {e}"))?;
|
||||
|
||||
let Some(update) = updater
|
||||
.check()
|
||||
.await
|
||||
.map_err(|e| format!("检查更新失败: {e}"))?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
log::info!("开始下载应用更新: {}", update.version);
|
||||
let bytes = update
|
||||
.download(|_, _| {}, || {})
|
||||
.await
|
||||
.map_err(|e| format!("下载更新失败: {e}"))?;
|
||||
|
||||
log::info!("开始安装应用更新: {}", update.version);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows updater 会在 install() 内启动安装器并直接退出当前进程
|
||||
// (插件内部 std::process::exit(0),绕过 TrayIcon::drop、不发
|
||||
// NIM_DELETE,会残留死图标——与托盘"退出"路径相同的问题)。
|
||||
// 因此清理只能放在 install 前执行,且必须显式移除托盘图标。
|
||||
crate::save_window_state_before_exit(&app);
|
||||
crate::cleanup_before_exit(&app).await;
|
||||
crate::remove_tray_icon_before_exit(&app);
|
||||
crate::destroy_single_instance_lock(&app);
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
update.install(bytes).map_err(|e| {
|
||||
format!(
|
||||
"Windows 更新安装失败: {e}。已执行退出前清理,代理或 Live 接管可能已暂停;请重启应用或重新开启代理后再试。"
|
||||
)
|
||||
})?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// macOS/Linux install() 会返回;先安装,避免安装失败时误停代理/撤回接管。
|
||||
update
|
||||
.install(bytes)
|
||||
.map_err(|e| format!("安装更新失败: {e}"))?;
|
||||
|
||||
crate::save_window_state_before_exit(&app);
|
||||
crate::cleanup_before_exit(&app).await;
|
||||
|
||||
log::info!("应用更新安装完成,正在重启应用");
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
crate::restart_process(&app);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 app_config_dir 覆盖配置 (从 Store)
|
||||
#[tauri::command]
|
||||
pub async fn get_app_config_dir_override(app: AppHandle) -> Result<Option<String>, String> {
|
||||
@@ -102,8 +277,9 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
|
||||
mod tests {
|
||||
use super::merge_settings_for_save;
|
||||
use crate::settings::{
|
||||
AppSettings, CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration,
|
||||
LocalMigrations, WebDavSyncSettings,
|
||||
AppSettings, CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration,
|
||||
CodexThirdPartyHistoryProviderBucketMigration, LocalMigrations, S3SyncSettings,
|
||||
WebDavSyncSettings,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -226,6 +402,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_settings_should_preserve_existing_s3_when_payload_omits_it() {
|
||||
let existing = AppSettings {
|
||||
s3_sync: Some(S3SyncSettings {
|
||||
bucket: "bucket".to_string(),
|
||||
access_key_id: "ak".to_string(),
|
||||
secret_access_key: "secret".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
|
||||
let incoming = AppSettings::default();
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
assert!(merged.s3_sync.is_some());
|
||||
assert_eq!(
|
||||
merged
|
||||
.s3_sync
|
||||
.as_ref()
|
||||
.map(|v| v.secret_access_key.as_str()),
|
||||
Some("secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_settings_should_preserve_s3_secret_when_incoming_has_empty_secret() {
|
||||
let existing = AppSettings {
|
||||
s3_sync: Some(S3SyncSettings {
|
||||
bucket: "bucket".to_string(),
|
||||
access_key_id: "ak".to_string(),
|
||||
secret_access_key: "secret".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
|
||||
let incoming = AppSettings {
|
||||
s3_sync: Some(S3SyncSettings {
|
||||
bucket: "bucket".to_string(),
|
||||
access_key_id: "ak".to_string(),
|
||||
secret_access_key: "".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
assert_eq!(
|
||||
merged
|
||||
.s3_sync
|
||||
.as_ref()
|
||||
.map(|v| v.secret_access_key.as_str()),
|
||||
Some("secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_settings_should_preserve_local_migrations_when_payload_omits_it() {
|
||||
let existing = AppSettings {
|
||||
@@ -244,6 +478,13 @@ mod tests {
|
||||
completed_at: "2026-05-20T00:01:00Z".to_string(),
|
||||
migrated_provider_ids: vec!["legacy".to_string()],
|
||||
}),
|
||||
codex_official_history_unify_v1: Some(CodexOfficialHistoryUnifyMigration {
|
||||
completed_at: "2026-06-12T00:00:00Z".to_string(),
|
||||
target_provider_id: "custom".to_string(),
|
||||
migrated_jsonl_files: 5,
|
||||
migrated_state_rows: 7,
|
||||
codex_config_dir: None,
|
||||
}),
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
@@ -273,6 +514,70 @@ mod tests {
|
||||
template_migration.migrated_provider_ids,
|
||||
vec!["legacy".to_string()]
|
||||
);
|
||||
|
||||
let unify_migration = merged
|
||||
.local_migrations
|
||||
.as_ref()
|
||||
.and_then(|migrations| migrations.codex_official_history_unify_v1.as_ref())
|
||||
.expect("official unify migration marker should be preserved");
|
||||
assert_eq!(unify_migration.migrated_jsonl_files, 5);
|
||||
assert_eq!(unify_migration.migrated_state_rows, 7);
|
||||
}
|
||||
|
||||
/// incoming 带有 local_migrations(哪怕是空的)也不能覆盖后端维护的标记。
|
||||
#[test]
|
||||
fn save_settings_should_keep_backend_migration_markers_over_incoming() {
|
||||
let existing = AppSettings {
|
||||
local_migrations: Some(LocalMigrations {
|
||||
codex_third_party_history_provider_bucket_v1: None,
|
||||
codex_provider_template_v1: None,
|
||||
codex_official_history_unify_v1: Some(CodexOfficialHistoryUnifyMigration {
|
||||
completed_at: "2026-06-12T00:00:00Z".to_string(),
|
||||
target_provider_id: "custom".to_string(),
|
||||
migrated_jsonl_files: 1,
|
||||
migrated_state_rows: 2,
|
||||
codex_config_dir: None,
|
||||
}),
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
|
||||
let incoming = AppSettings {
|
||||
local_migrations: Some(LocalMigrations::default()),
|
||||
..AppSettings::default()
|
||||
};
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
assert!(merged
|
||||
.local_migrations
|
||||
.as_ref()
|
||||
.and_then(|migrations| migrations.codex_official_history_unify_v1.as_ref())
|
||||
.is_some());
|
||||
}
|
||||
|
||||
/// 后端清掉 marker 后(如关闭统一会话开关)、前端缓存刷新前的全量保存
|
||||
/// 会携带旧 marker;merge 必须忽略它,否则被"复活"的标记会让重新开启
|
||||
/// 时误判已迁移而漏迁。
|
||||
#[test]
|
||||
fn save_settings_should_ignore_stale_incoming_migration_markers() {
|
||||
let existing = AppSettings::default();
|
||||
|
||||
let incoming = AppSettings {
|
||||
local_migrations: Some(LocalMigrations {
|
||||
codex_official_history_unify_v1: Some(CodexOfficialHistoryUnifyMigration {
|
||||
completed_at: "2026-06-12T00:00:00Z".to_string(),
|
||||
target_provider_id: "custom".to_string(),
|
||||
migrated_jsonl_files: 1,
|
||||
migrated_state_rows: 2,
|
||||
codex_config_dir: None,
|
||||
}),
|
||||
..LocalMigrations::default()
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
assert!(merged.local_migrations.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
//! 流式健康检查命令
|
||||
//! 供应商连通性检查命令
|
||||
//!
|
||||
//! 注意:本检查只探测 base_url 是否可达,不发真实大模型请求,也不触碰故障转移
|
||||
//! 熔断器(熔断器由真实转发流量驱动)。详见 `services::stream_check`。
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::commands::copilot::CopilotAuthState;
|
||||
@@ -10,7 +13,7 @@ use crate::store::AppState;
|
||||
use std::collections::HashSet;
|
||||
use tauri::State;
|
||||
|
||||
/// 流式健康检查(单个供应商)
|
||||
/// 连通性检查(单个供应商)
|
||||
#[tauri::command]
|
||||
pub async fn stream_check_provider(
|
||||
state: State<'_, AppState>,
|
||||
@@ -25,25 +28,12 @@ pub async fn stream_check_provider(
|
||||
.get(&provider_id)
|
||||
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
|
||||
|
||||
let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?;
|
||||
// Copilot 端点是动态的(随 OAuth token 解析),需预先取出 host 再探测;
|
||||
// 其余供应商传 None,由服务层从 settings_config 提取 base_url。无需鉴权。
|
||||
let base_url_override = resolve_copilot_base_url_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,
|
||||
base_url_override,
|
||||
claude_api_format_override,
|
||||
)
|
||||
.await?;
|
||||
let result =
|
||||
StreamCheckService::check_with_retry(&app_type, provider, &config, base_url_override)
|
||||
.await?;
|
||||
|
||||
// 记录日志
|
||||
let _ =
|
||||
@@ -54,7 +44,7 @@ pub async fn stream_check_provider(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 批量流式健康检查
|
||||
/// 批量连通性检查
|
||||
#[tauri::command]
|
||||
pub async fn stream_check_all_providers(
|
||||
state: State<'_, AppState>,
|
||||
@@ -65,7 +55,6 @@ pub async fn stream_check_all_providers(
|
||||
let config = state.db.get_stream_check_config()?;
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
let allowed_ids: Option<HashSet<String>> = if proxy_targets_only {
|
||||
let mut ids = HashSet::new();
|
||||
if let Ok(Some(current_id)) = state.db.get_current_provider(app_type.as_str()) {
|
||||
@@ -81,6 +70,7 @@ pub async fn stream_check_all_providers(
|
||||
None
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
for (id, provider) in providers {
|
||||
if let Some(ids) = &allowed_ids {
|
||||
if !ids.contains(&id) {
|
||||
@@ -88,54 +78,22 @@ pub async fn stream_check_all_providers(
|
||||
}
|
||||
}
|
||||
|
||||
let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?;
|
||||
let base_url_override =
|
||||
resolve_copilot_base_url_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,
|
||||
base_url_override,
|
||||
claude_api_format_override,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
let (http_status, message) = match &e {
|
||||
crate::error::AppError::HttpStatus { status, .. } => (
|
||||
Some(*status),
|
||||
StreamCheckService::classify_http_status(*status).to_string(),
|
||||
),
|
||||
_ => (None, e.to_string()),
|
||||
};
|
||||
StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message,
|
||||
response_time_ms: None,
|
||||
http_status,
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: 0,
|
||||
error_category: None,
|
||||
}
|
||||
});
|
||||
let result =
|
||||
StreamCheckService::check_with_retry(&app_type, &provider, &config, base_url_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,
|
||||
error_category: None,
|
||||
});
|
||||
|
||||
let _ = state
|
||||
.db
|
||||
@@ -147,13 +105,13 @@ pub async fn stream_check_all_providers(
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 获取流式检查配置
|
||||
/// 获取连通性检查配置
|
||||
#[tauri::command]
|
||||
pub fn get_stream_check_config(state: State<'_, AppState>) -> Result<StreamCheckConfig, AppError> {
|
||||
state.db.get_stream_check_config()
|
||||
}
|
||||
|
||||
/// 保存流式检查配置
|
||||
/// 保存连通性检查配置
|
||||
#[tauri::command]
|
||||
pub fn save_stream_check_config(
|
||||
state: State<'_, AppState>,
|
||||
@@ -162,39 +120,8 @@ pub fn save_stream_check_config(
|
||||
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 = is_copilot_provider(provider);
|
||||
|
||||
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.managed_account_id_for("github_copilot"));
|
||||
|
||||
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,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Copilot 供应商的 base_url 需要从 OAuth 管理器动态解析(按账号或默认端点)。
|
||||
/// `is_full_url` 的供应商已是完整地址,无需解析。
|
||||
async fn resolve_copilot_base_url_override(
|
||||
provider: &crate::provider::Provider,
|
||||
copilot_state: &State<'_, CopilotAuthState>,
|
||||
@@ -238,54 +165,6 @@ fn is_copilot_provider(provider: &crate::provider::Provider) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_copilot_provider;
|
||||
|
||||
@@ -14,10 +14,16 @@ pub fn get_usage_summary(
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<String>,
|
||||
provider_name: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> Result<UsageSummary, AppError> {
|
||||
state
|
||||
.db
|
||||
.get_usage_summary(start_date, end_date, app_type.as_deref())
|
||||
state.db.get_usage_summary(
|
||||
start_date,
|
||||
end_date,
|
||||
app_type.as_deref(),
|
||||
provider_name.as_deref(),
|
||||
model.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取按 app_type 拆分的使用量汇总
|
||||
@@ -26,8 +32,15 @@ pub fn get_usage_summary_by_app(
|
||||
state: State<'_, AppState>,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
provider_name: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> Result<Vec<UsageSummaryByApp>, AppError> {
|
||||
state.db.get_usage_summary_by_app(start_date, end_date)
|
||||
state.db.get_usage_summary_by_app(
|
||||
start_date,
|
||||
end_date,
|
||||
provider_name.as_deref(),
|
||||
model.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取每日趋势
|
||||
@@ -37,10 +50,16 @@ pub fn get_usage_trends(
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<String>,
|
||||
provider_name: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> Result<Vec<DailyStats>, AppError> {
|
||||
state
|
||||
.db
|
||||
.get_daily_trends(start_date, end_date, app_type.as_deref())
|
||||
state.db.get_daily_trends(
|
||||
start_date,
|
||||
end_date,
|
||||
app_type.as_deref(),
|
||||
provider_name.as_deref(),
|
||||
model.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取 Provider 统计
|
||||
@@ -50,10 +69,16 @@ pub fn get_provider_stats(
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<String>,
|
||||
provider_name: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> Result<Vec<ProviderStats>, AppError> {
|
||||
state
|
||||
.db
|
||||
.get_provider_stats(start_date, end_date, app_type.as_deref())
|
||||
state.db.get_provider_stats(
|
||||
start_date,
|
||||
end_date,
|
||||
app_type.as_deref(),
|
||||
provider_name.as_deref(),
|
||||
model.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取模型统计
|
||||
@@ -63,10 +88,16 @@ pub fn get_model_stats(
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
app_type: Option<String>,
|
||||
provider_name: Option<String>,
|
||||
model: Option<String>,
|
||||
) -> Result<Vec<ModelStats>, AppError> {
|
||||
state
|
||||
.db
|
||||
.get_model_stats(start_date, end_date, app_type.as_deref())
|
||||
state.db.get_model_stats(
|
||||
start_date,
|
||||
end_date,
|
||||
app_type.as_deref(),
|
||||
provider_name.as_deref(),
|
||||
model.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取请求日志列表
|
||||
@@ -276,6 +307,19 @@ pub fn sync_session_usage(
|
||||
}
|
||||
}
|
||||
|
||||
// 同步 OpenCode 使用数据
|
||||
match crate::services::session_usage_opencode::sync_opencode_usage(&state.db) {
|
||||
Ok(opencode_result) => {
|
||||
result.imported += opencode_result.imported;
|
||||
result.skipped += opencode_result.skipped;
|
||||
result.files_scanned += opencode_result.files_scanned;
|
||||
result.errors.extend(opencode_result.errors);
|
||||
}
|
||||
Err(e) => {
|
||||
result.errors.push(format!("OpenCode 同步失败: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,15 @@ impl Database {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// 剪枝是不可逆的:明细一旦汇总删除,0 成本行就永远失去按 pricing_model
|
||||
// 补价重算的机会(启动序列里 seed 定价先于 rollup、但启动回填在 rollup
|
||||
// 之后;周期任务同理)。所以剪枝前先尽力回填一次。失败仅告警不阻断——
|
||||
// 否则一行损坏的定价数据会永久卡死日志清理。
|
||||
// 注意必须在 SAVEPOINT 之外调用:回填内部自己开顶层事务。
|
||||
if let Err(e) = Self::backfill_missing_usage_costs_on_conn(&conn, None) {
|
||||
log::warn!("Pre-prune cost backfill failed, pruning anyway: {e}");
|
||||
}
|
||||
|
||||
// Use a savepoint for atomicity
|
||||
conn.execute("SAVEPOINT rollup_prune;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -106,15 +115,18 @@ impl Database {
|
||||
fn do_rollup_and_prune(conn: &rusqlite::Connection, cutoff: i64) -> Result<u64, AppError> {
|
||||
// Aggregate old logs, merging with any pre-existing rollup rows via LEFT JOIN.
|
||||
let effective_filter = effective_usage_log_filter("l");
|
||||
// request_model 维度保留路由接管的「客户端别名 → 真实模型」映射,
|
||||
// pricing_model 维度保留写入时的计价基准(request 计价模式下与 model 分叉);
|
||||
// 明细行的这两列可能为 NULL(历史/手工数据),归一为 ''。
|
||||
let aggregation_sql = format!(
|
||||
"INSERT OR REPLACE INTO usage_daily_rollups
|
||||
(date, app_type, provider_id, model,
|
||||
(date, app_type, provider_id, model, request_model, pricing_model,
|
||||
request_count, success_count,
|
||||
input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_creation_tokens,
|
||||
total_cost_usd, avg_latency_ms)
|
||||
SELECT
|
||||
d, a, p, m,
|
||||
d, a, p, m, rm, pm,
|
||||
COALESCE(old.request_count, 0) + new_req,
|
||||
COALESCE(old.success_count, 0) + new_succ,
|
||||
COALESCE(old.input_tokens, 0) + new_in,
|
||||
@@ -131,6 +143,8 @@ impl Database {
|
||||
SELECT
|
||||
date(l.created_at, 'unixepoch', 'localtime') as d,
|
||||
l.app_type as a, l.provider_id as p, l.model as m,
|
||||
COALESCE(l.request_model, '') as rm,
|
||||
COALESCE(l.pricing_model, '') as pm,
|
||||
COUNT(*) as new_req,
|
||||
SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END) as new_succ,
|
||||
COALESCE(SUM(l.input_tokens), 0) as new_in,
|
||||
@@ -141,11 +155,12 @@ impl Database {
|
||||
COALESCE(AVG(l.latency_ms), 0) as new_lat
|
||||
FROM proxy_request_logs l
|
||||
WHERE l.created_at < ?1 AND {effective_filter}
|
||||
GROUP BY d, a, p, m
|
||||
GROUP BY d, a, p, m, rm, pm
|
||||
) agg
|
||||
LEFT JOIN usage_daily_rollups old
|
||||
ON old.date = agg.d AND old.app_type = agg.a
|
||||
AND old.provider_id = agg.p AND old.model = agg.m"
|
||||
AND old.provider_id = agg.p AND old.model = agg.m
|
||||
AND old.request_model = agg.rm AND old.pricing_model = agg.pm"
|
||||
);
|
||||
|
||||
conn.execute(&aggregation_sql, [cutoff])
|
||||
@@ -325,6 +340,144 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollup_preserves_request_model_dimension() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let old_ts = now - 40 * 86400;
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
// 路由接管行:model 是真实上游模型,request_model 是客户端别名。
|
||||
// 同 model 下两个不同别名必须各自成行,prune 后映射关系仍可审计。
|
||||
for (i, request_model) in [
|
||||
("a", "claude-sonnet-4-6"),
|
||||
("b", "claude-sonnet-4-6"),
|
||||
("c", "claude-haiku-4-5"),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES (?1, 'p1', 'claude', 'kimi-k2', ?2, 100, 50, '0.01', 100, 200, ?3)",
|
||||
rusqlite::params![format!("takeover-{i}"), request_model, old_ts],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
let deleted = db.rollup_and_prune(30)?;
|
||||
assert_eq!(deleted, 3);
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT request_model, request_count FROM usage_daily_rollups
|
||||
WHERE model = 'kimi-k2' ORDER BY request_model",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
assert_eq!(
|
||||
rows,
|
||||
vec![
|
||||
("claude-haiku-4-5".to_string(), 1),
|
||||
("claude-sonnet-4-6".to_string(), 2),
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollup_preserves_pricing_model_dimension() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let old_ts = now - 40 * 86400;
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
// request 计价模式下 pricing_model 与 model 分叉,必须各自成行
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model, pricing_model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES ('pm-a', 'p1', 'claude', 'kimi-k2', 'claude-sonnet-4-6', 'kimi-k2',
|
||||
100, 50, '0.01', 100, 200, ?1)",
|
||||
rusqlite::params![old_ts],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model, pricing_model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES ('pm-b', 'p1', 'claude', 'kimi-k2', 'claude-sonnet-4-6', 'claude-sonnet-4-6',
|
||||
100, 50, '0.30', 100, 200, ?1)",
|
||||
rusqlite::params![old_ts],
|
||||
)?;
|
||||
}
|
||||
|
||||
let deleted = db.rollup_and_prune(30)?;
|
||||
assert_eq!(deleted, 2);
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT pricing_model, total_cost_usd FROM usage_daily_rollups
|
||||
WHERE model = 'kimi-k2' ORDER BY pricing_model",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].0, "claude-sonnet-4-6");
|
||||
assert_eq!(rows[1].0, "kimi-k2");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollup_backfills_costs_before_pruning() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let old_ts = now - 40 * 86400;
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
// >30 天的 0 成本行:pricing_model(gpt-5.5)在 seed 定价表中有价。
|
||||
// 剪枝是不可逆的,rollup 必须先回填再汇总,否则按 0 永久入账。
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model, pricing_model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES ('prune-backfill', 'p1', 'codex', 'gpt-5.5', 'gpt-5.5', 'gpt-5.5',
|
||||
1000000, 0, '0', 100, 200, ?1)",
|
||||
rusqlite::params![old_ts],
|
||||
)?;
|
||||
}
|
||||
|
||||
let deleted = db.rollup_and_prune(30)?;
|
||||
assert_eq!(deleted, 1);
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let total_cost: f64 = conn.query_row(
|
||||
"SELECT CAST(total_cost_usd AS REAL) FROM usage_daily_rollups
|
||||
WHERE model = 'gpt-5.5'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
// gpt-5.5 input $5/M × 1M tokens,回填后再汇总
|
||||
assert!(
|
||||
(total_cost - 5.0).abs() < 1e-6,
|
||||
"expected backfilled cost 5.0, got {total_cost}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollup_noop_when_no_old_data() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
@@ -49,7 +49,7 @@ use std::sync::Mutex;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 10;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 11;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
@@ -82,6 +82,7 @@ fn register_db_change_hook(conn: &Connection) {
|
||||
|action: Action, _database: &str, table: &str, _row_id: i64| match action {
|
||||
Action::SQLITE_INSERT | Action::SQLITE_UPDATE | Action::SQLITE_DELETE => {
|
||||
crate::services::webdav_auto_sync::notify_db_changed(table);
|
||||
crate::services::s3_auto_sync::notify_db_changed(table);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
|
||||
@@ -181,9 +181,12 @@ impl Database {
|
||||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 10. Proxy Request Logs 表
|
||||
// pricing_model = 写入时实际用于计价的模型名(pricing_model_source 解析结果),
|
||||
// 回填按它重算;NULL 表示 v11 之前的历史行,'' 表示未计价的错误行。
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
||||
request_model TEXT,
|
||||
pricing_model TEXT,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
@@ -255,12 +258,17 @@ impl Database {
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 17. Usage Daily Rollups 表 (日聚合统计)
|
||||
// request_model 保留路由接管的「客户端别名 → 真实模型」映射维度,
|
||||
// pricing_model 保留写入时的计价基准(request 计价模式下与 model 分叉),
|
||||
// 否则明细被 prune 后接管计费不可审计;历史行迁移时填 ''(未知)。
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS usage_daily_rollups (
|
||||
date TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
request_model TEXT NOT NULL DEFAULT '',
|
||||
pricing_model TEXT NOT NULL DEFAULT '',
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
success_count INTEGER NOT NULL DEFAULT 0,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -269,7 +277,7 @@ impl Database {
|
||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (date, app_type, provider_id, model)
|
||||
PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
@@ -431,6 +439,11 @@ impl Database {
|
||||
Self::migrate_v9_to_v10(conn)?;
|
||||
Self::set_user_version(conn, 10)?;
|
||||
}
|
||||
10 => {
|
||||
log::info!("迁移数据库从 v10 到 v11(usage_daily_rollups 保留 request_model 维度)");
|
||||
Self::migrate_v10_to_v11(conn)?;
|
||||
Self::set_user_version(conn, 11)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -1200,11 +1213,85 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v10 -> v11:usage_daily_rollups 增加 request_model 维度(进入主键),
|
||||
/// proxy_request_logs 增加 pricing_model 列(写入时的计价基准,回填依据)。
|
||||
///
|
||||
/// 路由接管下 model(真实上游模型)≠ request_model(客户端别名),
|
||||
/// 旧 rollup 只按 model 聚合,明细 prune 后映射关系永久丢失、计费不可审计。
|
||||
/// SQLite 改主键必须重建表;历史行的 request_model 已不可知,填 ''。
|
||||
fn migrate_v10_to_v11(conn: &Connection) -> Result<(), AppError> {
|
||||
// proxy_request_logs.pricing_model:NULL = v11 前的历史行(回填走
|
||||
// model → 占位符回退 request_model 的旧逻辑),'' = 未计价的错误行
|
||||
if Self::table_exists(conn, "proxy_request_logs")? {
|
||||
Self::add_column_if_missing(conn, "proxy_request_logs", "pricing_model", "TEXT")?;
|
||||
}
|
||||
|
||||
if !Self::table_exists(conn, "usage_daily_rollups")? {
|
||||
log::info!("v10 -> v11:usage_daily_rollups 不存在,跳过重建");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE usage_daily_rollups RENAME TO usage_daily_rollups_v10;
|
||||
CREATE TABLE usage_daily_rollups (
|
||||
date TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
request_model TEXT NOT NULL DEFAULT '',
|
||||
pricing_model TEXT NOT NULL DEFAULT '',
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
success_count INTEGER NOT NULL DEFAULT 0,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model)
|
||||
);
|
||||
INSERT INTO usage_daily_rollups
|
||||
(date, app_type, provider_id, model, request_model, pricing_model,
|
||||
request_count, success_count, input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms)
|
||||
SELECT date, app_type, provider_id, model, '', '',
|
||||
request_count, success_count, input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
|
||||
FROM usage_daily_rollups_v10;
|
||||
DROP TABLE usage_daily_rollups_v10;",
|
||||
)
|
||||
.map_err(|e| {
|
||||
AppError::Database(format!("v10 -> v11 重建 usage_daily_rollups 失败: {e}"))
|
||||
})?;
|
||||
|
||||
log::info!(
|
||||
"v10 -> v11 迁移完成:usage_daily_rollups 已保留 request_model/pricing_model 维度"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
||||
let pricing_data = [
|
||||
// Claude Fable 5(Opus 之上的新档)
|
||||
(
|
||||
"claude-fable-5",
|
||||
"Claude Fable 5",
|
||||
"10",
|
||||
"50",
|
||||
"1.00",
|
||||
"12.50",
|
||||
),
|
||||
(
|
||||
"claude-mythos-5",
|
||||
"Claude Mythos 5",
|
||||
"10",
|
||||
"50",
|
||||
"1.00",
|
||||
"12.50",
|
||||
),
|
||||
// Claude 4.8 系列
|
||||
(
|
||||
"claude-opus-4-8",
|
||||
@@ -1571,6 +1658,14 @@ impl Database {
|
||||
"0",
|
||||
),
|
||||
// StepFun 系列
|
||||
(
|
||||
"step-3.7-flash",
|
||||
"Step 3.7 Flash",
|
||||
"0.19",
|
||||
"1.13",
|
||||
"0.04",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"step-3.5-flash",
|
||||
"Step 3.5 Flash",
|
||||
@@ -1602,7 +1697,7 @@ impl Database {
|
||||
"Doubao Seed 2.0 Pro",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0",
|
||||
"0.09",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
@@ -1610,7 +1705,7 @@ impl Database {
|
||||
"Doubao Seed 2.0 Code",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0",
|
||||
"0.09",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
@@ -1618,15 +1713,15 @@ impl Database {
|
||||
"Doubao Seed 2.0 Code Preview",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0",
|
||||
"0.09",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-lite",
|
||||
"Doubao Seed 2.0 Lite",
|
||||
"0.25",
|
||||
"2",
|
||||
"0",
|
||||
"0.08",
|
||||
"0.50",
|
||||
"0.017",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
@@ -1634,7 +1729,7 @@ impl Database {
|
||||
"Doubao Seed 2.0 Mini",
|
||||
"0.03",
|
||||
"0.31",
|
||||
"0",
|
||||
"0.0056",
|
||||
"0",
|
||||
),
|
||||
// DeepSeek 系列
|
||||
@@ -1706,8 +1801,16 @@ impl Database {
|
||||
"0.14",
|
||||
"0",
|
||||
),
|
||||
("kimi-k2.5", "Kimi K2.5", "0.60", "2.50", "0.10", "0"),
|
||||
("kimi-k2.5", "Kimi K2.5", "0.60", "3.00", "0.10", "0"),
|
||||
("kimi-k2.6", "Kimi K2.6", "0.95", "4.00", "0.16", "0"),
|
||||
(
|
||||
"kimi-k2.7-code",
|
||||
"Kimi K2.7 Code",
|
||||
"0.95",
|
||||
"4.00",
|
||||
"0.19",
|
||||
"0",
|
||||
),
|
||||
// MiniMax 系列
|
||||
("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"),
|
||||
(
|
||||
@@ -1719,7 +1822,7 @@ impl Database {
|
||||
"0",
|
||||
),
|
||||
("minimax-m2", "MiniMax M2", "0.27", "0.95", "0.03", "0"),
|
||||
("minimax-m2.5", "MiniMax M2.5", "0.12", "0.95", "0.03", "0"),
|
||||
("minimax-m2.5", "MiniMax M2.5", "0.15", "0.95", "0.03", "0"),
|
||||
(
|
||||
"minimax-m2.5-lightning",
|
||||
"MiniMax M2.5 Lightning",
|
||||
@@ -1744,9 +1847,10 @@ impl Database {
|
||||
"0.06",
|
||||
"0.375",
|
||||
),
|
||||
("minimax-m3", "MiniMax M3", "0.60", "2.40", "0.12", "0"),
|
||||
// GLM (智谱)
|
||||
("glm-4.7", "GLM-4.7", "0.39", "1.75", "0.04", "0"),
|
||||
("glm-4.6", "GLM-4.6", "0.28", "1.11", "0.03", "0"),
|
||||
("glm-4.7", "GLM-4.7", "0.6", "2.2", "0.11", "0"),
|
||||
("glm-4.6", "GLM-4.6", "0.6", "2.2", "0.11", "0"),
|
||||
("glm-5", "GLM-5", "1", "3.2", "0.2", "0"),
|
||||
("glm-5.1", "GLM-5.1", "1.4", "4.4", "0.26", "0"),
|
||||
// MiMo (小米)
|
||||
@@ -1758,12 +1862,28 @@ impl Database {
|
||||
"0.009",
|
||||
"0",
|
||||
),
|
||||
("mimo-v2-pro", "MiMo V2 Pro", "1", "3", "0", "0"),
|
||||
("mimo-v2.5", "MiMo V2.5", "0.09", "0.29", "0.009", "0"),
|
||||
("mimo-v2.5-pro", "MiMo V2.5 Pro", "1", "3", "0", "0"),
|
||||
("mimo-v2-pro", "MiMo V2 Pro", "0.435", "0.87", "0.0036", "0"),
|
||||
("mimo-v2.5", "MiMo V2.5", "0.14", "0.29", "0.0028", "0"),
|
||||
(
|
||||
"mimo-v2.5-pro",
|
||||
"MiMo V2.5 Pro",
|
||||
"0.435",
|
||||
"0.87",
|
||||
"0.0036",
|
||||
"0",
|
||||
),
|
||||
// Qwen 系列 (阿里巴巴)
|
||||
("qwen3.6-plus", "Qwen3.6 Plus", "0.325", "1.95", "0", "0"),
|
||||
("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0", "0"),
|
||||
("qwen3.7-max", "Qwen3.7 Max", "2.50", "7.50", "0.25", "0"),
|
||||
("qwen3.7-plus", "Qwen3.7 Plus", "0.40", "1.60", "0.08", "0"),
|
||||
(
|
||||
"qwen3.6-plus",
|
||||
"Qwen3.6 Plus",
|
||||
"0.325",
|
||||
"1.95",
|
||||
"0.065",
|
||||
"0",
|
||||
),
|
||||
("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0.052", "0"),
|
||||
("qwen3-max", "Qwen3 Max", "0.78", "3.90", "0", "0"),
|
||||
(
|
||||
"qwen3-235b-a22b",
|
||||
@@ -1778,7 +1898,7 @@ impl Database {
|
||||
"Qwen3 Coder Plus",
|
||||
"0.65",
|
||||
"3.25",
|
||||
"0",
|
||||
"0.13",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
@@ -1802,7 +1922,7 @@ impl Database {
|
||||
"Qwen3 Coder Flash",
|
||||
"0.195",
|
||||
"0.975",
|
||||
"0",
|
||||
"0.039",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
@@ -1817,19 +1937,20 @@ impl Database {
|
||||
("qwq-32b", "QwQ 32B", "0.20", "0.60", "0", "0"),
|
||||
("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"),
|
||||
// Grok 系列 (xAI)
|
||||
("grok-4.3", "Grok 4.3", "1.25", "2.50", "0.20", "0"),
|
||||
(
|
||||
"grok-4.20-0309-reasoning",
|
||||
"Grok 4.20 Reasoning",
|
||||
"2",
|
||||
"6",
|
||||
"1.25",
|
||||
"2.50",
|
||||
"0.20",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"grok-4.20-0309-non-reasoning",
|
||||
"Grok 4.20",
|
||||
"2",
|
||||
"6",
|
||||
"1.25",
|
||||
"2.50",
|
||||
"0.20",
|
||||
"0",
|
||||
),
|
||||
@@ -1862,6 +1983,38 @@ impl Database {
|
||||
("grok-3", "Grok 3", "3", "15", "0.75", "0"),
|
||||
("grok-3-mini", "Grok 3 Mini", "0.25", "0.50", "0.075", "0"),
|
||||
// Mistral 系列
|
||||
(
|
||||
"mistral-medium-3.5",
|
||||
"Mistral Medium 3.5",
|
||||
"1.50",
|
||||
"7.50",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"mistral-small-4",
|
||||
"Mistral Small 4",
|
||||
"0.10",
|
||||
"0.30",
|
||||
"0.01",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"devstral-small-2-2512",
|
||||
"Devstral Small 2",
|
||||
"0.10",
|
||||
"0.30",
|
||||
"0.01",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"magistral-small",
|
||||
"Magistral Small",
|
||||
"0.50",
|
||||
"1.50",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
("codestral-2508", "Codestral", "0.30", "0.90", "0.03", "0"),
|
||||
(
|
||||
"devstral-small-1.1",
|
||||
@@ -1871,7 +2024,7 @@ impl Database {
|
||||
"0.01",
|
||||
"0",
|
||||
),
|
||||
("devstral-2-2512", "Devstral 2", "0.40", "0.90", "0.04", "0"),
|
||||
("devstral-2-2512", "Devstral 2", "0.40", "2", "0.04", "0"),
|
||||
(
|
||||
"devstral-medium",
|
||||
"Devstral Medium",
|
||||
@@ -1952,6 +2105,225 @@ impl Database {
|
||||
|
||||
fn repair_current_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
||||
let pricing_fixes = [
|
||||
// 2026-06-10 全量核价(厂商官方 list 价;CNY 按 ~7.14 折算)
|
||||
// GLM 4.6/4.7:旧值是中转/OpenRouter 折扣价,统一到 Z.ai 官方(与 glm-5/5.1 一致)
|
||||
(
|
||||
"glm-4.7", "GLM-4.7", "0.6", "2.2", "0.11", "0", "0.39", "1.75", "0.04", "0",
|
||||
),
|
||||
(
|
||||
"glm-4.6", "GLM-4.6", "0.6", "2.2", "0.11", "0", "0.28", "1.11", "0.03", "0",
|
||||
),
|
||||
// Grok 4.20:xAI 已降价 2/6 → 1.25/2.50
|
||||
(
|
||||
"grok-4.20-0309-reasoning",
|
||||
"Grok 4.20 Reasoning",
|
||||
"1.25",
|
||||
"2.50",
|
||||
"0.20",
|
||||
"0",
|
||||
"2",
|
||||
"6",
|
||||
"0.20",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"grok-4.20-0309-non-reasoning",
|
||||
"Grok 4.20",
|
||||
"1.25",
|
||||
"2.50",
|
||||
"0.20",
|
||||
"0",
|
||||
"2",
|
||||
"6",
|
||||
"0.20",
|
||||
"0",
|
||||
),
|
||||
// Kimi K2.5 官方 output 3.00
|
||||
(
|
||||
"kimi-k2.5",
|
||||
"Kimi K2.5",
|
||||
"0.60",
|
||||
"3.00",
|
||||
"0.10",
|
||||
"0",
|
||||
"0.60",
|
||||
"2.50",
|
||||
"0.10",
|
||||
"0",
|
||||
),
|
||||
// MiniMax M2.5 input 0.15
|
||||
(
|
||||
"minimax-m2.5",
|
||||
"MiniMax M2.5",
|
||||
"0.15",
|
||||
"0.95",
|
||||
"0.03",
|
||||
"0",
|
||||
"0.12",
|
||||
"0.95",
|
||||
"0.03",
|
||||
"0",
|
||||
),
|
||||
// Mistral Devstral 2 output 0.90 → 2(与同表 devstral-medium 一致)
|
||||
(
|
||||
"devstral-2-2512",
|
||||
"Devstral 2",
|
||||
"0.40",
|
||||
"2",
|
||||
"0.04",
|
||||
"0",
|
||||
"0.40",
|
||||
"0.90",
|
||||
"0.04",
|
||||
"0",
|
||||
),
|
||||
// Doubao Seed 2.0:lite 旧价贵 3-4 倍 + 全系补 cache 命中价
|
||||
(
|
||||
"doubao-seed-2-0-lite",
|
||||
"Doubao Seed 2.0 Lite",
|
||||
"0.08",
|
||||
"0.50",
|
||||
"0.017",
|
||||
"0",
|
||||
"0.25",
|
||||
"2",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-pro",
|
||||
"Doubao Seed 2.0 Pro",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0.09",
|
||||
"0",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-code",
|
||||
"Doubao Seed 2.0 Code",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0.09",
|
||||
"0",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-code-preview-latest",
|
||||
"Doubao Seed 2.0 Code Preview",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0.09",
|
||||
"0",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-mini",
|
||||
"Doubao Seed 2.0 Mini",
|
||||
"0.03",
|
||||
"0.31",
|
||||
"0.0056",
|
||||
"0",
|
||||
"0.03",
|
||||
"0.31",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
// MiMo:5/27 永久降价,旧值是旧价
|
||||
(
|
||||
"mimo-v2-pro",
|
||||
"MiMo V2 Pro",
|
||||
"0.435",
|
||||
"0.87",
|
||||
"0.0036",
|
||||
"0",
|
||||
"1",
|
||||
"3",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"mimo-v2.5",
|
||||
"MiMo V2.5",
|
||||
"0.14",
|
||||
"0.29",
|
||||
"0.0028",
|
||||
"0",
|
||||
"0.09",
|
||||
"0.29",
|
||||
"0.009",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"mimo-v2.5-pro",
|
||||
"MiMo V2.5 Pro",
|
||||
"0.435",
|
||||
"0.87",
|
||||
"0.0036",
|
||||
"0",
|
||||
"1",
|
||||
"3",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
// Qwen:官方"隐式缓存 = 输入 20%"补 cache 命中价
|
||||
(
|
||||
"qwen3.6-plus",
|
||||
"Qwen3.6 Plus",
|
||||
"0.325",
|
||||
"1.95",
|
||||
"0.065",
|
||||
"0",
|
||||
"0.325",
|
||||
"1.95",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"qwen3.5-plus",
|
||||
"Qwen3.5 Plus",
|
||||
"0.26",
|
||||
"1.56",
|
||||
"0.052",
|
||||
"0",
|
||||
"0.26",
|
||||
"1.56",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"qwen3-coder-plus",
|
||||
"Qwen3 Coder Plus",
|
||||
"0.65",
|
||||
"3.25",
|
||||
"0.13",
|
||||
"0",
|
||||
"0.65",
|
||||
"3.25",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"qwen3-coder-flash",
|
||||
"Qwen3 Coder Flash",
|
||||
"0.195",
|
||||
"0.975",
|
||||
"0.039",
|
||||
"0",
|
||||
"0.195",
|
||||
"0.975",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"deepseek-v4-flash",
|
||||
"DeepSeek V4 Flash",
|
||||
|
||||
@@ -345,6 +345,87 @@ fn schema_migration_v4_adds_pricing_model_columns() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v10_to_v11_rebuilds_rollups_with_request_model_dimension() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
|
||||
// 模拟 v10 形状的 rollup 表(主键不含 request_model)+ 一行历史聚合数据,
|
||||
// 以及 v10 形状的明细表(无 pricing_model 列)
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE proxy_request_logs (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
model TEXT NOT NULL,
|
||||
request_model TEXT
|
||||
);
|
||||
CREATE TABLE usage_daily_rollups (
|
||||
date TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
success_count INTEGER NOT NULL DEFAULT 0,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (date, app_type, provider_id, model)
|
||||
);
|
||||
INSERT INTO usage_daily_rollups
|
||||
(date, app_type, provider_id, model, request_count, success_count,
|
||||
input_tokens, output_tokens, total_cost_usd, avg_latency_ms)
|
||||
VALUES ('2026-05-01', 'claude', 'p1', 'kimi-k2', 7, 7, 1000, 500, '0.07', 120);
|
||||
"#,
|
||||
)
|
||||
.expect("seed v10 rollup table");
|
||||
|
||||
Database::set_user_version(&conn, 10).expect("set user_version=10");
|
||||
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
||||
|
||||
// 新列存在且 NOT NULL DEFAULT ''
|
||||
let request_model = get_column_info(&conn, "usage_daily_rollups", "request_model");
|
||||
assert_eq!(request_model.r#type, "TEXT");
|
||||
assert_eq!(request_model.notnull, 1);
|
||||
let rollup_pricing_model = get_column_info(&conn, "usage_daily_rollups", "pricing_model");
|
||||
assert_eq!(rollup_pricing_model.r#type, "TEXT");
|
||||
assert_eq!(rollup_pricing_model.notnull, 1);
|
||||
|
||||
// 明细表补上 pricing_model 列(可空,历史行 NULL)
|
||||
let pricing_model = get_column_info(&conn, "proxy_request_logs", "pricing_model");
|
||||
assert_eq!(pricing_model.r#type, "TEXT");
|
||||
assert_eq!(pricing_model.notnull, 0);
|
||||
|
||||
// 历史行保留,request_model 填 ''(未知)
|
||||
let (rm, count, input, cost): (String, i64, i64, String) = conn
|
||||
.query_row(
|
||||
"SELECT request_model, request_count, input_tokens, total_cost_usd
|
||||
FROM usage_daily_rollups WHERE model = 'kimi-k2'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.expect("migrated row");
|
||||
assert_eq!(rm, "");
|
||||
assert_eq!(count, 7);
|
||||
assert_eq!(input, 1000);
|
||||
assert_eq!(cost, "0.07");
|
||||
|
||||
// 主键包含 request_model:同 model 不同别名可共存
|
||||
conn.execute(
|
||||
"INSERT INTO usage_daily_rollups
|
||||
(date, app_type, provider_id, model, request_model, request_count)
|
||||
VALUES ('2026-05-01', 'claude', 'p1', 'kimi-k2', 'claude-sonnet-4-6', 1)",
|
||||
[],
|
||||
)
|
||||
.expect("insert row with same model but different request_model");
|
||||
|
||||
assert_eq!(
|
||||
Database::get_user_version(&conn).expect("version after migration"),
|
||||
SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
|
||||
@@ -9,7 +9,51 @@ use super::DeepLinkImportRequest;
|
||||
use crate::AppType;
|
||||
use crate::{store::AppState, Database};
|
||||
use base64::prelude::*;
|
||||
use std::sync::Arc;
|
||||
use std::{env, ffi::OsString, sync::Arc};
|
||||
|
||||
struct TestHomeGuard {
|
||||
_dir: tempfile::TempDir,
|
||||
original_home: Option<OsString>,
|
||||
original_userprofile: Option<OsString>,
|
||||
original_test_home: Option<OsString>,
|
||||
}
|
||||
|
||||
impl TestHomeGuard {
|
||||
fn new() -> Self {
|
||||
let dir = tempfile::tempdir().expect("create isolated test home");
|
||||
let original_home = env::var_os("HOME");
|
||||
let original_userprofile = env::var_os("USERPROFILE");
|
||||
let original_test_home = env::var_os("CC_SWITCH_TEST_HOME");
|
||||
|
||||
env::set_var("HOME", dir.path());
|
||||
env::set_var("USERPROFILE", dir.path());
|
||||
env::set_var("CC_SWITCH_TEST_HOME", dir.path());
|
||||
|
||||
Self {
|
||||
_dir: dir,
|
||||
original_home,
|
||||
original_userprofile,
|
||||
original_test_home,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestHomeGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.original_test_home {
|
||||
Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
match &self.original_userprofile {
|
||||
Some(value) => env::set_var("USERPROFILE", value),
|
||||
None => env::remove_var("USERPROFILE"),
|
||||
}
|
||||
match &self.original_home {
|
||||
Some(value) => env::set_var("HOME", value),
|
||||
None => env::remove_var("HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Parser Tests
|
||||
@@ -477,8 +521,12 @@ fn test_build_claude_provider_without_config_unchanged() {
|
||||
// Prompt Tests
|
||||
// =============================================================================
|
||||
|
||||
// Integration-style unit test: prompt import reaches PromptService and resolves
|
||||
// live config file paths, so HOME must be isolated before it runs.
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_import_prompt_allows_space_in_base64_content() {
|
||||
let _test_home = TestHomeGuard::new();
|
||||
let url = "ccswitch://v1/import?resource=prompt&app=codex&name=PromptPlus&content=Pj4+";
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
|
||||
@@ -116,10 +116,76 @@ pub fn read_hermes_config() -> Result<serde_yaml::Value, AppError> {
|
||||
return Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
|
||||
}
|
||||
|
||||
serde_yaml::from_str(&content)
|
||||
// Heal duplicate top-level keys left behind by the pre-CRLF-fix append
|
||||
// bug (#3633); serde_yaml rejects them outright, which bricked the panel.
|
||||
let deduped = deduplicate_top_level_keys(&content);
|
||||
|
||||
serde_yaml::from_str(&deduped)
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse Hermes config as YAML: {e}")))
|
||||
}
|
||||
|
||||
/// Remove duplicate top-level YAML sections, keeping the LAST occurrence of
|
||||
/// each key.
|
||||
///
|
||||
/// Keep-last is deliberate, not arbitrary: the duplicates come from section
|
||||
/// replacement degrading into appends (#3633), so the last block is the
|
||||
/// newest data — and Hermes itself reads the file with PyYAML, whose
|
||||
/// duplicate-key semantics are last-wins. Keeping the first occurrence would
|
||||
/// silently roll the user back to stale config and diverge from what Hermes
|
||||
/// actually runs with.
|
||||
fn deduplicate_top_level_keys(raw: &str) -> String {
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Pass 1: locate every top-level key line as (key, byte offset).
|
||||
let mut sections: Vec<(&str, usize)> = Vec::new();
|
||||
let mut offset = 0;
|
||||
for line in raw.split('\n') {
|
||||
if is_top_level_key_line(line) {
|
||||
if let Some(colon_pos) = line.find(':') {
|
||||
sections.push((&line[..colon_pos], offset));
|
||||
}
|
||||
}
|
||||
offset += line.len() + 1;
|
||||
}
|
||||
|
||||
let mut remaining: HashMap<&str, usize> = HashMap::new();
|
||||
for (key, _) in §ions {
|
||||
*remaining.entry(key).or_insert(0) += 1;
|
||||
}
|
||||
if remaining.values().all(|&count| count <= 1) {
|
||||
return raw.to_string();
|
||||
}
|
||||
|
||||
// Pass 2: re-emit, dropping every section that has a later occurrence of
|
||||
// the same key. A section spans from its key line to the next top-level
|
||||
// key line (or EOF), matching find_yaml_section_range. Content before the
|
||||
// first section (comments, document markers) is always kept.
|
||||
let mut result = String::with_capacity(raw.len());
|
||||
let head_end = sections
|
||||
.first()
|
||||
.map(|&(_, start)| start)
|
||||
.unwrap_or(raw.len());
|
||||
result.push_str(&raw[..head_end]);
|
||||
|
||||
for (i, &(key, start)) in sections.iter().enumerate() {
|
||||
let end = sections
|
||||
.get(i + 1)
|
||||
.map(|&(_, next_start)| next_start)
|
||||
.unwrap_or(raw.len());
|
||||
let count = remaining.get_mut(key).expect("key collected in pass 1");
|
||||
*count -= 1;
|
||||
if *count > 0 {
|
||||
log::warn!(
|
||||
"Hermes config: dropped duplicate top-level section '{key}' (keeping the last occurrence)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
result.push_str(&raw[start..end]);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// YAML Section-Level Replacement
|
||||
// ============================================================================
|
||||
@@ -132,6 +198,11 @@ pub fn read_hermes_config() -> Result<serde_yaml::Value, AppError> {
|
||||
/// - Not be a comment (starting with `#`)
|
||||
/// - Not be a sequence item (starting with `-`)
|
||||
/// - Contain `:` followed by space, tab, newline, or end-of-line
|
||||
///
|
||||
/// Lines may carry a trailing `\r` (CRLF files split on `\n`) or `\n`
|
||||
/// (callers using `split_inclusive`); both count as end-of-line after the
|
||||
/// colon. Rejecting `\r` here used to make every section lookup miss on
|
||||
/// CRLF configs, turning section replacement into endless appends (#3633).
|
||||
fn is_top_level_key_line(line: &str) -> bool {
|
||||
if line.is_empty() {
|
||||
return false;
|
||||
@@ -142,7 +213,7 @@ fn is_top_level_key_line(line: &str) -> bool {
|
||||
}
|
||||
if let Some(colon_pos) = line.find(':') {
|
||||
let after_colon = &line[colon_pos + 1..];
|
||||
after_colon.is_empty() || after_colon.starts_with(' ') || after_colon.starts_with('\t')
|
||||
after_colon.is_empty() || after_colon.starts_with([' ', '\t', '\r', '\n'])
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -196,6 +267,21 @@ fn serialize_yaml_section(key: &str, value: &serde_yaml::Value) -> Result<String
|
||||
Ok(yaml_str)
|
||||
}
|
||||
|
||||
/// Remove every top-level section with the given key from raw YAML text.
|
||||
/// Used to clean residual duplicates of a key after replacing its first
|
||||
/// occurrence; safe values come from the keep-last healed read, so dropping
|
||||
/// all on-disk copies here loses nothing.
|
||||
fn remove_all_sections(raw: &str, section_key: &str) -> String {
|
||||
let mut result = String::with_capacity(raw.len());
|
||||
let mut rest = raw;
|
||||
while let Some((start, end)) = find_yaml_section_range(rest, section_key) {
|
||||
result.push_str(&rest[..start]);
|
||||
rest = &rest[end..];
|
||||
}
|
||||
result.push_str(rest);
|
||||
result
|
||||
}
|
||||
|
||||
/// Replace a YAML section in raw text, or append it if not found.
|
||||
fn replace_yaml_section(
|
||||
raw: &str,
|
||||
@@ -208,12 +294,14 @@ fn replace_yaml_section(
|
||||
let mut result = String::with_capacity(raw.len());
|
||||
result.push_str(&raw[..start]);
|
||||
result.push_str(&serialized);
|
||||
// Drop duplicate sections of this key from the remainder — configs
|
||||
// written before the CRLF fix may carry several appended copies.
|
||||
let remainder = remove_all_sections(&raw[end..], section_key);
|
||||
// Ensure proper separation between sections
|
||||
let remainder = &raw[end..];
|
||||
if !serialized.ends_with('\n') && !remainder.is_empty() && !remainder.starts_with('\n') {
|
||||
result.push('\n');
|
||||
}
|
||||
result.push_str(remainder);
|
||||
result.push_str(&remainder);
|
||||
Ok(result)
|
||||
} else {
|
||||
// Section not found — append at end
|
||||
@@ -1204,6 +1292,111 @@ model:
|
||||
assert!(!section.starts_with("model_extra:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_section_handles_crlf() {
|
||||
// Regression for #3633: CRLF line endings must not hide sections.
|
||||
let yaml = "model:\r\n default: gpt-4\r\nagent:\r\n max_turns: 10\r\n";
|
||||
let (start, end) = find_yaml_section_range(yaml, "model").unwrap();
|
||||
let section = &yaml[start..end];
|
||||
assert!(section.starts_with("model:"));
|
||||
assert!(section.contains("default: gpt-4"));
|
||||
assert!(!section.contains("agent:"));
|
||||
}
|
||||
|
||||
// ---- deduplicate_top_level_keys tests ----
|
||||
|
||||
#[test]
|
||||
fn dedup_keeps_last_occurrence() {
|
||||
// Duplicates come from replace-degraded-to-append, so the last block
|
||||
// is the newest data and must win (PyYAML last-wins, like Hermes).
|
||||
let yaml = "\
|
||||
model:
|
||||
default: gpt-4
|
||||
agent:
|
||||
max_turns: 10
|
||||
model:
|
||||
default: claude-opus-4-8
|
||||
";
|
||||
let result = deduplicate_top_level_keys(yaml);
|
||||
assert_eq!(
|
||||
result.lines().filter(|l| *l == "model:").count(),
|
||||
1,
|
||||
"duplicate model: section was not removed"
|
||||
);
|
||||
assert!(result.contains("claude-opus-4-8"));
|
||||
assert!(!result.contains("gpt-4"));
|
||||
assert!(result.contains("max_turns"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_handles_crlf() {
|
||||
let yaml = "model:\r\n default: gpt-4\r\nagent:\r\n max_turns: 10\r\nmodel:\r\n default: claude\r\n";
|
||||
let result = deduplicate_top_level_keys(yaml);
|
||||
assert_eq!(result.lines().filter(|l| l.trim() == "model:").count(), 1);
|
||||
assert!(result.contains("default: claude"));
|
||||
assert!(!result.contains("gpt-4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_is_identity_without_duplicates() {
|
||||
let yaml = "\
|
||||
# Hermes config
|
||||
model:
|
||||
default: gpt-4
|
||||
|
||||
agent:
|
||||
max_turns: 10
|
||||
";
|
||||
assert_eq!(deduplicate_top_level_keys(yaml), yaml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_result_parses_with_last_value() {
|
||||
// End-to-end: a config that serde_yaml rejects today must parse after
|
||||
// healing, and expose the newest (last) value.
|
||||
let yaml = "\
|
||||
custom_providers:
|
||||
- name: old-provider
|
||||
model:
|
||||
default: gpt-4
|
||||
custom_providers:
|
||||
- name: old-provider
|
||||
- name: new-provider
|
||||
";
|
||||
let healed = deduplicate_top_level_keys(yaml);
|
||||
let value: serde_yaml::Value = serde_yaml::from_str(&healed).unwrap();
|
||||
let providers = value
|
||||
.get("custom_providers")
|
||||
.unwrap()
|
||||
.as_sequence()
|
||||
.unwrap();
|
||||
assert_eq!(providers.len(), 2);
|
||||
assert_eq!(
|
||||
providers[1].get("name").unwrap().as_str().unwrap(),
|
||||
"new-provider"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- remove_all_sections tests ----
|
||||
|
||||
#[test]
|
||||
fn remove_all_sections_strips_every_occurrence() {
|
||||
let yaml = "\
|
||||
model:
|
||||
default: gpt-4
|
||||
agent:
|
||||
max_turns: 10
|
||||
model:
|
||||
default: claude
|
||||
model:
|
||||
default: gemini
|
||||
";
|
||||
let result = remove_all_sections(yaml, "model");
|
||||
assert!(!result.contains("model:"));
|
||||
assert!(result.contains("agent:"));
|
||||
assert!(result.contains("max_turns"));
|
||||
}
|
||||
|
||||
// ---- replace_yaml_section tests ----
|
||||
|
||||
#[test]
|
||||
@@ -1239,6 +1432,62 @@ agent:
|
||||
assert!(!result.contains("openai"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_section_in_crlf_config_replaces_in_place() {
|
||||
// Regression for #3633: on CRLF configs every "replace" used to
|
||||
// degrade into an append, piling up duplicate sections.
|
||||
let yaml = "model:\r\n default: gpt-4\r\nagent:\r\n max_turns: 10\r\n";
|
||||
let new_model = serde_yaml::Value::Mapping({
|
||||
let mut m = serde_yaml::Mapping::new();
|
||||
m.insert(
|
||||
serde_yaml::Value::String("default".to_string()),
|
||||
serde_yaml::Value::String("claude-opus-4-8".to_string()),
|
||||
);
|
||||
m
|
||||
});
|
||||
|
||||
let result = replace_yaml_section(yaml, "model", &new_model).unwrap();
|
||||
assert_eq!(
|
||||
result.lines().filter(|l| l.trim() == "model:").count(),
|
||||
1,
|
||||
"model: must be replaced in place, not appended"
|
||||
);
|
||||
assert!(result.contains("claude-opus-4-8"));
|
||||
assert!(!result.contains("gpt-4"));
|
||||
assert!(result.contains("max_turns"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_section_removes_residual_duplicates() {
|
||||
// A config already broken by the append bug: replacing the section
|
||||
// must also clean the stale duplicate copies after it.
|
||||
let yaml = "\
|
||||
model:
|
||||
default: gpt-4
|
||||
agent:
|
||||
max_turns: 10
|
||||
model:
|
||||
default: stale-copy
|
||||
";
|
||||
let new_model = serde_yaml::Value::Mapping({
|
||||
let mut m = serde_yaml::Mapping::new();
|
||||
m.insert(
|
||||
serde_yaml::Value::String("default".to_string()),
|
||||
serde_yaml::Value::String("claude-opus-4-8".to_string()),
|
||||
);
|
||||
m
|
||||
});
|
||||
|
||||
let result = replace_yaml_section(yaml, "model", &new_model).unwrap();
|
||||
assert_eq!(result.lines().filter(|l| *l == "model:").count(), 1);
|
||||
assert!(result.contains("claude-opus-4-8"));
|
||||
assert!(!result.contains("stale-copy"));
|
||||
assert!(result.contains("agent:"));
|
||||
// The healed output must be valid YAML again
|
||||
let parsed: Result<serde_yaml::Value, _> = serde_yaml::from_str(&result);
|
||||
assert!(parsed.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_new_section() {
|
||||
let yaml = "\
|
||||
|
||||
@@ -69,6 +69,22 @@ use tauri::RunEvent;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn set_windows_app_user_model_id(app: &tauri::AppHandle) {
|
||||
let app_id = app.config().identifier.clone();
|
||||
let wide_app_id: Vec<u16> = app_id.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
let result = unsafe {
|
||||
windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID(wide_app_id.as_ptr())
|
||||
};
|
||||
|
||||
if result < 0 {
|
||||
log::warn!("设置 Windows AppUserModelID 失败: 0x{result:08X}");
|
||||
} else {
|
||||
log::debug!("Windows AppUserModelID 已设置为 {app_id}");
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_url_for_log(url_str: &str) -> String {
|
||||
match url::Url::parse(url_str) {
|
||||
Ok(url) => {
|
||||
@@ -288,6 +304,8 @@ pub fn run() {
|
||||
// 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等)
|
||||
app_store::refresh_app_config_dir_override(app.handle());
|
||||
panic_hook::init_app_config_dir(crate::config::get_app_config_dir());
|
||||
#[cfg(target_os = "windows")]
|
||||
set_windows_app_user_model_id(app.handle());
|
||||
|
||||
// 注册 Updater 插件(桌面端)
|
||||
#[cfg(desktop)]
|
||||
@@ -582,6 +600,25 @@ pub fn run() {
|
||||
log::warn!("✗ Codex provider template bucket migration failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// 统一会话开关的官方历史迁移:开关开启但上次未完成(如文件被占用
|
||||
// 中途失败)时在启动期重试;函数内部自门控,开关关闭时直接跳过。
|
||||
match crate::codex_history_migration::maybe_migrate_codex_official_history_to_unified_bucket() {
|
||||
Ok(outcome) => {
|
||||
if let Some(reason) = outcome.skipped_reason {
|
||||
log::debug!("○ Codex official history unify migration skipped: {reason}");
|
||||
} else {
|
||||
log::info!(
|
||||
"✓ Codex official history unify migration completed: jsonl_files={}, state_rows={}",
|
||||
outcome.migrated_jsonl_files,
|
||||
outcome.migrated_state_rows
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("✗ Codex official history unify migration failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -865,6 +902,10 @@ pub fn run() {
|
||||
app_state.db.clone(),
|
||||
app.handle().clone(),
|
||||
);
|
||||
crate::services::s3_auto_sync::start_worker(
|
||||
app_state.db.clone(),
|
||||
app.handle().clone(),
|
||||
);
|
||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||
app.manage(app_state);
|
||||
|
||||
@@ -1021,6 +1062,10 @@ pub fn run() {
|
||||
"Gemini usage initial sync",
|
||||
crate::services::session_usage_gemini::sync_gemini_usage(db),
|
||||
);
|
||||
run_step(
|
||||
"OpenCode usage initial sync",
|
||||
crate::services::session_usage_opencode::sync_opencode_usage(db),
|
||||
);
|
||||
|
||||
// 定期同步
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(
|
||||
@@ -1041,6 +1086,10 @@ pub fn run() {
|
||||
"Gemini usage periodic sync",
|
||||
crate::services::session_usage_gemini::sync_gemini_usage(db),
|
||||
);
|
||||
run_step(
|
||||
"OpenCode usage periodic sync",
|
||||
crate::services::session_usage_opencode::sync_opencode_usage(db),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1105,6 +1154,7 @@ pub fn run() {
|
||||
commands::get_claude_desktop_status,
|
||||
commands::get_claude_desktop_default_routes,
|
||||
commands::import_claude_desktop_providers_from_claude,
|
||||
commands::ensure_claude_desktop_official_provider,
|
||||
commands::get_claude_config_status,
|
||||
commands::get_config_status,
|
||||
commands::get_claude_code_config_path,
|
||||
@@ -1125,6 +1175,8 @@ pub fn run() {
|
||||
commands::read_live_provider_settings,
|
||||
commands::get_settings,
|
||||
commands::save_settings,
|
||||
commands::has_codex_unify_history_backup,
|
||||
commands::restore_codex_unified_history,
|
||||
commands::get_rectifier_config,
|
||||
commands::set_rectifier_config,
|
||||
commands::get_optimizer_config,
|
||||
@@ -1134,6 +1186,7 @@ pub fn run() {
|
||||
commands::get_log_config,
|
||||
commands::set_log_config,
|
||||
commands::restart_app,
|
||||
commands::install_update_and_restart,
|
||||
commands::check_for_updates,
|
||||
commands::is_portable_mode,
|
||||
commands::copy_text_to_clipboard,
|
||||
@@ -1197,6 +1250,11 @@ pub fn run() {
|
||||
commands::webdav_sync_download,
|
||||
commands::webdav_sync_save_settings,
|
||||
commands::webdav_sync_fetch_remote_info,
|
||||
commands::s3_test_connection,
|
||||
commands::s3_sync_upload,
|
||||
commands::s3_sync_download,
|
||||
commands::s3_sync_save_settings,
|
||||
commands::s3_sync_fetch_remote_info,
|
||||
commands::save_file_dialog,
|
||||
commands::open_file_dialog,
|
||||
commands::open_zip_file_dialog,
|
||||
@@ -1407,14 +1465,37 @@ pub fn run() {
|
||||
app.run(|app_handle, event| {
|
||||
// 处理退出请求(所有平台)
|
||||
if let RunEvent::ExitRequested { api, code, .. } = &event {
|
||||
// code 为 None 表示运行时自动触发(如隐藏窗口的 WebView 被回收导致无存活窗口),
|
||||
// 此时应仅阻止退出、保持托盘后台运行;
|
||||
// code 为 Some(_) 表示用户主动调用 app.exit() 退出(如托盘菜单"退出"),
|
||||
// 此时执行清理后退出。
|
||||
if code.is_none() {
|
||||
log::info!("运行时触发退出请求(无存活窗口),阻止退出以保持托盘后台运行");
|
||||
api.prevent_exit();
|
||||
return;
|
||||
match classify_exit_request(*code) {
|
||||
// code 为 None 表示运行时自动触发(如隐藏窗口的 WebView 被回收导致无存活窗口),
|
||||
// 此时应仅阻止退出、保持托盘后台运行。
|
||||
ExitRequestAction::StayInTray => {
|
||||
log::info!("运行时触发退出请求(无存活窗口),阻止退出以保持托盘后台运行");
|
||||
api.prevent_exit();
|
||||
return;
|
||||
}
|
||||
// code 为 RESTART_EXIT_CODE:app.restart() / 自更新 relaunch 发起的重启。
|
||||
// 这条路径上 prevent_exit() 会被 Tauri 忽略,事件循环必定退出,随后由
|
||||
// Tauri 在 RunEvent::Exit 后用新二进制 re-exec(macOS 会按更新后的
|
||||
// Info.plist 解析可执行名)。
|
||||
//
|
||||
// 绝不能复用下面的异步清理任务:该任务在 tokio 线程调 save_window_state,
|
||||
// 持有 window-state 插件锁的同时向主线程查询窗口几何;而主线程此刻正在
|
||||
// 退出事件循环,并在插件自带的 RunEvent::Exit 钩子里等待同一把锁——双方
|
||||
// 互等造成进程永久卡死(更新已安装但应用冻结、不再重启,见 #3998)。
|
||||
//
|
||||
// 重启路径交还 Tauri 默认流程即可:
|
||||
// - 窗口状态:插件 Exit 钩子在主线程保存(同线程读取窗口几何,无死锁)
|
||||
// - 托盘图标:Tauri 内部 cleanup_before_exit 清理,正常走 Drop
|
||||
// - 代理/Live 配置:无需恢复,重启后新实例立即接管并恢复代理状态
|
||||
// - 100ms 落盘等待:重启前的 DB 写入均为命令驱动、此刻已完成,
|
||||
// 与所有 Tauri 应用默认重启路径的行为一致,无需额外等待
|
||||
ExitRequestAction::DeferToTauriRestart => {
|
||||
log::info!("收到重启请求 (code={code:?}),交由 Tauri 默认重启流程 re-exec");
|
||||
return;
|
||||
}
|
||||
// 其它 Some(_):用户主动调用 app.exit() 退出(如托盘菜单"退出"),
|
||||
// 此时执行清理后退出。
|
||||
ExitRequestAction::CleanupAndExit => {}
|
||||
}
|
||||
|
||||
log::info!("收到用户主动退出请求 (code={code:?}),开始清理...");
|
||||
@@ -1424,6 +1505,11 @@ pub fn run() {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
save_window_state_before_exit(&app_handle);
|
||||
cleanup_before_exit(&app_handle).await;
|
||||
// 先于 std::process::exit 显式移除托盘图标。
|
||||
// 进程直接退出时 Tauri 运行时不走正常 Drop 流程,
|
||||
// 不会向 Windows Shell 发送 NIM_DELETE,导致已退出的进程
|
||||
// 注册的图标仍残留在系统托盘(鼠标悬停 Shell 才会重绘发现进程已死)。
|
||||
remove_tray_icon_before_exit(&app_handle);
|
||||
log::info!("清理完成,退出应用");
|
||||
|
||||
// 短暂等待确保所有 I/O 操作(如数据库写入)刷新到磁盘
|
||||
@@ -1571,6 +1657,26 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 主动从系统托盘移除托盘图标。
|
||||
///
|
||||
/// `std::process::exit` 会绕过 Tauri 运行时,触发不了 `TrayIcon::drop()`,
|
||||
/// 也就不会向 Windows Shell 发 `NIM_DELETE`。结果是进程退出后托盘里
|
||||
/// 仍保留一个死图标的缓存占位(Shell 不会主动重绘,需要鼠标悬停才刷新)。
|
||||
///
|
||||
/// 通过 `set_visible(false)` 走 `WM_USER_HIDE_TRAYICON` 消息路径,
|
||||
/// 触发 tray-icon 内部的 `remove_tray_icon` → `Shell_NotifyIconW(NIM_DELETE)`,
|
||||
/// 在进程结束前干净地把图标摘掉。其它平台 `set_visible(false)` 也是
|
||||
/// 正常的隐藏/移除语义,作为跨平台兜底也安全。
|
||||
pub(crate) fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) {
|
||||
if let Some(tray) = app_handle.tray_by_id(tray::TRAY_ID) {
|
||||
if let Err(e) = tray.set_visible(false) {
|
||||
log::warn!("退出时移除托盘图标失败: {e}");
|
||||
} else {
|
||||
log::info!("已显式从系统托盘移除图标");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 启动时恢复代理状态
|
||||
// ============================================================
|
||||
@@ -1830,6 +1936,36 @@ fn show_database_init_error_dialog(
|
||||
.blocking_show()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 退出请求分类
|
||||
// ============================================================
|
||||
|
||||
/// `RunEvent::ExitRequested` 的三类来源,处理方式必须区分。
|
||||
///
|
||||
/// 关键约束:重启请求(`code == RESTART_EXIT_CODE`)上 `prevent_exit()` 会被
|
||||
/// Tauri 静默忽略(见 `ExitRequestApi::prevent_exit` 文档),事件循环必定继续
|
||||
/// 退出并触发各插件的 `RunEvent::Exit` 钩子;任何与之并发的自定义清理任务都
|
||||
/// 可能与插件退出钩子争用同一状态而死锁。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ExitRequestAction {
|
||||
/// `code` 为 `None`:运行时自动触发(如隐藏窗口的 WebView 被回收导致无存活
|
||||
/// 窗口),阻止退出、保持托盘后台运行。
|
||||
StayInTray,
|
||||
/// `code` 为 `RESTART_EXIT_CODE`:`app.restart()` / 自更新 relaunch 发起的
|
||||
/// 重启,不拦截、不做自定义清理,交还 Tauri 默认 re-exec 流程。
|
||||
DeferToTauriRestart,
|
||||
/// 其它 `Some(_)`:用户主动退出(托盘「退出」等),执行完整异步清理后结束进程。
|
||||
CleanupAndExit,
|
||||
}
|
||||
|
||||
fn classify_exit_request(code: Option<i32>) -> ExitRequestAction {
|
||||
match code {
|
||||
None => ExitRequestAction::StayInTray,
|
||||
Some(tauri::RESTART_EXIT_CODE) => ExitRequestAction::DeferToTauriRestart,
|
||||
Some(_) => ExitRequestAction::CleanupAndExit,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 在应用主动退出前显式持久化窗口状态
|
||||
// ============================================================
|
||||
@@ -1847,3 +1983,59 @@ pub fn save_window_state_before_exit(app_handle: &tauri::AppHandle) {
|
||||
log::info!("已在退出前保存窗口状态");
|
||||
}
|
||||
}
|
||||
|
||||
/// 主动释放 single-instance 锁。
|
||||
///
|
||||
/// macOS single-instance 使用 `/tmp/{identifier}.sock`。我们有若干路径会直接
|
||||
/// `std::process::exit(0)`,不会触发插件挂在 `RunEvent::Exit` 上的清理钩子。
|
||||
/// 重启前主动 destroy 可以避免新进程误连旧 listener 后自行退出。
|
||||
pub fn destroy_single_instance_lock(app_handle: &tauri::AppHandle) {
|
||||
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||||
tauri_plugin_single_instance::destroy(app_handle);
|
||||
}
|
||||
|
||||
/// 清理托盘图标、释放 single-instance 锁后重启当前应用。
|
||||
///
|
||||
/// 直接走 `tauri::process::restart`(spawn 新进程 + `exit(0)`),不经过事件
|
||||
/// 循环退出,因此 Tauri 内部的 `cleanup_before_exit` 和各插件的
|
||||
/// `RunEvent::Exit` 钩子都不会执行。需要的清理由调用方与本函数显式补偿:
|
||||
/// 窗口状态、代理/Live 恢复(调用方);托盘图标、single-instance 锁(本函数)。
|
||||
///
|
||||
/// 有意不调 `AppHandle::cleanup_before_exit()`:它会在调用线程上 Drop 托盘
|
||||
/// 图标,而 macOS 的 NSStatusItem 操作要求主线程;`set_visible(false)` 走
|
||||
/// `run_item_main_thread` 代理,跨线程安全(见 `remove_tray_icon_before_exit`)。
|
||||
pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
|
||||
remove_tray_icon_before_exit(app_handle);
|
||||
destroy_single_instance_lock(app_handle);
|
||||
tauri::process::restart(&app_handle.env());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{classify_exit_request, ExitRequestAction};
|
||||
|
||||
#[test]
|
||||
fn no_code_keeps_app_alive_in_tray() {
|
||||
assert_eq!(classify_exit_request(None), ExitRequestAction::StayInTray);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_exit_code_defers_to_tauri_default_restart() {
|
||||
assert_eq!(
|
||||
classify_exit_request(Some(tauri::RESTART_EXIT_CODE)),
|
||||
ExitRequestAction::DeferToTauriRestart
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_exit_codes_run_cleanup_then_exit() {
|
||||
assert_eq!(
|
||||
classify_exit_request(Some(0)),
|
||||
ExitRequestAction::CleanupAndExit
|
||||
);
|
||||
assert_eq!(
|
||||
classify_exit_request(Some(1)),
|
||||
ExitRequestAction::CleanupAndExit
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,40 @@ pub fn get_opencode_config_path() -> PathBuf {
|
||||
get_opencode_dir().join("opencode.json")
|
||||
}
|
||||
|
||||
/// 获取 OpenCode SQLite 数据库路径
|
||||
/// 优先级: OPENCODE_DB 环境变量 > XDG_DATA_HOME > ~/.local/share/opencode
|
||||
pub fn get_opencode_db_path() -> PathBuf {
|
||||
// 支持 OPENCODE_DB 环境变量覆盖(忽略空字符串)
|
||||
if let Ok(custom_path) = std::env::var("OPENCODE_DB") {
|
||||
if !custom_path.is_empty() {
|
||||
let path = PathBuf::from(&custom_path);
|
||||
if path.is_absolute() {
|
||||
return path;
|
||||
}
|
||||
// 相对路径基于数据目录
|
||||
return get_opencode_data_dir().join(path);
|
||||
}
|
||||
}
|
||||
|
||||
get_opencode_data_dir().join("opencode.db")
|
||||
}
|
||||
|
||||
fn get_opencode_data_dir() -> PathBuf {
|
||||
// 尊重 XDG_DATA_HOME(按 XDG 规范,空字符串视为未设置)
|
||||
if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME") {
|
||||
if !xdg_data.is_empty() {
|
||||
return PathBuf::from(xdg_data).join("opencode");
|
||||
}
|
||||
}
|
||||
|
||||
// OpenCode 使用 xdg-basedir,不遵守 macOS/Windows 平台约定,
|
||||
// 所有平台默认都落在 ~/.local/share/opencode
|
||||
crate::config::get_home_dir()
|
||||
.join(".local")
|
||||
.join("share")
|
||||
.join("opencode")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_opencode_env_path() -> PathBuf {
|
||||
get_opencode_dir().join(".env")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use http::header::{HeaderValue, InvalidHeaderValue};
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -107,6 +108,104 @@ impl Provider {
|
||||
.map(|s| s.enabled)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Resolve `(base_url, api_key)` for usage queries (native balance /
|
||||
/// coding-plan and the JS-script `{{apiKey}}`/`{{baseUrl}}` fallback)
|
||||
/// from the stored provider config.
|
||||
///
|
||||
/// Each app persists credentials in a different shape, so callers must pass
|
||||
/// the owning app type. This mirrors the frontend `getProviderCredentials`
|
||||
/// in `UsageScriptModal.tsx`.
|
||||
pub fn resolve_usage_credentials(
|
||||
&self,
|
||||
app_type: &crate::app_config::AppType,
|
||||
) -> (String, String) {
|
||||
use crate::app_config::AppType;
|
||||
|
||||
let settings = &self.settings_config;
|
||||
let str_at =
|
||||
|value: Option<&Value>| value.and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
|
||||
// First present, non-empty string among `keys`, mirroring the frontend's
|
||||
// `a || b || c` — JS `||` skips empty strings, and presets seed fields like
|
||||
// `ANTHROPIC_AUTH_TOKEN` as present-but-empty placeholders, so a plain
|
||||
// `.get().or_else()` chain (which only skips *absent* keys) would stop short.
|
||||
fn first_non_empty(env: Option<&Value>, keys: &[&str]) -> String {
|
||||
let Some(env) = env else {
|
||||
return String::new();
|
||||
};
|
||||
for key in keys {
|
||||
if let Some(s) = env.get(key).and_then(|v| v.as_str()) {
|
||||
if !s.is_empty() {
|
||||
return s.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
let (base_url, api_key) = match app_type {
|
||||
// Codex keeps its key in `auth.OPENAI_API_KEY` and its base URL
|
||||
// inside a TOML `config` string, not in an `env` map.
|
||||
AppType::Codex => {
|
||||
let auth = settings.get("auth");
|
||||
let config_text = settings.get("config").and_then(|v| v.as_str());
|
||||
let api_key = crate::codex_config::extract_codex_api_key(auth, config_text)
|
||||
.unwrap_or_default();
|
||||
let base_url = config_text
|
||||
.and_then(crate::codex_config::extract_codex_base_url)
|
||||
.unwrap_or_default();
|
||||
(base_url, api_key)
|
||||
}
|
||||
// Gemini uses Google-specific env keys (with a legacy GOOGLE_API_KEY fallback).
|
||||
AppType::Gemini => {
|
||||
let env = settings.get("env");
|
||||
let base_url = str_at(env.and_then(|e| e.get("GOOGLE_GEMINI_BASE_URL")));
|
||||
let api_key = first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
|
||||
(base_url, api_key)
|
||||
}
|
||||
// Hermes (config.yaml) flattens credentials at the top level, snake_case.
|
||||
AppType::Hermes => (
|
||||
str_at(settings.get("base_url")),
|
||||
str_at(settings.get("api_key")),
|
||||
),
|
||||
// OpenClaw (openclaw.json) flattens credentials at the top level, camelCase.
|
||||
AppType::OpenClaw => (
|
||||
str_at(settings.get("baseUrl")),
|
||||
str_at(settings.get("apiKey")),
|
||||
),
|
||||
// OpenCode (OMO) nests credentials under `options` (the SDK options object).
|
||||
AppType::OpenCode => {
|
||||
let options = settings.get("options");
|
||||
(
|
||||
str_at(options.and_then(|o| o.get("baseURL"))),
|
||||
str_at(options.and_then(|o| o.get("apiKey"))),
|
||||
)
|
||||
}
|
||||
// Claude and Claude Desktop both use the Anthropic-style env map, keeping
|
||||
// the OpenRouter/Google key fallbacks the JS-script path relies on.
|
||||
// Listed explicitly (not `_`) so a new AppType fails to compile here.
|
||||
AppType::Claude | AppType::ClaudeDesktop => {
|
||||
let env = settings.get("env");
|
||||
let base_url = str_at(env.and_then(|e| e.get("ANTHROPIC_BASE_URL")));
|
||||
let api_key = first_non_empty(
|
||||
env,
|
||||
&[
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"GOOGLE_API_KEY",
|
||||
],
|
||||
);
|
||||
(base_url, api_key)
|
||||
}
|
||||
};
|
||||
|
||||
// Normalize like the JS-script path (extract_base_url_from_provider) so a
|
||||
// future delegation from services/provider/usage.rs is behavior-preserving
|
||||
// and `{{baseUrl}}/path` concatenation never produces a double slash.
|
||||
(base_url.trim_end_matches('/').to_string(), api_key)
|
||||
}
|
||||
}
|
||||
|
||||
/// 供应商管理器
|
||||
@@ -188,21 +287,15 @@ pub struct UsageResult {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 供应商单独的模型测试配置
|
||||
/// 供应商单独的连通检测配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderTestConfig {
|
||||
/// 是否启用单独配置(false 时使用全局配置)
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// 测试用的模型名称(覆盖全局配置)
|
||||
#[serde(rename = "testModel", skip_serializing_if = "Option::is_none")]
|
||||
pub test_model: Option<String>,
|
||||
/// 超时时间(秒)
|
||||
#[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_secs: Option<u64>,
|
||||
/// 测试提示词
|
||||
#[serde(rename = "testPrompt", skip_serializing_if = "Option::is_none")]
|
||||
pub test_prompt: Option<String>,
|
||||
/// 降级阈值(毫秒)
|
||||
#[serde(
|
||||
rename = "degradedThresholdMs",
|
||||
@@ -362,6 +455,9 @@ pub struct ProviderMeta {
|
||||
/// Codex Responses -> Chat Completions reasoning capability metadata.
|
||||
#[serde(rename = "codexChatReasoning", skip_serializing_if = "Option::is_none")]
|
||||
pub codex_chat_reasoning: Option<CodexChatReasoningConfig>,
|
||||
/// Custom User-Agent for local proxy routing.
|
||||
#[serde(rename = "customUserAgent", skip_serializing_if = "Option::is_none")]
|
||||
pub custom_user_agent: Option<String>,
|
||||
/// 累加模式应用中,该 provider 是否已写入 live config。
|
||||
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
|
||||
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
|
||||
@@ -376,6 +472,30 @@ pub struct ProviderMeta {
|
||||
pub github_account_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 解析 Provider 级自定义 User-Agent 字符串(单一真理来源)。
|
||||
///
|
||||
/// 转发(forwarder)、流式检测(stream_check)、获取模型列表(model_fetch)三条路径
|
||||
/// 共用同一口径,避免出现"某条路径用了 UA、另一条没用 / 报错"的不一致。
|
||||
///
|
||||
/// 合法性由 `http::HeaderValue::from_str` 按**字节**判定(`b >= 32 && b != 127 || b == '\t'`),
|
||||
/// 与前端 `src/lib/userAgent.ts::isValidUserAgentHeader` 严格一致:
|
||||
/// - `Ok(None)`:未设置或纯空白(trim 后为空)。
|
||||
/// - `Ok(Some(hv))`:合法。制表符、可见 ASCII(0x20–0x7E)、以及任意非 ASCII 字符
|
||||
/// (UTF-8 字节均 ≥ 0x80)都合法。
|
||||
/// - `Err(_)`:仅含控制字符时——除 `\t` 外的 0x00–0x1F(含换行)与 0x7F(DEL)。
|
||||
///
|
||||
/// 非法值的处理:三条运行时路径**均静默忽略**(`.ok().flatten()`,绝不让某条路径报错而
|
||||
/// 另一条放行);前端在输入框处给出非阻断提示。当前**不在保存时阻断**——deeplink 导入等
|
||||
/// 非表单路径应宽容,运行时静默忽略即为安全网。
|
||||
pub fn parse_custom_user_agent(
|
||||
raw: Option<&str>,
|
||||
) -> Result<Option<HeaderValue>, InvalidHeaderValue> {
|
||||
match raw.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(ua) => HeaderValue::from_str(ua).map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderMeta {
|
||||
/// Codex OAuth FAST mode 是否启用。默认关闭,因为 `service_tier="priority"`
|
||||
/// 会按更高速率消耗 ChatGPT 订阅配额,用户需显式开启以换取更低延迟。
|
||||
@@ -383,6 +503,11 @@ impl ProviderMeta {
|
||||
self.codex_fast_mode.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 经校验的 Provider 级自定义 User-Agent。见 [`parse_custom_user_agent`]。
|
||||
pub fn custom_user_agent_header(&self) -> Result<Option<HeaderValue>, InvalidHeaderValue> {
|
||||
parse_custom_user_agent(self.custom_user_agent.as_deref())
|
||||
}
|
||||
|
||||
/// 解析指定托管认证供应商绑定的账号 ID。
|
||||
///
|
||||
/// 新版优先读取 authBinding,旧版继续兼容 githubAccountId。
|
||||
@@ -1150,4 +1275,196 @@ mod tests {
|
||||
assert!(toml.contains("base_url = \"https://example.com/openai\""));
|
||||
assert!(!toml.contains("https://example.com/openai/v1"));
|
||||
}
|
||||
|
||||
// ── resolve_usage_credentials (per-app credential extraction) ──
|
||||
|
||||
use crate::app_config::AppType;
|
||||
|
||||
fn provider_with(settings_config: serde_json::Value) -> Provider {
|
||||
Provider::with_id("p".to_string(), "P".to_string(), settings_config, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_credentials_claude_env() {
|
||||
let p = provider_with(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-claude",
|
||||
}
|
||||
}));
|
||||
assert_eq!(
|
||||
p.resolve_usage_credentials(&AppType::Claude),
|
||||
(
|
||||
"https://api.deepseek.com/anthropic".to_string(),
|
||||
"sk-claude".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_credentials_claude_openrouter_fallback() {
|
||||
// OpenRouter-on-Claude keeps its key in OPENROUTER_API_KEY; the superset
|
||||
// fallback must still find it (regression guard for the per-app refactor).
|
||||
let p = provider_with(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1",
|
||||
"OPENROUTER_API_KEY": "sk-or",
|
||||
}
|
||||
}));
|
||||
let (base_url, api_key) = p.resolve_usage_credentials(&AppType::Claude);
|
||||
assert_eq!(base_url, "https://openrouter.ai/api/v1");
|
||||
assert_eq!(api_key, "sk-or");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_credentials_codex_auth_and_toml() {
|
||||
let p = provider_with(json!({
|
||||
"auth": { "OPENAI_API_KEY": "sk-codex" },
|
||||
"config": "model_provider = \"deepseek\"\n\
|
||||
[model_providers.deepseek]\n\
|
||||
base_url = \"https://api.deepseek.com\"\n",
|
||||
}));
|
||||
assert_eq!(
|
||||
p.resolve_usage_credentials(&AppType::Codex),
|
||||
(
|
||||
"https://api.deepseek.com".to_string(),
|
||||
"sk-codex".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_credentials_gemini_env_with_google_fallback() {
|
||||
let p = provider_with(json!({
|
||||
"env": {
|
||||
"GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com",
|
||||
"GOOGLE_API_KEY": "g-legacy",
|
||||
}
|
||||
}));
|
||||
let (base_url, api_key) = p.resolve_usage_credentials(&AppType::Gemini);
|
||||
assert_eq!(base_url, "https://generativelanguage.googleapis.com");
|
||||
assert_eq!(api_key, "g-legacy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_credentials_claude_skips_empty_primary_key() {
|
||||
// Presets seed ANTHROPIC_AUTH_TOKEN as a present-but-empty placeholder.
|
||||
// The fallback chain must skip empty values (matching the frontend's
|
||||
// `a || b` semantics), not just absent keys.
|
||||
let p = provider_with(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1",
|
||||
"ANTHROPIC_AUTH_TOKEN": "",
|
||||
"ANTHROPIC_API_KEY": "",
|
||||
"OPENROUTER_API_KEY": "sk-or",
|
||||
}
|
||||
}));
|
||||
let (_, api_key) = p.resolve_usage_credentials(&AppType::Claude);
|
||||
assert_eq!(api_key, "sk-or");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_credentials_gemini_skips_empty_primary_key() {
|
||||
let p = provider_with(json!({
|
||||
"env": {
|
||||
"GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com",
|
||||
"GEMINI_API_KEY": "",
|
||||
"GOOGLE_API_KEY": "g-real",
|
||||
}
|
||||
}));
|
||||
let (_, api_key) = p.resolve_usage_credentials(&AppType::Gemini);
|
||||
assert_eq!(api_key, "g-real");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_credentials_hermes_snake_case() {
|
||||
let p = provider_with(json!({
|
||||
"base_url": "https://api.deepseek.com",
|
||||
"api_key": "sk-hermes",
|
||||
}));
|
||||
assert_eq!(
|
||||
p.resolve_usage_credentials(&AppType::Hermes),
|
||||
(
|
||||
"https://api.deepseek.com".to_string(),
|
||||
"sk-hermes".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_credentials_openclaw_camel_case() {
|
||||
let p = provider_with(json!({
|
||||
"baseUrl": "https://api.deepseek.com",
|
||||
"apiKey": "sk-openclaw",
|
||||
}));
|
||||
assert_eq!(
|
||||
p.resolve_usage_credentials(&AppType::OpenClaw),
|
||||
(
|
||||
"https://api.deepseek.com".to_string(),
|
||||
"sk-openclaw".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_credentials_opencode_options() {
|
||||
// OpenCode (OMO) nests creds under options.{baseURL,apiKey}; useOpencodeFormState
|
||||
// writes config.options.apiKey, so the stored provider keeps them there.
|
||||
let p = provider_with(json!({
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"options": {
|
||||
"baseURL": "https://api.deepseek.com/v1",
|
||||
"apiKey": "sk-opencode",
|
||||
"setCacheKey": true,
|
||||
}
|
||||
}));
|
||||
assert_eq!(
|
||||
p.resolve_usage_credentials(&AppType::OpenCode),
|
||||
(
|
||||
"https://api.deepseek.com/v1".to_string(),
|
||||
"sk-opencode".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_credentials_claude_desktop_uses_env() {
|
||||
// ClaudeDesktop persists the Anthropic env shape (ClaudeDesktopProviderForm
|
||||
// reads env.ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN), so it resolves via
|
||||
// the default env branch — it is NOT unsupported.
|
||||
let p = provider_with(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-desktop",
|
||||
}
|
||||
}));
|
||||
assert_eq!(
|
||||
p.resolve_usage_credentials(&AppType::ClaudeDesktop),
|
||||
(
|
||||
"https://api.deepseek.com/anthropic".to_string(),
|
||||
"sk-desktop".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_credentials_trims_trailing_slash_on_base_url() {
|
||||
let p = provider_with(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic/",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-claude",
|
||||
}
|
||||
}));
|
||||
let (base_url, _) = p.resolve_usage_credentials(&AppType::Claude);
|
||||
assert_eq!(base_url, "https://api.deepseek.com/anthropic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_credentials_missing_fields_yield_empty() {
|
||||
let p = provider_with(json!({}));
|
||||
assert_eq!(
|
||||
p.resolve_usage_credentials(&AppType::Claude),
|
||||
(String::new(), String::new())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 错误类型到 HTTP 状态码的映射
|
||||
//!
|
||||
//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录
|
||||
//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录和手动构建错误响应
|
||||
|
||||
use super::ProxyError;
|
||||
|
||||
@@ -12,14 +12,21 @@ use super::ProxyError;
|
||||
/// - 连接失败:502 Bad Gateway
|
||||
/// - 无可用 Provider:503 Service Unavailable
|
||||
/// - 重试耗尽:503 Service Unavailable
|
||||
/// - 认证错误:401 Unauthorized
|
||||
/// - 配置/请求错误:400 Bad Request
|
||||
/// - 转换错误:422 Unprocessable Entity
|
||||
/// - 其他错误:500 Internal Server Error
|
||||
pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
|
||||
match error {
|
||||
// 服务状态错误:与 IntoResponse 保持一致
|
||||
ProxyError::AlreadyRunning => 409,
|
||||
ProxyError::NotRunning => 503,
|
||||
|
||||
// 上游错误:使用实际状态码
|
||||
ProxyError::UpstreamError { status, .. } => *status,
|
||||
|
||||
// 超时错误:504 Gateway Timeout
|
||||
ProxyError::Timeout(_) => 504,
|
||||
ProxyError::Timeout(_) | ProxyError::StreamIdleTimeout(_) => 504,
|
||||
|
||||
// 转发失败/连接失败:502 Bad Gateway
|
||||
ProxyError::ForwardFailed(_) => 502,
|
||||
@@ -39,11 +46,17 @@ pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
|
||||
// Provider 不健康:503 Service Unavailable
|
||||
ProxyError::ProviderUnhealthy(_) => 503,
|
||||
|
||||
// 配置错误/无效请求:400 Bad Request
|
||||
ProxyError::ConfigError(_) | ProxyError::InvalidRequest(_) => 400,
|
||||
|
||||
// 认证错误:401 Unauthorized
|
||||
ProxyError::AuthError(_) => 401,
|
||||
|
||||
// 数据库错误:500 Internal Server Error
|
||||
ProxyError::DatabaseError(_) => 500,
|
||||
|
||||
// 转换错误:500 Internal Server Error
|
||||
ProxyError::TransformError(_) => 500,
|
||||
// 转换错误:422 Unprocessable Entity
|
||||
ProxyError::TransformError(_) => 422,
|
||||
|
||||
// 其他未知错误:500 Internal Server Error
|
||||
_ => 500,
|
||||
@@ -104,6 +117,30 @@ mod tests {
|
||||
assert_eq!(map_proxy_error_to_status(&error), 503);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_status_matches_proxy_error_response_semantics() {
|
||||
assert_eq!(
|
||||
map_proxy_error_to_status(&ProxyError::AuthError("bad token".to_string())),
|
||||
401
|
||||
);
|
||||
assert_eq!(
|
||||
map_proxy_error_to_status(&ProxyError::ConfigError("bad config".to_string())),
|
||||
400
|
||||
);
|
||||
assert_eq!(
|
||||
map_proxy_error_to_status(&ProxyError::InvalidRequest("bad request".to_string())),
|
||||
400
|
||||
);
|
||||
assert_eq!(
|
||||
map_proxy_error_to_status(&ProxyError::TransformError("bad transform".to_string())),
|
||||
422
|
||||
);
|
||||
assert_eq!(
|
||||
map_proxy_error_to_status(&ProxyError::StreamIdleTimeout(30)),
|
||||
504
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_error_message() {
|
||||
let error = ProxyError::UpstreamError {
|
||||
|
||||
@@ -38,6 +38,11 @@ pub struct ForwardResult {
|
||||
pub response: ProxyResponse,
|
||||
pub provider: Provider,
|
||||
pub claude_api_format: Option<String>,
|
||||
/// 实际发往上游的模型名(路由接管/模型映射后的真值)。
|
||||
///
|
||||
/// usage 归因不能依赖 ctx.request_model(映射前的客户端别名):上游响应
|
||||
/// 缺失 model 或回显别名时,接管流量会被记成 claude-* 并按其定价计费。
|
||||
pub outbound_model: Option<String>,
|
||||
/// 活跃连接 RAII guard:随响应一起流转到 response_processor / handle_claude_transform,
|
||||
/// 最终被 move 进流式 body future(或非流式响应作用域),覆盖整个响应生命周期。
|
||||
pub(crate) connection_guard: Option<ActiveConnectionGuard>,
|
||||
@@ -122,6 +127,51 @@ pub struct RequestForwarder {
|
||||
}
|
||||
|
||||
impl RequestForwarder {
|
||||
/// 预防式 media 降级:发送前对 text-only 模型把图片块替换为标记。
|
||||
///
|
||||
/// 受 `enabled && request_media_fallback` 管辖;其中"启发式模型名单预测"
|
||||
/// 再受 `request_media_heuristic` 单独管辖(显式声明 text-only 始终生效)。
|
||||
/// 返回被替换的图片块数量(0 = 未触发或开关关闭)。
|
||||
fn apply_media_prevention(&self, body: &mut Value, provider: &Provider) -> usize {
|
||||
if !(self.rectifier_config.enabled && self.rectifier_config.request_media_fallback) {
|
||||
return 0;
|
||||
}
|
||||
let replaced_images = super::media_sanitizer::replace_images_for_text_only_model(
|
||||
body,
|
||||
provider,
|
||||
self.rectifier_config.request_media_heuristic,
|
||||
);
|
||||
if replaced_images > 0 {
|
||||
let model = body.get("model").and_then(Value::as_str).unwrap_or("");
|
||||
log::info!(
|
||||
"[Media] Replaced {replaced_images} image block(s) with {} for text-only provider={}, model={}",
|
||||
super::media_sanitizer::UNSUPPORTED_IMAGE_MARKER,
|
||||
provider.id,
|
||||
model
|
||||
);
|
||||
}
|
||||
replaced_images
|
||||
}
|
||||
|
||||
/// 反应式 media 重试判定:上游因图片输入报错后,是否应替换图片块并对同一供应商重试一次。
|
||||
///
|
||||
/// 受 `enabled && request_media_fallback` 管辖;不涉及 `request_media_heuristic`——
|
||||
/// 这里是上游"实测"错误后的纯恢复,不是预测,故启发式开关与它无关。
|
||||
fn media_retry_should_trigger(
|
||||
&self,
|
||||
adapter_name: &str,
|
||||
already_retried: bool,
|
||||
provider_body: &Value,
|
||||
error: &ProxyError,
|
||||
) -> bool {
|
||||
matches!(adapter_name, "Claude" | "Codex")
|
||||
&& self.rectifier_config.enabled
|
||||
&& self.rectifier_config.request_media_fallback
|
||||
&& !already_retried
|
||||
&& super::media_sanitizer::contains_image_blocks(provider_body)
|
||||
&& super::media_sanitizer::is_unsupported_image_error(error)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
router: Arc<ProviderRouter>,
|
||||
@@ -346,6 +396,7 @@ impl RequestForwarder {
|
||||
// —— 首家 provider 整流后被 5xx/timeout 击落时,下家仍能用整流后的请求体走整流流程
|
||||
let mut rectifier_retried = false;
|
||||
let mut budget_rectifier_retried = false;
|
||||
let mut media_rectifier_retried = false;
|
||||
|
||||
// 上限检查:尊重用户在 AppProxyConfig.max_retries 上配置的「重试次数」。
|
||||
// 放在熔断器 allow 检查之前,避免在已经超限时还占用 HalfOpen 探测名额。
|
||||
@@ -417,7 +468,7 @@ impl RequestForwarder {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((response, claude_api_format)) => {
|
||||
Ok((response, claude_api_format, outbound_model)) => {
|
||||
// 成功:普通闭合熔断状态异步记录,避免阻塞流式首包返回;
|
||||
// HalfOpen 探测仍同步等待,保证 permit 与熔断状态及时释放。
|
||||
self.record_success_result(&provider.id, app_type_str, used_half_open_permit)
|
||||
@@ -465,6 +516,7 @@ impl RequestForwarder {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
outbound_model,
|
||||
connection_guard: None,
|
||||
});
|
||||
}
|
||||
@@ -477,6 +529,124 @@ impl RequestForwarder {
|
||||
);
|
||||
let mut signature_rectifier_non_retryable_client_error = false;
|
||||
|
||||
if self.media_retry_should_trigger(
|
||||
adapter.name(),
|
||||
media_rectifier_retried,
|
||||
&provider_body,
|
||||
&e,
|
||||
) {
|
||||
let mut media_body = provider_body.clone();
|
||||
let replaced_images =
|
||||
super::media_sanitizer::replace_image_blocks_with_marker(
|
||||
&mut media_body,
|
||||
);
|
||||
|
||||
if replaced_images > 0 {
|
||||
let _ = std::mem::replace(&mut media_rectifier_retried, true);
|
||||
let model = media_body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
log::info!(
|
||||
"[{app_type_str}] [Media] Upstream rejected image input; retrying provider={} model={} with {replaced_images} image block(s) replaced by {}",
|
||||
provider.id,
|
||||
model,
|
||||
super::media_sanitizer::UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
|
||||
match self
|
||||
.forward(
|
||||
app_type,
|
||||
&method,
|
||||
provider,
|
||||
endpoint,
|
||||
&media_body,
|
||||
&headers,
|
||||
&extensions,
|
||||
adapter.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((response, claude_api_format, outbound_model)) => {
|
||||
log::info!(
|
||||
"[{app_type_str}] [Media] Unsupported-image retry succeeded"
|
||||
);
|
||||
self.record_success_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
|
||||
{
|
||||
let mut current_providers =
|
||||
self.current_providers.write().await;
|
||||
current_providers.insert(
|
||||
app_type_str.to_string(),
|
||||
(provider.id.clone(), provider.name.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.success_requests += 1;
|
||||
status.last_error = None;
|
||||
let should_switch =
|
||||
self.current_provider_id_at_start.as_str()
|
||||
!= provider.id.as_str();
|
||||
if should_switch {
|
||||
status.failover_count += 1;
|
||||
let fm = self.failover_manager.clone();
|
||||
let ah = self.app_handle.clone();
|
||||
let pid = provider.id.clone();
|
||||
let pname = provider.name.clone();
|
||||
let at = app_type_str.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = fm
|
||||
.try_switch(ah.as_ref(), &at, &pid, &pname)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(ForwardResult {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
outbound_model,
|
||||
connection_guard: None,
|
||||
});
|
||||
}
|
||||
Err(retry_err) => {
|
||||
log::warn!(
|
||||
"[{app_type_str}] [Media] Unsupported-image retry still failed: {retry_err}"
|
||||
);
|
||||
if let Some(err) = self
|
||||
.handle_rectifier_retry_failure(
|
||||
retry_err,
|
||||
provider,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
"media 降级",
|
||||
&mut last_error,
|
||||
&mut last_provider,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(err);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is_anthropic_provider {
|
||||
let error_message = extract_error_message(&e);
|
||||
if should_rectify_thinking_signature(
|
||||
@@ -543,7 +713,7 @@ impl RequestForwarder {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((response, claude_api_format)) => {
|
||||
Ok((response, claude_api_format, outbound_model)) => {
|
||||
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||
self.record_success_result(
|
||||
&provider.id,
|
||||
@@ -598,6 +768,7 @@ impl RequestForwarder {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
outbound_model,
|
||||
connection_guard: None,
|
||||
});
|
||||
}
|
||||
@@ -708,7 +879,7 @@ impl RequestForwarder {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((response, claude_api_format)) => {
|
||||
Ok((response, claude_api_format, outbound_model)) => {
|
||||
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
|
||||
self.record_success_result(
|
||||
&provider.id,
|
||||
@@ -757,6 +928,7 @@ impl RequestForwarder {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
outbound_model,
|
||||
connection_guard: None,
|
||||
});
|
||||
}
|
||||
@@ -914,6 +1086,9 @@ impl RequestForwarder {
|
||||
}
|
||||
|
||||
/// 转发单个请求(使用适配器)
|
||||
///
|
||||
/// 成功时返回 `(response, claude_api_format, outbound_model)`,其中
|
||||
/// `outbound_model` 是最终发往上游的模型名(所有映射/改写之后)。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn forward(
|
||||
&self,
|
||||
@@ -925,7 +1100,7 @@ impl RequestForwarder {
|
||||
headers: &axum::http::HeaderMap,
|
||||
extensions: &Extensions,
|
||||
adapter: &dyn ProviderAdapter,
|
||||
) -> Result<(ProxyResponse, Option<String>), ProxyError> {
|
||||
) -> Result<(ProxyResponse, Option<String>, Option<String>), ProxyError> {
|
||||
// 使用适配器提取 base_url
|
||||
let mut base_url = adapter.extract_base_url(provider)?;
|
||||
|
||||
@@ -1109,11 +1284,12 @@ impl RequestForwarder {
|
||||
};
|
||||
if adapter.name() == "Claude" {
|
||||
if let Some(api_format) = resolved_claude_api_format.as_deref() {
|
||||
super::providers::normalize_anthropic_tool_thinking_history_for_provider(
|
||||
super::providers::normalize_anthropic_messages_for_provider(
|
||||
&mut mapped_body,
|
||||
provider,
|
||||
api_format,
|
||||
);
|
||||
self.apply_media_prevention(&mut mapped_body, provider);
|
||||
}
|
||||
}
|
||||
let needs_transform = match resolved_claude_api_format.as_deref() {
|
||||
@@ -1156,8 +1332,17 @@ impl RequestForwarder {
|
||||
adapter.build_url(&base_url, &effective_endpoint)
|
||||
};
|
||||
|
||||
// 记录映射后的出站模型名(此时 mapped_body 已完成接管映射 / [1m] 剥离 /
|
||||
// Copilot 归一化)。格式转换后若 body 仍带 model 字段会在下方刷新覆盖;
|
||||
// gemini_native 等模型在 URL 中的格式则保留此处的转换前真值。
|
||||
let mut outbound_model = mapped_body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.filter(|m| !m.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
// 转换请求体(如果需要)
|
||||
let request_body = if codex_responses_to_chat {
|
||||
let mut request_body = if codex_responses_to_chat {
|
||||
let mut mapped_body = mapped_body;
|
||||
let restored = self
|
||||
.codex_chat_history
|
||||
@@ -1195,9 +1380,21 @@ impl RequestForwarder {
|
||||
mapped_body
|
||||
};
|
||||
|
||||
if matches!(app_type, AppType::Codex) {
|
||||
self.apply_media_prevention(&mut request_body, provider);
|
||||
}
|
||||
|
||||
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
|
||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||
let filtered_body = prepare_upstream_request_body(request_body);
|
||||
// 出站 body 定稿后刷新真值(覆盖 Codex chat 上游模型覆写、转换层模型改写)
|
||||
if let Some(m) = filtered_body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.filter(|m| !m.is_empty())
|
||||
{
|
||||
outbound_model = Some(m.to_string());
|
||||
}
|
||||
log_prompt_cache_trace(
|
||||
app_type,
|
||||
provider,
|
||||
@@ -1340,6 +1537,18 @@ impl RequestForwarder {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// 自定义 User-Agent:与 stream_check / model_fetch 共用 parse_custom_user_agent,
|
||||
// 运行时静默忽略非法值(前端在输入处给非阻断提示,不在保存时阻断)。
|
||||
// Copilot 指纹 UA 不可覆盖。
|
||||
let custom_user_agent = if is_copilot {
|
||||
None
|
||||
} else {
|
||||
provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.custom_user_agent_header().ok().flatten())
|
||||
};
|
||||
|
||||
// --- Copilot 优化器:动态 header 注入 ---
|
||||
if let Some((ref classification, ref det_request_id, ref interaction_id)) =
|
||||
copilot_optimization
|
||||
@@ -1434,6 +1643,7 @@ impl RequestForwarder {
|
||||
let mut ordered_headers = http::HeaderMap::new();
|
||||
let mut saw_auth = false;
|
||||
let mut saw_accept_encoding = false;
|
||||
let mut saw_user_agent = false;
|
||||
let mut saw_anthropic_beta = false;
|
||||
let mut saw_anthropic_version = false;
|
||||
|
||||
@@ -1514,6 +1724,19 @@ impl RequestForwarder {
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- user-agent: provider-level override for local proxy routing ---
|
||||
if !is_copilot && key_str.eq_ignore_ascii_case("user-agent") {
|
||||
if !saw_user_agent {
|
||||
saw_user_agent = true;
|
||||
if let Some(ref ua) = custom_user_agent {
|
||||
ordered_headers.append(http::header::USER_AGENT, ua.clone());
|
||||
} 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 {
|
||||
@@ -1563,6 +1786,12 @@ impl RequestForwarder {
|
||||
);
|
||||
}
|
||||
|
||||
if !saw_user_agent {
|
||||
if let Some(ref ua) = custom_user_agent {
|
||||
ordered_headers.append(http::header::USER_AGENT, ua.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 如果原始请求中没有 anthropic-beta 且有值需要添加,追加
|
||||
if !saw_anthropic_beta {
|
||||
if let Some(ref beta_val) = anthropic_beta_value {
|
||||
@@ -1710,7 +1939,7 @@ impl RequestForwarder {
|
||||
let response = self
|
||||
.prepare_success_response_for_failover(response, request_is_streaming)
|
||||
.await?;
|
||||
Ok((response, resolved_claude_api_format))
|
||||
Ok((response, resolved_claude_api_format, outbound_model))
|
||||
} else {
|
||||
let status_code = status.as_u16();
|
||||
let body_text = String::from_utf8(response.bytes().await?.to_vec()).ok();
|
||||
@@ -3106,4 +3335,187 @@ mod tests {
|
||||
assert_eq!(will_replace, should_replace, "{desc}");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== P3: forwarder 层 media 开关回归测试 =====
|
||||
// 验证 gate 在 forwarder 这一层的"接线",而非 media_sanitizer 纯函数本身。
|
||||
|
||||
fn forwarder_with_rectifier(config: RectifierConfig) -> RequestForwarder {
|
||||
let mut fwd = test_forwarder(Duration::from_secs(1), Duration::from_secs(1));
|
||||
fwd.rectifier_config = config;
|
||||
fwd
|
||||
}
|
||||
|
||||
fn provider_with_settings(settings_config: Value) -> Provider {
|
||||
let mut p = test_provider_with_type(Some("anthropic"));
|
||||
p.settings_config = settings_config;
|
||||
p
|
||||
}
|
||||
|
||||
fn body_with_image(model: &str) -> Value {
|
||||
json!({
|
||||
"model": model,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
fn body_with_codex_input_image(model: &str) -> Value {
|
||||
json!({
|
||||
"model": model,
|
||||
"input": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "input_image", "image_url": "data:image/png;base64,abc" }
|
||||
]
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
fn image_unsupported_error() -> ProxyError {
|
||||
ProxyError::UpstreamError {
|
||||
status: 400,
|
||||
body: Some(
|
||||
r#"{"error":{"message":"This model does not support image input"}}"#.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn prevention_replaces_when_all_switches_on_and_model_in_heuristic_list() {
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig::default());
|
||||
let provider = provider_with_settings(json!({}));
|
||||
let mut body = body_with_image("deepseek-v4-pro");
|
||||
|
||||
let replaced = fwd.apply_media_prevention(&mut body, &provider);
|
||||
|
||||
assert_eq!(replaced, 1, "默认全开 + 名单内模型应预替换");
|
||||
assert_eq!(body["messages"][0]["content"][0]["type"], "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prevention_skipped_when_media_fallback_off() {
|
||||
// 关闭 request_media_fallback:即使名单命中也不预替换。
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig {
|
||||
request_media_fallback: false,
|
||||
..RectifierConfig::default()
|
||||
});
|
||||
let provider = provider_with_settings(json!({}));
|
||||
let mut body = body_with_image("deepseek-v4-pro");
|
||||
|
||||
let replaced = fwd.apply_media_prevention(&mut body, &provider);
|
||||
|
||||
assert_eq!(replaced, 0);
|
||||
assert_eq!(body["messages"][0]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prevention_skipped_when_master_switch_off() {
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig {
|
||||
enabled: false,
|
||||
..RectifierConfig::default()
|
||||
});
|
||||
let provider = provider_with_settings(json!({}));
|
||||
let mut body = body_with_image("deepseek-v4-pro");
|
||||
|
||||
assert_eq!(fwd.apply_media_prevention(&mut body, &provider), 0);
|
||||
assert_eq!(body["messages"][0]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prevention_heuristic_off_skips_list_but_keeps_explicit_text_only() {
|
||||
// 关闭 request_media_heuristic:名单预测失效,但显式声明 text-only 仍预替换。
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig {
|
||||
request_media_heuristic: false,
|
||||
..RectifierConfig::default()
|
||||
});
|
||||
|
||||
// (a) 名单内模型、无显式声明 → 不再预替换
|
||||
let bare_provider = provider_with_settings(json!({}));
|
||||
let mut list_body = body_with_image("deepseek-v4-pro");
|
||||
assert_eq!(
|
||||
fwd.apply_media_prevention(&mut list_body, &bare_provider),
|
||||
0,
|
||||
"heuristic 关闭后名单模型不应被预替换"
|
||||
);
|
||||
assert_eq!(list_body["messages"][0]["content"][0]["type"], "image");
|
||||
|
||||
// (b) 显式声明 text-only → 仍预替换(声明驱动,不受 heuristic 开关影响)
|
||||
let declared_provider = provider_with_settings(json!({
|
||||
"models": [ { "id": "some-text-model", "input": ["text"] } ]
|
||||
}));
|
||||
let mut declared_body = body_with_image("some-text-model");
|
||||
assert_eq!(
|
||||
fwd.apply_media_prevention(&mut declared_body, &declared_provider),
|
||||
1,
|
||||
"显式 text-only 即使关闭 heuristic 也应预替换"
|
||||
);
|
||||
assert_eq!(declared_body["messages"][0]["content"][0]["type"], "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reactive_triggers_when_all_switches_on() {
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig::default());
|
||||
let body = body_with_image("any-model");
|
||||
assert!(fwd.media_retry_should_trigger("Claude", false, &body, &image_unsupported_error()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reactive_triggers_for_codex_image_url_deserialize_errors() {
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig::default());
|
||||
let body = body_with_codex_input_image("deepseek-v4-flash");
|
||||
let error = ProxyError::UpstreamError {
|
||||
status: 400,
|
||||
body: Some(
|
||||
r#"{"error":{"message":"Failed to deserialize the JSON body into the target type: messages[11]: unknown variant image_url, expected text"}}"#
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
assert!(fwd.media_retry_should_trigger("Codex", false, &body, &error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reactive_skipped_when_media_fallback_off() {
|
||||
// 关闭 request_media_fallback:上游报图片错误也不触发兜底重试。
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig {
|
||||
request_media_fallback: false,
|
||||
..RectifierConfig::default()
|
||||
});
|
||||
let body = body_with_image("any-model");
|
||||
assert!(!fwd.media_retry_should_trigger(
|
||||
"Claude",
|
||||
false,
|
||||
&body,
|
||||
&image_unsupported_error()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reactive_skipped_when_master_switch_off() {
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig {
|
||||
enabled: false,
|
||||
..RectifierConfig::default()
|
||||
});
|
||||
let body = body_with_image("any-model");
|
||||
assert!(!fwd.media_retry_should_trigger(
|
||||
"Claude",
|
||||
false,
|
||||
&body,
|
||||
&image_unsupported_error()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reactive_unaffected_by_heuristic_switch() {
|
||||
// 关闭 request_media_heuristic 不影响反应式兜底——它是上游实测错误后的恢复,不是预测。
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig {
|
||||
request_media_heuristic: false,
|
||||
..RectifierConfig::default()
|
||||
});
|
||||
let body = body_with_image("any-model");
|
||||
assert!(fwd.media_retry_should_trigger("Claude", false, &body, &image_unsupported_error()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,37 +60,40 @@ fn gemini_stream_usage_event_filter(data: &str) -> bool {
|
||||
// ============================================================================
|
||||
|
||||
/// Claude 流式响应模型提取(优先使用 usage.model)
|
||||
fn claude_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
///
|
||||
/// 空字符串模型名视为缺失(转换层对无回显上游会合成 model:""),
|
||||
/// 落到 fallback_model(映射后的出站模型或客户端请求模型)。
|
||||
fn claude_model_extractor(events: &[Value], fallback_model: &str) -> String {
|
||||
// 首先尝试从解析的 usage 中获取模型
|
||||
if let Some(usage) = TokenUsage::from_claude_stream_events(events) {
|
||||
if let Some(model) = usage.model {
|
||||
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
request_model.to_string()
|
||||
fallback_model.to_string()
|
||||
}
|
||||
|
||||
/// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model)
|
||||
fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
fn openai_model_extractor(events: &[Value], fallback_model: &str) -> String {
|
||||
// 首先尝试从解析的 usage 中获取模型
|
||||
if let Some(usage) = TokenUsage::from_openai_stream_events(events) {
|
||||
if let Some(model) = usage.model {
|
||||
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
// 回退:从事件中直接提取
|
||||
events
|
||||
.iter()
|
||||
.find_map(|e| e.get("model")?.as_str())
|
||||
.unwrap_or(request_model)
|
||||
.find_map(|e| e.get("model")?.as_str().filter(|m| !m.is_empty()))
|
||||
.unwrap_or(fallback_model)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Codex 智能流式响应模型提取(自动检测格式)
|
||||
fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
fn codex_auto_model_extractor(events: &[Value], fallback_model: &str) -> String {
|
||||
// 首先尝试从解析的 usage 中获取模型
|
||||
if let Some(usage) = TokenUsage::from_codex_stream_events_auto(events) {
|
||||
if let Some(model) = usage.model {
|
||||
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
@@ -99,28 +102,33 @@ fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
.iter()
|
||||
.find_map(|e| {
|
||||
if e.get("type")?.as_str()? == "response.completed" {
|
||||
e.get("response")?.get("model")?.as_str()
|
||||
e.get("response")?
|
||||
.get("model")?
|
||||
.as_str()
|
||||
.filter(|m| !m.is_empty())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or_else(|| {
|
||||
// 再回退:从 OpenAI 格式事件中提取
|
||||
events.iter().find_map(|e| e.get("model")?.as_str())
|
||||
events
|
||||
.iter()
|
||||
.find_map(|e| e.get("model")?.as_str().filter(|m| !m.is_empty()))
|
||||
})
|
||||
.unwrap_or(request_model)
|
||||
.unwrap_or(fallback_model)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Gemini 流式响应模型提取(优先使用 usage.model)
|
||||
fn gemini_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
fn gemini_model_extractor(events: &[Value], fallback_model: &str) -> String {
|
||||
// 首先尝试从解析的 usage 中获取模型
|
||||
if let Some(usage) = TokenUsage::from_gemini_stream_chunks(events) {
|
||||
if let Some(model) = usage.model {
|
||||
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
request_model.to_string()
|
||||
fallback_model.to_string()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -48,6 +48,11 @@ pub struct RequestContext {
|
||||
pub current_provider_id: String,
|
||||
/// 请求中的模型名称
|
||||
pub request_model: String,
|
||||
/// 实际发往上游的模型名(路由接管/模型映射后的真值,forward 成功后回填)。
|
||||
///
|
||||
/// usage 归因的兜底顺序:上游响应回显 → outbound_model → request_model。
|
||||
/// 不能直接用 request_model 兜底:接管场景下它是映射前的客户端别名。
|
||||
pub outbound_model: Option<String>,
|
||||
/// 日志标签(如 "Claude"、"Codex"、"Gemini")
|
||||
pub tag: &'static str,
|
||||
/// 应用类型字符串(如 "claude"、"codex"、"gemini")
|
||||
@@ -159,6 +164,7 @@ impl RequestContext {
|
||||
providers,
|
||||
current_provider_id,
|
||||
request_model,
|
||||
outbound_model: None,
|
||||
tag,
|
||||
app_type_str,
|
||||
app_type,
|
||||
|
||||
@@ -0,0 +1,831 @@
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub const UNSUPPORTED_IMAGE_MARKER: &str = "[Unsupported Image]";
|
||||
|
||||
/// Replace image blocks before sending when the routed model is text-only.
|
||||
///
|
||||
/// Two paths, both reached only when the caller's media-fallback switch is on:
|
||||
/// - explicit capability from the provider config (modelCatalog / modalities) is
|
||||
/// always trusted — it is declaration-driven, never a guess;
|
||||
/// - the curated `known_text_only_model` list is a heuristic *prediction* and only
|
||||
/// runs when `allow_heuristic` is true, so a mislabeled multimodal model cannot
|
||||
/// have its images silently stripped when the user opts out.
|
||||
pub fn replace_images_for_text_only_model(
|
||||
body: &mut Value,
|
||||
provider: &Provider,
|
||||
allow_heuristic: bool,
|
||||
) -> usize {
|
||||
if !contains_image_blocks(body) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("");
|
||||
|
||||
match explicit_model_image_support(provider, model) {
|
||||
Some(true) => return 0,
|
||||
Some(false) => return replace_images_in_body(body),
|
||||
None => {}
|
||||
}
|
||||
|
||||
if !allow_heuristic || !known_text_only_model(model) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
replace_images_in_body(body)
|
||||
}
|
||||
|
||||
pub fn contains_image_blocks(body: &Value) -> bool {
|
||||
messages_have_image_blocks(body) || responses_input_has_image_blocks(body.get("input"))
|
||||
}
|
||||
|
||||
pub fn replace_image_blocks_with_marker(body: &mut Value) -> usize {
|
||||
replace_images_in_body(body)
|
||||
}
|
||||
|
||||
pub fn is_unsupported_image_error(error: &ProxyError) -> bool {
|
||||
let ProxyError::UpstreamError { status, body } = error else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if !matches!(*status, 400 | 415 | 422 | 501) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(body) = body.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let message = extract_error_text(body);
|
||||
let message = message.to_ascii_lowercase();
|
||||
let mentions_image = message.contains("image")
|
||||
|| message.contains("vision")
|
||||
|| message.contains("multimodal")
|
||||
|| message.contains("multi-modal")
|
||||
|| message.contains("modality")
|
||||
|| message.contains("modalities")
|
||||
|| message.contains("media")
|
||||
|| message.contains("attachment");
|
||||
|
||||
if !mentions_image {
|
||||
return false;
|
||||
}
|
||||
|
||||
const UNSUPPORTED_HINTS: &[&str] = &[
|
||||
"unsupported",
|
||||
"not supported",
|
||||
"does not support",
|
||||
"doesn't support",
|
||||
"do not support",
|
||||
"don't support",
|
||||
"only supports text",
|
||||
"text only",
|
||||
"text-only",
|
||||
"invalid content type",
|
||||
"invalid message content",
|
||||
"unknown variant",
|
||||
"unknown content type",
|
||||
"unrecognized content type",
|
||||
"cannot process",
|
||||
"cannot handle",
|
||||
"can't process",
|
||||
"can't handle",
|
||||
"unable to process",
|
||||
];
|
||||
|
||||
UNSUPPORTED_HINTS.iter().any(|hint| message.contains(hint))
|
||||
}
|
||||
|
||||
fn content_has_image_blocks(content: &Value) -> bool {
|
||||
let Some(blocks) = content.as_array() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
blocks.iter().any(|block| {
|
||||
is_image_block_type(block.get("type").and_then(Value::as_str))
|
||||
|| block.get("content").is_some_and(content_has_image_blocks)
|
||||
})
|
||||
}
|
||||
|
||||
fn replace_images_in_body(body: &mut Value) -> usize {
|
||||
let message_replacements = body
|
||||
.get_mut("messages")
|
||||
.and_then(Value::as_array_mut)
|
||||
.map(|messages| {
|
||||
messages
|
||||
.iter_mut()
|
||||
.filter_map(|message| message.get_mut("content"))
|
||||
.map(replace_images_in_content)
|
||||
.sum()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
message_replacements
|
||||
+ body
|
||||
.get_mut("input")
|
||||
.map(replace_images_in_responses_input)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn replace_images_in_content(content: &mut Value) -> usize {
|
||||
replace_images_in_content_with_text_type(content, "text")
|
||||
}
|
||||
|
||||
fn replace_images_in_content_with_text_type(content: &mut Value, text_type: &str) -> usize {
|
||||
let Some(blocks) = content.as_array_mut() else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let mut replaced = 0usize;
|
||||
for block in blocks {
|
||||
if is_image_block_type(block.get("type").and_then(Value::as_str)) {
|
||||
replace_image_block_with_text_marker(block, text_type);
|
||||
replaced += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(nested_content) = block.get_mut("content") {
|
||||
replaced += replace_images_in_content_with_text_type(nested_content, text_type);
|
||||
}
|
||||
}
|
||||
|
||||
replaced
|
||||
}
|
||||
|
||||
fn messages_have_image_blocks(body: &Value) -> bool {
|
||||
body.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|messages| {
|
||||
messages
|
||||
.iter()
|
||||
.filter_map(|message| message.get("content"))
|
||||
.any(content_has_image_blocks)
|
||||
})
|
||||
}
|
||||
|
||||
fn responses_input_has_image_blocks(input: Option<&Value>) -> bool {
|
||||
match input {
|
||||
Some(Value::Array(items)) => items.iter().any(responses_input_item_has_image_blocks),
|
||||
Some(item @ Value::Object(_)) => responses_input_item_has_image_blocks(item),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn responses_input_item_has_image_blocks(item: &Value) -> bool {
|
||||
if item.get("type").and_then(Value::as_str) == Some("input_image") {
|
||||
return true;
|
||||
}
|
||||
|
||||
item.get("content").is_some_and(content_has_image_blocks)
|
||||
}
|
||||
|
||||
fn replace_images_in_responses_input(input: &mut Value) -> usize {
|
||||
match input {
|
||||
Value::Array(items) => items
|
||||
.iter_mut()
|
||||
.map(replace_images_in_responses_input_item)
|
||||
.sum(),
|
||||
Value::Object(_) => replace_images_in_responses_input_item(input),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_images_in_responses_input_item(item: &mut Value) -> usize {
|
||||
let mut replaced = 0usize;
|
||||
|
||||
if item.get("type").and_then(Value::as_str) == Some("input_image") {
|
||||
replace_image_block_with_text_marker(item, "input_text");
|
||||
replaced += 1;
|
||||
}
|
||||
|
||||
if let Some(content) = item.get_mut("content") {
|
||||
replaced += replace_images_in_content_with_text_type(content, "input_text");
|
||||
}
|
||||
|
||||
replaced
|
||||
}
|
||||
|
||||
fn is_image_block_type(block_type: Option<&str>) -> bool {
|
||||
matches!(block_type, Some("image" | "image_url" | "input_image"))
|
||||
}
|
||||
|
||||
fn replace_image_block_with_text_marker(block: &mut Value, text_type: &str) {
|
||||
let cache_control = block.get("cache_control").cloned();
|
||||
*block = json!({
|
||||
"type": text_type,
|
||||
"text": UNSUPPORTED_IMAGE_MARKER
|
||||
});
|
||||
if let (Some(cache_control), Some(object)) = (cache_control, block.as_object_mut()) {
|
||||
object.insert("cache_control".to_string(), cache_control);
|
||||
}
|
||||
}
|
||||
|
||||
fn explicit_model_image_support(provider: &Provider, model: &str) -> Option<bool> {
|
||||
let settings = &provider.settings_config;
|
||||
[
|
||||
settings
|
||||
.get("modelCatalog")
|
||||
.and_then(|catalog| catalog.get("models")),
|
||||
settings.get("modelCatalog"),
|
||||
settings.get("models"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|value| explicit_model_image_support_in_value(value, model))
|
||||
}
|
||||
|
||||
fn known_text_only_model(model: &str) -> bool {
|
||||
let normalized = normalize_model_id(model);
|
||||
let tail = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
|
||||
|
||||
const EXACT_TAILS: &[&str] = &[
|
||||
"ark-code-latest",
|
||||
"deepseek-chat",
|
||||
"deepseek-reasoner",
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-pro",
|
||||
"glm-5.1",
|
||||
"kat-coder",
|
||||
"kat-coder-pro",
|
||||
"kat-coder-pro v1",
|
||||
"kat-coder-pro v2",
|
||||
"kat-coder-pro-v1",
|
||||
"kat-coder-pro-v2",
|
||||
"ling-2.5-1t",
|
||||
"longcat-flash-chat",
|
||||
"mimo-v2.5-pro",
|
||||
"us.deepseek.r1-v1",
|
||||
];
|
||||
|
||||
const TAIL_PREFIXES: &[&str] = &["minimax-m2.7", "qwen3-coder", "step-3.5-flash"];
|
||||
|
||||
EXACT_TAILS.contains(&tail) || TAIL_PREFIXES.iter().any(|prefix| tail.starts_with(prefix))
|
||||
}
|
||||
|
||||
fn explicit_model_image_support_in_value(value: &Value, model: &str) -> Option<bool> {
|
||||
if let Some(models) = value.as_array() {
|
||||
return models.iter().find_map(|entry| {
|
||||
model_entry_matches(entry, None, model).then(|| explicit_image_support(entry))?
|
||||
});
|
||||
}
|
||||
|
||||
let object = value.as_object()?;
|
||||
object.iter().find_map(|(key, entry)| {
|
||||
model_entry_matches(entry, Some(key), model).then(|| explicit_image_support(entry))?
|
||||
})
|
||||
}
|
||||
|
||||
fn explicit_image_support(entry: &Value) -> Option<bool> {
|
||||
if let Some(value) = entry
|
||||
.get("supportsImage")
|
||||
.or_else(|| entry.get("supports_image"))
|
||||
.or_else(|| entry.get("vision"))
|
||||
.and_then(Value::as_bool)
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
|
||||
[
|
||||
entry.get("input"),
|
||||
entry.pointer("/modalities/input"),
|
||||
entry.get("input_modalities"),
|
||||
entry.get("inputModalities"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(input_modalities_support_image)
|
||||
}
|
||||
|
||||
fn input_modalities_support_image(value: &Value) -> Option<bool> {
|
||||
let modalities = value.as_array()?;
|
||||
Some(modalities.iter().any(|item| {
|
||||
item.as_str()
|
||||
.map(str::trim)
|
||||
.is_some_and(|item| item.eq_ignore_ascii_case("image"))
|
||||
}))
|
||||
}
|
||||
|
||||
fn extract_error_text(body: &str) -> String {
|
||||
if let Ok(value) = serde_json::from_str::<Value>(body) {
|
||||
let candidates = [
|
||||
value.pointer("/error/message"),
|
||||
value.pointer("/message"),
|
||||
value.pointer("/detail"),
|
||||
value.pointer("/error"),
|
||||
];
|
||||
if let Some(message) = candidates
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|value| value.as_str())
|
||||
{
|
||||
return message.to_string();
|
||||
}
|
||||
|
||||
if let Ok(compact) = serde_json::to_string(&value) {
|
||||
return compact;
|
||||
}
|
||||
}
|
||||
|
||||
body.to_string()
|
||||
}
|
||||
|
||||
fn model_entry_matches(entry: &Value, key: Option<&str>, model: &str) -> bool {
|
||||
key.is_some_and(|key| model_ids_match(key, model))
|
||||
|| ["model", "id", "name"]
|
||||
.into_iter()
|
||||
.filter_map(|field| entry.get(field).and_then(Value::as_str))
|
||||
.any(|candidate| model_ids_match(candidate, model))
|
||||
}
|
||||
|
||||
fn model_ids_match(candidate: &str, model: &str) -> bool {
|
||||
let candidate = normalize_model_id(candidate);
|
||||
let model = normalize_model_id(model);
|
||||
if candidate.is_empty() || model.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if candidate == model {
|
||||
return true;
|
||||
}
|
||||
|
||||
let candidate_tail = candidate.rsplit('/').next().unwrap_or(candidate.as_str());
|
||||
let model_tail = model.rsplit('/').next().unwrap_or(model.as_str());
|
||||
candidate_tail == model_tail || candidate == model_tail || candidate_tail == model
|
||||
}
|
||||
|
||||
fn normalize_model_id(value: &str) -> String {
|
||||
let mut normalized = value
|
||||
.trim()
|
||||
.trim_start_matches("models/")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if let Some(stripped) =
|
||||
normalized.strip_suffix(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)
|
||||
{
|
||||
normalized = stripped.trim().to_string();
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::provider::Provider;
|
||||
use serde_json::json;
|
||||
|
||||
fn provider(settings_config: Value) -> Provider {
|
||||
Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test".to_string(),
|
||||
settings_config,
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_images_when_model_capability_is_unknown() {
|
||||
let provider = provider(json!({}));
|
||||
let mut body = json!({
|
||||
"model": "unknown-model",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "text", "text": "look" },
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 0);
|
||||
assert_eq!(body["messages"][0]["content"][1]["type"], "image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_text_only_models_replace_images_before_send() {
|
||||
let provider = provider(json!({}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek/deepseek-v4-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][0]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_text_only_models_replace_chat_image_url_before_send() {
|
||||
let provider = provider(json!({}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-flash",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "text", "text": "look" },
|
||||
{ "type": "image_url", "image_url": { "url": "data:image/png;base64,abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(body["messages"][0]["content"][1]["type"], "text");
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][1]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_text_only_models_replace_codex_input_image_before_send() {
|
||||
let provider = provider(json!({}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-flash",
|
||||
"input": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "input_text", "text": "look" },
|
||||
{ "type": "input_image", "image_url": "data:image/png;base64,abc" }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(body["input"][0]["content"][1]["type"], "input_text");
|
||||
assert_eq!(
|
||||
body["input"][0]["content"][1]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_text_modalities_replace_images_before_send() {
|
||||
let provider = provider(json!({
|
||||
"models": [
|
||||
{ "id": "deepseek-v4-pro", "input": ["text"] }
|
||||
]
|
||||
}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "text", "text": "look" },
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(body["messages"][0]["content"][0]["text"], "look");
|
||||
assert_eq!(body["messages"][0]["content"][1]["type"], "text");
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][1]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_images_without_explicit_capability_even_for_unknown_models() {
|
||||
let provider = provider(json!({}));
|
||||
let mut body = json!({
|
||||
"model": "unknown-model",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 0);
|
||||
assert_eq!(body["messages"][0]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_text_modalities_can_override_visual_model_ids() {
|
||||
let provider = provider(json!({
|
||||
"models": [
|
||||
{ "id": "gpt-4o", "input": ["text"] }
|
||||
]
|
||||
}));
|
||||
let mut body = json!({
|
||||
"model": "gpt-4o",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][0]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_image_modalities_preserve_model_images() {
|
||||
let provider = provider(json!({
|
||||
"modelCatalog": {
|
||||
"models": [
|
||||
{ "model": "deepseek-v4-pro", "modalities": { "input": ["text", "image"] } }
|
||||
]
|
||||
}
|
||||
}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 0);
|
||||
assert_eq!(body["messages"][0]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_mimo_pro_replaces_but_mimo_multimodal_preserves() {
|
||||
let provider = provider(json!({}));
|
||||
let mut pro_body = json!({
|
||||
"model": "xiaomi-mimo-token-plan/mimo-v2.5-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
let mut multimodal_body = json!({
|
||||
"model": "xiaomi-mimo-token-plan/mimo-v2.5",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let pro_count = replace_images_for_text_only_model(&mut pro_body, &provider, true);
|
||||
let multimodal_count =
|
||||
replace_images_for_text_only_model(&mut multimodal_body, &provider, true);
|
||||
|
||||
assert_eq!(pro_count, 1);
|
||||
assert_eq!(multimodal_count, 0);
|
||||
assert_eq!(
|
||||
multimodal_body["messages"][0]["content"][0]["type"],
|
||||
"image"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multimodal_kimi_model_is_not_on_text_only_list() {
|
||||
let provider = provider(json!({}));
|
||||
let mut body = json!({
|
||||
"model": "kimi/kimi-k2.6",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 0);
|
||||
assert_eq!(body["messages"][0]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_text_only_prefixes_replace_images_before_send() {
|
||||
let provider = provider(json!({}));
|
||||
let mut body = json!({
|
||||
"model": "therouter/qwen/qwen3-coder-480b",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][0]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unconditional_marker_replacement_handles_retry_path() {
|
||||
let mut body = json!({
|
||||
"model": "xiaomi-mimo-token-plan/mimo-v2.5-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
assert!(contains_image_blocks(&body));
|
||||
let count = replace_image_blocks_with_marker(&mut body);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][0]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_nested_tool_result_image_blocks() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_1",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_image_blocks_with_marker(&mut body);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][0]["content"][0]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_unsupported_image_errors() {
|
||||
let error = ProxyError::UpstreamError {
|
||||
status: 400,
|
||||
body: Some(
|
||||
r#"{"error":{"message":"This model does not support image input"}}"#.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
assert!(is_unsupported_image_error(&error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_image_errors() {
|
||||
let error = ProxyError::UpstreamError {
|
||||
status: 400,
|
||||
body: Some(r#"{"error":{"message":"Invalid API key"}}"#.to_string()),
|
||||
};
|
||||
|
||||
assert!(!is_unsupported_image_error(&error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_cache_control_when_replacing_image() {
|
||||
// image block 可能承载 prompt cache 断点;替换成标记时必须把
|
||||
// cache_control 迁移到新的 text block,否则会断掉缓存命中。
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "image",
|
||||
"source": { "type": "base64", "media_type": "image/png", "data": "abc" },
|
||||
"cache_control": { "type": "ephemeral" }
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_image_blocks_with_marker(&mut body);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
let block = &body["messages"][0]["content"][0];
|
||||
assert_eq!(block["type"], "text");
|
||||
assert_eq!(block["text"], UNSUPPORTED_IMAGE_MARKER);
|
||||
assert_eq!(block["cache_control"]["type"], "ephemeral");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_media_and_attachment_error_phrasings() {
|
||||
let media_error = ProxyError::UpstreamError {
|
||||
status: 400,
|
||||
body: Some(
|
||||
r#"{"error":{"message":"This model cannot process media inputs"}}"#.to_string(),
|
||||
),
|
||||
};
|
||||
assert!(is_unsupported_image_error(&media_error));
|
||||
|
||||
let attachment_error = ProxyError::UpstreamError {
|
||||
status: 422,
|
||||
body: Some(r#"{"message":"attachments are not supported by this model"}"#.to_string()),
|
||||
};
|
||||
assert!(is_unsupported_image_error(&attachment_error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_chat_content_unknown_variant_image_url_errors() {
|
||||
let error = ProxyError::UpstreamError {
|
||||
status: 400,
|
||||
body: Some(
|
||||
r#"{"error":{"message":"Failed to deserialize the JSON body into the target type: messages[11]: unknown variant image_url, expected text"}}"#
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
assert!(is_unsupported_image_error(&error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_disabled_keeps_images_for_listed_text_only_models() {
|
||||
// allow_heuristic = false:内置列表不再预测性剥图,避免误判多模态模型时静默丢图。
|
||||
let provider = provider(json!({}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek/deepseek-v4-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, false);
|
||||
|
||||
assert_eq!(count, 0);
|
||||
assert_eq!(body["messages"][0]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_text_capability_replaces_even_when_heuristic_disabled() {
|
||||
// 显式声明 text-only 是声明驱动、零误判,即使关掉启发式也应生效。
|
||||
let provider = provider(json!({
|
||||
"models": [
|
||||
{ "id": "deepseek-v4-pro", "input": ["text"] }
|
||||
]
|
||||
}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, false);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][0]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ pub mod http_client;
|
||||
pub mod hyper_client;
|
||||
pub(crate) mod json_canonical;
|
||||
pub mod log_codes;
|
||||
pub mod media_sanitizer;
|
||||
pub mod model_mapper;
|
||||
pub mod provider_router;
|
||||
pub mod providers;
|
||||
|
||||
@@ -11,6 +11,7 @@ pub struct ModelMapping {
|
||||
pub haiku_model: Option<String>,
|
||||
pub sonnet_model: Option<String>,
|
||||
pub opus_model: Option<String>,
|
||||
pub fable_model: Option<String>,
|
||||
pub default_model: Option<String>,
|
||||
}
|
||||
|
||||
@@ -35,6 +36,11 @@ impl ModelMapping {
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
fable_model: env
|
||||
.and_then(|e| e.get("ANTHROPIC_DEFAULT_FABLE_MODEL"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from),
|
||||
default_model: env
|
||||
.and_then(|e| e.get("ANTHROPIC_MODEL"))
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -48,6 +54,7 @@ impl ModelMapping {
|
||||
self.haiku_model.is_some()
|
||||
|| self.sonnet_model.is_some()
|
||||
|| self.opus_model.is_some()
|
||||
|| self.fable_model.is_some()
|
||||
|| self.default_model.is_some()
|
||||
}
|
||||
|
||||
@@ -56,6 +63,16 @@ impl ModelMapping {
|
||||
let model_lower = original_model.to_lowercase();
|
||||
|
||||
// 1. 按模型类型匹配
|
||||
if model_lower.contains("fable") {
|
||||
if let Some(ref m) = self.fable_model {
|
||||
return m.clone();
|
||||
}
|
||||
// 未单独配置 fable 档时归入 opus 档,与 Claude Code 官方
|
||||
// 分类器降级方向一致(fable→opus),避免落到 default 失去层级。
|
||||
if let Some(ref m) = self.opus_model {
|
||||
return m.clone();
|
||||
}
|
||||
}
|
||||
if model_lower.contains("haiku") {
|
||||
if let Some(ref m) = self.haiku_model {
|
||||
return m.clone();
|
||||
@@ -154,7 +171,8 @@ mod tests {
|
||||
"ANTHROPIC_MODEL": "default-model",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped"
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped",
|
||||
"ANTHROPIC_DEFAULT_FABLE_MODEL": "fable-mapped"
|
||||
}
|
||||
}),
|
||||
website_url: None,
|
||||
@@ -214,6 +232,54 @@ mod tests {
|
||||
assert_eq!(mapped, Some("opus-mapped".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fable_mapping() {
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({"model": "claude-fable-5"});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "fable-mapped");
|
||||
assert_eq!(mapped, Some("fable-mapped".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fable_with_one_m_suffix_mapping() {
|
||||
// Claude Code 实际会发 claude-fable-5[1m] 形态(issue #3980)
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({"model": "claude-fable-5[1m]"});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "fable-mapped");
|
||||
assert_eq!(mapped, Some("fable-mapped".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fable_falls_back_to_opus_when_unset() {
|
||||
let mut provider = create_provider_with_mapping();
|
||||
provider.settings_config = json!({
|
||||
"env": {
|
||||
"ANTHROPIC_MODEL": "default-model",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped"
|
||||
}
|
||||
});
|
||||
let body = json!({"model": "claude-fable-5"});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "opus-mapped");
|
||||
assert_eq!(mapped, Some("opus-mapped".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fable_falls_back_to_default_without_opus() {
|
||||
let mut provider = create_provider_with_mapping();
|
||||
provider.settings_config = json!({
|
||||
"env": {
|
||||
"ANTHROPIC_MODEL": "default-model"
|
||||
}
|
||||
});
|
||||
let body = json!({"model": "claude-fable-5"});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "default-model");
|
||||
assert_eq!(mapped, Some("default-model".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_does_not_affect_model_mapping() {
|
||||
// Issue #2081: thinking 参数不应影响模型映射
|
||||
|
||||
@@ -21,6 +21,8 @@ use serde_json::{json, Value};
|
||||
|
||||
const ANTHROPIC_THINKING_PLACEHOLDER: &str = "tool call";
|
||||
const ANTHROPIC_REDACTED_THINKING_PLACEHOLDER: &str = "[redacted thinking]";
|
||||
// Keep hints lowercase; matching lowercases only the input value.
|
||||
const REASONING_VENDOR_HINTS: &[&str] = &["moonshot", "kimi", "deepseek", "mimo", "xiaomimimo"];
|
||||
|
||||
/// 获取 Claude 供应商的 API 格式
|
||||
///
|
||||
@@ -86,18 +88,11 @@ pub fn claude_api_format_needs_transform(api_format: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn is_reasoning_content_compatible_identifier(value: &str) -> bool {
|
||||
fn is_reasoning_vendor_identifier(value: &str) -> bool {
|
||||
let value = value.to_ascii_lowercase();
|
||||
value.contains("moonshot")
|
||||
|| value.contains("kimi")
|
||||
|| value.contains("deepseek")
|
||||
|| value.contains("mimo")
|
||||
|| value.contains("xiaomimimo")
|
||||
}
|
||||
|
||||
fn is_anthropic_tool_thinking_history_identifier(value: &str) -> bool {
|
||||
let value = value.to_ascii_lowercase();
|
||||
value.contains("deepseek") || value.contains("mimo") || value.contains("xiaomimimo")
|
||||
REASONING_VENDOR_HINTS
|
||||
.iter()
|
||||
.any(|hint| value.contains(hint))
|
||||
}
|
||||
|
||||
fn should_normalize_anthropic_tool_thinking_history(
|
||||
@@ -112,7 +107,7 @@ fn should_normalize_anthropic_tool_thinking_history(
|
||||
if body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.is_some_and(is_anthropic_tool_thinking_history_identifier)
|
||||
.is_some_and(is_reasoning_vendor_identifier)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -129,7 +124,7 @@ fn should_normalize_anthropic_tool_thinking_history(
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(is_anthropic_tool_thinking_history_identifier)
|
||||
.any(is_reasoning_vendor_identifier)
|
||||
}
|
||||
|
||||
/// DeepSeek's Anthropic-compatible endpoint requires thinking history to be
|
||||
@@ -150,6 +145,86 @@ pub fn normalize_anthropic_tool_thinking_history_for_provider(
|
||||
normalize_anthropic_tool_thinking_history(body)
|
||||
}
|
||||
|
||||
pub fn normalize_anthropic_messages_for_provider(
|
||||
body: &mut Value,
|
||||
provider: &Provider,
|
||||
api_format: &str,
|
||||
) -> bool {
|
||||
if api_format.trim() != "anthropic" {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut changed = normalize_anthropic_system_role_messages(body);
|
||||
changed |= normalize_anthropic_tool_thinking_history_for_provider(body, provider, api_format);
|
||||
changed
|
||||
}
|
||||
|
||||
fn normalize_anthropic_system_role_messages(body: &mut Value) -> bool {
|
||||
let mut system_parts = Vec::new();
|
||||
let changed = {
|
||||
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let original_len = messages.len();
|
||||
let mut kept_messages = Vec::with_capacity(messages.len());
|
||||
for message in std::mem::take(messages) {
|
||||
if message.get("role").and_then(Value::as_str) == Some("system") {
|
||||
if let Some(content) = message.get("content") {
|
||||
append_anthropic_system_parts(content, &mut system_parts);
|
||||
}
|
||||
} else {
|
||||
kept_messages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
let changed = kept_messages.len() != original_len;
|
||||
*messages = kept_messages;
|
||||
changed
|
||||
};
|
||||
|
||||
if !changed || system_parts.is_empty() {
|
||||
return changed;
|
||||
}
|
||||
|
||||
let mut merged_parts = Vec::new();
|
||||
if let Some(existing) = body.get("system") {
|
||||
append_anthropic_system_parts(existing, &mut merged_parts);
|
||||
}
|
||||
merged_parts.extend(system_parts);
|
||||
|
||||
if !merged_parts.is_empty() {
|
||||
body["system"] = Value::Array(merged_parts);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn append_anthropic_system_parts(content: &Value, parts: &mut Vec<Value>) {
|
||||
match content {
|
||||
Value::String(text) if !text.trim().is_empty() => {
|
||||
parts.push(json!({
|
||||
"type": "text",
|
||||
"text": text
|
||||
}));
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
append_anthropic_system_parts(item, parts);
|
||||
}
|
||||
}
|
||||
Value::Object(obj)
|
||||
if obj
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|text| !text.trim().is_empty()) =>
|
||||
{
|
||||
parts.push(Value::Object(obj.clone()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_anthropic_tool_thinking_history(body: &mut Value) -> bool {
|
||||
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
|
||||
return false;
|
||||
@@ -224,7 +299,7 @@ fn should_preserve_reasoning_content_for_openai_chat(provider: &Provider, body:
|
||||
if body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.is_some_and(is_reasoning_content_compatible_identifier)
|
||||
.is_some_and(is_reasoning_vendor_identifier)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -243,7 +318,7 @@ fn should_preserve_reasoning_content_for_openai_chat(provider: &Provider, body:
|
||||
base_urls
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(is_reasoning_content_compatible_identifier)
|
||||
.any(is_reasoning_vendor_identifier)
|
||||
}
|
||||
|
||||
pub fn transform_claude_request_for_api_format(
|
||||
@@ -334,6 +409,10 @@ pub fn transform_claude_request_for_api_format(
|
||||
{
|
||||
result["prompt_cache_key"] = serde_json::json!(key);
|
||||
}
|
||||
// 流式请求必须注入 stream_options.include_usage,否则 OpenAI 兼容上游
|
||||
// 不在 SSE 末尾吐 usage → 转换出的 Anthropic message_delta 全 0 →
|
||||
// 整笔 input/output/cache 漏记(与 Codex Responses→Chat 路径同源)。
|
||||
super::transform::inject_openai_stream_include_usage(&mut result);
|
||||
Ok(result)
|
||||
}
|
||||
"gemini_native" => super::transform_gemini::anthropic_to_gemini_with_shadow(
|
||||
@@ -1542,6 +1621,43 @@ mod tests {
|
||||
assert!(transformed.get("max_output_tokens").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_openai_chat_streaming_injects_include_usage() {
|
||||
let provider = create_provider(json!({
|
||||
"env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1" }
|
||||
}));
|
||||
// 流式请求必须注入 stream_options.include_usage,否则 OpenAI 兼容上游不在
|
||||
// SSE 末尾吐 usage → 转换出的 Anthropic message_delta 全 0 → 整笔 usage 漏记。
|
||||
let body = json!({
|
||||
"model": "moonshotai/kimi-k2",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 128,
|
||||
"stream": true
|
||||
});
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||||
.unwrap();
|
||||
assert_eq!(transformed["stream"], true);
|
||||
assert_eq!(transformed["stream_options"]["include_usage"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_openai_chat_non_streaming_omits_stream_options() {
|
||||
let provider = create_provider(json!({
|
||||
"env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1" }
|
||||
}));
|
||||
// 非流式请求不应注入 stream_options(usage 在非流式响应体里恒有)。
|
||||
let body = json!({
|
||||
"model": "moonshotai/kimi-k2",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 128
|
||||
});
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||||
.unwrap();
|
||||
assert!(transformed.get("stream_options").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_codex_oauth_uses_session_cache_key() {
|
||||
let provider = create_provider_with_meta(
|
||||
@@ -2000,6 +2116,95 @@ mod tests {
|
||||
assert_eq!(content[2]["type"], "tool_use");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_system_role_messages_move_to_top_level_system() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}));
|
||||
let mut body = json!({
|
||||
"system": "Existing top-level system.",
|
||||
"model": "deepseek-v4-pro",
|
||||
"messages": [
|
||||
{ "role": "system", "content": "Message system one." },
|
||||
{ "role": "user", "content": "hello" },
|
||||
{
|
||||
"role": "system",
|
||||
"content": [{ "type": "text", "text": "Message system two." }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let changed = normalize_anthropic_messages_for_provider(&mut body, &provider, "anthropic");
|
||||
|
||||
assert!(changed);
|
||||
let messages = body["messages"].as_array().unwrap();
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0]["role"], "user");
|
||||
|
||||
let system = body["system"].as_array().unwrap();
|
||||
assert_eq!(system[0]["text"], "Existing top-level system.");
|
||||
assert_eq!(system[1]["text"], "Message system one.");
|
||||
assert_eq!(system[2]["text"], "Message system two.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_system_role_messages_skip_non_anthropic_format() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/v1",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"messages": [
|
||||
{ "role": "system", "content": "Keep in messages." },
|
||||
{ "role": "user", "content": "hello" }
|
||||
]
|
||||
});
|
||||
|
||||
let changed =
|
||||
normalize_anthropic_messages_for_provider(&mut body, &provider, "openai_chat");
|
||||
|
||||
assert!(!changed);
|
||||
assert!(body.get("system").is_none());
|
||||
assert_eq!(body["messages"][0]["role"], "system");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kimi_anthropic_tool_history_injects_missing_thinking() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.kimi.com/coding",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}));
|
||||
let mut body = json!({
|
||||
"model": "kimi-for-coding",
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "call_123", "name": "read_file", "input": {"path": "README.md"}}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let changed = normalize_anthropic_tool_thinking_history_for_provider(
|
||||
&mut body,
|
||||
&provider,
|
||||
"anthropic",
|
||||
);
|
||||
|
||||
assert!(changed);
|
||||
let content = body["messages"][0]["content"].as_array().unwrap();
|
||||
assert_eq!(content[0]["type"], "thinking");
|
||||
assert_eq!(content[0]["thinking"], ANTHROPIC_THINKING_PLACEHOLDER);
|
||||
assert_eq!(content[1]["type"], "tool_use");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_anthropic_tool_history_rewrites_redacted_thinking() {
|
||||
let provider = create_provider(json!({
|
||||
|
||||