mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 21:54:58 +08:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e3f168903 | |||
| 9e6a7a0d81 | |||
| 4a631b28c7 | |||
| ec8afd63ea | |||
| 9050442b65 | |||
| d94eb6729b | |||
| a0131c9a2b | |||
| 58cd5302ae | |||
| 3fd38b0a3d | |||
| 18ffddbfa5 | |||
| d6bbbf72f0 | |||
| df11df4d9c | |||
| 4eb5543d9f | |||
| 08cd5ab56e | |||
| b642ef0633 | |||
| 940161fb0e | |||
| 0d09555503 | |||
| cb45c22b63 | |||
| 8dabb9fab9 | |||
| 99304ffcfd | |||
| 543e057e20 | |||
| 73bc4eb65d | |||
| f93b935d5f | |||
| 402570ce31 | |||
| 206125b4e3 | |||
| 85131d37d8 | |||
| 039784af73 | |||
| 3c35972548 | |||
| 5d3d9067af | |||
| f2ae9823cb | |||
| b06e0fa538 | |||
| 84aa87c3dd | |||
| cb4ecd3951 | |||
| c3d810a22b | |||
| 9a8f52021d | |||
| c9a6afc0b7 | |||
| 58648a9c53 | |||
| aa5e58d060 | |||
| 4b57f7e113 | |||
| 84bac6dce6 | |||
| edf28b6422 | |||
| c12364a940 | |||
| 60a3628360 | |||
| ea4cdaad27 | |||
| 44d4ea81af | |||
| 270f49a4a6 | |||
| 6a3c2fe0ba | |||
| 953b7cdcf9 | |||
| 417ad8149d | |||
| 968c75bdbe | |||
| ed41a7a7b9 |
+113
-1
@@ -7,9 +7,121 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [3.15.0] - 2026-05-16
|
||||
|
||||
Development since v3.14.1 focuses on a dedicated Claude Desktop surface with third-party provider switching through a proxy gateway, a large reverse-proxy hardening pass (reliability, retries, cache, takeover, Gemini/Vertex/Codex paths), expansion of the third-party provider preset catalog (BytePlus / Volcengine / ClaudeAPI / ClaudeCN / RunAPI / RelaxyCode / PatewayAI / Baidu Qianfan), role-based model mapping with a 1M context flag, Codex OAuth live model discovery, and a long tail of usage, OAuth, Codex, and session quality-of-life fixes.
|
||||
|
||||
**Stats**: 127 commits | 211 files changed | +17,980 insertions | -2,748 deletions
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Claude Desktop becomes a first-class managed surface** with third-party provider switching through an in-app proxy gateway, role-based model mapping (sonnet / opus / haiku) with a 1M context flag, Copilot/Codex OAuth provider reuse, and 44 imported provider presets translated from the Claude Code catalog. Note: 20 Claude Desktop presets now default to direct mode instead of routing through the proxy — verify connectivity if you previously relied on proxy routing for these presets.
|
||||
- **Major reverse-proxy hardening**: P0–P3 lifecycle, retry, failover, and rectifier patches; pooled HTTPS reuse for non-Anthropic backends; Codex/Responses cache hit-rate improvements; correct Anthropic ↔ OpenAI `tool_choice` mapping; Vertex AI URL preservation; Gemini path-based model extraction; takeover detection refinement; IPv6 listen address support.
|
||||
- **Provider Ecosystem Expansion**: Added BytePlus, Volcengine Agentplan, ClaudeAPI, ClaudeCN, RunAPI, RelaxyCode, PatewayAI, and Baidu Qianfan Coding Plan partner presets; promoted DouBao Seed to partner status; routing-support badges now surface on provider cards.
|
||||
- **Role-Based Model Mapping for Claude Code**: Display-name-aware sonnet / opus / haiku route mapping with a `supports1m` flag replaces the legacy `[1M]` suffix and decouples routing from raw model IDs.
|
||||
- **Codex OAuth Live Model Discovery**: ChatGPT Codex providers now fetch the live model list from the ChatGPT backend on demand instead of relying on a static list.
|
||||
- **Usage Dashboard Filter-Driven Hero**: A new filter-driven Hero card with cache-normalized totals replaces the legacy summary block, paired with cache-cost-semantics fixes that silence a noisy pricing warning storm.
|
||||
- **DeepSeek tool-call reasoning and zero-usage final deltas**: DeepSeek tool calls now return `reasoning_content` alongside `tool_calls` (#2543), and the final `message_delta` event always includes a usage block (even when zero) so strict Anthropic clients no longer crash on `null` (#2485).
|
||||
|
||||
### Added
|
||||
|
||||
- **Claude Desktop Third-Party Provider Switching via Proxy Gateway**: Added a dedicated Claude Desktop surface that brokers third-party Claude providers through CC Switch's in-app proxy, with a routing-support badge for providers that need it, role-based model route mapping locked to `sonnet` / `opus` / `haiku`, Copilot/Codex OAuth provider reuse, a redesigned Claude Code import flow, app-switcher differentiation between "Claude Code" and "Claude Desktop", and 44 provider presets translated from the Claude Code catalog.
|
||||
- **Routing Support Badges on Provider Cards**: Provider cards in both Claude Code and Codex panels now show a routing-support badge so users can tell at a glance which providers can be served through Local Routing.
|
||||
- **Codex OAuth Live Model List**: ChatGPT Codex providers now fetch the current model list from the ChatGPT backend on demand, replacing the previously hardcoded selection.
|
||||
- **Role-Based Model Mapping with 1M Flag**: Claude Code model mapping is now role-based (`sonnet` / `opus` / `haiku`) with display names and a `supports1m` flag, replacing the legacy `[1M]` suffix to decouple routing from raw model IDs.
|
||||
- **Filter-Driven Usage Hero**: The usage dashboard's Hero summary is now filter-driven with cache-normalized totals so the figures line up with the active date range and provider filters.
|
||||
- **Provider Form "Save Anyway" Prompt**: Softened provider form validation with a "save anyway" prompt so non-blocking input issues no longer prevent saving (#2307).
|
||||
- **Universal Provider Duplicate Action**: Added a duplicate action for universal providers from the provider list (#2416).
|
||||
- **Persisted Tauri Window State**: Window position and size now persist across launches (#2377).
|
||||
- **Tray Icon Tooltip**: The system tray icon now shows a tooltip on hover for clearer at-a-glance state (#2417).
|
||||
- **Warp Terminal Session Launch**: Added support for launching Warp and executing a saved session inside it (#2466).
|
||||
- **DeepSeek `reasoning_content` for Tool Calls**: DeepSeek tool-calling responses now return `reasoning_content` together with `tool_calls` so callers can render both (#2543).
|
||||
- **Baidu Qianfan Coding Plan for Claude Code**: Added a Baidu Qianfan Coding Plan preset for Claude Code (#2322).
|
||||
- **Compshare Coding Plan Preset (Cross-App)**: Added Compshare Coding Plan preset across claude / codex / hermes / openclaw.
|
||||
- **Partner Provider Presets**: Added BytePlus, Volcengine Agentplan, ClaudeAPI, ClaudeCN, RunAPI, RelaxyCode, and PatewayAI provider presets; promoted DouBao Seed to partner status with refreshed endpoint and links.
|
||||
- **44 Claude Desktop Provider Presets**: Translated 44 provider presets from the Claude Code catalog into the new Claude Desktop surface.
|
||||
|
||||
### Changed
|
||||
|
||||
- **20 Claude Desktop Presets Switched from Proxy to Direct Mode**: 20 Claude Desktop presets now ship in direct mode instead of routing through the proxy by default, reducing setup friction for users who don't need proxy-specific compatibility shims. Verify connectivity if you previously relied on proxy routing for these presets.
|
||||
- **Claude Desktop Operational Notes**: Switching a Claude Desktop provider now writes CC Switch's managed 3P profile and requires restarting Claude Desktop to take effect; proxy-mode providers require CC Switch Local Routing to stay running.
|
||||
- **Failover / Local Routing Guardrails**: Failover controls now require the target app's Local Routing takeover to be enabled, and stopping only the proxy service is blocked while any app still depends on takeover state.
|
||||
- **Usage Accounting Semantics**: Usage summaries now report cache-normalized real total tokens and cache hit rate; historical token and cost totals may shift after deduplication and pricing recalculation, but should be more accurate.
|
||||
- **Provider Preset Rendering Order**: Provider preset lists now render in author-defined array order with partners prioritized at the top, replacing the previous implicit sort.
|
||||
- **Model Mapping Hint Copy Simplified**: `modelMappingOffHint` was rewritten as action-oriented copy across zh / en / ja.
|
||||
- **CC Switch Brand Surface Unified to ccswitch.io**: All in-app and README references now point at ccswitch.io as the sole official website; the release notes template also surfaces ccswitch.io.
|
||||
- **Theme Switch Simplified**: Removed the circular reveal animation; theme changes are now an instant cross-fade.
|
||||
- **Claude Code App Switcher Differentiation**: The app switcher now visually distinguishes "Claude Code" from "Claude Desktop" and uses the "Claude Code" label in the app visibility settings.
|
||||
- **CI: Claude Review on Opus 4.7**: Upgraded the Claude review GitHub Action to Opus 4.7, tuned the prompt to reduce nitpick noise, added an `@claude` review-only Code Action, pinned PR head SHA for checkout, and dropped a `--max-turns 5` limit.
|
||||
- **Dependency Bumps**: `actions/checkout` 4 → 6 (#2517), `pnpm/action-setup` 5 → 6 (#2518), `softprops/action-gh-release` 2 → 3 (#2519), `actions/stale` 9 → 10 (#2520).
|
||||
- **DeepSeek Presets Switched to V4**: DeepSeek presets now ship V4 (flash / pro) with refreshed pricing seeds.
|
||||
- **Codex 1M Context Toggle Hidden in Provider Edit Form**: The 1M context-window toggle is no longer surfaced in the Codex provider edit form to reduce knob count for a setting that has no effect in current Codex deployments.
|
||||
- **OpenClaudeCode Migrated to MicuAPI Domain**: Updated the OpenClaudeCode preset to the MicuAPI domain; refreshed Micu API links to `micuapi.ai`.
|
||||
- **CrazyRouter Endpoints Switched to `cn` Subdomain**: Updated CrazyRouter preset endpoints to the `cn` subdomain.
|
||||
- **RelaxyCode Custom Icon**: Switched RelaxyCode preset icon to a custom `relaxcode.png` asset.
|
||||
- **Kimi For Coding Doc URL**: Updated Kimi For Coding website URL to the `/code/docs/` path.
|
||||
- **SiliconFlow International Site Shows USD**: Balance display now correctly shows USD for the SiliconFlow international site (was incorrectly displaying CNY).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OpenAI Responses API usage parsing robustness**: Hardened `build_anthropic_usage_from_responses()` and the Responses → Anthropic SSE translator so a missing or malformed upstream `usage` no longer produces `"usage": null` in `message_delta`. This unblocks strict Anthropic clients (notably the VSCode Claude Code extension) that crashed with "Cannot read properties of null (reading 'output_tokens')" against providers such as Codex OAuth and DashScope's `compatible-mode/v1/responses` endpoint. Added OpenAI field-name fallbacks (`prompt_tokens` / `completion_tokens`), null/empty/partial object handling, and preserved cache token fields even when input/output tokens are missing (#2422).
|
||||
- **OpenAI Responses API Usage Parsing Robustness**: Hardened `build_anthropic_usage_from_responses()` and the Responses → Anthropic SSE translator so a missing or malformed upstream `usage` no longer produces `"usage": null` in `message_delta`. This unblocks strict Anthropic clients (notably the VSCode Claude Code extension) that crashed with "Cannot read properties of null (reading 'output_tokens')" against providers such as Codex OAuth and DashScope's `compatible-mode/v1/responses` endpoint. Added OpenAI field-name fallbacks (`prompt_tokens` / `completion_tokens`), null/empty/partial object handling, and preserved cache token fields even when input/output tokens are missing (#2422).
|
||||
- **Proxy Reliability Patches (P0–P3)**: Multiple rounds of routing, lifecycle, retry, and rectifier patches across the request-forwarder paths; extracted a shared `handle_rectifier_retry_failure` helper and a shared `auth_header_value` helper across provider adapters.
|
||||
- **Proxy: Pooled HTTPS Connection Reuse**: Non-Anthropic backends now reuse pooled HTTPS connections instead of opening a fresh TLS session per request, materially reducing per-request latency.
|
||||
- **Proxy: Forward Client HTTP Method**: The proxy forwards the client's actual HTTP method instead of hard-coding `POST`, so non-POST upstream endpoints (e.g. GET `/v1/models`) work correctly.
|
||||
- **Proxy: Per-Attempt Counters and `max_retries` Wiring**: Client-request counters moved out of the per-attempt loop, and `AppProxyConfig.max_retries` is now correctly wired into the request forwarder.
|
||||
- **Proxy: Failover Decision Refinements**: Refined failover decision logic in the forwarder so retryable / unretryable errors are classified more accurately.
|
||||
- **Proxy: Takeover Detection Tightening**: Tightened takeover detection and use fallback restore when disabling takeover so leftover state no longer strands a provider.
|
||||
- **Proxy: Anthropic ↔ OpenAI `tool_choice` Mapping**: Anthropic `tool_choice` is now correctly mapped to the OpenAI Chat nested form during format conversion.
|
||||
- **Proxy: Gemini Request Model Extraction**: Gemini request model is now correctly extracted from the URI path (not the body) so transformed traffic reports the right model.
|
||||
- **Proxy: Auth Header Error Handling**: `get_auth_headers` now returns `Result` instead of panicking on bad credentials.
|
||||
- **Proxy: IPv6 Listen Address Validation**: The Proxy panel now accepts IPv6 listen addresses.
|
||||
- **Proxy: Codex / Responses Cache Hit Rate**: Improved cache hit rate for Codex and OpenAI Responses requests by stabilizing cache key derivation; only emit `prompt_cache_key` when a real client-provided session identity is available so unrelated conversations no longer collapse onto a single key; canonicalize (sort) JSON keys in outgoing request bodies and `tool_call` arguments / `tool_result` content for byte-identical prefix-cache reuse; thread `session_id` into the usage logger for request correlation.
|
||||
- **Proxy: JSON Schema Underscore Fields Preserved**: Private-parameter filtering now preserves underscore-prefixed field names inside JSON Schema name maps such as `properties`, `patternProperties`, `definitions`, and `$defs`, so user-defined schema keys like `_id` and `_meta` survive the filter.
|
||||
- **Proxy: Read Tool Empty Pages**: Drop empty pages from `Read` tool inputs so providers don't reject the request (#2472).
|
||||
- **Proxy: Per-Request Hot-Path Trim**: Trimmed per-request hot-path work and database wait time.
|
||||
- **Proxy: Real Provider Model Names Under Takeover**: The Claude Code menu now exposes the real provider model names when running under takeover, instead of a stale alias.
|
||||
- **Proxy: Zero Usage in Final Message Delta**: The final `message_delta` event always includes a usage block (even when zero) so strict Anthropic clients no longer crash on `null` (#2485).
|
||||
- **Proxy: Streaming `message_delta` Deduplication**: Deduplicated streaming `message_delta` events that some upstreams emit twice (#2366).
|
||||
- **Proxy: Scoped `reasoning_content` Preserved for Tool Calls**: Tool-call paths now correctly preserve the scoped `reasoning_content` field during transformation; Kimi / Moonshot OpenAI Chat compatibility paths keep the field while generic OpenAI-compatible requests stay free of it (#2367).
|
||||
- **Proxy: Vertex AI Full URL Preserved**: Full Vertex AI URLs are no longer truncated during proxy forwarding (#2415).
|
||||
- **Proxy: Leading Billing Header Stripped from System Content**: Strips the leading billing-header content that some upstreams prepend to the system message (#2350).
|
||||
- **Proxy: Claude Auth Strategy from `ANTHROPIC_*` Env Var**: The Claude auth strategy is now derived from the actual `ANTHROPIC_*` env variable name rather than an opaque heuristic.
|
||||
- **Third-Party Claude Providers: Disable Model Test**: Model probing is now disabled for third-party Claude providers where the gateway doesn't implement `/v1/models` consistently.
|
||||
- **Model-Fetch: `/models` Subpath for Anthropic-Compatible Providers**: `/models` discovery now works for Anthropic-compatible subpath providers.
|
||||
- **Copilot: Claude Model ID Resolution Against Live `/models`**: Copilot-backed providers now resolve Claude model IDs against the live `/models` list to avoid stale ID mismatches.
|
||||
- **Codex: Skip `environment_context` Injection When Extracting Session Title**: Session title extraction no longer pulls in `environment_context` noise (#2439).
|
||||
- **Codex: Hide Subagent Sessions**: Codex subagent sessions are now hidden from the main session list (#2445).
|
||||
- **Codex Startup Live Import Duplication**: Fixed a duplicate-import bug in the Codex startup live-import path (#2590).
|
||||
- **Codex Provider Switch History Drift**: Switching the active Codex provider no longer changes existing session history (#2349).
|
||||
- **Codex Usage Log Message**: Corrected a misleading log message for Codex session usage (#2473).
|
||||
- **Claude: Persist Max Effort via Env**: `max` effort now correctly persists via the env variable on restart (#2493).
|
||||
- **Claude Desktop: Match Proxy Model Route Without `[1M]` Suffix**: Route matching no longer requires the legacy `[1M]` suffix.
|
||||
- **Claude Desktop: Provider Form Focus Loss**: Fixed an input that lost focus while editing in the Claude Desktop provider form.
|
||||
- **Claude Desktop: Spurious Proxy-Stopped Status Alert**: Removed an alert that fired spuriously when the proxy was intentionally stopped.
|
||||
- **Claude Desktop: Empty Toolbar Capsule Hidden**: Hides the empty toolbar capsule when Claude Desktop is the active app.
|
||||
- **UI: Monitor Badge Icon Centering**: Centered the Monitor badge icon in the app switcher.
|
||||
- **Linux: Theme Selection Segfault**: Prevented selecting a theme from causing a segfault on Linux (#2502).
|
||||
- **Terminal: iTerm Fallback on Cold Launch**: Prevented iTerm from being selected as a fallback on cold launch when not actually present (#2448).
|
||||
- **Config: Sort JSON Keys Alphabetically**: Config writes now sort JSON keys alphabetically for deterministic output (#2469).
|
||||
- **Import Existing Side-Effect Free**: Made "import existing" side-effect free (#2429).
|
||||
- **Coding Plan: Zhipu Weekly Tier by Reset Time**: Corrected the Zhipu weekly tier name to match the actual reset time (#2420).
|
||||
- **DashScope: Usage Parsing Robustness**: Hardened DashScope usage parsing so a malformed payload no longer crashes the VSCode Claude Code extension (#2425).
|
||||
- **Usage: Prevent Double-Counting Between Proxy and Session-Log Sources**: Deduplicated usage records sourced from both the proxy and session logs.
|
||||
- **Usage: Cache Cost Semantics + Pricing Warn Storm**: Corrected cache-cost semantics and silenced a noisy pricing warning storm that fired on every request.
|
||||
- **CI: Frontend Formatting and Linux Clippy Restored**: Restored frontend formatting and Linux clippy checks in CI.
|
||||
- **Proxy Test Helper Clippy Warning**: Fixed a clippy warning in the proxy test helper.
|
||||
|
||||
### Removed
|
||||
|
||||
- **Hermes Agent Usage Tracking Integration**: Removed the in-cycle Hermes Agent usage tracking integration after upstream behavior changes made it impractical to keep in sync. The integration was never enabled in any released version — a zero-cost rendering bug found during its development was fixed before the integration was rolled back.
|
||||
- **Theme Switch Circular Reveal Animation**: Removed the circular reveal animation used during theme switching; the animation caused jank on slower compositors and added little visible value.
|
||||
- **DDSHub Partner Integration**: Removed DDSHub as a partner preset and dropped the cross-link blurbs across READMEs.
|
||||
|
||||
### Docs
|
||||
|
||||
- **README Sponsor Refresh (zh / en / ja)**: Added BytePlus, ClaudeCN, RunAPI, and PatewayAI sponsor entries; cross-linked BytePlus and Volcengine entries; refreshed the Crazyrouter $2 credit claim flow, the Compshare blurb, the Right Code blurb, and other sponsor logos and listings; flattened the LionCC logo onto a white background; switched the Chinese README's sponsor logo to the Volcengine artwork; added Hermes Agent to the README subtitles.
|
||||
- **Release Notes Template**: Surfaces `ccswitch.io` in the release notes template.
|
||||
- **Brand Surface**: Documented `ccswitch.io` as the sole official website across READMEs and in-app references.
|
||||
|
||||
## [3.14.1] - 2026-04-23
|
||||
|
||||
|
||||
@@ -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.</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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -131,12 +131,6 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
|
||||
<td>This project is sponsored by <a href="https://console.claudeapi.com/register?aff=pCLD">Claude API</a>. Direct Claude API access — connect Claude Code and Agent apps in 3 minutes. New users can claim a free trial credit.Powered by official Anthropic API keys + AWS Bedrock official channels. No reverse engineering, no model degradation. Full support for Opus / Sonnet / Haiku model lineup, with official capabilities preserved including Tool Use, 1M context window, and more. Built for Claude Code power users, Agent engineers, and enterprise engineering teams. Invoicing and dedicated team support available. Click <a href="https://console.claudeapi.com/register?aff=pCLD">here</a> to register!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://ddshub.short.gy/ccswitch"><img src="assets/partners/logos/dds.png" alt="DDS" width="150"></a></td>
|
||||
<td>Thanks to DDS for sponsoring this project! DDS Hub is a reliable and high-performance Claude API proxy service. We provides cost-effective domestic Claude direct acceleration services for both individual and enterprise users. We offer stable and low-latency Claude Max number pools, with full support for Claude Haiku, Opus, Sonnet and other flagship models. Invoices are available for recharges of 1000 RMB or more. Enterprise customers can also enjoy customized grouping and dedicated technical support services.
|
||||
Exclusive benefit for CC Switch users: Register via <a href="https://ddshub.short.gy/ccswitch">the link </a>below and enjoy an extra 10% credit on your first recharge (please contact the group admin to claim after recharging)!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://claudecn.top"><img src="assets/partners/logos/claudecn.jpg" alt="ClaudeCN" width="150"></a></td>
|
||||
<td>Thanks to ClaudeCN for sponsoring this project! ClaudeCN is an enterprise-grade AI gateway platform operated by a registered company. It delivers high-availability commercial API access to popular models including Claude, GPT, and DeepSeek, and is built around formal enterprise procurement workflows — corporate bank transfers, signed contracts, and full compliance. Register via <a href="https://claudecn.top">this link</a>!</td>
|
||||
@@ -173,7 +167,7 @@ Modern AI-powered coding relies on CLI tools like Claude Code, Codex, Gemini CLI
|
||||
|
||||
## Features
|
||||
|
||||
[Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.12.3-en.md)
|
||||
[Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.15.0-en.md)
|
||||
|
||||
### Provider Management
|
||||
|
||||
|
||||
+1
-8
@@ -130,13 +130,6 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
<td>本プロジェクトは <a href="https://console.claudeapi.com/register?aff=pCLD">Claude API</a> がスポンサーです。Claude API 直結 — わずか 3 分で Claude Code や Agent アプリに接続可能。新規ユーザーにはテストクレジットを提供しています。Anthropic 公式キーおよび AWS Bedrock 公式チャネルに基づいており、リバースエンジニアリングや性能劣化はありません。Opus / Sonnet / Haiku の全モデルラインナップをサポートし、Tool Use や 1M コンテキストなどの公式機能をすべて保持しています。Claude Code ヘビーユーザー、Agent エンジニア、企業技術チームに最適です。請求書発行およびチーム対応が可能です。<a href="https://console.claudeapi.com/register?aff=pCLD">こちら</a>から登録してください!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://ddshub.short.gy/ccswitch"><img src="assets/partners/logos/dds.png" alt="DDS" width="150"></a></td>
|
||||
<td>本プロジェクトのスポンサーである DDS に感謝いたします! DDS(呆呆獣 / DDS Hub)は、Claude に特化した信頼性とパフォーマンスの高い API プロキシサービスです。個人および企業ユーザーの皆様に、圧倒的なコストパフォーマンスを誇る Claude 直結アクセラレーションサービスを提供しています。Claude Haiku / Opus / Sonnet などのフルスペックモデルを完全サポートし、安定した低遅延のアクセスを実現します。
|
||||
1,000人民元以上のチャージで領収書(発票)の発行が可能です。さらに、企業のお客様にはカスタマイズされたグループ管理や専用テクニカルサポートをご提供しています。
|
||||
CC Switch ユーザー限定特典: 専用リンクからご<a href="https://ddshub.short.gy/ccswitch">登録</a>いただくと、初回チャージ時に 10% の追加ボーナスクレジット をプレゼントいたします!(※チャージ完了後、グループ管理人へご連絡の上お受け取りください。)</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://claudecn.top"><img src="assets/partners/logos/claudecn.jpg" alt="ClaudeCN" width="150"></a></td>
|
||||
<td>本プロジェクトのスポンサーである ClaudeCN に感謝いたします!ClaudeCN は、実体のある企業によって運営されるエンタープライズ向け AI ゲートウェイプラットフォームです。Claude、GPT、DeepSeek など主要モデルへの高可用な商用 API アクセスを提供し、企業の調達プロセスにも対応 — 法人振込や正式契約に対応し、コンプライアンス面でも安心してご利用いただけます。<a href="https://claudecn.top">こちら</a>からご登録ください!</td>
|
||||
@@ -173,7 +166,7 @@ CC Switch ユーザー限定特典: 専用リンクからご<a href="https://d
|
||||
|
||||
## 特長
|
||||
|
||||
[完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-notes/v3.12.3-ja.md)
|
||||
[完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-notes/v3.15.0-ja.md)
|
||||
|
||||
### プロバイダ管理
|
||||
|
||||
|
||||
+2
-7
@@ -63,7 +63,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
|
||||
<td>感谢火山方舟Agent Plan 模型赞助了本项目!方舟Agent Plan 模型订阅套餐集成了包含Doubao-Seed、Doubao-Seedance、Doubao-Seedream等在内的字节跳动自研SOTA级模型,覆盖文本、代码、图像、视频等多模态任务。同时支持一站式接入DeepSeek V4、GLM 5.1等主流大模型。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。方舟 Agent Plan 为 CC Switch 的用户提供了专属福利:通过<a href="https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">此链接</a>订阅方舟AgentPlan,新客户首月40元起!</td>
|
||||
<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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -131,11 +131,6 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
<td>本项目由 <a href="https://console.claudeapi.com/register?aff=pCLD">Claude API</a> 赞助。Claude API 直连,三分钟接入 Claude Code 与 Agent 应用 新用户可领取测试额度。基于 Anthropic 官方 Key + AWS Bedrock 官方渠道,非逆向、非降智,支持 Opus / Sonnet / Haiku 全系列模型,保留 Tool Use、1M 上下文等官方能力。适合 Claude Code 深度用户、Agent 工程师与企业技术团队,支持开票和团队对接。点击<a href="https://console.claudeapi.com/register?aff=pCLD">这里</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://ddshub.short.gy/ccswitch"><img src="assets/partners/logos/dds.png" alt="DDS" width="150"></a></td>
|
||||
<td>感谢 DDS 赞助本项目!呆呆兽是一家专注 Claude 的可靠高效 API 中转站,为个人和企业用户提供极具性价比的国内 Claude 直连加速服务。支持 Claude Haiku / Opus / Sonnet 等满血模型。充值满 1000 元即可开具发票,企业客户更可享受定制化分组和技术支持服务。CC Switch 用户专属福利:通过<a href="https://ddshub.short.gy/ccswitch">此链接</a>注册后,首单充值可额外赠送 10% 额度(充值后请联系群主领取)!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://claudecn.top"><img src="assets/partners/logos/claudecn.jpg" alt="ClaudeCN" width="150"></a></td>
|
||||
<td>感谢 ClaudeCN 赞助本项目!ClaudeCN 由是一家实体企业运营的企业级AI中转平台。平台可提供高可用性的商用API服务,提供Claude、GPT、Deepseek等热门模型,支持企业采购流程,可对公打款、签约,服务合规有保障。点击<a href="https://claudecn.top">此链接</a>注册!</td>
|
||||
@@ -172,7 +167,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
|
||||
## 功能特性
|
||||
|
||||
[完整更新日志](CHANGELOG.md) | [发布说明](docs/release-notes/v3.12.3-zh.md)
|
||||
[完整更新日志](CHANGELOG.md) | [发布说明](docs/release-notes/v3.15.0-zh.md)
|
||||
|
||||
### 供应商管理
|
||||
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
# CC Switch v3.15.0
|
||||
|
||||
> Claude Desktop becomes a first-class managed surface with third-party provider switching via proxy gateway, role-based model mapping, major reverse-proxy hardening, Codex OAuth live model discovery, and a filter-driven usage dashboard Hero card
|
||||
|
||||
**[中文版 →](v3.15.0-zh.md) | [日本語版 →](v3.15.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.15.0 is a major release following the v3.14.x line, centered on **promoting Claude Desktop to a first-class managed surface**. It ships third-party provider switching through the in-app proxy gateway, role-based model mapping (`sonnet` / `opus` / `haiku`) with a `supports1m` long-context flag, Copilot/Codex OAuth provider reuse, a redesigned Claude Code import flow, app-switcher differentiation between "Claude Code" and "Claude Desktop", and 44 provider presets translated from the Claude Code catalog into the new Claude Desktop surface.
|
||||
|
||||
Around proxy reliability, this release performs a systematic hardening pass: P0–P3 patches across routing / lifecycle / retry / failover / rectifier paths; pooled HTTPS connection reuse for non-Anthropic backends to cut per-request latency; cache hit-rate improvements for Codex and OpenAI Responses (emit `prompt_cache_key` only when a real client-provided session identity exists, canonicalize JSON keys in outgoing request bodies plus `tool_call` arguments and `tool_result` content, and thread `session_id` into the usage logger); correct Anthropic ↔ OpenAI `tool_choice` mapping; Vertex AI full URLs are no longer truncated; Gemini request models are now extracted from the URI path; takeover detection is tightened; and IPv6 listen addresses are supported. ChatGPT Codex OAuth providers no longer depend on hardcoded model lists — CC Switch now fetches a live model list from the ChatGPT backend on demand.
|
||||
|
||||
Claude Code's model mapping is now role-based (`sonnet` / `opus` / `haiku`) with display names and a new `supports1m` boolean flag, replacing the legacy `[1M]` suffix and decoupling routing decisions from raw model IDs. The usage dashboard adds a **filter-driven Hero card** that exposes cache-normalized real total tokens and cache hit rate, updated live as the active date range / provider / model filters change; paired with a fix for cache-cost semantics and the noisy pricing warning storm that fired on every request. Robustness improvements in the OpenAI Responses API usage parsing path mean missing or malformed upstream `usage` no longer crashes the VSCode Claude Code extension with a `null` output.
|
||||
|
||||
The provider ecosystem expands further: new BytePlus, Volcengine Agentplan, ClaudeAPI, ClaudeCN, RunAPI, RelaxyCode, PatewayAI, and Baidu Qianfan Coding Plan partner presets; DouBao Seed is promoted to partner status; and provider cards now surface a "routing support" badge so users can tell at a glance which providers can be served through Local Routing. This release also fixes a long tail of issues across Codex sessions, OAuth, Claude Desktop forms, Linux segfaults, terminal fallbacks, and ships several GitHub Actions dependency bumps.
|
||||
|
||||
**Release date**: 2026-05-16
|
||||
|
||||
**Stats**: 127 commits | 211 files changed | +17,980 insertions | -2,748 deletions
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Claude Desktop Becomes a First-Class Managed Surface**: Third-party provider switching through the in-app proxy gateway, role-based model mapping (`sonnet` / `opus` / `haiku`) with a `supports1m` long-context flag, Copilot/Codex OAuth provider reuse, and 44 provider presets translated from the Claude Code catalog. Note: 20 Claude Desktop presets now default to direct mode instead of proxy mode — verify connectivity after upgrade if you previously relied on proxy routing.
|
||||
- **Major Reverse-Proxy Hardening**: P0–P3 lifecycle / retry / failover / rectifier patches; pooled HTTPS reuse for non-Anthropic backends; Codex / Responses cache hit-rate improvements; correct Anthropic ↔ OpenAI `tool_choice` mapping; Vertex AI URL preservation; Gemini path-based model extraction; refined takeover detection; IPv6 listen address support.
|
||||
- **Provider Ecosystem Expansion**: New BytePlus, Volcengine Agentplan, ClaudeAPI, ClaudeCN, RunAPI, RelaxyCode, PatewayAI, and Baidu Qianfan Coding Plan partner presets; DouBao Seed promoted to partner status; routing-support badges on provider cards.
|
||||
- **Role-Based Model Mapping with 1M Flag**: Role-based `sonnet` / `opus` / `haiku` routing with display names and a `supports1m` flag replaces the legacy `[1M]` suffix.
|
||||
- **Codex OAuth Live Model Discovery**: ChatGPT Codex providers fetch the live model list from the ChatGPT backend on demand.
|
||||
- **Usage Dashboard Filter-Driven Hero**: Surfaces cache-normalized real total tokens and cache hit rate, updated live as date / provider / model filters change.
|
||||
- **DeepSeek Tool Calls + Zero-Usage Final Delta**: DeepSeek tool calls now return `reasoning_content` alongside `tool_calls` (#2543, thanks @bling-yshs); the final `message_delta` always includes a usage block (even when zero) so strict Anthropic clients no longer crash on `null` (#2485, thanks @Myoontyee).
|
||||
- **OpenAI Responses API Usage Parsing Robustness**: Missing or malformed upstream `usage` no longer crashes the VSCode Claude Code extension (#2422, thanks @magucas).
|
||||
|
||||
---
|
||||
|
||||
## Added
|
||||
|
||||
### Claude Desktop Third-Party Provider Switching via Proxy Gateway
|
||||
|
||||
CC Switch now treats **Claude Desktop** as a first-class managed surface alongside Claude Code / Codex / Gemini / OpenCode / OpenClaw / Hermes.
|
||||
|
||||
- New dedicated Claude Desktop panel that brokers third-party providers to Claude Desktop through CC Switch's in-app proxy gateway
|
||||
- Routing-support badge on cards for providers that need Local Routing
|
||||
- Role-based model route mapping locked to `sonnet` / `opus` / `haiku`
|
||||
- Copilot / Codex OAuth providers can be reused in the Claude Desktop panel
|
||||
- Redesigned Claude Code settings import flow
|
||||
- App switcher visually distinguishes "Claude Code" from "Claude Desktop", and the app visibility settings use the "Claude Code" label
|
||||
- 44 Claude Desktop provider presets translated from the Claude Code preset catalog
|
||||
|
||||
### Routing Support Badges on Provider Cards
|
||||
|
||||
Provider cards in both the Claude Code and Codex panels now show a routing-support badge so users can tell at a glance which providers can be served through Local Routing.
|
||||
|
||||
### Codex OAuth Live Model List
|
||||
|
||||
ChatGPT Codex providers no longer rely on a hardcoded model selection — CC Switch fetches a **live model list** from the ChatGPT backend on demand.
|
||||
|
||||
### Role-Based Model Mapping with 1M Flag
|
||||
|
||||
Claude Code model mapping is now role-based (`sonnet` / `opus` / `haiku`) with display names and a `supports1m` boolean flag, replacing the legacy `[1M]` suffix and decoupling routing from raw model IDs.
|
||||
|
||||
### Filter-Driven Usage Hero
|
||||
|
||||
The usage dashboard's Hero summary is now filter-driven, updating live as the active date range / provider / model filters change; it surfaces **cache-normalized real total tokens** and cache hit rate so the Hero figures line up with the detail list below.
|
||||
|
||||
### Provider Form "Save Anyway" Prompt
|
||||
|
||||
Softened provider form input validation by turning non-blocking input issues into a "save anyway" prompt, so a harmless field issue no longer blocks saving (#2307, thanks @allenxln).
|
||||
|
||||
### Universal Provider Duplicate Action
|
||||
|
||||
Added a "duplicate" button for universal providers from the provider list (#2416, thanks @hubutui).
|
||||
|
||||
### Persisted Tauri Window State
|
||||
|
||||
Window position and size now persist across launches (#2377, thanks @BillSaul).
|
||||
|
||||
### Tray Icon Tooltip
|
||||
|
||||
The system tray icon now surfaces a status tooltip on hover (#2417, thanks @Coconut-Fish).
|
||||
|
||||
### Warp Terminal Session Launch
|
||||
|
||||
Added support for launching Warp and executing a saved session inside it (#2466, thanks @tisonkun).
|
||||
|
||||
### DeepSeek `reasoning_content` for Tool Calls
|
||||
|
||||
DeepSeek tool-call responses now return `reasoning_content` and `tool_calls` together, so callers can render both (#2543, thanks @bling-yshs).
|
||||
|
||||
### Baidu Qianfan Coding Plan (Claude Code)
|
||||
|
||||
Added a Baidu Qianfan Coding Plan preset (#2322, thanks @jimmyzhuu).
|
||||
|
||||
### Compshare Coding Plan Preset (Cross-App)
|
||||
|
||||
The Compshare Coding Plan preset now lands across claude / codex / hermes / openclaw.
|
||||
|
||||
### Partner Provider Presets
|
||||
|
||||
Added **BytePlus**, **Volcengine Agentplan**, **ClaudeAPI**, **ClaudeCN**, **RunAPI**, **RelaxyCode**, and **PatewayAI** partner presets; promoted **DouBao Seed** to partner status (refreshed endpoint and links).
|
||||
|
||||
### 44 Claude Desktop Provider Presets
|
||||
|
||||
Translated 44 provider presets from the Claude Code preset catalog into the new Claude Desktop panel.
|
||||
|
||||
---
|
||||
|
||||
## Changed
|
||||
|
||||
### 20 Claude Desktop Presets Default to Direct Mode
|
||||
|
||||
20 Claude Desktop presets now ship in direct mode instead of routing through the proxy by default, reducing setup friction for users who don't need proxy-specific compatibility shims. If you previously relied on proxy routing for these presets, verify connectivity after upgrading.
|
||||
|
||||
### Claude Desktop Operational Notes
|
||||
|
||||
Switching a Claude Desktop provider writes CC Switch's managed 3P profile and **requires restarting Claude Desktop** to take effect; proxy-mode providers require CC Switch's Local Routing to stay running while in use.
|
||||
|
||||
### Failover / Local Routing Guardrails
|
||||
|
||||
Failover controls now require the target app's Local Routing takeover to be enabled before they can be turned on; stopping only the proxy service is blocked while any app still depends on takeover state, preventing the "proxy stopped but the app still thinks takeover is running" inconsistency.
|
||||
|
||||
### Usage Accounting Semantics Changed
|
||||
|
||||
Usage summaries now report **cache-normalized real total tokens** and **cache hit rate**. Historical token and cost figures may **shift** after deduplication and pricing recalculation — the new numbers are more accurate but will not equal the values reported in earlier versions.
|
||||
|
||||
### Provider Preset Rendering Order
|
||||
|
||||
Preset lists now render in the author-defined array order, with partners prioritized first, replacing the previous implicit sort.
|
||||
|
||||
### Model Mapping Hint Copy Simplified
|
||||
|
||||
`modelMappingOffHint` was rewritten as action-oriented copy across zh / en / ja.
|
||||
|
||||
### CC Switch Brand Surface Unified to ccswitch.io
|
||||
|
||||
All in-app and README "official website" references now point at ccswitch.io as the sole official site; the release notes template also surfaces ccswitch.io.
|
||||
|
||||
### Theme Switch Simplified
|
||||
|
||||
Removed the circular reveal animation during theme switches; theme changes are now an instant cross-fade.
|
||||
|
||||
### Claude Code App Switcher Differentiation
|
||||
|
||||
The app switcher visually distinguishes "Claude Code" from "Claude Desktop", and the app visibility settings use the "Claude Code" label.
|
||||
|
||||
### CI: Claude Review Upgraded to Opus 4.7
|
||||
|
||||
The Claude review GitHub Action is upgraded to Opus 4.7; the prompt is tuned to reduce nitpick noise; a new `@claude` review-only Code Action is added; PR head SHA is pinned for checkout; the `--max-turns 5` limit is removed.
|
||||
|
||||
### GitHub Actions Dependency Bumps
|
||||
|
||||
- `actions/checkout` 4 → 6 (#2517)
|
||||
- `pnpm/action-setup` 5 → 6 (#2518)
|
||||
- `softprops/action-gh-release` 2 → 3 (#2519)
|
||||
- `actions/stale` 9 → 10 (#2520)
|
||||
|
||||
### DeepSeek Presets Switched to V4
|
||||
|
||||
DeepSeek presets now ship V4 (flash / pro) with refreshed pricing seeds.
|
||||
|
||||
### Codex 1M Context Toggle Hidden in Edit Form
|
||||
|
||||
The 1M context-window toggle is no longer surfaced in the Codex provider edit form, reducing the density of knobs that have no effect in current Codex deployments.
|
||||
|
||||
### OpenClaudeCode Migrated to MicuAPI Domain
|
||||
|
||||
The OpenClaudeCode preset is migrated to the MicuAPI domain; Micu API links are refreshed to `micuapi.ai`.
|
||||
|
||||
### CrazyRouter Endpoints Switched to `cn` Subdomain
|
||||
|
||||
CrazyRouter preset endpoints now use the `cn` subdomain.
|
||||
|
||||
### RelaxyCode Custom Icon
|
||||
|
||||
The RelaxyCode preset icon is switched to a custom `relaxcode.png` asset.
|
||||
|
||||
### Kimi For Coding Doc URL
|
||||
|
||||
The Kimi For Coding website URL is updated to the `/code/docs/` path.
|
||||
|
||||
### SiliconFlow International Site Shows USD
|
||||
|
||||
The SiliconFlow international site now correctly shows USD for balance display (it previously displayed CNY incorrectly).
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### OpenAI Responses API Usage Parsing Robustness
|
||||
|
||||
Hardened `build_anthropic_usage_from_responses()` and the Responses → Anthropic SSE translator so a missing or malformed upstream `usage` no longer produces `"usage": null` in `message_delta`. This unblocks strict Anthropic clients (notably the VSCode Claude Code extension) that crashed with `Cannot read properties of null (reading 'output_tokens')` against providers such as Codex OAuth and DashScope's `compatible-mode/v1/responses` endpoint. Added OpenAI field-name fallbacks (`prompt_tokens` / `completion_tokens`), null / empty / partial object handling, and preserved cache token fields even when input/output tokens are missing (#2422, thanks @magucas).
|
||||
|
||||
### Proxy Reliability Patches (P0–P3)
|
||||
|
||||
Multiple rounds of routing / lifecycle / retry / rectifier patches across the request-forwarder paths; extracted a shared `handle_rectifier_retry_failure` helper and a shared `auth_header_value` helper.
|
||||
|
||||
### Proxy: Pooled HTTPS Connection Reuse for Non-Anthropic Backends
|
||||
|
||||
Non-Anthropic backends now reuse pooled HTTPS connections instead of opening a fresh TLS session per request, materially reducing per-request latency.
|
||||
|
||||
### Proxy: Forward Client's Actual HTTP Method
|
||||
|
||||
The proxy no longer hard-codes `POST` — it forwards the client's actual HTTP method, so non-POST upstream endpoints (e.g. GET `/v1/models`) now work correctly.
|
||||
|
||||
### Proxy: Per-Attempt Counters and `max_retries` Wiring
|
||||
|
||||
Client-request counters are moved out of the per-attempt loop; `AppProxyConfig.max_retries` is now correctly wired into the request forwarder.
|
||||
|
||||
### Proxy: Failover Decision Refinements
|
||||
|
||||
Refined retryable vs. unretryable error classification in the request forwarder.
|
||||
|
||||
### Proxy: Takeover Detection Tightening
|
||||
|
||||
Takeover detection is tightened; disabling takeover uses fallback restore, so leftover state no longer strands a provider.
|
||||
|
||||
### Proxy: Anthropic ↔ OpenAI `tool_choice` Mapping
|
||||
|
||||
During format conversion, Anthropic's `tool_choice` is now correctly mapped to the OpenAI Chat nested form.
|
||||
|
||||
### Proxy: Gemini Request Model Extracted from URI Path
|
||||
|
||||
Gemini request models are now extracted from the URI path (instead of the body), so transformed traffic reports the right model name.
|
||||
|
||||
### Proxy: Auth Header Error Handling
|
||||
|
||||
`get_auth_headers` now returns `Result` instead of panicking on bad credentials.
|
||||
|
||||
### Proxy: IPv6 Listen Address Validation
|
||||
|
||||
The Proxy panel now accepts IPv6 listen addresses.
|
||||
|
||||
### Proxy: Codex / Responses Cache Hit Rate
|
||||
|
||||
Improved cache hit rate for Codex and OpenAI Responses requests by stabilizing cache key derivation: emit `prompt_cache_key` only when the client actually carries a session identity, so unrelated conversations no longer collapse onto a single key; canonicalize (sort) JSON keys in outgoing request bodies and in `tool_call` arguments / `tool_result` content for byte-identical prefix-cache reuse; thread `session_id` into the usage logger for request correlation.
|
||||
|
||||
### Proxy: JSON Schema Underscore Fields Preserved
|
||||
|
||||
Private-parameter filtering now preserves underscore-prefixed field names inside JSON Schema name maps (`properties`, `patternProperties`, `definitions`, `$defs`), so user-defined schema keys like `_id` and `_meta` pass through the filter intact.
|
||||
|
||||
### Proxy: Read Tool Empty Pages
|
||||
|
||||
Drop empty pages from `Read` tool inputs so providers no longer reject the request (#2472, thanks @Kwensiu).
|
||||
|
||||
### Proxy: Per-Request Hot-Path Trim
|
||||
|
||||
Trimmed per-request hot-path work and database wait time.
|
||||
|
||||
### Proxy: Real Provider Model Names Under Takeover
|
||||
|
||||
Under takeover, the Claude Code menu now exposes the real provider model names instead of a stale alias.
|
||||
|
||||
### Proxy: Zero Usage in Final `message_delta`
|
||||
|
||||
The final `message_delta` event now always includes a usage block (even when zero) so strict Anthropic clients no longer crash on `null` (#2485, thanks @Myoontyee).
|
||||
|
||||
### Proxy: Streaming `message_delta` Deduplication
|
||||
|
||||
Deduplicated `message_delta` events that some upstreams emit twice (#2366, thanks @codeasier).
|
||||
|
||||
### Proxy: Scoped `reasoning_content` Preserved for Tool Calls
|
||||
|
||||
Tool-call paths now correctly preserve the scoped `reasoning_content` field during transformation; Kimi / Moonshot's OpenAI Chat compatibility path keeps the field while generic OpenAI-compatible requests stay free of it (#2367, thanks @codeasier).
|
||||
|
||||
### Proxy: Vertex AI Full URL Preserved
|
||||
|
||||
Full Vertex AI URLs are no longer truncated during proxy forwarding (#2415, thanks @xpfo-go).
|
||||
|
||||
### Proxy: Leading Billing Header Stripped from System Content
|
||||
|
||||
Some upstreams prepend a billing-header chunk to the system message; this content is now stripped (#2350).
|
||||
|
||||
### Proxy: Claude Auth Strategy Derived from `ANTHROPIC_*` Env Var
|
||||
|
||||
The Claude auth strategy is now derived from the actual `ANTHROPIC_*` env variable name rather than an opaque heuristic.
|
||||
|
||||
### Third-Party Claude Providers: Disable Model Test
|
||||
|
||||
Model probing is disabled for third-party Claude gateways that don't implement `/v1/models` consistently.
|
||||
|
||||
### Model-Fetch: `/models` for Anthropic-Compatible Subpath Providers
|
||||
|
||||
`/models` discovery now works for Anthropic-compatible subpath providers.
|
||||
|
||||
### Copilot: Claude Model IDs Resolved Against Live `/models`
|
||||
|
||||
Copilot-backed providers now resolve Claude model IDs against the live `/models` list to avoid stale ID mismatches.
|
||||
|
||||
### Codex: Session Title No Longer Pulls in `environment_context`
|
||||
|
||||
Codex session title extraction no longer pulls in the `environment_context` noise (#2439, thanks @eclipsehx).
|
||||
|
||||
### Codex: Subagent Sessions Hidden
|
||||
|
||||
Codex subagent sessions are now hidden from the main session list (#2445, thanks @LanternCX).
|
||||
|
||||
### Codex Startup Live Import Duplication
|
||||
|
||||
Fixed a duplicate-import bug in the Codex startup live-import path (#2590, thanks @DhruvShankpal).
|
||||
|
||||
### Codex Provider Switch No Longer Disturbs History
|
||||
|
||||
Switching the active Codex provider no longer changes existing session history (#2349, thanks @SaladDay).
|
||||
|
||||
### Codex Usage Log Wording
|
||||
|
||||
Corrected a misleading log message for Codex session usage (#2473, thanks @tisonkun).
|
||||
|
||||
### Claude: Persist `max` Effort via Env
|
||||
|
||||
`max` effort now correctly persists across restart via the env variable (#2493, thanks @makoMakoGo).
|
||||
|
||||
### Claude Desktop: Model Route Matching Without `[1M]` Suffix
|
||||
|
||||
Route matching no longer requires the legacy `[1M]` suffix.
|
||||
|
||||
### Claude Desktop: Provider Form Input Focus Loss
|
||||
|
||||
Fixed an input in the Claude Desktop provider form that lost focus while being edited.
|
||||
|
||||
### Claude Desktop: Spurious Proxy-Stopped Status Alert
|
||||
|
||||
Removed an alert that fired spuriously when the proxy was intentionally stopped.
|
||||
|
||||
### Claude Desktop: Empty Toolbar Capsule Hidden
|
||||
|
||||
The empty toolbar capsule is now hidden when Claude Desktop is the active app.
|
||||
|
||||
### UI: Monitor Badge Icon Centering
|
||||
|
||||
Centered the Monitor badge icon in the app switcher.
|
||||
|
||||
### Linux: Theme Selection Segfault
|
||||
|
||||
Prevented a segfault triggered by selecting a theme on Linux (#2502, thanks @definfo).
|
||||
|
||||
### Terminal: iTerm Fallback on Cold Launch
|
||||
|
||||
Prevented iTerm from being selected as a fallback on cold launch when it isn't actually installed (#2448, thanks @hulkbig).
|
||||
|
||||
### Config: JSON Keys Sorted Alphabetically
|
||||
|
||||
Config writes now sort JSON keys alphabetically for deterministic output (#2469, thanks @fuleinist).
|
||||
|
||||
### "Import Existing" Made Side-Effect Free
|
||||
|
||||
The "import existing" action is now side-effect free (#2429, thanks @xwil1).
|
||||
|
||||
### Coding Plan: Zhipu Weekly Tier Named by Reset Time
|
||||
|
||||
Corrected the Zhipu weekly tier name to match the actual reset time (#2420, thanks @TuYv).
|
||||
|
||||
### DashScope: Usage Parsing Robustness
|
||||
|
||||
Hardened DashScope usage parsing so a malformed payload no longer crashes the VSCode Claude Code extension (#2425, thanks @magucas).
|
||||
|
||||
### Usage: Deduplicate Proxy and Session-Log Sources
|
||||
|
||||
Deduplicated usage records sourced from both the proxy and session logs.
|
||||
|
||||
### Usage: Cache Cost Semantics + Pricing Warn Storm
|
||||
|
||||
Corrected cache-cost semantics and silenced the noisy pricing warning that fired on every request.
|
||||
|
||||
### CI: Frontend Formatting + Linux Clippy Restored
|
||||
|
||||
Restored frontend formatting and Linux clippy checks in CI.
|
||||
|
||||
### Proxy Test Helper Clippy Warning
|
||||
|
||||
Fixed a clippy warning in the proxy test helper.
|
||||
|
||||
---
|
||||
|
||||
## Removed
|
||||
|
||||
### Hermes Agent Usage Tracking Integration
|
||||
|
||||
Removed the Hermes Agent usage tracking integration originally planned for this cycle — upstream behavior changes made the integration impractical to maintain. The integration was **never enabled in any released version**; the "zero-cost rendering" bug discovered during its development was fixed before the integration was rolled back.
|
||||
|
||||
### Theme Switch Circular Reveal Animation
|
||||
|
||||
Removed the circular reveal animation used during theme switches — it stuttered on slower compositors and added little visible value.
|
||||
|
||||
### DDSHub Partner Integration
|
||||
|
||||
Removed DDSHub as a partner preset and dropped the cross-link blurbs from the READMEs.
|
||||
|
||||
---
|
||||
|
||||
## Docs
|
||||
|
||||
### README Sponsor Refresh (zh / en / ja)
|
||||
|
||||
Added BytePlus, ClaudeCN, RunAPI, and PatewayAI sponsor entries; cross-linked BytePlus and Volcengine entries; refreshed the CrazyRouter $2 credit claim flow, the Compshare blurb, the Right Code blurb, and other sponsor logos and listings; flattened the LionCC logo onto a white background; switched the Chinese README's sponsor logo to the Volcengine artwork; added Hermes Agent to the README subtitles.
|
||||
|
||||
### Release Notes Template
|
||||
|
||||
The release notes template now surfaces `ccswitch.io`.
|
||||
|
||||
### Brand Surface
|
||||
|
||||
Documented `ccswitch.io` as the sole official website across READMEs and in-app references.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Upgrade Notes
|
||||
|
||||
### 20 Claude Desktop Presets Default to Direct Mode
|
||||
|
||||
These 20 presets previously routed through the proxy by default and now default to direct mode. If you were using one of these presets pre-upgrade and depended on the proxy path for connectivity (for example because the proxy applies a special rectifier or transformation layer), verify connectivity after upgrading; you can manually switch them back to proxy mode from the CC Switch panel if needed.
|
||||
|
||||
### Claude Desktop Operational Constraints
|
||||
|
||||
Switching a Claude Desktop provider **requires restarting Claude Desktop** to take effect; proxy-mode providers require CC Switch's Local Routing to stay running while in use — quitting CC Switch or stopping Local Routing will cut off any proxy-mode Claude Desktop providers.
|
||||
|
||||
### Failover Requires Takeover Enabled
|
||||
|
||||
Before enabling Failover, make sure the target app's Local Routing takeover is enabled, otherwise the Failover control will refuse to start; stopping the proxy service while any app still depends on takeover state is blocked, so you need to disable takeover at the app layer first before stopping the proxy.
|
||||
|
||||
### Usage Figures May Diverge from History
|
||||
|
||||
Usage summaries now use cache-normalized real total tokens + cache hit rate. Historical token and cost figures may **shift** after deduplication and pricing recalculation — the new numbers are more accurate but will not equal what earlier versions reported.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Risk Notice
|
||||
|
||||
This release inherits the risk notices originally introduced in v3.12.3 / v3.13.0 for reverse-proxy-style features.
|
||||
|
||||
**GitHub Copilot Reverse Proxy**: Using Copilot's reverse-proxy path may violate GitHub / Microsoft's terms of service. See the [v3.12.3 release notes](v3.12.3-en.md#️-risk-notice) for details.
|
||||
|
||||
**Codex OAuth Reverse Proxy**: Using the Codex OAuth reverse proxy with a ChatGPT subscription may violate OpenAI's terms of service. See the [v3.13.0 release notes](v3.13.0-en.md#️-risk-notice) for details.
|
||||
|
||||
**Claude Desktop Third-Party Provider Switching via Proxy Gateway**: Routing Claude Desktop traffic through CC Switch's in-app proxy gateway to a third-party provider exposes those requests to that provider's billing, compliance, and data-retention policies — read the target provider's terms of service before using.
|
||||
|
||||
By enabling these features, users **accept all associated risks**. CC Switch is not responsible for any account restrictions, warnings, or service suspensions that result from using these features.
|
||||
|
||||
---
|
||||
|
||||
## Download & Installation
|
||||
|
||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
|
||||
|
||||
### System Requirements
|
||||
|
||||
| OS | Minimum Version | Architecture |
|
||||
| ------- | ---------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 or later | x64 |
|
||||
| macOS | macOS 12 (Monterey) or later | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | See table below | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------------------- | ----------------------------------------------------- |
|
||||
| `CC-Switch-v3.15.0-Windows.msi` | **Recommended** - MSI installer, supports auto-update |
|
||||
| `CC-Switch-v3.15.0-Windows-Portable.zip` | Portable, extract and run, no registry writes |
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| -------------------------------- | ------------------------------------------------------- |
|
||||
| `CC-Switch-v3.15.0-macOS.dmg` | **Recommended** - DMG installer, drag into Applications |
|
||||
| `CC-Switch-v3.15.0-macOS.zip` | Extract and drag into Applications, Universal Binary |
|
||||
| `CC-Switch-v3.15.0-macOS.tar.gz` | For Homebrew installation and auto-update |
|
||||
|
||||
> macOS builds are Apple code-signed and notarized — install directly.
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Update:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
> Linux artifacts are published for both **x86_64** and **ARM64** (`aarch64`). The architecture is included in the asset filename — pick the one matching your machine's `uname -m` output:
|
||||
>
|
||||
> - `CC-Switch-v3.15.0-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
> - `CC-Switch-v3.15.0-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| Distribution | Recommended | Installation |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | Add execute permission and run, or use AUR |
|
||||
| Other distros / not sure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,498 @@
|
||||
# CC Switch v3.15.0
|
||||
|
||||
> Claude Desktop が一等管理パネルに昇格(プロキシゲートウェイ経由のサードパーティプロバイダー切り替えを含む)、ロールベースのモデルマッピング、リバースプロキシの大幅強化、Codex OAuth ライブモデル検出、Usage ダッシュボードのフィルター駆動 Hero カード
|
||||
|
||||
**[English →](v3.15.0-en.md) | [中文 →](v3.15.0-zh.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.15.0 は v3.14.x に続く大型バージョンアップで、コアの焦点は **Claude Desktop を一等管理パネルに昇格させること**にあります。これに合わせて、内蔵プロキシゲートウェイを介したサードパーティプロバイダーの切り替え、ロールベースのモデルマッピング(sonnet / opus / haiku)+ `supports1m` ロングコンテキストフラグ、Copilot / Codex OAuth プロバイダーの再利用、再設計された Claude Code インポートフロー、App スイッチャーでの「Claude Code」と「Claude Desktop」の視覚的な区別、そして Claude Code プリセットディレクトリから翻訳された 44 個の Claude Desktop プリセットを提供します。
|
||||
|
||||
リバースプロキシの信頼性については、本リリースで系統的なハードニングを行いました: P0–P3 の複数回にわたるルーティング / ライフサイクル / リトライ / フェイルオーバー / 補正器の修正; 非 Anthropic バックエンドで HTTPS コネクションプールを再利用してリクエスト単位のレイテンシを低減; Codex と OpenAI Responses のキャッシュヒット率改善(`prompt_cache_key` は本物のクライアントセッション識別子がある場合のみ送信、外部リクエストボディと `tool_call` 引数 / `tool_result` 内容の JSON キーを正規化してソート、`session_id` を Usage ロガーに通す); Anthropic ↔ OpenAI `tool_choice` の正しい相互変換; Vertex AI の完全な URL を切り詰めない; Gemini は URI パスからモデル名を抽出するように変更; Local Routing のテイクオーバー検出をより精緻化; IPv6 リッスンアドレスのサポート。Codex OAuth 系の Claude プロバイダーはハードコードされたモデルリストに依存しなくなり、CC Switch が必要に応じて ChatGPT バックエンドからライブモデルリストを取得します。
|
||||
|
||||
Claude Code のモデルマッピングはロールベース(`sonnet` / `opus` / `haiku`)+ 表示名に変更され、`supports1m` 真偽値フラグが導入されました。これは旧来の `[1M]` サフィックス記法に取って代わり、ルーティング判定と元のモデル ID を分離します。Usage ダッシュボードには**フィルター駆動 Hero カード**が追加され、キャッシュ正規化後の真の総トークン数とキャッシュヒット率を表示し、現在の日付範囲 / プロバイダー / モデルのフィルターに追従してリアルタイム更新します。あわせてキャッシュコストのセマンティクスエラーと、リクエストごとに発生していた pricing 警告ノイズを修正しました。OpenAI Responses API の usage 解析パスを堅牢化し、上流の欠損または不正な `usage` のせいで VSCode Claude Code プラグインが `null` 出力でクラッシュしないようにしました。
|
||||
|
||||
プロバイダーエコシステムはさらに拡張されました: BytePlus、Volcengine Agentplan、ClaudeAPI、ClaudeCN、RunAPI、RelaxyCode、PatewayAI、Baidu Qianfan Coding Plan のパートナープリセットを追加; Doubao Seed をパートナープリセットに昇格; プロバイダーカードに「Local Routing 対応」バッジを表示。本リリースでは、Codex セッション、OAuth、Claude Desktop フォーム、Linux segfault、ターミナルフォールバックなどに関する多くの細部の問題も修正し、複数の GitHub Actions 依存関係をアップグレードしました。
|
||||
|
||||
**リリース日**: 2026-05-16
|
||||
|
||||
**Stats**: 127 commits | 211 files changed | +17,980 insertions | -2,748 deletions
|
||||
|
||||
---
|
||||
|
||||
## ハイライト
|
||||
|
||||
- **Claude Desktop が一等管理パネルに**: 内蔵プロキシゲートウェイを介したサードパーティプロバイダーの切り替え、ロールベースのモデルマッピング(sonnet / opus / haiku)+ `supports1m` ロングコンテキストフラグ、Copilot / Codex OAuth プロバイダーの再利用、Claude Code プリセットディレクトリから翻訳された 44 個のプリセットを提供。注意: 20 個の Claude Desktop プリセットがデフォルトでプロキシモードから直接接続モードに切り替わったため、アップグレード後にプロキシルーティングに依存している場合は接続性を検証してください
|
||||
- **リバースプロキシの大幅強化**: P0–P3 のライフサイクル / リトライ / フェイルオーバー / 補正器の修正; 非 Anthropic バックエンドの HTTPS コネクションプール再利用; Codex / Responses キャッシュヒット率改善; Anthropic ↔ OpenAI `tool_choice` の正しいマッピング; Vertex AI URL の完全保持; Gemini パスベースのモデル抽出; テイクオーバー検出の精緻化; IPv6 リッスンアドレスのサポート
|
||||
- **プロバイダーエコシステムの拡張**: BytePlus、Volcengine Agentplan、ClaudeAPI、ClaudeCN、RunAPI、RelaxyCode、PatewayAI、Baidu Qianfan Coding Plan のパートナープリセットを追加; Doubao Seed をパートナーに昇格; プロバイダーカードに「ルーティングプロキシ対応」バッジを表示
|
||||
- **ロールベースのモデルマッピング + 1M フラグ**: ロールベースの sonnet / opus / haiku ルーティング + 表示名 + `supports1m` フラグ。旧来の `[1M]` サフィックスに取って代わる
|
||||
- **Codex OAuth ライブモデル検出**: ChatGPT Codex 系プロバイダーは必要に応じて ChatGPT バックエンドからライブモデルリストを取得
|
||||
- **Usage ダッシュボードのフィルター駆動 Hero**: キャッシュ正規化後の真の総トークン数とキャッシュヒット率を表示し、現在の日付 / プロバイダー / モデルフィルターに追従してリアルタイム更新
|
||||
- **DeepSeek ツール呼び出し + ゼロ usage 最終 delta**: DeepSeek のツール呼び出しが `reasoning_content` も返却するように (#2543, 感謝 @bling-yshs); 最終 `message_delta` は常に usage ブロックを含む(すべてゼロでも)ため、厳格な Anthropic クライアントが `null` でクラッシュしなくなった (#2485, 感謝 @Myoontyee)
|
||||
- **OpenAI Responses API usage 解析の堅牢化**: 上流の欠損または不正な usage によって VSCode Claude Code プラグインがクラッシュしないように (#2422, 感謝 @magucas)
|
||||
|
||||
---
|
||||
|
||||
## 追加機能
|
||||
|
||||
### Claude Desktop サードパーティプロバイダーのプロキシ切り替え
|
||||
|
||||
CC Switch は初めて **Claude Desktop** を一等管理対象パネルとして扱い、Claude Code / Codex / Gemini / OpenCode / OpenClaw / Hermes と並列に位置づけます。
|
||||
|
||||
- Claude Desktop 専用パネルを追加し、CC Switch 内蔵プロキシゲートウェイを介してサードパーティプロバイダーを Claude Desktop に代理転送
|
||||
- ルーティングプロキシを必要とするプロバイダーには、カード上に「Local Routing 対応」バッジを表示
|
||||
- ロールベースのモデルルーティングマッピングで `sonnet` / `opus` / `haiku` にロック
|
||||
- Copilot / Codex OAuth プロバイダーを Claude Desktop パネルで再利用可能
|
||||
- 再設計された Claude Code 設定インポートフロー
|
||||
- App スイッチャーで「Claude Code」と「Claude Desktop」を視覚的に区別、アプリ可視性設定では「Claude Code」ラベルを使用
|
||||
- Claude Code プリセットディレクトリから翻訳された 44 個の Claude Desktop プロバイダープリセット
|
||||
|
||||
### プロバイダーカード: ルーティングプロキシ対応バッジ
|
||||
|
||||
Claude Code と Codex パネルのプロバイダーカードに「ルーティングプロキシ対応」バッジを追加し、Local Routing 経由で提供可能なプロバイダーを一目で識別できるようにしました。
|
||||
|
||||
### Codex OAuth ライブモデルリスト
|
||||
|
||||
ChatGPT Codex 系プロバイダーはハードコードされたモデル選択に依存しなくなり、CC Switch が必要に応じて ChatGPT バックエンドから**ライブモデルリスト**を取得します。
|
||||
|
||||
### ロールベースのモデルマッピング + 1M フラグ
|
||||
|
||||
Claude Code のモデルマッピングはロールベース(`sonnet` / `opus` / `haiku`)+ 表示名に変更され、`supports1m` 真偽値フラグが導入されました。これは旧来の `[1M]` サフィックス記法に取って代わり、ルーティング判定と元のモデル ID を分離します。
|
||||
|
||||
### Usage ダッシュボードのフィルター駆動 Hero
|
||||
|
||||
Usage ダッシュボードの Hero サマリーがフィルター駆動になり、現在の日付範囲 / プロバイダー / モデルフィルターに追従してリアルタイムに変化します。**キャッシュ正規化後の真の総トークン数**とキャッシュヒット率を表示することで、Hero の数値が下部の詳細リストと整合するようになります。
|
||||
|
||||
### プロバイダーフォームの「とりあえず保存」
|
||||
|
||||
プロバイダーフォームの入力検証を緩和し、非ブロッキングな入力上の問題を「とりあえず保存」型のヒントに変更しました。無害な軽微なフィールド問題が原因で保存が阻まれることがなくなります (#2307, 感謝 @allenxln)。
|
||||
|
||||
### Universal プロバイダーの複製アクション
|
||||
|
||||
プロバイダーリスト内の universal プロバイダーに「複製」ボタンを追加しました (#2416, 感謝 @hubutui)。
|
||||
|
||||
### Tauri ウィンドウ状態の永続化
|
||||
|
||||
ウィンドウの位置とサイズが再起動をまたいで保持されるようになりました (#2377, 感謝 @BillSaul)。
|
||||
|
||||
### トレイアイコンのホバーヒント
|
||||
|
||||
システムトレイアイコンにホバー時のステータスヒントを表示するようになりました (#2417, 感謝 @Coconut-Fish)。
|
||||
|
||||
### Warp ターミナルセッション起動
|
||||
|
||||
Warp ターミナルのサポートを追加し、保存されたセッションを Warp で実行できるようになりました (#2466, 感謝 @tisonkun)。
|
||||
|
||||
### DeepSeek ツール呼び出し `reasoning_content`
|
||||
|
||||
DeepSeek のツール呼び出しレスポンスが `reasoning_content` と `tool_calls` を同時に返却するようになり、呼び出し側が両方を一緒にレンダリングできるようになりました (#2543, 感謝 @bling-yshs)。
|
||||
|
||||
### Baidu Qianfan Coding Plan(Claude Code)
|
||||
|
||||
Baidu Qianfan Coding Plan プリセットを追加しました (#2322, 感謝 @jimmyzhuu)。
|
||||
|
||||
### Compshare Coding Plan プリセット(クロスアプリ)
|
||||
|
||||
Compshare Coding Plan プリセットを claude / codex / hermes / openclaw の全アプリに展開しました。
|
||||
|
||||
### パートナープロバイダープリセット
|
||||
|
||||
**BytePlus**、**Volcengine Agentplan**、**ClaudeAPI**、**ClaudeCN**、**RunAPI**、**RelaxyCode**、**PatewayAI** のパートナープリセットを追加; **Doubao Seed** をパートナープリセットに昇格(エンドポイントとリンクをリフレッシュ)。
|
||||
|
||||
### 44 個の Claude Desktop プロバイダープリセット
|
||||
|
||||
Claude Code プリセットディレクトリから 44 個のプロバイダープリセットを翻訳し、新しい Claude Desktop パネルに投入しました。
|
||||
|
||||
---
|
||||
|
||||
## 変更
|
||||
|
||||
### 20 個の Claude Desktop プリセットがデフォルトで直接接続モードに
|
||||
|
||||
20 個の Claude Desktop プリセットがデフォルトでプロキシモードから直接接続モードに切り替わり、プロキシ互換シムを必要としないユーザーの導入摩擦を低減しました。アップグレード前にこれらのプリセットのプロキシルーティング経由の接続性に依存していた場合は、アップグレード後に検証してください。
|
||||
|
||||
### Claude Desktop の操作制約
|
||||
|
||||
Claude Desktop のプロバイダーを切り替えると CC Switch 管理の 3P プロファイルが書き込まれます。**Claude Desktop の再起動**が必要です; プロキシモードのプロバイダーは、使用中 CC Switch の Local Routing が動作し続けている必要があります。
|
||||
|
||||
### Failover / Local Routing 連動検証
|
||||
|
||||
Failover コントロールは、ターゲットアプリの Local Routing テイクオーバーが有効になっていないと開けないように変更しました。プロキシサービスのみを止めてもテイクオーバー状態に依存するアプリがある場合はブロックされ、「プロキシは止めたがアプリはまだテイクオーバー中と認識している」という不整合を回避します。
|
||||
|
||||
### Usage 統計のセマンティクス変更
|
||||
|
||||
Usage サマリーは**キャッシュ正規化後の真の総トークン数**と**キャッシュヒット率**を報告するようになりました。データの重複排除と価格再計算により、過去のトークン数とコスト数値は**ずれる可能性があります** — 新しい数値の方が正確ですが、旧バージョンの数値とは一致しません。
|
||||
|
||||
### プロバイダープリセットのレンダリング順序
|
||||
|
||||
プリセットリストは作者が定義した配列順序でレンダリングされるようになり、パートナーが先頭に並びます。以前の暗黙的なソートを置き換えます。
|
||||
|
||||
### モデルマッピングヒント文面の簡素化
|
||||
|
||||
`modelMappingOffHint` を中 / 英 / 日でアクション指向の簡潔な文面に書き直しました。
|
||||
|
||||
### CC Switch ブランド公式サイトを ccswitch.io に統一
|
||||
|
||||
アプリ内および README 内のすべての「公式サイト」参照を、唯一の公式サイトとして ccswitch.io に統一しました; Release notes テンプレートにも ccswitch.io を反映。
|
||||
|
||||
### テーマ切り替えの簡素化
|
||||
|
||||
テーマ切り替え時の円形拡散アニメーションを削除し、即座にクロスフェードする方式に変更しました。
|
||||
|
||||
### Claude Code App スイッチャーの視覚的な区別
|
||||
|
||||
App スイッチャーで「Claude Code」と「Claude Desktop」を視覚的に区別し、アプリ可視性設定では「Claude Code」ラベルを使用するようにしました。
|
||||
|
||||
### CI: Claude Review を Opus 4.7 にアップグレード
|
||||
|
||||
Claude review GitHub Action を Opus 4.7 にアップグレード; nitpick ノイズを減らすためプロンプトを調整; `@claude` レビュー専用 Code Action を追加; PR head SHA を checkout 用にロック; `--max-turns 5` 制限を削除。
|
||||
|
||||
### GitHub Actions 依存関係のアップグレード
|
||||
|
||||
- `actions/checkout` 4 → 6 (#2517)
|
||||
- `pnpm/action-setup` 5 → 6 (#2518)
|
||||
- `softprops/action-gh-release` 2 → 3 (#2519)
|
||||
- `actions/stale` 9 → 10 (#2520)
|
||||
|
||||
### DeepSeek プリセットを V4 に
|
||||
|
||||
DeepSeek プリセットが V4(flash / pro)+ リフレッシュされた価格シードを出荷するようになりました。
|
||||
|
||||
### Codex 1M コンテキストトグルを編集フォームから隠す
|
||||
|
||||
Codex プロバイダー編集フォームでは 1M コンテキストトグルを表示しなくなり、現在の Codex デプロイメントには実効性のないノブの密度を低減しました。
|
||||
|
||||
### OpenClaudeCode を MicuAPI ドメインに移行
|
||||
|
||||
OpenClaudeCode プリセットを MicuAPI ドメインに移行; Micu API リンクを `micuapi.ai` にリフレッシュ。
|
||||
|
||||
### CrazyRouter エンドポイントを `cn` サブドメインに切り替え
|
||||
|
||||
CrazyRouter プリセットのエンドポイントを `cn` サブドメインに変更しました。
|
||||
|
||||
### RelaxyCode カスタムアイコン
|
||||
|
||||
RelaxyCode プリセットのアイコンをカスタム `relaxcode.png` アセットに変更しました。
|
||||
|
||||
### Kimi For Coding ドキュメント URL
|
||||
|
||||
Kimi For Coding のウェブサイト URL を `/code/docs/` パスに更新しました。
|
||||
|
||||
### SiliconFlow 国際版で USD 表示
|
||||
|
||||
SiliconFlow 国際版の残高を正しく USD で表示するように修正しました(以前は誤って CNY と表示)。
|
||||
|
||||
---
|
||||
|
||||
## 修正
|
||||
|
||||
### OpenAI Responses API usage 解析の堅牢化
|
||||
|
||||
`build_anthropic_usage_from_responses()` と Responses → Anthropic SSE トランスレーターを強化し、上流の欠損または不正な `usage` が `message_delta` 内で `"usage": null` を生成しないようにしました。これにより、厳格な Anthropic クライアント(典型例: VSCode Claude Code プラグイン)が一部のプロバイダー(Codex OAuth、DashScope の `compatible-mode/v1/responses` エンドポイント)で `Cannot read properties of null (reading 'output_tokens')` でクラッシュしていた問題が解消されます。OpenAI フィールド名のフォールバック(`prompt_tokens` / `completion_tokens`)、null / 空 / 部分オブジェクトの処理、input/output tokens が欠損していても cache token フィールドを保持する処理を追加しました (#2422, 感謝 @magucas)。
|
||||
|
||||
### プロキシ信頼性パッチ(P0–P3)
|
||||
|
||||
request-forwarder パス全体で複数回にわたるルーティング / ライフサイクル / リトライ / 補正器の修正を実施; 共有された `handle_rectifier_retry_failure` ヘルパーと `auth_header_value` ヘルパーを抽出。
|
||||
|
||||
### プロキシ: 非 Anthropic バックエンドの HTTPS コネクションプール再利用
|
||||
|
||||
非 Anthropic バックエンドはプールされた HTTPS コネクションを再利用し、リクエストごとに新しい TLS セッションを開かなくなりました。リクエスト単位のレイテンシが大幅に低減します。
|
||||
|
||||
### プロキシ: クライアントの実際の HTTP メソッドを転送
|
||||
|
||||
`POST` のハードコーディングをやめ、クライアントの実際の HTTP メソッドに従って転送するようになりました; 上流の非 POST エンドポイント(例: GET `/v1/models`)が正常に動作します。
|
||||
|
||||
### プロキシ: 試行ごとのカウンター + `max_retries` の接続
|
||||
|
||||
クライアントリクエストカウンターを試行ごとのループから外に移動; `AppProxyConfig.max_retries` がリクエストフォワーダーに正しく接続されるようになりました。
|
||||
|
||||
### プロキシ: フェイルオーバー判定の精緻化
|
||||
|
||||
リクエストフォワーダー内でのリトライ可能 / 不可能エラーの分類がより正確になりました。
|
||||
|
||||
### プロキシ: テイクオーバー検出の精緻化
|
||||
|
||||
テイクオーバー検出をより厳密にしました; テイクオーバー OFF 時はフォールバック復旧パスを通り、残留状態によってプロバイダーが固まらないようにします。
|
||||
|
||||
### プロキシ: Anthropic ↔ OpenAI `tool_choice` の相互変換
|
||||
|
||||
フォーマット変換時に Anthropic の `tool_choice` を OpenAI Chat のネスト形式に正しくマッピングするようになりました。
|
||||
|
||||
### プロキシ: Gemini リクエストのモデルを URI パスから抽出
|
||||
|
||||
Gemini リクエストのモデルを URI パスから抽出するようになりました(body からは取らない)。変換後のトラフィックが正しいモデル名を報告します。
|
||||
|
||||
### プロキシ: 認証ヘッダーのエラー処理
|
||||
|
||||
`get_auth_headers` が `Result` を返すようになり、認証情報に問題がある場合にパニックしなくなりました。
|
||||
|
||||
### プロキシ: IPv6 リッスンアドレスの検証
|
||||
|
||||
プロキシパネルが IPv6 リッスンアドレスを受け付けるようになりました。
|
||||
|
||||
### プロキシ: Codex / Responses キャッシュヒット率の改善
|
||||
|
||||
安定したキャッシュキー導出によって Codex と OpenAI Responses リクエストのキャッシュヒット率を改善: クライアントが本当にセッション識別子を持参してきた場合にのみ `prompt_cache_key` を送信し、無関係な会話が同じキーに潰されないようにする; 外部リクエストボディと `tool_call` 引数 / `tool_result` 内容内の JSON キーを正規化してソートし、プレフィックスキャッシュがバイト単位でマッチできるようにする; `session_id` を usage ロガーに通してリクエストを関連付けする。
|
||||
|
||||
### プロキシ: JSON Schema のアンダースコアフィールド保持
|
||||
|
||||
プライベートパラメータフィルタリングが JSON Schema name map(`properties`、`patternProperties`、`definitions`、`$defs`)内のアンダースコア接頭辞のフィールド名を保持するようになりました。ユーザー定義 schema キー(`_id`、`_meta` など)がフィルターを正常に通り抜けられます。
|
||||
|
||||
### プロキシ: Read ツールの空白ページ除去
|
||||
|
||||
`Read` ツールの入力から空白ページを除去し、プロバイダーがリクエストを拒否しないようにしました (#2472, 感謝 @Kwensiu)。
|
||||
|
||||
### プロキシ: リクエスト単位のホットパス軽量化
|
||||
|
||||
リクエストごとのホットパスのオーバーヘッドとデータベース待ち時間を削減しました。
|
||||
|
||||
### プロキシ: テイクオーバー下で真のプロバイダーモデル名を表示
|
||||
|
||||
テイクオーバー実行時に、Claude Code メニューが古いエイリアスではなく真のプロバイダーモデル名を露出するようになりました。
|
||||
|
||||
### プロキシ: 最終 `message_delta` は常に usage を含む
|
||||
|
||||
最終 `message_delta` イベントには常に usage ブロックが含まれるようになりました(すべてゼロでも)。厳格な Anthropic クライアントが `null` でクラッシュしなくなります (#2485, 感謝 @Myoontyee)。
|
||||
|
||||
### プロキシ: ストリーミング `message_delta` の重複排除
|
||||
|
||||
一部の上流が二重に送信する `message_delta` イベントの重複排除を行います (#2366, 感謝 @codeasier)。
|
||||
|
||||
### プロキシ: ツール呼び出しパスでの `reasoning_content` 保持
|
||||
|
||||
ツール呼び出しパスの変換時に scoped `reasoning_content` フィールドを正しく保持するようにしました; Kimi / Moonshot の OpenAI Chat 互換パスではこのフィールドを保持し、汎用 OpenAI 互換リクエストでは引き続き付加しません (#2367, 感謝 @codeasier)。
|
||||
|
||||
### プロキシ: Vertex AI の完全 URL 保持
|
||||
|
||||
Vertex AI の完全 URL がプロキシ転送時に切り詰められないようにしました (#2415, 感謝 @xpfo-go)。
|
||||
|
||||
### プロキシ: system content 先頭の課金ヘッダーを除去
|
||||
|
||||
一部の上流が system message の先頭に挿入する課金ヘッダー内容を除去するようにしました (#2350)。
|
||||
|
||||
### プロキシ: Claude 認証ストラテジーを `ANTHROPIC_*` 環境変数名から導出
|
||||
|
||||
不透明なヒューリスティックに依存するのをやめ、認証ストラテジーを実際の `ANTHROPIC_*` 環境変数名から導出するようにしました。
|
||||
|
||||
### サードパーティ Claude プロバイダー: モデルテストの無効化
|
||||
|
||||
`/v1/models` を一貫して実装していないサードパーティ Claude ゲートウェイに対して、モデルプローブを無効化しました。
|
||||
|
||||
### Model-Fetch: Anthropic 互換サブパスプロバイダーの `/models`
|
||||
|
||||
`/models` ディスカバリーが Anthropic 互換のサブパスプロバイダーに対しても動作するようになりました。
|
||||
|
||||
### Copilot: Claude モデル ID をライブ `/models` と照合
|
||||
|
||||
Copilot バックエンドのプロバイダーはライブ `/models` リストを使って Claude モデル ID を照合し、古い ID の不整合を回避するようになりました。
|
||||
|
||||
### Codex: セッションタイトルが `environment_context` を取り込まないように
|
||||
|
||||
Codex のセッションタイトル抽出が `environment_context` のノイズを引き込まなくなりました (#2439, 感謝 @eclipsehx)。
|
||||
|
||||
### Codex: subagent セッションを非表示
|
||||
|
||||
Codex の subagent セッションをメインセッションリストから非表示にしました (#2445, 感謝 @LanternCX)。
|
||||
|
||||
### Codex 起動時の live import 重複排除
|
||||
|
||||
Codex 起動時の live import パスにおける重複インポートのバグを修正しました (#2590, 感謝 @DhruvShankpal)。
|
||||
|
||||
### Codex プロバイダー切り替えで履歴を擾乱しないように
|
||||
|
||||
アクティブな Codex プロバイダーの切り替えが既存のセッション履歴を変更しなくなりました (#2349, 感謝 @SaladDay)。
|
||||
|
||||
### Codex usage ログの文言修正
|
||||
|
||||
Codex セッション usage の誤解を招くログを 1 件修正しました (#2473, 感謝 @tisonkun)。
|
||||
|
||||
### Claude: `max` effort を env 経由で永続化
|
||||
|
||||
`max` effort が再起動をまたいで env 変数経由で正しく永続化されるようになりました (#2493, 感謝 @makoMakoGo)。
|
||||
|
||||
### Claude Desktop: モデルルーティングで `[1M]` サフィックスを要求しないように
|
||||
|
||||
ルーティングマッチングがレガシーな `[1M]` サフィックスを要求しなくなりました。
|
||||
|
||||
### Claude Desktop: プロバイダーフォームの入力フォーカス消失
|
||||
|
||||
Claude Desktop プロバイダーフォームで入力ボックス編集中にフォーカスを失う問題を修正しました。
|
||||
|
||||
### Claude Desktop: 偽の「プロキシ停止」ステータス通知
|
||||
|
||||
プロキシが能動的に停止された際に誤って発火するヒントを削除しました。
|
||||
|
||||
### Claude Desktop: 空のツールバーカプセル非表示
|
||||
|
||||
Claude Desktop がアクティブアプリの場合、空のツールバーカプセルを非表示にします。
|
||||
|
||||
### UI: Monitor バッジアイコンのセンタリング
|
||||
|
||||
App スイッチャー内の Monitor バッジアイコンをセンタリングしました。
|
||||
|
||||
### Linux: テーマ選択で segfault
|
||||
|
||||
Linux でテーマを選択した際の segfault を防止しました (#2502, 感謝 @definfo)。
|
||||
|
||||
### ターミナル: コールドスタート時の iTerm fallback
|
||||
|
||||
コールドスタート時に存在しない iTerm をフォールバックに選んでしまうのを防止しました (#2448, 感謝 @hulkbig)。
|
||||
|
||||
### 設定: JSON キーを辞書順でソート
|
||||
|
||||
設定の書き込みが JSON キーを辞書順にソートするようになり、出力が決定的になりました (#2469, 感謝 @fuleinist)。
|
||||
|
||||
### 「既存をインポート」を副作用なしに
|
||||
|
||||
「既存をインポート」操作を副作用なしに変更しました (#2429, 感謝 @xwil1)。
|
||||
|
||||
### Coding Plan: Zhipu の週次ウィンドウをリセット時刻で命名
|
||||
|
||||
Zhipu の週次ウィンドウのティア名を実際のリセット時刻に合うように修正しました (#2420, 感謝 @TuYv)。
|
||||
|
||||
### DashScope: usage 解析の堅牢化
|
||||
|
||||
DashScope の usage 解析を強化し、不正なペイロードが VSCode Claude Code プラグインをクラッシュさせないようにしました (#2425, 感謝 @magucas)。
|
||||
|
||||
### Usage: プロキシとセッションログの重複排除
|
||||
|
||||
プロキシとセッションログという 2 つのソースをまたいで usage レコードの重複排除を行います。
|
||||
|
||||
### Usage: キャッシュコストのセマンティクス + pricing 警告の嵐
|
||||
|
||||
キャッシュコストのセマンティクスを修正し、リクエストごとに発生していたノイズの多い pricing 警告を解消しました。
|
||||
|
||||
### CI: フロントエンドフォーマット + Linux clippy の復活
|
||||
|
||||
CI のフロントエンドフォーマットと Linux clippy の実行を復活させました。
|
||||
|
||||
### プロキシテストヘルパー clippy 警告
|
||||
|
||||
プロキシテストヘルパーの clippy 警告を 1 件修正しました。
|
||||
|
||||
---
|
||||
|
||||
## 削除
|
||||
|
||||
### Hermes Agent usage トラッキング統合
|
||||
|
||||
本サイクルでオンラインにする予定だった Hermes Agent usage トラッキング統合を削除しました — 上流の動作変更によって、この統合のメンテナンスが現実的でなくなりました。この統合は**いかなるリリース版でも有効化されたことはなく**; 開発過程で発見された「ゼロコストレンダリング」バグは統合をロールバックする前に修正済みです。
|
||||
|
||||
### テーマ切り替えの円形拡散アニメーション
|
||||
|
||||
テーマ切り替え時の円形拡散アニメーションを削除しました — 性能の弱いコンポジターでカクつき、視覚的なメリットが限定的でした。
|
||||
|
||||
### DDSHub パートナー統合
|
||||
|
||||
DDSHub をパートナープリセットから削除し、各 README 内の相互リンクセクションも削除しました。
|
||||
|
||||
---
|
||||
|
||||
## ドキュメント
|
||||
|
||||
### README スポンサー更新(中 / 英 / 日)
|
||||
|
||||
BytePlus、ClaudeCN、RunAPI、PatewayAI のスポンサーエントリを追加; BytePlus と Volcengine のエントリを相互リンク; CrazyRouter の $2 クレジット受領フロー、Compshare の説明、Right Code の説明、その他スポンサーのロゴおよびリストアイテムをリフレッシュ; LionCC のロゴを白背景にフラット化; 中国語 README のスポンサーロゴを Volcengine 画像に切り替え; README のサブタイトルに Hermes Agent を追加。
|
||||
|
||||
### Release notes テンプレート
|
||||
|
||||
Release notes テンプレート内に `ccswitch.io` を反映しました。
|
||||
|
||||
### ブランド公式サイト
|
||||
|
||||
各 README およびアプリ内参照で `ccswitch.io` を唯一の公式サイトとしてドキュメント化しました。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ アップグレード時の注意
|
||||
|
||||
### 20 個の Claude Desktop プリセットがデフォルトで直接接続モードに
|
||||
|
||||
これら 20 個のプリセットは以前はデフォルトでプロキシ経由でルーティングされていましたが、現在はデフォルトで直接接続です。アップグレード前にこのうちのいずれかを使用しており、かつプロキシルーティングの接続性に依存していた場合(例: プロキシに特殊な補正器や変換層がある場合)、接続性を検証してください; 必要に応じて、CC Switch パネル内で手動でプロキシモードに戻すことができます。
|
||||
|
||||
### Claude Desktop の操作制約
|
||||
|
||||
Claude Desktop プロバイダーの切り替えには、**Claude Desktop の再起動**が必要です; プロキシモードのプロバイダーは、使用中 CC Switch の Local Routing が動作し続けている必要があります — CC Switch を終了させたり Local Routing を停止させたりすると、プロキシモードの Claude Desktop プロバイダーへの接続が切断されます。
|
||||
|
||||
### Failover にはテイクオーバーの有効化が必要
|
||||
|
||||
Failover を有効化する前に、ターゲットアプリの Local Routing テイクオーバーが有効になっていることを確認してください。さもないと Failover コントロールは起動を拒否します; プロキシサービスを止めたいがテイクオーバーに依存するアプリがある場合はブロックされるため、アプリ層で先にテイクオーバーを止めてからプロキシを停止する必要があります。
|
||||
|
||||
### Usage 統計の数値が過去と一致しない可能性
|
||||
|
||||
Usage サマリーはキャッシュ正規化後の真の総トークン数 + キャッシュヒット率を使用するようになりました。データの重複排除と価格再計算により、過去のトークン数とコスト数値は**ずれる可能性があります** — 新しい数値の方が正確ですが、旧バージョンの数値とは一致しません。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ リスク通知
|
||||
|
||||
本リリースは、リバースプロキシ系機能について v3.12.3 / v3.13.0 で提起されたリスク通知を継承します。
|
||||
|
||||
**GitHub Copilot リバースプロキシ**: Copilot のリバースプロキシパスを使用すると、GitHub / Microsoft の利用規約に違反する可能性があります。詳細は [v3.12.3 リリースノート](v3.12.3-ja.md#️-リスクに関する注意事項) を参照してください。
|
||||
|
||||
**Codex OAuth リバースプロキシ**: ChatGPT サブスクリプションを使用した Codex OAuth リバースプロキシは、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 リリースノート](v3.13.0-ja.md#️-リスクに関する注意事項) を参照してください。
|
||||
|
||||
**Claude Desktop サードパーティプロバイダーのプロキシ切り替え**: CC Switch 内蔵プロキシゲートウェイ経由で Claude Desktop のリクエストをサードパーティプロバイダーに転送する際、サードパーティプロバイダーの課金、コンプライアンス、データ保持に関する制約はそれぞれ異なります。利用前にターゲットプロバイダーの利用規約をお読みください。
|
||||
|
||||
ユーザーが上記機能を有効化することで、**すべてのリスクを自己責任で**受諾したものとみなされます。CC Switch は、これらの機能の使用に起因するアカウントの制限、警告、サービス停止について一切の責任を負いません。
|
||||
|
||||
---
|
||||
|
||||
## ダウンロード・インストール
|
||||
|
||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から対応バージョンをダウンロードしてください。
|
||||
|
||||
### システム要件
|
||||
|
||||
| OS | 最小バージョン | アーキテクチャ |
|
||||
| ------- | ------------------------ | ----------------------------------- |
|
||||
| Windows | Windows 10 以降 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 下表参照 | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ---------------------------------------- | ------------------------------------------- |
|
||||
| `CC-Switch-v3.15.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
|
||||
| `CC-Switch-v3.15.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ不要 |
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| -------------------------------- | ------------------------------------------------------ |
|
||||
| `CC-Switch-v3.15.0-macOS.dmg` | **推奨** - DMG インストーラー、Applications にドラッグ |
|
||||
| `CC-Switch-v3.15.0-macOS.zip` | 解凍して Applications にドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.15.0-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||
|
||||
> macOS 版は Apple のコード署名および公証済みで、直接インストールして使用できます。
|
||||
|
||||
### Homebrew(macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
> Linux 向けの成果物は **x86_64** と **ARM64**(`aarch64`)の両方が提供されます。ファイル名にアーキテクチャ識別子が含まれているため、`uname -m` の出力に応じて選択してください:
|
||||
>
|
||||
> - `CC-Switch-v3.15.0-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
> - `CC-Switch-v3.15.0-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,498 @@
|
||||
# CC Switch v3.15.0
|
||||
|
||||
> Claude Desktop 升级为一等管理面板(含第三方供应商代理切换)、按角色的模型映射、反向代理大幅强化、Codex OAuth 实时模型发现、用量看板筛选驱动 Hero 卡
|
||||
|
||||
**[English →](v3.15.0-en.md) | [日本語版 →](v3.15.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.15.0 是 v3.14.x 之后的一次大版本更新,核心聚焦在**把 Claude Desktop 升级为一等管理面板**,并配套提供第三方供应商通过内置代理网关进行切换、按角色的模型映射(sonnet / opus / haiku)+ `supports1m` 长上下文标志、Copilot/Codex OAuth 供应商复用、重新设计的 Claude Code 导入流程、App 切换器对"Claude Code"和"Claude Desktop"的可视化区分,以及 44 个从 Claude Code 预设目录翻译而来的 Claude Desktop 预设。
|
||||
|
||||
围绕反向代理的可靠性,本版本进行了一次系统性硬化:P0–P3 多轮针对路由 / 生命周期 / 重试 / 故障转移 / 补正器的修补;非 Anthropic 后端启用 HTTPS 连接池复用以降低单请求延迟;Codex 与 OpenAI Responses 缓存命中率提升(`prompt_cache_key` 仅在有真实客户端会话标识时发送、对外请求体与 `tool_call` 参数 / `tool_result` 内容的 JSON key 规范化排序、`session_id` 串入用量记录器);Anthropic ↔ OpenAI `tool_choice` 正确互转;Vertex AI 完整 URL 不再被截断;Gemini 改为从 URI 路径提取模型名;Local Routing 接管检测更精细;可监听 IPv6 地址。Codex OAuth 类 Claude 供应商不再依赖硬编码的模型列表,CC Switch 会按需从 ChatGPT 后端拉取实时模型列表。
|
||||
|
||||
Claude Code 的模型映射改为基于角色(`sonnet` / `opus` / `haiku`)+ 显示名,并引入 `supports1m` 布尔标志,替代旧版的 `[1M]` 后缀写法,把路由决策与原始模型 ID 解耦。用量看板新增**筛选驱动的 Hero 卡**,展示缓存归一化后的真实总 token 与缓存命中率,并跟随当前日期范围 / 供应商 / 模型筛选实时更新;配套修复了缓存成本语义错误以及每个请求都触发的定价警告噪声。在 OpenAI Responses API usage 解析路径上做了鲁棒化处理,让上游缺失或畸形的 `usage` 不再让 VSCode Claude Code 插件因 `null` 输出崩溃。
|
||||
|
||||
供应商生态进一步扩张:新增 BytePlus、火山 Agentplan、ClaudeAPI、ClaudeCN、RunAPI、RelaxyCode、PatewayAI、百度千帆 Coding Plan 合作伙伴预设;豆包 Seed 升级为合作伙伴预设;供应商卡片现在会显示"是否支持 Local Routing"的徽章。本版本还修复了大量 Codex 会话、OAuth、Claude Desktop 表单、Linux 段错误、终端 fallback 等场景下的细节问题,并完成了多项 GitHub Actions 依赖升级。
|
||||
|
||||
**发布日期**:2026-05-16
|
||||
|
||||
**更新规模**:127 commits | 211 files changed | +17,980 / -2,748 lines
|
||||
|
||||
---
|
||||
|
||||
## 重点内容
|
||||
|
||||
- **Claude Desktop 成为一等管理面板**:通过内置代理网关提供第三方供应商切换、按角色的模型映射(sonnet / opus / haiku)+ `supports1m` 长上下文标志、Copilot/Codex OAuth 供应商复用、44 个从 Claude Code 预设目录翻译过来的预设。注意:20 个 Claude Desktop 预设默认从代理模式切到直连模式,升级后如依赖代理路由请验证连通性
|
||||
- **反向代理大幅强化**:P0–P3 生命周期 / 重试 / 故障转移 / 补正器修补;非 Anthropic 后端 HTTPS 连接池复用;Codex/Responses 缓存命中率提升;Anthropic ↔ OpenAI `tool_choice` 正确映射;Vertex AI URL 完整保留;Gemini 路径式模型提取;接管检测细化;支持 IPv6 监听地址
|
||||
- **供应商生态扩张**:新增 BytePlus、火山 Agentplan、ClaudeAPI、ClaudeCN、RunAPI、RelaxyCode、PatewayAI、百度千帆 Coding Plan 合作伙伴预设;豆包 Seed 升级合作伙伴;供应商卡片显示"路由代理支持"徽章
|
||||
- **按角色的模型映射 + 1M 标志**:基于角色的 sonnet / opus / haiku 路由 + 显示名 + `supports1m` 标志,替代旧的 `[1M]` 后缀
|
||||
- **Codex OAuth 实时模型发现**:ChatGPT Codex 类供应商按需从 ChatGPT 后端拉取实时模型列表
|
||||
- **用量看板筛选驱动 Hero**:展示缓存归一化的真实总 token 和缓存命中率,跟随当前日期 / 供应商 / 模型筛选实时更新
|
||||
- **DeepSeek 工具调用 + 零 usage 最终 delta**:DeepSeek 工具调用一并返回 `reasoning_content` (#2543, 感谢 @bling-yshs);最终 `message_delta` 总是带 usage 块(即便全为 0),严格 Anthropic 客户端不再因 `null` 崩溃 (#2485, 感谢 @Myoontyee)
|
||||
- **OpenAI Responses API usage 解析鲁棒化**:让上游缺失或畸形 usage 不再让 VSCode Claude Code 插件崩溃 (#2422, 感谢 @magucas)
|
||||
|
||||
---
|
||||
|
||||
## 新功能
|
||||
|
||||
### Claude Desktop 第三方供应商代理切换
|
||||
|
||||
CC Switch 第一次把 **Claude Desktop** 作为一等受管面板对待,与 Claude Code / Codex / Gemini / OpenCode / OpenClaw / Hermes 并列。
|
||||
|
||||
- 新增 Claude Desktop 专属面板,通过 CC Switch 内置代理网关把第三方供应商代理给 Claude Desktop
|
||||
- 为需要路由代理的供应商在卡片上呈现"是否支持 Local Routing"的徽章
|
||||
- 按角色的模型路由映射,锁定到 `sonnet` / `opus` / `haiku`
|
||||
- Copilot / Codex OAuth 供应商在 Claude Desktop 面板中可复用
|
||||
- 重新设计的 Claude Code 设置导入流程
|
||||
- App 切换器视觉区分"Claude Code"与"Claude Desktop",应用可见性设置中使用"Claude Code"标签
|
||||
- 44 个从 Claude Code 预设目录翻译过来的 Claude Desktop 供应商预设
|
||||
|
||||
### 供应商卡片:路由代理支持徽章
|
||||
|
||||
Claude Code 与 Codex 面板中的供应商卡片新增"路由代理支持"徽章,方便一眼识别哪些供应商可以通过 Local Routing 提供服务。
|
||||
|
||||
### Codex OAuth 实时模型列表
|
||||
|
||||
ChatGPT Codex 类供应商不再依赖硬编码的模型选择,CC Switch 会按需从 ChatGPT 后端拉取**实时模型列表**。
|
||||
|
||||
### 按角色的模型映射 + 1M 标志
|
||||
|
||||
Claude Code 模型映射改为基于角色(`sonnet` / `opus` / `haiku`)+ 显示名,并引入 `supports1m` 布尔标志,替代旧版 `[1M]` 后缀,把路由决策与原始模型 ID 解耦。
|
||||
|
||||
### 用量看板筛选驱动 Hero
|
||||
|
||||
用量看板的 Hero 摘要现在是筛选驱动的,跟随当前日期范围 / 供应商 / 模型筛选实时变化;展示**缓存归一化后的真实总 token**与缓存命中率,让 Hero 数字与下方明细列表对齐。
|
||||
|
||||
### 供应商表单"先存上再说"
|
||||
|
||||
软化供应商表单的输入校验,把非阻塞性的输入问题改为"先存上再说"的提示,不会因为一个无伤大雅的字段问题阻止保存 (#2307, 感谢 @allenxln)。
|
||||
|
||||
### Universal 供应商复制动作
|
||||
|
||||
为 universal 供应商在供应商列表中新增"复制"按钮 (#2416, 感谢 @hubutui)。
|
||||
|
||||
### 持久化 Tauri 窗口状态
|
||||
|
||||
窗口位置和尺寸现在跨重启保留 (#2377, 感谢 @BillSaul)。
|
||||
|
||||
### 托盘图标 hover 提示
|
||||
|
||||
系统托盘图标现在悬浮显示状态提示 (#2417, 感谢 @Coconut-Fish)。
|
||||
|
||||
### Warp 终端会话启动
|
||||
|
||||
新增对 Warp 终端的支持,可在 Warp 中执行保存的会话 (#2466, 感谢 @tisonkun)。
|
||||
|
||||
### DeepSeek 工具调用 `reasoning_content`
|
||||
|
||||
DeepSeek 工具调用响应现在同时返回 `reasoning_content` 和 `tool_calls`,调用方可以两者一并渲染 (#2543, 感谢 @bling-yshs)。
|
||||
|
||||
### 百度千帆 Coding Plan(Claude Code)
|
||||
|
||||
新增百度千帆 Coding Plan 预设 (#2322, 感谢 @jimmyzhuu)。
|
||||
|
||||
### Compshare Coding Plan 预设(跨应用)
|
||||
|
||||
Compshare Coding Plan 预设跨 claude / codex / hermes / openclaw 全应用就位。
|
||||
|
||||
### 合作伙伴供应商预设
|
||||
|
||||
新增 **BytePlus**、**火山 Agentplan**、**ClaudeAPI**、**ClaudeCN**、**RunAPI**、**RelaxyCode**、**PatewayAI** 合作伙伴预设;**豆包 Seed** 升级合作伙伴预设(端点和链接刷新)。
|
||||
|
||||
### 44 个 Claude Desktop 供应商预设
|
||||
|
||||
从 Claude Code 预设目录翻译 44 个供应商预设进入新的 Claude Desktop 面板。
|
||||
|
||||
---
|
||||
|
||||
## 变更
|
||||
|
||||
### 20 个 Claude Desktop 预设默认切到直连模式
|
||||
|
||||
20 个 Claude Desktop 预设默认从代理模式切到直连模式,降低对不需要代理兼容垫片的用户的上手摩擦。如果你升级前依赖代理路由这些预设的连通性,请升级后验证。
|
||||
|
||||
### Claude Desktop 操作约束
|
||||
|
||||
切换 Claude Desktop 供应商会写入 CC Switch 管理的 3P profile,**需要重启 Claude Desktop** 才能生效;代理模式的供应商在使用期间需要 CC Switch 的 Local Routing 保持运行。
|
||||
|
||||
### Failover / Local Routing 联动校验
|
||||
|
||||
Failover 控件现在要求目标应用的 Local Routing 接管已启用才能开启;只关代理服务但仍有应用依赖接管状态的情况会被拦下,避免出现"代理关了但应用仍以为接管在跑"的不一致。
|
||||
|
||||
### 用量统计语义变化
|
||||
|
||||
用量摘要现在报告**缓存归一化后的真实总 token**和**缓存命中率**。历史 token 与成本数字在数据去重 + 价格重算后**可能会有偏移**——新数字更准,但不会等于旧版给出的数字。
|
||||
|
||||
### 供应商预设渲染顺序
|
||||
|
||||
预设列表现在按作者定义的数组顺序渲染,合作伙伴排前面,替代之前的隐式排序。
|
||||
|
||||
### 模型映射提示文案简化
|
||||
|
||||
`modelMappingOffHint` 跨中 / 英 / 日重写为动作导向的简洁文案。
|
||||
|
||||
### CC Switch 品牌官网统一到 ccswitch.io
|
||||
|
||||
应用内和 README 中所有"官网"引用都统一到 ccswitch.io 作为唯一官方网站;Release notes 模板也呈现 ccswitch.io。
|
||||
|
||||
### 主题切换简化
|
||||
|
||||
移除主题切换时的圆形扩散动画,改为即时交叉淡入。
|
||||
|
||||
### Claude Code App 切换器视觉区分
|
||||
|
||||
App 切换器视觉上区分"Claude Code"和"Claude Desktop",应用可见性设置中使用"Claude Code"标签。
|
||||
|
||||
### CI:Claude Review 升级到 Opus 4.7
|
||||
|
||||
Claude review GitHub Action 升级到 Opus 4.7;调整 prompt 降低 nitpick 噪声;新增 `@claude` 仅 review 的 Code Action;锁定 PR head SHA 用于 checkout;移除 `--max-turns 5` 限制。
|
||||
|
||||
### GitHub Actions 依赖升级
|
||||
|
||||
- `actions/checkout` 4 → 6 (#2517)
|
||||
- `pnpm/action-setup` 5 → 6 (#2518)
|
||||
- `softprops/action-gh-release` 2 → 3 (#2519)
|
||||
- `actions/stale` 9 → 10 (#2520)
|
||||
|
||||
### DeepSeek 预设切到 V4
|
||||
|
||||
DeepSeek 预设现在出货 V4(flash / pro)+ 刷新定价种子。
|
||||
|
||||
### Codex 1M 上下文开关在编辑表单隐藏
|
||||
|
||||
Codex 供应商编辑表单中不再呈现 1M 上下文开关,降低对当前 Codex 部署无实际效果的旋钮密度。
|
||||
|
||||
### OpenClaudeCode 迁移到 MicuAPI 域名
|
||||
|
||||
OpenClaudeCode 预设迁移到 MicuAPI 域名;Micu API 链接刷新到 `micuapi.ai`。
|
||||
|
||||
### CrazyRouter 端点切到 `cn` 子域
|
||||
|
||||
CrazyRouter 预设端点改用 `cn` 子域。
|
||||
|
||||
### RelaxyCode 自定义图标
|
||||
|
||||
RelaxyCode 预设图标改用自定义 `relaxcode.png` 资源。
|
||||
|
||||
### Kimi For Coding 文档 URL
|
||||
|
||||
Kimi For Coding 网站 URL 更新到 `/code/docs/` 路径。
|
||||
|
||||
### SiliconFlow 国际站显示 USD
|
||||
|
||||
SiliconFlow 国际站的余额显示正确为 USD(之前错显 CNY)。
|
||||
|
||||
---
|
||||
|
||||
## 修复
|
||||
|
||||
### OpenAI Responses API usage 解析鲁棒化
|
||||
|
||||
强化 `build_anthropic_usage_from_responses()` 与 Responses → Anthropic SSE 翻译器,让上游缺失或畸形的 `usage` 不再在 `message_delta` 中产出 `"usage": null`。这解决了严格 Anthropic 客户端(典型如 VSCode Claude Code 插件)在某些供应商(Codex OAuth、DashScope 的 `compatible-mode/v1/responses` 端点)下崩在 `Cannot read properties of null (reading 'output_tokens')` 的问题。增加 OpenAI 字段名回退(`prompt_tokens` / `completion_tokens`)、null / 空 / 部分对象处理、即使 input/output tokens 缺失也保留缓存 token 字段 (#2422, 感谢 @magucas)。
|
||||
|
||||
### 代理可靠性补丁(P0–P3)
|
||||
|
||||
跨 request-forwarder 路径多轮路由 / 生命周期 / 重试 / 补正器修补;抽取共享的 `handle_rectifier_retry_failure` helper 与 `auth_header_value` helper。
|
||||
|
||||
### 代理:非 Anthropic 后端 HTTPS 连接池复用
|
||||
|
||||
非 Anthropic 后端复用池化的 HTTPS 连接,不再每个请求开新 TLS session,显著降低单请求延迟。
|
||||
|
||||
### 代理:转发客户端真实 HTTP 方法
|
||||
|
||||
不再硬编码 `POST`,按客户端实际的 HTTP 方法转发;上游的非 POST 端点(如 GET `/v1/models`)现在能正常工作。
|
||||
|
||||
### 代理:每次尝试计数器 + `max_retries` 接线
|
||||
|
||||
客户端请求计数器移出每次尝试的循环;`AppProxyConfig.max_retries` 现在正确接到请求转发器。
|
||||
|
||||
### 代理:故障转移判定细化
|
||||
|
||||
请求转发器中重试 / 不可重试错误的分类更准确。
|
||||
|
||||
### 代理:接管检测细化
|
||||
|
||||
接管检测更紧;关接管时走 fallback 恢复,避免遗留状态把供应商卡住。
|
||||
|
||||
### 代理:Anthropic ↔ OpenAI `tool_choice` 互转
|
||||
|
||||
格式转换时把 Anthropic 的 `tool_choice` 正确映射到 OpenAI Chat 的嵌套形式。
|
||||
|
||||
### 代理:Gemini 请求模型从 URI 路径提取
|
||||
|
||||
Gemini 请求模型从 URI 路径提取(不再从 body 取),转换后的流量上报正确的模型名。
|
||||
|
||||
### 代理:认证 header 错误处理
|
||||
|
||||
`get_auth_headers` 现在返回 `Result`,凭据有问题时不再 panic。
|
||||
|
||||
### 代理:IPv6 监听地址校验
|
||||
|
||||
代理面板现在接受 IPv6 监听地址。
|
||||
|
||||
### 代理:Codex / Responses 缓存命中率提升
|
||||
|
||||
通过稳定缓存键派生提高 Codex 与 OpenAI Responses 请求的缓存命中率;只在客户端确实带了会话标识时才发 `prompt_cache_key`,避免不相关对话被坍缩到同一个 key 上;对外请求体与 `tool_call` 参数 / `tool_result` 内容里的 JSON key 做规范化排序以便前缀缓存能字节级匹配;把 `session_id` 串到 usage 日志记录器做请求关联。
|
||||
|
||||
### 代理:JSON Schema 下划线字段保留
|
||||
|
||||
私参过滤现在保留 JSON Schema name map(`properties`、`patternProperties`、`definitions`、`$defs`)内的下划线前缀字段名,用户自定义 schema key(如 `_id`、`_meta`)能正常穿过过滤。
|
||||
|
||||
### 代理:Read 工具空白页剔除
|
||||
|
||||
从 `Read` 工具输入中剔除空白页,避免供应商拒绝请求 (#2472, 感谢 @Kwensiu)。
|
||||
|
||||
### 代理:单请求热路径瘦身
|
||||
|
||||
缩减每个请求的热路径开销和数据库等待时间。
|
||||
|
||||
### 代理:接管下展示真实供应商模型名
|
||||
|
||||
接管运行时,Claude Code 菜单现在暴露真实供应商模型名,不是陈旧的 alias。
|
||||
|
||||
### 代理:最终 `message_delta` 总是带 usage
|
||||
|
||||
最终 `message_delta` 事件现在总是包含 usage 块(即使全为 0),严格 Anthropic 客户端不再因为 `null` 崩溃 (#2485, 感谢 @Myoontyee)。
|
||||
|
||||
### 代理:流式 `message_delta` 去重
|
||||
|
||||
对某些上游会发两次的 `message_delta` 事件做去重 (#2366, 感谢 @codeasier)。
|
||||
|
||||
### 代理:工具调用路径的 `reasoning_content` 保留
|
||||
|
||||
工具调用路径转换时正确保留 scoped `reasoning_content` 字段;Kimi / Moonshot 的 OpenAI Chat 兼容路径保留该字段,通用 OpenAI 兼容请求保持不带 (#2367, 感谢 @codeasier)。
|
||||
|
||||
### 代理:Vertex AI 完整 URL 保留
|
||||
|
||||
Vertex AI 的完整 URL 在代理转发时不再被截断 (#2415, 感谢 @xpfo-go)。
|
||||
|
||||
### 代理:剥离 system content 开头的计费 header
|
||||
|
||||
某些上游会在 system message 开头插一段计费 header 内容,现在被剥离 (#2350)。
|
||||
|
||||
### 代理:Claude 鉴权策略从 `ANTHROPIC_*` 环境变量名派生
|
||||
|
||||
不再依赖不透明的启发式,鉴权策略从实际的 `ANTHROPIC_*` 环境变量名派生。
|
||||
|
||||
### 第三方 Claude 供应商:禁用模型测试
|
||||
|
||||
对那些不一致实现 `/v1/models` 的第三方 Claude 网关,关闭模型探测。
|
||||
|
||||
### Model-Fetch:Anthropic 兼容子路径供应商的 `/models`
|
||||
|
||||
`/models` 发现现在对 Anthropic 兼容的子路径供应商生效。
|
||||
|
||||
### Copilot:Claude 模型 ID 对比实时 `/models`
|
||||
|
||||
Copilot 后端的供应商现在用实时 `/models` 列表来比对 Claude 模型 ID,避免陈旧 ID 不一致。
|
||||
|
||||
### Codex:会话标题不再吸入 `environment_context`
|
||||
|
||||
Codex 会话标题提取不再把 `environment_context` 的噪声拉进来 (#2439, 感谢 @eclipsehx)。
|
||||
|
||||
### Codex:隐藏 subagent 会话
|
||||
|
||||
Codex subagent 会话从主会话列表隐藏 (#2445, 感谢 @LanternCX)。
|
||||
|
||||
### Codex 启动期 live import 去重
|
||||
|
||||
修复 Codex 启动期 live import 路径里的重复导入 bug (#2590, 感谢 @DhruvShankpal)。
|
||||
|
||||
### Codex 供应商切换不再扰动历史
|
||||
|
||||
切换激活 Codex 供应商不再改动现有会话历史 (#2349, 感谢 @SaladDay)。
|
||||
|
||||
### Codex 用量日志措辞修正
|
||||
|
||||
修正一条 Codex 会话用量的误导性日志 (#2473, 感谢 @tisonkun)。
|
||||
|
||||
### Claude:`max` effort 通过 env 持久化
|
||||
|
||||
`max` effort 现在能跨重启正确通过 env 变量持久化 (#2493, 感谢 @makoMakoGo)。
|
||||
|
||||
### Claude Desktop:模型路由不再要求 `[1M]` 后缀
|
||||
|
||||
路由匹配不再要求遗留的 `[1M]` 后缀。
|
||||
|
||||
### Claude Desktop:供应商表单输入失焦
|
||||
|
||||
修复 Claude Desktop 供应商表单中输入框编辑时丢失焦点的问题。
|
||||
|
||||
### Claude Desktop:假的"代理停止"状态提示
|
||||
|
||||
移除代理主动停止时假触发的提示。
|
||||
|
||||
### Claude Desktop:空工具栏胶囊隐藏
|
||||
|
||||
当 Claude Desktop 是激活应用时,空的工具栏胶囊会被隐藏。
|
||||
|
||||
### UI:Monitor 徽章图标居中
|
||||
|
||||
在 App 切换器里居中 Monitor 徽章图标。
|
||||
|
||||
### Linux:选择主题触发 segfault
|
||||
|
||||
防止在 Linux 上选择主题触发 segfault (#2502, 感谢 @definfo)。
|
||||
|
||||
### 终端:冷启动时 iTerm fallback
|
||||
|
||||
防止冷启动时把不存在的 iTerm 选为 fallback (#2448, 感谢 @hulkbig)。
|
||||
|
||||
### 配置:JSON key 字母序排序
|
||||
|
||||
配置写入现在按字母序排 JSON key,输出确定 (#2469, 感谢 @fuleinist)。
|
||||
|
||||
### "导入已有"无副作用化
|
||||
|
||||
"导入已有"操作改为无副作用 (#2429, 感谢 @xwil1)。
|
||||
|
||||
### Coding Plan:智谱周窗口按重置时间命名
|
||||
|
||||
修正智谱周窗口的等级名以匹配实际重置时间 (#2420, 感谢 @TuYv)。
|
||||
|
||||
### DashScope:usage 解析鲁棒化
|
||||
|
||||
强化 DashScope usage 解析,畸形 payload 不会再让 VSCode Claude Code 插件崩 (#2425, 感谢 @magucas)。
|
||||
|
||||
### 用量:代理和会话日志去重
|
||||
|
||||
跨代理和会话日志两个来源的用量记录去重。
|
||||
|
||||
### 用量:缓存成本语义 + 定价警告风暴
|
||||
|
||||
修正缓存成本语义,并消除每个请求都触发的噪声定价警告。
|
||||
|
||||
### CI:前端格式化 + Linux clippy 恢复
|
||||
|
||||
恢复前端格式化与 Linux clippy 在 CI 中的运行。
|
||||
|
||||
### 代理测试 helper clippy 警告
|
||||
|
||||
修复代理测试 helper 中的一个 clippy 警告。
|
||||
|
||||
---
|
||||
|
||||
## 移除
|
||||
|
||||
### Hermes Agent 用量追踪集成
|
||||
|
||||
移除原本计划在本周期上线的 Hermes Agent 用量追踪集成——上游行为变化让维护这个集成变得不切实际。该集成**从未在任何已发布版本中启用**;开发过程中发现的"零成本渲染" bug 在回滚集成前已经修复。
|
||||
|
||||
### 主题切换圆形扩散动画
|
||||
|
||||
移除主题切换时的圆形扩散动画——在性能较弱的合成器上会卡顿,视觉收益有限。
|
||||
|
||||
### DDSHub 合作伙伴整合
|
||||
|
||||
移除 DDSHub 作为合作伙伴预设,并删除各 README 中的交叉链接段落。
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
### README 赞助商刷新(中 / 英 / 日)
|
||||
|
||||
新增 BytePlus、ClaudeCN、RunAPI、PatewayAI 赞助商条目;交叉链接 BytePlus 与火山条目;刷新 CrazyRouter 的 $2 额度领取流程、Compshare 描述、Right Code 描述、其他赞助商的 logo 与列表项;把 LionCC logo 抹平到白底;中文 README 的赞助商 logo 切到火山图;在 README 副标题中加入 Hermes Agent。
|
||||
|
||||
### Release notes 模板
|
||||
|
||||
Release notes 模板中呈现 `ccswitch.io`。
|
||||
|
||||
### 品牌官网
|
||||
|
||||
在各 README 与应用内引用中把 `ccswitch.io` 文档化为唯一官方网站。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 升级提醒
|
||||
|
||||
### 20 个 Claude Desktop 预设默认切到直连模式
|
||||
|
||||
这 20 个预设之前默认通过代理路由,现在默认直连。如果你升级前正好用着其中某个、又依赖代理路由的连通性(比如代理里有特殊补正器或转换层),请验证一下连通性;如有需要,可在 CC Switch 面板里手动切回代理模式。
|
||||
|
||||
### Claude Desktop 操作约束
|
||||
|
||||
切换 Claude Desktop 供应商需要**重启 Claude Desktop** 才能生效;代理模式的供应商在使用期间需要 CC Switch 的 Local Routing 保持运行——退出 CC Switch 或停止 Local Routing 会让代理模式的 Claude Desktop 供应商断流。
|
||||
|
||||
### Failover 需要接管启用
|
||||
|
||||
启用 Failover 前请先确认目标应用的 Local Routing 接管已开启,否则 Failover 控件会拒绝启动;想关代理服务但仍有应用依赖接管的情况会被拦下,需要先在应用层关掉接管再停代理。
|
||||
|
||||
### 用量统计数字可能与历史不一致
|
||||
|
||||
用量摘要现在用缓存归一化的真实总 token + 缓存命中率。历史 token 与成本数字在数据去重 + 价格重算后**可能会有偏移**——新数字更准,但不会等于旧版给出的数字。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 风险提示
|
||||
|
||||
本版本在涉及反向代理类功能上沿用 v3.12.3 / v3.13.0 提出的风险提示。
|
||||
|
||||
**GitHub Copilot 反向代理**:使用 Copilot 的反代路径可能违反 GitHub / Microsoft 服务条款。详情见 [v3.12.3 release notes](v3.12.3-zh.md#️-风险提示)。
|
||||
|
||||
**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#️-风险提示)。
|
||||
|
||||
**Claude Desktop 第三方供应商代理切换**:通过 CC Switch 内置代理网关把 Claude Desktop 的请求转到第三方供应商时,第三方供应商对计费、合规与数据留存的约束各不相同,请在使用前阅读目标供应商的服务条款。
|
||||
|
||||
用户启用上述功能即表示**自行承担所有风险**。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | -------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------------- | ----------------------------------- |
|
||||
| `CC-Switch-v3.15.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.15.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| -------------------------------- | --------------------------------------------- |
|
||||
| `CC-Switch-v3.15.0-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
|
||||
| `CC-Switch-v3.15.0-macOS.zip` | 解压后拖入 Applications,Universal Binary |
|
||||
| `CC-Switch-v3.15.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
> macOS 版本已通过 Apple 代码签名和公证,可直接安装使用。
|
||||
|
||||
### Homebrew(macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
> Linux 资产同时提供 **x86_64** 和 **ARM64**(`aarch64`)两种架构。资产文件名中包含架构标识,请按你机器的 `uname -m` 输出选择对应版本:
|
||||
>
|
||||
> - `CC-Switch-v3.15.0-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
> - `CC-Switch-v3.15.0-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
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.14.1",
|
||||
"version": "3.15.0",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -735,7 +735,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.14.1"
|
||||
version = "3.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.14.1"
|
||||
version = "3.15.0"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -21,14 +21,59 @@ const CONFIG_LIBRARY_DIR: &str = "configLibrary";
|
||||
const GATEWAY_TOKEN_SETTING_KEY: &str = "claude_desktop_gateway_token";
|
||||
const CLAUDE_DESKTOP_PROXY_PREFIX: &str = "/claude-desktop";
|
||||
const DEFAULT_CREATED_AT: &str = "2024-01-01T00:00:00Z";
|
||||
const ONE_M_CONTEXT_SUFFIX: &str = " [1M]";
|
||||
|
||||
/// Claude Desktop 模型菜单识别的 route ID 前缀。
|
||||
pub const CLAUDE_ROUTE_PREFIX: &str = "claude-";
|
||||
/// 替代前缀(与前端 `ANTHROPIC_CLAUDE_ROUTE_PREFIX` 一致)。
|
||||
pub const ANTHROPIC_CLAUDE_ROUTE_PREFIX: &str = "anthropic/claude-";
|
||||
/// Claude Code env 中通过 `[1M]` 后缀声明 1M 上下文能力(匹配用 `eq_ignore_ascii_case`)。
|
||||
/// Claude Desktop schema 不接受此后缀,import 边界翻译为 `supports1m` 字段。
|
||||
pub const ONE_M_CONTEXT_MARKER: &str = "[1m]";
|
||||
|
||||
const NON_ANTHROPIC_ROUTE_MARKERS: &[&str] = &[
|
||||
"ark-code",
|
||||
"astron",
|
||||
"command-r",
|
||||
"deepseek",
|
||||
"doubao",
|
||||
"gemini",
|
||||
"gemma",
|
||||
"glm",
|
||||
"gpt",
|
||||
"grok",
|
||||
"hermes",
|
||||
"hy3",
|
||||
"kimi",
|
||||
"lfm",
|
||||
"llama",
|
||||
"longcat",
|
||||
"mimo",
|
||||
"minimax",
|
||||
"mistral",
|
||||
"mixtral",
|
||||
"moonshot",
|
||||
"nemotron",
|
||||
"openai",
|
||||
"qianfan",
|
||||
"qwen",
|
||||
"stepfun",
|
||||
"seed-",
|
||||
"hunyuan",
|
||||
"nova-",
|
||||
"ernie",
|
||||
"codex",
|
||||
"abab",
|
||||
"jamba",
|
||||
"arctic",
|
||||
"solar",
|
||||
"mercury",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClaudeDesktopDefaultRoute {
|
||||
pub route_id: &'static str,
|
||||
pub env_key: &'static str,
|
||||
pub display_name: &'static str,
|
||||
#[serde(rename = "supports1m")]
|
||||
pub supports_1m: bool,
|
||||
}
|
||||
@@ -37,19 +82,16 @@ pub const DEFAULT_PROXY_ROUTES: &[ClaudeDesktopDefaultRoute] = &[
|
||||
ClaudeDesktopDefaultRoute {
|
||||
route_id: "claude-sonnet-4-6",
|
||||
env_key: "ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
display_name: "Sonnet",
|
||||
supports_1m: true,
|
||||
},
|
||||
ClaudeDesktopDefaultRoute {
|
||||
route_id: "claude-opus-4-7",
|
||||
env_key: "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
display_name: "Opus",
|
||||
supports_1m: true,
|
||||
},
|
||||
ClaudeDesktopDefaultRoute {
|
||||
route_id: "claude-haiku-4-5",
|
||||
env_key: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
display_name: "Haiku",
|
||||
supports_1m: true,
|
||||
},
|
||||
];
|
||||
@@ -96,10 +138,17 @@ pub struct ClaudeDesktopStatus {
|
||||
pub struct ResolvedModelRoute {
|
||||
pub route_id: String,
|
||||
pub upstream_model: String,
|
||||
pub display_name: Option<String>,
|
||||
pub label_override: Option<String>,
|
||||
pub supports_1m: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct InferenceModelSpec {
|
||||
name: String,
|
||||
label_override: Option<String>,
|
||||
supports_1m: bool,
|
||||
}
|
||||
|
||||
pub fn apply_provider(db: &Database, provider: &Provider) -> Result<(), AppError> {
|
||||
let paths = current_platform_paths()?;
|
||||
apply_provider_to_paths(db, provider, &paths)
|
||||
@@ -209,39 +258,37 @@ pub fn provider_mode(provider: &Provider) -> ClaudeDesktopMode {
|
||||
}
|
||||
|
||||
pub fn is_claude_safe_model_id(model: &str) -> bool {
|
||||
let normalized = strip_one_m_context_suffix(model).to_ascii_lowercase();
|
||||
normalized.starts_with("claude-") || normalized.starts_with("anthropic/claude-")
|
||||
let normalized = model.trim().to_ascii_lowercase();
|
||||
let has_allowed_shape = (normalized.starts_with(CLAUDE_ROUTE_PREFIX)
|
||||
&& normalized.len() > CLAUDE_ROUTE_PREFIX.len())
|
||||
|| (normalized.starts_with(ANTHROPIC_CLAUDE_ROUTE_PREFIX)
|
||||
&& normalized.len() > ANTHROPIC_CLAUDE_ROUTE_PREFIX.len())
|
||||
|| matches!(normalized.as_str(), "sonnet" | "opus" | "haiku")
|
||||
|| (normalized.starts_with("sonnet-") && normalized.len() > "sonnet-".len())
|
||||
|| (normalized.starts_with("opus-") && normalized.len() > "opus-".len())
|
||||
|| (normalized.starts_with("haiku-") && normalized.len() > "haiku-".len());
|
||||
has_allowed_shape
|
||||
&& !normalized.contains(ONE_M_CONTEXT_MARKER)
|
||||
&& !NON_ANTHROPIC_ROUTE_MARKERS
|
||||
.iter()
|
||||
.any(|marker| normalized.contains(marker))
|
||||
}
|
||||
|
||||
fn strip_one_m_context_suffix(model: &str) -> String {
|
||||
let trimmed = model.trim();
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if lower.ends_with("[1m]") {
|
||||
trimmed[..trimmed.len().saturating_sub("[1M]".len())]
|
||||
.trim_end()
|
||||
.to_string()
|
||||
fn inference_model_json(spec: &InferenceModelSpec) -> Value {
|
||||
if spec.supports_1m || spec.label_override.is_some() {
|
||||
let mut item = json!({ "name": spec.name });
|
||||
if let Some(label_override) = spec.label_override.as_deref() {
|
||||
item["labelOverride"] = json!(label_override);
|
||||
}
|
||||
if spec.supports_1m {
|
||||
item["supports1m"] = json!(true);
|
||||
}
|
||||
item
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
Value::String(spec.name.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn has_one_m_context_suffix(model: &str) -> bool {
|
||||
model.trim().to_ascii_lowercase().ends_with("[1m]")
|
||||
}
|
||||
|
||||
fn desktop_model_id(model_id: &str, supports_1m: bool) -> String {
|
||||
let normalized = strip_one_m_context_suffix(model_id);
|
||||
if supports_1m {
|
||||
format!("{normalized}{ONE_M_CONTEXT_SUFFIX}")
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
fn upstream_model_id(model_id: &str, supports_1m: bool) -> String {
|
||||
desktop_model_id(model_id, supports_1m)
|
||||
}
|
||||
|
||||
pub fn get_or_create_gateway_token(db: &Database) -> Result<String, AppError> {
|
||||
if let Some(token) = db.get_setting(GATEWAY_TOKEN_SETTING_KEY)? {
|
||||
let trimmed = token.trim();
|
||||
@@ -356,7 +403,7 @@ pub fn validate_direct_provider(provider: &Provider) -> Result<(), AppError> {
|
||||
}
|
||||
}
|
||||
|
||||
direct_inference_model_ids(provider)?;
|
||||
direct_inference_model_specs(provider)?;
|
||||
direct_gateway_credentials(provider)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -375,17 +422,6 @@ pub fn validate_proxy_provider(provider: &Provider) -> Result<(), AppError> {
|
||||
}
|
||||
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if matches!(
|
||||
meta.provider_type.as_deref(),
|
||||
Some("github_copilot") | Some("codex_oauth")
|
||||
) {
|
||||
return Err(AppError::localized(
|
||||
"claude_desktop.provider.type_unsupported",
|
||||
"Claude Desktop 本地路由模式暂不支持 Copilot 或 Codex OAuth 供应商",
|
||||
"Claude Desktop proxy mode does not support Copilot or Codex OAuth providers yet",
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(api_format) = meta.api_format.as_deref() {
|
||||
if !matches!(
|
||||
api_format,
|
||||
@@ -424,6 +460,10 @@ fn has_proxy_base_url_and_key(provider: &Provider) -> bool {
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
|
||||
if is_managed_oauth_proxy_provider(provider) {
|
||||
return has_base_url;
|
||||
}
|
||||
|
||||
let has_key = env
|
||||
.and_then(|value| {
|
||||
[
|
||||
@@ -445,6 +485,14 @@ fn has_proxy_base_url_and_key(provider: &Provider) -> bool {
|
||||
has_base_url && has_key
|
||||
}
|
||||
|
||||
fn is_managed_oauth_proxy_provider(provider: &Provider) -> bool {
|
||||
provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.provider_type.as_deref())
|
||||
.is_some_and(|provider_type| matches!(provider_type, "github_copilot" | "codex_oauth"))
|
||||
}
|
||||
|
||||
pub fn validate_provider(provider: &Provider) -> Result<(), AppError> {
|
||||
if is_official_provider(provider) {
|
||||
return Ok(());
|
||||
@@ -456,7 +504,7 @@ pub fn validate_provider(provider: &Provider) -> Result<(), AppError> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn direct_inference_model_ids(provider: &Provider) -> Result<Vec<String>, AppError> {
|
||||
fn direct_inference_model_specs(provider: &Provider) -> Result<Vec<InferenceModelSpec>, AppError> {
|
||||
let Some(routes) = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
@@ -467,23 +515,42 @@ pub fn direct_inference_model_ids(provider: &Provider) -> Result<Vec<String>, Ap
|
||||
|
||||
let mut result = Vec::new();
|
||||
for (route_id, route) in routes {
|
||||
let supports_1m = route.supports_1m.unwrap_or(false) || has_one_m_context_suffix(route_id);
|
||||
let route_id = strip_one_m_context_suffix(route_id);
|
||||
let supports_1m = route.supports_1m.unwrap_or(false);
|
||||
let route_id = route_id.trim();
|
||||
if route_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !is_claude_safe_model_id(&route_id) {
|
||||
if !is_claude_safe_model_id(route_id) {
|
||||
return Err(AppError::localized(
|
||||
"claude_desktop.provider.route_invalid",
|
||||
format!("Claude Desktop 直连模型必须使用 claude-* 或 anthropic/claude-* 名称: {route_id}"),
|
||||
format!("Claude Desktop direct model must use a claude-* or anthropic/claude-* name: {route_id}"),
|
||||
format!(
|
||||
"Claude Desktop 直连模型必须使用 claude-* 或 anthropic/claude-* 名称: {route_id}"
|
||||
),
|
||||
format!(
|
||||
"Claude Desktop direct model must use a claude-* or anthropic/claude-* name: {route_id}"
|
||||
),
|
||||
));
|
||||
}
|
||||
result.push(desktop_model_id(&route_id, supports_1m));
|
||||
result.push(InferenceModelSpec {
|
||||
name: route_id.to_string(),
|
||||
label_override: route
|
||||
.label_override
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string),
|
||||
supports_1m,
|
||||
});
|
||||
}
|
||||
|
||||
result.sort();
|
||||
result.dedup();
|
||||
// Sort supports_1m=true first within each name so the subsequent dedup_by
|
||||
// (which keeps the first occurrence) preserves the 1M-capable variant.
|
||||
result.sort_by(|a, b| {
|
||||
a.name
|
||||
.cmp(&b.name)
|
||||
.then_with(|| b.supports_1m.cmp(&a.supports_1m))
|
||||
});
|
||||
result.dedup_by(|a, b| a.name == b.name);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -500,25 +567,39 @@ pub fn proxy_model_routes(provider: &Provider) -> Result<Vec<ResolvedModelRoute>
|
||||
)
|
||||
})?;
|
||||
|
||||
let reserved_route_ids = routes
|
||||
.keys()
|
||||
.map(|route_id| route_id.trim())
|
||||
.filter(|route_id| is_claude_safe_model_id(route_id))
|
||||
.map(str::to_string)
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
let mut result = Vec::new();
|
||||
for (route_id, route) in routes {
|
||||
let supports_1m = route.supports_1m.unwrap_or(false) || has_one_m_context_suffix(route_id);
|
||||
let route_id = strip_one_m_context_suffix(route_id);
|
||||
let mut entries = routes.iter().collect::<Vec<_>>();
|
||||
entries.sort_by_key(|(left, _)| *left);
|
||||
for (route_id, route) in entries {
|
||||
let supports_1m = route.supports_1m.unwrap_or(false);
|
||||
let route_id = route_id.trim();
|
||||
let upstream_model = route.model.trim();
|
||||
if route_id.is_empty() || upstream_model.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !is_claude_safe_model_id(&route_id) {
|
||||
return Err(AppError::localized(
|
||||
"claude_desktop.provider.route_invalid",
|
||||
format!("Claude Desktop 模型路由必须使用 claude-* 或 anthropic/claude-* 名称: {route_id}"),
|
||||
format!("Claude Desktop model route must use a claude-* or anthropic/claude-* name: {route_id}"),
|
||||
));
|
||||
}
|
||||
let repaired_route_id = if is_claude_safe_model_id(route_id) {
|
||||
route_id.to_string()
|
||||
} else {
|
||||
next_catalog_safe_route_id(&result, &reserved_route_ids)
|
||||
};
|
||||
result.push(ResolvedModelRoute {
|
||||
route_id: desktop_model_id(&route_id, supports_1m),
|
||||
upstream_model: upstream_model_id(upstream_model, supports_1m),
|
||||
display_name: route.display_name.clone(),
|
||||
route_id: repaired_route_id,
|
||||
upstream_model: upstream_model.to_string(),
|
||||
label_override: route
|
||||
.label_override
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
(!is_claude_safe_model_id(route_id)).then(|| upstream_model.to_string())
|
||||
}),
|
||||
supports_1m,
|
||||
});
|
||||
}
|
||||
@@ -537,16 +618,41 @@ pub fn proxy_model_routes(provider: &Provider) -> Result<Vec<ResolvedModelRoute>
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn next_catalog_safe_route_id(
|
||||
existing: &[ResolvedModelRoute],
|
||||
reserved: &std::collections::HashSet<String>,
|
||||
) -> String {
|
||||
if let Some(default_route) = DEFAULT_PROXY_ROUTES
|
||||
.iter()
|
||||
.map(|route| route.route_id)
|
||||
.find(|route_id| {
|
||||
!reserved.contains(*route_id)
|
||||
&& !existing.iter().any(|route| route.route_id == *route_id)
|
||||
})
|
||||
{
|
||||
return default_route.to_string();
|
||||
}
|
||||
|
||||
let mut index = 2usize;
|
||||
loop {
|
||||
let route_id = format!("{}-r{index}", DEFAULT_PROXY_ROUTES[0].route_id);
|
||||
if !reserved.contains(&route_id) && !existing.iter().any(|route| route.route_id == route_id)
|
||||
{
|
||||
return route_id;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model_list_response(provider: &Provider) -> Result<Value, AppError> {
|
||||
let routes = proxy_model_routes(provider)?;
|
||||
let data: Vec<Value> = routes
|
||||
.iter()
|
||||
.map(|route| {
|
||||
let model_id = desktop_model_id(&route.route_id, route.supports_1m);
|
||||
let model_id = route.route_id.clone();
|
||||
let mut item = json!({
|
||||
"type": "model",
|
||||
"id": model_id,
|
||||
"display_name": route.display_name.as_deref().unwrap_or(&route.route_id),
|
||||
"created_at": DEFAULT_CREATED_AT,
|
||||
});
|
||||
if route.supports_1m {
|
||||
@@ -590,12 +696,7 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
|
||||
})?;
|
||||
|
||||
let routes = proxy_model_routes(provider)?;
|
||||
let route = routes.iter().find(|r| r.route_id == requested).or_else(|| {
|
||||
let base = strip_one_m_context_suffix(&requested);
|
||||
routes
|
||||
.iter()
|
||||
.find(|r| strip_one_m_context_suffix(&r.route_id) == base)
|
||||
});
|
||||
let route = routes.iter().find(|r| r.route_id == requested);
|
||||
let Some(route) = route else {
|
||||
return Err(AppError::localized(
|
||||
"claude_desktop.provider.route_unknown",
|
||||
@@ -665,22 +766,26 @@ fn apply_provider_to_paths_inner(
|
||||
let profile = match provider_mode(provider) {
|
||||
ClaudeDesktopMode::Direct => {
|
||||
let credentials = direct_gateway_credentials(provider)?;
|
||||
let model_ids = direct_inference_model_ids(provider)?;
|
||||
let model_specs = direct_inference_model_specs(provider)?;
|
||||
build_gateway_profile(
|
||||
&credentials.base_url,
|
||||
&credentials.api_key,
|
||||
(!model_ids.is_empty()).then_some(model_ids.as_slice()),
|
||||
(!model_specs.is_empty()).then_some(model_specs.as_slice()),
|
||||
)
|
||||
}
|
||||
ClaudeDesktopMode::Proxy => {
|
||||
let base_url = proxy_gateway_base_url_from_db(db)?;
|
||||
let api_key = get_or_create_gateway_token(db)?;
|
||||
let routes = proxy_model_routes(provider)?;
|
||||
let model_ids = routes
|
||||
let model_specs = routes
|
||||
.iter()
|
||||
.map(|route| desktop_model_id(&route.route_id, route.supports_1m))
|
||||
.map(|route| InferenceModelSpec {
|
||||
name: route.route_id.clone(),
|
||||
label_override: route.label_override.clone(),
|
||||
supports_1m: route.supports_1m,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
build_gateway_profile(&base_url, &api_key, Some(model_ids.as_slice()))
|
||||
build_gateway_profile(&base_url, &api_key, Some(model_specs.as_slice()))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -705,7 +810,11 @@ fn restore_official_at_paths_inner(paths: &ClaudeDesktopPaths) -> Result<(), App
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_gateway_profile(base_url: &str, api_key: &str, model_ids: Option<&[String]>) -> Value {
|
||||
fn build_gateway_profile(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
model_specs: Option<&[InferenceModelSpec]>,
|
||||
) -> Value {
|
||||
let mut profile = json!({
|
||||
"disableDeploymentModeChooser": true,
|
||||
"inferenceGatewayApiKey": api_key,
|
||||
@@ -714,13 +823,9 @@ fn build_gateway_profile(base_url: &str, api_key: &str, model_ids: Option<&[Stri
|
||||
"inferenceProvider": "gateway"
|
||||
});
|
||||
|
||||
if let Some(model_ids) = model_ids {
|
||||
profile["inferenceModels"] = Value::Array(
|
||||
model_ids
|
||||
.iter()
|
||||
.map(|model_id| Value::String(model_id.clone()))
|
||||
.collect(),
|
||||
);
|
||||
if let Some(model_specs) = model_specs {
|
||||
profile["inferenceModels"] =
|
||||
Value::Array(model_specs.iter().map(inference_model_json).collect());
|
||||
}
|
||||
|
||||
profile
|
||||
@@ -1065,7 +1170,7 @@ mod tests {
|
||||
"claude-sonnet-4-6".to_string(),
|
||||
ClaudeDesktopModelRoute {
|
||||
model: "kimi-k2".to_string(),
|
||||
display_name: Some("Kimi".to_string()),
|
||||
label_override: Some("Kimi K2".to_string()),
|
||||
supports_1m: Some(true),
|
||||
},
|
||||
)]),
|
||||
@@ -1074,16 +1179,44 @@ mod tests {
|
||||
provider
|
||||
}
|
||||
|
||||
fn oauth_proxy_provider(id: &str, provider_type: &str, api_format: &str) -> Provider {
|
||||
let mut provider = Provider::with_id(
|
||||
id.to_string(),
|
||||
"OAuth Proxy".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://oauth-upstream.example.com"
|
||||
}
|
||||
}),
|
||||
Some("https://example.com".to_string()),
|
||||
);
|
||||
provider.meta = Some(ProviderMeta {
|
||||
claude_desktop_mode: Some(ClaudeDesktopMode::Proxy),
|
||||
api_format: Some(api_format.to_string()),
|
||||
provider_type: Some(provider_type.to_string()),
|
||||
claude_desktop_model_routes: std::collections::HashMap::from([(
|
||||
"claude-sonnet-4-6".to_string(),
|
||||
ClaudeDesktopModelRoute {
|
||||
model: "gpt-5.4".to_string(),
|
||||
label_override: Some("GPT-5.4".to_string()),
|
||||
supports_1m: Some(false),
|
||||
},
|
||||
)]),
|
||||
..Default::default()
|
||||
});
|
||||
provider
|
||||
}
|
||||
|
||||
fn direct_provider_with_models(id: &str) -> Provider {
|
||||
let mut provider = direct_provider(id);
|
||||
provider.meta = Some(ProviderMeta {
|
||||
claude_desktop_mode: Some(ClaudeDesktopMode::Direct),
|
||||
api_format: Some("anthropic".to_string()),
|
||||
claude_desktop_model_routes: std::collections::HashMap::from([(
|
||||
"claude-deepseek-chat".to_string(),
|
||||
"claude-sonnet-4-6".to_string(),
|
||||
ClaudeDesktopModelRoute {
|
||||
model: "claude-deepseek-chat".to_string(),
|
||||
display_name: Some("DeepSeek".to_string()),
|
||||
model: "claude-sonnet-4-6".to_string(),
|
||||
label_override: None,
|
||||
supports_1m: Some(true),
|
||||
},
|
||||
)]),
|
||||
@@ -1141,7 +1274,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
profile["inferenceModels"],
|
||||
json!(["claude-deepseek-chat [1M]"])
|
||||
json!([{ "name": "claude-sonnet-4-6", "supports1m": true }])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1167,24 +1300,51 @@ mod tests {
|
||||
.starts_with("ccs-"));
|
||||
assert_eq!(
|
||||
profile["inferenceModels"],
|
||||
json!(["claude-sonnet-4-6 [1M]"])
|
||||
json!([{ "name": "claude-sonnet-4-6", "labelOverride": "Kimi K2", "supports1m": true }])
|
||||
);
|
||||
assert!(!profile.to_string().contains("kimi-k2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_proxy_accepts_managed_oauth_providers_without_static_key() {
|
||||
for (provider_type, api_format) in [
|
||||
("github_copilot", "openai_chat"),
|
||||
("codex_oauth", "openai_responses"),
|
||||
] {
|
||||
let provider = oauth_proxy_provider(provider_type, provider_type, api_format);
|
||||
validate_proxy_provider(&provider).expect("oauth proxy provider should validate");
|
||||
|
||||
let temp = TempDir::new().expect("tempdir");
|
||||
let paths = test_paths(temp.path());
|
||||
let db = test_db();
|
||||
apply_provider_to_paths(&db, &provider, &paths).expect("apply oauth proxy provider");
|
||||
|
||||
let profile: Value = read_json_file(&paths.profile_path).expect("read profile");
|
||||
assert_eq!(
|
||||
profile["inferenceGatewayBaseUrl"],
|
||||
json!("http://127.0.0.1:15721/claude-desktop")
|
||||
);
|
||||
assert_eq!(
|
||||
profile["inferenceModels"],
|
||||
json!([{ "name": "claude-sonnet-4-6", "labelOverride": "GPT-5.4" }])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_proxy_maps_known_route_and_rejects_unknown_route() {
|
||||
let provider = proxy_provider("proxy");
|
||||
|
||||
let mapped = map_proxy_request_model(
|
||||
json!({"model": "claude-sonnet-4-6 [1M]", "messages": []}),
|
||||
json!({"model": "claude-sonnet-4-6", "messages": []}),
|
||||
&provider,
|
||||
)
|
||||
.expect("map route");
|
||||
assert_eq!(mapped["model"], json!("kimi-k2 [1M]"));
|
||||
assert_eq!(mapped["model"], json!("kimi-k2"));
|
||||
|
||||
let models = model_list_response(&provider).expect("model list");
|
||||
assert_eq!(models["data"][0]["id"], json!("claude-sonnet-4-6 [1M]"));
|
||||
assert_eq!(models["data"][0]["id"], json!("claude-sonnet-4-6"));
|
||||
assert_eq!(models["data"][0]["supports1m"], json!(true));
|
||||
|
||||
let err = map_proxy_request_model(json!({"model": "claude-opus-4-7"}), &provider)
|
||||
.expect_err("unknown route should fail");
|
||||
@@ -1192,29 +1352,72 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_proxy_maps_route_without_1m_suffix() {
|
||||
let provider = proxy_provider("proxy");
|
||||
fn claude_desktop_proxy_repairs_legacy_unsafe_route_without_colliding() {
|
||||
let mut provider = proxy_provider("proxy");
|
||||
provider.meta = Some(ProviderMeta {
|
||||
claude_desktop_mode: Some(ClaudeDesktopMode::Proxy),
|
||||
api_format: Some("openai_chat".to_string()),
|
||||
claude_desktop_model_routes: std::collections::HashMap::from([
|
||||
(
|
||||
"claude-deepseek-v4-pro".to_string(),
|
||||
ClaudeDesktopModelRoute {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
label_override: None,
|
||||
supports_1m: Some(true),
|
||||
},
|
||||
),
|
||||
(
|
||||
"claude-sonnet-4-6".to_string(),
|
||||
ClaudeDesktopModelRoute {
|
||||
model: "claude-sonnet-4-6".to_string(),
|
||||
label_override: None,
|
||||
supports_1m: Some(false),
|
||||
},
|
||||
),
|
||||
]),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let routes = proxy_model_routes(&provider).expect("routes");
|
||||
assert_eq!(routes.len(), 2);
|
||||
let repaired = routes
|
||||
.iter()
|
||||
.find(|route| route.upstream_model == "deepseek-v4-pro")
|
||||
.expect("repaired route");
|
||||
assert_eq!(repaired.route_id, "claude-opus-4-7");
|
||||
assert_eq!(repaired.label_override.as_deref(), Some("deepseek-v4-pro"));
|
||||
assert!(repaired.supports_1m);
|
||||
|
||||
let mapped = map_proxy_request_model(
|
||||
json!({"model": "claude-sonnet-4-6", "messages": []}),
|
||||
json!({"model": "claude-opus-4-7", "messages": []}),
|
||||
&provider,
|
||||
)
|
||||
.expect("base name should fallback-match the [1M] route");
|
||||
assert_eq!(mapped["model"], json!("kimi-k2 [1M]"));
|
||||
.expect("map repaired route");
|
||||
assert_eq!(mapped["model"], json!("deepseek-v4-pro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_one_m_suffix_normalization_is_case_and_space_tolerant() {
|
||||
assert!(is_claude_safe_model_id("claude-sonnet-4-6 [1m]"));
|
||||
assert!(is_claude_safe_model_id(" claude-sonnet-4-6 [1M] "));
|
||||
assert_eq!(
|
||||
strip_one_m_context_suffix(" claude-sonnet-4-6 [1m] "),
|
||||
"claude-sonnet-4-6"
|
||||
);
|
||||
assert_eq!(
|
||||
desktop_model_id(" claude-sonnet-4-6 [1m] ", true),
|
||||
"claude-sonnet-4-6 [1M]"
|
||||
);
|
||||
fn claude_desktop_proxy_rejects_1m_suffix_route() {
|
||||
let provider = proxy_provider("proxy");
|
||||
|
||||
let err = 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]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_rejects_1m_suffix_as_model_id() {
|
||||
assert!(!is_claude_safe_model_id("claude-sonnet-4-6 [1m]"));
|
||||
assert!(!is_claude_safe_model_id(" claude-sonnet-4-6 [1M] "));
|
||||
assert!(!is_claude_safe_model_id("claude-deepseek-v4-pro"));
|
||||
assert!(!is_claude_safe_model_id("claude-gpt-5-4"));
|
||||
assert!(!is_claude_safe_model_id("claude-"));
|
||||
assert!(!is_claude_safe_model_id("anthropic/claude-"));
|
||||
assert!(!is_claude_safe_model_id("sonnet-"));
|
||||
assert!(is_claude_safe_model_id(" claude-sonnet-4-6 "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
//! 提供 OpenAI ChatGPT Plus/Pro OAuth 认证相关的 Tauri 命令。
|
||||
//!
|
||||
//! 大部分认证命令通过通用 `auth_*` 命令(参见 `commands::auth`)暴露给前端,
|
||||
//! 此处定义 State wrapper 以及 Codex OAuth 专属的订阅额度查询命令。
|
||||
//! 此处定义 State wrapper 以及 Codex OAuth 专属的订阅额度和模型列表查询命令。
|
||||
|
||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
||||
use crate::services::model_fetch::FetchedModel;
|
||||
use crate::services::subscription::{query_codex_quota, CredentialStatus, SubscriptionQuota};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
@@ -56,3 +57,34 @@ pub async fn get_codex_oauth_quota(
|
||||
)
|
||||
.await)
|
||||
}
|
||||
|
||||
/// 获取 Codex OAuth (ChatGPT Plus/Pro) 可用模型列表
|
||||
///
|
||||
/// ChatGPT Codex 反代使用 `chatgpt.com/backend-api/codex/*`,不是 OpenAI 兼容
|
||||
/// `/v1/models`。这里复用托管 OAuth 账号的 access_token,直接读取 Codex 后端
|
||||
/// 暴露的模型列表端点。
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn get_codex_oauth_models(
|
||||
account_id: Option<String>,
|
||||
state: State<'_, CodexOAuthState>,
|
||||
) -> Result<Vec<FetchedModel>, String> {
|
||||
let manager = state.0.read().await;
|
||||
let resolved = match account_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
{
|
||||
Some(id) => Some(id.to_string()),
|
||||
None => manager.default_account_id().await,
|
||||
};
|
||||
let Some(id) = resolved else {
|
||||
return Err("No ChatGPT account available".to_string());
|
||||
};
|
||||
|
||||
let token = manager
|
||||
.get_valid_token_for_account(&id)
|
||||
.await
|
||||
.map_err(|e| format!("Codex OAuth token unavailable: {e}"))?;
|
||||
|
||||
crate::services::codex_oauth_models::fetch_models_with_token(&token, &id).await
|
||||
}
|
||||
|
||||
@@ -86,11 +86,19 @@ pub async fn set_auto_failover_enabled(
|
||||
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
|
||||
);
|
||||
|
||||
// 强一致语义:开启故障转移后立即切到队列 P1(并确保队列非空)
|
||||
//
|
||||
// 说明:
|
||||
// - 仅在 enabled=true 时执行“切到 P1”
|
||||
// - 若队列为空,则尝试把“当前供应商”自动加入队列作为 P1,避免用户在 UI 上陷入死锁(无法先加队列再开启)
|
||||
// 读取当前配置
|
||||
let mut config = state
|
||||
.db
|
||||
.get_proxy_config_for_app(&app_type)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if enabled && !config.enabled {
|
||||
return Err("需要先启用该应用的代理接管,再开启故障转移".to_string());
|
||||
}
|
||||
|
||||
// 队列为空时把当前供应商自动加入作为 P1,避免用户陷入"必须先加队列才能开启"的死锁
|
||||
let mut auto_added_provider_id: Option<String> = None;
|
||||
let p1_provider_id = if enabled {
|
||||
let mut queue = state
|
||||
.db
|
||||
@@ -112,6 +120,7 @@ pub async fn set_auto_failover_enabled(
|
||||
.db
|
||||
.add_to_failover_queue(&app_type, ¤t_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
auto_added_provider_id = Some(current_id);
|
||||
|
||||
queue = state
|
||||
.db
|
||||
@@ -127,12 +136,20 @@ pub async fn set_auto_failover_enabled(
|
||||
String::new()
|
||||
};
|
||||
|
||||
// 读取当前配置
|
||||
let mut config = state
|
||||
.db
|
||||
.get_proxy_config_for_app(&app_type)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// 开启前先切到 P1。只有切换成功后才写入 auto_failover_enabled=true,
|
||||
// 避免 P1 不可切换(例如 official provider)时留下“开关已开但目标未切”的脏状态。
|
||||
if enabled {
|
||||
if let Err(e) = state
|
||||
.proxy_service
|
||||
.switch_proxy_target(&app_type, &p1_provider_id)
|
||||
.await
|
||||
{
|
||||
if let Some(provider_id) = auto_added_provider_id {
|
||||
let _ = state.db.remove_from_failover_queue(&app_type, &provider_id);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 auto_failover_enabled 字段
|
||||
config.auto_failover_enabled = enabled;
|
||||
@@ -144,13 +161,7 @@ pub async fn set_auto_failover_enabled(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 开启后立即切到 P1:更新 is_current + 本地 settings + Live 备份(接管模式下)
|
||||
if enabled {
|
||||
state
|
||||
.proxy_service
|
||||
.switch_proxy_target(&app_type, &p1_provider_id)
|
||||
.await?;
|
||||
|
||||
// 发射 provider-switched 事件(让前端刷新当前供应商)
|
||||
let event_data = serde_json::json!({
|
||||
"appType": app_type,
|
||||
|
||||
@@ -184,16 +184,6 @@ pub fn import_claude_desktop_providers_from_claude(
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.provider_type.as_deref()),
|
||||
Some("github_copilot") | Some("codex_oauth")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut desktop_provider = provider.clone();
|
||||
desktop_provider.in_failover_queue = false;
|
||||
let meta = desktop_provider.meta.get_or_insert_with(Default::default);
|
||||
@@ -216,6 +206,16 @@ pub fn import_claude_desktop_providers_from_claude(
|
||||
imported += 1;
|
||||
}
|
||||
|
||||
// Safety net: 用户可能手动删除过 claude-desktop-official seed。
|
||||
// 用户主动点 import 是"重新整理 ClaudeDesktop 表"的隐式信号,把官方入口补回来。
|
||||
// 失败只 warn,不影响 imported 主流程;imported 计数语义保持纯净。
|
||||
if let Err(e) = state.db.ensure_official_seed_by_id(
|
||||
crate::database::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID,
|
||||
AppType::ClaudeDesktop,
|
||||
) {
|
||||
log::warn!("Failed to ensure claude-desktop-official seed during import: {e}");
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@ fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool {
|
||||
.all(crate::claude_desktop_config::is_claude_safe_model_id)
|
||||
}
|
||||
|
||||
fn suggested_claude_desktop_routes(
|
||||
pub(crate) fn suggested_claude_desktop_routes(
|
||||
provider: &Provider,
|
||||
) -> Option<std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>> {
|
||||
let env = provider
|
||||
@@ -249,29 +249,88 @@ fn suggested_claude_desktop_routes(
|
||||
.get("env")
|
||||
.and_then(|value| value.as_object())?;
|
||||
let mut routes = std::collections::HashMap::new();
|
||||
let supports_1m_default = !matches!(
|
||||
provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.provider_type.as_deref()),
|
||||
Some("github_copilot") | Some("codex_oauth")
|
||||
);
|
||||
|
||||
fn add_route(
|
||||
routes: &mut std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>,
|
||||
env: &serde_json::Map<String, serde_json::Value>,
|
||||
route_id: &str,
|
||||
route_key: &str,
|
||||
env_key: &str,
|
||||
display_name: &str,
|
||||
supports_1m_default: bool,
|
||||
) {
|
||||
if let Some(model) = env
|
||||
let Some(raw_model) = env
|
||||
.get(env_key)
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
routes.insert(
|
||||
route_id.to_string(),
|
||||
crate::provider::ClaudeDesktopModelRoute {
|
||||
model: model.to_string(),
|
||||
display_name: Some(display_name.to_string()),
|
||||
supports_1m: Some(true),
|
||||
},
|
||||
);
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Claude 端 env 值可能带 [1M] 后缀;Claude Desktop schema 不接受后缀,
|
||||
// 改用 supports1m 字段表达 1M 能力。在 import 边界做单向翻译。
|
||||
let marker = crate::claude_desktop_config::ONE_M_CONTEXT_MARKER.as_bytes();
|
||||
let raw_bytes = raw_model.as_bytes();
|
||||
let has_1m_marker = raw_bytes.len() >= marker.len()
|
||||
&& raw_bytes[raw_bytes.len() - marker.len()..].eq_ignore_ascii_case(marker);
|
||||
let stripped_model: &str = if has_1m_marker {
|
||||
raw_model[..raw_model.len() - marker.len()].trim_end()
|
||||
} else {
|
||||
raw_model
|
||||
};
|
||||
if stripped_model.is_empty() {
|
||||
return;
|
||||
}
|
||||
let effective_supports_1m = supports_1m_default || has_1m_marker;
|
||||
let explicit_label_override = env
|
||||
.get(&format!("{env_key}_NAME"))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
let label_override = explicit_label_override.clone().or_else(|| {
|
||||
(!crate::claude_desktop_config::is_claude_safe_model_id(stripped_model))
|
||||
.then(|| stripped_model.to_string())
|
||||
});
|
||||
|
||||
// 何时覆盖既有 label_override:原本为空 / 这次来的是 explicit _NAME /
|
||||
// 既有值只是 stripped_model 派生的占位(被 explicit 或更具体的值挤掉)。
|
||||
let should_overwrite = |existing: Option<&str>| {
|
||||
existing.is_none()
|
||||
|| explicit_label_override.is_some()
|
||||
|| existing == Some(stripped_model)
|
||||
};
|
||||
|
||||
let merge_into = |existing: &mut crate::provider::ClaudeDesktopModelRoute| {
|
||||
let merged = existing.supports_1m.unwrap_or(false) || effective_supports_1m;
|
||||
existing.supports_1m = Some(merged);
|
||||
if should_overwrite(existing.label_override.as_deref()) {
|
||||
existing.label_override = label_override.clone();
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(existing) = routes
|
||||
.values_mut()
|
||||
.find(|existing| existing.model == stripped_model)
|
||||
{
|
||||
merge_into(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
routes
|
||||
.entry(route_key.to_string())
|
||||
.and_modify(merge_into)
|
||||
.or_insert_with(|| crate::provider::ClaudeDesktopModelRoute {
|
||||
model: stripped_model.to_string(),
|
||||
label_override,
|
||||
supports_1m: Some(effective_supports_1m),
|
||||
});
|
||||
}
|
||||
|
||||
for spec in crate::claude_desktop_config::DEFAULT_PROXY_ROUTES {
|
||||
@@ -280,18 +339,19 @@ fn suggested_claude_desktop_routes(
|
||||
env,
|
||||
spec.route_id,
|
||||
spec.env_key,
|
||||
spec.display_name,
|
||||
supports_1m_default,
|
||||
);
|
||||
}
|
||||
|
||||
let primary_route = crate::claude_desktop_config::DEFAULT_PROXY_ROUTES[0];
|
||||
if !routes.contains_key(primary_route.route_id) {
|
||||
// 三个 default env_key 全空时用 ANTHROPIC_MODEL 派生兜底路由。
|
||||
if routes.is_empty() {
|
||||
let primary_route = crate::claude_desktop_config::DEFAULT_PROXY_ROUTES[0].route_id;
|
||||
add_route(
|
||||
&mut routes,
|
||||
env,
|
||||
primary_route.route_id,
|
||||
primary_route,
|
||||
"ANTHROPIC_MODEL",
|
||||
primary_route.display_name,
|
||||
supports_1m_default,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -682,3 +742,218 @@ pub fn get_opencode_live_provider_ids() -> Result<Vec<String>, String> {
|
||||
// ============================================================================
|
||||
// OpenClaw 专属命令 → 已迁移至 commands/openclaw.rs
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod import_claude_desktop_tests {
|
||||
use super::suggested_claude_desktop_routes;
|
||||
use crate::provider::{Provider, ProviderMeta};
|
||||
use serde_json::json;
|
||||
|
||||
fn make_provider(env: serde_json::Value, provider_type: Option<&str>) -> Provider {
|
||||
let mut p = Provider::with_id(
|
||||
"test-claude".to_string(),
|
||||
"Test".to_string(),
|
||||
json!({ "env": env }),
|
||||
None,
|
||||
);
|
||||
if let Some(pt) = provider_type {
|
||||
p.meta = Some(ProviderMeta {
|
||||
provider_type: Some(pt.to_string()),
|
||||
..ProviderMeta::default()
|
||||
});
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_strips_1m_suffix_and_sets_supports_1m() {
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-5-20250929[1M]",
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
let r = routes
|
||||
.get("claude-sonnet-4-6")
|
||||
.expect("sonnet route present");
|
||||
assert_eq!(r.model, "claude-sonnet-4-5-20250929");
|
||||
assert!(
|
||||
!r.model.to_ascii_lowercase().contains("[1m]"),
|
||||
"model must not contain [1m] suffix"
|
||||
);
|
||||
assert_eq!(r.label_override, None);
|
||||
assert_eq!(r.supports_1m, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_preserves_model_without_suffix() {
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k2",
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
let r = routes
|
||||
.get("claude-sonnet-4-6")
|
||||
.expect("sonnet route present");
|
||||
assert_eq!(r.model, "kimi-k2");
|
||||
assert_eq!(r.label_override.as_deref(), Some("kimi-k2"));
|
||||
// 默认 provider_type 缺省 → supports_1m_default = true
|
||||
assert_eq!(r.supports_1m, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_uses_claude_code_model_name_as_label_override() {
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k2",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Kimi K2",
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
let r = routes
|
||||
.get("claude-sonnet-4-6")
|
||||
.expect("sonnet route present");
|
||||
assert_eq!(r.model, "kimi-k2");
|
||||
assert_eq!(r.label_override.as_deref(), Some("Kimi K2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_1m_suffix_overrides_provider_type_default() {
|
||||
// github_copilot 默认 supports_1m_default = false,但 [1M] 后缀应强制 true
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5-codex[1M]",
|
||||
}),
|
||||
Some("github_copilot"),
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
let r = routes
|
||||
.get("claude-sonnet-4-6")
|
||||
.expect("sonnet route present");
|
||||
assert_eq!(r.model, "gpt-5-codex");
|
||||
assert_eq!(r.label_override.as_deref(), Some("gpt-5-codex"));
|
||||
assert_eq!(r.supports_1m, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_github_copilot_without_suffix_keeps_false() {
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5-codex",
|
||||
}),
|
||||
Some("github_copilot"),
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
let r = routes
|
||||
.get("claude-sonnet-4-6")
|
||||
.expect("sonnet route present");
|
||||
assert_eq!(r.model, "gpt-5-codex");
|
||||
assert_eq!(r.label_override.as_deref(), Some("gpt-5-codex"));
|
||||
assert_eq!(r.supports_1m, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_upstream_across_three_aliases_merges_to_one_route() {
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "MiniMax-M2",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "MiniMax-M2",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "MiniMax-M2",
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
assert_eq!(routes.len(), 1, "three aliases → one merged route");
|
||||
let r = routes
|
||||
.get("claude-sonnet-4-6")
|
||||
.expect("merged route present");
|
||||
assert_eq!(r.model, "MiniMax-M2");
|
||||
assert_eq!(r.label_override.as_deref(), Some("MiniMax-M2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_upstream_with_partial_1m_marker_takes_or_aggregation() {
|
||||
// sonnet 带 [1M],opus/haiku 不带 → 合并后 supports_1m == Some(true)
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "MiniMax-M2[1M]",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "MiniMax-M2",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "MiniMax-M2",
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
assert_eq!(routes.len(), 1);
|
||||
let r = routes
|
||||
.get("claude-sonnet-4-6")
|
||||
.expect("merged route present");
|
||||
assert_eq!(r.supports_1m, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_upstream_models_produce_separate_routes() {
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "GLM-4.6",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "GLM-4-Air",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "GLM-4-Flash",
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
assert_eq!(routes.len(), 3);
|
||||
assert_eq!(routes.get("claude-sonnet-4-6").unwrap().model, "GLM-4.6");
|
||||
assert_eq!(routes.get("claude-opus-4-7").unwrap().model, "GLM-4-Air");
|
||||
assert_eq!(routes.get("claude-haiku-4-5").unwrap().model, "GLM-4-Flash");
|
||||
assert_eq!(
|
||||
routes
|
||||
.get("claude-sonnet-4-6")
|
||||
.unwrap()
|
||||
.label_override
|
||||
.as_deref(),
|
||||
Some("GLM-4.6")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_model_fallback_only_triggers_when_empty() {
|
||||
// 三个 default env_key 都不填,仅 ANTHROPIC_MODEL
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_MODEL": "kimi-k2",
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
assert_eq!(routes.len(), 1);
|
||||
let r = routes
|
||||
.get("claude-sonnet-4-6")
|
||||
.expect("fallback route present");
|
||||
assert_eq!(r.model, "kimi-k2");
|
||||
assert_eq!(r.label_override.as_deref(), Some("kimi-k2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_claude_prefix_not_duplicated() {
|
||||
let p = make_provider(
|
||||
json!({
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-5-20250929",
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let routes = suggested_claude_desktop_routes(&p).expect("routes built");
|
||||
assert!(routes.contains_key("claude-sonnet-4-6"));
|
||||
assert!(!routes.contains_key("claude-claude-sonnet-4-5-20250929"));
|
||||
assert_eq!(
|
||||
routes
|
||||
.get("claude-sonnet-4-6")
|
||||
.expect("route")
|
||||
.label_override,
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,9 +133,17 @@ pub async fn update_proxy_config_for_app(
|
||||
config: AppProxyConfig,
|
||||
) -> Result<(), String> {
|
||||
let db = &state.db;
|
||||
let app_type = config.app_type.clone();
|
||||
let circuit_config = CircuitBreakerConfig::from(&config);
|
||||
|
||||
db.update_proxy_config_for_app(config)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
state
|
||||
.proxy_service
|
||||
.update_circuit_breaker_config_for_app(&app_type, circuit_config)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_default_cost_multiplier_internal(
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
use crate::error::AppError;
|
||||
use crate::services::usage_stats::*;
|
||||
use crate::store::AppState;
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
use tauri::State;
|
||||
|
||||
/// 获取使用量汇总
|
||||
@@ -18,6 +20,16 @@ pub fn get_usage_summary(
|
||||
.get_usage_summary(start_date, end_date, app_type.as_deref())
|
||||
}
|
||||
|
||||
/// 获取按 app_type 拆分的使用量汇总
|
||||
#[tauri::command]
|
||||
pub fn get_usage_summary_by_app(
|
||||
state: State<'_, AppState>,
|
||||
start_date: Option<i64>,
|
||||
end_date: Option<i64>,
|
||||
) -> Result<Vec<UsageSummaryByApp>, AppError> {
|
||||
state.db.get_usage_summary_by_app(start_date, end_date)
|
||||
}
|
||||
|
||||
/// 获取每日趋势
|
||||
#[tauri::command]
|
||||
pub fn get_usage_trends(
|
||||
@@ -139,23 +151,67 @@ pub fn update_model_pricing(
|
||||
cache_creation_cost: String,
|
||||
) -> Result<(), AppError> {
|
||||
let db = state.db.clone();
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let model_id = model_id.trim().to_string();
|
||||
let display_name = display_name.trim().to_string();
|
||||
if model_id.is_empty() {
|
||||
return Err(AppError::localized(
|
||||
"usage.modelIdRequired",
|
||||
"模型 ID 不能为空",
|
||||
"Model ID is required",
|
||||
));
|
||||
}
|
||||
if display_name.is_empty() {
|
||||
return Err(AppError::localized(
|
||||
"usage.displayNameRequired",
|
||||
"显示名称不能为空",
|
||||
"Display name is required",
|
||||
));
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO model_pricing (
|
||||
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
model_id,
|
||||
display_name,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_read_cost,
|
||||
cache_creation_cost
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("更新模型定价失败: {e}")))?;
|
||||
for (label, value) in [
|
||||
("input_cost", &input_cost),
|
||||
("output_cost", &output_cost),
|
||||
("cache_read_cost", &cache_read_cost),
|
||||
("cache_creation_cost", &cache_creation_cost),
|
||||
] {
|
||||
let parsed = Decimal::from_str(value.trim()).map_err(|e| {
|
||||
AppError::localized(
|
||||
"usage.invalidPrice",
|
||||
format!("{label} 价格无效: {value} - {e}"),
|
||||
format!("{label} price is invalid: {value} - {e}"),
|
||||
)
|
||||
})?;
|
||||
if parsed < Decimal::ZERO {
|
||||
return Err(AppError::localized(
|
||||
"usage.invalidPrice",
|
||||
format!("{label} 价格必须为非负数: {value}"),
|
||||
format!("{label} price must be non-negative: {value}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO model_pricing (
|
||||
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
model_id,
|
||||
display_name,
|
||||
input_cost.trim(),
|
||||
output_cost.trim(),
|
||||
cache_read_cost.trim(),
|
||||
cache_creation_cost.trim()
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("更新模型定价失败: {e}")))?;
|
||||
}
|
||||
|
||||
if let Err(e) = db.backfill_missing_usage_costs_for_model(&model_id) {
|
||||
log::warn!("模型定价更新后回填历史用量成本失败 (model_id={model_id}): {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -649,4 +649,138 @@ impl Database {
|
||||
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
/// 按 id 兜底插入单条 official seed(仅当目标表中该 id 不存在时插入)。
|
||||
///
|
||||
/// 与 `init_default_official_providers` 不同:
|
||||
/// - 不触碰 `official_providers_seeded` 全局 flag,是 on-demand 修复
|
||||
/// - 只处理一条 seed,由调用方决定 id + app_type
|
||||
/// - 已存在则尊重用户自定义,不覆盖
|
||||
///
|
||||
/// 返回 Ok(true) 表示插入了新行,Ok(false) 表示已存在被跳过。
|
||||
pub fn ensure_official_seed_by_id(
|
||||
&self,
|
||||
seed_id: &str,
|
||||
app_type: crate::app_config::AppType,
|
||||
) -> Result<bool, AppError> {
|
||||
use crate::database::dao::providers_seed::OFFICIAL_SEEDS;
|
||||
|
||||
let seed = OFFICIAL_SEEDS
|
||||
.iter()
|
||||
.find(|s| s.id == seed_id && s.app_type == app_type)
|
||||
.ok_or_else(|| {
|
||||
AppError::Database(format!(
|
||||
"unknown official seed: id={seed_id}, app_type={}",
|
||||
app_type.as_str()
|
||||
))
|
||||
})?;
|
||||
|
||||
let app_type_str = seed.app_type.as_str();
|
||||
|
||||
if self.get_provider_by_id(seed_id, app_type_str)?.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let settings_config: serde_json::Value = serde_json::from_str(seed.settings_config_json)
|
||||
.map_err(|e| {
|
||||
AppError::Database(format!("Seed JSON parse failed for {}: {e}", seed.id))
|
||||
})?;
|
||||
|
||||
let next_sort_index = self.next_sort_index_for_app(app_type_str)?;
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
|
||||
let mut provider = Provider::with_id(
|
||||
seed.id.to_string(),
|
||||
seed.name.to_string(),
|
||||
settings_config,
|
||||
Some(seed.website_url.to_string()),
|
||||
);
|
||||
provider.category = Some("official".to_string());
|
||||
provider.icon = Some(seed.icon.to_string());
|
||||
provider.icon_color = Some(seed.icon_color.to_string());
|
||||
provider.sort_index = Some(next_sort_index);
|
||||
provider.created_at = Some(now_ms);
|
||||
|
||||
self.save_provider(app_type_str, &provider)?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ensure_official_seed_tests {
|
||||
use crate::app_config::AppType;
|
||||
use crate::database::{Database, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID};
|
||||
|
||||
#[test]
|
||||
fn ensure_inserts_when_missing() {
|
||||
let db = Database::memory().expect("memory db");
|
||||
let inserted = db
|
||||
.ensure_official_seed_by_id(CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, AppType::ClaudeDesktop)
|
||||
.expect("ensure ok");
|
||||
assert!(inserted, "should insert when missing");
|
||||
|
||||
let provider = db
|
||||
.get_provider_by_id(
|
||||
CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID,
|
||||
AppType::ClaudeDesktop.as_str(),
|
||||
)
|
||||
.expect("query ok")
|
||||
.expect("provider exists after ensure");
|
||||
|
||||
assert_eq!(provider.id, CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID);
|
||||
assert_eq!(provider.name, "Claude Desktop Official");
|
||||
assert_eq!(provider.category.as_deref(), Some("official"));
|
||||
assert_eq!(provider.icon.as_deref(), Some("anthropic"));
|
||||
assert_eq!(provider.icon_color.as_deref(), Some("#D4915D"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_skips_when_present_and_preserves_customization() {
|
||||
let db = Database::memory().expect("memory db");
|
||||
db.init_default_official_providers().expect("seed");
|
||||
|
||||
let mut renamed = db
|
||||
.get_provider_by_id(
|
||||
CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID,
|
||||
AppType::ClaudeDesktop.as_str(),
|
||||
)
|
||||
.expect("query ok")
|
||||
.expect("seed present");
|
||||
renamed.name = "My Custom Backup".to_string();
|
||||
db.save_provider(AppType::ClaudeDesktop.as_str(), &renamed)
|
||||
.expect("save customization");
|
||||
|
||||
let inserted = db
|
||||
.ensure_official_seed_by_id(CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, AppType::ClaudeDesktop)
|
||||
.expect("ensure ok");
|
||||
assert!(!inserted, "should skip when present");
|
||||
|
||||
let after = db
|
||||
.get_provider_by_id(
|
||||
CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID,
|
||||
AppType::ClaudeDesktop.as_str(),
|
||||
)
|
||||
.expect("query ok")
|
||||
.expect("still present");
|
||||
assert_eq!(
|
||||
after.name, "My Custom Backup",
|
||||
"customization must not be overwritten"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_rejects_unknown_seed() {
|
||||
let db = Database::memory().expect("memory db");
|
||||
let result = db.ensure_official_seed_by_id("nonexistent-id", AppType::ClaudeDesktop);
|
||||
assert!(result.is_err(), "unknown seed id should be Err");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_rejects_seed_app_type_mismatch() {
|
||||
let db = Database::memory().expect("memory db");
|
||||
let result =
|
||||
db.ensure_official_seed_by_id(CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID, AppType::Claude);
|
||||
assert!(result.is_err(), "(id, app_type) mismatch should be Err");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,56 @@
|
||||
//!
|
||||
//! 处理代理配置、Provider健康状态和使用统计的数据库操作
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::proxy::types::*;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
use super::super::{lock_conn, Database};
|
||||
|
||||
pub(crate) const PRICING_SOURCE_RESPONSE: &str = "response";
|
||||
pub(crate) const PRICING_SOURCE_REQUEST: &str = "request";
|
||||
|
||||
pub(crate) fn validate_cost_multiplier(value: &str) -> Result<Decimal, AppError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::localized(
|
||||
"error.multiplierEmpty",
|
||||
"倍率不能为空",
|
||||
"Multiplier cannot be empty",
|
||||
));
|
||||
}
|
||||
let parsed = Decimal::from_str(trimmed).map_err(|e| {
|
||||
AppError::localized(
|
||||
"error.invalidMultiplier",
|
||||
format!("无效倍率: {value} - {e}"),
|
||||
format!("Invalid multiplier: {value} - {e}"),
|
||||
)
|
||||
})?;
|
||||
if parsed < Decimal::ZERO {
|
||||
return Err(AppError::localized(
|
||||
"error.invalidMultiplier",
|
||||
format!("无效倍率: {value} - 倍率不能为负数"),
|
||||
format!("Invalid multiplier: {value} - multiplier cannot be negative"),
|
||||
));
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_pricing_source(value: &str) -> Result<&str, AppError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed == PRICING_SOURCE_RESPONSE || trimmed == PRICING_SOURCE_REQUEST {
|
||||
Ok(trimmed)
|
||||
} else {
|
||||
Err(AppError::localized(
|
||||
"error.invalidPricingMode",
|
||||
format!("无效计费模式: {value}"),
|
||||
format!("Invalid pricing mode: {value}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Database {
|
||||
// ==================== Global Proxy Config ====================
|
||||
|
||||
@@ -103,21 +147,8 @@ impl Database {
|
||||
app_type: &str,
|
||||
value: &str,
|
||||
) -> Result<(), AppError> {
|
||||
validate_cost_multiplier(value)?;
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::localized(
|
||||
"error.multiplierEmpty",
|
||||
"倍率不能为空",
|
||||
"Multiplier cannot be empty",
|
||||
));
|
||||
}
|
||||
trimmed.parse::<Decimal>().map_err(|e| {
|
||||
AppError::localized(
|
||||
"error.invalidMultiplier",
|
||||
format!("无效倍率: {value} - {e}"),
|
||||
format!("Invalid multiplier: {value} - {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 确保行存在
|
||||
self.ensure_proxy_config_row_exists(app_type)?;
|
||||
@@ -150,7 +181,7 @@ impl Database {
|
||||
Ok(value) => Ok(value),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
self.init_proxy_config_rows().await?;
|
||||
Ok("response".to_string())
|
||||
Ok(PRICING_SOURCE_RESPONSE.to_string())
|
||||
}
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
@@ -162,14 +193,7 @@ impl Database {
|
||||
app_type: &str,
|
||||
value: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let trimmed = value.trim();
|
||||
if !matches!(trimmed, "response" | "request") {
|
||||
return Err(AppError::localized(
|
||||
"error.invalidPricingMode",
|
||||
format!("无效计费模式: {value}"),
|
||||
format!("Invalid pricing mode: {value}"),
|
||||
));
|
||||
}
|
||||
let trimmed = validate_pricing_source(value)?;
|
||||
|
||||
// 确保行存在
|
||||
self.ensure_proxy_config_row_exists(app_type)?;
|
||||
@@ -911,6 +935,18 @@ mod tests {
|
||||
}
|
||||
));
|
||||
|
||||
let err = db
|
||||
.set_default_cost_multiplier("claude", "-0.5")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
AppError::Localized {
|
||||
key: "error.invalidMultiplier",
|
||||
..
|
||||
}
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ mod tests;
|
||||
|
||||
// DAO 类型导出供外部使用
|
||||
pub(crate) use dao::providers_seed::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID;
|
||||
pub(crate) use dao::proxy::{
|
||||
validate_cost_multiplier, validate_pricing_source, PRICING_SOURCE_REQUEST,
|
||||
PRICING_SOURCE_RESPONSE,
|
||||
};
|
||||
pub use dao::FailoverQueueItem;
|
||||
|
||||
use crate::config::get_app_config_dir;
|
||||
|
||||
@@ -1105,6 +1105,7 @@ pub fn run() {
|
||||
// subscription quota
|
||||
commands::get_subscription_quota,
|
||||
commands::get_codex_oauth_quota,
|
||||
commands::get_codex_oauth_models,
|
||||
commands::get_coding_plan_quota,
|
||||
commands::get_balance,
|
||||
// New MCP via config.json (SSOT)
|
||||
@@ -1230,6 +1231,7 @@ pub fn run() {
|
||||
commands::set_auto_failover_enabled,
|
||||
// Usage statistics
|
||||
commands::get_usage_summary,
|
||||
commands::get_usage_summary_by_app,
|
||||
commands::get_usage_trends,
|
||||
commands::get_provider_stats,
|
||||
commands::get_model_stats,
|
||||
|
||||
@@ -230,9 +230,9 @@ pub enum ClaudeDesktopMode {
|
||||
pub struct ClaudeDesktopModelRoute {
|
||||
/// 真实上游模型名,只保存在 CC Switch 内部,不写入 Claude Desktop profile。
|
||||
pub model: String,
|
||||
/// Desktop /v1/models 中显示的名称。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
/// Claude Desktop 模型菜单显示名;写入 profile 的 `labelOverride`。
|
||||
#[serde(rename = "labelOverride", skip_serializing_if = "Option::is_none")]
|
||||
pub label_override: Option<String>,
|
||||
/// Claude Desktop 3P 识别的 1M 上下文能力标记。
|
||||
#[serde(rename = "supports1m", skip_serializing_if = "Option::is_none")]
|
||||
pub supports_1m: Option<bool>,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
|
||||
|
||||
use super::log_codes::cb as log_cb;
|
||||
use super::types::AppProxyConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
@@ -47,6 +48,18 @@ pub struct CircuitBreakerConfig {
|
||||
pub min_requests: u32,
|
||||
}
|
||||
|
||||
impl From<&AppProxyConfig> for CircuitBreakerConfig {
|
||||
fn from(config: &AppProxyConfig) -> Self {
|
||||
Self {
|
||||
failure_threshold: config.circuit_failure_threshold,
|
||||
success_threshold: config.circuit_success_threshold,
|
||||
timeout_seconds: config.circuit_timeout_seconds as u64,
|
||||
error_rate_threshold: config.circuit_error_rate_threshold,
|
||||
min_requests: config.circuit_min_requests,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CircuitBreakerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
||||
+470
-133
@@ -25,6 +25,7 @@ use crate::commands::{CodexOAuthState, CopilotAuthState};
|
||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
||||
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
|
||||
use crate::{app_config::AppType, provider::Provider};
|
||||
use futures::StreamExt;
|
||||
use http::Extensions;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
@@ -35,6 +36,9 @@ pub struct ForwardResult {
|
||||
pub response: ProxyResponse,
|
||||
pub provider: Provider,
|
||||
pub claude_api_format: Option<String>,
|
||||
/// 活跃连接 RAII guard:随响应一起流转到 response_processor / handle_claude_transform,
|
||||
/// 最终被 move 进流式 body future(或非流式响应作用域),覆盖整个响应生命周期。
|
||||
pub(crate) connection_guard: Option<ActiveConnectionGuard>,
|
||||
}
|
||||
|
||||
pub struct ForwardError {
|
||||
@@ -42,6 +46,44 @@ pub struct ForwardError {
|
||||
pub provider: Option<Provider>,
|
||||
}
|
||||
|
||||
/// 活跃连接 RAII guard
|
||||
///
|
||||
/// 构造时把 `ProxyStatus.active_connections` +1;Drop 时在 tokio runtime 上调度
|
||||
/// 一个异步任务执行 -1,从而支持把 guard move 进流式 body future(stream 自然结束
|
||||
/// 时 guard 与 future 一起 drop)。
|
||||
///
|
||||
/// 设计动机:之前在 `forward_with_retry` 出口处同步 -1,但流式响应的 body 实际
|
||||
/// 在 `create_logged_passthrough_stream` 内还会继续 yield 字节流,导致 UI 的
|
||||
/// `active_connections` 计数过早归零。RAII guard 让"减量"由 Rust 类型系统驱动,
|
||||
/// 不需要每条出口路径都手动调用。
|
||||
pub(crate) struct ActiveConnectionGuard {
|
||||
status: Arc<RwLock<ProxyStatus>>,
|
||||
}
|
||||
|
||||
impl ActiveConnectionGuard {
|
||||
pub(crate) async fn acquire(status: Arc<RwLock<ProxyStatus>>) -> Self {
|
||||
{
|
||||
let mut s = status.write().await;
|
||||
s.active_connections = s.active_connections.saturating_add(1);
|
||||
}
|
||||
Self { status }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ActiveConnectionGuard {
|
||||
fn drop(&mut self) {
|
||||
// Drop 不能 await:把减量操作调度到 tokio runtime
|
||||
let status = self.status.clone();
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
handle.spawn(async move {
|
||||
let mut s = status.write().await;
|
||||
s.active_connections = s.active_connections.saturating_sub(1);
|
||||
});
|
||||
}
|
||||
// 没有 runtime 时静默丢失计数(仅 UI 展示用,可接受最终一致性)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RequestForwarder {
|
||||
/// 共享的 ProviderRouter(持有熔断器状态)
|
||||
router: Arc<ProviderRouter>,
|
||||
@@ -68,6 +110,12 @@ pub struct RequestForwarder {
|
||||
non_streaming_timeout: std::time::Duration,
|
||||
/// 流式请求响应头等待超时(秒)
|
||||
streaming_first_byte_timeout: std::time::Duration,
|
||||
/// 单个客户端请求最多尝试的 provider 数。
|
||||
///
|
||||
/// 由 `AppProxyConfig.max_retries` (UI: "请求失败时的重试次数, 0-10") 派生:
|
||||
/// `max_attempts = max_retries + 1`,所以 max_retries=0 表示仅尝试一家、
|
||||
/// max_retries=3(默认)表示最多 4 家。loop 同时受 providers.len() 自然限制。
|
||||
max_attempts: usize,
|
||||
}
|
||||
|
||||
impl RequestForwarder {
|
||||
@@ -88,7 +136,11 @@ impl RequestForwarder {
|
||||
rectifier_config: RectifierConfig,
|
||||
optimizer_config: OptimizerConfig,
|
||||
copilot_optimizer_config: CopilotOptimizerConfig,
|
||||
max_retries: u32,
|
||||
) -> Self {
|
||||
// max_retries 是「失败后重试次数」语义,attempt 上限 = retries + 1。
|
||||
// saturating_add 防止 u32::MAX + 1 溢出。
|
||||
let max_attempts = (max_retries as usize).saturating_add(1);
|
||||
Self {
|
||||
router,
|
||||
status,
|
||||
@@ -106,6 +158,7 @@ impl RequestForwarder {
|
||||
streaming_first_byte_timeout: std::time::Duration::from_secs(
|
||||
streaming_first_byte_timeout,
|
||||
),
|
||||
max_attempts,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,17 +196,121 @@ impl RequestForwarder {
|
||||
});
|
||||
}
|
||||
|
||||
/// 整流(thinking signature 或 budget)重试失败后的统一收尾。
|
||||
///
|
||||
/// `None` 表示已记录熔断器、累积 `last_error`/`last_provider`,
|
||||
/// 调用方应 `continue` 让下一家 provider 继续故障转移;
|
||||
/// `Some(ForwardError)` 表示是客户端错误,没有 provider 能修复,
|
||||
/// 调用方应直接 `return` 把错误返回给客户端。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_rectifier_retry_failure(
|
||||
&self,
|
||||
retry_err: ProxyError,
|
||||
provider: &Provider,
|
||||
app_type_str: &str,
|
||||
used_half_open_permit: bool,
|
||||
rectifier_label: &str,
|
||||
last_error: &mut Option<ProxyError>,
|
||||
last_provider: &mut Option<Provider>,
|
||||
) -> Option<ForwardError> {
|
||||
// Provider 错误:本家上游/网络确实出问题,下一家 provider 可能可用 → 继续故障转移。
|
||||
// 客户端错误:整流后请求仍违法,下一家也修不好 → 直接返回。
|
||||
let is_provider_error = match &retry_err {
|
||||
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => true,
|
||||
ProxyError::UpstreamError { status, .. } => *status >= 500,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if is_provider_error {
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
false,
|
||||
Some(retry_err.to_string()),
|
||||
)
|
||||
.await;
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.last_error = Some(format!(
|
||||
"Provider {} {rectifier_label}重试失败: {}",
|
||||
provider.name, retry_err
|
||||
));
|
||||
}
|
||||
*last_error = Some(retry_err);
|
||||
*last_provider = Some(provider.clone());
|
||||
return None;
|
||||
}
|
||||
|
||||
self.router
|
||||
.release_permit_neutral(&provider.id, app_type_str, used_half_open_permit)
|
||||
.await;
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(retry_err.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate =
|
||||
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
|
||||
}
|
||||
Some(ForwardError {
|
||||
error: retry_err,
|
||||
provider: Some(provider.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
/// 转发请求(带故障转移)
|
||||
///
|
||||
/// 这是 thin wrapper:在客户端请求维度记一次 `total_requests` / 调整
|
||||
/// `active_connections` / 刷新 `last_request_at`,无论 inner 走哪条出口路径,
|
||||
/// 出口处都会把 `active_connections` 回收。Per-attempt 维度(成功/失败/熔断
|
||||
/// 等)仍由 inner 内自行更新 `success_requests` / `failed_requests`。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn forward_with_retry(
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
method: http::Method,
|
||||
endpoint: &str,
|
||||
body: Value,
|
||||
headers: axum::http::HeaderMap,
|
||||
extensions: Extensions,
|
||||
providers: Vec<Provider>,
|
||||
) -> Result<ForwardResult, ForwardError> {
|
||||
let guard = ActiveConnectionGuard::acquire(self.status.clone()).await;
|
||||
{
|
||||
let mut s = self.status.write().await;
|
||||
s.total_requests = s.total_requests.saturating_add(1);
|
||||
s.last_request_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
}
|
||||
let result = self
|
||||
.forward_with_retry_inner(
|
||||
app_type, method, endpoint, body, headers, extensions, providers,
|
||||
)
|
||||
.await;
|
||||
// 把 guard 注入到 Ok 结果,让它随响应一起流转到 response_processor,
|
||||
// 在流式 body 的 future 内才真正 drop。
|
||||
// Err 路径:guard 在函数 scope 内随返回值落地时自动 drop。
|
||||
result.map(|mut fr| {
|
||||
fr.connection_guard = Some(guard);
|
||||
fr
|
||||
})
|
||||
}
|
||||
|
||||
/// 实际转发逻辑(不包含客户端维度的入口/出口计数)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `app_type` - 应用类型
|
||||
/// * `method` - 客户端请求的 HTTP 方法(透传给上游,支持 GET/POST 等)
|
||||
/// * `endpoint` - API 端点
|
||||
/// * `body` - 请求体
|
||||
/// * `headers` - 请求头
|
||||
/// * `providers` - 已选择的 Provider 列表(由 RequestContext 提供,避免重复调用 select_providers)
|
||||
pub async fn forward_with_retry(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn forward_with_retry_inner(
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
method: http::Method,
|
||||
endpoint: &str,
|
||||
body: Value,
|
||||
headers: axum::http::HeaderMap,
|
||||
@@ -175,15 +332,27 @@ impl RequestForwarder {
|
||||
let mut last_provider = None;
|
||||
let mut attempted_providers = 0usize;
|
||||
|
||||
// 整流器重试标记:确保整流最多触发一次
|
||||
let mut rectifier_retried = false;
|
||||
let mut budget_rectifier_retried = false;
|
||||
|
||||
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
||||
let bypass_circuit_breaker = providers.len() == 1;
|
||||
|
||||
// 依次尝试每个供应商
|
||||
for provider in providers.iter() {
|
||||
// 整流器重试标记:每个 provider 独立持有,避免标记跨 provider 短路故障转移
|
||||
// —— 首家 provider 整流后被 5xx/timeout 击落时,下家仍能用整流后的请求体走整流流程
|
||||
let mut rectifier_retried = false;
|
||||
let mut budget_rectifier_retried = false;
|
||||
|
||||
// 上限检查:尊重用户在 AppProxyConfig.max_retries 上配置的「重试次数」。
|
||||
// 放在熔断器 allow 检查之前,避免在已经超限时还占用 HalfOpen 探测名额。
|
||||
if attempted_providers >= self.max_attempts {
|
||||
log::warn!(
|
||||
"[{app_type_str}] 已达最大尝试次数上限 ({}/{}), 停止故障转移",
|
||||
attempted_providers,
|
||||
self.max_attempts
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额)
|
||||
// 单 Provider 场景下跳过此检查,避免熔断器阻塞所有请求
|
||||
let (allowed, used_half_open_permit) = if bypass_circuit_breaker {
|
||||
@@ -218,19 +387,22 @@ impl RequestForwarder {
|
||||
|
||||
attempted_providers += 1;
|
||||
|
||||
// 更新状态中的当前Provider信息
|
||||
// 更新状态中的当前 Provider 信息(per-attempt 维度的标识)
|
||||
//
|
||||
// total_requests / last_request_at / active_connections 已由
|
||||
// forward_with_retry wrapper 在客户端请求维度统一处理,这里只刷
|
||||
// 新「正在尝试哪个 provider」的展示字段。
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.current_provider = Some(provider.name.clone());
|
||||
status.current_provider_id = Some(provider.id.clone());
|
||||
status.total_requests += 1;
|
||||
status.last_request_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
}
|
||||
|
||||
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制)
|
||||
match self
|
||||
.forward(
|
||||
app_type,
|
||||
&method,
|
||||
provider,
|
||||
endpoint,
|
||||
&provider_body,
|
||||
@@ -288,6 +460,7 @@ impl RequestForwarder {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
connection_guard: None,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -355,6 +528,7 @@ impl RequestForwarder {
|
||||
match self
|
||||
.forward(
|
||||
app_type,
|
||||
&method,
|
||||
provider,
|
||||
endpoint,
|
||||
&provider_body,
|
||||
@@ -419,59 +593,28 @@ impl RequestForwarder {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
connection_guard: None,
|
||||
});
|
||||
}
|
||||
Err(retry_err) => {
|
||||
// 整流重试仍失败:区分错误类型决定是否记录熔断器
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
|
||||
);
|
||||
|
||||
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
|
||||
let is_provider_error = match &retry_err {
|
||||
ProxyError::Timeout(_)
|
||||
| ProxyError::ForwardFailed(_) => true,
|
||||
ProxyError::UpstreamError { status, .. } => {
|
||||
*status >= 500
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if is_provider_error {
|
||||
// Provider 问题:记录失败到熔断器
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
false,
|
||||
Some(retry_err.to_string()),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
// 客户端问题:仅释放 permit,不记录熔断器
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
if let Some(err) = self
|
||||
.handle_rectifier_retry_failure(
|
||||
retry_err,
|
||||
provider,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
"整流",
|
||||
&mut last_error,
|
||||
&mut last_provider,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(retry_err.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: retry_err,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -550,6 +693,7 @@ impl RequestForwarder {
|
||||
match self
|
||||
.forward(
|
||||
app_type,
|
||||
&method,
|
||||
provider,
|
||||
endpoint,
|
||||
&provider_body,
|
||||
@@ -608,54 +752,28 @@ impl RequestForwarder {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
connection_guard: None,
|
||||
});
|
||||
}
|
||||
Err(retry_err) => {
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-012] budget 整流重试仍失败: {retry_err}"
|
||||
);
|
||||
|
||||
let is_provider_error = match &retry_err {
|
||||
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
|
||||
true
|
||||
}
|
||||
ProxyError::UpstreamError { status, .. } => *status >= 500,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if is_provider_error {
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
false,
|
||||
Some(retry_err.to_string()),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
if let Some(err) = self
|
||||
.handle_rectifier_retry_failure(
|
||||
retry_err,
|
||||
provider,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
"budget 整流",
|
||||
&mut last_error,
|
||||
&mut last_provider,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(retry_err.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: retry_err,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -683,24 +801,25 @@ impl RequestForwarder {
|
||||
});
|
||||
}
|
||||
|
||||
// 失败:记录失败并更新熔断器
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
false,
|
||||
Some(e.to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
// 分类错误
|
||||
// 先分类错误,决定是否计入 provider 健康度
|
||||
// —— NonRetryable / ClientAbort 是客户端层错误,无论换哪家 provider 都会被拒绝,
|
||||
// 不应污染熔断器和数据库健康度(与 release_permit_neutral 同语义)。
|
||||
let category = self.categorize_proxy_error(&e);
|
||||
|
||||
match category {
|
||||
ErrorCategory::Retryable => {
|
||||
// 可重试:更新错误信息,继续尝试下一个供应商
|
||||
// 可重试:真正的 provider 故障 → 记录失败并更新熔断器/DB 健康度
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
false,
|
||||
Some(e.to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.last_error =
|
||||
@@ -721,7 +840,14 @@ impl RequestForwarder {
|
||||
continue;
|
||||
}
|
||||
ErrorCategory::NonRetryable | ErrorCategory::ClientAbort => {
|
||||
// 不可重试:直接返回错误
|
||||
// 不可重试:客户端层错误或客户端断连 → 不污染健康度,仅释放 HalfOpen permit
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
@@ -787,6 +913,7 @@ impl RequestForwarder {
|
||||
async fn forward(
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
method: &http::Method,
|
||||
provider: &Provider,
|
||||
endpoint: &str,
|
||||
body: &Value,
|
||||
@@ -803,6 +930,14 @@ impl RequestForwarder {
|
||||
.and_then(|meta| meta.is_full_url)
|
||||
.unwrap_or(false);
|
||||
|
||||
// GitHub Copilot API 使用 /chat/completions(无 /v1 前缀)
|
||||
let is_copilot = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("github_copilot")
|
||||
|| base_url.contains("githubcopilot.com");
|
||||
|
||||
// 应用模型映射(独立于格式转换)
|
||||
// Claude Desktop proxy 模式必须先把 Desktop 可见的 claude-* route
|
||||
// 映射成真实上游模型名,并且未知 route 要直接报错,不能使用默认模型兜底。
|
||||
@@ -818,20 +953,14 @@ impl RequestForwarder {
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mut mapped_body = normalize_thinking_type(mapped_body);
|
||||
|
||||
// 确定有效端点
|
||||
// GitHub Copilot API 使用 /chat/completions(无 /v1 前缀)
|
||||
let is_copilot = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("github_copilot")
|
||||
|| base_url.contains("githubcopilot.com");
|
||||
|
||||
if is_copilot {
|
||||
mapped_body =
|
||||
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
|
||||
self.apply_copilot_live_model_resolution(provider, &mut mapped_body)
|
||||
.await;
|
||||
} else {
|
||||
mapped_body =
|
||||
super::model_mapper::strip_one_m_suffix_for_upstream_from_body(mapped_body);
|
||||
}
|
||||
|
||||
// --- Copilot 优化器:分类 + 请求体优化(在格式转换之前执行) ---
|
||||
@@ -1036,8 +1165,9 @@ impl RequestForwarder {
|
||||
&filtered_body,
|
||||
self.session_client_provided,
|
||||
);
|
||||
let force_identity_encoding = needs_transform
|
||||
|| should_force_identity_encoding(&effective_endpoint, &filtered_body, headers);
|
||||
let request_is_streaming =
|
||||
is_streaming_request(&effective_endpoint, &filtered_body, headers);
|
||||
let force_identity_encoding = needs_transform || request_is_streaming;
|
||||
|
||||
// Codex OAuth 需要注入的 ChatGPT-Account-Id(在动态 token 获取期间填充)
|
||||
let mut codex_oauth_account_id: Option<String> = None;
|
||||
@@ -1149,7 +1279,7 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
adapter.get_auth_headers(&auth)
|
||||
adapter.get_auth_headers(&auth)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -1414,9 +1544,15 @@ impl RequestForwarder {
|
||||
ordered_headers.insert(name, value);
|
||||
}
|
||||
|
||||
// 序列化请求体
|
||||
let body_bytes = serde_json::to_vec(&filtered_body)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to serialize request body: {e}")))?;
|
||||
// 序列化请求体。GET/HEAD 是 idempotent/safe 方法,按 HTTP 语义不应携带 body;
|
||||
// 强行附带 JSON body 会让某些上游(如 Google Gemini 的 models.list)拒绝请求。
|
||||
let body_bytes = if matches!(method, &http::Method::GET | &http::Method::HEAD) {
|
||||
Vec::new()
|
||||
} else {
|
||||
serde_json::to_vec(&filtered_body).map_err(|e| {
|
||||
ProxyError::Internal(format!("Failed to serialize request body: {e}"))
|
||||
})?
|
||||
};
|
||||
|
||||
// 确保 content-type 存在
|
||||
if !ordered_headers.contains_key(http::header::CONTENT_TYPE) {
|
||||
@@ -1474,9 +1610,7 @@ impl RequestForwarder {
|
||||
"[Forwarder] Using pooled reqwest client (preserve_exact_header_case={preserve_exact_header_case}, socks_proxy={is_socks_proxy})"
|
||||
);
|
||||
let client = super::http_client::get();
|
||||
let mut request = client.post(&url);
|
||||
let request_is_streaming =
|
||||
is_streaming_request(&effective_endpoint, &filtered_body, headers);
|
||||
let mut request = client.request(method.clone(), &url);
|
||||
if request_is_streaming {
|
||||
// reqwest 的 timeout 是整请求超时;流式请求交给 response_processor
|
||||
// 的首包/静默期超时控制,避免长流被总时长误杀。
|
||||
@@ -1515,7 +1649,7 @@ impl RequestForwarder {
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid URL '{url}': {e}")))?;
|
||||
super::hyper_client::send_request(
|
||||
uri,
|
||||
http::Method::POST,
|
||||
method.clone(),
|
||||
ordered_headers,
|
||||
extensions.clone(),
|
||||
body_bytes,
|
||||
@@ -1529,6 +1663,9 @@ impl RequestForwarder {
|
||||
let status = response.status();
|
||||
|
||||
if status.is_success() {
|
||||
let response = self
|
||||
.prepare_success_response_for_failover(response, request_is_streaming)
|
||||
.await?;
|
||||
Ok((response, resolved_claude_api_format))
|
||||
} else {
|
||||
let status_code = status.as_u16();
|
||||
@@ -1541,6 +1678,73 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
/// 故障转移开启时,成功不能只看上游响应头。
|
||||
///
|
||||
/// - 非流式:先把完整 body 读到内存,读超时/连接中断会回到 retry loop 尝试下一家。
|
||||
/// - 流式:至少等首个 chunk 到达,避免上游返回 200 后一直不吐 SSE 时被误记成功。
|
||||
async fn prepare_success_response_for_failover(
|
||||
&self,
|
||||
response: ProxyResponse,
|
||||
request_is_streaming: bool,
|
||||
) -> Result<ProxyResponse, ProxyError> {
|
||||
if request_is_streaming {
|
||||
return self.prime_streaming_response(response).await;
|
||||
}
|
||||
|
||||
if self.non_streaming_timeout.is_zero() {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let body_timeout = self.non_streaming_timeout;
|
||||
let body = tokio::time::timeout(body_timeout, response.bytes())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ProxyError::Timeout(format!(
|
||||
"响应体读取超时: {}s(上游发完响应头后 body 未到达)",
|
||||
body_timeout.as_secs()
|
||||
))
|
||||
})??;
|
||||
|
||||
Ok(ProxyResponse::buffered(status, headers, body))
|
||||
}
|
||||
|
||||
async fn prime_streaming_response(
|
||||
&self,
|
||||
response: ProxyResponse,
|
||||
) -> Result<ProxyResponse, ProxyError> {
|
||||
if self.streaming_first_byte_timeout.is_zero() {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let timeout = self.streaming_first_byte_timeout;
|
||||
let mut stream = Box::pin(response.bytes_stream());
|
||||
|
||||
let first = tokio::time::timeout(timeout, stream.next())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ProxyError::Timeout(format!(
|
||||
"流式响应首包超时: {}s(上游已返回响应头但未返回数据)",
|
||||
timeout.as_secs()
|
||||
))
|
||||
})?;
|
||||
|
||||
let Some(first) = first else {
|
||||
return Err(ProxyError::ForwardFailed(
|
||||
"流式响应在首包到达前结束".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let first =
|
||||
first.map_err(|e| ProxyError::ForwardFailed(format!("读取流式响应首包失败: {e}")))?;
|
||||
|
||||
let replay = futures::stream::once(async move { Ok(first) }).chain(stream);
|
||||
Ok(ProxyResponse::streamed(status, headers, replay))
|
||||
}
|
||||
|
||||
async fn resolve_claude_api_format(
|
||||
&self,
|
||||
provider: &Provider,
|
||||
@@ -1652,10 +1856,22 @@ impl RequestForwarder {
|
||||
ProxyError::Timeout(_) => ErrorCategory::Retryable,
|
||||
ProxyError::ForwardFailed(_) => ErrorCategory::Retryable,
|
||||
ProxyError::ProviderUnhealthy(_) => ErrorCategory::Retryable,
|
||||
// 上游 HTTP 错误:无论状态码如何,都尝试下一个供应商
|
||||
// 原因:不同供应商有不同的限制和认证,一个供应商的 4xx 错误
|
||||
// 不代表其他供应商也会失败
|
||||
ProxyError::UpstreamError { .. } => ErrorCategory::Retryable,
|
||||
// 上游 HTTP 错误:按状态码分桶。
|
||||
//
|
||||
// 客户端请求自身有问题的状态码无论换哪个 provider 都会被拒绝,
|
||||
// 继续轮询只会放大错误率、污染熔断器健康度、浪费配额:
|
||||
// 400 Bad Request / 422 Unprocessable Entity ← 请求体格式或语义错误
|
||||
// 405 Method Not Allowed / 406 Not Acceptable ← 方法或 Accept 错误
|
||||
// 413 Payload Too Large / 414 URI Too Long ← 客户端构造超限
|
||||
// 415 Unsupported Media Type ← Content-Type 错误
|
||||
// 501 Not Implemented ← 上游协议确实不支持
|
||||
//
|
||||
// 其他 4xx(401/403/404/408/409/429/451 等)和全部 5xx 都保留
|
||||
// Retryable —— 换一家 provider 可能持有不同的 key、配额、地域或模型映射。
|
||||
ProxyError::UpstreamError { status, .. } => match *status {
|
||||
400 | 405 | 406 | 413 | 414 | 415 | 422 | 501 => ErrorCategory::NonRetryable,
|
||||
_ => ErrorCategory::Retryable,
|
||||
},
|
||||
// Provider 级配置/转换问题:换一个 Provider 可能就能成功
|
||||
ProxyError::ConfigError(_) => ErrorCategory::Retryable,
|
||||
ProxyError::TransformError(_) => ErrorCategory::Retryable,
|
||||
@@ -1974,6 +2190,7 @@ fn is_streaming_request(endpoint: &str, body: &Value, headers: &axum::http::Head
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn should_force_identity_encoding(
|
||||
endpoint: &str,
|
||||
body: &Value,
|
||||
@@ -2067,9 +2284,14 @@ fn value_for_log(value: &Value) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::database::Database;
|
||||
use axum::http::header::{HeaderValue, ACCEPT};
|
||||
use axum::http::HeaderMap;
|
||||
use bytes::Bytes;
|
||||
use http::StatusCode;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
fn test_provider_with_type(provider_type: Option<&str>) -> Provider {
|
||||
Provider {
|
||||
@@ -2091,6 +2313,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_forwarder(
|
||||
non_streaming_timeout: Duration,
|
||||
streaming_first_byte_timeout: Duration,
|
||||
) -> RequestForwarder {
|
||||
let db = Arc::new(Database::memory().expect("memory db"));
|
||||
|
||||
RequestForwarder {
|
||||
router: Arc::new(ProviderRouter::new(db.clone())),
|
||||
status: Arc::new(RwLock::new(ProxyStatus::default())),
|
||||
current_providers: Arc::new(RwLock::new(HashMap::new())),
|
||||
gemini_shadow: Arc::new(GeminiShadowStore::new()),
|
||||
failover_manager: Arc::new(FailoverSwitchManager::new(db)),
|
||||
app_handle: None,
|
||||
current_provider_id_at_start: String::new(),
|
||||
session_id: String::new(),
|
||||
session_client_provided: false,
|
||||
rectifier_config: RectifierConfig::default(),
|
||||
optimizer_config: OptimizerConfig::default(),
|
||||
copilot_optimizer_config: CopilotOptimizerConfig::default(),
|
||||
non_streaming_timeout,
|
||||
streaming_first_byte_timeout,
|
||||
max_attempts: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_provider_retryable_log_uses_single_provider_code() {
|
||||
let error = ProxyError::UpstreamError {
|
||||
@@ -2236,6 +2483,96 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_streaming_success_is_buffered_before_marking_provider_successful() {
|
||||
let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1));
|
||||
let response = ProxyResponse::streamed(
|
||||
StatusCode::OK,
|
||||
HeaderMap::new(),
|
||||
futures::stream::once(async {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
Ok::<Bytes, std::io::Error>(Bytes::from_static(b"{\"ok\":true}"))
|
||||
}),
|
||||
);
|
||||
|
||||
let prepared = forwarder
|
||||
.prepare_success_response_for_failover(response, false)
|
||||
.await
|
||||
.expect("response should be buffered");
|
||||
|
||||
assert_eq!(
|
||||
prepared.bytes().await.unwrap(),
|
||||
Bytes::from_static(b"{\"ok\":true}")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_streaming_body_read_error_is_retryable_before_success_record() {
|
||||
let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1));
|
||||
let response = ProxyResponse::streamed(
|
||||
StatusCode::OK,
|
||||
HeaderMap::new(),
|
||||
futures::stream::once(async {
|
||||
Err::<Bytes, std::io::Error>(std::io::Error::other("body boom"))
|
||||
}),
|
||||
);
|
||||
|
||||
let err = match forwarder
|
||||
.prepare_success_response_for_failover(response, false)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("body read errors should fail the attempt"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
assert!(matches!(err, ProxyError::ForwardFailed(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_success_primes_first_chunk_and_replays_it() {
|
||||
let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1));
|
||||
let response = ProxyResponse::streamed(
|
||||
StatusCode::OK,
|
||||
HeaderMap::new(),
|
||||
futures::stream::iter(vec![
|
||||
Ok::<Bytes, std::io::Error>(Bytes::from_static(b"first")),
|
||||
Ok::<Bytes, std::io::Error>(Bytes::from_static(b"second")),
|
||||
]),
|
||||
);
|
||||
|
||||
let prepared = forwarder
|
||||
.prepare_success_response_for_failover(response, true)
|
||||
.await
|
||||
.expect("stream should be primed");
|
||||
|
||||
assert_eq!(
|
||||
prepared.bytes().await.unwrap(),
|
||||
Bytes::from_static(b"firstsecond")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_first_chunk_error_is_retryable_before_success_record() {
|
||||
let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1));
|
||||
let response = ProxyResponse::streamed(
|
||||
StatusCode::OK,
|
||||
HeaderMap::new(),
|
||||
futures::stream::once(async {
|
||||
Err::<Bytes, std::io::Error>(std::io::Error::other("first chunk boom"))
|
||||
}),
|
||||
);
|
||||
|
||||
let err = match forwarder
|
||||
.prepare_success_response_for_failover(response, true)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("first chunk errors should fail the attempt"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
assert!(matches!(err, ProxyError::ForwardFailed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_oauth_session_headers_match_codex_cache_identity() {
|
||||
let headers = build_codex_oauth_session_headers("session-123");
|
||||
|
||||
@@ -175,18 +175,12 @@ impl RequestContext {
|
||||
/// Gemini API 的模型名称在 URI 中,格式如:
|
||||
/// `/v1beta/models/gemini-pro:generateContent`
|
||||
pub fn with_model_from_uri(mut self, uri: &axum::http::Uri) -> Self {
|
||||
let endpoint = uri
|
||||
.path_and_query()
|
||||
.map(|pq| pq.as_str())
|
||||
.unwrap_or(uri.path());
|
||||
// 用 path() 而不是 path_and_query():模型名必须从路径段中解析,
|
||||
// 否则 GET /v1beta/models/<id>?key=... 会把 query 拼到 request_model 上。
|
||||
let endpoint = uri.path();
|
||||
|
||||
self.request_model = endpoint
|
||||
.split('/')
|
||||
.find(|s| s.starts_with("models/"))
|
||||
.and_then(|s| s.strip_prefix("models/"))
|
||||
.map(|s| s.split(':').next().unwrap_or(s))
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
self.request_model =
|
||||
extract_gemini_model_from_path(endpoint).unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
self
|
||||
}
|
||||
@@ -216,6 +210,13 @@ impl RequestContext {
|
||||
(0, 0, 0)
|
||||
};
|
||||
|
||||
// 故障转移关闭时强制 max_retries=0(仅尝试 1 个 provider),与「不超时 + 不切换」语义一致。
|
||||
let max_retries = if self.app_config.auto_failover_enabled {
|
||||
self.app_config.max_retries
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
RequestForwarder::new(
|
||||
state.provider_router.clone(),
|
||||
non_streaming_timeout,
|
||||
@@ -232,6 +233,7 @@ impl RequestContext {
|
||||
self.rectifier_config.clone(),
|
||||
self.optimizer_config.clone(),
|
||||
self.copilot_optimizer_config.clone(),
|
||||
max_retries,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -270,3 +272,99 @@ impl RequestContext {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the Gemini model name out of an API path.
|
||||
///
|
||||
/// Accepts forms like `/v1beta/models/gemini-pro:generateContent`,
|
||||
/// `/v1/models/gemini-1.5-flash`, `gemini/v1beta/models/<model>:streamGenerateContent`.
|
||||
/// Returns `None` when no `models/<name>` segment is present.
|
||||
pub(crate) fn extract_gemini_model_from_path(endpoint: &str) -> Option<String> {
|
||||
let segments: Vec<&str> = endpoint.split('/').collect();
|
||||
segments
|
||||
.iter()
|
||||
.position(|s| *s == "models")
|
||||
.and_then(|i| segments.get(i + 1).copied())
|
||||
// 防御性裁剪:即便调用方传入带 ? 或 :action 的字符串,也只保留 model id 本身
|
||||
.map(|s| s.split('?').next().unwrap_or(s))
|
||||
.map(|s| s.split(':').next().unwrap_or(s))
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extract_gemini_model_from_path;
|
||||
|
||||
#[test]
|
||||
fn extract_model_with_action() {
|
||||
assert_eq!(
|
||||
extract_gemini_model_from_path("/v1beta/models/gemini-pro:generateContent").as_deref(),
|
||||
Some("gemini-pro"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_model_with_dotted_version() {
|
||||
assert_eq!(
|
||||
extract_gemini_model_from_path("/v1beta/models/gemini-1.5-flash:streamGenerateContent")
|
||||
.as_deref(),
|
||||
Some("gemini-1.5-flash"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_model_without_action() {
|
||||
assert_eq!(
|
||||
extract_gemini_model_from_path("/v1/models/gemini-1.5-pro").as_deref(),
|
||||
Some("gemini-1.5-pro"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_model_with_proxy_prefix() {
|
||||
assert_eq!(
|
||||
extract_gemini_model_from_path("/gemini/v1beta/models/gemini-2.0-flash:countTokens")
|
||||
.as_deref(),
|
||||
Some("gemini-2.0-flash"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_model_with_query_string() {
|
||||
assert_eq!(
|
||||
extract_gemini_model_from_path("/v1beta/models/gemini-pro:generateContent?key=abc")
|
||||
.as_deref(),
|
||||
Some("gemini-pro"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_model_missing_segment() {
|
||||
assert_eq!(extract_gemini_model_from_path("/v1beta/operations"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_model_trailing_models_segment() {
|
||||
// `/v1beta/models` (list endpoint) has no following segment → None.
|
||||
assert_eq!(extract_gemini_model_from_path("/v1beta/models"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_model_get_with_query_only() {
|
||||
// GET /v1beta/models/<id>?key=... 无 action verb,仅靠 ':' 拆分会把 query 带进 model 名。
|
||||
// 修复后应该把 query 剥掉。
|
||||
assert_eq!(
|
||||
extract_gemini_model_from_path("/v1beta/models/gemini-pro?key=abc").as_deref(),
|
||||
Some("gemini-pro"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_model_get_with_proxy_prefix_and_query() {
|
||||
assert_eq!(
|
||||
extract_gemini_model_from_path("/gemini/v1beta/models/gemini-2.0-flash?key=abc")
|
||||
.as_deref(),
|
||||
Some("gemini-2.0-flash"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
use super::{
|
||||
error_mapper::{get_error_message, map_proxy_error_to_status},
|
||||
forwarder::ActiveConnectionGuard,
|
||||
handler_config::{
|
||||
claude_stream_usage_event_filter, CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG,
|
||||
GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
|
||||
@@ -32,6 +33,7 @@ use super::{
|
||||
ProxyError,
|
||||
};
|
||||
use crate::app_config::AppType;
|
||||
use crate::database::PRICING_SOURCE_REQUEST;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use bytes::Bytes;
|
||||
use http_body_util::BodyExt;
|
||||
@@ -115,6 +117,7 @@ async fn handle_messages_for_app(
|
||||
strip_prefix: Option<&'static str>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
@@ -144,9 +147,10 @@ async fn handle_messages_for_app(
|
||||
|
||||
// 转发请求
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let result = match forwarder
|
||||
let mut result = match forwarder
|
||||
.forward_with_retry(
|
||||
&app_type,
|
||||
method,
|
||||
endpoint,
|
||||
body.clone(),
|
||||
headers,
|
||||
@@ -165,6 +169,7 @@ async fn handle_messages_for_app(
|
||||
}
|
||||
};
|
||||
|
||||
let connection_guard = result.connection_guard.take();
|
||||
ctx.provider = result.provider;
|
||||
let api_format = result
|
||||
.claude_api_format
|
||||
@@ -179,12 +184,27 @@ async fn handle_messages_for_app(
|
||||
|
||||
// Claude 特有:格式转换处理
|
||||
if needs_transform {
|
||||
return handle_claude_transform(response, &ctx, &state, &body, is_stream, &api_format)
|
||||
.await;
|
||||
return handle_claude_transform(
|
||||
response,
|
||||
&ctx,
|
||||
&state,
|
||||
&body,
|
||||
is_stream,
|
||||
&api_format,
|
||||
connection_guard,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// 通用响应处理(透传模式)
|
||||
process_response(response, &ctx, &state, &CLAUDE_PARSER_CONFIG).await
|
||||
process_response(
|
||||
response,
|
||||
&ctx,
|
||||
&state,
|
||||
&CLAUDE_PARSER_CONFIG,
|
||||
connection_guard,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn validate_claude_desktop_gateway_auth(
|
||||
@@ -224,6 +244,7 @@ async fn handle_claude_transform(
|
||||
original_body: &Value,
|
||||
is_stream: bool,
|
||||
api_format: &str,
|
||||
connection_guard: Option<ActiveConnectionGuard>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let status = response.status();
|
||||
let is_codex_oauth = ctx
|
||||
@@ -324,6 +345,7 @@ async fn handle_claude_transform(
|
||||
"Claude/OpenRouter",
|
||||
usage_collector,
|
||||
timeout_config,
|
||||
connection_guard,
|
||||
);
|
||||
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
@@ -453,6 +475,7 @@ pub async fn handle_chat_completions(
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
@@ -474,9 +497,10 @@ pub async fn handle_chat_completions(
|
||||
.unwrap_or(false);
|
||||
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let result = match forwarder
|
||||
let mut result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
method,
|
||||
&endpoint,
|
||||
body,
|
||||
headers,
|
||||
@@ -495,10 +519,18 @@ pub async fn handle_chat_completions(
|
||||
}
|
||||
};
|
||||
|
||||
let connection_guard = result.connection_guard.take();
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
|
||||
process_response(
|
||||
response,
|
||||
&ctx,
|
||||
&state,
|
||||
&OPENAI_PARSER_CONFIG,
|
||||
connection_guard,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传)
|
||||
@@ -507,6 +539,7 @@ pub async fn handle_responses(
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
@@ -528,9 +561,10 @@ pub async fn handle_responses(
|
||||
.unwrap_or(false);
|
||||
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let result = match forwarder
|
||||
let mut result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
method,
|
||||
&endpoint,
|
||||
body,
|
||||
headers,
|
||||
@@ -549,10 +583,18 @@ pub async fn handle_responses(
|
||||
}
|
||||
};
|
||||
|
||||
let connection_guard = result.connection_guard.take();
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
|
||||
process_response(
|
||||
response,
|
||||
&ctx,
|
||||
&state,
|
||||
&CODEX_PARSER_CONFIG,
|
||||
connection_guard,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 处理 /v1/responses/compact 请求(OpenAI Responses Compact API - Codex CLI 透传)
|
||||
@@ -561,6 +603,7 @@ pub async fn handle_responses_compact(
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
@@ -582,9 +625,10 @@ pub async fn handle_responses_compact(
|
||||
.unwrap_or(false);
|
||||
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let result = match forwarder
|
||||
let mut result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
method,
|
||||
&endpoint,
|
||||
body,
|
||||
headers,
|
||||
@@ -603,10 +647,18 @@ pub async fn handle_responses_compact(
|
||||
}
|
||||
};
|
||||
|
||||
let connection_guard = result.connection_guard.take();
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
|
||||
process_response(
|
||||
response,
|
||||
&ctx,
|
||||
&state,
|
||||
&CODEX_PARSER_CONFIG,
|
||||
connection_guard,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -620,6 +672,7 @@ pub async fn handle_gemini(
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let method = parts.method.clone();
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
@@ -627,8 +680,14 @@ pub async fn handle_gemini(
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
// GET 类只读端点(/v1beta/models、/v1beta/models/<model> 等)没有请求体,
|
||||
// 不能强制 parse 为 JSON —— 否则空 body 会被拒绝。
|
||||
let body: Value = if body_bytes.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?
|
||||
};
|
||||
|
||||
// Gemini 的模型名称在 URI 中
|
||||
let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Gemini, "Gemini", "gemini")
|
||||
@@ -647,9 +706,10 @@ pub async fn handle_gemini(
|
||||
.unwrap_or(false);
|
||||
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let result = match forwarder
|
||||
let mut result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Gemini,
|
||||
method,
|
||||
endpoint,
|
||||
body,
|
||||
headers,
|
||||
@@ -668,10 +728,18 @@ pub async fn handle_gemini(
|
||||
}
|
||||
};
|
||||
|
||||
let connection_guard = result.connection_guard.take();
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
|
||||
process_response(
|
||||
response,
|
||||
&ctx,
|
||||
&state,
|
||||
&GEMINI_PARSER_CONFIG,
|
||||
connection_guard,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn should_use_claude_transform_streaming(
|
||||
@@ -813,7 +881,7 @@ async fn log_usage(
|
||||
|
||||
let (multiplier, pricing_model_source) =
|
||||
logger.resolve_pricing_config(provider_id, app_type).await;
|
||||
let pricing_model = if pricing_model_source == "request" {
|
||||
let pricing_model = if pricing_model_source == PRICING_SOURCE_REQUEST {
|
||||
request_model
|
||||
} else {
|
||||
model
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use super::ProxyError;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::Stream;
|
||||
use futures::{stream::Stream, StreamExt};
|
||||
use http_body_util::BodyExt;
|
||||
use hyper_rustls::HttpsConnectorBuilder;
|
||||
use hyper_util::{client::legacy::Client, rt::TokioExecutor};
|
||||
@@ -79,13 +79,44 @@ fn global_hyper_client() -> &'static HyperClient {
|
||||
pub enum ProxyResponse {
|
||||
Hyper(hyper::Response<hyper::body::Incoming>),
|
||||
Reqwest(reqwest::Response),
|
||||
Buffered {
|
||||
status: http::StatusCode,
|
||||
headers: http::HeaderMap,
|
||||
body: Bytes,
|
||||
},
|
||||
Streamed {
|
||||
status: http::StatusCode,
|
||||
headers: http::HeaderMap,
|
||||
stream: std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ProxyResponse {
|
||||
pub fn buffered(status: http::StatusCode, headers: http::HeaderMap, body: Bytes) -> Self {
|
||||
Self::Buffered {
|
||||
status,
|
||||
headers,
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn streamed(
|
||||
status: http::StatusCode,
|
||||
headers: http::HeaderMap,
|
||||
stream: impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,
|
||||
) -> Self {
|
||||
Self::Streamed {
|
||||
status,
|
||||
headers,
|
||||
stream: Box::pin(stream),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> http::StatusCode {
|
||||
match self {
|
||||
Self::Hyper(r) => r.status(),
|
||||
Self::Reqwest(r) => r.status(),
|
||||
Self::Buffered { status, .. } | Self::Streamed { status, .. } => *status,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +124,7 @@ impl ProxyResponse {
|
||||
match self {
|
||||
Self::Hyper(r) => r.headers(),
|
||||
Self::Reqwest(r) => r.headers(),
|
||||
Self::Buffered { headers, .. } | Self::Streamed { headers, .. } => headers,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +154,17 @@ impl ProxyResponse {
|
||||
Self::Reqwest(r) => r.bytes().await.map_err(|e| {
|
||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||
}),
|
||||
Self::Buffered { body, .. } => Ok(body),
|
||||
Self::Streamed { mut stream, .. } => {
|
||||
let mut body = bytes::BytesMut::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| {
|
||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||
})?;
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(body.freeze())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +204,9 @@ impl ProxyResponse {
|
||||
.map(|r| r.map_err(|e| std::io::Error::other(e.to_string())));
|
||||
Box::pin(stream)
|
||||
}
|
||||
Self::Buffered { body, .. } => Box::pin(futures::stream::once(async move { Ok(body) }))
|
||||
as std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
|
||||
Self::Streamed { stream, .. } => stream,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! 在请求转发前,根据 Provider 配置替换请求中的模型名称
|
||||
|
||||
use crate::claude_desktop_config::ONE_M_CONTEXT_MARKER;
|
||||
use crate::provider::Provider;
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -112,6 +113,33 @@ pub fn apply_model_mapping(
|
||||
(body, original_model, None)
|
||||
}
|
||||
|
||||
/// Claude Code 通过 `[1M]` 后缀声明 100 万上下文能力;上游 API
|
||||
/// 通常不接受这个本地能力标记,转发前需要剥离。
|
||||
pub fn strip_one_m_suffix_for_upstream(model: &str) -> &str {
|
||||
let trimmed = model.trim_end();
|
||||
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();
|
||||
}
|
||||
model
|
||||
}
|
||||
|
||||
pub fn strip_one_m_suffix_for_upstream_from_body(mut body: Value) -> Value {
|
||||
let Some(model) = body.get("model").and_then(Value::as_str) else {
|
||||
return body;
|
||||
};
|
||||
|
||||
let stripped = strip_one_m_suffix_for_upstream(model);
|
||||
if stripped != model {
|
||||
log::debug!("[ModelMapper] 去除本地 1M 标记: {model} → {stripped}");
|
||||
body["model"] = serde_json::json!(stripped);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -251,4 +279,34 @@ mod tests {
|
||||
assert_eq!(result["model"], "sonnet-mapped");
|
||||
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_one_m_suffix_before_upstream() {
|
||||
let body = json!({"model": "deepseek-v4-pro[1M]"});
|
||||
let result = strip_one_m_suffix_for_upstream_from_body(body);
|
||||
assert_eq!(result["model"], "deepseek-v4-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_one_m_suffix_after_mapping() {
|
||||
let mut provider = create_provider_with_mapping();
|
||||
provider.settings_config = json!({
|
||||
"env": {
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-pro [1M]"
|
||||
}
|
||||
});
|
||||
|
||||
let body = json!({"model": "claude-sonnet-4-6"});
|
||||
let (mapped, _, _) = apply_model_mapping(body, &provider);
|
||||
let result = strip_one_m_suffix_for_upstream_from_body(mapped);
|
||||
|
||||
assert_eq!(result["model"], "deepseek-v4-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_model_without_one_m_suffix() {
|
||||
let body = json!({"model": "deepseek-v4-pro"});
|
||||
let result = strip_one_m_suffix_for_upstream_from_body(body);
|
||||
assert_eq!(result["model"], "deepseek-v4-pro");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +201,17 @@ impl ProviderRouter {
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新指定应用已创建熔断器的配置(热更新)
|
||||
pub async fn update_app_configs(&self, app_type: &str, config: CircuitBreakerConfig) {
|
||||
let prefix = format!("{app_type}:");
|
||||
let breakers = self.circuit_breakers.read().await;
|
||||
for (key, breaker) in breakers.iter() {
|
||||
if key.starts_with(&prefix) {
|
||||
breaker.update_config(config.clone()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取熔断器状态
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_circuit_breaker_stats(
|
||||
|
||||
@@ -30,7 +30,14 @@ pub trait ProviderAdapter: Send + Sync {
|
||||
///
|
||||
/// The forwarder inserts these at the position of the original auth header
|
||||
/// so that header order is preserved.
|
||||
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)>;
|
||||
///
|
||||
/// Returns `ProxyError::AuthError` when the credential contains characters
|
||||
/// that cannot be encoded as an HTTP header value (e.g. control chars,
|
||||
/// CR/LF), which would otherwise panic inside `HeaderValue::from_str`.
|
||||
fn get_auth_headers(
|
||||
&self,
|
||||
auth: &AuthInfo,
|
||||
) -> Result<Vec<(http::HeaderName, http::HeaderValue)>, ProxyError>;
|
||||
|
||||
/// 是否需要格式转换
|
||||
fn needs_transform(&self, _provider: &Provider) -> bool {
|
||||
@@ -48,3 +55,15 @@ pub trait ProviderAdapter: Send + Sync {
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an HTTP `HeaderValue` from a credential / token string.
|
||||
///
|
||||
/// Returns `ProxyError::AuthError` when the string contains characters that
|
||||
/// cannot live in an HTTP header value (control bytes, CR/LF, non-ASCII).
|
||||
/// Adapters call this for every header value derived from user-pasted
|
||||
/// material so a malformed key surfaces as a 401 instead of panicking
|
||||
/// the worker via `HeaderValue::from_str(...).unwrap()`.
|
||||
pub fn auth_header_value(s: &str) -> Result<http::HeaderValue, ProxyError> {
|
||||
http::HeaderValue::from_str(s)
|
||||
.map_err(|e| ProxyError::AuthError(format!("invalid auth header value: {e}")))
|
||||
}
|
||||
|
||||
@@ -589,33 +589,31 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
base
|
||||
}
|
||||
|
||||
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)> {
|
||||
fn get_auth_headers(
|
||||
&self,
|
||||
auth: &AuthInfo,
|
||||
) -> Result<Vec<(http::HeaderName, http::HeaderValue)>, ProxyError> {
|
||||
use super::adapter::auth_header_value as hv;
|
||||
use http::{HeaderName, HeaderValue};
|
||||
// 注意:anthropic-version 由 forwarder.rs 统一处理(透传客户端值或设置默认值)
|
||||
let bearer = format!("Bearer {}", auth.api_key);
|
||||
match auth.strategy {
|
||||
Ok(match auth.strategy {
|
||||
AuthStrategy::Anthropic => {
|
||||
vec![(
|
||||
HeaderName::from_static("x-api-key"),
|
||||
HeaderValue::from_str(&auth.api_key).unwrap(),
|
||||
)]
|
||||
vec![(HeaderName::from_static("x-api-key"), hv(&auth.api_key)?)]
|
||||
}
|
||||
AuthStrategy::ClaudeAuth | AuthStrategy::Bearer => {
|
||||
vec![(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&bearer).unwrap(),
|
||||
)]
|
||||
vec![(HeaderName::from_static("authorization"), hv(&bearer)?)]
|
||||
}
|
||||
AuthStrategy::Google => vec![(
|
||||
HeaderName::from_static("x-goog-api-key"),
|
||||
HeaderValue::from_str(&auth.api_key).unwrap(),
|
||||
hv(&auth.api_key)?,
|
||||
)],
|
||||
AuthStrategy::GoogleOAuth => {
|
||||
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
|
||||
vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
|
||||
hv(&format!("Bearer {token}"))?,
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-goog-api-client"),
|
||||
@@ -627,10 +625,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
// 注意:bearer token 由 forwarder 动态注入到 auth.api_key
|
||||
// ChatGPT-Account-Id 由 forwarder 注入额外 header
|
||||
vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&bearer).unwrap(),
|
||||
),
|
||||
(HeaderName::from_static("authorization"), hv(&bearer)?),
|
||||
(
|
||||
HeaderName::from_static("originator"),
|
||||
HeaderValue::from_static("cc-switch"),
|
||||
@@ -641,10 +636,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
// 生成请求追踪 ID
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&bearer).unwrap(),
|
||||
),
|
||||
(HeaderName::from_static("authorization"), hv(&bearer)?),
|
||||
(
|
||||
HeaderName::from_static("editor-version"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_EDITOR_VERSION),
|
||||
@@ -683,17 +675,11 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
HeaderName::from_static("x-vscode-user-agent-library-version"),
|
||||
HeaderValue::from_static("electron-fetch"),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-request-id"),
|
||||
HeaderValue::from_str(&request_id).unwrap(),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-agent-task-id"),
|
||||
HeaderValue::from_str(&request_id).unwrap(),
|
||||
),
|
||||
(HeaderName::from_static("x-request-id"), hv(&request_id)?),
|
||||
(HeaderName::from_static("x-agent-task-id"), hv(&request_id)?),
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn needs_transform(&self, provider: &Provider) -> bool {
|
||||
@@ -871,7 +857,7 @@ mod tests {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let auth = AuthInfo::new("sk-ant-test".to_string(), AuthStrategy::Anthropic);
|
||||
|
||||
let headers = adapter.get_auth_headers(&auth);
|
||||
let headers = adapter.get_auth_headers(&auth).unwrap();
|
||||
assert_eq!(headers.len(), 1);
|
||||
assert_eq!(headers[0].0.as_str(), "x-api-key");
|
||||
assert_eq!(headers[0].1.to_str().unwrap(), "sk-ant-test");
|
||||
@@ -882,7 +868,7 @@ mod tests {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let auth = AuthInfo::new("sk-relay-test".to_string(), AuthStrategy::ClaudeAuth);
|
||||
|
||||
let headers = adapter.get_auth_headers(&auth);
|
||||
let headers = adapter.get_auth_headers(&auth).unwrap();
|
||||
assert_eq!(headers.len(), 1);
|
||||
assert_eq!(headers[0].0.as_str(), "authorization");
|
||||
assert_eq!(headers[0].1.to_str().unwrap(), "Bearer sk-relay-test");
|
||||
@@ -893,12 +879,26 @@ mod tests {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let auth = AuthInfo::new("sk-or-test".to_string(), AuthStrategy::Bearer);
|
||||
|
||||
let headers = adapter.get_auth_headers(&auth);
|
||||
let headers = adapter.get_auth_headers(&auth).unwrap();
|
||||
assert_eq!(headers.len(), 1);
|
||||
assert_eq!(headers[0].0.as_str(), "authorization");
|
||||
assert_eq!(headers[0].1.to_str().unwrap(), "Bearer sk-or-test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_auth_headers_rejects_illegal_header_chars() {
|
||||
// 用户粘贴含 \r\n 的"脏"key 不能让进程 panic
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let auth = AuthInfo::new(
|
||||
"sk-ant-bad\r\nX-Inject: 1".to_string(),
|
||||
AuthStrategy::Anthropic,
|
||||
);
|
||||
|
||||
let result = adapter.get_auth_headers(&auth);
|
||||
assert!(result.is_err(), "expected AuthError, got Ok");
|
||||
assert!(matches!(result, Err(ProxyError::AuthError(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_openrouter() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
@@ -173,12 +173,16 @@ impl ProviderAdapter for CodexAdapter {
|
||||
url
|
||||
}
|
||||
|
||||
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)> {
|
||||
fn get_auth_headers(
|
||||
&self,
|
||||
auth: &AuthInfo,
|
||||
) -> Result<Vec<(http::HeaderName, http::HeaderValue)>, ProxyError> {
|
||||
use super::adapter::auth_header_value;
|
||||
let bearer = format!("Bearer {}", auth.api_key);
|
||||
vec![(
|
||||
Ok(vec![(
|
||||
http::HeaderName::from_static("authorization"),
|
||||
http::HeaderValue::from_str(&bearer).unwrap(),
|
||||
)]
|
||||
auth_header_value(&bearer)?,
|
||||
)])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -228,15 +228,19 @@ impl ProviderAdapter for GeminiAdapter {
|
||||
url
|
||||
}
|
||||
|
||||
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)> {
|
||||
fn get_auth_headers(
|
||||
&self,
|
||||
auth: &AuthInfo,
|
||||
) -> Result<Vec<(http::HeaderName, http::HeaderValue)>, ProxyError> {
|
||||
use super::adapter::auth_header_value as hv;
|
||||
use http::{HeaderName, HeaderValue};
|
||||
match auth.strategy {
|
||||
Ok(match auth.strategy {
|
||||
AuthStrategy::GoogleOAuth => {
|
||||
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
|
||||
vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
|
||||
hv(&format!("Bearer {token}"))?,
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-goog-api-client"),
|
||||
@@ -246,9 +250,9 @@ impl ProviderAdapter for GeminiAdapter {
|
||||
}
|
||||
_ => vec![(
|
||||
HeaderName::from_static("x-goog-api-key"),
|
||||
HeaderValue::from_str(&auth.api_key).unwrap(),
|
||||
hv(&auth.api_key)?,
|
||||
)],
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -228,12 +228,49 @@ pub fn anthropic_to_openai_with_reasoning_content(
|
||||
}
|
||||
|
||||
if let Some(v) = body.get("tool_choice") {
|
||||
result["tool_choice"] = v.clone();
|
||||
result["tool_choice"] = map_tool_choice_to_chat(v);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Translate an Anthropic `tool_choice` into the OpenAI Chat Completions form.
|
||||
///
|
||||
/// Anthropic forms:
|
||||
/// "auto" / "any" / "none" (string enum)
|
||||
/// {"type": "auto" | "any" | "none"}
|
||||
/// {"type": "tool", "name": "<X>"}
|
||||
///
|
||||
/// OpenAI Chat forms:
|
||||
/// "auto" / "none" / "required" (note: no "any" — use "required")
|
||||
/// {"type": "function", "function": {"name": "<X>"}}
|
||||
///
|
||||
/// The Responses API uses a flatter `{"type":"function","name":"X"}` selector,
|
||||
/// so it has a sibling `map_tool_choice_to_responses` in `transform_responses.rs`.
|
||||
/// Keep the two in sync.
|
||||
fn map_tool_choice_to_chat(tool_choice: &Value) -> Value {
|
||||
match tool_choice {
|
||||
Value::String(s) => match s.as_str() {
|
||||
"any" => json!("required"),
|
||||
_ => json!(s),
|
||||
},
|
||||
Value::Object(obj) => match obj.get("type").and_then(|t| t.as_str()) {
|
||||
Some("any") => json!("required"),
|
||||
Some("auto") => json!("auto"),
|
||||
Some("none") => json!("none"),
|
||||
Some("tool") => {
|
||||
let name = obj.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": { "name": name }
|
||||
})
|
||||
}
|
||||
_ => tool_choice.clone(),
|
||||
},
|
||||
_ => tool_choice.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
|
||||
let system_count = messages
|
||||
.iter()
|
||||
@@ -1510,4 +1547,51 @@ mod tests {
|
||||
assert_eq!(result["max_tokens"], 1024);
|
||||
assert!(result.get("max_completion_tokens").is_none());
|
||||
}
|
||||
|
||||
fn run_tool_choice(value: Value) -> Value {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
"tools": [{
|
||||
"name": "search",
|
||||
"description": "search the web",
|
||||
"input_schema": {"type": "object", "properties": {}}
|
||||
}],
|
||||
"tool_choice": value,
|
||||
});
|
||||
anthropic_to_openai(input).unwrap()["tool_choice"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_choice_string_any_maps_to_required() {
|
||||
assert_eq!(run_tool_choice(json!("any")), json!("required"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_choice_string_auto_and_none_pass_through() {
|
||||
assert_eq!(run_tool_choice(json!("auto")), json!("auto"));
|
||||
assert_eq!(run_tool_choice(json!("none")), json!("none"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_choice_object_any_maps_to_required() {
|
||||
assert_eq!(run_tool_choice(json!({"type": "any"})), json!("required"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_choice_object_auto_and_none_collapse_to_string() {
|
||||
assert_eq!(run_tool_choice(json!({"type": "auto"})), json!("auto"));
|
||||
assert_eq!(run_tool_choice(json!({"type": "none"})), json!("none"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_choice_forced_tool_maps_to_nested_function_selector() {
|
||||
// Anthropic {"type":"tool","name":"X"} must become OpenAI Chat
|
||||
// {"type":"function","function":{"name":"X"}} — the *nested* form, not
|
||||
// the flat Responses-API form.
|
||||
assert_eq!(
|
||||
run_tool_choice(json!({"type": "tool", "name": "search"})),
|
||||
json!({"type": "function", "function": {"name": "search"}}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 统一处理流式和非流式 API 响应
|
||||
|
||||
use super::{
|
||||
forwarder::ActiveConnectionGuard,
|
||||
handler_config::{StreamUsageEventFilter, UsageParserConfig},
|
||||
handler_context::{RequestContext, StreamingTimeoutConfig},
|
||||
hyper_client::ProxyResponse,
|
||||
@@ -11,6 +12,7 @@ use super::{
|
||||
usage::parser::TokenUsage,
|
||||
ProxyError,
|
||||
};
|
||||
use crate::database::PRICING_SOURCE_REQUEST;
|
||||
use axum::http::{header::HeaderMap, HeaderName};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use bytes::Bytes;
|
||||
@@ -181,6 +183,7 @@ pub async fn handle_streaming(
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
parser_config: &UsageParserConfig,
|
||||
connection_guard: Option<ActiveConnectionGuard>,
|
||||
) -> Response {
|
||||
let status = response.status();
|
||||
log::debug!(
|
||||
@@ -218,8 +221,13 @@ pub async fn handle_streaming(
|
||||
let timeout_config = ctx.streaming_timeout_config();
|
||||
|
||||
// 创建带日志和超时的透传流
|
||||
let logged_stream =
|
||||
create_logged_passthrough_stream(stream, ctx.tag, usage_collector, timeout_config);
|
||||
let logged_stream = create_logged_passthrough_stream(
|
||||
stream,
|
||||
ctx.tag,
|
||||
usage_collector,
|
||||
timeout_config,
|
||||
connection_guard,
|
||||
);
|
||||
|
||||
let body = axum::body::Body::from_stream(logged_stream);
|
||||
match builder.body(body) {
|
||||
@@ -237,6 +245,8 @@ pub async fn handle_non_streaming(
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
parser_config: &UsageParserConfig,
|
||||
// guard 在函数 scope 内持有,整包响应读取完成后随函数返回一并 drop
|
||||
_connection_guard: Option<ActiveConnectionGuard>,
|
||||
) -> Result<Response, ProxyError> {
|
||||
// 整包超时:仅在故障转移开启且配置值非零时生效
|
||||
let body_timeout =
|
||||
@@ -339,11 +349,12 @@ pub async fn process_response(
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
parser_config: &UsageParserConfig,
|
||||
connection_guard: Option<ActiveConnectionGuard>,
|
||||
) -> Result<Response, ProxyError> {
|
||||
if is_sse_response(&response) {
|
||||
Ok(handle_streaming(response, ctx, state, parser_config).await)
|
||||
Ok(handle_streaming(response, ctx, state, parser_config, connection_guard).await)
|
||||
} else {
|
||||
handle_non_streaming(response, ctx, state, parser_config).await
|
||||
handle_non_streaming(response, ctx, state, parser_config, connection_guard).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +373,7 @@ pub struct SseUsageCollector {
|
||||
struct SseUsageCollectorInner {
|
||||
events: Mutex<Vec<Value>>,
|
||||
first_event_time: Mutex<Option<std::time::Instant>>,
|
||||
first_event_set: AtomicBool,
|
||||
start_time: std::time::Instant,
|
||||
on_complete: UsageCallbackWithTiming,
|
||||
should_collect: Option<StreamUsageEventFilter>,
|
||||
@@ -380,6 +392,7 @@ impl SseUsageCollector {
|
||||
inner: Arc::new(SseUsageCollectorInner {
|
||||
events: Mutex::new(Vec::new()),
|
||||
first_event_time: Mutex::new(None),
|
||||
first_event_set: AtomicBool::new(false),
|
||||
start_time,
|
||||
on_complete,
|
||||
should_collect,
|
||||
@@ -395,15 +408,21 @@ impl SseUsageCollector {
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// 标记首个被收集的 SSE 事件时间,沿用 `first_token_ms` 的既有近似语义。
|
||||
async fn mark_first_collected_event_time(&self) {
|
||||
if self.inner.first_event_set.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let mut first_time = self.inner.first_event_time.lock().await;
|
||||
if first_time.is_none() {
|
||||
*first_time = Some(std::time::Instant::now());
|
||||
self.inner.first_event_set.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
/// 推送 SSE 事件
|
||||
pub async fn push(&self, event: Value) {
|
||||
// 记录首个事件时间
|
||||
{
|
||||
let mut first_time = self.inner.first_event_time.lock().await;
|
||||
if first_time.is_none() {
|
||||
*first_time = Some(std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
self.mark_first_collected_event_time().await;
|
||||
let mut events = self.inner.events.lock().await;
|
||||
events.push(event);
|
||||
}
|
||||
@@ -428,6 +447,36 @@ impl SseUsageCollector {
|
||||
}
|
||||
}
|
||||
|
||||
struct SseUsageFinishGuard {
|
||||
collector: Option<SseUsageCollector>,
|
||||
}
|
||||
|
||||
impl SseUsageFinishGuard {
|
||||
fn new(collector: SseUsageCollector) -> Self {
|
||||
Self {
|
||||
collector: Some(collector),
|
||||
}
|
||||
}
|
||||
|
||||
fn disarm(&mut self) {
|
||||
self.collector = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SseUsageFinishGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(collector) = self.collector.take() {
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
handle.spawn(async move {
|
||||
collector.finish().await;
|
||||
});
|
||||
} else {
|
||||
log::warn!("SSE 用量收尾保护触发时 Tokio runtime 不可用,跳过异步 finish");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 内部辅助函数
|
||||
// ============================================================================
|
||||
@@ -588,7 +637,7 @@ async fn log_usage_internal(
|
||||
let logger = UsageLogger::new(&state.db);
|
||||
let (multiplier, pricing_model_source) =
|
||||
logger.resolve_pricing_config(provider_id, app_type).await;
|
||||
let pricing_model = if pricing_model_source == "request" {
|
||||
let pricing_model = if pricing_model_source == PRICING_SOURCE_REQUEST {
|
||||
request_model
|
||||
} else {
|
||||
model
|
||||
@@ -631,11 +680,14 @@ pub fn create_logged_passthrough_stream(
|
||||
tag: &'static str,
|
||||
usage_collector: Option<SseUsageCollector>,
|
||||
timeout_config: StreamingTimeoutConfig,
|
||||
connection_guard: Option<ActiveConnectionGuard>,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let _conn_guard = connection_guard;
|
||||
let mut buffer = String::new();
|
||||
let mut utf8_remainder: Vec<u8> = Vec::new();
|
||||
let mut collector = usage_collector;
|
||||
let mut finish_guard = collector.clone().map(SseUsageFinishGuard::new);
|
||||
let inspect_sse_events =
|
||||
collector.is_some() || log::log_enabled!(log::Level::Debug);
|
||||
let mut is_first_chunk = true;
|
||||
@@ -741,6 +793,9 @@ pub fn create_logged_passthrough_stream(
|
||||
if let Some(c) = collector.take() {
|
||||
c.finish().await;
|
||||
}
|
||||
if let Some(guard) = &mut finish_guard {
|
||||
guard.disarm();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::{
|
||||
use crate::database::Database;
|
||||
use axum::{
|
||||
extract::DefaultBodyLimit,
|
||||
routing::{get, post},
|
||||
routing::{any, get, post},
|
||||
Router,
|
||||
};
|
||||
use hyper_util::rt::TokioIo;
|
||||
@@ -331,8 +331,15 @@ impl ProxyServer {
|
||||
post(handlers::handle_responses_compact),
|
||||
)
|
||||
// Gemini API (支持带前缀和不带前缀)
|
||||
.route("/v1beta/*path", post(handlers::handle_gemini))
|
||||
.route("/gemini/v1beta/*path", post(handlers::handle_gemini))
|
||||
//
|
||||
// 用 `any(..)` 覆盖所有 HTTP 方法:除了 POST `:generateContent` /
|
||||
// `:streamGenerateContent` / `:countTokens` 之外,Gemini SDK / CLI 还会发
|
||||
// GET `/models`、GET `/models/<id>` 等只读端点。如果只挂 POST,这些 GET
|
||||
// 请求会在路由层 404,绕过本地代理的统计、整流和故障转移。
|
||||
.route("/v1beta/*path", any(handlers::handle_gemini))
|
||||
.route("/gemini/v1beta/*path", any(handlers::handle_gemini))
|
||||
// Gemini 的 GA 版本也叫 /v1,给原 SDK 留一条出口
|
||||
.route("/gemini/v1/*path", any(handlers::handle_gemini))
|
||||
// 提高默认请求体大小限制(避免 413 Payload Too Large)
|
||||
.layer(DefaultBodyLimit::max(200 * 1024 * 1024))
|
||||
.with_state(self.state.clone())
|
||||
@@ -353,6 +360,17 @@ impl ProxyServer {
|
||||
self.state.provider_router.update_all_configs(config).await;
|
||||
}
|
||||
|
||||
pub async fn update_circuit_breaker_config_for_app(
|
||||
&self,
|
||||
app_type: &str,
|
||||
config: super::circuit_breaker::CircuitBreakerConfig,
|
||||
) {
|
||||
self.state
|
||||
.provider_router
|
||||
.update_app_configs(app_type, config)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// 重置指定 Provider 的熔断器
|
||||
pub async fn reset_provider_circuit_breaker(&self, provider_id: &str, app_type: &str) {
|
||||
self.state
|
||||
|
||||
@@ -37,19 +37,52 @@ impl CostCalculator {
|
||||
/// - `cost_multiplier`: 成本倍数 (provider 自定义)
|
||||
///
|
||||
/// # 计算逻辑
|
||||
/// - input_cost: (input_tokens - cache_read_tokens) × 输入价格
|
||||
/// - input_cost: input_tokens × 输入价格
|
||||
/// - cache_read_cost: cache_read_tokens × 缓存读取价格
|
||||
/// - 这样避免缓存部分被重复计费
|
||||
/// - Claude/Anthropic 的 input_tokens 已经不包含 cache_read_tokens
|
||||
/// - total_cost: 各项成本之和 × 倍率(倍率只作用于最终总价)
|
||||
pub fn calculate(
|
||||
usage: &TokenUsage,
|
||||
pricing: &ModelPricing,
|
||||
cost_multiplier: Decimal,
|
||||
) -> CostBreakdown {
|
||||
Self::calculate_with_cache_semantics(usage, pricing, cost_multiplier, false)
|
||||
}
|
||||
|
||||
/// 按 app_type 选择输入 token 语义后计算成本。
|
||||
///
|
||||
/// Codex/OpenAI Responses 与 Gemini 的输入 token 字段包含 cache read 部分;
|
||||
/// Claude/Anthropic 的 input_tokens 已经是 fresh input。
|
||||
pub fn calculate_for_app(
|
||||
app_type: &str,
|
||||
usage: &TokenUsage,
|
||||
pricing: &ModelPricing,
|
||||
cost_multiplier: Decimal,
|
||||
) -> CostBreakdown {
|
||||
let input_includes_cache_read = matches!(app_type, "codex" | "gemini");
|
||||
Self::calculate_with_cache_semantics(
|
||||
usage,
|
||||
pricing,
|
||||
cost_multiplier,
|
||||
input_includes_cache_read,
|
||||
)
|
||||
}
|
||||
|
||||
fn calculate_with_cache_semantics(
|
||||
usage: &TokenUsage,
|
||||
pricing: &ModelPricing,
|
||||
cost_multiplier: Decimal,
|
||||
input_includes_cache_read: bool,
|
||||
) -> CostBreakdown {
|
||||
let million = Decimal::from(1_000_000);
|
||||
|
||||
// 计算实际需要按输入价格计费的 token 数(减去缓存命中部分)
|
||||
let billable_input_tokens = usage.input_tokens.saturating_sub(usage.cache_read_tokens);
|
||||
// OpenAI/Gemini 风格的 input_tokens 包含缓存命中,需要扣除后再按输入价计费;
|
||||
// Claude/Anthropic 风格的 input_tokens 已经是 fresh input,不能再次扣减。
|
||||
let billable_input_tokens = if input_includes_cache_read {
|
||||
usage.input_tokens.saturating_sub(usage.cache_read_tokens)
|
||||
} else {
|
||||
usage.input_tokens
|
||||
};
|
||||
|
||||
// 各项基础成本(不含倍率)
|
||||
let input_cost =
|
||||
@@ -76,6 +109,7 @@ impl CostCalculator {
|
||||
}
|
||||
|
||||
/// 尝试计算成本,如果模型未知则返回 None
|
||||
#[allow(dead_code)]
|
||||
pub fn try_calculate(
|
||||
usage: &TokenUsage,
|
||||
pricing: Option<&ModelPricing>,
|
||||
@@ -83,6 +117,15 @@ impl CostCalculator {
|
||||
) -> Option<CostBreakdown> {
|
||||
pricing.map(|p| Self::calculate(usage, p, cost_multiplier))
|
||||
}
|
||||
|
||||
pub fn try_calculate_for_app(
|
||||
app_type: &str,
|
||||
usage: &TokenUsage,
|
||||
pricing: Option<&ModelPricing>,
|
||||
cost_multiplier: Decimal,
|
||||
) -> Option<CostBreakdown> {
|
||||
pricing.map(|p| Self::calculate_for_app(app_type, usage, p, cost_multiplier))
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelPricing {
|
||||
@@ -122,8 +165,9 @@ mod tests {
|
||||
|
||||
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
|
||||
|
||||
// input: (1000 - 200) * 3.0 / 1M = 0.0024 (只计算非缓存部分)
|
||||
assert_eq!(cost.input_cost, Decimal::from_str("0.0024").unwrap());
|
||||
// Claude/Anthropic 语义:input_tokens 已经不含 cache_read_tokens
|
||||
// input: 1000 * 3.0 / 1M = 0.003
|
||||
assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap());
|
||||
// output: 500 * 15.0 / 1M = 0.0075
|
||||
assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap());
|
||||
// cache_read: 200 * 0.3 / 1M = 0.00006
|
||||
@@ -133,7 +177,34 @@ mod tests {
|
||||
cost.cache_creation_cost,
|
||||
Decimal::from_str("0.000375").unwrap()
|
||||
);
|
||||
// total: 0.0024 + 0.0075 + 0.00006 + 0.000375 = 0.010335
|
||||
// total: 0.003 + 0.0075 + 0.00006 + 0.000375 = 0.010935
|
||||
assert_eq!(cost.total_cost, Decimal::from_str("0.010935").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_calculation_for_cache_inclusive_app() {
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
cache_read_tokens: 200,
|
||||
cache_creation_tokens: 100,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
let pricing = ModelPricing::from_strings("3.0", "15.0", "0.3", "3.75").unwrap();
|
||||
let multiplier = Decimal::from_str("1.0").unwrap();
|
||||
|
||||
let cost = CostCalculator::calculate_for_app("codex", &usage, &pricing, multiplier);
|
||||
|
||||
// Codex/OpenAI 语义:input_tokens 包含 cached_tokens,需要扣除 cache_read_tokens
|
||||
assert_eq!(cost.input_cost, Decimal::from_str("0.0024").unwrap());
|
||||
assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap());
|
||||
assert_eq!(cost.cache_read_cost, Decimal::from_str("0.00006").unwrap());
|
||||
assert_eq!(
|
||||
cost.cache_creation_cost,
|
||||
Decimal::from_str("0.000375").unwrap()
|
||||
);
|
||||
assert_eq!(cost.total_cost, Decimal::from_str("0.010335").unwrap());
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
use super::calculator::{CostBreakdown, CostCalculator, ModelPricing};
|
||||
use super::parser::TokenUsage;
|
||||
use crate::database::Database;
|
||||
use crate::database::{Database, PRICING_SOURCE_REQUEST, PRICING_SOURCE_RESPONSE};
|
||||
use crate::error::AppError;
|
||||
use crate::services::usage_stats::find_model_pricing_row;
|
||||
use crate::services::usage_stats::{find_model_pricing_row, is_placeholder_pricing_model};
|
||||
use rust_decimal::Decimal;
|
||||
use std::{str::FromStr, time::SystemTime};
|
||||
use std::str::FromStr;
|
||||
|
||||
/// 请求日志
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -64,13 +64,7 @@ impl<'a> UsageLogger<'a> {
|
||||
)
|
||||
};
|
||||
|
||||
let created_at = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("SystemTime is before UNIX_EPOCH, falling back to 0: {e}");
|
||||
0
|
||||
});
|
||||
let created_at = chrono::Utc::now().timestamp();
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO proxy_request_logs (
|
||||
@@ -227,18 +221,19 @@ impl<'a> UsageLogger<'a> {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
log::warn!("[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}");
|
||||
"response".to_string()
|
||||
PRICING_SOURCE_RESPONSE.to_string()
|
||||
}
|
||||
};
|
||||
let default_pricing_source =
|
||||
if matches!(default_pricing_source_raw.as_str(), "response" | "request") {
|
||||
default_pricing_source_raw
|
||||
} else {
|
||||
log::warn!(
|
||||
let default_pricing_source = if default_pricing_source_raw == PRICING_SOURCE_RESPONSE
|
||||
|| default_pricing_source_raw == PRICING_SOURCE_REQUEST
|
||||
{
|
||||
default_pricing_source_raw
|
||||
} else {
|
||||
log::warn!(
|
||||
"[USG-003] 默认计费模式无效 (app_type={app_type}): {default_pricing_source_raw}"
|
||||
);
|
||||
"response".to_string()
|
||||
};
|
||||
PRICING_SOURCE_RESPONSE.to_string()
|
||||
};
|
||||
|
||||
let provider = self
|
||||
.db
|
||||
@@ -271,7 +266,9 @@ impl<'a> UsageLogger<'a> {
|
||||
};
|
||||
|
||||
let pricing_model_source = match provider_pricing_source {
|
||||
Some(value) if matches!(value, "response" | "request") => value.to_string(),
|
||||
Some(value) if value == PRICING_SOURCE_RESPONSE || value == PRICING_SOURCE_REQUEST => {
|
||||
value.to_string()
|
||||
}
|
||||
Some(value) => {
|
||||
log::warn!("[USG-003] 供应商计费模式无效 (provider_id={provider_id}): {value}");
|
||||
default_pricing_source.clone()
|
||||
@@ -303,11 +300,21 @@ impl<'a> UsageLogger<'a> {
|
||||
) -> Result<(), AppError> {
|
||||
let pricing = self.get_model_pricing(&pricing_model)?;
|
||||
|
||||
if pricing.is_none() {
|
||||
let has_usage = usage.input_tokens > 0
|
||||
|| usage.output_tokens > 0
|
||||
|| usage.cache_read_tokens > 0
|
||||
|| usage.cache_creation_tokens > 0;
|
||||
|
||||
if pricing.is_none() && has_usage && !is_placeholder_pricing_model(&pricing_model) {
|
||||
log::warn!("[USG-002] 模型定价未找到,成本将记录为 0: {pricing_model}");
|
||||
}
|
||||
|
||||
let cost = CostCalculator::try_calculate(&usage, pricing.as_ref(), cost_multiplier);
|
||||
let cost = CostCalculator::try_calculate_for_app(
|
||||
&app_type,
|
||||
&usage,
|
||||
pricing.as_ref(),
|
||||
cost_multiplier,
|
||||
);
|
||||
|
||||
let log = RequestLog {
|
||||
request_id,
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Codex OAuth model list service.
|
||||
//!
|
||||
//! ChatGPT Codex exposes models through `chatgpt.com/backend-api/codex/models`,
|
||||
//! which is not an OpenAI-compatible `/v1/models` endpoint.
|
||||
|
||||
use crate::services::model_fetch::FetchedModel;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
|
||||
const CODEX_OAUTH_MODELS_URL: &str = "https://chatgpt.com/backend-api/codex/models";
|
||||
const CODEX_OAUTH_FETCH_TIMEOUT_SECS: u64 = 15;
|
||||
const ERROR_BODY_MAX_CHARS: usize = 512;
|
||||
const CODEX_OAUTH_CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
pub async fn fetch_models_with_token(
|
||||
token: &str,
|
||||
account_id: &str,
|
||||
) -> Result<Vec<FetchedModel>, String> {
|
||||
let client = crate::proxy::http_client::get();
|
||||
let response = client
|
||||
.get(CODEX_OAUTH_MODELS_URL)
|
||||
.query(&[("client_version", CODEX_OAUTH_CLIENT_VERSION)])
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("originator", "cc-switch")
|
||||
.header("chatgpt-account-id", account_id)
|
||||
.timeout(Duration::from_secs(CODEX_OAUTH_FETCH_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {e}"))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = truncate_body(response.text().await.unwrap_or_default());
|
||||
return Err(format!("HTTP {status}: {body}"));
|
||||
}
|
||||
|
||||
let value: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {e}"))?;
|
||||
|
||||
Ok(parse_models(value))
|
||||
}
|
||||
|
||||
fn parse_models(value: Value) -> Vec<FetchedModel> {
|
||||
let entries = value
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.or_else(|| value.get("models").and_then(Value::as_array))
|
||||
.or_else(|| value.get("items").and_then(Value::as_array))
|
||||
.or_else(|| value.as_array());
|
||||
|
||||
let mut models = Vec::new();
|
||||
|
||||
if let Some(entries) = entries {
|
||||
for entry in entries {
|
||||
push_model_entry(&mut models, entry, None);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(model_map) = value.get("models").and_then(Value::as_object) {
|
||||
for (key, entry) in model_map {
|
||||
push_model_entry(&mut models, entry, Some(key));
|
||||
}
|
||||
}
|
||||
|
||||
models.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
models.dedup_by(|a, b| a.id == b.id);
|
||||
models
|
||||
}
|
||||
|
||||
fn push_model_entry(models: &mut Vec<FetchedModel>, entry: &Value, fallback_id: Option<&str>) {
|
||||
if let Some(id) = entry.as_str().map(str::trim).filter(|id| !id.is_empty()) {
|
||||
models.push(FetchedModel {
|
||||
id: id.to_string(),
|
||||
owned_by: Some("Codex".to_string()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(obj) = entry.as_object() else {
|
||||
if let Some(id) = fallback_id.map(str::trim).filter(|id| !id.is_empty()) {
|
||||
models.push(FetchedModel {
|
||||
id: id.to_string(),
|
||||
owned_by: Some("Codex".to_string()),
|
||||
});
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(id) = string_field(obj, &["slug", "id", "model", "name"]).or_else(|| {
|
||||
fallback_id
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(str::to_string)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let owned_by = string_field(
|
||||
obj,
|
||||
&[
|
||||
"owned_by", "ownedBy", "provider", "vendor", "category", "owner",
|
||||
],
|
||||
)
|
||||
.or_else(|| Some("Codex".to_string()));
|
||||
|
||||
models.push(FetchedModel { id, owned_by });
|
||||
}
|
||||
|
||||
fn string_field(obj: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<String> {
|
||||
keys.iter()
|
||||
.filter_map(|key| obj.get(*key))
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.find(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn truncate_body(body: String) -> String {
|
||||
if body.chars().count() <= ERROR_BODY_MAX_CHARS {
|
||||
body
|
||||
} else {
|
||||
let mut s: String = body.chars().take(ERROR_BODY_MAX_CHARS).collect();
|
||||
s.push_str("...");
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn parse_codex_oauth_models_accepts_openai_style_data() {
|
||||
let models = parse_models(json!({
|
||||
"data": [
|
||||
{ "id": "gpt-5.4", "owned_by": "openai" },
|
||||
{ "id": "gpt-5.4-mini", "ownedBy": "openai" }
|
||||
]
|
||||
}));
|
||||
|
||||
assert_eq!(models.len(), 2);
|
||||
assert_eq!(models[0].id, "gpt-5.4");
|
||||
assert_eq!(models[0].owned_by.as_deref(), Some("openai"));
|
||||
assert_eq!(models[1].id, "gpt-5.4-mini");
|
||||
assert_eq!(models[1].owned_by.as_deref(), Some("openai"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_codex_oauth_models_accepts_model_list_shape() {
|
||||
let models = parse_models(json!({
|
||||
"models": [
|
||||
{ "slug": "gpt-5.3-codex", "display_name": "GPT-5.3 Codex" },
|
||||
"gpt-5.5"
|
||||
]
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
models.into_iter().map(|model| model.id).collect::<Vec<_>>(),
|
||||
vec!["gpt-5.3-codex".to_string(), "gpt-5.5".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_codex_oauth_models_deduplicates_ids() {
|
||||
let models = parse_models(json!({
|
||||
"data": [
|
||||
{ "id": "gpt-5.4" },
|
||||
{ "model": "gpt-5.4" }
|
||||
]
|
||||
}));
|
||||
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(models[0].id, "gpt-5.4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_codex_oauth_models_accepts_model_map_shape() {
|
||||
let models = parse_models(json!({
|
||||
"models": {
|
||||
"gpt-5.4": { "display_name": "GPT-5.4" },
|
||||
"gpt-5.5": { "slug": "gpt-5.5" }
|
||||
}
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
models.into_iter().map(|model| model.id).collect::<Vec<_>>(),
|
||||
vec!["gpt-5.4".to_string(), "gpt-5.5".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod balance;
|
||||
pub mod codex_oauth_models;
|
||||
pub mod coding_plan;
|
||||
pub mod config;
|
||||
pub mod env_checker;
|
||||
@@ -14,6 +15,7 @@ pub mod session_usage_codex;
|
||||
pub mod session_usage_gemini;
|
||||
pub mod skill;
|
||||
pub mod speedtest;
|
||||
pub mod sql_helpers;
|
||||
pub mod stream_check;
|
||||
pub mod subscription;
|
||||
pub mod usage_cache;
|
||||
@@ -35,5 +37,5 @@ pub use usage_cache::UsageCache;
|
||||
#[allow(unused_imports)]
|
||||
pub use usage_stats::{
|
||||
DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats,
|
||||
RequestLogDetail, UsageSummary,
|
||||
RequestLogDetail, UsageSummary, UsageSummaryByApp,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::database::{validate_cost_multiplier, validate_pricing_source};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::{Provider, UsageResult};
|
||||
use crate::services::mcp::McpService;
|
||||
@@ -261,6 +262,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_provider_settings_rejects_negative_cost_multiplier() {
|
||||
let mut provider = Provider::with_id(
|
||||
"claude".into(),
|
||||
"Claude".into(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "token",
|
||||
"ANTHROPIC_BASE_URL": "https://claude.example"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
);
|
||||
provider.meta = Some(ProviderMeta {
|
||||
cost_multiplier: Some("-1".to_string()),
|
||||
..ProviderMeta::default()
|
||||
});
|
||||
|
||||
let err = ProviderService::validate_provider_settings(&AppType::Claude, &provider)
|
||||
.expect_err("negative multiplier should be rejected");
|
||||
assert!(matches!(
|
||||
err,
|
||||
AppError::Localized {
|
||||
key: "error.invalidMultiplier",
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_credentials_returns_expected_values() {
|
||||
let provider = Provider::with_id(
|
||||
@@ -1768,12 +1798,15 @@ impl ProviderService {
|
||||
// Auth
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
// Models (4 fields + 1 legacy)
|
||||
// Models and Claude Code model-menu display names
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
|
||||
// Endpoint
|
||||
"ANTHROPIC_BASE_URL",
|
||||
];
|
||||
@@ -2145,6 +2178,12 @@ impl ProviderService {
|
||||
|
||||
// Validate and clean UsageScript configuration (common for all app types)
|
||||
if let Some(meta) = &provider.meta {
|
||||
if let Some(multiplier) = meta.cost_multiplier.as_deref() {
|
||||
validate_cost_multiplier(multiplier)?;
|
||||
}
|
||||
if let Some(source) = meta.pricing_model_source.as_deref() {
|
||||
validate_pricing_source(source)?;
|
||||
}
|
||||
if let Some(usage_script) = &meta.usage_script {
|
||||
validate_usage_script(usage_script)?;
|
||||
}
|
||||
|
||||
+198
-38
@@ -12,7 +12,7 @@ use crate::proxy::types::*;
|
||||
use crate::services::provider::{
|
||||
build_effective_settings_with_common_config, write_live_with_common_config,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tauri::Emitter;
|
||||
@@ -23,18 +23,28 @@ const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED";
|
||||
|
||||
/// 代理接管模式下需要从 Claude Live 配置中移除的"模型覆盖"字段。
|
||||
///
|
||||
/// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段,
|
||||
/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。
|
||||
const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [
|
||||
/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,
|
||||
/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,
|
||||
/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。
|
||||
const CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_REASONING_MODEL", // legacy: 已废弃,但旧配置可能残留
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
|
||||
// Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型
|
||||
"ANTHROPIC_SMALL_FAST_MODEL",
|
||||
];
|
||||
|
||||
const CLAUDE_TAKEOVER_HAIKU_MODEL: &str = "claude-haiku-4-5";
|
||||
const CLAUDE_TAKEOVER_SONNET_MODEL: &str = "claude-sonnet-4-6";
|
||||
const CLAUDE_TAKEOVER_OPUS_MODEL: &str = "claude-opus-4-7";
|
||||
// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。
|
||||
const CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = "[1M]";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProxyService {
|
||||
db: Arc<Database>,
|
||||
@@ -59,32 +69,10 @@ impl ProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理接管模式下 Claude Live 配置中的模型覆盖字段。
|
||||
///
|
||||
/// 这可以避免"接管开启后切换供应商仍使用旧模型"的问题。
|
||||
/// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。
|
||||
pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {
|
||||
let mut config = self.read_claude_live()?;
|
||||
|
||||
let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut changed = false;
|
||||
for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {
|
||||
if env.remove(key).is_some() {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
self.write_claude_live(&config)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {
|
||||
// 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。
|
||||
let takeover_model_fields = Self::build_claude_takeover_model_fields(config);
|
||||
|
||||
if !config.is_object() {
|
||||
*config = json!({});
|
||||
}
|
||||
@@ -106,6 +94,10 @@ impl ProxyService {
|
||||
env.remove(key);
|
||||
}
|
||||
|
||||
for (key, value) in takeover_model_fields {
|
||||
env.insert(key.to_string(), Value::String(value));
|
||||
}
|
||||
|
||||
let token_keys = [
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
@@ -129,6 +121,101 @@ impl ProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {
|
||||
let Some(env) = config.get("env").and_then(Value::as_object) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let default_model = Self::claude_env_string(env, "ANTHROPIC_MODEL");
|
||||
let small_fast_model = Self::claude_env_string(env, "ANTHROPIC_SMALL_FAST_MODEL");
|
||||
let haiku_model = Self::claude_env_string(env, "ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||||
.or(small_fast_model)
|
||||
.or(default_model);
|
||||
let sonnet_model = Self::claude_env_string(env, "ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||||
.or(default_model)
|
||||
.or(small_fast_model);
|
||||
let opus_model = Self::claude_env_string(env, "ANTHROPIC_DEFAULT_OPUS_MODEL")
|
||||
.or(default_model)
|
||||
.or(small_fast_model);
|
||||
|
||||
let mut fields = Vec::with_capacity(6);
|
||||
Self::push_claude_takeover_role_fields(
|
||||
&mut fields,
|
||||
env,
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
|
||||
CLAUDE_TAKEOVER_HAIKU_MODEL,
|
||||
false,
|
||||
haiku_model,
|
||||
);
|
||||
Self::push_claude_takeover_role_fields(
|
||||
&mut fields,
|
||||
env,
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
|
||||
CLAUDE_TAKEOVER_SONNET_MODEL,
|
||||
true,
|
||||
sonnet_model,
|
||||
);
|
||||
Self::push_claude_takeover_role_fields(
|
||||
&mut fields,
|
||||
env,
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
|
||||
CLAUDE_TAKEOVER_OPUS_MODEL,
|
||||
true,
|
||||
opus_model,
|
||||
);
|
||||
fields
|
||||
}
|
||||
|
||||
fn push_claude_takeover_role_fields(
|
||||
fields: &mut Vec<(&'static str, String)>,
|
||||
env: &Map<String, Value>,
|
||||
model_key: &'static str,
|
||||
name_key: &'static str,
|
||||
takeover_model: &'static str,
|
||||
supports_one_m: bool,
|
||||
upstream_model: Option<&str>,
|
||||
) {
|
||||
let Some(upstream_model) = upstream_model else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut client_model = takeover_model.to_string();
|
||||
if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {
|
||||
client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);
|
||||
}
|
||||
fields.push((model_key, client_model));
|
||||
|
||||
let display_name = Self::claude_env_string(env, name_key)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));
|
||||
if !display_name.is_empty() {
|
||||
fields.push((name_key, display_name));
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
|
||||
env.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn has_claude_one_m_marker(model: &str) -> bool {
|
||||
model
|
||||
.trim_end()
|
||||
.to_ascii_lowercase()
|
||||
.ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)
|
||||
}
|
||||
|
||||
fn strip_claude_one_m_marker(model: &str) -> String {
|
||||
crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub async fn sync_claude_live_from_provider_while_proxy_active(
|
||||
&self,
|
||||
provider: &Provider,
|
||||
@@ -327,12 +414,15 @@ impl ProxyService {
|
||||
};
|
||||
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);
|
||||
|
||||
if has_backup || live_taken_over {
|
||||
// 必须 backup AND live 占位符同时存在才算真接管。
|
||||
// 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」
|
||||
// 两种脏角落,下面的重建分支会把这些情况修复成一致状态。
|
||||
if has_backup && live_taken_over {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"{app_type_str} 标记为已接管,但缺少备份或占位符,正在重新接管并补齐备份"
|
||||
"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -411,7 +501,11 @@ impl ProxyService {
|
||||
}
|
||||
|
||||
// 1) 恢复 Live 配置
|
||||
self.restore_live_config_for_app(&app).await?;
|
||||
//
|
||||
// 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。
|
||||
// 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),
|
||||
// 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。
|
||||
self.restore_live_config_for_app_with_fallback(&app).await?;
|
||||
|
||||
// 2) 删除该 app 的备份(避免长期存储敏感 Token)
|
||||
self.db
|
||||
@@ -1559,9 +1653,6 @@ impl ProxyService {
|
||||
if matches!(app_type_enum, AppType::Claude) {
|
||||
self.sync_claude_live_from_provider_while_proxy_active(&provider)
|
||||
.await?;
|
||||
if let Err(e) = self.cleanup_claude_model_overrides_in_live() {
|
||||
log::warn!("清理 Claude Live 模型字段失败(不影响热切换结果): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1902,6 +1993,23 @@ impl ProxyService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 热更新指定应用的熔断器配置
|
||||
pub async fn update_circuit_breaker_config_for_app(
|
||||
&self,
|
||||
app_type: &str,
|
||||
config: crate::proxy::CircuitBreakerConfig,
|
||||
) -> Result<(), String> {
|
||||
if let Some(server) = self.server.read().await.as_ref() {
|
||||
server
|
||||
.update_circuit_breaker_config_for_app(app_type, config)
|
||||
.await;
|
||||
log::info!("已热更新 {app_type} 运行中的熔断器配置");
|
||||
} else {
|
||||
log::debug!("{app_type} 熔断器配置将在下次代理启动时生效");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 重置指定 Provider 的熔断器
|
||||
///
|
||||
/// 如果代理服务器正在运行,立即重置内存中的熔断器状态
|
||||
@@ -2240,7 +2348,12 @@ model = "gpt-5.1-codex"
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "b-key",
|
||||
"ANTHROPIC_BASE_URL": "https://api.b.example",
|
||||
"ANTHROPIC_MODEL": "claude-new"
|
||||
"ANTHROPIC_MODEL": "claude-new",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v4-flash",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": "DeepSeek V4 Flash",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "deepseek-v4-pro[1M]",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "DeepSeek V4 Pro",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "deepseek-v4-ultra [1m]"
|
||||
},
|
||||
"permissions": { "allow": ["Read"] }
|
||||
}),
|
||||
@@ -2266,7 +2379,8 @@ model = "gpt-5.1-codex"
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721",
|
||||
"ANTHROPIC_API_KEY": PROXY_TOKEN_PLACEHOLDER,
|
||||
"ANTHROPIC_MODEL": "stale-model"
|
||||
"ANTHROPIC_MODEL": "stale-model",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Stale Sonnet"
|
||||
},
|
||||
"permissions": { "allow": ["Bash"] }
|
||||
}))
|
||||
@@ -2301,7 +2415,53 @@ model = "gpt-5.1-codex"
|
||||
live.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_MODEL"))
|
||||
.is_none(),
|
||||
"Claude model override fields should be removed in takeover mode"
|
||||
"fallback model override should be removed in takeover mode"
|
||||
);
|
||||
let live_env = live
|
||||
.get("env")
|
||||
.and_then(|env| env.as_object())
|
||||
.expect("live env");
|
||||
assert_eq!(
|
||||
live_env
|
||||
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("claude-haiku-4-5"),
|
||||
"takeover mode should expose a stable Haiku role model"
|
||||
);
|
||||
assert_eq!(
|
||||
live_env
|
||||
.get("ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("DeepSeek V4 Flash"),
|
||||
"model menu should show the current provider Haiku display name"
|
||||
);
|
||||
assert_eq!(
|
||||
live_env
|
||||
.get("ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("claude-sonnet-4-6[1M]"),
|
||||
"Sonnet role should carry the local 1M declaration for Claude Code"
|
||||
);
|
||||
assert_eq!(
|
||||
live_env
|
||||
.get("ANTHROPIC_DEFAULT_SONNET_MODEL_NAME")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("DeepSeek V4 Pro"),
|
||||
"stale model display names should be replaced during hot switch"
|
||||
);
|
||||
assert_eq!(
|
||||
live_env
|
||||
.get("ANTHROPIC_DEFAULT_OPUS_MODEL")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("claude-opus-4-7[1M]"),
|
||||
"Opus role should preserve the current provider 1M capability marker"
|
||||
);
|
||||
assert_eq!(
|
||||
live_env
|
||||
.get("ANTHROPIC_DEFAULT_OPUS_MODEL_NAME")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("deepseek-v4-ultra"),
|
||||
"implicit display names should strip the local 1M marker"
|
||||
);
|
||||
|
||||
let backup = db
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::error::AppError;
|
||||
use crate::proxy::usage::calculator::{CostCalculator, ModelPricing};
|
||||
use crate::proxy::usage::parser::TokenUsage;
|
||||
use crate::services::usage_stats::{
|
||||
effective_usage_log_filter, should_skip_session_insert, DedupKey,
|
||||
effective_usage_log_filter, find_model_pricing, should_skip_session_insert, DedupKey,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -142,12 +142,7 @@ fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppEr
|
||||
// 获取文件元数据
|
||||
let metadata = fs::metadata(file_path)
|
||||
.map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?;
|
||||
let file_modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
let file_modified = metadata_modified_nanos(&metadata);
|
||||
|
||||
// 检查同步状态
|
||||
let (last_modified, last_offset) = get_sync_state(db, &file_path_str)?;
|
||||
@@ -321,6 +316,19 @@ pub(crate) fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64
|
||||
Ok(result.unwrap_or((0, 0)))
|
||||
}
|
||||
|
||||
/// 返回文件 mtime 的纳秒时间戳。
|
||||
///
|
||||
/// `session_log_sync.last_modified` 旧数据是秒级时间戳;新写入纳秒值不需要
|
||||
/// schema 迁移,旧值会自然触发一次增量重扫,并继续依赖行 offset 避免重复导入。
|
||||
pub(crate) fn metadata_modified_nanos(metadata: &fs::Metadata) -> i64 {
|
||||
metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_nanos().min(i64::MAX as u128) as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 更新 session_log_sync 表中某条目的同步进度。
|
||||
///
|
||||
/// Shared by all session_usage_* parsers.
|
||||
@@ -459,77 +467,7 @@ fn find_model_pricing_for_session(
|
||||
conn: &rusqlite::Connection,
|
||||
model_id: &str,
|
||||
) -> Option<ModelPricing> {
|
||||
// 精确匹配
|
||||
if let Ok(Some(pricing)) = try_find_pricing(conn, model_id) {
|
||||
return Some(pricing);
|
||||
}
|
||||
|
||||
// 模糊匹配:去掉日期后缀
|
||||
// 例如 "claude-opus-4-6-20260206" -> "claude-opus-4-6"
|
||||
let parts: Vec<&str> = model_id.rsplitn(2, '-').collect();
|
||||
if parts.len() == 2 {
|
||||
if let Some(suffix) = parts.first() {
|
||||
if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_digit()) {
|
||||
if let Ok(Some(pricing)) = try_find_pricing(conn, parts[1]) {
|
||||
return Some(pricing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试 LIKE 匹配
|
||||
let pattern = format!("{model_id}%");
|
||||
let result = conn.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing WHERE model_id LIKE ?1 LIMIT 1",
|
||||
rusqlite::params![pattern],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok((input, output, cache_read, cache_creation)) => {
|
||||
ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation).ok()
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn try_find_pricing(
|
||||
conn: &rusqlite::Connection,
|
||||
model_id: &str,
|
||||
) -> Result<Option<ModelPricing>, AppError> {
|
||||
let result = conn.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing WHERE model_id = ?1",
|
||||
rusqlite::params![model_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok((input, output, cache_read, cache_creation)) => {
|
||||
ModelPricing::from_strings(&input, &output, &cache_read, &cache_creation)
|
||||
.map(Some)
|
||||
.map_err(|e| AppError::Database(format!("解析定价失败: {e}")))
|
||||
}
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(AppError::Database(format!("查询定价失败: {e}"))),
|
||||
}
|
||||
find_model_pricing(conn, model_id)
|
||||
}
|
||||
|
||||
/// 查询数据来源分布统计
|
||||
|
||||
@@ -18,8 +18,10 @@ use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::proxy::usage::calculator::{CostCalculator, ModelPricing};
|
||||
use crate::proxy::usage::parser::TokenUsage;
|
||||
use crate::services::session_usage::{get_sync_state, update_sync_state, SessionSyncResult};
|
||||
use crate::services::usage_stats::{should_skip_session_insert, DedupKey};
|
||||
use crate::services::session_usage::{
|
||||
get_sync_state, metadata_modified_nanos, update_sync_state, SessionSyncResult,
|
||||
};
|
||||
use crate::services::usage_stats::{find_model_pricing, should_skip_session_insert, DedupKey};
|
||||
use rust_decimal::Decimal;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
@@ -232,12 +234,7 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32),
|
||||
// 获取文件元数据
|
||||
let metadata = fs::metadata(file_path)
|
||||
.map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?;
|
||||
let file_modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
let file_modified = metadata_modified_nanos(&metadata);
|
||||
|
||||
// 检查同步状态
|
||||
let (last_modified, last_offset) = get_sync_state(db, &file_path_str)?;
|
||||
@@ -480,7 +477,7 @@ fn insert_codex_session_entry(
|
||||
let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = match pricing
|
||||
{
|
||||
Some(p) => {
|
||||
let cost = CostCalculator::calculate(&usage, &p, multiplier);
|
||||
let cost = CostCalculator::calculate_for_app("codex", &usage, &p, multiplier);
|
||||
(
|
||||
cost.input_cost.to_string(),
|
||||
cost.output_cost.to_string(),
|
||||
@@ -540,51 +537,7 @@ fn insert_codex_session_entry(
|
||||
|
||||
/// 查找 Codex 模型定价(带归一化)
|
||||
fn find_codex_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
|
||||
let normalized = normalize_codex_model(model_id);
|
||||
|
||||
// 1. 精确匹配(归一化后的名称)
|
||||
if let Some(pricing) = try_find_pricing(conn, &normalized) {
|
||||
return Some(pricing);
|
||||
}
|
||||
|
||||
// 2. LIKE 模糊匹配(兜底)
|
||||
let pattern = format!("{normalized}%");
|
||||
conn.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing WHERE model_id LIKE ?1 LIMIT 1",
|
||||
rusqlite::params![pattern],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.ok()
|
||||
.and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok())
|
||||
}
|
||||
|
||||
/// 精确匹配定价查询
|
||||
fn try_find_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
|
||||
conn.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing WHERE model_id = ?1",
|
||||
rusqlite::params![model_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.ok()
|
||||
.and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok())
|
||||
find_model_pricing(conn, &normalize_codex_model(model_id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -18,8 +18,10 @@ use crate::error::AppError;
|
||||
use crate::gemini_config::get_gemini_dir;
|
||||
use crate::proxy::usage::calculator::{CostCalculator, ModelPricing};
|
||||
use crate::proxy::usage::parser::TokenUsage;
|
||||
use crate::services::session_usage::{get_sync_state, update_sync_state, SessionSyncResult};
|
||||
use crate::services::usage_stats::{should_skip_session_insert, DedupKey};
|
||||
use crate::services::session_usage::{
|
||||
get_sync_state, metadata_modified_nanos, update_sync_state, SessionSyncResult,
|
||||
};
|
||||
use crate::services::usage_stats::{find_model_pricing, should_skip_session_insert, DedupKey};
|
||||
use rust_decimal::Decimal;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -126,12 +128,7 @@ fn sync_single_gemini_file(db: &Database, file_path: &Path) -> Result<(u32, u32)
|
||||
// 获取文件元数据
|
||||
let metadata = fs::metadata(file_path)
|
||||
.map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?;
|
||||
let file_modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
let file_modified = metadata_modified_nanos(&metadata);
|
||||
|
||||
// 检查同步状态
|
||||
let (last_modified, _last_offset) = get_sync_state(db, &file_path_str)?;
|
||||
@@ -282,7 +279,7 @@ fn insert_gemini_session_entry(
|
||||
let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) = match pricing
|
||||
{
|
||||
Some(p) => {
|
||||
let cost = CostCalculator::calculate(&usage, &p, multiplier);
|
||||
let cost = CostCalculator::calculate_for_app("gemini", &usage, &p, multiplier);
|
||||
(
|
||||
cost.input_cost.to_string(),
|
||||
cost.output_cost.to_string(),
|
||||
@@ -358,49 +355,7 @@ fn insert_gemini_session_entry(
|
||||
|
||||
/// 查找 Gemini 模型定价
|
||||
fn find_gemini_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
|
||||
// 精确匹配
|
||||
if let Some(pricing) = try_find_pricing(conn, model_id) {
|
||||
return Some(pricing);
|
||||
}
|
||||
|
||||
// LIKE 模糊匹配(兜底)
|
||||
let pattern = format!("{model_id}%");
|
||||
conn.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing WHERE model_id LIKE ?1 LIMIT 1",
|
||||
rusqlite::params![pattern],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.ok()
|
||||
.and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok())
|
||||
}
|
||||
|
||||
/// 精确匹配定价查询
|
||||
fn try_find_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
|
||||
conn.query_row(
|
||||
"SELECT input_cost_per_million, output_cost_per_million,
|
||||
cache_read_cost_per_million, cache_creation_cost_per_million
|
||||
FROM model_pricing WHERE model_id = ?1",
|
||||
rusqlite::params![model_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.ok()
|
||||
.and_then(|(i, o, cr, cc)| ModelPricing::from_strings(&i, &o, &cr, &cc).ok())
|
||||
find_model_pricing(conn, model_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! SQL fragment helpers shared across usage aggregation queries.
|
||||
//!
|
||||
//! Anthropic reports `input_tokens` as fresh (cache reads counted
|
||||
//! separately); OpenAI Responses API and Google Gemini's
|
||||
//! `promptTokenCount` both include the cached portion. Any aggregation
|
||||
//! summing `input_tokens` across providers must route through
|
||||
//! [`fresh_input_sql`] to recover a consistent semantics.
|
||||
|
||||
/// Set of `app_type` values whose stored `input_tokens` already includes
|
||||
/// `cache_read_tokens`. Aggregations subtract cache reads from these rows
|
||||
/// to recover the fresh-input semantics used by Claude.
|
||||
///
|
||||
/// Why list providers explicitly: new providers default to the
|
||||
/// Claude-style "input excludes cache" semantics, which is safer if the
|
||||
/// caller forgets to update this list. The wrong direction (a new OpenAI-
|
||||
/// style provider not added here) shows up loudly as a too-low cache hit
|
||||
/// rate, which is easier to catch than the silent over-deduction that
|
||||
/// would happen with the opposite default.
|
||||
const CACHE_INCLUSIVE_APP_TYPES: &[&str] = &["codex", "gemini"];
|
||||
|
||||
/// Build an SQL expression that returns the cache-normalized `input_tokens`
|
||||
/// for a single row in `proxy_request_logs` or `usage_daily_rollups`.
|
||||
///
|
||||
/// For rows whose `app_type` is in [`CACHE_INCLUSIVE_APP_TYPES`] and
|
||||
/// `input_tokens >= cache_read_tokens`, returns
|
||||
/// `input_tokens - cache_read_tokens`. For all other rows the original
|
||||
/// `input_tokens` is returned unchanged.
|
||||
///
|
||||
/// Pass an empty string to reference the columns directly (no alias),
|
||||
/// or a table alias such as `"l"` to emit `l.input_tokens` style references.
|
||||
pub fn fresh_input_sql(alias: &str) -> String {
|
||||
let prefix = if alias.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{alias}.")
|
||||
};
|
||||
let app_type_list = CACHE_INCLUSIVE_APP_TYPES
|
||||
.iter()
|
||||
.map(|t| format!("'{t}'"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!(
|
||||
"CASE WHEN {prefix}app_type IN ({app_type_list}) AND {prefix}input_tokens >= {prefix}cache_read_tokens \
|
||||
THEN ({prefix}input_tokens - {prefix}cache_read_tokens) \
|
||||
ELSE {prefix}input_tokens END"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn setup_conn() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE proxy_request_logs (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
app_type TEXT NOT NULL,
|
||||
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
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_input_with_alias_emits_prefixed_columns() {
|
||||
let sql = fresh_input_sql("l");
|
||||
assert!(sql.contains("l.app_type"));
|
||||
assert!(sql.contains("l.input_tokens"));
|
||||
assert!(sql.contains("l.cache_read_tokens"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_input_without_alias_uses_bare_columns() {
|
||||
let sql = fresh_input_sql("");
|
||||
assert!(!sql.contains("."));
|
||||
assert!(sql.contains("'codex'"));
|
||||
assert!(sql.contains("'gemini'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_input_subtracts_cache_for_cache_inclusive_providers() {
|
||||
let conn = setup_conn();
|
||||
// Codex row: OpenAI semantics — input_tokens includes the 600 cached.
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens)
|
||||
VALUES ('codex-1', 'codex', 1000, 600)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// Gemini row: Google semantics — promptTokenCount includes cachedContentTokenCount.
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens)
|
||||
VALUES ('gemini-1', 'gemini', 800, 300)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// Claude row: Anthropic semantics — input_tokens already excludes cache.
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens)
|
||||
VALUES ('claude-1', 'claude', 200, 5000)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let expr = fresh_input_sql("l");
|
||||
let sql = format!("SELECT COALESCE(SUM({expr}), 0) FROM proxy_request_logs l");
|
||||
let total: i64 = conn.query_row(&sql, [], |r| r.get(0)).unwrap();
|
||||
// Codex: 1000-600=400; Gemini: 800-300=500; Claude: 200 unchanged.
|
||||
assert_eq!(total, 400 + 500 + 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_input_handles_codex_with_cache_exceeding_input() {
|
||||
// Defensive: if a malformed Codex row somehow has cache > input,
|
||||
// we keep the original value rather than producing a negative number.
|
||||
let conn = setup_conn();
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (request_id, app_type, input_tokens, cache_read_tokens)
|
||||
VALUES ('codex-broken', 'codex', 100, 999)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let expr = fresh_input_sql("l");
|
||||
let sql = format!("SELECT {expr} FROM proxy_request_logs l");
|
||||
let value: i64 = conn.query_row(&sql, [], |r| r.get(0)).unwrap();
|
||||
assert_eq!(value, 100);
|
||||
}
|
||||
}
|
||||
@@ -444,7 +444,10 @@ impl StreamCheckService {
|
||||
// - AuthStrategy::ClaudeAuth → Authorization: Bearer
|
||||
// - AuthStrategy::Bearer → Authorization: Bearer
|
||||
// 避免之前"无条件 Bearer + 条件 x-api-key 双发"导致的假阴性 / auth conflict。
|
||||
for (name, value) in ClaudeAdapter::new().get_auth_headers(auth) {
|
||||
let auth_headers = ClaudeAdapter::new()
|
||||
.get_auth_headers(auth)
|
||||
.map_err(|e| AppError::Message(format!("stream check 构造鉴权头失败: {e}")))?;
|
||||
for (name, value) in auth_headers {
|
||||
request_builder = request_builder.header(name, value);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.14.1",
|
||||
"version": "3.15.0",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
+51
-42
@@ -165,6 +165,8 @@ function App() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [activeApp, setActiveApp] = useState<AppId>(getInitialApp);
|
||||
const sharedFeatureApp: AppId =
|
||||
activeApp === "claude-desktop" ? "claude" : activeApp;
|
||||
const [currentView, setCurrentView] = useState<View>(getInitialView);
|
||||
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
@@ -210,16 +212,16 @@ function App() {
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentView === "sessions" &&
|
||||
activeApp !== "claude" &&
|
||||
activeApp !== "codex" &&
|
||||
activeApp !== "opencode" &&
|
||||
activeApp !== "openclaw" &&
|
||||
activeApp !== "gemini" &&
|
||||
activeApp !== "hermes"
|
||||
sharedFeatureApp !== "claude" &&
|
||||
sharedFeatureApp !== "codex" &&
|
||||
sharedFeatureApp !== "opencode" &&
|
||||
sharedFeatureApp !== "openclaw" &&
|
||||
sharedFeatureApp !== "gemini" &&
|
||||
sharedFeatureApp !== "hermes"
|
||||
) {
|
||||
setCurrentView("providers");
|
||||
}
|
||||
}, [activeApp, currentView]);
|
||||
}, [sharedFeatureApp, currentView]);
|
||||
|
||||
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
||||
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
||||
@@ -273,14 +275,14 @@ function App() {
|
||||
currentView === "openclawAgents");
|
||||
const { data: openclawHealthWarnings = [] } =
|
||||
useOpenClawHealth(isOpenClawView);
|
||||
const hasSkillsSupport = activeApp !== "claude-desktop";
|
||||
const hasSkillsSupport = sharedFeatureApp !== "openclaw";
|
||||
const hasSessionSupport =
|
||||
activeApp === "claude" ||
|
||||
activeApp === "codex" ||
|
||||
activeApp === "opencode" ||
|
||||
activeApp === "openclaw" ||
|
||||
activeApp === "gemini" ||
|
||||
activeApp === "hermes";
|
||||
sharedFeatureApp === "claude" ||
|
||||
sharedFeatureApp === "codex" ||
|
||||
sharedFeatureApp === "opencode" ||
|
||||
sharedFeatureApp === "openclaw" ||
|
||||
sharedFeatureApp === "gemini" ||
|
||||
sharedFeatureApp === "hermes";
|
||||
|
||||
const {
|
||||
addProvider,
|
||||
@@ -911,7 +913,7 @@ function App() {
|
||||
ref={promptPanelRef}
|
||||
open={true}
|
||||
onOpenChange={() => setCurrentView("providers")}
|
||||
appId={activeApp}
|
||||
appId={sharedFeatureApp}
|
||||
/>
|
||||
);
|
||||
case "hermesMemory":
|
||||
@@ -921,14 +923,18 @@ function App() {
|
||||
<UnifiedSkillsPanel
|
||||
ref={unifiedSkillsPanelRef}
|
||||
onOpenDiscovery={() => setCurrentView("skillsDiscovery")}
|
||||
currentApp={activeApp === "openclaw" ? "claude" : activeApp}
|
||||
currentApp={
|
||||
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "skillsDiscovery":
|
||||
return (
|
||||
<SkillsPage
|
||||
ref={skillsPageRef}
|
||||
initialApp={activeApp === "openclaw" ? "claude" : activeApp}
|
||||
initialApp={
|
||||
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "mcp":
|
||||
@@ -950,7 +956,12 @@ function App() {
|
||||
);
|
||||
|
||||
case "sessions":
|
||||
return <SessionManagerPage key={activeApp} appId={activeApp} />;
|
||||
return (
|
||||
<SessionManagerPage
|
||||
key={sharedFeatureApp}
|
||||
appId={sharedFeatureApp}
|
||||
/>
|
||||
);
|
||||
case "workspace":
|
||||
return <WorkspaceFilesPanel />;
|
||||
case "openclawEnv":
|
||||
@@ -1163,7 +1174,9 @@ function App() {
|
||||
<h1 className="text-lg font-semibold">
|
||||
{currentView === "settings" && t("settings.title")}
|
||||
{currentView === "prompts" &&
|
||||
t("prompts.title", { appName: t(`apps.${activeApp}`) })}
|
||||
t("prompts.title", {
|
||||
appName: t(`apps.${sharedFeatureApp}`),
|
||||
})}
|
||||
{currentView === "skills" && t("skills.title")}
|
||||
{currentView === "skillsDiscovery" && t("skills.title")}
|
||||
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
|
||||
@@ -1185,7 +1198,7 @@ function App() {
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative inline-flex items-center">
|
||||
<a
|
||||
href="https://github.com/farion1231/cc-switch"
|
||||
href="https://ccswitch.io"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
@@ -1496,17 +1509,15 @@ function App() {
|
||||
>
|
||||
<Wrench className="flex-shrink-0 w-4 h-4" />
|
||||
</Button>
|
||||
{activeApp !== "claude-desktop" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("prompts")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
||||
title={t("prompts.manage")}
|
||||
>
|
||||
<Book className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("prompts")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
||||
title={t("prompts.manage")}
|
||||
>
|
||||
<Book className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -1522,17 +1533,15 @@ function App() {
|
||||
>
|
||||
<History className="flex-shrink-0 w-4 h-4" />
|
||||
</Button>
|
||||
{activeApp !== "claude-desktop" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("mcp")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
||||
title={t("mcp.title")}
|
||||
>
|
||||
<McpIcon size={16} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("mcp")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5 w-8 px-2"
|
||||
title={t("mcp.title")}
|
||||
>
|
||||
<McpIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
@@ -2,6 +2,14 @@ import type { AppId } from "@/lib/api";
|
||||
import type { VisibleApps } from "@/types";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Monitor, Terminal } from "lucide-react";
|
||||
|
||||
const APP_BADGE_ICON: Partial<
|
||||
Record<AppId, { icon: typeof Terminal; offsetY?: number }>
|
||||
> = {
|
||||
claude: { icon: Terminal },
|
||||
"claude-desktop": { icon: Monitor, offsetY: 0.5 },
|
||||
};
|
||||
|
||||
interface AppSwitcherProps {
|
||||
activeApp: AppId;
|
||||
@@ -43,7 +51,7 @@ export function AppSwitcher({
|
||||
hermes: "hermes",
|
||||
};
|
||||
const appDisplayName: Record<AppId, string> = {
|
||||
claude: "Claude",
|
||||
claude: "Claude Code",
|
||||
"claude-desktop": "Claude Desktop",
|
||||
codex: "Codex",
|
||||
gemini: "Gemini",
|
||||
@@ -60,35 +68,63 @@ export function AppSwitcher({
|
||||
|
||||
return (
|
||||
<div className="inline-flex bg-muted rounded-xl p-1 gap-1">
|
||||
{appsToShow.map((app) => (
|
||||
<button
|
||||
key={app}
|
||||
type="button"
|
||||
onClick={() => handleSwitch(app)}
|
||||
className={cn(
|
||||
"group inline-flex items-center px-3 h-8 rounded-md text-sm font-medium transition-all duration-200",
|
||||
activeApp === app
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50",
|
||||
)}
|
||||
>
|
||||
<ProviderIcon
|
||||
icon={appIconName[app]}
|
||||
name={appDisplayName[app]}
|
||||
size={iconSize}
|
||||
/>
|
||||
<span
|
||||
{appsToShow.map((app) => {
|
||||
const badgeConfig = APP_BADGE_ICON[app];
|
||||
const BadgeIcon = badgeConfig?.icon;
|
||||
const isActive = activeApp === app;
|
||||
return (
|
||||
<button
|
||||
key={app}
|
||||
type="button"
|
||||
onClick={() => handleSwitch(app)}
|
||||
className={cn(
|
||||
"transition-all duration-200 whitespace-nowrap overflow-hidden",
|
||||
compact
|
||||
? "max-w-0 opacity-0 ml-0"
|
||||
: "max-w-[80px] opacity-100 ml-2",
|
||||
"group inline-flex items-center px-3 h-8 rounded-md text-sm font-medium transition-all duration-200",
|
||||
isActive
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50",
|
||||
)}
|
||||
>
|
||||
{appDisplayName[app]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<span className="relative inline-flex shrink-0">
|
||||
<ProviderIcon
|
||||
icon={appIconName[app]}
|
||||
name={appDisplayName[app]}
|
||||
size={iconSize}
|
||||
/>
|
||||
{BadgeIcon && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -bottom-0.5 -right-0.5 flex items-center justify-center rounded-[3px] border h-[11px] w-[11px]",
|
||||
isActive
|
||||
? "bg-background border-border text-foreground"
|
||||
: "bg-muted border-background text-muted-foreground group-hover:bg-background group-hover:text-foreground",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<BadgeIcon
|
||||
className="h-[8px] w-[8px]"
|
||||
strokeWidth={2.5}
|
||||
style={
|
||||
badgeConfig?.offsetY
|
||||
? { transform: `translateY(${badgeConfig.offsetY}px)` }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"transition-all duration-200 whitespace-nowrap overflow-hidden",
|
||||
compact
|
||||
? "max-w-0 opacity-0 ml-0"
|
||||
: "max-w-[90px] opacity-100 ml-2",
|
||||
)}
|
||||
>
|
||||
{appDisplayName[app]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,6 +191,8 @@ export function ProviderCard({
|
||||
appId === "hermes" && isHermesReadOnlyProvider(provider.settingsConfig);
|
||||
const isCodexOauth =
|
||||
provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH;
|
||||
const isClaudeThirdParty =
|
||||
appId === "claude" && provider.category === "third_party";
|
||||
|
||||
// 获取用量数据以判断是否有多套餐
|
||||
// 累加模式应用(OpenCode/OpenClaw/Hermes):使用 isInConfig 代替 isCurrent
|
||||
@@ -332,9 +334,37 @@ export function ProviderCard({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{appId === "claude" &&
|
||||
provider.category !== "official" &&
|
||||
provider.meta?.apiFormat &&
|
||||
provider.meta.apiFormat !== "anthropic" && (
|
||||
<span className="inline-flex items-center rounded-md bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
|
||||
{t("claudeCode.needsRouting", {
|
||||
defaultValue: "需要路由",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{appId === "claude" && provider.category === "official" && (
|
||||
<span className="inline-flex items-center rounded-md bg-slate-200 px-1.5 py-0.5 text-[10px] font-semibold text-slate-700 dark:bg-slate-700/60 dark:text-slate-200">
|
||||
{t("claudeCode.noRoutingSupport", {
|
||||
defaultValue: "不支持路由",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{appId === "codex" && provider.category === "official" && (
|
||||
<span className="inline-flex items-center rounded-md bg-slate-200 px-1.5 py-0.5 text-[10px] font-semibold text-slate-700 dark:bg-slate-700/60 dark:text-slate-200">
|
||||
{t("codex.noRoutingSupport", {
|
||||
defaultValue: "不支持路由",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isProxyRunning && isInFailoverQueue && health && (
|
||||
<ProviderHealthBadge
|
||||
consecutiveFailures={health.consecutive_failures}
|
||||
isHealthy={health.is_healthy}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -467,7 +497,11 @@ export function ProviderCard({
|
||||
onEdit={() => onEdit(provider)}
|
||||
onDuplicate={() => onDuplicate(provider)}
|
||||
onTest={
|
||||
onTest && !isOfficial && !isCopilot && !isCodexOauth
|
||||
onTest &&
|
||||
!isOfficial &&
|
||||
!isCopilot &&
|
||||
!isCodexOauth &&
|
||||
!isClaudeThirdParty
|
||||
? () => onTest(provider)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function ProviderEmptyState({
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{appId === "claude-desktop"
|
||||
? t("provider.importFromClaude", {
|
||||
defaultValue: "从 Claude 导入兼容供应商",
|
||||
defaultValue: "将 Claude Code 中已有的供应商导入",
|
||||
})
|
||||
: t("provider.importCurrent")}
|
||||
</Button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ProviderHealthBadgeProps {
|
||||
consecutiveFailures: number;
|
||||
isHealthy?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -13,6 +14,7 @@ interface ProviderHealthBadgeProps {
|
||||
*/
|
||||
export function ProviderHealthBadge({
|
||||
consecutiveFailures,
|
||||
isHealthy,
|
||||
className,
|
||||
}: ProviderHealthBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -29,7 +31,7 @@ export function ProviderHealthBadge({
|
||||
bgColor: "bg-green-500/10",
|
||||
textColor: "text-green-600 dark:text-green-400",
|
||||
};
|
||||
} else if (consecutiveFailures < 5) {
|
||||
} else if (isHealthy !== false) {
|
||||
return {
|
||||
labelKey: "health.degraded",
|
||||
labelFallback: "降级",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { toast } from "sonner";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
} from "@/lib/api/copilot";
|
||||
import type { CopilotModel } from "@/lib/api/copilot";
|
||||
import {
|
||||
fetchCodexOauthModels,
|
||||
fetchModelsForConfig,
|
||||
showFetchModelsError,
|
||||
type FetchedModel,
|
||||
@@ -50,6 +52,12 @@ import type {
|
||||
ClaudeApiFormat,
|
||||
ClaudeApiKeyField,
|
||||
} from "@/types";
|
||||
import {
|
||||
hasClaudeOneMMarker,
|
||||
setClaudeOneMMarker,
|
||||
stripClaudeOneMMarker,
|
||||
type ClaudeModelEnvField,
|
||||
} from "./hooks/useModelState";
|
||||
import {
|
||||
providerPresets,
|
||||
type TemplateValueConfig,
|
||||
@@ -109,16 +117,12 @@ interface ClaudeFormFieldsProps {
|
||||
shouldShowModelSelector: boolean;
|
||||
claudeModel: string;
|
||||
defaultHaikuModel: string;
|
||||
defaultHaikuModelName: string;
|
||||
defaultSonnetModel: string;
|
||||
defaultSonnetModelName: string;
|
||||
defaultOpusModel: string;
|
||||
onModelChange: (
|
||||
field:
|
||||
| "ANTHROPIC_MODEL"
|
||||
| "ANTHROPIC_DEFAULT_HAIKU_MODEL"
|
||||
| "ANTHROPIC_DEFAULT_SONNET_MODEL"
|
||||
| "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
value: string,
|
||||
) => void;
|
||||
defaultOpusModelName: string;
|
||||
onModelChange: (field: ClaudeModelEnvField, value: string) => void;
|
||||
|
||||
// Speed Test Endpoints
|
||||
speedTestEndpoints: EndpointCandidate[];
|
||||
@@ -152,6 +156,7 @@ export function ClaudeFormFields({
|
||||
selectedGitHubAccountId,
|
||||
onGitHubAccountSelect,
|
||||
isCodexOauthPreset,
|
||||
isCodexOauthAuthenticated,
|
||||
selectedCodexAccountId,
|
||||
onCodexAccountSelect,
|
||||
codexFastMode,
|
||||
@@ -172,8 +177,11 @@ export function ClaudeFormFields({
|
||||
shouldShowModelSelector,
|
||||
claudeModel,
|
||||
defaultHaikuModel,
|
||||
defaultHaikuModelName,
|
||||
defaultSonnetModel,
|
||||
defaultSonnetModelName,
|
||||
defaultOpusModel,
|
||||
defaultOpusModelName,
|
||||
onModelChange,
|
||||
speedTestEndpoints,
|
||||
apiFormat,
|
||||
@@ -204,11 +212,28 @@ export function ClaudeFormFields({
|
||||
// Copilot 可用模型列表
|
||||
const [copilotModels, setCopilotModels] = useState<CopilotModel[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const copilotModelsRequestRef = useRef(0);
|
||||
|
||||
// Codex OAuth 可用模型列表
|
||||
const [codexOauthModels, setCodexOauthModels] = useState<FetchedModel[]>([]);
|
||||
const [codexOauthModelsLoading, setCodexOauthModelsLoading] = useState(false);
|
||||
const codexOauthModelsRequestRef = useRef(0);
|
||||
|
||||
// 通用模型获取(非 Copilot 供应商)
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
||||
const [isFetchingModels, setIsFetchingModels] = useState(false);
|
||||
|
||||
const showModelFetchResult = useCallback(
|
||||
(count: number) => {
|
||||
if (count === 0) {
|
||||
toast.info(t("providerForm.fetchModelsEmpty"));
|
||||
} else {
|
||||
toast.success(t("providerForm.fetchModelsSuccess", { count }));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleFetchModels = useCallback(() => {
|
||||
if (!baseUrl || !apiKey) {
|
||||
showFetchModelsError(null, t, {
|
||||
@@ -229,31 +254,27 @@ export function ClaudeFormFields({
|
||||
fetchModelsForConfig(baseUrl, apiKey, isFullUrl, modelsUrl)
|
||||
.then((models) => {
|
||||
setFetchedModels(models);
|
||||
if (models.length === 0) {
|
||||
toast.info(t("providerForm.fetchModelsEmpty"));
|
||||
} else {
|
||||
toast.success(
|
||||
t("providerForm.fetchModelsSuccess", { count: models.length }),
|
||||
);
|
||||
}
|
||||
showModelFetchResult(models.length);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[ModelFetch] Failed:", err);
|
||||
showFetchModelsError(err, t);
|
||||
})
|
||||
.finally(() => setIsFetchingModels(false));
|
||||
}, [baseUrl, apiKey, isFullUrl, t]);
|
||||
}, [baseUrl, apiKey, isFullUrl, showModelFetchResult, t]);
|
||||
|
||||
// 当 Copilot 预设且已认证时,加载可用模型
|
||||
useEffect(() => {
|
||||
// 如果不是 Copilot 预设或未认证,清空模型列表
|
||||
if (!isCopilotPreset || !isCopilotAuthenticated) {
|
||||
setCopilotModels([]);
|
||||
setModelsLoading(false);
|
||||
const handleFetchCopilotModels = useCallback(() => {
|
||||
if (!isCopilotAuthenticated) {
|
||||
toast.error(
|
||||
t("copilot.loginRequired", {
|
||||
defaultValue: "请先登录 GitHub Copilot",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const requestId = copilotModelsRequestRef.current + 1;
|
||||
copilotModelsRequestRef.current = requestId;
|
||||
setModelsLoading(true);
|
||||
const fetchModels = selectedGitHubAccountId
|
||||
? copilotGetModelsForAccount(selectedGitHubAccountId)
|
||||
@@ -261,38 +282,114 @@ export function ClaudeFormFields({
|
||||
|
||||
fetchModels
|
||||
.then((models) => {
|
||||
if (!cancelled) setCopilotModels(models);
|
||||
if (copilotModelsRequestRef.current !== requestId) return;
|
||||
setCopilotModels(models);
|
||||
showModelFetchResult(models.length);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (copilotModelsRequestRef.current !== requestId) return;
|
||||
console.warn("[Copilot] Failed to fetch models:", err);
|
||||
if (!cancelled) {
|
||||
toast.error(
|
||||
t("copilot.loadModelsFailed", {
|
||||
defaultValue: "加载 Copilot 模型列表失败",
|
||||
}),
|
||||
);
|
||||
}
|
||||
toast.error(
|
||||
t("copilot.loadModelsFailed", {
|
||||
defaultValue: "加载 Copilot 模型列表失败",
|
||||
}),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setModelsLoading(false);
|
||||
if (copilotModelsRequestRef.current === requestId) {
|
||||
setModelsLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
isCopilotAuthenticated,
|
||||
selectedGitHubAccountId,
|
||||
showModelFetchResult,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleFetchCodexOauthModels = useCallback(() => {
|
||||
if (!isCodexOauthAuthenticated) {
|
||||
toast.error(
|
||||
t("codexOauth.loginRequired", {
|
||||
defaultValue: "请先登录 ChatGPT 账号",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = codexOauthModelsRequestRef.current + 1;
|
||||
codexOauthModelsRequestRef.current = requestId;
|
||||
setCodexOauthModelsLoading(true);
|
||||
fetchCodexOauthModels(selectedCodexAccountId)
|
||||
.then((models) => {
|
||||
if (codexOauthModelsRequestRef.current !== requestId) return;
|
||||
setCodexOauthModels(models);
|
||||
showModelFetchResult(models.length);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (codexOauthModelsRequestRef.current !== requestId) return;
|
||||
console.warn("[CodexOAuth] Failed to fetch models:", err);
|
||||
showFetchModelsError(err, t);
|
||||
})
|
||||
.finally(() => {
|
||||
if (codexOauthModelsRequestRef.current === requestId) {
|
||||
setCodexOauthModelsLoading(false);
|
||||
}
|
||||
});
|
||||
}, [
|
||||
isCodexOauthAuthenticated,
|
||||
selectedCodexAccountId,
|
||||
showModelFetchResult,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
copilotModelsRequestRef.current += 1;
|
||||
setCopilotModels([]);
|
||||
setModelsLoading(false);
|
||||
}, [isCopilotPreset, isCopilotAuthenticated, selectedGitHubAccountId]);
|
||||
|
||||
useEffect(() => {
|
||||
codexOauthModelsRequestRef.current += 1;
|
||||
setCodexOauthModels([]);
|
||||
setCodexOauthModelsLoading(false);
|
||||
}, [isCodexOauthPreset, isCodexOauthAuthenticated, selectedCodexAccountId]);
|
||||
|
||||
const modelFetchLoading = isCopilotPreset
|
||||
? modelsLoading
|
||||
: isCodexOauthPreset
|
||||
? codexOauthModelsLoading
|
||||
: isFetchingModels;
|
||||
const handleModelFetchClick = isCopilotPreset
|
||||
? handleFetchCopilotModels
|
||||
: isCodexOauthPreset
|
||||
? handleFetchCodexOauthModels
|
||||
: handleFetchModels;
|
||||
|
||||
// 模型输入框:支持手动输入 + 下拉选择
|
||||
const renderModelInput = (
|
||||
id: string,
|
||||
value: string,
|
||||
field: ClaudeFormFieldsProps["onModelChange"] extends (
|
||||
f: infer F,
|
||||
v: string,
|
||||
) => void
|
||||
? F
|
||||
: never,
|
||||
field: ClaudeModelEnvField,
|
||||
placeholder?: string,
|
||||
onValueChange?: (value: string) => void,
|
||||
) => {
|
||||
const updateValue =
|
||||
onValueChange ?? ((next: string) => onModelChange(field, next));
|
||||
|
||||
if (isCodexOauthPreset) {
|
||||
return (
|
||||
<ModelInputWithFetch
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={updateValue}
|
||||
placeholder={placeholder}
|
||||
fetchedModels={codexOauthModels}
|
||||
isLoading={codexOauthModelsLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCopilotPreset && copilotModels.length > 0) {
|
||||
// 按 vendor 分组
|
||||
const grouped: Record<string, CopilotModel[]> = {};
|
||||
@@ -309,7 +406,7 @@ export function ClaudeFormFields({
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onModelChange(field, e.target.value)}
|
||||
onChange={(e) => updateValue(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
className="flex-1"
|
||||
@@ -331,7 +428,7 @@ export function ClaudeFormFields({
|
||||
{grouped[vendor].map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.id}
|
||||
onSelect={() => onModelChange(field, model.id)}
|
||||
onSelect={() => updateValue(model.id)}
|
||||
>
|
||||
{model.id}
|
||||
</DropdownMenuItem>
|
||||
@@ -351,7 +448,7 @@ export function ClaudeFormFields({
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onModelChange(field, e.target.value)}
|
||||
onChange={(e) => updateValue(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
className="flex-1"
|
||||
@@ -363,12 +460,25 @@ export function ClaudeFormFields({
|
||||
);
|
||||
}
|
||||
|
||||
// 非 Copilot 供应商: 使用 ModelInputWithFetch(获取按钮在 section 标题旁)
|
||||
if (isCopilotPreset) {
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => updateValue(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 普通供应商: 使用 ModelInputWithFetch(获取按钮在 section 标题旁)
|
||||
return (
|
||||
<ModelInputWithFetch
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(v) => onModelChange(field, v)}
|
||||
onChange={updateValue}
|
||||
placeholder={placeholder}
|
||||
fetchedModels={fetchedModels}
|
||||
isLoading={isFetchingModels}
|
||||
@@ -376,6 +486,69 @@ export function ClaudeFormFields({
|
||||
);
|
||||
};
|
||||
|
||||
type ModelRoleRow = {
|
||||
role: "sonnet" | "opus" | "haiku";
|
||||
label: string;
|
||||
model: string;
|
||||
displayName: string;
|
||||
modelField: ClaudeModelEnvField;
|
||||
displayNameField: ClaudeModelEnvField;
|
||||
inputId: string;
|
||||
supportsOneM: boolean;
|
||||
};
|
||||
|
||||
const modelRoleRows: ModelRoleRow[] = [
|
||||
{
|
||||
role: "sonnet",
|
||||
label: t("providerForm.modelRoleSonnet", { defaultValue: "Sonnet" }),
|
||||
model: defaultSonnetModel,
|
||||
displayName: defaultSonnetModelName,
|
||||
modelField: "ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
displayNameField: "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
|
||||
inputId: "claudeDefaultSonnetModel",
|
||||
supportsOneM: true,
|
||||
},
|
||||
{
|
||||
role: "opus",
|
||||
label: t("providerForm.modelRoleOpus", { defaultValue: "Opus" }),
|
||||
model: defaultOpusModel,
|
||||
displayName: defaultOpusModelName,
|
||||
modelField: "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
displayNameField: "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
|
||||
inputId: "claudeDefaultOpusModel",
|
||||
supportsOneM: true,
|
||||
},
|
||||
{
|
||||
role: "haiku",
|
||||
label: t("providerForm.modelRoleHaiku", { defaultValue: "Haiku" }),
|
||||
model: defaultHaikuModel,
|
||||
displayName: defaultHaikuModelName,
|
||||
modelField: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
displayNameField: "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
|
||||
inputId: "claudeDefaultHaikuModel",
|
||||
supportsOneM: false,
|
||||
},
|
||||
];
|
||||
|
||||
const handleRoleModelChange = (row: ModelRoleRow, value: string) => {
|
||||
const oldModelBase = stripClaudeOneMMarker(row.model).trim();
|
||||
const normalizedValue = row.supportsOneM
|
||||
? value
|
||||
: stripClaudeOneMMarker(value);
|
||||
const nextModelBase = stripClaudeOneMMarker(normalizedValue).trim();
|
||||
const displayName = row.displayName.trim();
|
||||
const shouldSyncDisplayName = !displayName || displayName === oldModelBase;
|
||||
onModelChange(row.modelField, normalizedValue);
|
||||
if (shouldSyncDisplayName) {
|
||||
onModelChange(row.displayNameField, nextModelBase);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleOneMChange = (row: ModelRoleRow, enabled: boolean) => {
|
||||
if (!row.supportsOneM) return;
|
||||
handleRoleModelChange(row, setClaudeOneMMarker(row.model, enabled));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* GitHub Copilot OAuth 认证 */}
|
||||
@@ -604,17 +777,23 @@ export function ClaudeFormFields({
|
||||
onClick={() => {
|
||||
const value =
|
||||
claudeModel ||
|
||||
defaultHaikuModel ||
|
||||
defaultSonnetModel ||
|
||||
defaultOpusModel;
|
||||
defaultOpusModel ||
|
||||
defaultHaikuModel;
|
||||
if (value) {
|
||||
onModelChange("ANTHROPIC_MODEL", value);
|
||||
onModelChange("ANTHROPIC_DEFAULT_HAIKU_MODEL", value);
|
||||
onModelChange("ANTHROPIC_DEFAULT_SONNET_MODEL", value);
|
||||
onModelChange("ANTHROPIC_DEFAULT_OPUS_MODEL", value);
|
||||
for (const row of modelRoleRows) {
|
||||
const roleValue = row.supportsOneM
|
||||
? value
|
||||
: stripClaudeOneMMarker(value);
|
||||
onModelChange(row.modelField, roleValue);
|
||||
onModelChange(
|
||||
row.displayNameField,
|
||||
stripClaudeOneMMarker(roleValue),
|
||||
);
|
||||
}
|
||||
toast.success(
|
||||
t("providerForm.quickSetSuccess", {
|
||||
defaultValue: "已将模型名称应用到所有字段",
|
||||
defaultValue: "已将模型名称应用到所有角色",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -632,89 +811,127 @@ export function ClaudeFormFields({
|
||||
defaultValue: "一键设置",
|
||||
})}
|
||||
</Button>
|
||||
{!isCopilotPreset && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFetchModels}
|
||||
disabled={isFetchingModels}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
{isFetchingModels ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("providerForm.fetchModels")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleModelFetchClick}
|
||||
disabled={modelFetchLoading}
|
||||
className="h-7 gap-1"
|
||||
>
|
||||
{modelFetchLoading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("providerForm.fetchModels")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.modelMappingHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 主模型 */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeModel">
|
||||
{t("providerForm.anthropicModel", {
|
||||
defaultValue: "主模型",
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="hidden grid-cols-[120px_1fr_minmax(0,1fr)_104px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
|
||||
<span>
|
||||
{t("providerForm.modelRoleLabel", {
|
||||
defaultValue: "模型角色",
|
||||
})}
|
||||
</FormLabel>
|
||||
{renderModelInput(
|
||||
"claudeModel",
|
||||
claudeModel,
|
||||
"ANTHROPIC_MODEL",
|
||||
t("providerForm.modelPlaceholder", { defaultValue: "" }),
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{t("providerForm.modelDisplayNameLabel", {
|
||||
defaultValue: "显示名称",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("providerForm.requestModelLabel", {
|
||||
defaultValue: "实际请求模型",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("providerForm.modelOneMHeader", {
|
||||
defaultValue: "声明支持 1M",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 默认 Haiku */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeDefaultHaikuModel">
|
||||
{t("providerForm.anthropicDefaultHaikuModel", {
|
||||
defaultValue: "Haiku 默认模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
{renderModelInput(
|
||||
"claudeDefaultHaikuModel",
|
||||
defaultHaikuModel,
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
t("providerForm.haikuModelPlaceholder", { defaultValue: "" }),
|
||||
)}
|
||||
</div>
|
||||
{modelRoleRows.map((row) => {
|
||||
const modelBase = stripClaudeOneMMarker(row.model);
|
||||
const usesOneM =
|
||||
row.supportsOneM && hasClaudeOneMMarker(row.model);
|
||||
|
||||
{/* 默认 Sonnet */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeDefaultSonnetModel">
|
||||
{t("providerForm.anthropicDefaultSonnetModel", {
|
||||
defaultValue: "Sonnet 默认模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
{renderModelInput(
|
||||
"claudeDefaultSonnetModel",
|
||||
defaultSonnetModel,
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
t("providerForm.modelPlaceholder", { defaultValue: "" }),
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={row.role}
|
||||
className="grid grid-cols-1 gap-2 md:grid-cols-[120px_1fr_minmax(0,1fr)_104px]"
|
||||
>
|
||||
<div className="flex h-9 items-center rounded-md border border-input bg-muted px-3 text-sm font-medium text-muted-foreground">
|
||||
{row.label}
|
||||
</div>
|
||||
<Input
|
||||
value={row.displayName}
|
||||
onChange={(event) =>
|
||||
onModelChange(row.displayNameField, event.target.value)
|
||||
}
|
||||
placeholder={
|
||||
modelBase ||
|
||||
t("providerForm.modelDisplayNamePlaceholder", {
|
||||
defaultValue: "例如 DeepSeek V4 Pro",
|
||||
})
|
||||
}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{renderModelInput(
|
||||
row.inputId,
|
||||
modelBase,
|
||||
row.modelField,
|
||||
t("providerForm.modelPlaceholder", { defaultValue: "" }),
|
||||
(value) =>
|
||||
handleRoleModelChange(
|
||||
row,
|
||||
row.supportsOneM
|
||||
? setClaudeOneMMarker(value, usesOneM)
|
||||
: stripClaudeOneMMarker(value),
|
||||
),
|
||||
)}
|
||||
{row.supportsOneM && (
|
||||
<label className="flex h-9 items-center gap-2 text-sm text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={usesOneM}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoleOneMChange(row, checked === true)
|
||||
}
|
||||
/>
|
||||
{t("providerForm.modelOneMLabel", {
|
||||
defaultValue: "1M",
|
||||
})}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 默认 Opus */}
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="claudeDefaultOpusModel">
|
||||
{t("providerForm.anthropicDefaultOpusModel", {
|
||||
defaultValue: "Opus 默认模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
{renderModelInput(
|
||||
"claudeDefaultOpusModel",
|
||||
defaultOpusModel,
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
t("providerForm.modelPlaceholder", { defaultValue: "" }),
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<FormLabel htmlFor="claudeModel">
|
||||
{t("providerForm.fallbackModelLabel", {
|
||||
defaultValue: "默认兜底模型",
|
||||
})}
|
||||
</FormLabel>
|
||||
{renderModelInput(
|
||||
"claudeModel",
|
||||
claudeModel,
|
||||
"ANTHROPIC_MODEL",
|
||||
t("providerForm.modelPlaceholder", { defaultValue: "" }),
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.fallbackModelHint", {
|
||||
defaultValue:
|
||||
"仅在 Claude Code 请求没有明确落到 Sonnet、Opus 或 Haiku 角色时使用;通常可以留空。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
@@ -297,6 +297,7 @@ export function ProviderAdvancedConfig({
|
||||
id="cost-multiplier"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
inputMode="decimal"
|
||||
value={pricingConfig.costMultiplier || ""}
|
||||
onChange={(e) =>
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
hasApiKeyField,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||
import { isNonNegativeDecimalString } from "@/types/usage";
|
||||
import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
||||
import CodexConfigEditor from "./CodexConfigEditor";
|
||||
import { CommonConfigEditor } from "./CommonConfigEditor";
|
||||
@@ -346,8 +347,11 @@ function ProviderFormFull({
|
||||
const {
|
||||
claudeModel,
|
||||
defaultHaikuModel,
|
||||
defaultHaikuModelName,
|
||||
defaultSonnetModel,
|
||||
defaultSonnetModelName,
|
||||
defaultOpusModel,
|
||||
defaultOpusModelName,
|
||||
handleModelChange,
|
||||
} = useModelState({
|
||||
settingsConfig: form.getValues("settingsConfig"),
|
||||
@@ -811,6 +815,20 @@ function ProviderFormFull({
|
||||
);
|
||||
}
|
||||
|
||||
const costMultiplier = pricingConfig.costMultiplier?.trim();
|
||||
if (
|
||||
pricingConfig.enabled &&
|
||||
costMultiplier &&
|
||||
!isNonNegativeDecimalString(costMultiplier)
|
||||
) {
|
||||
toast.error(
|
||||
t("settings.globalProxy.defaultCostMultiplierInvalid", {
|
||||
defaultValue: "成本倍率必须为非负数",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// opencode / openclaw / hermes: providerKey 相关
|
||||
// A 类(空)归到 issues;B 类(正则不合法 / 重复 / 状态加载中)仍硬拒绝
|
||||
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
@@ -1233,23 +1251,6 @@ function ProviderFormFull({
|
||||
await onSubmit(payload);
|
||||
};
|
||||
|
||||
const groupedPresets = useMemo(() => {
|
||||
return presetEntries.reduce<Record<string, PresetEntry[]>>((acc, entry) => {
|
||||
const category = entry.preset.category ?? "others";
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(entry);
|
||||
return acc;
|
||||
}, {});
|
||||
}, [presetEntries]);
|
||||
|
||||
const categoryKeys = useMemo(() => {
|
||||
return Object.keys(groupedPresets).filter(
|
||||
(key) => key !== "custom" && groupedPresets[key]?.length,
|
||||
);
|
||||
}, [groupedPresets]);
|
||||
|
||||
const shouldShowSpeedTest =
|
||||
category !== "official" && category !== "cloud_provider";
|
||||
|
||||
@@ -1535,8 +1536,7 @@ function ProviderFormFull({
|
||||
{!initialData && (
|
||||
<ProviderPresetSelector
|
||||
selectedPresetId={selectedPresetId}
|
||||
groupedPresets={groupedPresets}
|
||||
categoryKeys={categoryKeys}
|
||||
presetEntries={presetEntries}
|
||||
presetCategoryLabels={presetCategoryLabels}
|
||||
onPresetChange={handlePresetChange}
|
||||
onUniversalPresetSelect={onUniversalPresetSelect}
|
||||
@@ -1815,8 +1815,11 @@ function ProviderFormFull({
|
||||
shouldShowModelSelector={category !== "official"}
|
||||
claudeModel={claudeModel}
|
||||
defaultHaikuModel={defaultHaikuModel}
|
||||
defaultHaikuModelName={defaultHaikuModelName}
|
||||
defaultSonnetModel={defaultSonnetModel}
|
||||
defaultSonnetModelName={defaultSonnetModelName}
|
||||
defaultOpusModel={defaultOpusModel}
|
||||
defaultOpusModelName={defaultOpusModelName}
|
||||
onModelChange={handleModelChange}
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
apiFormat={localApiFormat}
|
||||
|
||||
@@ -6,6 +6,9 @@ import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
|
||||
import type { ClaudeDesktopProviderPreset } from "@/config/claudeDesktopProviderPresets";
|
||||
import type { OpenCodeProviderPreset } from "@/config/opencodeProviderPresets";
|
||||
import type { OpenClawProviderPreset } from "@/config/openclawProviderPresets";
|
||||
import type { HermesProviderPreset } from "@/config/hermesProviderPresets";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
import {
|
||||
universalProviderPresets,
|
||||
@@ -17,7 +20,10 @@ type AnyPreset =
|
||||
| ProviderPreset
|
||||
| CodexProviderPreset
|
||||
| GeminiProviderPreset
|
||||
| ClaudeDesktopProviderPreset;
|
||||
| ClaudeDesktopProviderPreset
|
||||
| OpenCodeProviderPreset
|
||||
| OpenClawProviderPreset
|
||||
| HermesProviderPreset;
|
||||
|
||||
type PresetEntry = {
|
||||
id: string;
|
||||
@@ -26,8 +32,7 @@ type PresetEntry = {
|
||||
|
||||
interface ProviderPresetSelectorProps {
|
||||
selectedPresetId: string | null;
|
||||
groupedPresets: Record<string, PresetEntry[]>;
|
||||
categoryKeys: string[];
|
||||
presetEntries: PresetEntry[];
|
||||
presetCategoryLabels: Record<string, string>;
|
||||
onPresetChange: (value: string) => void;
|
||||
onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;
|
||||
@@ -37,8 +42,7 @@ interface ProviderPresetSelectorProps {
|
||||
|
||||
export function ProviderPresetSelector({
|
||||
selectedPresetId,
|
||||
groupedPresets,
|
||||
categoryKeys,
|
||||
presetEntries,
|
||||
presetCategoryLabels,
|
||||
onPresetChange,
|
||||
onUniversalPresetSelect,
|
||||
@@ -140,35 +144,33 @@ export function ProviderPresetSelector({
|
||||
{t("providerPreset.custom")}
|
||||
</button>
|
||||
|
||||
{categoryKeys.map((category) => {
|
||||
const entries = groupedPresets[category];
|
||||
if (!entries || entries.length === 0) return null;
|
||||
return entries.map((entry) => {
|
||||
const isSelected = selectedPresetId === entry.id;
|
||||
const isPartner = entry.preset.isPartner;
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => onPresetChange(entry.id)}
|
||||
className={`${getPresetButtonClass(isSelected, entry.preset)} relative`}
|
||||
style={getPresetButtonStyle(isSelected, entry.preset)}
|
||||
title={
|
||||
presetCategoryLabels[category] ?? t("providerPreset.other")
|
||||
}
|
||||
>
|
||||
{renderPresetIcon(entry.preset)}
|
||||
{entry.preset.nameKey
|
||||
? t(entry.preset.nameKey)
|
||||
: entry.preset.name}
|
||||
{isPartner && (
|
||||
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-amber-500 to-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
|
||||
<Star className="h-2.5 w-2.5 fill-current" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
{presetEntries.map((entry) => {
|
||||
const isSelected = selectedPresetId === entry.id;
|
||||
const isPartner = entry.preset.isPartner;
|
||||
const presetCategory = entry.preset.category ?? "others";
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => onPresetChange(entry.id)}
|
||||
className={`${getPresetButtonClass(isSelected, entry.preset)} relative`}
|
||||
style={getPresetButtonStyle(isSelected, entry.preset)}
|
||||
title={
|
||||
presetCategoryLabels[presetCategory] ??
|
||||
t("providerPreset.other")
|
||||
}
|
||||
>
|
||||
{renderPresetIcon(entry.preset)}
|
||||
{entry.preset.nameKey
|
||||
? t(entry.preset.nameKey)
|
||||
: entry.preset.name}
|
||||
{isPartner && (
|
||||
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-amber-500 to-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
|
||||
<Star className="h-2.5 w-2.5 fill-current" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,33 @@ interface UseModelStateProps {
|
||||
onConfigChange: (config: string) => void;
|
||||
}
|
||||
|
||||
export type ClaudeModelEnvField =
|
||||
| "ANTHROPIC_MODEL"
|
||||
| "ANTHROPIC_DEFAULT_HAIKU_MODEL"
|
||||
| "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME"
|
||||
| "ANTHROPIC_DEFAULT_SONNET_MODEL"
|
||||
| "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME"
|
||||
| "ANTHROPIC_DEFAULT_OPUS_MODEL"
|
||||
| "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME";
|
||||
|
||||
export const CLAUDE_ONE_M_MARKER = "[1M]";
|
||||
|
||||
export function hasClaudeOneMMarker(model: string): boolean {
|
||||
return model.trimEnd().toLowerCase().endsWith("[1m]");
|
||||
}
|
||||
|
||||
export function stripClaudeOneMMarker(model: string): string {
|
||||
const trimmedEnd = model.trimEnd();
|
||||
if (!trimmedEnd.toLowerCase().endsWith("[1m]")) return model;
|
||||
return trimmedEnd.slice(0, -CLAUDE_ONE_M_MARKER.length).trimEnd();
|
||||
}
|
||||
|
||||
export function setClaudeOneMMarker(model: string, enabled: boolean): string {
|
||||
const base = stripClaudeOneMMarker(model).trim();
|
||||
if (!base) return "";
|
||||
return enabled ? `${base}${CLAUDE_ONE_M_MARKER}` : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse model values from settings config JSON
|
||||
*/
|
||||
@@ -22,18 +49,38 @@ function parseModelsFromConfig(settingsConfig: string) {
|
||||
typeof env.ANTHROPIC_DEFAULT_HAIKU_MODEL === "string"
|
||||
? env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
: small || model;
|
||||
const haikuName =
|
||||
typeof env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME === "string"
|
||||
? env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME
|
||||
: stripClaudeOneMMarker(haiku);
|
||||
const sonnet =
|
||||
typeof env.ANTHROPIC_DEFAULT_SONNET_MODEL === "string"
|
||||
? env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
: model || small;
|
||||
const sonnetName =
|
||||
typeof env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME === "string"
|
||||
? env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME
|
||||
: stripClaudeOneMMarker(sonnet);
|
||||
const opus =
|
||||
typeof env.ANTHROPIC_DEFAULT_OPUS_MODEL === "string"
|
||||
? env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
: model || small;
|
||||
const opusName =
|
||||
typeof env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME === "string"
|
||||
? env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME
|
||||
: stripClaudeOneMMarker(opus);
|
||||
|
||||
return { model, haiku, sonnet, opus };
|
||||
return { model, haiku, haikuName, sonnet, sonnetName, opus, opusName };
|
||||
} catch {
|
||||
return { model: "", haiku: "", sonnet: "", opus: "" };
|
||||
return {
|
||||
model: "",
|
||||
haiku: "",
|
||||
haikuName: "",
|
||||
sonnet: "",
|
||||
sonnetName: "",
|
||||
opus: "",
|
||||
opusName: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,18 +92,19 @@ export function useModelState({
|
||||
settingsConfig,
|
||||
onConfigChange,
|
||||
}: UseModelStateProps) {
|
||||
// Initialize state by parsing config directly (fixes edit mode backfill)
|
||||
const [claudeModel, setClaudeModel] = useState(
|
||||
() => parseModelsFromConfig(settingsConfig).model,
|
||||
const initial = useState(() => parseModelsFromConfig(settingsConfig))[0];
|
||||
const [claudeModel, setClaudeModel] = useState(initial.model);
|
||||
const [defaultHaikuModel, setDefaultHaikuModel] = useState(initial.haiku);
|
||||
const [defaultHaikuModelName, setDefaultHaikuModelName] = useState(
|
||||
initial.haikuName,
|
||||
);
|
||||
const [defaultHaikuModel, setDefaultHaikuModel] = useState(
|
||||
() => parseModelsFromConfig(settingsConfig).haiku,
|
||||
const [defaultSonnetModel, setDefaultSonnetModel] = useState(initial.sonnet);
|
||||
const [defaultSonnetModelName, setDefaultSonnetModelName] = useState(
|
||||
initial.sonnetName,
|
||||
);
|
||||
const [defaultSonnetModel, setDefaultSonnetModel] = useState(
|
||||
() => parseModelsFromConfig(settingsConfig).sonnet,
|
||||
);
|
||||
const [defaultOpusModel, setDefaultOpusModel] = useState(
|
||||
() => parseModelsFromConfig(settingsConfig).opus,
|
||||
const [defaultOpusModel, setDefaultOpusModel] = useState(initial.opus);
|
||||
const [defaultOpusModelName, setDefaultOpusModelName] = useState(
|
||||
initial.opusName,
|
||||
);
|
||||
|
||||
const isUserEditingRef = useRef(false);
|
||||
@@ -65,72 +113,45 @@ export function useModelState({
|
||||
|
||||
latestConfigRef.current = settingsConfig;
|
||||
|
||||
// 初始化读取:读新键;若缺失,按兼容优先级回退
|
||||
// Haiku: DEFAULT_HAIKU || SMALL_FAST || MODEL
|
||||
// Sonnet: DEFAULT_SONNET || MODEL || SMALL_FAST
|
||||
// Opus: DEFAULT_OPUS || MODEL || SMALL_FAST
|
||||
// 仅在 settingsConfig 变化时同步一次(表单加载/切换预设时)
|
||||
// 仅在 settingsConfig 外部变化时同步(表单加载 / 切换预设);
|
||||
// 用户正在编辑时 (isUserEditingRef) 跳过一次以避免回填覆盖。
|
||||
useEffect(() => {
|
||||
if (lastConfigRef.current === settingsConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUserEditingRef.current) {
|
||||
isUserEditingRef.current = false;
|
||||
lastConfigRef.current = settingsConfig;
|
||||
return;
|
||||
}
|
||||
|
||||
lastConfigRef.current = settingsConfig;
|
||||
|
||||
try {
|
||||
const cfg = settingsConfig ? JSON.parse(settingsConfig) : {};
|
||||
const env = cfg?.env || {};
|
||||
const model =
|
||||
typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : "";
|
||||
const small =
|
||||
typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string"
|
||||
? env.ANTHROPIC_SMALL_FAST_MODEL
|
||||
: "";
|
||||
const haiku =
|
||||
typeof env.ANTHROPIC_DEFAULT_HAIKU_MODEL === "string"
|
||||
? env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
: small || model;
|
||||
const sonnet =
|
||||
typeof env.ANTHROPIC_DEFAULT_SONNET_MODEL === "string"
|
||||
? env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
: model || small;
|
||||
const opus =
|
||||
typeof env.ANTHROPIC_DEFAULT_OPUS_MODEL === "string"
|
||||
? env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
: model || small;
|
||||
|
||||
setClaudeModel(model || "");
|
||||
setDefaultHaikuModel(haiku || "");
|
||||
setDefaultSonnetModel(sonnet || "");
|
||||
setDefaultOpusModel(opus || "");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const parsed = parseModelsFromConfig(settingsConfig);
|
||||
setClaudeModel(parsed.model);
|
||||
setDefaultHaikuModel(parsed.haiku);
|
||||
setDefaultHaikuModelName(parsed.haikuName);
|
||||
setDefaultSonnetModel(parsed.sonnet);
|
||||
setDefaultSonnetModelName(parsed.sonnetName);
|
||||
setDefaultOpusModel(parsed.opus);
|
||||
setDefaultOpusModelName(parsed.opusName);
|
||||
}, [settingsConfig]);
|
||||
|
||||
const handleModelChange = useCallback(
|
||||
(
|
||||
field:
|
||||
| "ANTHROPIC_MODEL"
|
||||
| "ANTHROPIC_DEFAULT_HAIKU_MODEL"
|
||||
| "ANTHROPIC_DEFAULT_SONNET_MODEL"
|
||||
| "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
value: string,
|
||||
) => {
|
||||
(field: ClaudeModelEnvField, value: string) => {
|
||||
isUserEditingRef.current = true;
|
||||
|
||||
if (field === "ANTHROPIC_MODEL") setClaudeModel(value);
|
||||
if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||||
setDefaultHaikuModel(value);
|
||||
if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME")
|
||||
setDefaultHaikuModelName(value);
|
||||
if (field === "ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||||
setDefaultSonnetModel(value);
|
||||
if (field === "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME")
|
||||
setDefaultSonnetModelName(value);
|
||||
if (field === "ANTHROPIC_DEFAULT_OPUS_MODEL") setDefaultOpusModel(value);
|
||||
if (field === "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME")
|
||||
setDefaultOpusModelName(value);
|
||||
|
||||
try {
|
||||
const currentConfig = latestConfigRef.current
|
||||
@@ -164,10 +185,16 @@ export function useModelState({
|
||||
setClaudeModel,
|
||||
defaultHaikuModel,
|
||||
setDefaultHaikuModel,
|
||||
defaultHaikuModelName,
|
||||
setDefaultHaikuModelName,
|
||||
defaultSonnetModel,
|
||||
setDefaultSonnetModel,
|
||||
defaultSonnetModelName,
|
||||
setDefaultSonnetModelName,
|
||||
defaultOpusModel,
|
||||
setDefaultOpusModel,
|
||||
defaultOpusModelName,
|
||||
setDefaultOpusModelName,
|
||||
handleModelChange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useAutoFailoverEnabled,
|
||||
useSetAutoFailoverEnabled,
|
||||
} from "@/lib/query/failover";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AppId } from "@/lib/api";
|
||||
@@ -24,8 +25,11 @@ export function FailoverToggle({ className, activeApp }: FailoverToggleProps) {
|
||||
const { data: isEnabled = false, isLoading } =
|
||||
useAutoFailoverEnabled(activeApp);
|
||||
const setEnabled = useSetAutoFailoverEnabled();
|
||||
const { takeoverStatus } = useProxyStatus();
|
||||
const takeoverEnabled = takeoverStatus?.[activeApp] ?? false;
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
if (checked && !takeoverEnabled) return;
|
||||
setEnabled.mutate({ appType: activeApp, enabled: checked });
|
||||
};
|
||||
|
||||
@@ -36,15 +40,20 @@ export function FailoverToggle({ className, activeApp }: FailoverToggleProps) {
|
||||
? "Codex"
|
||||
: "Gemini";
|
||||
|
||||
const tooltipText = isEnabled
|
||||
? t("failover.tooltip.enabled", {
|
||||
const tooltipText = !takeoverEnabled
|
||||
? t("failover.tooltip.takeoverRequired", {
|
||||
app: appLabel,
|
||||
defaultValue: `${appLabel} 故障转移已启用\n按队列优先级(P1→P2→...)选择供应商`,
|
||||
defaultValue: `请先接管 ${appLabel},再启用故障转移`,
|
||||
})
|
||||
: t("failover.tooltip.disabled", {
|
||||
app: appLabel,
|
||||
defaultValue: `启用 ${appLabel} 故障转移\n将立即切换到队列 P1,并在失败时自动切换到下一个`,
|
||||
});
|
||||
: isEnabled
|
||||
? t("failover.tooltip.enabled", {
|
||||
app: appLabel,
|
||||
defaultValue: `${appLabel} 故障转移已启用\n按队列优先级(P1→P2→...)选择供应商`,
|
||||
})
|
||||
: t("failover.tooltip.disabled", {
|
||||
app: appLabel,
|
||||
defaultValue: `启用 ${appLabel} 故障转移\n将立即切换到队列 P1,并在失败时自动切换到下一个`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -69,7 +78,7 @@ export function FailoverToggle({ className, activeApp }: FailoverToggleProps) {
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={setEnabled.isPending || isLoading}
|
||||
disabled={setEnabled.isPending || isLoading || !takeoverEnabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -119,22 +119,36 @@ export function ProxyPanel({
|
||||
const handleSaveBasicConfig = async () => {
|
||||
if (!globalConfig) return;
|
||||
|
||||
// 校验地址格式(简单的 IP 地址或 localhost 校验)
|
||||
// 校验地址格式(IPv4 / IPv6 字面量 / localhost)
|
||||
const addressTrimmed = listenAddress.trim();
|
||||
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
const isValidIpv4 = (addr: string): boolean =>
|
||||
ipv4Regex.test(addr) &&
|
||||
addr.split(".").every((n) => {
|
||||
const num = parseInt(n, 10);
|
||||
return num >= 0 && num <= 255;
|
||||
});
|
||||
// IPv6 字面量校验:必须含 `:` 且能在 [..] 包装后被 URL 解析器接受。
|
||||
// 后端 (services/proxy.rs) 会把 `::` 改写成 `::1`,所以这里也接受 `::`。
|
||||
const isValidIpv6 = (addr: string): boolean => {
|
||||
if (!addr.includes(":")) return false;
|
||||
try {
|
||||
new URL(`http://[${addr}]/`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const isValidAddress =
|
||||
addressTrimmed === "localhost" ||
|
||||
addressTrimmed === "0.0.0.0" ||
|
||||
(ipv4Regex.test(addressTrimmed) &&
|
||||
addressTrimmed.split(".").every((n) => {
|
||||
const num = parseInt(n);
|
||||
return num >= 0 && num <= 255;
|
||||
}));
|
||||
isValidIpv4(addressTrimmed) ||
|
||||
isValidIpv6(addressTrimmed);
|
||||
if (!isValidAddress) {
|
||||
toast.error(
|
||||
t("proxy.settings.invalidAddress", {
|
||||
defaultValue:
|
||||
"地址无效,请输入有效的 IP 地址(如 127.0.0.1)或 localhost",
|
||||
"地址无效,请输入 IPv4(如 127.0.0.1)、IPv6(如 ::1)或 localhost",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
@@ -711,6 +725,7 @@ function ProviderQueueItem({
|
||||
{/* 健康徽章 */}
|
||||
<ProviderHealthBadge
|
||||
consecutiveFailures={health?.consecutive_failures ?? 0}
|
||||
isHealthy={health?.is_healthy}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Download,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Github,
|
||||
Globe,
|
||||
Info,
|
||||
Loader2,
|
||||
@@ -371,6 +372,20 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
{t("settings.officialWebsite")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
settingsApi.openExternal(
|
||||
"https://github.com/farion1231/cc-switch",
|
||||
)
|
||||
}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<Github className="h-3.5 w-3.5" />
|
||||
{t("settings.github")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -16,7 +16,7 @@ const APP_CONFIG: Array<{
|
||||
icon: string;
|
||||
nameKey: string;
|
||||
}> = [
|
||||
{ id: "claude", icon: "claude", nameKey: "apps.claude" },
|
||||
{ id: "claude", icon: "claude", nameKey: "apps.claudeCode" },
|
||||
{
|
||||
id: "claude-desktop",
|
||||
icon: "claude",
|
||||
|
||||
@@ -35,6 +35,7 @@ export function ProxyTabContent({
|
||||
|
||||
const {
|
||||
isRunning,
|
||||
takeoverStatus,
|
||||
startProxyServer,
|
||||
stopWithRestore,
|
||||
isPending: isProxyPending,
|
||||
@@ -176,72 +177,38 @@ export function ProxyTabContent({
|
||||
<TabsTrigger value="codex">Codex</TabsTrigger>
|
||||
<TabsTrigger value="gemini">Gemini</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="claude" className="mt-4 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.failoverQueue.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failoverQueue.description")}
|
||||
</p>
|
||||
</div>
|
||||
<FailoverQueueManager
|
||||
appType="claude"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType="claude"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="codex" className="mt-4 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.failoverQueue.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failoverQueue.description")}
|
||||
</p>
|
||||
</div>
|
||||
<FailoverQueueManager
|
||||
appType="codex"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType="codex"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="gemini" className="mt-4 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.failoverQueue.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failoverQueue.description")}
|
||||
</p>
|
||||
</div>
|
||||
<FailoverQueueManager
|
||||
appType="gemini"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType="gemini"
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
{(["claude", "codex", "gemini"] as const).map((appType) => {
|
||||
const failoverDisabled =
|
||||
!isRunning || !(takeoverStatus?.[appType] ?? false);
|
||||
return (
|
||||
<TabsContent
|
||||
key={appType}
|
||||
value={appType}
|
||||
className="mt-4 space-y-6"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.failoverQueue.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failoverQueue.description")}
|
||||
</p>
|
||||
</div>
|
||||
<FailoverQueueManager
|
||||
appType={appType}
|
||||
disabled={failoverDisabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
appType={appType}
|
||||
disabled={failoverDisabled}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { useModelPricing, useDeleteModelPricing } from "@/lib/query/usage";
|
||||
import { PricingEditModal } from "./PricingEditModal";
|
||||
import type { ModelPricing } from "@/types/usage";
|
||||
import { isNonNegativeDecimalString, type ModelPricing } from "@/types/usage";
|
||||
import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { proxyApi } from "@/lib/api/proxy";
|
||||
@@ -143,7 +143,7 @@ export function PricingConfigPanel() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!/^-?\d+(?:\.\d+)?$/.test(trimmed)) {
|
||||
if (!isNonNegativeDecimalString(trimmed)) {
|
||||
toast.error(
|
||||
`${t(`apps.${app}`)}: ${t("settings.globalProxy.defaultCostMultiplierInvalid")}`,
|
||||
);
|
||||
@@ -281,6 +281,7 @@ export function PricingConfigPanel() {
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
inputMode="decimal"
|
||||
value={appConfigs[app].multiplier}
|
||||
onChange={(e) =>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useUpdateModelPricing } from "@/lib/query/usage";
|
||||
import type { ModelPricing } from "@/types/usage";
|
||||
import { isNonNegativeDecimalString, type ModelPricing } from "@/types/usage";
|
||||
|
||||
interface PricingEditModalProps {
|
||||
open: boolean;
|
||||
@@ -52,8 +52,7 @@ export function PricingEditModal({
|
||||
];
|
||||
|
||||
for (const value of values) {
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num) || num < 0) {
|
||||
if (!isNonNegativeDecimalString(value)) {
|
||||
toast.error(t("usage.invalidPrice", "价格必须为非负数"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useRequestDetail } from "@/lib/query/usage";
|
||||
import { getFreshInputTokens, isUnpricedUsage } from "@/types/usage";
|
||||
|
||||
interface RequestDetailPanelProps {
|
||||
requestId: string;
|
||||
@@ -50,6 +51,10 @@ export function RequestDetailPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const freshInput = getFreshInputTokens(request);
|
||||
const isCacheInclusive = request.inputTokens !== freshInput;
|
||||
const unpriced = isUnpricedUsage(request);
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
@@ -135,7 +140,13 @@ export function RequestDetailPanel({
|
||||
{t("usage.inputTokens", "输入 Tokens")}
|
||||
</dt>
|
||||
<dd className="font-mono">
|
||||
{request.inputTokens.toLocaleString()}
|
||||
{freshInput.toLocaleString()}
|
||||
{isCacheInclusive && (
|
||||
<span className="ml-2 text-xs text-muted-foreground/70 font-normal">
|
||||
({t("usage.rawInputLabel", "原始")}:{" "}
|
||||
{request.inputTokens.toLocaleString()})
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
@@ -167,9 +178,7 @@ export function RequestDetailPanel({
|
||||
{t("usage.totalTokens", "总计")}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold">
|
||||
{(
|
||||
request.inputTokens + request.outputTokens
|
||||
).toLocaleString()}
|
||||
{(freshInput + request.outputTokens).toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -247,8 +256,14 @@ export function RequestDetailPanel({
|
||||
</span>
|
||||
)}
|
||||
</dt>
|
||||
<dd className="text-lg font-semibold text-primary">
|
||||
${parseFloat(request.totalCostUsd).toFixed(6)}
|
||||
<dd
|
||||
className={`text-lg font-semibold ${
|
||||
unpriced ? "text-muted-foreground" : "text-primary"
|
||||
}`}
|
||||
>
|
||||
{unpriced
|
||||
? t("usage.unpriced", "未定价")
|
||||
: `$${parseFloat(request.totalCostUsd).toFixed(6)}`}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@@ -18,7 +18,13 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useRequestLogs } from "@/lib/query/usage";
|
||||
import type { LogFilters, UsageRangeSelection } from "@/types/usage";
|
||||
import {
|
||||
KNOWN_APP_TYPES,
|
||||
getFreshInputTokens,
|
||||
isUnpricedUsage,
|
||||
type LogFilters,
|
||||
type UsageRangeSelection,
|
||||
} from "@/types/usage";
|
||||
import { ChevronLeft, ChevronRight, Search, X } from "lucide-react";
|
||||
import { UsageDateRangePicker } from "./UsageDateRangePicker";
|
||||
import {
|
||||
@@ -138,9 +144,11 @@ export function RequestLogTable({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("usage.allApps")}</SelectItem>
|
||||
<SelectItem value="claude">Claude</SelectItem>
|
||||
<SelectItem value="codex">Codex</SelectItem>
|
||||
<SelectItem value="gemini">Gemini</SelectItem>
|
||||
{KNOWN_APP_TYPES.map((at) => (
|
||||
<SelectItem key={at} value={at}>
|
||||
{t(`usage.appFilter.${at}`, { defaultValue: at })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -286,101 +294,127 @@ export function RequestLogTable({
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.requestId}>
|
||||
<TableCell className="text-center whitespace-nowrap text-xs px-1.5">
|
||||
{new Date(log.createdAt * 1000).toLocaleString(locale, {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{log.providerName || t("usage.unknownProvider")}
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-mono text-xs max-w-[200px]">
|
||||
<div
|
||||
className="truncate"
|
||||
title={
|
||||
log.requestModel && log.requestModel !== log.model
|
||||
? `${log.requestModel} → ${log.model}`
|
||||
: log.model
|
||||
}
|
||||
>
|
||||
{log.requestModel &&
|
||||
log.requestModel !== log.model ? (
|
||||
<span>
|
||||
{log.requestModel}
|
||||
<span className="text-muted-foreground">
|
||||
{" → "}
|
||||
{log.model}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
log.model
|
||||
logs.map((log) => {
|
||||
const unpriced = isUnpricedUsage(log);
|
||||
return (
|
||||
<TableRow key={log.requestId}>
|
||||
<TableCell className="text-center whitespace-nowrap text-xs px-1.5">
|
||||
{new Date(log.createdAt * 1000).toLocaleString(
|
||||
locale,
|
||||
{
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center px-1.5">
|
||||
<div className="tabular-nums">
|
||||
{fmtInt(log.inputTokens, locale)}
|
||||
</div>
|
||||
{(log.cacheReadTokens > 0 ||
|
||||
log.cacheCreationTokens > 0) && (
|
||||
<div className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{[
|
||||
log.cacheReadTokens > 0 &&
|
||||
`R${fmtInt(log.cacheReadTokens, locale)}`,
|
||||
log.cacheCreationTokens > 0 &&
|
||||
`W${fmtInt(log.cacheCreationTokens, locale)}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("·")}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{log.providerName || t("usage.unknownProvider")}
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-mono text-xs max-w-[200px]">
|
||||
<div
|
||||
className="truncate"
|
||||
title={
|
||||
log.requestModel && log.requestModel !== log.model
|
||||
? `${log.requestModel} → ${log.model}`
|
||||
: log.model
|
||||
}
|
||||
>
|
||||
{log.requestModel &&
|
||||
log.requestModel !== log.model ? (
|
||||
<span>
|
||||
{log.requestModel}
|
||||
<span className="text-muted-foreground">
|
||||
{" → "}
|
||||
{log.model}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
log.model
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{fmtInt(log.outputTokens, locale)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center px-1.5">
|
||||
<div className="font-medium tabular-nums">
|
||||
{fmtUsd(log.totalCostUsd, 4)}
|
||||
</div>
|
||||
{parseFiniteNumber(log.costMultiplier) != null &&
|
||||
parseFiniteNumber(log.costMultiplier) !== 1 && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
×
|
||||
{parseFiniteNumber(log.costMultiplier)?.toFixed(
|
||||
2,
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center px-1.5">
|
||||
{(() => {
|
||||
const freshInput = getFreshInputTokens(log);
|
||||
const isCacheInclusive =
|
||||
log.inputTokens !== freshInput;
|
||||
return (
|
||||
<div
|
||||
className="tabular-nums"
|
||||
title={
|
||||
isCacheInclusive
|
||||
? `Raw: ${log.inputTokens.toLocaleString()}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{fmtInt(freshInput, locale)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{(log.cacheReadTokens > 0 ||
|
||||
log.cacheCreationTokens > 0) && (
|
||||
<div className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{[
|
||||
log.cacheReadTokens > 0 &&
|
||||
`R${fmtInt(log.cacheReadTokens, locale)}`,
|
||||
log.cacheCreationTokens > 0 &&
|
||||
`W${fmtInt(log.cacheCreationTokens, locale)}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("·")}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap text-xs tabular-nums">
|
||||
{(log.latencyMs / 1000).toFixed(1)}s
|
||||
{log.firstTokenMs != null && (
|
||||
<span className="text-muted-foreground">
|
||||
/{(log.firstTokenMs / 1000).toFixed(1)}s
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{fmtInt(log.outputTokens, locale)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center px-1.5">
|
||||
<div
|
||||
className={`font-medium tabular-nums ${
|
||||
unpriced ? "text-muted-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
{unpriced
|
||||
? t("usage.unpriced", "未定价")
|
||||
: fmtUsd(log.totalCostUsd, 4)}
|
||||
</div>
|
||||
{parseFiniteNumber(log.costMultiplier) != null &&
|
||||
parseFiniteNumber(log.costMultiplier) !== 1 && (
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
×
|
||||
{parseFiniteNumber(log.costMultiplier)?.toFixed(
|
||||
2,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap text-xs tabular-nums">
|
||||
{(log.latencyMs / 1000).toFixed(1)}s
|
||||
{log.firstTokenMs != null && (
|
||||
<span className="text-muted-foreground">
|
||||
/{(log.firstTokenMs / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span
|
||||
className={
|
||||
log.statusCode >= 200 && log.statusCode < 300
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{log.statusCode}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span
|
||||
className={
|
||||
log.statusCode >= 200 && log.statusCode < 300
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{log.statusCode}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs text-muted-foreground">
|
||||
{log.dataSource || "proxy"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs text-muted-foreground">
|
||||
{log.dataSource || "proxy"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UsageSummaryCards } from "./UsageSummaryCards";
|
||||
import { UsageHero } from "./UsageHero";
|
||||
import { UsageTrendChart } from "./UsageTrendChart";
|
||||
import { RequestLogTable } from "./RequestLogTable";
|
||||
import { ProviderStatsTable } from "./ProviderStatsTable";
|
||||
import { ModelStatsTable } from "./ModelStatsTable";
|
||||
import type { AppTypeFilter, UsageRangeSelection } from "@/types/usage";
|
||||
import {
|
||||
KNOWN_APP_TYPES,
|
||||
type AppTypeFilter,
|
||||
type UsageRangeSelection,
|
||||
} from "@/types/usage";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
BarChart3,
|
||||
@@ -30,12 +34,7 @@ import { getUsageRangePresetLabel, resolveUsageRange } from "@/lib/usageRange";
|
||||
import { UsageDateRangePicker } from "./UsageDateRangePicker";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
const APP_FILTER_OPTIONS: AppTypeFilter[] = [
|
||||
"all",
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
];
|
||||
const APP_FILTER_OPTIONS: AppTypeFilter[] = ["all", ...KNOWN_APP_TYPES];
|
||||
|
||||
export function UsageDashboard() {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -127,9 +126,9 @@ export function UsageDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UsageSummaryCards
|
||||
<UsageHero
|
||||
range={range}
|
||||
appType={appType}
|
||||
appType={appType === "all" ? undefined : appType}
|
||||
refreshIntervalMs={refreshIntervalMs}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useUsageSummaryByApp } from "@/lib/query/usage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Activity,
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
Database,
|
||||
Info,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
fmtUsd,
|
||||
formatTokensShort,
|
||||
getResolvedLang,
|
||||
parseFiniteNumber,
|
||||
} from "./format";
|
||||
import {
|
||||
CACHE_INCLUSIVE_APP_TYPES,
|
||||
type AppType,
|
||||
type UsageRangeSelection,
|
||||
type UsageSummary,
|
||||
type UsageSummaryByApp,
|
||||
} from "@/types/usage";
|
||||
|
||||
interface UsageHeroProps {
|
||||
range: UsageRangeSelection;
|
||||
appType?: string;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
interface TitleTheme {
|
||||
/** Foreground color for the icon glyph (text-* class). */
|
||||
accent: string;
|
||||
/** Background tint for the icon square (bg-* class). */
|
||||
iconBg: string;
|
||||
}
|
||||
|
||||
const TITLE_THEMES: Record<AppType | "all", TitleTheme> = {
|
||||
all: { accent: "text-primary", iconBg: "bg-primary/10" },
|
||||
claude: {
|
||||
accent: "text-amber-600 dark:text-amber-400",
|
||||
iconBg: "bg-amber-500/10",
|
||||
},
|
||||
codex: {
|
||||
accent: "text-emerald-600 dark:text-emerald-400",
|
||||
iconBg: "bg-emerald-500/10",
|
||||
},
|
||||
gemini: {
|
||||
accent: "text-sky-600 dark:text-sky-400",
|
||||
iconBg: "bg-sky-500/10",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Combine per-app summaries into a single rolled-up summary.
|
||||
*
|
||||
* The backend's per-app rows already use fresh-input semantics (cache-inclusive
|
||||
* providers have been normalized in SQL), so plain addition is correct here.
|
||||
* `cacheHitRate` and `successRate` must be re-derived from the summed counts
|
||||
* rather than averaged across rows.
|
||||
*/
|
||||
function aggregateSummaries(items: UsageSummary[]): UsageSummary {
|
||||
let totalRequests = 0;
|
||||
let successCount = 0;
|
||||
let totalCostNum = 0;
|
||||
let input = 0;
|
||||
let output = 0;
|
||||
let cacheCreation = 0;
|
||||
let cacheRead = 0;
|
||||
|
||||
for (const s of items) {
|
||||
totalRequests += s.totalRequests;
|
||||
successCount += Math.round((s.totalRequests * s.successRate) / 100);
|
||||
totalCostNum += parseFiniteNumber(s.totalCost) ?? 0;
|
||||
input += s.totalInputTokens;
|
||||
output += s.totalOutputTokens;
|
||||
cacheCreation += s.totalCacheCreationTokens;
|
||||
cacheRead += s.totalCacheReadTokens;
|
||||
}
|
||||
|
||||
const cacheableInput = input + cacheCreation + cacheRead;
|
||||
return {
|
||||
totalRequests,
|
||||
totalCost: totalCostNum.toFixed(6),
|
||||
totalInputTokens: input,
|
||||
totalOutputTokens: output,
|
||||
totalCacheCreationTokens: cacheCreation,
|
||||
totalCacheReadTokens: cacheRead,
|
||||
successRate: totalRequests > 0 ? (successCount / totalRequests) * 100 : 0,
|
||||
realTotalTokens: input + output + cacheCreation + cacheRead,
|
||||
cacheHitRate: cacheableInput > 0 ? cacheRead / cacheableInput : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function pickSummary(
|
||||
apps: UsageSummaryByApp[],
|
||||
appType: string | undefined,
|
||||
): UsageSummary | undefined {
|
||||
if (apps.length === 0) return undefined;
|
||||
if (appType) {
|
||||
return apps.find((a) => a.appType === appType)?.summary;
|
||||
}
|
||||
return aggregateSummaries(apps.map((a) => a.summary));
|
||||
}
|
||||
|
||||
type CacheWriteState = "ok" | "partial" | "na";
|
||||
|
||||
/**
|
||||
* Anthropic-style protocols report cache creation; OpenAI-style protocols
|
||||
* (Codex/Gemini) do not — so a mix shows the number with a caveat, all-OpenAI
|
||||
* shows N/A. `appTypes` is the set actually contributing to the displayed
|
||||
* summary (a single app, or every app that participated in "all").
|
||||
*/
|
||||
function deriveCacheWriteState(appTypes: string[]): CacheWriteState {
|
||||
if (appTypes.length === 0) return "ok";
|
||||
const inclusive = appTypes.filter((t) =>
|
||||
CACHE_INCLUSIVE_APP_TYPES.has(t),
|
||||
).length;
|
||||
if (inclusive === appTypes.length) return "na";
|
||||
if (inclusive === 0) return "ok";
|
||||
return "partial";
|
||||
}
|
||||
|
||||
export function UsageHero({
|
||||
range,
|
||||
appType,
|
||||
refreshIntervalMs,
|
||||
}: UsageHeroProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = getResolvedLang(i18n);
|
||||
|
||||
const { data, isLoading } = useUsageSummaryByApp(range, {
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
});
|
||||
|
||||
// No client-side filtering: Hero's totals must match the Trend/Logs/Stats
|
||||
// below, which all go through the backend's full set of app_types. The
|
||||
// KNOWN_APP_TYPES list only governs which filter buttons appear, not which
|
||||
// rows participate in the "all" aggregate.
|
||||
const allApps = data ?? [];
|
||||
const summary = pickSummary(allApps, appType);
|
||||
|
||||
const titleTheme =
|
||||
TITLE_THEMES[(appType ?? "all") as keyof typeof TITLE_THEMES] ??
|
||||
TITLE_THEMES.all;
|
||||
const appLabel =
|
||||
appType && appType in TITLE_THEMES ? t(`usage.appFilter.${appType}`) : null;
|
||||
|
||||
const cacheWriteState = deriveCacheWriteState(
|
||||
appType ? [appType] : allApps.map((a) => a.appType),
|
||||
);
|
||||
|
||||
const input = summary?.totalInputTokens ?? 0;
|
||||
const output = summary?.totalOutputTokens ?? 0;
|
||||
const cacheWrite = summary?.totalCacheCreationTokens ?? 0;
|
||||
const cacheRead = summary?.totalCacheReadTokens ?? 0;
|
||||
const realTotal = summary?.realTotalTokens ?? 0;
|
||||
const hitRate = summary?.cacheHitRate ?? 0;
|
||||
const totalCost = parseFiniteNumber(summary?.totalCost);
|
||||
const requests = summary?.totalRequests ?? 0;
|
||||
|
||||
const cacheWriteDisplay = {
|
||||
value:
|
||||
cacheWriteState === "na" ? "N/A" : formatTokensShort(cacheWrite, lang),
|
||||
muted: cacheWriteState === "na",
|
||||
tooltip:
|
||||
cacheWriteState === "na"
|
||||
? t(
|
||||
"usage.cacheWriteNotReported",
|
||||
"OpenAI 协议不区分缓存写入,仅上报缓存命中",
|
||||
)
|
||||
: cacheWriteState === "partial"
|
||||
? t(
|
||||
"usage.cacheWritePartial",
|
||||
"部分协议(如 OpenAI)不上报缓存写入,数值可能偏低",
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border border-border/50 bg-card/40 backdrop-blur-sm">
|
||||
<CardContent className="flex items-center justify-center min-h-[200px]">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground/50" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const hitPercent = Math.max(0, Math.min(100, hitRate * 100));
|
||||
const hitPercentLabel = hitPercent.toFixed(hitPercent >= 99.95 ? 0 : 1);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<Card className="relative overflow-hidden border border-border/50 bg-gradient-to-br from-primary/5 via-card/50 to-background/50 backdrop-blur-xl shadow-sm">
|
||||
<CardContent className="p-6 md:p-8">
|
||||
{/* Header: title + cost */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("p-2 rounded-lg", titleTheme.iconBg)}>
|
||||
<Zap className={cn("h-4 w-4", titleTheme.accent)} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{appLabel && (
|
||||
<>
|
||||
<span className={cn("font-semibold", titleTheme.accent)}>
|
||||
{appLabel}
|
||||
</span>
|
||||
<span className="mx-1.5 text-muted-foreground/40">·</span>
|
||||
</>
|
||||
)}
|
||||
{t("usage.realTotal", "真实消耗 Tokens")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-right">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("usage.totalRequests")}
|
||||
</span>
|
||||
<span className="text-sm font-semibold flex items-center gap-1 justify-end">
|
||||
<Activity className="h-3.5 w-3.5 text-blue-500" />
|
||||
{requests.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("usage.totalCost")}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-green-500">
|
||||
{totalCost == null ? "--" : fmtUsd(totalCost, 4)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero number */}
|
||||
<div className="flex flex-col items-start mb-6">
|
||||
<div
|
||||
className="text-4xl md:text-5xl font-bold tracking-tight tabular-nums leading-tight"
|
||||
title={realTotal.toLocaleString()}
|
||||
>
|
||||
{realTotal.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
≈ {formatTokensShort(realTotal, lang, 2)}{" "}
|
||||
{t("usage.tokensSuffix", "tokens")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdown row: 4 mini stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-5">
|
||||
<MiniStat
|
||||
icon={<ArrowDownToLine className="h-3.5 w-3.5" />}
|
||||
label={t("usage.freshInput", "新增输入")}
|
||||
value={formatTokensShort(input, lang)}
|
||||
accent="text-blue-500"
|
||||
/>
|
||||
<MiniStat
|
||||
icon={<ArrowUpFromLine className="h-3.5 w-3.5" />}
|
||||
label={t("usage.output")}
|
||||
value={formatTokensShort(output, lang)}
|
||||
accent="text-purple-500"
|
||||
/>
|
||||
<MiniStat
|
||||
icon={<Database className="h-3.5 w-3.5" />}
|
||||
label={t("usage.cacheWrite", "缓存写入")}
|
||||
value={cacheWriteDisplay.value}
|
||||
accent="text-amber-500"
|
||||
muted={cacheWriteDisplay.muted}
|
||||
tooltip={cacheWriteDisplay.tooltip}
|
||||
/>
|
||||
<MiniStat
|
||||
icon={<Sparkles className="h-3.5 w-3.5" />}
|
||||
label={t("usage.cacheRead", "缓存命中")}
|
||||
value={formatTokensShort(cacheRead, lang)}
|
||||
accent="text-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hit rate progress */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("usage.cacheHitRate", "缓存命中率")}
|
||||
</span>
|
||||
<span className="font-semibold text-emerald-500 tabular-nums">
|
||||
{hitPercentLabel}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-2 rounded-full bg-muted/50 overflow-hidden">
|
||||
<motion.div
|
||||
className="absolute inset-y-0 left-0 bg-gradient-to-r from-emerald-500/80 to-emerald-400 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${hitPercent}%` }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MiniStatProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
accent: string;
|
||||
/** Optional hover tooltip — used to flag protocol-level caveats. */
|
||||
tooltip?: string;
|
||||
/** Visually de-emphasize the value (e.g. for "N/A" cases). */
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
function MiniStat({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
accent,
|
||||
tooltip,
|
||||
muted,
|
||||
}: MiniStatProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-1 rounded-lg border border-border/40 bg-background/40 px-3 py-2.5"
|
||||
title={tooltip}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-xs text-muted-foreground ${accent}`}
|
||||
>
|
||||
{icon}
|
||||
<span className="text-foreground/70">{label}</span>
|
||||
{tooltip && (
|
||||
<Info className="h-3 w-3 text-muted-foreground/60 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-base font-semibold tabular-nums",
|
||||
muted && "text-muted-foreground/70",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useUsageSummary } from "@/lib/query/usage";
|
||||
import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { fmtUsd, parseFiniteNumber } from "./format";
|
||||
import type { UsageRangeSelection } from "@/types/usage";
|
||||
|
||||
interface UsageSummaryCardsProps {
|
||||
range: UsageRangeSelection;
|
||||
appType?: string;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
export function UsageSummaryCards({
|
||||
range,
|
||||
appType,
|
||||
refreshIntervalMs,
|
||||
}: UsageSummaryCardsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: summary, isLoading } = useUsageSummary(range, appType, {
|
||||
refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false,
|
||||
});
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const totalRequests = summary?.totalRequests ?? 0;
|
||||
const totalCost = parseFiniteNumber(summary?.totalCost);
|
||||
|
||||
const inputTokens = summary?.totalInputTokens ?? 0;
|
||||
const outputTokens = summary?.totalOutputTokens ?? 0;
|
||||
const totalTokens = inputTokens + outputTokens;
|
||||
|
||||
const cacheWriteTokens = summary?.totalCacheCreationTokens ?? 0;
|
||||
const cacheReadTokens = summary?.totalCacheReadTokens ?? 0;
|
||||
const totalCacheTokens = cacheWriteTokens + cacheReadTokens;
|
||||
|
||||
return [
|
||||
{
|
||||
title: t("usage.totalRequests"),
|
||||
value: totalRequests.toLocaleString(),
|
||||
icon: Activity,
|
||||
color: "text-blue-500",
|
||||
bg: "bg-blue-500/10",
|
||||
subValue: null,
|
||||
},
|
||||
{
|
||||
title: t("usage.totalCost"),
|
||||
value: totalCost == null ? "--" : fmtUsd(totalCost, 4),
|
||||
icon: DollarSign,
|
||||
color: "text-green-500",
|
||||
bg: "bg-green-500/10",
|
||||
subValue: null,
|
||||
},
|
||||
{
|
||||
title: t("usage.totalTokens"),
|
||||
value: totalTokens.toLocaleString(),
|
||||
icon: Layers,
|
||||
color: "text-purple-500",
|
||||
bg: "bg-purple-500/10",
|
||||
subValue: (
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{t("usage.input")}</span>
|
||||
<span className="text-foreground/80">
|
||||
{(inputTokens / 1000).toFixed(1)}k
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{t("usage.output")}</span>
|
||||
<span className="text-foreground/80">
|
||||
{(outputTokens / 1000).toFixed(1)}k
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("usage.cacheTokens"),
|
||||
value: totalCacheTokens.toLocaleString(),
|
||||
icon: Database,
|
||||
color: "text-orange-500",
|
||||
bg: "bg-orange-500/10",
|
||||
subValue: (
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{t("usage.cacheWrite")}</span>
|
||||
<span className="text-foreground/80">
|
||||
{(cacheWriteTokens / 1000).toFixed(1)}k
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span>{t("usage.cacheRead")}</span>
|
||||
<span className="text-foreground/80">
|
||||
{(cacheReadTokens / 1000).toFixed(1)}k
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [summary, t]);
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const item = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
show: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card
|
||||
key={i}
|
||||
className="border border-border/50 bg-card/40 backdrop-blur-sm shadow-sm"
|
||||
>
|
||||
<CardContent className="p-6 flex items-center justify-center min-h-[160px]">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground/50" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid gap-4 md:grid-cols-4"
|
||||
>
|
||||
{stats.map((stat, i) => (
|
||||
<motion.div key={i} variants={item}>
|
||||
<Card className="relative h-full overflow-hidden border border-border/50 bg-gradient-to-br from-card/50 to-background/50 backdrop-blur-xl hover:from-card/60 hover:to-background/60 transition-all shadow-sm">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{stat.title}
|
||||
</p>
|
||||
<div className={`p-2 rounded-lg ${stat.bg}`}>
|
||||
<stat.icon className={`h-4 w-4 ${stat.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-2xl font-bold truncate" title={stat.value}>
|
||||
{stat.value}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{stat.subValue || (
|
||||
/* Placeholder to properly align cards if no subvalue (first 2 cards) - effectively adding empty space or using flex-1 equivalent */
|
||||
<div className="mt-3 pt-3 border-t border-transparent h-[52px]"></div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -37,3 +37,37 @@ export function getLocaleFromLanguage(language: string): string {
|
||||
if (language.startsWith("ja")) return "ja-JP";
|
||||
return "en-US";
|
||||
}
|
||||
|
||||
interface I18nLike {
|
||||
resolvedLanguage?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export function getResolvedLang(i18n: I18nLike): string {
|
||||
return i18n.resolvedLanguage || i18n.language || "en";
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 数量的紧凑显示。
|
||||
*
|
||||
* Why: 中日文用户期待 "亿/万" 量纲;英文用户期待 K/M/B。共用同一份格式化
|
||||
* 逻辑避免 Hero 卡和分应用卡显示不一致。`compactDecimals=2` 用于 Hero
|
||||
* 大数副标(更精确),默认 1 位用于卡片副字段。
|
||||
*/
|
||||
export function formatTokensShort(
|
||||
value: number,
|
||||
lang: string,
|
||||
compactDecimals: 1 | 2 = 1,
|
||||
): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0";
|
||||
const decimals = compactDecimals;
|
||||
if (lang.startsWith("zh") || lang.startsWith("ja")) {
|
||||
if (value >= 1e8) return `${(value / 1e8).toFixed(2)} 亿`;
|
||||
if (value >= 1e4) return `${(value / 1e4).toFixed(decimals)} 万`;
|
||||
return value.toLocaleString();
|
||||
}
|
||||
if (value >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
|
||||
if (value >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
|
||||
if (value >= 1e3) return `${(value / 1e3).toFixed(decimals)}K`;
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* 形态与 Claude Code 预设不同:
|
||||
* - baseUrl 是顶级字段,而不是 settingsConfig.env.ANTHROPIC_BASE_URL
|
||||
* - 模型信息以"请求模型 → 上游模型 → 显示模型"三段式表达,
|
||||
* 对应后端 ClaudeDesktopModelRoute 的 routeId / model / displayName
|
||||
* - 模型信息以"Desktop 可见模型 ID → 上游模型"表达,
|
||||
* 对应后端 ClaudeDesktopModelRoute 的 routeId / model
|
||||
*
|
||||
* 翻译来源:src/config/claudeProviderPresets.ts(排除 OAuth 与不兼容预设)
|
||||
*/
|
||||
@@ -20,10 +20,23 @@ export type ClaudeDesktopApiFormat =
|
||||
export interface ClaudeDesktopRoutePreset {
|
||||
routeId: string;
|
||||
upstreamModel: string;
|
||||
displayName: string;
|
||||
labelOverride?: string;
|
||||
supports1m: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Desktop 3P fail-all 校验只接受 `claude-(sonnet|opus|haiku)-*` 形式的
|
||||
* routeId(1.6259.1+,实测 2026-05-13)。所有预设工厂、表单角色下拉、
|
||||
* 后端 `next_catalog_safe_route_id` 都从此映射派生 routeId,避免散落硬编码。
|
||||
*/
|
||||
export const CLAUDE_DESKTOP_ROLE_ROUTE_IDS = {
|
||||
sonnet: "claude-sonnet-4-6",
|
||||
opus: "claude-opus-4-7",
|
||||
haiku: "claude-haiku-4-5",
|
||||
} as const;
|
||||
|
||||
export type ClaudeDesktopRoleId = keyof typeof CLAUDE_DESKTOP_ROLE_ROUTE_IDS;
|
||||
|
||||
export interface ClaudeDesktopProviderPreset {
|
||||
name: string;
|
||||
nameKey?: string;
|
||||
@@ -39,6 +52,8 @@ export interface ClaudeDesktopProviderPreset {
|
||||
mode: "direct" | "proxy";
|
||||
apiFormat?: ClaudeDesktopApiFormat;
|
||||
modelRoutes?: ClaudeDesktopRoutePreset[];
|
||||
providerType?: "github_copilot" | "codex_oauth";
|
||||
requiresOAuth?: boolean;
|
||||
|
||||
endpointCandidates?: string[];
|
||||
theme?: PresetTheme;
|
||||
@@ -48,21 +63,18 @@ export interface ClaudeDesktopProviderPreset {
|
||||
|
||||
const passthroughRoutes = (supports1m = false): ClaudeDesktopRoutePreset[] => [
|
||||
{
|
||||
routeId: "claude-sonnet-4-6",
|
||||
upstreamModel: "claude-sonnet-4-6",
|
||||
displayName: "Sonnet",
|
||||
routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.sonnet,
|
||||
upstreamModel: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.sonnet,
|
||||
supports1m,
|
||||
},
|
||||
{
|
||||
routeId: "claude-opus-4-7",
|
||||
upstreamModel: "claude-opus-4-7",
|
||||
displayName: "Opus",
|
||||
routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.opus,
|
||||
upstreamModel: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.opus,
|
||||
supports1m,
|
||||
},
|
||||
{
|
||||
routeId: "claude-haiku-4-5",
|
||||
upstreamModel: "claude-haiku-4-5",
|
||||
displayName: "Haiku",
|
||||
routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.haiku,
|
||||
upstreamModel: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.haiku,
|
||||
supports1m,
|
||||
},
|
||||
];
|
||||
@@ -74,54 +86,52 @@ const mappedRoutes = (
|
||||
supports1m = false,
|
||||
): ClaudeDesktopRoutePreset[] => [
|
||||
{
|
||||
routeId: "claude-sonnet-4-6",
|
||||
routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.sonnet,
|
||||
upstreamModel: sonnet,
|
||||
displayName: "Sonnet",
|
||||
supports1m,
|
||||
},
|
||||
{
|
||||
routeId: "claude-opus-4-7",
|
||||
routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.opus,
|
||||
upstreamModel: opus,
|
||||
displayName: "Opus",
|
||||
supports1m,
|
||||
},
|
||||
{
|
||||
routeId: "claude-haiku-4-5",
|
||||
routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.haiku,
|
||||
upstreamModel: haiku,
|
||||
displayName: "Haiku",
|
||||
supports1m,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 非 Claude 上游模型用此工厂:displayName 直接等于 upstreamModel,
|
||||
* 让用户在 Claude Desktop 看到的就是实际请求的模型 ID(如 "deepseek-v4-pro")。
|
||||
* 非 Claude 上游模型用此工厂:route ID 使用 Claude Desktop 能通过校验的
|
||||
* Sonnet/Opus/Haiku 路由,真实品牌名只写入 labelOverride 和 upstreamModel。
|
||||
*/
|
||||
const brandedRoutes = (
|
||||
sonnet: string,
|
||||
opus: string,
|
||||
haiku: string,
|
||||
supports1m = false,
|
||||
): ClaudeDesktopRoutePreset[] => [
|
||||
{
|
||||
routeId: "claude-sonnet-4-6",
|
||||
upstreamModel: sonnet,
|
||||
displayName: sonnet,
|
||||
supports1m,
|
||||
},
|
||||
{
|
||||
routeId: "claude-opus-4-7",
|
||||
upstreamModel: opus,
|
||||
displayName: opus,
|
||||
supports1m,
|
||||
},
|
||||
{
|
||||
routeId: "claude-haiku-4-5",
|
||||
upstreamModel: haiku,
|
||||
displayName: haiku,
|
||||
supports1m,
|
||||
},
|
||||
];
|
||||
): ClaudeDesktopRoutePreset[] => {
|
||||
const seenUpstream = new Set<string>();
|
||||
return [
|
||||
{ routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.sonnet, upstreamModel: sonnet },
|
||||
{ routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.opus, upstreamModel: opus },
|
||||
{ routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.haiku, upstreamModel: haiku },
|
||||
]
|
||||
.map(({ routeId, upstreamModel }) => ({
|
||||
routeId,
|
||||
upstreamModel,
|
||||
labelOverride: upstreamModel,
|
||||
supports1m,
|
||||
}))
|
||||
.filter((route) => {
|
||||
if (seenUpstream.has(route.upstreamModel)) {
|
||||
return false;
|
||||
}
|
||||
seenUpstream.add(route.upstreamModel);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
{
|
||||
@@ -131,13 +141,86 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
apiKeyUrl: "https://www.shengsuanyun.com/?from=CH_4HHXMRYF",
|
||||
category: "aggregator",
|
||||
baseUrl: "https://router.shengsuanyun.com/api",
|
||||
mode: "proxy",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "shengsuanyun",
|
||||
icon: "shengsuanyun",
|
||||
},
|
||||
{
|
||||
name: "PatewayAI",
|
||||
websiteUrl: "https://pateway.ai",
|
||||
apiKeyUrl: "https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/",
|
||||
category: "third_party",
|
||||
baseUrl: "https://api.pateway.ai",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "patewayai",
|
||||
icon: "pateway",
|
||||
},
|
||||
{
|
||||
name: "火山Agentplan",
|
||||
websiteUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
category: "cn_official",
|
||||
baseUrl: "https://ark.cn-beijing.volces.com/api/coding",
|
||||
mode: "proxy",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: brandedRoutes(
|
||||
"ark-code-latest",
|
||||
"ark-code-latest",
|
||||
"ark-code-latest",
|
||||
),
|
||||
icon: "huoshan",
|
||||
iconColor: "#3370FF",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "volcengine_agentplan",
|
||||
},
|
||||
{
|
||||
name: "BytePlus",
|
||||
websiteUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
category: "cn_official",
|
||||
baseUrl: "https://ark.ap-southeast.bytepluses.com/api/coding",
|
||||
mode: "proxy",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: brandedRoutes(
|
||||
"ark-code-latest",
|
||||
"ark-code-latest",
|
||||
"ark-code-latest",
|
||||
),
|
||||
icon: "byteplus",
|
||||
iconColor: "#3370FF",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "byteplus",
|
||||
},
|
||||
{
|
||||
name: "DouBaoSeed",
|
||||
websiteUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
category: "cn_official",
|
||||
baseUrl: "https://ark.cn-beijing.volces.com/api/compatible",
|
||||
mode: "proxy",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: brandedRoutes(
|
||||
"doubao-seed-2-0-code-preview-latest",
|
||||
"doubao-seed-2-0-code-preview-latest",
|
||||
"doubao-seed-2-0-code-preview-latest",
|
||||
),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "doubaoseed",
|
||||
icon: "doubao",
|
||||
iconColor: "#3370FF",
|
||||
},
|
||||
{
|
||||
name: "Gemini Native",
|
||||
websiteUrl: "https://ai.google.dev/gemini-api",
|
||||
@@ -156,6 +239,36 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
icon: "gemini",
|
||||
iconColor: "#4285F4",
|
||||
},
|
||||
{
|
||||
name: "GitHub Copilot",
|
||||
websiteUrl: "https://github.com/features/copilot",
|
||||
category: "third_party",
|
||||
baseUrl: "https://api.githubcopilot.com",
|
||||
mode: "proxy",
|
||||
apiFormat: "openai_chat",
|
||||
providerType: "github_copilot",
|
||||
requiresOAuth: true,
|
||||
modelRoutes: brandedRoutes(
|
||||
"claude-sonnet-4.6",
|
||||
"claude-sonnet-4.6",
|
||||
"claude-haiku-4.5",
|
||||
),
|
||||
icon: "github",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "Codex",
|
||||
websiteUrl: "https://openai.com/chatgpt/pricing",
|
||||
category: "third_party",
|
||||
baseUrl: "https://chatgpt.com/backend-api/codex",
|
||||
mode: "proxy",
|
||||
apiFormat: "openai_responses",
|
||||
providerType: "codex_oauth",
|
||||
requiresOAuth: true,
|
||||
modelRoutes: brandedRoutes("gpt-5.4", "gpt-5.4", "gpt-5.4-mini"),
|
||||
icon: "openai",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "DeepSeek",
|
||||
websiteUrl: "https://platform.deepseek.com",
|
||||
@@ -358,22 +471,6 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
icon: "minimax",
|
||||
iconColor: "#FF6B6B",
|
||||
},
|
||||
{
|
||||
name: "DouBaoSeed",
|
||||
websiteUrl: "https://www.volcengine.com/product/doubao",
|
||||
apiKeyUrl: "https://www.volcengine.com/product/doubao",
|
||||
category: "cn_official",
|
||||
baseUrl: "https://ark.cn-beijing.volces.com/api/coding",
|
||||
mode: "proxy",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: brandedRoutes(
|
||||
"doubao-seed-2-0-code-preview-latest",
|
||||
"doubao-seed-2-0-code-preview-latest",
|
||||
"doubao-seed-2-0-code-preview-latest",
|
||||
),
|
||||
icon: "doubao",
|
||||
iconColor: "#3370FF",
|
||||
},
|
||||
{
|
||||
name: "BaiLing",
|
||||
websiteUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/get_started",
|
||||
@@ -390,7 +487,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
category: "aggregator",
|
||||
baseUrl: "https://aihubmix.com",
|
||||
apiKeyField: "ANTHROPIC_API_KEY",
|
||||
mode: "proxy",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: ["https://aihubmix.com", "https://api.aihubmix.com"],
|
||||
@@ -439,7 +536,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
apiKeyUrl: "https://www.dmxapi.cn",
|
||||
category: "aggregator",
|
||||
baseUrl: "https://www.dmxapi.cn",
|
||||
mode: "proxy",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: ["https://www.dmxapi.cn", "https://api.dmxapi.cn"],
|
||||
@@ -452,7 +549,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
apiKeyUrl: "https://www.packyapi.com/register?aff=cc-switch",
|
||||
category: "third_party",
|
||||
baseUrl: "https://www.packyapi.com",
|
||||
mode: "proxy",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: [
|
||||
@@ -463,13 +560,63 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
partnerPromotionKey: "packycode",
|
||||
icon: "packycode",
|
||||
},
|
||||
{
|
||||
name: "ClaudeAPI",
|
||||
websiteUrl: "https://claudeapi.com",
|
||||
apiKeyUrl: "https://console.claudeapi.com/register?aff=pCLD",
|
||||
category: "third_party",
|
||||
baseUrl: "https://gw.claudeapi.com",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "claudeapi",
|
||||
icon: "claudeapi",
|
||||
},
|
||||
{
|
||||
name: "ClaudeCN",
|
||||
websiteUrl: "https://claudecn.top",
|
||||
apiKeyUrl: "https://claudecn.top/register?aff=ccswitch",
|
||||
category: "third_party",
|
||||
baseUrl: "https://claudecn.top",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "claudecn",
|
||||
icon: "claudecn",
|
||||
},
|
||||
{
|
||||
name: "RunAPI",
|
||||
websiteUrl: "https://runapi.co",
|
||||
apiKeyUrl: "https://runapi.co",
|
||||
category: "aggregator",
|
||||
baseUrl: "https://runapi.co",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "runapi",
|
||||
icon: "runapi",
|
||||
},
|
||||
{
|
||||
name: "RelaxyCode",
|
||||
websiteUrl: "https://www.relaxycode.com",
|
||||
apiKeyUrl: "https://www.relaxycode.com/register",
|
||||
category: "third_party",
|
||||
baseUrl: "https://www.relaxycode.com",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
icon: "relaxcode",
|
||||
},
|
||||
{
|
||||
name: "Cubence",
|
||||
websiteUrl: "https://cubence.com",
|
||||
apiKeyUrl: "https://cubence.com/signup?code=CCSWITCH&source=ccs",
|
||||
category: "third_party",
|
||||
baseUrl: "https://api.cubence.com",
|
||||
mode: "proxy",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: [
|
||||
@@ -489,7 +636,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
apiKeyUrl: "https://aigocode.com/invite/CC-SWITCH",
|
||||
category: "third_party",
|
||||
baseUrl: "https://api.aigocode.com",
|
||||
mode: "proxy",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: ["https://api.aigocode.com"],
|
||||
@@ -504,7 +651,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
apiKeyUrl: "https://www.right.codes/register?aff=CCSWITCH",
|
||||
category: "third_party",
|
||||
baseUrl: "https://www.right.codes/claude",
|
||||
mode: "proxy",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
isPartner: true,
|
||||
@@ -518,7 +665,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
apiKeyUrl: "https://www.aicodemirror.com/register?invitecode=9915W3",
|
||||
category: "third_party",
|
||||
baseUrl: "https://api.aicodemirror.com/api/claudecode",
|
||||
mode: "proxy",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: [
|
||||
@@ -536,7 +683,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
apiKeyUrl: "https://aicoding.sh/i/CCSWITCH",
|
||||
category: "third_party",
|
||||
baseUrl: "https://api.aicoding.sh",
|
||||
mode: "proxy",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: ["https://api.aicoding.sh"],
|
||||
@@ -550,11 +697,11 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
websiteUrl: "https://www.crazyrouter.com",
|
||||
apiKeyUrl: "https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch",
|
||||
category: "third_party",
|
||||
baseUrl: "https://crazyrouter.com",
|
||||
mode: "proxy",
|
||||
baseUrl: "https://cn.crazyrouter.com",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: ["https://crazyrouter.com"],
|
||||
endpointCandidates: ["https://cn.crazyrouter.com"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "crazyrouter",
|
||||
icon: "crazyrouter",
|
||||
@@ -566,7 +713,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
apiKeyUrl: "https://www.sssaicode.com/register?ref=DCP0SM",
|
||||
category: "third_party",
|
||||
baseUrl: "https://node-hk.sssaicode.com/api",
|
||||
mode: "proxy",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: [
|
||||
@@ -587,7 +734,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
"https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch",
|
||||
category: "aggregator",
|
||||
baseUrl: "https://api.modelverse.cn",
|
||||
mode: "proxy",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: ["https://api.modelverse.cn"],
|
||||
@@ -604,7 +751,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
"https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch",
|
||||
category: "aggregator",
|
||||
baseUrl: "https://cp.compshare.cn",
|
||||
mode: "proxy",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: ["https://cp.compshare.cn"],
|
||||
@@ -615,14 +762,14 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "Micu",
|
||||
websiteUrl: "https://www.openclaudecode.cn",
|
||||
apiKeyUrl: "https://www.openclaudecode.cn/register?aff=aOYQ",
|
||||
websiteUrl: "https://www.micuapi.ai",
|
||||
apiKeyUrl: "https://www.micuapi.ai/register?aff=aOYQ",
|
||||
category: "third_party",
|
||||
baseUrl: "https://www.openclaudecode.cn",
|
||||
mode: "proxy",
|
||||
baseUrl: "https://www.micuapi.ai",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: ["https://www.openclaudecode.cn"],
|
||||
endpointCandidates: ["https://www.micuapi.ai"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "micu",
|
||||
icon: "micu",
|
||||
@@ -634,7 +781,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
apiKeyUrl: "https://ctok.ai",
|
||||
category: "third_party",
|
||||
baseUrl: "https://api.ctok.ai",
|
||||
mode: "proxy",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
isPartner: true,
|
||||
@@ -642,27 +789,13 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
icon: "ctok",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "DDSHub",
|
||||
websiteUrl: "https://www.ddshub.cc",
|
||||
apiKeyUrl: "https://ddshub.short.gy/ccswitch",
|
||||
category: "third_party",
|
||||
baseUrl: "https://www.ddshub.cc",
|
||||
mode: "proxy",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "ddshub",
|
||||
icon: "dds",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "E-FlowCode",
|
||||
websiteUrl: "https://e-flowcode.cc",
|
||||
apiKeyUrl: "https://e-flowcode.cc",
|
||||
category: "third_party",
|
||||
baseUrl: "https://e-flowcode.cc",
|
||||
mode: "proxy",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
endpointCandidates: ["https://e-flowcode.cc"],
|
||||
@@ -674,7 +807,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
websiteUrl: "https://vibecodingapi.ai",
|
||||
category: "third_party",
|
||||
baseUrl: "https://vibecodingapi.ai",
|
||||
mode: "proxy",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
isPartner: true,
|
||||
@@ -738,7 +871,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
category: "third_party",
|
||||
baseUrl: "https://api.lemondata.cc",
|
||||
apiKeyField: "ANTHROPIC_API_KEY",
|
||||
mode: "proxy",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(),
|
||||
isPartner: true,
|
||||
@@ -767,14 +900,9 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
apiKeyUrl: "https://code.pipellm.ai/login?ref=uvw650za",
|
||||
category: "aggregator",
|
||||
baseUrl: "https://cc-api.pipellm.ai",
|
||||
mode: "proxy",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: mappedRoutes(
|
||||
"claude-sonnet-4-6",
|
||||
"claude-opus-4-7",
|
||||
"claude-haiku-4-5-20251001",
|
||||
true,
|
||||
),
|
||||
modelRoutes: passthroughRoutes(),
|
||||
icon: "pipellm",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -105,6 +105,90 @@ export const providerPresets: ProviderPreset[] = [
|
||||
partnerPromotionKey: "shengsuanyun",
|
||||
icon: "shengsuanyun",
|
||||
},
|
||||
{
|
||||
name: "PatewayAI",
|
||||
websiteUrl: "https://pateway.ai",
|
||||
apiKeyUrl: "https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/",
|
||||
apiKeyField: "ANTHROPIC_API_KEY",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.pateway.ai",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "patewayai",
|
||||
icon: "pateway",
|
||||
},
|
||||
{
|
||||
name: "火山Agentplan",
|
||||
websiteUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://ark.cn-beijing.volces.com/api/coding",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "ark-code-latest",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "ark-code-latest",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "ark-code-latest",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "ark-code-latest",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "volcengine_agentplan",
|
||||
icon: "huoshan",
|
||||
iconColor: "#3370FF",
|
||||
},
|
||||
{
|
||||
name: "BytePlus",
|
||||
websiteUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL:
|
||||
"https://ark.ap-southeast.bytepluses.com/api/coding",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_MODEL: "ark-code-latest",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "ark-code-latest",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "ark-code-latest",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "ark-code-latest",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "byteplus",
|
||||
icon: "byteplus",
|
||||
iconColor: "#3370FF",
|
||||
},
|
||||
{
|
||||
name: "DouBaoSeed",
|
||||
websiteUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://ark.cn-beijing.volces.com/api/compatible",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
API_TIMEOUT_MS: "3000000",
|
||||
ANTHROPIC_MODEL: "doubao-seed-2-0-code-preview-latest",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "doubao-seed-2-0-code-preview-latest",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "doubao-seed-2-0-code-preview-latest",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "doubao-seed-2-0-code-preview-latest",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "doubaoseed",
|
||||
icon: "doubao",
|
||||
iconColor: "#3370FF",
|
||||
},
|
||||
{
|
||||
name: "Gemini Native",
|
||||
websiteUrl: "https://ai.google.dev/gemini-api",
|
||||
@@ -411,25 +495,6 @@ export const providerPresets: ProviderPreset[] = [
|
||||
icon: "minimax",
|
||||
iconColor: "#FF6B6B",
|
||||
},
|
||||
{
|
||||
name: "DouBaoSeed",
|
||||
websiteUrl: "https://www.volcengine.com/product/doubao",
|
||||
apiKeyUrl: "https://www.volcengine.com/product/doubao",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://ark.cn-beijing.volces.com/api/coding",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
API_TIMEOUT_MS: "3000000",
|
||||
ANTHROPIC_MODEL: "doubao-seed-2-0-code-preview-latest",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "doubao-seed-2-0-code-preview-latest",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "doubao-seed-2-0-code-preview-latest",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "doubao-seed-2-0-code-preview-latest",
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "doubao",
|
||||
iconColor: "#3370FF",
|
||||
},
|
||||
{
|
||||
name: "BaiLing",
|
||||
websiteUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/get_started",
|
||||
@@ -539,6 +604,64 @@ export const providerPresets: ProviderPreset[] = [
|
||||
partnerPromotionKey: "packycode", // 促销信息 i18n key
|
||||
icon: "packycode",
|
||||
},
|
||||
{
|
||||
name: "ClaudeAPI",
|
||||
websiteUrl: "https://claudeapi.com",
|
||||
apiKeyUrl: "https://console.claudeapi.com/register?aff=pCLD",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://gw.claudeapi.com",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "claudeapi",
|
||||
icon: "claudeapi",
|
||||
},
|
||||
{
|
||||
name: "ClaudeCN",
|
||||
websiteUrl: "https://claudecn.top",
|
||||
apiKeyUrl: "https://claudecn.top/register?aff=ccswitch",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://claudecn.top",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "claudecn",
|
||||
icon: "claudecn",
|
||||
},
|
||||
{
|
||||
name: "RunAPI",
|
||||
websiteUrl: "https://runapi.co",
|
||||
apiKeyUrl: "https://runapi.co",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://runapi.co",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "runapi",
|
||||
icon: "runapi",
|
||||
},
|
||||
{
|
||||
name: "RelaxyCode",
|
||||
websiteUrl: "https://www.relaxycode.com",
|
||||
apiKeyUrl: "https://www.relaxycode.com/register",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://www.relaxycode.com",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
icon: "relaxcode",
|
||||
},
|
||||
{
|
||||
name: "Cubence",
|
||||
websiteUrl: "https://cubence.com",
|
||||
@@ -638,11 +761,11 @@ export const providerPresets: ProviderPreset[] = [
|
||||
apiKeyUrl: "https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://crazyrouter.com",
|
||||
ANTHROPIC_BASE_URL: "https://cn.crazyrouter.com",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
endpointCandidates: ["https://crazyrouter.com"],
|
||||
endpointCandidates: ["https://cn.crazyrouter.com"],
|
||||
category: "third_party",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "crazyrouter", // 促销信息 i18n key
|
||||
@@ -710,15 +833,15 @@ export const providerPresets: ProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "Micu",
|
||||
websiteUrl: "https://www.openclaudecode.cn",
|
||||
apiKeyUrl: "https://www.openclaudecode.cn/register?aff=aOYQ",
|
||||
websiteUrl: "https://www.micuapi.ai",
|
||||
apiKeyUrl: "https://www.micuapi.ai/register?aff=aOYQ",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://www.openclaudecode.cn",
|
||||
ANTHROPIC_BASE_URL: "https://www.micuapi.ai",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
endpointCandidates: ["https://www.openclaudecode.cn"],
|
||||
endpointCandidates: ["https://www.micuapi.ai"],
|
||||
category: "third_party",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "micu", // 促销信息 i18n key
|
||||
@@ -741,22 +864,6 @@ export const providerPresets: ProviderPreset[] = [
|
||||
icon: "ctok",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "DDSHub",
|
||||
websiteUrl: "https://www.ddshub.cc",
|
||||
apiKeyUrl: "https://ddshub.short.gy/ccswitch",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://www.ddshub.cc",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "ddshub", // 促销信息 i18n key
|
||||
icon: "dds",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "E-FlowCode",
|
||||
websiteUrl: "https://e-flowcode.cc",
|
||||
|
||||
@@ -94,6 +94,22 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
partnerPromotionKey: "shengsuanyun",
|
||||
icon: "shengsuanyun",
|
||||
},
|
||||
{
|
||||
name: "PatewayAI",
|
||||
websiteUrl: "https://pateway.ai",
|
||||
apiKeyUrl: "https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/",
|
||||
category: "third_party",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"patewayai",
|
||||
"https://api.pateway.ai/v1",
|
||||
"gpt-5.5",
|
||||
),
|
||||
endpointCandidates: ["https://api.pateway.ai/v1"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "patewayai",
|
||||
icon: "pateway",
|
||||
},
|
||||
{
|
||||
name: "Azure OpenAI",
|
||||
websiteUrl:
|
||||
@@ -170,6 +186,49 @@ requires_openai_auth = true`,
|
||||
partnerPromotionKey: "packycode", // 促销信息 i18n key
|
||||
icon: "packycode",
|
||||
},
|
||||
{
|
||||
name: "ClaudeCN",
|
||||
websiteUrl: "https://claudecn.top",
|
||||
apiKeyUrl: "https://claudecn.top/register?aff=ccswitch",
|
||||
category: "third_party",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"claudecn",
|
||||
"https://claudecn.top/v1",
|
||||
"gpt-5.5",
|
||||
),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "claudecn",
|
||||
icon: "claudecn",
|
||||
},
|
||||
{
|
||||
name: "RunAPI",
|
||||
websiteUrl: "https://runapi.co",
|
||||
apiKeyUrl: "https://runapi.co",
|
||||
category: "aggregator",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"runapi",
|
||||
"https://runapi.co/v1",
|
||||
"gpt-5.5",
|
||||
),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "runapi",
|
||||
icon: "runapi",
|
||||
},
|
||||
{
|
||||
name: "RelaxyCode",
|
||||
websiteUrl: "https://www.relaxycode.com",
|
||||
apiKeyUrl: "https://www.relaxycode.com/register",
|
||||
category: "third_party",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"relaxycode",
|
||||
"https://www.relaxycode.com/v1",
|
||||
"gpt-5.5",
|
||||
),
|
||||
icon: "relaxcode",
|
||||
},
|
||||
{
|
||||
name: "Cubence",
|
||||
websiteUrl: "https://cubence.com",
|
||||
@@ -267,10 +326,10 @@ requires_openai_auth = true`,
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"crazyrouter",
|
||||
"https://crazyrouter.com/v1",
|
||||
"https://cn.crazyrouter.com/v1",
|
||||
"gpt-5.4",
|
||||
),
|
||||
endpointCandidates: ["https://crazyrouter.com/v1"],
|
||||
endpointCandidates: ["https://cn.crazyrouter.com/v1"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "crazyrouter",
|
||||
icon: "crazyrouter",
|
||||
@@ -337,15 +396,15 @@ requires_openai_auth = true`,
|
||||
},
|
||||
{
|
||||
name: "Micu",
|
||||
websiteUrl: "https://www.openclaudecode.cn",
|
||||
apiKeyUrl: "https://www.openclaudecode.cn/register?aff=aOYQ",
|
||||
websiteUrl: "https://www.micuapi.ai",
|
||||
apiKeyUrl: "https://www.micuapi.ai/register?aff=aOYQ",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"micu",
|
||||
"https://www.openclaudecode.cn/v1",
|
||||
"https://www.micuapi.ai/v1",
|
||||
"gpt-5.4",
|
||||
),
|
||||
endpointCandidates: ["https://www.openclaudecode.cn/v1"],
|
||||
endpointCandidates: ["https://www.micuapi.ai/v1"],
|
||||
category: "third_party",
|
||||
isPartner: true, // 合作伙伴
|
||||
partnerPromotionKey: "micu", // 促销信息 i18n key
|
||||
@@ -383,23 +442,6 @@ requires_openai_auth = true`,
|
||||
partnerPromotionKey: "lionccapi",
|
||||
icon: "lioncc",
|
||||
},
|
||||
{
|
||||
name: "DDSHub",
|
||||
websiteUrl: "https://www.ddshub.cc",
|
||||
apiKeyUrl: "https://ddshub.short.gy/ccswitch",
|
||||
category: "third_party",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"ddshub",
|
||||
"https://www.ddshub.cc",
|
||||
"gpt-5.4",
|
||||
),
|
||||
endpointCandidates: ["https://www.ddshub.cc"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "ddshub",
|
||||
icon: "dds",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "E-FlowCode",
|
||||
websiteUrl: "https://e-flowcode.cc",
|
||||
|
||||
@@ -185,17 +185,17 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
apiKeyUrl: "https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://crazyrouter.com",
|
||||
GOOGLE_GEMINI_BASE_URL: "https://cn.crazyrouter.com",
|
||||
GEMINI_MODEL: "gemini-3.1-pro",
|
||||
},
|
||||
},
|
||||
baseURL: "https://crazyrouter.com",
|
||||
baseURL: "https://cn.crazyrouter.com",
|
||||
model: "gemini-3.1-pro",
|
||||
description: "CrazyRouter",
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "crazyrouter",
|
||||
endpointCandidates: ["https://crazyrouter.com"],
|
||||
endpointCandidates: ["https://cn.crazyrouter.com"],
|
||||
icon: "crazyrouter",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
|
||||
@@ -148,6 +148,96 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
model: { default: "openai/gpt-5.4", provider: "shengsuanyun" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "火山Agentplan",
|
||||
websiteUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
name: "ark_agentplan",
|
||||
base_url: "https://ark.cn-beijing.volces.com/api/coding",
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{
|
||||
id: "ark-code-latest",
|
||||
name: "Ark Code Latest",
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "volcengine_agentplan",
|
||||
icon: "huoshan",
|
||||
iconColor: "#3370FF",
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
default: "ark-code-latest",
|
||||
provider: "ark_agentplan",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "BytePlus",
|
||||
websiteUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
name: "byteplus",
|
||||
base_url: "https://ark.ap-southeast.bytepluses.com/api/coding",
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{
|
||||
id: "ark-code-latest",
|
||||
name: "Ark Code Latest",
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "byteplus",
|
||||
icon: "byteplus",
|
||||
iconColor: "#3370FF",
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
default: "ark-code-latest",
|
||||
provider: "byteplus",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DouBaoSeed",
|
||||
websiteUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
name: "doubao_seed",
|
||||
base_url: "https://ark.cn-beijing.volces.com/api/compatible",
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{
|
||||
id: "doubao-seed-2-0-code-preview-latest",
|
||||
name: "Doubao Seed 2.0 Code Preview",
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "doubaoseed",
|
||||
icon: "doubao",
|
||||
iconColor: "#3370FF",
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
default: "doubao-seed-2-0-code-preview-latest",
|
||||
provider: "doubao_seed",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OpenRouter",
|
||||
nameKey: "providerForm.presets.openrouter",
|
||||
@@ -531,32 +621,6 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
model: { default: "MiniMax-M2.7", provider: "minimax_en" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DouBaoSeed",
|
||||
websiteUrl: "https://www.volcengine.com/product/doubao",
|
||||
apiKeyUrl: "https://www.volcengine.com/product/doubao",
|
||||
settingsConfig: {
|
||||
name: "doubao_seed",
|
||||
base_url: "https://ark.cn-beijing.volces.com/api/coding",
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{
|
||||
id: "doubao-seed-2-0-code-preview-latest",
|
||||
name: "Doubao Seed 2.0 Code Preview",
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "doubao",
|
||||
iconColor: "#3370FF",
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
default: "doubao-seed-2-0-code-preview-latest",
|
||||
provider: "doubao_seed",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "BaiLing",
|
||||
websiteUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/get_started",
|
||||
@@ -706,6 +770,66 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
model: { default: "claude-opus-4-7", provider: "cubence" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ClaudeCN",
|
||||
websiteUrl: "https://claudecn.top",
|
||||
apiKeyUrl: "https://claudecn.top/register?aff=ccswitch",
|
||||
settingsConfig: {
|
||||
name: "claudecn",
|
||||
base_url: "https://claudecn.top",
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{ id: "claude-opus-4-7", name: "Claude Opus 4.7" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5" },
|
||||
],
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "claudecn",
|
||||
icon: "claudecn",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { default: "claude-sonnet-4-6", provider: "claudecn" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "RunAPI",
|
||||
websiteUrl: "https://runapi.co",
|
||||
apiKeyUrl: "https://runapi.co",
|
||||
settingsConfig: {
|
||||
name: "runapi",
|
||||
base_url: "https://runapi.co",
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{ id: "claude-opus-4-7", name: "Claude Opus 4.7" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5" },
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "runapi",
|
||||
icon: "runapi",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { default: "claude-sonnet-4-6", provider: "runapi" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AIGoCode",
|
||||
websiteUrl: "https://aigocode.com",
|
||||
@@ -808,7 +932,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
apiKeyUrl: "https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch",
|
||||
settingsConfig: {
|
||||
name: "crazyrouter",
|
||||
base_url: "https://crazyrouter.com",
|
||||
base_url: "https://cn.crazyrouter.com",
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
@@ -896,11 +1020,11 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "Micu",
|
||||
websiteUrl: "https://www.openclaudecode.cn",
|
||||
apiKeyUrl: "https://www.openclaudecode.cn/register?aff=aOYQ",
|
||||
websiteUrl: "https://www.micuapi.ai",
|
||||
apiKeyUrl: "https://www.micuapi.ai/register?aff=aOYQ",
|
||||
settingsConfig: {
|
||||
name: "micu",
|
||||
base_url: "https://www.openclaudecode.cn",
|
||||
base_url: "https://www.micuapi.ai",
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
@@ -942,30 +1066,6 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
model: { default: "claude-opus-4-7", provider: "ctok" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DDSHub",
|
||||
websiteUrl: "https://www.ddshub.cc",
|
||||
apiKeyUrl: "https://ddshub.short.gy/ccswitch",
|
||||
settingsConfig: {
|
||||
name: "ddshub",
|
||||
base_url: "https://www.ddshub.cc",
|
||||
api_key: "",
|
||||
api_mode: "anthropic_messages",
|
||||
models: [
|
||||
{ id: "claude-opus-4-7", name: "Claude Opus 4.7" },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
|
||||
],
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "ddshub",
|
||||
icon: "dds",
|
||||
iconColor: "#000000",
|
||||
suggestedDefaults: {
|
||||
model: { default: "claude-opus-4-7", provider: "ddshub" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "E-FlowCode",
|
||||
websiteUrl: "https://e-flowcode.cc",
|
||||
|
||||
@@ -104,6 +104,118 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "火山Agentplan",
|
||||
websiteUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "ark-code-latest",
|
||||
name: "Ark Code Latest",
|
||||
contextWindow: 256000,
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "volcengine_agentplan",
|
||||
icon: "huoshan",
|
||||
iconColor: "#3370FF",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "ark_agentplan/ark-code-latest" },
|
||||
modelCatalog: {
|
||||
"ark_agentplan/ark-code-latest": { alias: "Ark Code" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "BytePlus",
|
||||
websiteUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://ark.ap-southeast.bytepluses.com/api/coding/v3",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "ark-code-latest",
|
||||
name: "Ark Code Latest",
|
||||
contextWindow: 256000,
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "byteplus",
|
||||
icon: "byteplus",
|
||||
iconColor: "#3370FF",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "byteplus/ark-code-latest" },
|
||||
modelCatalog: {
|
||||
"byteplus/ark-code-latest": { alias: "Ark Code" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DouBaoSeed",
|
||||
websiteUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "doubao-seed-2-0-code-preview-latest",
|
||||
name: "DouBao Seed Code Preview",
|
||||
contextWindow: 128000,
|
||||
cost: { input: 0.002, output: 0.006 },
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "doubaoseed",
|
||||
icon: "doubao",
|
||||
iconColor: "#3370FF",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "doubaoseed/doubao-seed-2-0-code-preview-latest" },
|
||||
modelCatalog: {
|
||||
"doubaoseed/doubao-seed-2-0-code-preview-latest": { alias: "DouBao" },
|
||||
},
|
||||
},
|
||||
},
|
||||
// ========== Chinese Officials ==========
|
||||
{
|
||||
name: "DeepSeek",
|
||||
@@ -589,40 +701,6 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
modelCatalog: { "longcat/LongCat-Flash-Chat": { alias: "LongCat" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DouBaoSeed",
|
||||
websiteUrl: "https://www.volcengine.com/product/doubao",
|
||||
apiKeyUrl: "https://www.volcengine.com/product/doubao",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "doubao-seed-2-0-code-preview-latest",
|
||||
name: "DouBao Seed Code Preview",
|
||||
contextWindow: 128000,
|
||||
cost: { input: 0.002, output: 0.006 },
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "doubao",
|
||||
iconColor: "#3370FF",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: { primary: "doubaoseed/doubao-seed-2-0-code-preview-latest" },
|
||||
modelCatalog: {
|
||||
"doubaoseed/doubao-seed-2-0-code-preview-latest": { alias: "DouBao" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "BaiLing",
|
||||
websiteUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/get_started",
|
||||
@@ -774,6 +852,102 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ClaudeCN",
|
||||
websiteUrl: "https://claudecn.top",
|
||||
apiKeyUrl: "https://claudecn.top/register?aff=ccswitch",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://claudecn.top",
|
||||
apiKey: "",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-7",
|
||||
name: "Claude Opus 4.7",
|
||||
contextWindow: 1000000,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 1000000,
|
||||
},
|
||||
{
|
||||
id: "claude-haiku-4-5",
|
||||
name: "Claude Haiku 4.5",
|
||||
contextWindow: 200000,
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "claudecn",
|
||||
icon: "claudecn",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "claudecn/claude-sonnet-4-6",
|
||||
},
|
||||
modelCatalog: {
|
||||
"claudecn/claude-opus-4-7": { alias: "Opus" },
|
||||
"claudecn/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"claudecn/claude-haiku-4-5": { alias: "Haiku" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "RunAPI",
|
||||
websiteUrl: "https://runapi.co",
|
||||
apiKeyUrl: "https://runapi.co",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://runapi.co",
|
||||
apiKey: "",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-7",
|
||||
name: "Claude Opus 4.7",
|
||||
contextWindow: 1000000,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 1000000,
|
||||
},
|
||||
{
|
||||
id: "claude-haiku-4-5",
|
||||
name: "Claude Haiku 4.5",
|
||||
contextWindow: 200000,
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "runapi",
|
||||
icon: "runapi",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "runapi/claude-sonnet-4-6",
|
||||
},
|
||||
modelCatalog: {
|
||||
"runapi/claude-opus-4-7": { alias: "Opus" },
|
||||
"runapi/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"runapi/claude-haiku-4-5": { alias: "Haiku" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OpenRouter",
|
||||
websiteUrl: "https://openrouter.ai",
|
||||
@@ -1392,7 +1566,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
websiteUrl: "https://www.crazyrouter.com",
|
||||
apiKeyUrl: "https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://crazyrouter.com/v1",
|
||||
baseUrl: "https://cn.crazyrouter.com/v1",
|
||||
apiKey: "",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
@@ -1561,10 +1735,10 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "Micu",
|
||||
websiteUrl: "https://www.openclaudecode.cn",
|
||||
apiKeyUrl: "https://www.openclaudecode.cn/register?aff=aOYQ",
|
||||
websiteUrl: "https://www.micuapi.ai",
|
||||
apiKeyUrl: "https://www.micuapi.ai/register?aff=aOYQ",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://www.openclaudecode.cn",
|
||||
baseUrl: "https://www.micuapi.ai",
|
||||
apiKey: "",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
|
||||
@@ -326,6 +326,105 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "火山Agentplan",
|
||||
websiteUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "火山Agentplan",
|
||||
options: {
|
||||
baseURL: "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"ark-code-latest": {
|
||||
name: "Ark Code Latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "volcengine_agentplan",
|
||||
icon: "huoshan",
|
||||
iconColor: "#3370FF",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "BytePlus",
|
||||
websiteUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "BytePlus",
|
||||
options: {
|
||||
baseURL: "https://ark.ap-southeast.bytepluses.com/api/coding/v3",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"ark-code-latest": {
|
||||
name: "Ark Code Latest",
|
||||
},
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "byteplus",
|
||||
icon: "byteplus",
|
||||
iconColor: "#3370FF",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DouBaoSeed",
|
||||
websiteUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
apiKeyUrl:
|
||||
"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "DouBaoSeed",
|
||||
options: {
|
||||
baseURL: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"doubao-seed-2-0-code-preview-latest": {
|
||||
name: "Doubao Seed Code Preview",
|
||||
},
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "doubaoseed",
|
||||
icon: "doubao",
|
||||
iconColor: "#3370FF",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DeepSeek",
|
||||
websiteUrl: "https://platform.deepseek.com",
|
||||
@@ -783,35 +882,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DouBaoSeed",
|
||||
websiteUrl: "https://www.volcengine.com/product/doubao",
|
||||
apiKeyUrl: "https://www.volcengine.com/product/doubao",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "DouBaoSeed",
|
||||
options: {
|
||||
baseURL: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"doubao-seed-2-0-code-preview-latest": {
|
||||
name: "Doubao Seed Code Preview",
|
||||
},
|
||||
},
|
||||
},
|
||||
category: "cn_official",
|
||||
icon: "doubao",
|
||||
iconColor: "#3370FF",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "BaiLing",
|
||||
websiteUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/get_started",
|
||||
@@ -1210,6 +1280,66 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ClaudeCN",
|
||||
websiteUrl: "https://claudecn.top",
|
||||
apiKeyUrl: "https://claudecn.top/register?aff=ccswitch",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "ClaudeCN",
|
||||
options: {
|
||||
baseURL: "https://claudecn.top",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
|
||||
"claude-opus-4-7": { name: "Claude Opus 4.7" },
|
||||
"claude-haiku-4-5": { name: "Claude Haiku 4.5" },
|
||||
},
|
||||
},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "claudecn",
|
||||
icon: "claudecn",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "RunAPI",
|
||||
websiteUrl: "https://runapi.co",
|
||||
apiKeyUrl: "https://runapi.co",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "RunAPI",
|
||||
options: {
|
||||
baseURL: "https://runapi.co",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
|
||||
"claude-opus-4-7": { name: "Claude Opus 4.7" },
|
||||
"claude-haiku-4-5": { name: "Claude Haiku 4.5" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "runapi",
|
||||
icon: "runapi",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AICoding",
|
||||
websiteUrl: "https://aicoding.sh",
|
||||
@@ -1248,7 +1378,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "CrazyRouter",
|
||||
options: {
|
||||
baseURL: "https://crazyrouter.com",
|
||||
baseURL: "https://cn.crazyrouter.com",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
@@ -1302,13 +1432,13 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
{
|
||||
name: "Micu",
|
||||
websiteUrl: "https://www.openclaudecode.cn",
|
||||
apiKeyUrl: "https://www.openclaudecode.cn/register?aff=aOYQ",
|
||||
websiteUrl: "https://www.micuapi.ai",
|
||||
apiKeyUrl: "https://www.micuapi.ai/register?aff=aOYQ",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
name: "Micu",
|
||||
options: {
|
||||
baseURL: "https://www.openclaudecode.cn/v1",
|
||||
baseURL: "https://www.micuapi.ai/v1",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
|
||||
+54
-14
@@ -104,7 +104,7 @@
|
||||
"noProvidersDescription": "If you already have a config, click \"Import Current Config\" — all data will be safely saved in a default provider",
|
||||
"noProvidersDescriptionSnippet": "Settings other than API key and endpoint (e.g. plugins) will be saved to a common config snippet for sharing across providers",
|
||||
"importCurrent": "Import Current Config",
|
||||
"importFromClaude": "Import Compatible Providers from Claude",
|
||||
"importFromClaude": "Import existing providers from Claude Code",
|
||||
"importCurrentDescription": "Import current live config as default provider",
|
||||
"currentlyUsing": "Currently Using",
|
||||
"enable": "Enable",
|
||||
@@ -173,26 +173,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"claudeCode": {
|
||||
"needsRouting": "Needs Routing",
|
||||
"noRoutingSupport": "No Routing Support"
|
||||
},
|
||||
"codex": {
|
||||
"noRoutingSupport": "No Routing Support"
|
||||
},
|
||||
"claudeDesktop": {
|
||||
"officialNotice": "Claude Desktop official providers use the built-in 1P sign-in. No API key or endpoint URL is required.",
|
||||
"mode": "Model handling",
|
||||
"modeDirect": "Direct",
|
||||
"modeProxy": "Requires routing",
|
||||
"modelMappingToggle": "Needs model mapping",
|
||||
"modelMappingOffHint": "Use this when the provider already exposes and accepts claude-* / anthropic/claude-* model IDs through Anthropic Messages. Claude Desktop connects to the provider directly.",
|
||||
"modelMappingOffHint": "If your provider does not offer native Claude series models, please enable this switch.",
|
||||
"modelMappingOnHint": "Claude Desktop currently restricts model IDs. If your provider offers non-Claude models, enable this switch and keep local routing running while in use.",
|
||||
"routeMapTitle": "Model mapping",
|
||||
"routeMapHint": "Enter the model name provided by your provider. The display name is shown in the Claude Desktop model list.",
|
||||
"routeMapHint": "Choose a model role and CC Switch will generate a Claude Desktop-compatible route automatically. Use the menu display name for branded models such as DeepSeek or Kimi; requested model IDs are sent upstream exactly as entered.",
|
||||
"routeModelLabel": "Model role",
|
||||
"routeRoleSonnet": "Sonnet",
|
||||
"routeRoleOpus": "Opus",
|
||||
"routeRoleHaiku": "Haiku",
|
||||
"labelOverrideLabel": "Menu display name",
|
||||
"upstreamModelLabel": "Requested model",
|
||||
"displayNameLabel": "Display name",
|
||||
"supports1mLabel": "1M",
|
||||
"supports1mLabel": "Declare 1M",
|
||||
"supports1mShort": "1M",
|
||||
"directModelListTitle": "Manually specify Claude Desktop models (advanced, optional)",
|
||||
"directModelListCollapsedHint": "Native Claude model providers usually do not need this. Claude Desktop will fetch /v1/models automatically.",
|
||||
"directModelListHint": "Only fill this when the provider's /v1/models is unavailable or does not return Claude Desktop-safe claude-* model IDs. Checking 1M writes the [1M] marker after the model ID.",
|
||||
"directModelListHint": "Only fill this when the provider's /v1/models is unavailable or does not return Claude Desktop-safe claude-* model IDs. Checking 1M declares 1M context support to Claude Desktop.",
|
||||
"directModelInvalid": "Direct models must use claude-* / anthropic/claude-* model IDs",
|
||||
"addModel": "Add model",
|
||||
"addRoute": "Add model",
|
||||
"routeInvalid": "Please enter the upstream model name",
|
||||
"routesRequired": "At least one upstream model is required",
|
||||
"routeInvalid": "Choose a model role and enter the requested model",
|
||||
"routesRequired": "At least one model mapping is required",
|
||||
"statusTitle": "Claude Desktop configuration needs attention",
|
||||
"statusUnsupported": "This platform does not support writing Claude Desktop 3P configuration yet.",
|
||||
"statusStaleRawModels": "The Claude Desktop profile contains non-claude-* model IDs. Newer Claude Desktop versions may reject it; switch to the current provider again to repair it.",
|
||||
@@ -644,6 +657,7 @@
|
||||
"viewReleaseNotes": "View release notes for this version",
|
||||
"viewCurrentReleaseNotes": "View current version release notes",
|
||||
"officialWebsite": "Official Website",
|
||||
"github": "GitHub",
|
||||
"oneClickInstall": "One-click Install",
|
||||
"oneClickInstallHint": "Install Claude Code / Codex / Gemini CLI / OpenCode",
|
||||
"localEnvCheck": "Local environment check",
|
||||
@@ -698,6 +712,7 @@
|
||||
},
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"claudeCode": "Claude Code",
|
||||
"claudeDesktop": "Claude Desktop",
|
||||
"claude-desktop": "Claude Desktop",
|
||||
"codex": "Codex",
|
||||
@@ -821,6 +836,10 @@
|
||||
"getApiKey": "Get API Key",
|
||||
"partnerPromotion": {
|
||||
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off",
|
||||
"patewayai": "PatewayAI offers special benefits for CC Switch users. Register via this link to receive $3 credit.",
|
||||
"claudeapi": "ClaudeAPI offers special benefits for CC Switch users. Register via this link to claim test credits.",
|
||||
"claudecn": "ClaudeCN is an enterprise-grade AI gateway operated by a registered company, supporting enterprise procurement processes with corporate payments, contracts, and compliance guarantees.",
|
||||
"runapi": "RunAPI offers special benefits for CC Switch users. Register via this link and contact customer support to claim ¥14 free credit.",
|
||||
"minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9",
|
||||
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)",
|
||||
"dmxapi": "Claude Code exclusive model 66% OFF now!",
|
||||
@@ -835,10 +854,12 @@
|
||||
"ucloud": "Compshare offers an exclusive bonus for CC Switch users — register via this link to get ¥5 platform trial credit!",
|
||||
"micu": "Micu is an official partner of CC Switch",
|
||||
"ctok": "Join the CTok community on the official website and subscribe to a plan.",
|
||||
"ddshub": "DDSHub offers a special bonus for CC Switch users — register via this link and get 10% extra credit on your first top-up (contact group admin to claim)!",
|
||||
"lionccapi": "LionCCAPI offers exclusive benefits for CC Switch users. After registration, add WeChat HSQBJ088888888 and mention 'cc-switch' to receive $10 free credits!",
|
||||
"shengsuanyun": "Shengsuanyun offers exclusive benefits for CC Switch users. New users who register via this link get ¥10 free credits and 10% bonus on first top-up!",
|
||||
"lemondata": "Lemon Code offers a special promotion for CC Switch users. Sign up via this link to receive $1 in free credits."
|
||||
"lemondata": "Lemon Code offers a special promotion for CC Switch users. Sign up via this link to receive $1 in free credits.",
|
||||
"doubaoseed": "DouBao offers exclusive benefits for CC Switch users: register via this link to get 500K tokens of inference credits for all DouBao text models and 5M tokens of free Seedance 2.0 credits.",
|
||||
"volcengine_agentplan": "Volcengine Ark Agent Plan offers exclusive benefits for CC Switch users: subscribe via this link, starting from ¥40/month for new customers.",
|
||||
"byteplus": "Register via this link to get 500,000 tokens of free inference quota per model."
|
||||
},
|
||||
"presets": {
|
||||
"ucloud": "Compshare",
|
||||
@@ -901,9 +922,20 @@
|
||||
"haikuModelPlaceholder": "",
|
||||
"modelHelper": "Optional: Specify default Claude model to use, leave blank to use system default.",
|
||||
"modelMappingLabel": "Model Mapping",
|
||||
"modelMappingHint": "Usually not needed if the provider natively serves Claude models. Only configure when you need to map requests to different model names.",
|
||||
"modelMappingHint": "Display names only affect the /model menu; 1M is only a Claude Code context-capability declaration.",
|
||||
"modelRoleLabel": "Model role",
|
||||
"modelRoleSonnet": "Sonnet",
|
||||
"modelRoleOpus": "Opus",
|
||||
"modelRoleHaiku": "Haiku",
|
||||
"modelDisplayNameLabel": "Display name",
|
||||
"modelDisplayNamePlaceholder": "e.g. DeepSeek V4 Pro",
|
||||
"requestModelLabel": "Requested model",
|
||||
"modelOneMHeader": "Declare 1M",
|
||||
"modelOneMLabel": "1M",
|
||||
"fallbackModelLabel": "Default fallback model",
|
||||
"fallbackModelHint": "Used only when a Claude Code request does not clearly map to Sonnet, Opus, or Haiku. Usually safe to leave blank.",
|
||||
"quickSetModels": "Quick Set",
|
||||
"quickSetSuccess": "Model name applied to all fields",
|
||||
"quickSetSuccess": "Model name applied to all roles",
|
||||
"advancedOptionsToggle": "Advanced Options",
|
||||
"advancedOptionsHint": "Includes API format, auth field, and model mapping. Defaults work for most use cases.",
|
||||
"categoryOfficial": "Official",
|
||||
@@ -1132,8 +1164,13 @@
|
||||
"rangeToday": "Last 24 hours (hourly)",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"totalTokens": "Total Tokens",
|
||||
"totalTokens": "New Tokens (Input+Output)",
|
||||
"cacheTokens": "Cache Tokens",
|
||||
"realTotal": "Tokens Processed",
|
||||
"realTotalHint": "Input + Output + Cache Write + Cache Read",
|
||||
"tokensSuffix": "tokens",
|
||||
"freshInput": "Fresh Input",
|
||||
"cacheHitRate": "Cache Hit Rate",
|
||||
"requestLogs": "Request Logs",
|
||||
"providerStats": "Provider Stats",
|
||||
"modelStats": "Model Stats",
|
||||
@@ -1162,6 +1199,7 @@
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
},
|
||||
"rawInputLabel": "Raw",
|
||||
"dataSources": "Data Sources",
|
||||
"dataSource": {
|
||||
"proxy": "Routing",
|
||||
@@ -1222,6 +1260,8 @@
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "Creation",
|
||||
"cacheWriteNotReported": "OpenAI protocol doesn't distinguish cache creation; only cache hits are reported",
|
||||
"cacheWritePartial": "Some protocols (e.g. OpenAI) don't report cache creation; the value may be incomplete",
|
||||
"cacheRead": "Hit",
|
||||
"baseCost": "Base",
|
||||
"costMultiplier": "Cost Multiplier",
|
||||
@@ -2170,7 +2210,7 @@
|
||||
"saveFailed": "Save failed: {{error}}"
|
||||
},
|
||||
"invalidPort": "Invalid port, please enter a number between 1024-65535",
|
||||
"invalidAddress": "Invalid address, please enter a valid IP address (e.g. 127.0.0.1) or localhost",
|
||||
"invalidAddress": "Invalid address. Enter an IPv4 (e.g. 127.0.0.1), IPv6 (e.g. ::1), or localhost",
|
||||
"configSaved": "Routing configuration saved",
|
||||
"configSaveFailed": "Failed to save configuration",
|
||||
"restartRequired": "Restart routing service for address or port changes to take effect"
|
||||
|
||||
+54
-14
@@ -104,7 +104,7 @@
|
||||
"noProvidersDescription": "既存の設定がある場合は「現在の設定をインポート」をクリックしてください。すべてのデータが default プロバイダーに安全に保存されます",
|
||||
"noProvidersDescriptionSnippet": "API キーとリクエスト URL 以外のデータ(プラグインなど)は共通設定スニペットに保存され、プロバイダー間で共有できます",
|
||||
"importCurrent": "現在の設定をインポート",
|
||||
"importFromClaude": "Claude から互換プロバイダーをインポート",
|
||||
"importFromClaude": "Claude Code の既存プロバイダーをインポート",
|
||||
"importCurrentDescription": "現在使用中の設定をデフォルトプロバイダーとしてインポート",
|
||||
"currentlyUsing": "現在使用中",
|
||||
"enable": "有効化",
|
||||
@@ -173,26 +173,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"claudeCode": {
|
||||
"needsRouting": "ルーティングが必要",
|
||||
"noRoutingSupport": "ルーティング非対応"
|
||||
},
|
||||
"codex": {
|
||||
"noRoutingSupport": "ルーティング非対応"
|
||||
},
|
||||
"claudeDesktop": {
|
||||
"officialNotice": "Claude Desktop の公式プロバイダーは内蔵の 1P サインインを使用します。API キーやエンドポイントの設定は不要です。",
|
||||
"mode": "モデル処理方式",
|
||||
"modeDirect": "直結",
|
||||
"modeProxy": "ルーティング必要",
|
||||
"modelMappingToggle": "モデルマッピングが必要",
|
||||
"modelMappingOffHint": "プロバイダーが Anthropic Messages で claude-* / anthropic/claude-* のモデル ID を公開し、そのまま受け付ける場合に使います。Claude Desktop はプロバイダーへ直接接続します。",
|
||||
"modelMappingOffHint": "プロバイダーがネイティブの Claude シリーズモデルを提供していない場合は、このスイッチを有効にしてください。",
|
||||
"modelMappingOnHint": "Claude Desktop は現在モデル ID を制限しています。お使いのプロバイダーが Claude 系列以外のモデルを提供する場合、このスイッチを有効にし、使用中はローカルルーティングを起動したままにしてください。",
|
||||
"routeMapTitle": "モデルマッピング",
|
||||
"routeMapHint": "プロバイダーが実際に提供するモデル名を入力してください。表示名は Claude Desktop のモデルリストに表示される名前です。",
|
||||
"routeMapHint": "モデル役割を選ぶと、CC Switch が Claude Desktop 互換ルートを自動生成します。DeepSeek や Kimi などのブランド名はメニュー表示名に入れ、実際のリクエストモデルは右側の入力どおりに送信されます。",
|
||||
"routeModelLabel": "モデル役割",
|
||||
"routeRoleSonnet": "Sonnet",
|
||||
"routeRoleOpus": "Opus",
|
||||
"routeRoleHaiku": "Haiku",
|
||||
"labelOverrideLabel": "メニュー表示名",
|
||||
"upstreamModelLabel": "リクエストモデル",
|
||||
"displayNameLabel": "表示名",
|
||||
"supports1mLabel": "1M",
|
||||
"supports1mLabel": "1M 対応を宣言",
|
||||
"supports1mShort": "1M",
|
||||
"directModelListTitle": "Claude Desktop モデルを手動指定(高度・任意)",
|
||||
"directModelListCollapsedHint": "ネイティブ Claude モデルのプロバイダーでは通常不要です。Claude Desktop が /v1/models を自動取得します。",
|
||||
"directModelListHint": "プロバイダーの /v1/models が使えない、または Claude Desktop が認識できる claude-* モデル ID を返さない場合だけ入力してください。1M をオンにするとモデル ID の後ろに [1M] マーカーを書き込みます。",
|
||||
"directModelListHint": "プロバイダーの /v1/models が使えない、または Claude Desktop が認識できる claude-* モデル ID を返さない場合だけ入力してください。1M をオンにすると Claude Desktop に 1M コンテキスト対応を宣言します。",
|
||||
"directModelInvalid": "直結モデルは claude-* / anthropic/claude-* のモデル ID を使う必要があります",
|
||||
"addModel": "モデルを追加",
|
||||
"addRoute": "モデルを追加",
|
||||
"routeInvalid": "上流モデル名を入力してください",
|
||||
"routesRequired": "少なくとも 1 つの上流モデルが必要です",
|
||||
"routeInvalid": "モデル役割を選択し、リクエストモデルを入力してください",
|
||||
"routesRequired": "少なくとも 1 つのモデルマッピングが必要です",
|
||||
"statusTitle": "Claude Desktop 設定の確認が必要です",
|
||||
"statusUnsupported": "このプラットフォームでは Claude Desktop 3P 設定の書き込みはまだサポートされていません。",
|
||||
"statusStaleRawModels": "Claude Desktop profile に claude-* ではないモデル ID が含まれています。新しい Claude Desktop では拒否される可能性があります。現在のプロバイダーへ再度切り替えると修復できます。",
|
||||
@@ -644,6 +657,7 @@
|
||||
"viewReleaseNotes": "このバージョンのリリースノートを見る",
|
||||
"viewCurrentReleaseNotes": "現在のバージョンのリリースノートを見る",
|
||||
"officialWebsite": "公式サイト",
|
||||
"github": "GitHub",
|
||||
"oneClickInstall": "ワンクリックインストール",
|
||||
"oneClickInstallHint": "Claude Code / Codex / Gemini CLI / OpenCode をインストール",
|
||||
"localEnvCheck": "ローカル環境チェック",
|
||||
@@ -698,6 +712,7 @@
|
||||
},
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"claudeCode": "Claude Code",
|
||||
"claudeDesktop": "Claude Desktop",
|
||||
"claude-desktop": "Claude Desktop",
|
||||
"codex": "Codex",
|
||||
@@ -821,6 +836,10 @@
|
||||
"getApiKey": "API Key を取得",
|
||||
"partnerPromotion": {
|
||||
"packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ",
|
||||
"patewayai": "PatewayAI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録すると $3 のクレジットがもらえます。",
|
||||
"claudeapi": "ClaudeAPI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録するとテストクレジットを受け取ることができます。",
|
||||
"claudecn": "ClaudeCN は登録企業が運営するエンタープライズグレードの AI ゲートウェイプラットフォームで、企業調達プロセスをサポートし、法人支払い、契約、コンプライアンス保証を提供します。",
|
||||
"runapi": "RunAPI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録しカスタマーサポートにご連絡いただくと、¥14 の無料クレジットを受け取ることができます。",
|
||||
"minimax_cn": "MiniMax Coding Plan 特別価格、Starter ¥9.9 から",
|
||||
"minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $2(80% OFF)",
|
||||
"dmxapi": "Claude Code 専用モデル 66% OFF 実施中!",
|
||||
@@ -835,10 +854,12 @@
|
||||
"ucloud": "Compshare は CC Switch ユーザー向けに特別ボーナスを提供しています。このリンクから登録すると、5元のプラットフォーム体験クレジットがもらえます!",
|
||||
"micu": "Micu は CC Switch の公式パートナーです",
|
||||
"ctok": "公式サイトで CTok コミュニティに参加し、プランを購読してください。",
|
||||
"ddshub": "DDSHub は CC Switch ユーザー向けに特別ボーナスを提供しています。このリンクから登録すると、初回チャージで 10% の追加クレジットがもらえます(グループ管理者に連絡して受け取り)!",
|
||||
"lionccapi": "LionCCAPIはCC Switchユーザーに特別特典を提供しています。登録後、WeChat HSQBJ088888888を追加し、「cc-switch」と伝えると$10分のクレジットがもらえます!",
|
||||
"shengsuanyun": "胜算云はCC Switchユーザーに特別特典を提供しています。このリンクから登録すると、10元分の無料クレジットと初回チャージ10%ボーナスがもらえます!",
|
||||
"lemondata": "Lemon Code は CC Switch ユーザー向けの特別オファーを提供しています。このリンクから登録すると 1 ドル分の無料クレジットがもらえます。"
|
||||
"lemondata": "Lemon Code は CC Switch ユーザー向けの特別オファーを提供しています。このリンクから登録すると 1 ドル分の無料クレジットがもらえます。",
|
||||
"doubaoseed": "豆包大モデルは CC Switch ユーザーに専属特典を提供しています:このリンクから登録すると、豆包全テキストモデル50万トークンの推論クレジットおよびSeedance2.0の500万トークン無料クレジットがもらえます。",
|
||||
"volcengine_agentplan": "火山方舟 Agent Plan は CC Switch ユーザーに専属特典を提供しています:このリンクから方舟AgentPlanを購読すると、新規のお客様は初月40元から利用できます。",
|
||||
"byteplus": "このリンクから登録すると、各モデルごとに50万トークンの無料推論クォータがもらえます。"
|
||||
},
|
||||
"presets": {
|
||||
"ucloud": "Compshare",
|
||||
@@ -901,9 +922,20 @@
|
||||
"haikuModelPlaceholder": "",
|
||||
"modelHelper": "任意: 既定で使いたい Claude モデルを指定。空欄ならシステム既定を使用します。",
|
||||
"modelMappingLabel": "モデルマッピング",
|
||||
"modelMappingHint": "プロバイダーが Claude モデルをネイティブ提供している場合、通常は設定不要です。リクエストを別のモデル名にマッピングする場合のみ設定してください。",
|
||||
"modelMappingHint": "表示名は /model メニューだけに影響します。1M は Claude Code へのコンテキスト能力宣言です。",
|
||||
"modelRoleLabel": "モデル役割",
|
||||
"modelRoleSonnet": "Sonnet",
|
||||
"modelRoleOpus": "Opus",
|
||||
"modelRoleHaiku": "Haiku",
|
||||
"modelDisplayNameLabel": "表示名",
|
||||
"modelDisplayNamePlaceholder": "例: DeepSeek V4 Pro",
|
||||
"requestModelLabel": "リクエストモデル",
|
||||
"modelOneMHeader": "1M 対応を宣言",
|
||||
"modelOneMLabel": "1M",
|
||||
"fallbackModelLabel": "既定フォールバックモデル",
|
||||
"fallbackModelHint": "Claude Code のリクエストが Sonnet、Opus、Haiku のいずれにも明確に対応しない場合のみ使われます。通常は空欄で構いません。",
|
||||
"quickSetModels": "一括設定",
|
||||
"quickSetSuccess": "モデル名をすべてのフィールドに適用しました",
|
||||
"quickSetSuccess": "モデル名をすべての役割に適用しました",
|
||||
"advancedOptionsToggle": "高級オプション",
|
||||
"advancedOptionsHint": "API フォーマット、認証フィールド、モデルマッピングの設定を含みます。通常はデフォルトのままで問題ありません。",
|
||||
"categoryOfficial": "公式",
|
||||
@@ -1132,8 +1164,13 @@
|
||||
"rangeToday": "直近24時間 (時間別)",
|
||||
"rangeLast7Days": "過去7日間",
|
||||
"rangeLast30Days": "過去30日間",
|
||||
"totalTokens": "総トークン数",
|
||||
"totalTokens": "新規トークン (Input+Output)",
|
||||
"cacheTokens": "キャッシュトークン",
|
||||
"realTotal": "実消費トークン",
|
||||
"realTotalHint": "Input + Output + キャッシュ書込 + キャッシュ命中",
|
||||
"tokensSuffix": "tokens",
|
||||
"freshInput": "新規入力",
|
||||
"cacheHitRate": "キャッシュヒット率",
|
||||
"requestLogs": "リクエストログ",
|
||||
"providerStats": "プロバイダー統計",
|
||||
"modelStats": "モデル統計",
|
||||
@@ -1162,6 +1199,7 @@
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
},
|
||||
"rawInputLabel": "原始",
|
||||
"dataSources": "データソース",
|
||||
"dataSource": {
|
||||
"proxy": "ルーティング",
|
||||
@@ -1222,6 +1260,8 @@
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "作成",
|
||||
"cacheWriteNotReported": "OpenAI プロトコルはキャッシュ作成を区別せず、キャッシュヒットのみ報告します",
|
||||
"cacheWritePartial": "一部のプロトコル(OpenAIなど)はキャッシュ作成を報告しないため、値は不完全な可能性があります",
|
||||
"cacheRead": "ヒット",
|
||||
"baseCost": "基本",
|
||||
"costMultiplier": "コスト倍率",
|
||||
@@ -2170,7 +2210,7 @@
|
||||
"saveFailed": "保存に失敗しました: {{error}}"
|
||||
},
|
||||
"invalidPort": "無効なポートです。1024〜65535 の数値を入力してください",
|
||||
"invalidAddress": "無効なアドレスです。有効な IP アドレス(例: 127.0.0.1)または localhost を入力してください",
|
||||
"invalidAddress": "無効なアドレスです。IPv4(例: 127.0.0.1)、IPv6(例: ::1)、または localhost を入力してください",
|
||||
"configSaved": "ルーティング設定を保存しました",
|
||||
"configSaveFailed": "設定の保存に失敗しました",
|
||||
"restartRequired": "アドレスまたはポートの変更を反映するにはルーティングサービスの再起動が必要です"
|
||||
|
||||
+54
-14
@@ -104,7 +104,7 @@
|
||||
"noProvidersDescription": "如果你已有配置,请点击\"导入当前配置\",所有数据将安全保存在 default 供应商中",
|
||||
"noProvidersDescriptionSnippet": "除 Key 和请求地址外的数据(如插件配置)会被保存到通用配置片段,用于在不同供应商之间共享",
|
||||
"importCurrent": "导入当前配置",
|
||||
"importFromClaude": "从 Claude 导入兼容供应商",
|
||||
"importFromClaude": "将 Claude Code 中已有的供应商导入",
|
||||
"importCurrentDescription": "将当前正在使用的配置导入为默认供应商",
|
||||
"currentlyUsing": "当前使用",
|
||||
"enable": "启用",
|
||||
@@ -173,26 +173,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"claudeCode": {
|
||||
"needsRouting": "需要路由",
|
||||
"noRoutingSupport": "不支持路由"
|
||||
},
|
||||
"codex": {
|
||||
"noRoutingSupport": "不支持路由"
|
||||
},
|
||||
"claudeDesktop": {
|
||||
"officialNotice": "Claude Desktop 官方供应商使用应用内置的 1P 登录,无需配置 API Key 和接口地址。",
|
||||
"mode": "模型处理方式",
|
||||
"modeDirect": "直连",
|
||||
"modeProxy": "需要路由",
|
||||
"modelMappingToggle": "需要模型映射",
|
||||
"modelMappingOffHint": "适合供应商已经暴露并接受 claude-* / anthropic/claude-* 模型名的 Anthropic Messages API;请求会由 Claude Desktop 直连供应商。",
|
||||
"modelMappingOffHint": "如果您的供应商提供的不是原生 Claude 系列模型,请打开此开关。",
|
||||
"modelMappingOnHint": "Claude Desktop 目前对模型 ID 进行了限制,如果您的供应商提供的模型不是 Claude 系列模型,则需要打开本开关,并在使用过程中保持本地路由开启。",
|
||||
"routeMapTitle": "模型映射",
|
||||
"routeMapHint": "填写供应商实际提供的模型名,显示名为在 Claude Desktop 模型列表中展示的名称。",
|
||||
"routeMapHint": "选择模型角色后,CC Switch 会自动生成 Claude Desktop 兼容路由;菜单显示名可以写 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。",
|
||||
"routeModelLabel": "模型角色",
|
||||
"routeRoleSonnet": "Sonnet",
|
||||
"routeRoleOpus": "Opus",
|
||||
"routeRoleHaiku": "Haiku",
|
||||
"labelOverrideLabel": "菜单显示名",
|
||||
"upstreamModelLabel": "实际请求模型",
|
||||
"displayNameLabel": "显示名",
|
||||
"supports1mLabel": "1M",
|
||||
"supports1mLabel": "声明支持 1M",
|
||||
"supports1mShort": "1M",
|
||||
"directModelListTitle": "手动指定 Claude Desktop 模型列表(高级,可选)",
|
||||
"directModelListCollapsedHint": "原生 Claude 模型供应商通常不用填写,Claude Desktop 会自动读取 /v1/models。",
|
||||
"directModelListHint": "仅当供应商的 /v1/models 不可用或没有返回 Claude Desktop 可识别的 claude-* 模型名时填写;勾选 1M 会在模型名后写入 [1M] 标记。",
|
||||
"directModelListHint": "仅当供应商的 /v1/models 不可用或没有返回 Claude Desktop 可识别的 claude-* 模型名时填写;勾选 1M 会向 Claude Desktop 声明支持 1M 上下文。",
|
||||
"directModelInvalid": "直连模型必须使用 claude-* / anthropic/claude-* 模型名",
|
||||
"addModel": "添加模型",
|
||||
"addRoute": "添加模型",
|
||||
"routeInvalid": "请填写上游模型名",
|
||||
"routesRequired": "至少填写一个上游模型",
|
||||
"routeInvalid": "请选择模型角色并填写实际请求模型",
|
||||
"routesRequired": "至少填写一个模型映射",
|
||||
"statusTitle": "Claude Desktop 配置需要检查",
|
||||
"statusUnsupported": "当前平台暂不支持 Claude Desktop 3P 配置写入。",
|
||||
"statusStaleRawModels": "Claude Desktop profile 中存在非 claude-* 模型名,新版 Claude Desktop 可能拒绝加载;重新切换当前供应商可修复。",
|
||||
@@ -644,6 +657,7 @@
|
||||
"viewReleaseNotes": "查看该版本更新日志",
|
||||
"viewCurrentReleaseNotes": "查看当前版本更新日志",
|
||||
"officialWebsite": "官方网站",
|
||||
"github": "GitHub",
|
||||
"oneClickInstall": "一键安装",
|
||||
"oneClickInstallHint": "安装 Claude Code / Codex / Gemini CLI / OpenCode",
|
||||
"localEnvCheck": "本地环境检查",
|
||||
@@ -698,6 +712,7 @@
|
||||
},
|
||||
"apps": {
|
||||
"claude": "Claude",
|
||||
"claudeCode": "Claude Code",
|
||||
"claudeDesktop": "Claude Desktop",
|
||||
"claude-desktop": "Claude Desktop",
|
||||
"codex": "Codex",
|
||||
@@ -821,6 +836,10 @@
|
||||
"getApiKey": "获取 API Key",
|
||||
"partnerPromotion": {
|
||||
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠",
|
||||
"patewayai": "PatewayAI 为 CC Switch 的用户提供了特别福利,通过此链接注册可以获得3美元额度。",
|
||||
"claudeapi": "ClaudeAPI 为 CC Switch 的用户提供了特别福利,通过此链接注册可以领取测试额度。",
|
||||
"claudecn": "ClaudeCN 是一家实体企业运营的企业级AI中转平台,支持企业采购流程,可对公打款、签约,服务合规有保障。",
|
||||
"runapi": "RunAPI 为 CC Switch 的用户提供了特别福利,通过此链接注册并联系客服可领取 ¥14 免费额度。",
|
||||
"minimax_cn": "MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起",
|
||||
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)",
|
||||
"dmxapi": "Claude Code 专属模型 3.4 折优惠进行中!",
|
||||
@@ -835,10 +854,12 @@
|
||||
"ucloud": "优云智算为CC Switch 的用户提供了特殊优惠,通过此链接注册,可以获得五元平台体验金!",
|
||||
"micu": "Micu 是 CC Switch 的官方合作伙伴",
|
||||
"ctok": "官网加入CTok社群,订阅套餐。",
|
||||
"ddshub": "呆呆兽为CC Switch 的用户提供了特别福利,通过此链接注册后,首单充值可额外赠送 10% 额度(联系群主领取)!",
|
||||
"lionccapi": "LionCCAPI 为 CC Switch 的用户提供了特别福利,注册后添加客服微信HSQBJ088888888,发暗号cc-switch备注即可送10美金额度!",
|
||||
"shengsuanyun": "胜算云为 CC Switch 的用户提供了特别福利,使用此链接注册的新用户可获 10 元模力及首充 10% 赠送!",
|
||||
"lemondata": "Lemon Code 为 CC Switch 的用户提供了特别优惠。使用此链接注册可以获得1美元免费额度。"
|
||||
"lemondata": "Lemon Code 为 CC Switch 的用户提供了特别优惠。使用此链接注册可以获得1美元免费额度。",
|
||||
"doubaoseed": "豆包大模型为 CC Switch 的用户提供了专属福利:通过此链接注册即可获取豆包全系列文本模型 50万tokens推理额度以及Seedance2.0的500万tokens免费额度",
|
||||
"volcengine_agentplan": "火山方舟 Agent Plan 为 CC Switch 的用户提供了专属福利:通过此链接订阅方舟AgentPlan,新客户首月40元起。",
|
||||
"byteplus": "通过此链接注册即可获取每个模型50万tokens的免费推理额度。"
|
||||
},
|
||||
"presets": {
|
||||
"ucloud": "优云智算",
|
||||
@@ -901,9 +922,20 @@
|
||||
"haikuModelPlaceholder": "",
|
||||
"modelHelper": "可选:指定默认使用的 Claude 模型,留空则使用系统默认。",
|
||||
"modelMappingLabel": "模型映射",
|
||||
"modelMappingHint": "如果供应商原生提供 Claude 系列模型,通常无需配置。仅在需要将请求映射到不同模型名称时填写。",
|
||||
"modelMappingHint": "显示名称只影响 /model 菜单;1M 只是给 Claude Code 的上下文能力声明。",
|
||||
"modelRoleLabel": "模型角色",
|
||||
"modelRoleSonnet": "Sonnet",
|
||||
"modelRoleOpus": "Opus",
|
||||
"modelRoleHaiku": "Haiku",
|
||||
"modelDisplayNameLabel": "显示名称",
|
||||
"modelDisplayNamePlaceholder": "例如 DeepSeek V4 Pro",
|
||||
"requestModelLabel": "实际请求模型",
|
||||
"modelOneMHeader": "声明支持 1M",
|
||||
"modelOneMLabel": "1M",
|
||||
"fallbackModelLabel": "默认兜底模型",
|
||||
"fallbackModelHint": "仅在 Claude Code 请求没有明确落到 Sonnet、Opus 或 Haiku 角色时使用;通常可以留空。",
|
||||
"quickSetModels": "一键设置",
|
||||
"quickSetSuccess": "已将模型名称应用到所有字段",
|
||||
"quickSetSuccess": "已将模型名称应用到所有角色",
|
||||
"advancedOptionsToggle": "高级选项",
|
||||
"advancedOptionsHint": "包含 API 格式、认证字段、模型映射等配置。大多数场景下保持默认即可。",
|
||||
"categoryOfficial": "官方",
|
||||
@@ -1132,8 +1164,13 @@
|
||||
"rangeToday": "过去 24 小时 (按小时)",
|
||||
"rangeLast7Days": "过去 7 天",
|
||||
"rangeLast30Days": "过去 30 天",
|
||||
"totalTokens": "总 Token 数",
|
||||
"totalTokens": "新生成 Token (Input+Output)",
|
||||
"cacheTokens": "缓存 Token",
|
||||
"realTotal": "真实消耗 Tokens",
|
||||
"realTotalHint": "Input + Output + 缓存写入 + 缓存命中",
|
||||
"tokensSuffix": "tokens",
|
||||
"freshInput": "新增输入",
|
||||
"cacheHitRate": "缓存命中率",
|
||||
"requestLogs": "请求日志",
|
||||
"providerStats": "Provider 统计",
|
||||
"modelStats": "模型统计",
|
||||
@@ -1162,6 +1199,7 @@
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini"
|
||||
},
|
||||
"rawInputLabel": "原始",
|
||||
"dataSources": "数据来源",
|
||||
"dataSource": {
|
||||
"proxy": "路由",
|
||||
@@ -1222,6 +1260,8 @@
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "创建",
|
||||
"cacheWriteNotReported": "OpenAI 协议不区分缓存写入,仅上报缓存命中",
|
||||
"cacheWritePartial": "部分协议(如 OpenAI)不上报缓存写入,数值可能偏低",
|
||||
"cacheRead": "命中",
|
||||
"baseCost": "基础",
|
||||
"costMultiplier": "成本倍率",
|
||||
@@ -2170,7 +2210,7 @@
|
||||
"saveFailed": "保存失败: {{error}}"
|
||||
},
|
||||
"invalidPort": "端口无效,请输入 1024-65535 之间的数字",
|
||||
"invalidAddress": "地址无效,请输入有效的 IP 地址(如 127.0.0.1)或 localhost",
|
||||
"invalidAddress": "地址无效,请输入 IPv4(如 127.0.0.1)、IPv6(如 ::1)或 localhost",
|
||||
"configSaved": "路由配置已保存",
|
||||
"configSaveFailed": "保存配置失败",
|
||||
"restartRequired": "修改地址或端口后需要重启路由服务才能生效"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -1,11 +1,17 @@
|
||||
// Auto-generated icon index
|
||||
// Do not edit manually
|
||||
|
||||
import _dds from "./dds.svg?url";
|
||||
import _claudeapi from "./ClaudeApi.png";
|
||||
import _byteplus from "./byteplus.png";
|
||||
import _claudecn from "./claudecn.png";
|
||||
import _eflowcode from "./eflowcode.png";
|
||||
import _hermes from "./hermes.png";
|
||||
import _huoshan from "./huoshan.png";
|
||||
import _lemondata from "./lemondata.png";
|
||||
import _pateway from "./pateway.jpg";
|
||||
import _pipellm from "./pipellm.png";
|
||||
import _relaxcode from "./relaxcode.png";
|
||||
import _runapi from "./runapi.jpg";
|
||||
import _shengsuanyun from "./shengsuanyun.svg?url";
|
||||
|
||||
export const icons: Record<string, string> = {
|
||||
@@ -81,11 +87,17 @@ export const icons: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const iconUrls: Record<string, string> = {
|
||||
dds: _dds,
|
||||
byteplus: _byteplus,
|
||||
claudeapi: _claudeapi,
|
||||
claudecn: _claudecn,
|
||||
eflowcode: _eflowcode,
|
||||
hermes: _hermes,
|
||||
huoshan: _huoshan,
|
||||
lemondata: _lemondata,
|
||||
pateway: _pateway,
|
||||
pipellm: _pipellm,
|
||||
relaxcode: _relaxcode,
|
||||
runapi: _runapi,
|
||||
shengsuanyun: _shengsuanyun,
|
||||
};
|
||||
|
||||
|
||||
@@ -58,6 +58,20 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
huoshan: {
|
||||
name: "huoshan",
|
||||
displayName: "火山方舟",
|
||||
category: "ai-provider",
|
||||
keywords: ["huoshan", "volcengine", "ark", "agentplan", "byteplus"],
|
||||
defaultColor: "#3370FF",
|
||||
},
|
||||
byteplus: {
|
||||
name: "byteplus",
|
||||
displayName: "BytePlus",
|
||||
category: "ai-provider",
|
||||
keywords: ["byteplus", "volcengine", "ark", "modelark"],
|
||||
defaultColor: "#3370FF",
|
||||
},
|
||||
chatglm: {
|
||||
name: "chatglm",
|
||||
displayName: "chatglm",
|
||||
@@ -72,6 +86,18 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
keywords: ["anthropic"],
|
||||
defaultColor: "#D4915D",
|
||||
},
|
||||
claudeapi: {
|
||||
name: "claudeapi",
|
||||
displayName: "ClaudeAPI",
|
||||
category: "ai-provider",
|
||||
keywords: ["claudeapi", "claude", "anthropic", "bedrock"],
|
||||
},
|
||||
claudecn: {
|
||||
name: "claudecn",
|
||||
displayName: "ClaudeCN",
|
||||
category: "ai-provider",
|
||||
keywords: ["claudecn", "claude", "enterprise"],
|
||||
},
|
||||
cloudflare: {
|
||||
name: "cloudflare",
|
||||
displayName: "Cloudflare",
|
||||
@@ -107,13 +133,6 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
keywords: ["cubence", "api", "relay"],
|
||||
defaultColor: "#4B5563",
|
||||
},
|
||||
dds: {
|
||||
name: "dds",
|
||||
displayName: "DDS",
|
||||
category: "other",
|
||||
keywords: [],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
deepseek: {
|
||||
name: "deepseek",
|
||||
displayName: "DeepSeek",
|
||||
@@ -289,6 +308,12 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
keywords: ["packycode", "packy", "packyapi"],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
pateway: {
|
||||
name: "pateway",
|
||||
displayName: "PatewayAI",
|
||||
category: "ai-provider",
|
||||
keywords: ["pateway", "patewayai", "claude", "codex"],
|
||||
},
|
||||
palm: {
|
||||
name: "palm",
|
||||
displayName: "palm",
|
||||
@@ -380,6 +405,18 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
keywords: ["pipellm", "pipe"],
|
||||
defaultColor: "currentColor",
|
||||
},
|
||||
runapi: {
|
||||
name: "runapi",
|
||||
displayName: "RunAPI",
|
||||
category: "ai-provider",
|
||||
keywords: ["runapi", "run", "aggregator", "gateway"],
|
||||
},
|
||||
relaxcode: {
|
||||
name: "relaxcode",
|
||||
displayName: "RelaxyCode",
|
||||
category: "ai-provider",
|
||||
keywords: ["relaxycode", "relaxcode", "relax"],
|
||||
},
|
||||
eflowcode: {
|
||||
name: "eflowcode",
|
||||
displayName: "E-FlowCode",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -27,6 +27,19 @@ export async function fetchModelsForConfig(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Codex OAuth (ChatGPT Plus/Pro 反代) 可用模型列表
|
||||
*
|
||||
* Codex OAuth 使用 ChatGPT 的 backend-api/codex 端点,不兼容普通 /v1/models。
|
||||
*/
|
||||
export async function fetchCodexOauthModels(
|
||||
accountId?: string | null,
|
||||
): Promise<FetchedModel[]> {
|
||||
return invoke("get_codex_oauth_models", {
|
||||
accountId: accountId || null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据错误类型显示对应的 toast 提示
|
||||
*/
|
||||
|
||||
@@ -43,7 +43,6 @@ export interface ClaudeDesktopStatus {
|
||||
export interface ClaudeDesktopDefaultRoute {
|
||||
routeId: string;
|
||||
envKey: string;
|
||||
displayName: string;
|
||||
supports1m: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
UsageSummary,
|
||||
UsageSummaryByApp,
|
||||
DailyStats,
|
||||
ProviderStats,
|
||||
ModelStats,
|
||||
@@ -55,6 +56,13 @@ export const usageApi = {
|
||||
return invoke("get_usage_summary", { startDate, endDate, appType });
|
||||
},
|
||||
|
||||
getUsageSummaryByApp: async (
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
): Promise<UsageSummaryByApp[]> => {
|
||||
return invoke("get_usage_summary_by_app", { startDate, endDate });
|
||||
},
|
||||
|
||||
getUsageTrends: async (
|
||||
startDate?: number,
|
||||
endDate?: number,
|
||||
|
||||
@@ -227,8 +227,12 @@ export function useUpdateAppProxyConfig() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["appProxyConfig", variables.appType],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["autoFailoverEnabled", variables.appType],
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyConfig"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["circuitBreakerConfig"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(
|
||||
|
||||
+33
-2
@@ -45,6 +45,18 @@ export const usageKeys = {
|
||||
customEndDate ?? 0,
|
||||
appType ?? "all",
|
||||
] as const,
|
||||
summaryByApp: (
|
||||
preset: UsageRangeSelection["preset"],
|
||||
customStartDate: number | undefined,
|
||||
customEndDate: number | undefined,
|
||||
) =>
|
||||
[
|
||||
...usageKeys.all,
|
||||
"summary-by-app",
|
||||
preset,
|
||||
customStartDate ?? 0,
|
||||
customEndDate ?? 0,
|
||||
] as const,
|
||||
trends: (
|
||||
preset: UsageRangeSelection["preset"],
|
||||
customStartDate: number | undefined,
|
||||
@@ -133,6 +145,25 @@ export function useUsageSummary(
|
||||
});
|
||||
}
|
||||
|
||||
export function useUsageSummaryByApp(
|
||||
range: UsageRangeSelection,
|
||||
options?: UsageQueryOptions,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: usageKeys.summaryByApp(
|
||||
range.preset,
|
||||
range.customStartDate,
|
||||
range.customEndDate,
|
||||
),
|
||||
queryFn: () => {
|
||||
const { startDate, endDate } = resolveUsageRange(range);
|
||||
return usageApi.getUsageSummaryByApp(startDate, endDate);
|
||||
},
|
||||
refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUsageTrends(
|
||||
range: UsageRangeSelection,
|
||||
appType?: string,
|
||||
@@ -271,7 +302,7 @@ export function useUpdateModelPricing() {
|
||||
params.cacheCreationCost,
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: usageKeys.pricing() });
|
||||
queryClient.invalidateQueries({ queryKey: usageKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -282,7 +313,7 @@ export function useDeleteModelPricing() {
|
||||
return useMutation({
|
||||
mutationFn: (modelId: string) => usageApi.deleteModelPricing(modelId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: usageKeys.pricing() });
|
||||
queryClient.invalidateQueries({ queryKey: usageKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@ export interface AuthBinding {
|
||||
|
||||
export interface ClaudeDesktopModelRoute {
|
||||
model: string;
|
||||
displayName?: string;
|
||||
labelOverride?: string;
|
||||
supports1m?: boolean;
|
||||
}
|
||||
|
||||
|
||||
+109
-1
@@ -71,6 +71,15 @@ export interface UsageSummary {
|
||||
totalCacheCreationTokens: number;
|
||||
totalCacheReadTokens: number;
|
||||
successRate: number;
|
||||
/** input + output + cache_creation + cache_read, all cache-normalized */
|
||||
realTotalTokens: number;
|
||||
/** cache_read / (input + cache_creation + cache_read), range 0–1 */
|
||||
cacheHitRate: number;
|
||||
}
|
||||
|
||||
export interface UsageSummaryByApp {
|
||||
appType: string;
|
||||
summary: UsageSummary;
|
||||
}
|
||||
|
||||
export interface DailyStats {
|
||||
@@ -129,7 +138,106 @@ export interface UsageRangeSelection {
|
||||
customEndDate?: number;
|
||||
}
|
||||
|
||||
export type AppTypeFilter = "all" | "claude" | "codex" | "gemini";
|
||||
/**
|
||||
* App types whose token usage is reliably collected by the proxy.
|
||||
*
|
||||
* The proxy has handlers for `claude-desktop` too, but in practice those
|
||||
* requests overwhelmingly fail (500/503) and contribute near-zero tokens, so
|
||||
* it is hidden from the Dashboard. `opencode` / `openclaw` / `hermes` have
|
||||
* no proxy handler at all — they appear only as managed apps elsewhere.
|
||||
*/
|
||||
export type AppType = "claude" | "codex" | "gemini";
|
||||
|
||||
export type AppTypeFilter = "all" | AppType;
|
||||
|
||||
export const KNOWN_APP_TYPES: ReadonlyArray<AppType> = [
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
];
|
||||
|
||||
/**
|
||||
* App types whose proxy uses an OpenAI-style protocol. Two consequences:
|
||||
*
|
||||
* 1. `inputTokens` already includes the cached portion (must subtract
|
||||
* `cacheReadTokens` to get fresh-input semantics — see
|
||||
* [getFreshInputTokens]).
|
||||
* 2. The protocol does not report cache _creation_ separately, only cache
|
||||
* _reads_. So `cacheCreationTokens` is always 0 for these app types and
|
||||
* the UI should label it as N/A rather than 0.
|
||||
*
|
||||
* Mirror of the Rust `CACHE_INCLUSIVE_APP_TYPES` whitelist.
|
||||
*/
|
||||
export const CACHE_INCLUSIVE_APP_TYPES: ReadonlySet<string> = new Set([
|
||||
"codex",
|
||||
"gemini",
|
||||
]);
|
||||
|
||||
/** Subset of request-log fields needed to derive cache-normalized input. */
|
||||
export interface CacheNormalizableLog {
|
||||
appType: string;
|
||||
inputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a single request log, return the input token count with cache reads
|
||||
* removed. Anthropic-style providers already report `inputTokens` without
|
||||
* cache, so they pass through unchanged.
|
||||
*/
|
||||
export function getFreshInputTokens(log: CacheNormalizableLog): number {
|
||||
if (
|
||||
CACHE_INCLUSIVE_APP_TYPES.has(log.appType) &&
|
||||
log.inputTokens >= log.cacheReadTokens
|
||||
) {
|
||||
return log.inputTokens - log.cacheReadTokens;
|
||||
}
|
||||
return log.inputTokens;
|
||||
}
|
||||
|
||||
export const NON_NEGATIVE_DECIMAL_REGEX = /^\d+(?:\.\d+)?$/;
|
||||
|
||||
export function isNonNegativeDecimalString(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!NON_NEGATIVE_DECIMAL_REGEX.test(trimmed)) return false;
|
||||
return Number.isFinite(Number(trimmed));
|
||||
}
|
||||
|
||||
type UsageCostLog = Pick<
|
||||
RequestLog,
|
||||
| "inputTokens"
|
||||
| "outputTokens"
|
||||
| "cacheReadTokens"
|
||||
| "cacheCreationTokens"
|
||||
| "totalCostUsd"
|
||||
| "statusCode"
|
||||
> &
|
||||
Partial<Pick<RequestLog, "costMultiplier">>;
|
||||
|
||||
export function hasUsageTokens(log: UsageCostLog): boolean {
|
||||
return (
|
||||
log.inputTokens > 0 ||
|
||||
log.outputTokens > 0 ||
|
||||
log.cacheReadTokens > 0 ||
|
||||
log.cacheCreationTokens > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function isUnpricedUsage(log: UsageCostLog): boolean {
|
||||
const totalCost = Number.parseFloat(log.totalCostUsd);
|
||||
const multiplier =
|
||||
log.costMultiplier == null
|
||||
? undefined
|
||||
: Number.parseFloat(log.costMultiplier);
|
||||
return (
|
||||
log.statusCode >= 200 &&
|
||||
log.statusCode < 300 &&
|
||||
hasUsageTokens(log) &&
|
||||
Number.isFinite(totalCost) &&
|
||||
(!Number.isFinite(multiplier) || multiplier !== 0) &&
|
||||
totalCost === 0
|
||||
);
|
||||
}
|
||||
|
||||
export interface StatsFilters {
|
||||
timeRange: UsageRangePreset;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ComponentProps } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ClaudeDesktopProviderForm } from "@/components/providers/forms/ClaudeDesktopProviderForm";
|
||||
import { createTestQueryClient } from "../utils/testQueryClient";
|
||||
|
||||
vi.mock("@/lib/api/providers", () => ({
|
||||
providersApi: {
|
||||
getClaudeDesktopDefaultRoutes: () => Promise.resolve([]),
|
||||
},
|
||||
}));
|
||||
|
||||
function renderForm(
|
||||
initialData: ComponentProps<typeof ClaudeDesktopProviderForm>["initialData"],
|
||||
) {
|
||||
const queryClient = createTestQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ClaudeDesktopProviderForm
|
||||
submitLabel="保存"
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
initialData={initialData}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ClaudeDesktopProviderForm", () => {
|
||||
it("编辑模型映射的菜单显示名时保持输入框焦点", () => {
|
||||
renderForm({
|
||||
name: "Proxy Provider",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.example.com",
|
||||
ANTHROPIC_AUTH_TOKEN: "sk-test",
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
claudeDesktopMode: "proxy",
|
||||
claudeDesktopModelRoutes: {
|
||||
"claude-old": {
|
||||
model: "upstream-old",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
"DeepSeek V4 Pro",
|
||||
) as HTMLInputElement;
|
||||
input.focus();
|
||||
|
||||
fireEvent.change(input, { target: { value: "DeepSeek V4 Pro" } });
|
||||
|
||||
const currentInput = screen.getByPlaceholderText(
|
||||
"DeepSeek V4 Pro",
|
||||
) as HTMLInputElement;
|
||||
expect(currentInput).toHaveValue("DeepSeek V4 Pro");
|
||||
expect(document.activeElement).toBe(currentInput);
|
||||
});
|
||||
|
||||
it("编辑直连模型列表的模型 ID 时保持输入框焦点", () => {
|
||||
renderForm({
|
||||
name: "Direct Provider",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.example.com",
|
||||
ANTHROPIC_AUTH_TOKEN: "sk-test",
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
claudeDesktopMode: "direct",
|
||||
claudeDesktopModelRoutes: {
|
||||
"claude-old": {
|
||||
model: "claude-old",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
"claude-deepseek-chat",
|
||||
) as HTMLInputElement;
|
||||
input.focus();
|
||||
|
||||
fireEvent.change(input, { target: { value: "claude-12345" } });
|
||||
|
||||
const currentInput = screen.getByPlaceholderText(
|
||||
"claude-deepseek-chat",
|
||||
) as HTMLInputElement;
|
||||
expect(currentInput).toHaveValue("claude-12345");
|
||||
expect(document.activeElement).toBe(currentInput);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ComponentProps, PropsWithChildren } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ClaudeFormFields } from "@/components/providers/forms/ClaudeFormFields";
|
||||
import { Form } from "@/components/ui/form";
|
||||
|
||||
const copilotApiMock = vi.hoisted(() => ({
|
||||
copilotGetModels: vi.fn(),
|
||||
copilotGetModelsForAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
const modelFetchApiMock = vi.hoisted(() => ({
|
||||
fetchCodexOauthModels: vi.fn(),
|
||||
fetchModelsForConfig: vi.fn(),
|
||||
showFetchModelsError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/copilot", () => ({
|
||||
copilotGetModels: copilotApiMock.copilotGetModels,
|
||||
copilotGetModelsForAccount: copilotApiMock.copilotGetModelsForAccount,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/model-fetch", () => ({
|
||||
fetchCodexOauthModels: modelFetchApiMock.fetchCodexOauthModels,
|
||||
fetchModelsForConfig: modelFetchApiMock.fetchModelsForConfig,
|
||||
showFetchModelsError: modelFetchApiMock.showFetchModelsError,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/forms/CopilotAuthSection", () => ({
|
||||
CopilotAuthSection: () => <div data-testid="copilot-auth-section" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/forms/CodexOAuthSection", () => ({
|
||||
CodexOAuthSection: () => <div data-testid="codex-oauth-section" />,
|
||||
}));
|
||||
|
||||
type ClaudeFormFieldsProps = ComponentProps<typeof ClaudeFormFields>;
|
||||
|
||||
const FormShell = ({ children }: PropsWithChildren) => {
|
||||
const form = useForm();
|
||||
|
||||
return <Form {...form}>{children}</Form>;
|
||||
};
|
||||
|
||||
const renderCopilotForm = (overrides: Partial<ClaudeFormFieldsProps> = {}) => {
|
||||
const props: ClaudeFormFieldsProps = {
|
||||
shouldShowApiKey: false,
|
||||
apiKey: "",
|
||||
onApiKeyChange: vi.fn(),
|
||||
category: "official",
|
||||
shouldShowApiKeyLink: false,
|
||||
websiteUrl: "",
|
||||
isCopilotPreset: true,
|
||||
usesOAuth: true,
|
||||
isCopilotAuthenticated: true,
|
||||
selectedGitHubAccountId: "gh-1",
|
||||
onGitHubAccountSelect: vi.fn(),
|
||||
isCodexOauthPreset: false,
|
||||
isCodexOauthAuthenticated: false,
|
||||
selectedCodexAccountId: null,
|
||||
onCodexAccountSelect: vi.fn(),
|
||||
codexFastMode: false,
|
||||
onCodexFastModeChange: vi.fn(),
|
||||
templateValueEntries: [],
|
||||
templateValues: {},
|
||||
templatePresetName: "",
|
||||
onTemplateValueChange: vi.fn(),
|
||||
shouldShowSpeedTest: false,
|
||||
baseUrl: "",
|
||||
onBaseUrlChange: vi.fn(),
|
||||
isEndpointModalOpen: false,
|
||||
onEndpointModalToggle: vi.fn(),
|
||||
onCustomEndpointsChange: vi.fn(),
|
||||
autoSelect: false,
|
||||
onAutoSelectChange: vi.fn(),
|
||||
showEndpointTools: true,
|
||||
shouldShowModelSelector: true,
|
||||
claudeModel: "",
|
||||
defaultHaikuModel: "",
|
||||
defaultHaikuModelName: "",
|
||||
defaultSonnetModel: "claude-sonnet",
|
||||
defaultSonnetModelName: "Claude Sonnet",
|
||||
defaultOpusModel: "",
|
||||
defaultOpusModelName: "",
|
||||
onModelChange: vi.fn(),
|
||||
speedTestEndpoints: [],
|
||||
apiFormat: "anthropic",
|
||||
onApiFormatChange: vi.fn(),
|
||||
apiKeyField: "ANTHROPIC_AUTH_TOKEN",
|
||||
onApiKeyFieldChange: vi.fn(),
|
||||
isFullUrl: false,
|
||||
onFullUrlChange: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return render(
|
||||
<FormShell>
|
||||
<ClaudeFormFields {...props} />
|
||||
</FormShell>,
|
||||
);
|
||||
};
|
||||
|
||||
const renderCodexOauthForm = (overrides: Partial<ClaudeFormFieldsProps> = {}) =>
|
||||
renderCopilotForm({
|
||||
isCopilotPreset: false,
|
||||
isCopilotAuthenticated: false,
|
||||
selectedGitHubAccountId: null,
|
||||
isCodexOauthPreset: true,
|
||||
isCodexOauthAuthenticated: true,
|
||||
selectedCodexAccountId: "chatgpt-1",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("ClaudeFormFields", () => {
|
||||
beforeEach(() => {
|
||||
copilotApiMock.copilotGetModels.mockResolvedValue([]);
|
||||
copilotApiMock.copilotGetModelsForAccount.mockResolvedValue([]);
|
||||
modelFetchApiMock.fetchCodexOauthModels.mockResolvedValue([]);
|
||||
modelFetchApiMock.fetchModelsForConfig.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("不会在 Copilot 表单打开时自动获取模型列表", () => {
|
||||
renderCopilotForm();
|
||||
|
||||
expect(copilotApiMock.copilotGetModels).not.toHaveBeenCalled();
|
||||
expect(copilotApiMock.copilotGetModelsForAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("点击获取模型列表后才请求当前 Copilot 账号的模型", async () => {
|
||||
renderCopilotForm();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "providerForm.fetchModels",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(copilotApiMock.copilotGetModelsForAccount).toHaveBeenCalledWith(
|
||||
"gh-1",
|
||||
);
|
||||
});
|
||||
expect(copilotApiMock.copilotGetModels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("不会在 Codex OAuth 表单打开时自动获取模型列表", () => {
|
||||
renderCodexOauthForm();
|
||||
|
||||
expect(modelFetchApiMock.fetchCodexOauthModels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("点击获取模型列表后才请求当前 Codex OAuth 账号的模型", async () => {
|
||||
renderCodexOauthForm();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "providerForm.fetchModels",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(modelFetchApiMock.fetchCodexOauthModels).toHaveBeenCalledWith(
|
||||
"chatgpt-1",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { ProviderPresetSelector } from "@/components/providers/forms/ProviderPresetSelector";
|
||||
|
||||
describe("ProviderPresetSelector", () => {
|
||||
it("按传入的预设数组顺序渲染,不按分类重新排序", () => {
|
||||
const Wrapper = () => {
|
||||
const form = useForm();
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<ProviderPresetSelector
|
||||
selectedPresetId="custom"
|
||||
presetEntries={[
|
||||
{
|
||||
id: "preset-0",
|
||||
preset: {
|
||||
name: "First",
|
||||
websiteUrl: "https://first.example.com",
|
||||
settingsConfig: {},
|
||||
category: "third_party",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "preset-1",
|
||||
preset: {
|
||||
name: "Second",
|
||||
websiteUrl: "https://second.example.com",
|
||||
settingsConfig: {},
|
||||
category: "official",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "preset-2",
|
||||
preset: {
|
||||
name: "Third",
|
||||
websiteUrl: "https://third.example.com",
|
||||
settingsConfig: {},
|
||||
category: "aggregator",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "preset-3",
|
||||
preset: {
|
||||
name: "Fourth",
|
||||
websiteUrl: "https://fourth.example.com",
|
||||
settingsConfig: {},
|
||||
category: "official",
|
||||
},
|
||||
},
|
||||
]}
|
||||
presetCategoryLabels={{
|
||||
official: "官方",
|
||||
aggregator: "聚合服务",
|
||||
third_party: "第三方",
|
||||
}}
|
||||
onPresetChange={vi.fn()}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
render(<Wrapper />);
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("button").map((button) => button.textContent),
|
||||
).toEqual(["providerPreset.custom", "First", "Second", "Third", "Fourth"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||
import { claudeDesktopProviderPresets } from "@/config/claudeDesktopProviderPresets";
|
||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
import { opencodeProviderPresets } from "@/config/opencodeProviderPresets";
|
||||
import { openclawProviderPresets } from "@/config/openclawProviderPresets";
|
||||
import { hermesProviderPresets } from "@/config/hermesProviderPresets";
|
||||
|
||||
const namesOf = (presets: Array<{ name: string }>) =>
|
||||
presets.map((preset) => preset.name);
|
||||
|
||||
const expectInOrder = (names: string[], expected: string[]) => {
|
||||
const indexes = expected.map((name) => names.indexOf(name));
|
||||
|
||||
expect(indexes).not.toContain(-1);
|
||||
expect(indexes).toEqual(expected.map((_, index) => indexes[0] + index));
|
||||
};
|
||||
|
||||
describe("provider preset order", () => {
|
||||
it("Claude 预设按合作伙伴优先顺序排列", () => {
|
||||
expectInOrder(namesOf(providerPresets), [
|
||||
"Shengsuanyun",
|
||||
"PatewayAI",
|
||||
"火山Agentplan",
|
||||
"BytePlus",
|
||||
"DouBaoSeed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("Claude Desktop 预设按合作伙伴优先顺序排列", () => {
|
||||
expectInOrder(namesOf(claudeDesktopProviderPresets), [
|
||||
"Shengsuanyun",
|
||||
"PatewayAI",
|
||||
"火山Agentplan",
|
||||
"BytePlus",
|
||||
"DouBaoSeed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("Codex 预设把 PatewayAI 放在胜算云后面", () => {
|
||||
expectInOrder(namesOf(codexProviderPresets), ["Shengsuanyun", "PatewayAI"]);
|
||||
});
|
||||
|
||||
it("OpenCode 预设把火山、BytePlus、DouBaoSeed 放在胜算云后面", () => {
|
||||
expectInOrder(namesOf(opencodeProviderPresets), [
|
||||
"Shengsuanyun",
|
||||
"火山Agentplan",
|
||||
"BytePlus",
|
||||
"DouBaoSeed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("OpenClaw 预设把火山、BytePlus、DouBaoSeed 放在胜算云后面", () => {
|
||||
expectInOrder(namesOf(openclawProviderPresets), [
|
||||
"Shengsuanyun",
|
||||
"火山Agentplan",
|
||||
"BytePlus",
|
||||
"DouBaoSeed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("Hermes 预设把火山、BytePlus、DouBaoSeed 放在胜算云后面", () => {
|
||||
expectInOrder(namesOf(hermesProviderPresets), [
|
||||
"Shengsuanyun",
|
||||
"火山Agentplan",
|
||||
"BytePlus",
|
||||
"DouBaoSeed",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user