Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b27de2c66e | |||
| 7685ab7049 | |||
| deeeca1920 | |||
| 2fc6753e42 | |||
| 4b384dfe55 | |||
| 00a789e7a3 | |||
| aec055a1d1 | |||
| e45470cd91 | |||
| 50a873ca24 | |||
| 2ac5e053b4 | |||
| a7dd7117e7 | |||
| b016a17783 | |||
| 10c874afdc | |||
| 5bbd83f7ca | |||
| 292c117509 | |||
| f526d01578 | |||
| 3cd74400dc | |||
| 950b7dd35f | |||
| 309f7609a5 | |||
| 1fa019026e | |||
| 21b9eb0430 | |||
| 2deee1097b | |||
| 34698723e3 | |||
| 83f4e1d0ad | |||
| 8b3ad9caf9 | |||
| e15bfbfe7a | |||
| b05be92aa1 | |||
| f5fbcd0493 | |||
| 1d44b1ba41 | |||
| ddc7b4567e | |||
| 8e59a634fd | |||
| bc1f9341f4 | |||
| 72ab8a5cfd | |||
| b61dad4b43 | |||
| faa6021c6f | |||
| 79d6ede7ae | |||
| c2714774b9 | |||
| 026b5dbcb5 | |||
| 59f7105cbc | |||
| 586450a372 | |||
| 7621b645d1 | |||
| d75e4e7eb3 | |||
| adc7ffefa4 | |||
| bdc4c1e8b8 | |||
| 35bce24633 | |||
| 518d945eb8 | |||
| 7b667f7a44 | |||
| 295dd9a944 | |||
| 064b339bab | |||
| db66348ff8 | |||
| 7f0c7b11e5 | |||
| fafc122d82 | |||
| 693c36a12a | |||
| 7965862e66 | |||
| 1c69269405 | |||
| 15497b0e41 | |||
| 7c8720bd3f | |||
| 8084bfafa8 | |||
| 151af0ffa3 | |||
| 608ee35ecd | |||
| a1e6c3b65d | |||
| eb304232b3 | |||
| f061b777b7 | |||
| 2ee7cb4101 | |||
| bcf8434c1f | |||
| d2556be5b9 | |||
| 5b6339d78c | |||
| 21e2d68d76 | |||
| 6441bc5c01 | |||
| 4536b95ac9 | |||
| 85f0be9e1d | |||
| 08e2b29b8a | |||
| 948fa87ae3 | |||
| 4833d24d83 | |||
| 221af57a49 | |||
| b1f9ce4653 | |||
| 67dbfc0a8c | |||
| fcd83ee30d | |||
| c002688a84 | |||
| 4737158439 | |||
| cdcc423122 | |||
| dc04165f18 | |||
| 010b163430 | |||
| 59735f976b | |||
| 10e0772d8c | |||
| 444c123ad0 | |||
| 3ad7d0b1cc | |||
| 29e87487a1 | |||
| 7bfe2609db | |||
| e7ff2ee47d | |||
| 7ddf7ffd66 | |||
| 1b3c53d235 | |||
| 5548b81bad | |||
| 416f3e4256 | |||
| 6fcc190471 | |||
| b1fedc5e5d |
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
node-version: "20"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.12.3
|
||||
run_install: false
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Claude
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
((github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR'))
|
||||
||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR'))
|
||||
||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') &&
|
||||
(github.event.review.author_association == 'OWNER' ||
|
||||
github.event.review.author_association == 'MEMBER' ||
|
||||
github.event.review.author_association == 'COLLABORATOR'))
|
||||
||
|
||||
(github.event_name == 'issues' &&
|
||||
(contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) &&
|
||||
(github.event.issue.author_association == 'OWNER' ||
|
||||
github.event.issue.author_association == 'MEMBER' ||
|
||||
github.event.issue.author_association == 'COLLABORATOR')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Run Claude (review only)
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
claude_args: >-
|
||||
--model claude-opus-4-7
|
||||
--disallowedTools Edit,Write,MultiEdit,NotebookEdit
|
||||
--append-system-prompt "You are reviewing PRs for cc-switch — a small open-source Tauri 2.0 + React + TypeScript desktop app maintained by a single developer who values pragmatism over perfection.
|
||||
ONLY flag HIGH SIGNAL issues. An issue qualifies only if it meets at least one of: code that fails to compile/parse/run; code that will definitely produce wrong results; a security vulnerability with a concrete trigger (SSRF, injection, unsafe deserialization, credential or data leaks); database/migration correctness (schema mismatch, data loss on upgrade, broken SCHEMA_VERSION transition); cross-platform breakage (Windows/macOS/Linux specific); clear, quotable violations of CLAUDE.md.
|
||||
Each finding must include a confidence score from 0 to 100. Do NOT post any finding scored below 80. Each finding must carry a severity tag — Important (bug that should be fixed before merge), Nit (minor, worth fixing but not blocking), or Pre-existing (exists already, not introduced by this PR).
|
||||
DO NOT flag any of the following: pedantic nitpicks a senior engineer would not raise; anything a linter, typechecker, pnpm format, or cargo fmt would catch; missing test coverage unless CLAUDE.md explicitly mandates it; style, naming, or 'could be more idiomatic' suggestions; speculative future-proofing ('what if X is added later'); issues on lines the PR did not modify; potential bugs for which you cannot construct a concrete failure scenario; positive feedback or 'what is well done' sections.
|
||||
OUTPUT RULES: Cap Nit-level findings at 5 per review. If you found more nits, append 'plus N similar nits' to the summary instead of listing them inline. If only nits were found, lead the review with 'No blocking issues.' If nothing material is wrong, say 'LGTM' explicitly and stop — approval with zero comments is a valid and expected outcome for most PRs. Do NOT post a final APPROVE or REQUEST CHANGES verdict; leave the merge decision to the maintainer.
|
||||
Never modify files, push commits, or create PRs."
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
|| sudo apt-get install -y --no-install-recommends libsoup2.4-dev
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.12.3
|
||||
run_install: false
|
||||
@@ -535,7 +535,7 @@ jobs:
|
||||
ls -la release-assets
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: CC Switch ${{ github.ref_name }}
|
||||
@@ -543,6 +543,8 @@ jobs:
|
||||
body: |
|
||||
## CC Switch ${{ github.ref_name }}
|
||||
|
||||
🌐 **Only Official Website / 唯一官方网站 / 唯一の公式サイト**: [ccswitch.io](https://ccswitch.io)
|
||||
|
||||
Claude Code 供应商切换工具
|
||||
|
||||
### 下载
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
# --- Timing ---
|
||||
days-before-stale: 60
|
||||
|
||||
@@ -29,3 +29,4 @@ copilot-api
|
||||
.history
|
||||
CODEBUDDY.md
|
||||
.github
|
||||
mainWindow.js
|
||||
|
||||
@@ -5,6 +5,44 @@ All notable changes to CC Switch will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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).
|
||||
|
||||
## [3.14.1] - 2026-04-23
|
||||
|
||||
Development since v3.14.0 focuses on Codex OAuth stability, tray usage visibility, Skills import/install reliability, Gemini session restore paths, and simplifying Hermes configuration health handling.
|
||||
|
||||
**Stats**: 13 commits | 48 files changed | +1,883 insertions | -808 deletions
|
||||
|
||||
### Added
|
||||
|
||||
- **Tray Usage Visibility**: System tray submenus now show cached usage for the current Claude / Codex / Gemini provider, including subscription and script-based usage summaries with utilization color markers. Tray-triggered refreshes are throttled, limited to visible apps, and synchronized back into React Query so the main window and tray share fresh usage data (#2184).
|
||||
- **Tray Coding-Plan Usage (Kimi / Zhipu / MiniMax)**: System tray now renders 5-hour + weekly window usage for Chinese coding-plan providers using the same `🟢 h12% w80%` two-window layout as official subscription badges (worst utilization drives the emoji). Creating a Claude provider whose `ANTHROPIC_BASE_URL` matches a known coding-plan host now auto-injects `meta.usage_script`, so the tray lights up without opening the Usage Script modal. Existing `usage_script` values are preserved on update.
|
||||
- **Codex OAuth FAST Mode**: Added an explicit FAST mode toggle for Codex OAuth-backed Claude providers. When enabled, converted Responses requests send `service_tier="priority"` for lower latency; the toggle stays off by default to avoid unexpectedly increasing ChatGPT quota consumption (#2210).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Session and Settings Layout Polish**: Hardened the scroll-area viewport with width containment to fix horizontal overflow, and tightened app bottom spacing plus settings footer spacing so long session/settings views fit more cleanly (#2201).
|
||||
|
||||
### Removed
|
||||
|
||||
- **Hermes Config Health Scanner**: Removed the in-app Hermes config health scanner, warning banner, `scan_hermes_config_health` command, `HermesHealthWarning` type, and `HermesWriteOutcome.warnings` payload. CC Switch now keeps the Hermes surface focused on active provider display, provider switching defaults, memory editing, and launching the Hermes Web UI for deep configuration.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Codex OAuth Cache Routing**: Stabilized ChatGPT Codex reverse-proxy cache identity by using client-provided session IDs for `prompt_cache_key` and Codex session headers, preserving explicit cache keys, and avoiding generated UUID cache churn (#2218).
|
||||
- **Codex OAuth Responses SSE Aggregation**: Non-streaming Anthropic clients now receive JSON even when the ChatGPT Codex upstream forces OpenAI Responses SSE; CC Switch aggregates the upstream SSE events before running the non-streaming transform (#2235).
|
||||
- **Codex OAuth Stream Check Parity**: Stream checks now build Codex OAuth test requests with the same `store: false`, encrypted reasoning include, and provider FAST mode setting as production proxy requests (#2210).
|
||||
- **Codex Model Extraction**: Replaced first-line regex matching with TOML parsing when reading Codex config models, so multiline TOML is handled correctly (#2227).
|
||||
- **Model Quick-Set / One-Click Config**: Model quick-set updates now apply against the latest provider form config, preventing stale state from making one-click configuration fail (#2249).
|
||||
- **Skills Import Duplicates**: The Skills import dialog disables actions while import is pending and the installed-skills cache deduplicates imported results by ID, preventing double-clicks from adding duplicate installed entries (#2139, #2211).
|
||||
- **Root-Level Skill Repos**: Skill install and update flows now consistently resolve three source patterns: direct nested paths, install-name recursive search, and repository-root `SKILL.md` sources (#2231).
|
||||
- **Gemini Session Restore Paths**: Gemini session scanning now reads `.project_root` metadata so restore flows can pass the original project directory when available (#2240).
|
||||
- **Provider Hover Names**: Provider icons now expose the provider name on hover for inline SVG, image URL, and fallback initials render paths (#2237).
|
||||
|
||||
## [3.14.0] - 2026-04-21
|
||||
|
||||
Development since v3.13.0 focuses on onboarding Hermes Agent as a first-class managed app, rolling out Claude Opus 4.7 across the preset matrix, adding a Gemini Native API proxy, and sharpening session, usage, and proxy workflows.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### The All-in-One Manager for Claude Code, Codex, Gemini CLI, OpenCode & OpenClaw
|
||||
### The All-in-One Manager for Claude Code, Codex, Gemini CLI, OpenCode, OpenClaw & Hermes Agent
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
### 🌐 The Only Official Website: **[ccswitch.io](https://ccswitch.io)**
|
||||
|
||||
English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANGELOG.md)
|
||||
|
||||
</div>
|
||||
@@ -42,21 +44,33 @@ MiniMax-M2.7 is a next-generation large language model designed for autonomous e
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.svg" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>Thanks to Shengsuanyun for sponsoring this project! Shengsuanyun is a super factory serving AI Native Teams — an industrial-grade AI task parallel execution platform. Its model marketplace aggregates Claude, ChatGPT, Gemini, and other domestic and international LLM and multimedia model capabilities with direct supply. Absolutely no reverse engineering or dilution — platform-wide model SLA availability reaches 99.7%, with <a href="https://watch.shengsuanyun.com/status/shengsuanyun">monitoring dashboards</a> showing green across the board. It also offers enterprise-grade custom gateways for fine-grained team cost and permission management, smart routing, security protection, and BYOK (Bring Your Own Key) hosting. The platform charges on a pay-per-use and tokens plan (coming soon) basis, with invoicing available. Register via <a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">this link</a> as a new user to receive ¥10 in credits plus a 10% bonus on your first top-up.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via <a href="https://cloud.siliconflow.cn/i/drGuwc9k">this link</a> and complete real-name verification to receive ¥16 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support.
|
||||
Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CC Switch users: register via <a href="https://www.aicodemirror.com/register?invitecode=9915W3">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/"><img src="assets/partners/logos/pateway.png" alt="PatewayAI" width="150"></a></td>
|
||||
<td>Thanks to PatewayAI for sponsoring this project! PatewayAI is an API relay service provider built for heavy AI developers, focused on directly relaying official high-quality model APIs. It offers the full Claude lineup and the Codex series, 100% sourced from official channels — no dilution, no fakes, verification welcome. Billing is transparent and every token-level invoice can be audited line by line.
|
||||
It also supports enterprise-grade concurrency and provides a dedicated management platform for enterprise customers — formal contracts and invoicing are available; visit the official website for contact details.
|
||||
Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this link</a> to receive $3 in trial credit. Top-ups go as low as 60% of the original price, with a two-way referral bonus of up to $150!</td>
|
||||
</tr>
|
||||
|
||||
<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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via <a href="https://cloud.siliconflow.cn/i/drGuwc9k">this link</a> and complete real-name verification to receive ¥16 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
|
||||
<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more with flexible billing options including pay-as-you-go and monthly plans. Cubence provides special discounts for CC Switch users: register using <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">this link</a> and enter the "CCSWITCH" promo code during recharge to get 10% off every top-up!</td>
|
||||
@@ -69,7 +83,7 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Compshare" width="150"></a></td>
|
||||
<td>Thanks to Compshare for sponsoring this project! Compshare is UCloud's AI cloud platform, providing stable and comprehensive domestic and international model APIs with just one key. Featuring cost-effective monthly and pay-as-you-go Coding Plan packages at 60-80% off official prices. Supports Claude Code, Codex, and API access. Enterprise-grade high concurrency, 24/7 technical support, and self-service invoicing. Users who register via <a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">this link</a> will receive a free 5 CNY platform trial credit!</td>
|
||||
<td>Thanks to Compshare for sponsoring this project! Compshare is UCloud's AI cloud platform, providing stable and comprehensive domestic and international model APIs with just one key. Featuring cost-effective monthly and per-use domestic-model Coding Plan packages, alongside stable officially-relayed overseas models. Supports Claude Code, Codex, and API access. Enterprise-grade high concurrency, 24/7 technical support, and self-service invoicing. Users who register via <a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">this link</a> will receive a free 5 CNY platform trial credit!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -79,12 +93,12 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.png" alt="Crazyrouter" width="150"></a></td>
|
||||
<td>Thanks to Crazyrouter for sponsoring this project! Crazyrouter is a high-performance AI API aggregation platform — one API key for 300+ models including Claude Code, Codex, Gemini CLI, and more. All models at 55% of official pricing with auto-failover, smart routing, and unlimited concurrency. Crazyrouter offers an exclusive deal for CC Switch users: register via <a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">this link</a> to get <strong>$2 free credit</strong> instantly, plus enter promo code `CCSWITCH` on your first top-up for an extra <strong>30% bonus credit</strong>! </td>
|
||||
<td>Thanks to Crazyrouter for sponsoring this project! Crazyrouter is a high-performance AI API aggregation platform — one API key for 300+ models including Claude Code, Codex, Gemini CLI, and more. All models at 55% of official pricing with auto-failover, smart routing, and unlimited concurrency. Crazyrouter offers an exclusive deal for CC Switch users: register via <a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">this link</a> and contact customer support to claim <strong>$2 free credit</strong>, plus enter promo code `CCSWITCH` on your first top-up for an extra <strong>30% bonus credit</strong>! </td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>Thank you to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini. It features a highly cost-effective Codex monthly subscription plan and <strong>supports quota rollovers—unused quota from one day can be carried over and used the next day.</strong> Invoices are available upon top-up. Enterprise and team users can receive dedicated one-on-one support. Right Code also offers an exclusive discount for CC Switch users: register via <a href="https://www.right.codes/register?aff=CCSWITCH">this link</a>, and with every top-up you will receive pay-as-you-go credit equivalent to 25% of the amount paid.</td>
|
||||
<td>Thank you to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support. Right Code also offers an exclusive discount for CC Switch users: register via <a href="https://www.right.codes/register?aff=CCSWITCH">this link</a>, and with every top-up you will receive pay-as-you-go credit equivalent to 5% of the amount paid.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -93,8 +107,8 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.openclaudecode.cn/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>Thanks to Micu API for sponsoring this project! Micu API is a global LLM relay service provider dedicated to delivering the best cost-performance ratio with high stability. Backed by a registered enterprise for core assurance, eliminating any risk of service discontinuation, with fast official invoicing support! We champion "zero cost to try": top up from as low as ¥1 with no minimum, and get fee-free refunds anytime! Micu API offers an exclusive deal for CC Switch users: register via <a href="https://www.openclaudecode.cn/register?aff=aOYQ">this link</a> and enter promo code "ccswitch" when topping up to enjoy a <strong>10% discount</strong>!</td>
|
||||
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>Thanks to Micu API for sponsoring this project! Micu API is a global LLM relay service provider dedicated to delivering the best cost-performance ratio with high stability. Backed by a registered enterprise for core assurance, eliminating any risk of service discontinuation, with fast official invoicing support! We champion "zero cost to try": top up from as low as ¥1 with no minimum, and get fee-free refunds anytime! Micu API offers an exclusive deal for CC Switch users: register via <a href="https://www.micuapi.ai/register?aff=aOYQ">this link</a> and enter promo code "ccswitch" when topping up to enjoy a <strong>10% discount</strong>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -108,13 +122,13 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
|
||||
<td>Thanks to ChefShop AI for sponsoring this project! ChefShop AI is a premium account service provider tailored for heavy AI subscription users. The platform offers official top-up and stable account services for mainstream large models including ChatGPT Plus/Pro, Claude Max, Grok Super/Heavy, and Gemini. Click <a href="https://chefshop.ai">here</a> to purchase!</td>
|
||||
<td width="180"><a href="https://vibecodingapi.ai"><img src="assets/partners/logos/lioncc.png" alt="LionCC" width="150"></a></td>
|
||||
<td>Thanks to LionCC for sponsoring this project! LionCC is built for Vibe Coders who pursue the ultimate development experience. We provide stable, low-latency, and competitively priced computing services for Claude Code, Codex, and OpenClaw, saving up to 50% in costs. After registering, add customer service on WeChat (HSQBJ088888888) with the code "cc-switch" to receive $10 in free credits (10 million tokens). For other collaborations, follow the blog @LionCC.ai. Click <a href="https://vibecodingapi.ai">here</a> to register!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://vibecodingapi.ai"><img src="assets/partners/logos/lioncc.png" alt="LionCC" width="150"></a></td>
|
||||
<td>Thanks to LionCC for sponsoring this project! LionCC is built for Vibe Coders who pursue the ultimate development experience. We provide stable, low-latency, and competitively priced computing services for Claude Code, Codex, and OpenClaw, saving up to 50% in costs. After registering, add customer service on WeChat (HSQBJ088888888) with the code "cc-switch" to receive $10 in free credits (10 million tokens). For other collaborations, follow the blog @LionCC.ai. Click <a href="https://vibecodingapi.ai">here</a> to register!</td>
|
||||
<td width="180"><a href="https://console.claudeapi.com/register?aff=pCLD"><img src="assets/partners/logos/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
|
||||
<td>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>
|
||||
@@ -123,6 +137,16 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://runapi.co"><img src="assets/partners/logos/runapi.jpg" alt="RunAPI" width="150"></a></td>
|
||||
<td>Thanks to RunAPI for sponsoring this project! RunAPI is a high-performance and reliable AI model API gateway — one API key gives you access to 150+ mainstream models including OpenAI, Claude, Gemini, DeepSeek, and Grok, with prices as low as 10% of the official rate and excellent stability. It works seamlessly with Claude Code, OpenClaw, and other tools. Exclusive benefit for CC Switch users: register and contact customer support to claim a free ¥14 credit. Register via <a href="https://runapi.co">this link</a>!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw のオールインワン管理ツール
|
||||
### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes Agent のオールインワン管理ツール
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
### 🌐 唯一の公式サイト:**[ccswitch.io](https://ccswitch.io)**
|
||||
|
||||
[English](README.md) | [中文](README_ZH.md) | 日本語 | [Changelog](CHANGELOG.md)
|
||||
|
||||
</div>
|
||||
@@ -42,21 +44,33 @@ MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.svg" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>胜算雲(Shengsuanyun)のご支援に感謝します!胜算雲は AI ネイティブチーム向けのスーパーファクトリーであり、産業グレードの AI タスク並列実行プラットフォームです。モデルマーケットプレイスでは Claude、ChatGPT、Gemini をはじめとする国内外の LLM およびマルチメディアモデルの計算リソースを集約・直接提供。リバースエンジニアリングや品質低下は一切なく、プラットフォーム全体のモデル SLA 可用性は 99.7% に達し、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">監視ダッシュボード</a>は常時グリーン表示です。さらにエンタープライズ向けカスタムゲートウェイを提供し、チームのきめ細かなコスト・権限管理、スマートルーティング、セキュリティ保護、BYOK(自社キー持ち込み)ホスティングを実現します。従量課金およびトークンプラン(近日公開)対応で、請求書発行にも対応。<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">このリンク</a>から新規登録すると 10 元分のクレジットと初回チャージ 10% ボーナスが付与されます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。<a href="https://cloud.siliconflow.cn/i/drGuwc9k">このリンク</a>から登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥16 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定リレーサービスを提供しており、エンタープライズ級の同時接続、迅速な請求書発行、24時間年中無休の専用テクニカルサポートを備えています。
|
||||
Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / 2% / 9%、チャージ時にはさらに割引!AICodeMirror は CC Switch ユーザー向けに特別特典を用意:<a href="https://www.aicodemirror.com/register?invitecode=9915W3">このリンク</a>から登録すると初回チャージ 20% オフ、法人のお客様は最大 25% オフ!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/"><img src="assets/partners/logos/pateway.png" alt="PatewayAI" width="150"></a></td>
|
||||
<td>PatewayAI のご支援に感謝します!PatewayAI はヘビーな AI 開発者向けに、公式直結の高品質モデル API 中継サービスを専門に提供するプロバイダーです。Claude シリーズ全モデルおよび Codex シリーズに対応し、100% 公式ソースから直接提供。混ぜ物・水増しは一切なく、検証も歓迎します。課金は透明で、トークン単位の請求書を 1 件ずつ照合可能です。
|
||||
エンタープライズ級の高同時接続にも対応し、法人のお客様には専用の管理プラットフォームを提供。正式契約および請求書発行に対応しており、詳細は公式サイトの連絡先よりお問い合わせください。
|
||||
現在、<a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">このリンク</a>からご登録いただくと $3 のトライアルクレジットを進呈。チャージは最安で元価格の 60%、友達紹介は双方にボーナスが付与され、紹介報酬は最大 $150!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||
<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は ByteDance がグローバル市場向けに独自開発したフルモーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャを基盤に、テキスト・画像・音声・動画の統合的な理解と生成をサポートします。エージェント連携をネイティブに実現し、強力な推論、長時間タスクの実行、ツール統合、コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど幅広いシナリオに適用可能で、マルチモーダル認識、エンドツーエンドの複雑なタスク遂行、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて手軽に利用・デプロイできます。<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">このリンク</a>からご登録いただくと、モデルごとに 500,000 トークンの無料推論クォータを進呈します。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。<a href="https://cloud.siliconflow.cn/i/drGuwc9k">このリンク</a>から登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥16 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
|
||||
<td>Cubence のご支援に感謝します!Cubence は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームで、従量課金や月額プランなど柔軟な料金体系を提供しています。CC Switch ユーザー向けの特別割引:<a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">このリンク</a>で登録し、チャージ時に「CCSWITCH」クーポンを入力すると、毎回 10% オフになります!</td>
|
||||
@@ -69,11 +83,9 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Compshare" width="150"></a></td>
|
||||
<td>Compshare のご支援に感謝します!Compshare は UCloud 傘下の AI クラウドプラットフォームで、国内外の安定した包括的なモデル API を 1 つのキーだけで利用可能。月額・従量課金のコストパフォーマンスに優れた Coding Plan パッケージを提供し、公式価格の 60〜80% オフで利用できます。Claude Code、Codex および API アクセスに対応。エンタープライズ級の高同時接続、24 時間年中無休のテクニカルサポート、セルフサービス請求書発行に対応。<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">こちらのリンク</a>から登録すると、無料で 5 元分のプラットフォーム体験クレジットがもらえます!</td>
|
||||
<td>Compshare のご支援に感謝します!Compshare は UCloud 傘下の AI クラウドプラットフォームで、国内外の安定した包括的なモデル API を 1 つのキーだけで利用可能。月額・都度課金のコストパフォーマンスに優れた国内モデル Coding Plan パッケージを提供し、公式リレーによる安定した海外モデルも利用できます。Claude Code、Codex および API アクセスに対応。エンタープライズ級の高同時接続、24 時間年中無休のテクニカルサポート、セルフサービス請求書発行に対応。<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">こちらのリンク</a>から登録すると、無料で 5 元分のプラットフォーム体験クレジットがもらえます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td>AICoding.sh のご支援に感謝します!AICoding.sh —— グローバル AI モデル API 超お得な中継サービス!Claude Code 81% オフ、GPT 99% オフ!数百社の企業に高コストパフォーマンスの AI サービスを提供。Claude Code、GPT、Gemini および国内主要モデルに対応、エンタープライズ級の高同時接続、迅速な請求書発行、24 時間年中無休の専属テクニカルサポート。<a href="https://aicoding.sh/i/CCSWITCH">こちらのリンク</a>から登録した CC Switch ユーザーは、初回チャージ 10% オフ!</td>
|
||||
@@ -81,12 +93,12 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.png" alt="Crazyrouter" width="150"></a></td>
|
||||
<td>Crazyrouter のご支援に感謝します!Crazyrouter は高性能 AI API アグリゲーションプラットフォームです。1 つの API キーで Claude Code、Codex、Gemini CLI など 300 以上のモデルにアクセス可能。全モデルが公式価格の 55% で利用でき、自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。CC Switch ユーザー向けの限定特典:<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">こちらのリンク</a>から登録すると <strong>$2 の無料クレジット</strong> を即時進呈。さらに初回チャージ時にプロモコード `CCSWITCH` を入力すると <strong>30% のボーナスクレジット</strong> が追加されます!</td>
|
||||
<td>Crazyrouter のご支援に感謝します!Crazyrouter は高性能 AI API アグリゲーションプラットフォームです。1 つの API キーで Claude Code、Codex、Gemini CLI など 300 以上のモデルにアクセス可能。全モデルが公式価格の 55% で利用でき、自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。CC Switch ユーザー向けの限定特典:<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">こちらのリンク</a>から登録後、カスタマーサポートまでご連絡いただくと <strong>$2 の無料クレジット</strong> を受け取れます。さらに初回チャージ時にプロモコード `CCSWITCH` を入力すると <strong>30% のボーナスクレジット</strong> が追加されます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデルに対応した中継(プロキシ)サービスを安定して出しています。特に高いコストパフォーマンスを誇る Codex の月刊プランを主力としており、<strong>未使用分の利用枠を翌日に繰り越して利用できる(繰越対応)</strong>점이特长です。チャージ(入金)後に請求書の発行が可能で、企業・チーム向けには専任担当による個別対応も行っています。さらに CC Switch ユーザー向けの特別優待として、<a href="https://www.right.codes/register?aff=CCSWITCH">こちらのリンク</a>からご登録くと、チャージのたびに実支払額の 25% 相当の従量課金クレジットが付与されます。</td>
|
||||
<td>本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデル向け中継サービスを安定して提供しており、従量課金と月額プランの 2 つの料金体系から選択できます。チャージ後に請求書の発行が可能で、法人・チームのお客様には専任担当による個別対応も行っています。さらに、CC Switch ユーザー向けの特別優待として、<a href="https://www.right.codes/register?aff=CCSWITCH">こちらのリンク</a>から登録すると、チャージのたびに実際の支払額の 5% 相当の従量課金クレジットが付与されます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -95,8 +107,8 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.openclaudecode.cn/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>Micu API のご支援に感謝します!Micu API は、最高のコストパフォーマンスと高い安定性を追求するグローバル大規模言語モデル中継サービスプロバイダーです。法人企業がバックアップしており、サービス停止のリスクを排除、迅速な正規請求書発行に対応!「試行コストゼロ」をモットーに、最低 1 元からチャージ可能で手数料無料、いつでも返金可能!CC Switch ユーザー向けの限定特典:<a href="https://www.openclaudecode.cn/register?aff=aOYQ">こちらのリンク</a>から登録し、チャージ時にプロモコード「ccswitch」を入力すると <strong>10% 割引</strong> が適用されます!</td>
|
||||
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>Micu API のご支援に感謝します!Micu API は、最高のコストパフォーマンスと高い安定性を追求するグローバル大規模言語モデル中継サービスプロバイダーです。法人企業がバックアップしており、サービス停止のリスクを排除、迅速な正規請求書発行に対応!「試行コストゼロ」をモットーに、最低 1 元からチャージ可能で手数料無料、いつでも返金可能!CC Switch ユーザー向けの限定特典:<a href="https://www.micuapi.ai/register?aff=aOYQ">こちらのリンク</a>から登録し、チャージ時にプロモコード「ccswitch」を入力すると <strong>10% 割引</strong> が適用されます!</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="180"><a href="https://lemondata.cc/r/FFX1ZDUP"><img src="assets/partners/logos/lemondata.png" alt="LemonData" width="150"></a></td>
|
||||
@@ -109,13 +121,13 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
|
||||
<td>ChefShop AI のご支援に感謝します!ChefShop AI は、AI ヘビーユーザー向けにカスタマイズされたプレミアムアカウントサービスプロバイダーです。ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy、Gemini など主流の大規模モデルの公式チャージと安定したアカウントサービスを提供しています。<a href="https://chefshop.ai">こちら</a>から購入してください!</td>
|
||||
<td width="180"><a href="https://vibecodingapi.ai"><img src="assets/partners/logos/lioncc.png" alt="LionCC" width="150"></a></td>
|
||||
<td>LionCC のご支援に感謝します!LionCC は究極の開発体験を追求する「Vibe Coders」のために生まれました。Claude Code、Codex、OpenClaw 向けに安定・低遅延・お得な価格の計算リソースサービスを提供し、最大 50% のコスト削減を実現します。登録後、カスタマーサービスの WeChat(HSQBJ088888888)を追加し、合言葉「cc-switch」を送信すると、10 ドル分のクレジット(1,000 万トークン)がもらえます。その他のコラボレーションについてはブログ @LionCC.ai をフォローしてください。<a href="https://vibecodingapi.ai">こちら</a>から登録してください!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://vibecodingapi.ai"><img src="assets/partners/logos/lioncc.png" alt="LionCC" width="150"></a></td>
|
||||
<td>LionCC のご支援に感謝します!LionCC は究極の開発体験を追求する「Vibe Coders」のために生まれました。Claude Code、Codex、OpenClaw 向けに安定・低遅延・お得な価格の計算リソースサービスを提供し、最大 50% のコスト削減を実現します。登録後、カスタマーサービスの WeChat(HSQBJ088888888)を追加し、合言葉「cc-switch」を送信すると、10 ドル分のクレジット(1,000 万トークン)がもらえます。その他のコラボレーションについてはブログ @LionCC.ai をフォローしてください。<a href="https://vibecodingapi.ai">こちら</a>から登録してください!</td>
|
||||
<td width="180"><a href="https://console.claudeapi.com/register?aff=pCLD"><img src="assets/partners/logos/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
|
||||
<td>本プロジェクトは <a href="https://console.claudeapi.com/register?aff=pCLD">Claude API</a> がスポンサーです。Claude API 直結 — わずか 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>
|
||||
@@ -125,6 +137,16 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://runapi.co"><img src="assets/partners/logos/runapi.jpg" alt="RunAPI" width="150"></a></td>
|
||||
<td>本プロジェクトのスポンサーである RunAPI に感謝いたします!RunAPI は高効率で安定した AI モデル API ゲートウェイです。一つの API Key で、OpenAI、Claude、Gemini、DeepSeek、Grok など 150 種類以上の主要モデルにアクセス可能。料金は公式価格の最大 10%、安定性にも優れ、Claude Code や OpenClaw などのツールとシームレスに連携できます。CC Switch ユーザー限定特典:ご登録後にカスタマーサポートへご連絡いただくと、14 元の無料クレジットを進呈いたします。<a href="https://runapi.co">こちら</a>からご登録ください!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# CC Switch
|
||||
|
||||
### Claude Code、Codex、Gemini CLI、OpenCode 和 OpenClaw 的全方位管理工具
|
||||
### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw 和 Hermes Agent 的全方位管理工具
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
### 🌐 唯一官方网站:**[ccswitch.io](https://ccswitch.io)**
|
||||
|
||||
[English](README.md) | 中文 | [日本語](README_JA.md) | [更新日志](CHANGELOG.md)
|
||||
|
||||
</div>
|
||||
@@ -42,21 +44,33 @@ MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.svg" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td width="180"><a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF"><img src="assets/partners/logos/shengsuanyun.png" alt="Shengsuanyun" width="150"></a></td>
|
||||
<td>感谢胜算云赞助了本项目!胜算云是专为AI Native Teams服务的超级工厂,工业级AI任务并行执行平台,模型商城集采直供聚合接入了Claude、Chatgpt、Gemini等海内外LLM及图片视频多媒体模型算力,绝无逆向掺水、全站模型SLA可用性高达99.7%、<a href="https://watch.shengsuanyun.com/status/shengsuanyun">监测接口</a>日常全绿。更有企业级专属定制网关,实现团队精细化成本与权限管控,智能路由+安全防护+BYOK企业自带密钥托管。平台按量及tokens plan(即将上线)计费,可开票,使用<a href="https://www.shengsuanyun.com/?from=CH_4HHXMRYF">此链接</a>注册新用户可获10元模力及首充10%赠送。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_zh.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过<a href="https://cloud.siliconflow.cn/i/drGuwc9k">此链接</a>注册并完成实名认证,即可获得 ¥16 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=9915W3"><img src="assets/partners/logos/aicodemirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。
|
||||
Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CCSwitch 的用户提供了特别福利,通过<a href="https://www.aicodemirror.com/register?invitecode=9915W3">此链接</a>注册的用户,可享受首充8折,企业客户最高可享 7.5 折!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/"><img src="assets/partners/logos/pateway.png" alt="PatewayAI" width="150"></a></td>
|
||||
<td>感谢 PatewayAI 赞助了本项目!PatewayAI 是一家面向重度 AI 开发者、专注官方直连高品质模型 API 中转服务商。提供 Claude 全系列与 Codex 系列模型,100% 官方源直供,不掺假不注水,欢迎检验。计费透明,Token 级账单可逐笔核验。
|
||||
同时支持企业级高并发,并为企业客户提供了专业的管理平台,企业客户可签订正式合同并开具发票,更多详情进入官网获取联系方式。
|
||||
现在通过<a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">此链接</a>注册即送 $3 试用额度,用户充值低至 6 折,邀请好友双向赠送,邀请奖励可达 $150!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
|
||||
<td>感谢火山方舟Agent Plan 模型赞助了本项目!方舟Agent Plan 模型订阅套餐集成了包含Doubao-Seed、Doubao-Seedance、Doubao-Seedream等在内的字节跳动自研SOTA级模型,覆盖文本、代码、图像、视频等多模态任务。同时支持一站式接入DeepSeek V4、GLM 5.1等主流大模型。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。方舟 Agent Plan 为 CC Switch 的用户提供了专属福利:通过<a href="https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">此链接</a>订阅方舟AgentPlan,新客户首月40元起!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_zh.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过<a href="https://cloud.siliconflow.cn/i/drGuwc9k">此链接</a>注册并完成实名认证,即可获得 ¥16 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cubence.com/signup?code=CCSWITCH&source=ccs"><img src="assets/partners/logos/cubence.png" alt="Cubence" width="150"></a></td>
|
||||
<td>感谢 Cubence 赞助本项目!Cubence 是一家可靠高效的 API 中继服务提供商,提供对 Claude Code、Codex、Gemini 等模型的中继服务,并提供按量、包月等灵活的计费方式。Cubence 为 CC Switch 的用户提供了特别优惠:使用 <a href="https://cubence.com/signup?code=CCSWITCH&source=ccs">此链接</a> 注册,并在充值时输入 "CCSWITCH" 优惠码,每次充值均可享受九折优惠!</td>
|
||||
@@ -70,7 +84,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="优云智算" width="150"></a></td>
|
||||
<td>感谢优云智算赞助了本项目!优云智算是UCloud旗下AI云平台,提供稳定、全面的国内外模型API,仅一个key即可调用。主打包月、按量的高性价比 Coding Plan 套餐,基于官方2~5折优惠。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24技术支持、自助开票。通过<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">此链接</a>注册的用户,可得免费5元平台体验金!</td>
|
||||
<td>感谢优云智算赞助了本项目!优云智算是UCloud旗下AI云平台,提供稳定、全面的国内外模型API,仅一个key即可调用。主打包月、按次的高性价比 国模Coding Plan套餐,同时提供官转稳定海外模型。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24技术支持、自助开票。通过<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">此链接</a>注册的用户,可得免费5元平台体验金!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -80,12 +94,12 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.png" alt="Crazyrouter" width="150"></a></td>
|
||||
<td>感谢 Crazyrouter 赞助了本项目!Crazyrouter 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 300+ 模型,包括 Claude Code、Codex、Gemini CLI 等。全部模型低至官方定价的 55%,支持自动故障转移、智能路由和无限并发。Crazyrouter 为 CC Switch 用户提供了专属优惠:通过<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">此链接</a>注册即可获得 <strong>$2 免费额度</strong>,首次充值时输入优惠码 `CCSWITCH` 还可获得额外 <strong>30% 奖励额度</strong>!</td>
|
||||
<td>感谢 Crazyrouter 赞助了本项目!Crazyrouter 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 300+ 模型,包括 Claude Code、Codex、Gemini CLI 等。全部模型低至官方定价的 55%,支持自动故障转移、智能路由和无限并发。Crazyrouter 为 CC Switch 用户提供了专属优惠:通过<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">此链接</a>注册后联系客服即可领取 <strong>$2 免费额度</strong>,首次充值时输入优惠码 `CCSWITCH` 还可获得额外 <strong>30% 奖励额度</strong>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务。主打<strong>极高性价比</strong>的Codex包月套餐,<strong>提供额度转结,套餐当天用不完的额度,第二天还能接着用!</strong>充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过<a href="https://www.right.codes/register?aff=CCSWITCH">此链接</a>注册,每次充值均可获得实付金额25%的按量额度!</td>
|
||||
<td>感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过<a href="https://www.right.codes/register?aff=CCSWITCH">此链接</a>注册,每次充值均可获得实付金额5%的按量额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -94,8 +108,8 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.openclaudecode.cn/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>感谢 米醋API 赞助了本项目!米醋API 是一家致力于提供极致性价比与高稳定性的全球大模型中转服务商。米醋API 背后有实体企业做核心保障,杜绝跑路风险,支持极速正规开票!我们主打“试错零成本”:1 元起充低门槛,0 手续费随时退款!米醋API 为本软件的用户提供了特别优惠,使用<a href="https://www.openclaudecode.cn/register?aff=aOYQ">此链接</a>注册并在充值时填写"ccswitch"优惠码可享九折优惠!</td>
|
||||
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>感谢 米醋API 赞助了本项目!米醋API 是一家致力于提供极致性价比与高稳定性的全球大模型中转服务商。米醋API 背后有实体企业做核心保障,杜绝跑路风险,支持极速正规开票!我们主打“试错零成本”:1 元起充低门槛,0 手续费随时退款!米醋API 为本软件的用户提供了特别优惠,使用<a href="https://www.micuapi.ai/register?aff=aOYQ">此链接</a>注册并在充值时填写"ccswitch"优惠码可享九折优惠!</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="180"><a href="https://lemondata.cc/r/FFX1ZDUP"><img src="assets/partners/logos/lemondata.png" alt="LemonData" width="150"></a></td>
|
||||
@@ -107,21 +121,31 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
<td>感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://ctok.ai">这里</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://chefshop.ai"><img src="assets/partners/logos/chefshop.png" alt="ChefShop" width="150"></a></td>
|
||||
<td>感谢 厨师长AI小铺 赞助了本项目!厨师长AI小铺 是一家专为 AI 重度订阅用户量身定制的优质账号服务商。平台提供涵盖 ChatGPT Plus/Pro、Claude Max、Grok Super/Heavy 以及 Gemini 等主流大模型的官方代充与稳定成品账号服务。点击<a href="https://chefshop.ai">这里</a>购买!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://vibecodingapi.ai"><img src="assets/partners/logos/lioncc.png" alt="LionCC" width="150"></a></td>
|
||||
<td>感谢 LionCC 狮子API 赞助了本项目!LionCC 专为追求极致开发体验的”Vibe Coders”而生。我们提供稳定、低延迟、优惠价格的 Claude Code、Codex 及 OpenClaw 算力服务,可节约 50% 成本。注册后添加客服微信 HSQBJ088888888,发暗号 cc-switch 备注即可送 10 美金额度(1000 万 token 算力)。其他项目合作关注博客 @LionCC.ai,点击<a href=”https://vibecodingapi.ai”>这里</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/register?aff=pCLD"><img src="assets/partners/logos/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
|
||||
<td>本项目由 <a href="https://console.claudeapi.com/register?aff=pCLD">Claude API</a> 赞助。Claude API 直连,三分钟接入 Claude Code 与 Agent 应用 新用户可领取测试额度。基于 Anthropic 官方 Key + AWS Bedrock 官方渠道,非逆向、非降智,支持 Opus / Sonnet / Haiku 全系列模型,保留 Tool Use、1M 上下文等官方能力。适合 Claude Code 深度用户、Agent 工程师与企业技术团队,支持开票和团队对接。点击<a href="https://console.claudeapi.com/register?aff=pCLD">这里</a>注册!</td>
|
||||
</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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://runapi.co"><img src="assets/partners/logos/runapi.jpg" alt="RunAPI" width="150"></a></td>
|
||||
<td>感谢 RunAPI 赞助本项目!RunAPI 是高效稳定的 AI 模型 API 中转平台,一个 API Key 即可访问 OpenAI、Claude、Gemini、DeepSeek、Grok 等 150+ 主流模型,低至 1 折,极其稳定,可以无缝兼容 Claude Code、OpenClaw 等工具。RunAPI为CC switch的用户提供了特别福利,注册后联系客服可以领取14元额度,点击<a href="https://runapi.co">此链接</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 415 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 511 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 447 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 170 KiB |
@@ -0,0 +1,185 @@
|
||||
# CC Switch v3.14.1
|
||||
|
||||
> Tray usage visibility, Codex OAuth stability fixes, Skills import/install reliability, and removal of the Hermes config health scanner
|
||||
|
||||
**[中文版 →](v3.14.1-zh.md) | [日本語版 →](v3.14.1-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.14.1 is a patch release following v3.14.0, focused on **Codex OAuth reverse-proxy stability**, **tray usage visibility**, **Skills import / install reliability**, **Gemini session restore paths**, and **simplifying Hermes configuration health handling**.
|
||||
|
||||
For the first time, the system tray surfaces **cached usage** for the current Claude / Codex / Gemini provider directly in its submenus — including subscription summaries and usage-script summaries with color-coded utilization markers. For Chinese coding-plan providers like Kimi / Zhipu / MiniMax, the tray additionally renders a **5-hour + weekly window** layout in the `🟢 h12% w80%` style (worst utilization drives the emoji), semantically identical to the official subscription badges. Creating a Claude provider whose `ANTHROPIC_BASE_URL` matches a known coding-plan host now auto-injects `meta.usage_script` so the tray lights up without opening the Usage Script modal.
|
||||
|
||||
Several Codex OAuth reverse-proxy stability issues are addressed this release: client-provided session IDs are now used as both `prompt_cache_key` and the Codex session header to avoid UUID-driven cache churn; non-streaming Anthropic clients receive proper JSON responses even when the ChatGPT Codex upstream forces OpenAI Responses SSE; and Stream Check now builds probes with the same `store: false`, encrypted reasoning include, and provider FAST mode setting as production requests, eliminating the "check fails but it actually works" mismatch. Paired with a new explicit **FAST mode toggle**, users can now opt into `service_tier="priority"` on Codex OAuth-backed Claude providers, trading latency against ChatGPT quota consumption on their own terms.
|
||||
|
||||
Additionally, the in-app **Hermes config health scanner** and its warning banner are removed (along with the `scan_hermes_config_health` command, `HermesHealthWarning` type, and `HermesWriteOutcome.warnings` payload), refocusing the Hermes surface on active provider display, switching defaults, memory editing, and launching the Hermes Web UI — deep configuration health is now Hermes's own responsibility.
|
||||
|
||||
**Release Date**: 2026-04-23
|
||||
|
||||
**Update Scale**: 13 commits | 48 files changed | +1,883 / -808 lines
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Tray Usage Visibility**: Claude / Codex / Gemini tray submenus show cached usage for the current provider, including subscription and script-based summaries with color markers; refreshes are throttled, limited to visible apps, and synchronized back into React Query (#2184, thanks @TuYv)
|
||||
- **Tray Coding-Plan Usage (Kimi / Zhipu / MiniMax)**: The tray renders 5-hour + weekly window usage using the `🟢 h12% w80%` layout; Claude providers whose base URL matches a known host auto-inject `meta.usage_script`
|
||||
- **Codex OAuth FAST Mode**: New explicit FAST mode toggle for Codex OAuth-backed Claude providers; when enabled, converted Responses requests send `service_tier="priority"`. Off by default (#2210, thanks @JesusDR01)
|
||||
- **Codex OAuth Stability**: Fixed reverse-proxy cache routing (#2218, thanks @majiayu000), Responses SSE aggregation (#2235, thanks @xpfo-go), and Stream Check parity with production (#2210, thanks @JesusDR01)
|
||||
- **Hermes Config Health Scanner Removed**: Refocuses the Hermes surface on provider management, memory editing, and launching the Web UI — no longer duplicates deep configuration health judgments
|
||||
- **Skills Import / Install Reliability**: Import dialog disables actions while pending and deduplicates results by ID (#2211, thanks @TuYv); model quick-set / one-click config applies against the latest form state (#2249, thanks @Coconut-Fish); root-level `SKILL.md` repo installs are stable (#2231, thanks @santugege)
|
||||
- **Gemini Session Restore Paths**: Session scanning reads `.project_root` metadata and passes the original project directory back into restore flows (#2240, thanks @tisonkun)
|
||||
- **Session / Settings Layout Polish**: Hardened the scroll-area viewport with width containment to fix horizontal overflow; tightened app bottom and settings footer spacing (#2201, thanks @Coconut-Fish)
|
||||
|
||||
---
|
||||
|
||||
## Added
|
||||
|
||||
### Tray Usage Visibility
|
||||
|
||||
- System tray submenus now show **cached usage** for the current Claude / Codex / Gemini provider (#2184, thanks @TuYv)
|
||||
- Includes subscription quota summaries and usage-script summaries with color-coded utilization markers
|
||||
- Tray-triggered refreshes are **throttled**, **limited to visible apps**, and synchronized back into React Query so the main window and tray share the same usage data
|
||||
|
||||
### Tray Coding-Plan Usage (Kimi / Zhipu / MiniMax)
|
||||
|
||||
- The tray renders **5-hour + weekly window** usage for Chinese coding-plan providers
|
||||
- Uses the same `🟢 h12% w80%` two-window layout as official subscription badges (worst utilization drives the emoji color)
|
||||
- Creating a Claude provider whose `ANTHROPIC_BASE_URL` matches a known coding-plan host **auto-injects** `meta.usage_script`, so the tray lights up without opening the Usage Script modal
|
||||
- Existing `usage_script` values are **preserved on update**, never clobbering user customizations
|
||||
|
||||
### Codex OAuth FAST Mode
|
||||
|
||||
- New explicit FAST mode toggle for Codex OAuth-backed Claude providers (#2210, thanks @JesusDR01)
|
||||
- When enabled, converted Responses requests send `service_tier="priority"` for lower latency
|
||||
- Off by default to avoid unexpectedly increasing ChatGPT quota consumption
|
||||
|
||||
---
|
||||
|
||||
## Changed
|
||||
|
||||
### Session and Settings Layout Polish
|
||||
|
||||
- Hardened the scroll-area viewport with width containment to fix horizontal overflow (#2201, thanks @Coconut-Fish)
|
||||
- Tightened app bottom and settings footer spacing so long session / settings views fit more cleanly
|
||||
|
||||
---
|
||||
|
||||
## Removed
|
||||
|
||||
### Hermes Config Health Scanner
|
||||
|
||||
- Removed the in-app Hermes config health scanner and its warning banner
|
||||
- Removed the `scan_hermes_config_health` command, `HermesHealthWarning` type, and `HermesWriteOutcome.warnings` payload
|
||||
- The CC Switch Hermes surface now focuses on its core job: active provider display, default provider switching, memory editing, and launching the Hermes Web UI for deep configuration
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### Codex OAuth Cache Routing
|
||||
|
||||
- Use the client-provided session ID as both `prompt_cache_key` and the Codex session header, preserving explicit cache keys (#2218, thanks @majiayu000)
|
||||
- Stop generating UUIDs that caused cache-identity churn, stabilizing the ChatGPT Codex reverse-proxy cache identity
|
||||
|
||||
### Codex OAuth Responses SSE Aggregation
|
||||
|
||||
- Non-streaming Anthropic clients now receive proper JSON even when the ChatGPT Codex upstream forces OpenAI Responses SSE (#2235, thanks @xpfo-go)
|
||||
- CC Switch aggregates the upstream SSE events before running the non-streaming transform
|
||||
|
||||
### Codex OAuth Stream Check Parity
|
||||
|
||||
- Stream Check now builds Codex OAuth probe requests with the same `store: false`, encrypted reasoning include, and provider FAST mode setting as production proxy traffic (#2210, thanks @JesusDR01)
|
||||
- Eliminates the "check fails but it actually works" mismatch
|
||||
|
||||
### Codex Model Extraction
|
||||
|
||||
- Reading the `model` field from Codex config now uses TOML parsing instead of first-line regex matching (#2227, thanks @nmsn)
|
||||
- Multiline TOML is handled correctly
|
||||
|
||||
### Model Quick-Set / One-Click Config
|
||||
|
||||
- Model quick-set now applies against the **latest** provider form config (#2249, thanks @Coconut-Fish)
|
||||
- Fixes stale form state preventing one-click configuration from succeeding
|
||||
|
||||
### Skills Import Duplicates
|
||||
|
||||
- The Skills import dialog disables actions while import is pending (#2211, thanks @TuYv)
|
||||
- The installed-skills cache deduplicates imported results by ID, preventing double-clicks from adding duplicate installed entries (#2139)
|
||||
|
||||
### Root-Level Skill Repos
|
||||
|
||||
- Skill install and update flows now consistently resolve three source patterns: direct nested paths, install-name recursive search, and repository-root `SKILL.md` sources (#2231, thanks @santugege)
|
||||
|
||||
### Gemini Session Restore Paths
|
||||
|
||||
- Gemini session scanning now reads `.project_root` metadata (#2240, thanks @tisonkun)
|
||||
- Restore flows can pass the original project directory when available
|
||||
|
||||
### Provider Hover Names
|
||||
|
||||
- Provider icons now expose the provider name on hover for inline SVG, image URL, and fallback initials render paths (#2237, thanks @tisonkun)
|
||||
|
||||
---
|
||||
|
||||
## Notes & Caveats
|
||||
|
||||
- **Hermes Health Scanner Removed**: If you were relying on CC Switch to surface deep Hermes YAML configuration issues, switch to the "Launch Hermes Web UI" toolbar button and inspect them in Hermes's own panel. Day-to-day provider management, switching, memory editing, and MCP / Skills sync continue to be handled by CC Switch.
|
||||
- **Codex OAuth FAST Mode Off by Default**: Only turn it on if you accept potentially increased ChatGPT quota consumption in exchange for lower latency.
|
||||
- **Tray Cached Usage**: Refreshes are throttled and limited to the currently visible app to avoid unnecessary upstream API calls; values are synchronized into React Query so the main window and tray stay in sync.
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
### Windows
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------------------- | ----------------------------------------------------- |
|
||||
| `CC-Switch-v3.14.1-Windows.msi` | **Recommended** - MSI installer, supports auto-update |
|
||||
| `CC-Switch-v3.14.1-Windows-Portable.zip` | Portable, extract and run, no registry writes |
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| -------------------------------- | ------------------------------------------------------- |
|
||||
| `CC-Switch-v3.14.1-macOS.dmg` | **Recommended** - DMG installer, drag into Applications |
|
||||
| `CC-Switch-v3.14.1-macOS.zip` | Extract and drag into Applications, Universal Binary |
|
||||
| `CC-Switch-v3.14.1-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
|
||||
|
||||
| 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,185 @@
|
||||
# CC Switch v3.14.1
|
||||
|
||||
> トレイでの用量可視化、Codex OAuth の複数の安定性修正、Skills インポート/インストールの信頼性向上、Hermes 設定ヘルススキャナーの削除
|
||||
|
||||
**[中文版 →](v3.14.1-zh.md) | [English →](v3.14.1-en.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.14.1 は v3.14.0 に続くパッチリリースで、**Codex OAuth リバースプロキシの安定性**、**トレイでの用量可視化**、**Skills インポート / インストールの信頼性**、**Gemini セッション復元パス**、および **Hermes 設定ヘルス処理の簡素化**を中心に据えています。
|
||||
|
||||
システムトレイは初めて、現在の Claude / Codex / Gemini プロバイダーの**キャッシュ済み用量**をサブメニューに直接表示するようになりました — サブスクリプション要約と用量スクリプト要約を、使用率に応じた色分けマーカーとともに表示します。Kimi / Zhipu / MiniMax のような中国系コーディングプランプロバイダーには、公式サブスクリプションバッジと同じ `🟢 h12% w80%` スタイルで **5 時間 + 週次ウィンドウ**の 2 ウィンドウレイアウトを追加描画します(より厳しい方の使用率が絵文字色を決定)。`ANTHROPIC_BASE_URL` が既知のコーディングプランホストに一致する Claude プロバイダーを作成すると、`meta.usage_script` が自動注入されるため、Usage Script モーダルを開かなくてもトレイが点灯します。
|
||||
|
||||
Codex OAuth 側では、複数のリバースプロキシ安定性の問題を修正しました: クライアント提供の session ID を `prompt_cache_key` と Codex session ヘッダーの両方に使用し、UUID 生成によるキャッシュ揺らぎを回避。ChatGPT Codex 上流が OpenAI Responses SSE を強制する場合でも、非ストリーミングの Anthropic クライアントが適切な JSON レスポンスを受け取れるようになりました。Stream Check は、本番環境と同じ `store: false`、暗号化 reasoning include、およびプロバイダーの FAST モード設定でプローブを構築するようになり、「検出は失敗するのに実際は動く」というズレが解消されました。新しい明示的な **FAST モードトグル**と組み合わせることで、ユーザーは Codex OAuth バックの Claude プロバイダーで `service_tier="priority"` を選択的に送信でき、レイテンシと ChatGPT 配額消費の間で自分で選べるようになりました。
|
||||
|
||||
さらに、CC Switch 内蔵の **Hermes 設定ヘルススキャナー**と警告バナー(および対応する `scan_hermes_config_health` コマンド、`HermesHealthWarning` 型、`HermesWriteOutcome.warnings` ペイロード)を削除し、Hermes サーフェスをアクティブプロバイダー表示、デフォルト切り替え、Memory 編集、および Hermes Web UI の起動に再フォーカスしました — 深い設定ヘルスは Hermes 自身の責任になります。
|
||||
|
||||
**リリース日**: 2026-04-23
|
||||
|
||||
**更新規模**: 13 commits | 48 files changed | +1,883 / -808 lines
|
||||
|
||||
---
|
||||
|
||||
## ハイライト
|
||||
|
||||
- **トレイでの用量可視化**: Claude / Codex / Gemini のトレイサブメニューに、現在のプロバイダーのキャッシュ済み用量(サブスクリプション要約とスクリプト要約、色分けマーカー付き)を表示。リフレッシュはスロットル、可視アプリに限定、React Query に同期 (#2184, 感謝 @TuYv)
|
||||
- **トレイのコーディングプラン用量(Kimi / Zhipu / MiniMax)**: トレイが 5 時間 + 週次ウィンドウの用量を `🟢 h12% w80%` レイアウトで描画。既知のホストにマッチする Claude プロバイダーは `meta.usage_script` を自動注入
|
||||
- **Codex OAuth FAST モード**: Codex OAuth バックの Claude プロバイダーに明示的な FAST モードトグルを追加。有効時は変換された Responses リクエストに `service_tier="priority"` を送信、デフォルトは OFF (#2210, 感謝 @JesusDR01)
|
||||
- **Codex OAuth 安定性**: リバースプロキシのキャッシュルーティング (#2218, 感謝 @majiayu000)、Responses SSE 集約 (#2235, 感謝 @xpfo-go)、Stream Check と本番の一致性 (#2210, 感謝 @JesusDR01) を修正
|
||||
- **Hermes 設定ヘルススキャナー削除**: Hermes サーフェスをプロバイダー管理、Memory 編集、Web UI 起動に再フォーカス。深い設定ヘルス判定を重複して担わなくなる
|
||||
- **Skills インポート / インストールの信頼性**: インポート中はダイアログのアクションを無効化し、結果を ID で重複排除 (#2211, 感謝 @TuYv); ワンクリック設定は最新のフォーム状態に基づいて適用 (#2249, 感謝 @Coconut-Fish); ルートレベルの `SKILL.md` リポジトリインストールが安定 (#2231, 感謝 @santugege)
|
||||
- **Gemini セッション復元パス**: セッションスキャン時に `.project_root` メタデータを読み、元のプロジェクトディレクトリを復元フローに渡す (#2240, 感謝 @tisonkun)
|
||||
- **セッション / 設定レイアウトの磨き込み**: スクロールエリアビューポートに幅制約を追加して横方向のはみ出しを修正。アプリ下部と設定フッター間隔をよりタイトに (#2201, 感謝 @Coconut-Fish)
|
||||
|
||||
---
|
||||
|
||||
## 新機能
|
||||
|
||||
### トレイでの用量可視化
|
||||
|
||||
- システムトレイサブメニューに、現在の Claude / Codex / Gemini プロバイダーの**キャッシュ済み用量**を表示 (#2184, 感謝 @TuYv)
|
||||
- サブスクリプション配額要約と用量スクリプト要約を含み、使用率に応じた色分けマーカー付き
|
||||
- トレイ起因のリフレッシュは**スロットル**、**可視アプリに限定**、React Query に同期されるため、メインウィンドウとトレイが同じ用量データを共有
|
||||
|
||||
### トレイのコーディングプラン用量(Kimi / Zhipu / MiniMax)
|
||||
|
||||
- 中国系コーディングプランプロバイダー向けに、トレイが **5 時間 + 週次ウィンドウ**の用量を描画
|
||||
- 公式サブスクリプションバッジと同じ `🟢 h12% w80%` の 2 ウィンドウレイアウトを使用(より厳しい使用率が絵文字色を決定)
|
||||
- `ANTHROPIC_BASE_URL` が既知のコーディングプランホストにマッチする Claude プロバイダーを作成すると、`meta.usage_script` が**自動注入**され、Usage Script モーダルを開かなくてもトレイが点灯
|
||||
- 更新時は既存の `usage_script` 値を**保持**し、ユーザーカスタマイズを上書きしない
|
||||
|
||||
### Codex OAuth FAST モード
|
||||
|
||||
- Codex OAuth バックの Claude プロバイダーに明示的な FAST モードトグルを追加 (#2210, 感謝 @JesusDR01)
|
||||
- 有効時は変換された Responses リクエストに `service_tier="priority"` を送信してレイテンシを低減
|
||||
- 予期せぬ ChatGPT 配額消費の増加を避けるため、デフォルトは OFF
|
||||
|
||||
---
|
||||
|
||||
## 変更
|
||||
|
||||
### セッション・設定レイアウトの磨き込み
|
||||
|
||||
- スクロールエリアビューポートに幅制約を追加して横方向のはみ出しを修正 (#2201, 感謝 @Coconut-Fish)
|
||||
- アプリ下部と設定フッター間隔をよりタイトにし、長いセッション / 設定ビューをすっきり表示
|
||||
|
||||
---
|
||||
|
||||
## 削除
|
||||
|
||||
### Hermes 設定ヘルススキャナー
|
||||
|
||||
- アプリ内の Hermes 設定ヘルススキャナーと警告バナーを削除
|
||||
- `scan_hermes_config_health` コマンド、`HermesHealthWarning` 型、`HermesWriteOutcome.warnings` ペイロードを削除
|
||||
- CC Switch の Hermes サーフェスは本来の役割に回帰: アクティブプロバイダー表示、デフォルトプロバイダー切り替え、Memory 編集、および深い設定用の Hermes Web UI 起動
|
||||
|
||||
---
|
||||
|
||||
## バグ修正
|
||||
|
||||
### Codex OAuth キャッシュルーティング
|
||||
|
||||
- クライアント提供の session ID を `prompt_cache_key` と Codex session ヘッダーの両方に使用し、明示的なキャッシュキーを保持 (#2218, 感謝 @majiayu000)
|
||||
- キャッシュアイデンティティの揺らぎを引き起こしていた UUID 生成を停止し、ChatGPT Codex リバースプロキシのキャッシュアイデンティティを安定化
|
||||
|
||||
### Codex OAuth Responses SSE 集約
|
||||
|
||||
- ChatGPT Codex 上流が OpenAI Responses SSE を強制する場合でも、非ストリーミングの Anthropic クライアントが適切な JSON を受け取れるように修正 (#2235, 感謝 @xpfo-go)
|
||||
- CC Switch が非ストリーミング変換を実行する前に上流 SSE イベントを集約
|
||||
|
||||
### Codex OAuth Stream Check の一致性
|
||||
|
||||
- Stream Check が構築する Codex OAuth プローブリクエストは、本番プロキシと同じ `store: false`、暗号化 reasoning include、プロバイダー FAST モード設定を使用するように修正 (#2210, 感謝 @JesusDR01)
|
||||
- 「検出は失敗するのに実際は動く」ズレを解消
|
||||
|
||||
### Codex モデル抽出
|
||||
|
||||
- Codex 設定の `model` フィールドを読む際、先頭行の正規表現マッチではなく TOML パーサーを使用するように変更 (#2227, 感謝 @nmsn)
|
||||
- 複数行 TOML も正しく処理
|
||||
|
||||
### モデルのクイック入力 / ワンクリック設定
|
||||
|
||||
- モデルクイック入力は**最新の**プロバイダーフォーム設定に対して適用されるように修正 (#2249, 感謝 @Coconut-Fish)
|
||||
- 古いフォーム状態によってワンクリック設定が失敗する問題を修正
|
||||
|
||||
### Skills インポートの重複排除
|
||||
|
||||
- Skills インポートダイアログは、インポート中にすべてのアクションボタンを無効化 (#2211, 感謝 @TuYv)
|
||||
- インストール済み Skills のキャッシュを ID で重複排除し、ダブルクリックによる重複したインストール済みエントリを防止 (#2139)
|
||||
|
||||
### ルートレベルの Skill リポジトリ
|
||||
|
||||
- Skill のインストールと更新フローが 3 つのソースパターンを一貫して解決: 直接ネストパス、install-name の再帰検索、およびリポジトリルートの `SKILL.md` ソース (#2231, 感謝 @santugege)
|
||||
|
||||
### Gemini セッション復元パス
|
||||
|
||||
- Gemini セッションスキャンが `.project_root` メタデータを読み取るように修正 (#2240, 感謝 @tisonkun)
|
||||
- 復元フローは利用可能な場合に元のプロジェクトディレクトリを渡せる
|
||||
|
||||
### プロバイダー名のホバー表示
|
||||
|
||||
- プロバイダーアイコンは、inline SVG、画像 URL、およびフォールバックの頭文字レンダリングパスで、ホバー時にプロバイダー名を表示 (#2237, 感謝 @tisonkun)
|
||||
|
||||
---
|
||||
|
||||
## 備考・注意事項
|
||||
|
||||
- **Hermes ヘルススキャナー削除済み**: Hermes YAML の深い設定の問題提示を CC Switch に頼っていた場合は、ツールバーの「Hermes Web UI を起動」ボタンから Hermes 自身のパネルで確認してください。日常のプロバイダー管理、切り替え、Memory 編集、MCP / Skills 同期は引き続き CC Switch が担います。
|
||||
- **Codex OAuth FAST モードはデフォルト OFF**: レイテンシ低減と引き換えに ChatGPT 配額消費が増える可能性を許容する場合にのみ有効化してください。
|
||||
- **トレイのキャッシュ用量**: リフレッシュはスロットル済み、かつ現在可視のアプリに限定されており、不要な上流 API 呼び出しを回避します。値は React Query に同期されるため、メインウィンドウとトレイで同じ値が見えます。
|
||||
|
||||
---
|
||||
|
||||
## ダウンロード・インストール
|
||||
|
||||
[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 |
|
||||
|
||||
### Windows
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ---------------------------------------- | ------------------------------------------- |
|
||||
| `CC-Switch-v3.14.1-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
|
||||
| `CC-Switch-v3.14.1-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ不要 |
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| -------------------------------- | ------------------------------------------------------ |
|
||||
| `CC-Switch-v3.14.1-macOS.dmg` | **推奨** - DMG インストーラー、Applications にドラッグ |
|
||||
| `CC-Switch-v3.14.1-macOS.zip` | 解凍して Applications にドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.14.1-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||
|
||||
> macOS 版は Apple のコード署名および公証済みで、直接インストールして使用できます。
|
||||
|
||||
### Homebrew(macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||
| --------------------------------------- | ----------- | -------------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して実行、または AUR を使用 |
|
||||
| その他のディストリビューション / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,185 @@
|
||||
# CC Switch v3.14.1
|
||||
|
||||
> 托盘用量可见化、Codex OAuth 多项稳定性修复、Skills 导入/安装可靠性提升、Hermes 配置健康扫描器移除
|
||||
|
||||
**[English →](v3.14.1-en.md) | [日本語版 →](v3.14.1-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.14.1 是 v3.14.0 之后的一次补丁版本,围绕 **Codex OAuth 反代稳定性**、**托盘用量可见化**、**Skills 导入 / 安装可靠性**、**Gemini 会话恢复路径**,以及**简化 Hermes 配置健康处理**展开。
|
||||
|
||||
系统托盘第一次把当前 Claude / Codex / Gemini 供应商的**缓存用量**直接呈现在子菜单里——包含订阅额度摘要和用量脚本摘要,并用颜色标记利用率;针对 Kimi / 智谱 / MiniMax 这类中国编码套餐供应商,托盘还会额外渲染 `🟢 h12% w80%` 风格的 **5 小时 + 周窗口**双窗口排版,语义与官方订阅徽章完全一致(取更紧的那个驱动 emoji)。创建 Claude 供应商时,如果 `ANTHROPIC_BASE_URL` 命中已知的编码套餐 host,会自动注入 `meta.usage_script`,托盘可以不打开 Usage Script 模态框就直接点亮。
|
||||
|
||||
Codex OAuth 侧修复了多项反代稳定性问题:使用客户端自带的 session ID 作为 `prompt_cache_key` 和 Codex session 头,避免生成 UUID 造成缓存抖动,显著提高缓存命中率;非流式 Anthropic 客户端在 ChatGPT Codex 上游强制 OpenAI Responses SSE 时也能正确拿到 JSON 响应;Stream Check 现在会以和生产一致的 `store: false`、encrypted reasoning include 以及供应商 FAST 模式构造探测请求,避免出现"检测失败但实际能用"的错位。配合新增的 **FAST 模式显式开关**,让用户可以在 Codex OAuth 型 Claude 供应商上按需发 `service_tier="priority"`,在延迟和 ChatGPT 配额消耗之间自己选。
|
||||
|
||||
另外,移除了 CC Switch 内置的 **Hermes 配置健康扫描器**及其警告横幅(以及对应的 `scan_hermes_config_health` 命令、`HermesHealthWarning` 类型和 `HermesWriteOutcome.warnings` 载荷),把 Hermes 面板聚焦回当前供应商展示、默认切换、Memory 编辑和启动 Hermes Web UI,深度配置健康度由 Hermes 自己负责。
|
||||
|
||||
**发布日期**:2026-04-23
|
||||
|
||||
**更新规模**:13 commits | 48 files changed | +1,883 / -808 lines
|
||||
|
||||
---
|
||||
|
||||
## 重点内容
|
||||
|
||||
- **托盘用量可见化**:Claude / Codex / Gemini 托盘子菜单展示当前供应商缓存用量,含订阅与脚本摘要及颜色标记;刷新带节流、仅针对可见应用、并回写到 React Query (#2184, 感谢 @TuYv)
|
||||
- **托盘编码套餐用量(Kimi / 智谱 / MiniMax)**:托盘渲染 5 小时 + 周窗口双窗口用量,沿用 `🟢 h12% w80%` 排版;命中已知 host 的 Claude 供应商自动注入 `meta.usage_script`
|
||||
- **Codex OAuth FAST 模式**:为 Codex OAuth 型 Claude 供应商新增显式 FAST 开关,开启后转换后的 Responses 请求发 `service_tier="priority"`,默认关闭 (#2210, 感谢 @JesusDR01)
|
||||
- **Codex OAuth 稳定性**:修复反代缓存路由 (#2218, 感谢 @majiayu000)、Responses SSE 聚合 (#2235, 感谢 @xpfo-go)、Stream Check 与生产一致性 (#2210, 感谢 @JesusDR01)
|
||||
- **Hermes 配置健康扫描器移除**:把 Hermes 面板聚焦回供应商管理、Memory 编辑和 Web UI 启动,不再重复承担深度配置健康判断
|
||||
- **Skills 导入 / 安装可靠性**:导入过程中禁用操作按钮、结果按 ID 去重 (#2211, 感谢 @TuYv);一键配置基于最新表单状态 (#2249, 感谢 @Coconut-Fish);根级 `SKILL.md` 仓库安装稳定 (#2231, 感谢 @santugege)
|
||||
- **Gemini 会话恢复路径**:扫描会话时读取 `.project_root` 元数据,把原始项目目录带回恢复流程 (#2240, 感谢 @tisonkun)
|
||||
- **Session / 设置布局打磨**:滚动区域视口加宽度约束修复横向溢出,应用底部和设置页底部间距更紧凑 (#2201, 感谢 @Coconut-Fish)
|
||||
|
||||
---
|
||||
|
||||
## 新功能
|
||||
|
||||
### 托盘用量可见化
|
||||
|
||||
- 系统托盘子菜单新增当前 Claude / Codex / Gemini 供应商的**缓存用量**展示 (#2184, 感谢 @TuYv)
|
||||
- 包含订阅额度摘要和用量脚本摘要,并用颜色标记利用率
|
||||
- 托盘触发的刷新**带节流**、**只覆盖可见应用**,并同步回 React Query,主窗口和托盘共享同一份用量数据
|
||||
|
||||
### 托盘编码套餐用量(Kimi / 智谱 / MiniMax)
|
||||
|
||||
- 托盘为中国编码套餐供应商渲染 **5 小时 + 周窗口**双窗口用量
|
||||
- 使用与官方订阅徽章一致的 `🟢 h12% w80%` 两窗口排版,取更紧的那个利用率驱动 emoji 颜色
|
||||
- 创建 Claude 供应商时,如果 `ANTHROPIC_BASE_URL` 匹配已知编码套餐 host,会**自动注入** `meta.usage_script`,托盘不打开 Usage Script 模态框也能直接点亮
|
||||
- 更新时会**保留已有** `usage_script` 值,不覆盖用户自定义
|
||||
|
||||
### Codex OAuth FAST 模式
|
||||
|
||||
- 为 Codex OAuth 型 Claude 供应商新增显式 FAST 模式开关 (#2210, 感谢 @JesusDR01)
|
||||
- 开启时,转换后的 Responses 请求会发 `service_tier="priority"` 以降低延迟
|
||||
- 默认关闭,避免意外增加 ChatGPT 配额消耗
|
||||
|
||||
---
|
||||
|
||||
## 变更
|
||||
|
||||
### Session 与设置布局打磨
|
||||
|
||||
- 滚动区域视口加上宽度约束,修复横向溢出 (#2201, 感谢 @Coconut-Fish)
|
||||
- 应用底部和设置页底部间距更紧凑,让长 Session / 设置视图看起来更干净
|
||||
|
||||
---
|
||||
|
||||
## 移除
|
||||
|
||||
### Hermes 配置健康扫描器
|
||||
|
||||
- 移除应用内的 Hermes 配置健康扫描器和警告横幅
|
||||
- 移除 `scan_hermes_config_health` 命令、`HermesHealthWarning` 类型以及 `HermesWriteOutcome.warnings` 载荷
|
||||
- CC Switch 的 Hermes 面板回归核心职责:当前供应商展示、切换默认供应商、Memory 编辑、以及启动 Hermes Web UI 处理深度配置
|
||||
|
||||
---
|
||||
|
||||
## 修复
|
||||
|
||||
### Codex OAuth 缓存路由
|
||||
|
||||
- 使用客户端自带的 session ID 作为 `prompt_cache_key` 和 Codex session 头,保留显式缓存 key (#2218, 感谢 @majiayu000)
|
||||
- 停止生成 UUID 导致的缓存抖动,让 ChatGPT Codex 反代的缓存身份更稳定
|
||||
|
||||
### Codex OAuth Responses SSE 聚合
|
||||
|
||||
- ChatGPT Codex 上游强制 OpenAI Responses SSE 时,非流式 Anthropic 客户端也能正确拿到 JSON (#2235, 感谢 @xpfo-go)
|
||||
- CC Switch 会在非流式转换之前先聚合上游 SSE 事件
|
||||
|
||||
### Codex OAuth Stream Check 对齐
|
||||
|
||||
- Stream Check 构造的 Codex OAuth 测试请求现在与生产代理一致,使用相同的 `store: false`、加密 reasoning include 和供应商 FAST 模式设置 (#2210, 感谢 @JesusDR01)
|
||||
- 避免"检测失败但实际能用"的错位
|
||||
|
||||
### Codex 模型提取
|
||||
|
||||
- 读取 Codex 配置的 `model` 字段时,改用 TOML 解析替代首行正则匹配 (#2227, 感谢 @nmsn)
|
||||
- 多行 TOML 也能正确处理
|
||||
|
||||
### 模型快速填入 / 一键配置
|
||||
|
||||
- 模型快速填入现在基于**最新的**供应商表单配置应用 (#2249, 感谢 @Coconut-Fish)
|
||||
- 修复陈旧表单状态导致一键配置失败的问题
|
||||
|
||||
### Skills 导入去重
|
||||
|
||||
- Skills 导入对话框在导入进行时禁用所有操作按钮 (#2211, 感谢 @TuYv)
|
||||
- 已安装 Skills 的缓存按 ID 去重,避免双击造成重复的已安装条目 (#2139)
|
||||
|
||||
### 根级 Skill 仓库
|
||||
|
||||
- Skill 的安装与更新流程现在能一致地识别三种源路径:直接嵌套路径、按 install-name 递归搜索、以及仓库根的 `SKILL.md` 源 (#2231, 感谢 @santugege)
|
||||
|
||||
### Gemini 会话恢复路径
|
||||
|
||||
- Gemini 会话扫描时读取 `.project_root` 元数据 (#2240, 感谢 @tisonkun)
|
||||
- 恢复流程可以在可用时把原始项目目录传回
|
||||
|
||||
### 供应商名悬浮提示
|
||||
|
||||
- 供应商图标在 inline SVG、图像 URL、以及首字母回退渲染路径下都会在 hover 时展示供应商名称 (#2237, 感谢 @tisonkun)
|
||||
|
||||
---
|
||||
|
||||
## 说明与注意事项
|
||||
|
||||
- **Hermes 健康扫描器已移除**:如果你依赖 CC Switch 提示 Hermes YAML 的深度配置问题,请改为通过工具栏的"启动 Hermes Web UI"按钮在 Hermes 原生面板里查看。日常供应商管理、切换、Memory 编辑、MCP 与 Skills 同步仍然由 CC Switch 负责。
|
||||
- **Codex OAuth FAST 模式默认关闭**:只有在你接受可能增加 ChatGPT 配额消耗换取更低延迟时,才需要打开。
|
||||
- **托盘缓存用量**:刷新带节流,只覆盖当前显示的应用,避免无必要的上游 API 调用;数据会回写到 React Query,因此主窗口和托盘看到的值一致。
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | -------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------------- | ----------------------------------- |
|
||||
| `CC-Switch-v3.14.1-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.14.1-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| -------------------------------- | --------------------------------------------- |
|
||||
| `CC-Switch-v3.14.1-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
|
||||
| `CC-Switch-v3.14.1-macOS.zip` | 解压后拖入 Applications,Universal Binary |
|
||||
| `CC-Switch-v3.14.1-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
> macOS 版本已通过 Apple 代码签名和公证,可直接安装使用。
|
||||
|
||||
### Homebrew(macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| 发行版 | 推荐格式 | 安装方式 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
|
||||
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -509,7 +509,7 @@ When editing Claude providers, a set of **quick toggles** is available above the
|
||||
| **Hide Attribution** | Clears commit/PR attribution metadata | Sets `attribution: {commit: "", pr: ""}` |
|
||||
| **Enable Teammates** | Enables the agent teams feature | Sets `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` |
|
||||
| **Enable Tool Search** | Enables tool search functionality | Sets `env.ENABLE_TOOL_SEARCH = "true"` |
|
||||
| **Max Effort** | Sets effort level to max | Sets `effortLevel = "max"` |
|
||||
| **Max Effort** | Sets effort level to max | Sets `env.CLAUDE_CODE_EFFORT_LEVEL = "max"` |
|
||||
| **Disable Auto Upgrade** | Prevents Claude Code auto-updates | Sets `env.DISABLE_AUTOUPDATER = "1"` |
|
||||
|
||||
When a toggle is unchecked, its corresponding config entry is removed entirely. Changes are reflected in the JSON editor in real-time.
|
||||
|
||||
@@ -277,15 +277,16 @@ CC Switch includes preset official prices for common models (per million tokens)
|
||||
**Chinese Provider Models**:
|
||||
|
||||
> Note: Currency follows each provider's official pricing page. StepFun is currently listed in USD.
|
||||
>
|
||||
> **DeepSeek compatibility**: Legacy model IDs `deepseek-chat` / `deepseek-reasoner` now alias to `deepseek-v4-flash` (non-thinking / thinking modes) and are billed at v4-flash rates.
|
||||
|
||||
| Model | Input | Output | Cache Read |
|
||||
|-------|-------|--------|------------|
|
||||
| **StepFun** | | | |
|
||||
| step-3.5-flash | $0.10 | $0.30 | $0.02 |
|
||||
| **DeepSeek** | | | |
|
||||
| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |
|
||||
| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |
|
||||
| deepseek-v3 | ¥2.00 | ¥8.00 | ¥0.40 |
|
||||
| deepseek-v4-flash | ¥1.00 | ¥2.00 | ¥0.20 |
|
||||
| deepseek-v4-pro | ¥12.00 | ¥24.00 | ¥1.00 |
|
||||
| **Kimi (Moonshot)** | | | |
|
||||
| kimi-k2-thinking | ¥4.00 | ¥16.00 | ¥1.00 |
|
||||
| kimi-k2 | ¥4.00 | ¥16.00 | ¥1.00 |
|
||||
|
||||
@@ -509,7 +509,7 @@ Claude プロバイダーの編集時、JSON エディタの上部に **クイ
|
||||
| **帰属情報を非表示** | コミット/PR の帰属メタデータをクリア | `attribution: {commit: "", pr: ""}` を設定 |
|
||||
| **チームメイトを有効化** | エージェントチーム機能を有効化 | `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` を設定 |
|
||||
| **ツール検索を有効化** | ツール検索機能を有効化 | `env.ENABLE_TOOL_SEARCH = "true"` を設定 |
|
||||
| **最大強度思考** | エフォートレベルを max に設定 | `effortLevel = "max"` を設定 |
|
||||
| **最大強度思考** | エフォートレベルを max に設定 | `env.CLAUDE_CODE_EFFORT_LEVEL = "max"` を設定 |
|
||||
| **自動アップグレードを無効化** | Claude Code の自動更新を防止 | `env.DISABLE_AUTOUPDATER = "1"` を設定 |
|
||||
|
||||
トグルのチェックを外すと、対応する設定エントリが完全に削除されます。変更は JSON エディタにリアルタイムで反映されます。
|
||||
|
||||
@@ -277,15 +277,16 @@ CC Switch は一般的なモデルの公式価格(100 万 Token あたり)
|
||||
**中国メーカーのモデル**:
|
||||
|
||||
> 注: 通貨は各プロバイダーの公式料金ページに従います。StepFun は現在 USD 表記です。
|
||||
>
|
||||
> **DeepSeek 互換**: 旧モデル名 `deepseek-chat` / `deepseek-reasoner` は `deepseek-v4-flash`(非思考/思考モード)と等価になり、v4-flash 料金で課金されます。
|
||||
|
||||
| モデル | 入力 | 出力 | キャッシュ読取 |
|
||||
|------|------|------|----------|
|
||||
| **StepFun** | | | |
|
||||
| step-3.5-flash | $0.10 | $0.30 | $0.02 |
|
||||
| **DeepSeek** | | | |
|
||||
| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |
|
||||
| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |
|
||||
| deepseek-v3 | ¥2.00 | ¥8.00 | ¥0.40 |
|
||||
| deepseek-v4-flash | ¥1.00 | ¥2.00 | ¥0.20 |
|
||||
| deepseek-v4-pro | ¥12.00 | ¥24.00 | ¥1.00 |
|
||||
| **Kimi (月之暗面)** | | | |
|
||||
| kimi-k2-thinking | ¥4.00 | ¥16.00 | ¥1.00 |
|
||||
| kimi-k2 | ¥4.00 | ¥16.00 | ¥1.00 |
|
||||
|
||||
@@ -509,7 +509,7 @@ v3.13.0 起新增的高级选项。默认情况下,CC Switch 会把配置的 `
|
||||
| **隐藏署名** | 清除提交/PR 的署名元数据 | 设置 `attribution: {commit: "", pr: ""}` |
|
||||
| **启用 Teammates** | 启用 Agent 团队功能 | 设置 `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"` |
|
||||
| **启用工具搜索** | 启用工具搜索功能 | 设置 `env.ENABLE_TOOL_SEARCH = "true"` |
|
||||
| **最大强度思考** | 将 effort 级别设为 max | 设置 `effortLevel = "max"` |
|
||||
| **最大强度思考** | 将 effort 级别设为 max | 设置 `env.CLAUDE_CODE_EFFORT_LEVEL = "max"` |
|
||||
| **禁用自动更新** | 阻止 Claude Code 自动更新 | 设置 `env.DISABLE_AUTOUPDATER = "1"` |
|
||||
|
||||
取消勾选开关时,对应的配置项会被完全移除。更改会实时反映在 JSON 编辑器中。
|
||||
|
||||
@@ -277,15 +277,16 @@ CC Switch 预设了常用模型的官方价格(每百万 Token)。v3.13.0
|
||||
**中国厂商模型**:
|
||||
|
||||
> 注:币种遵循各供应商官方定价页面。StepFun 当前按美元列出。
|
||||
>
|
||||
> **DeepSeek 兼容**:旧模型名 `deepseek-chat` / `deepseek-reasoner` 现等价于 `deepseek-v4-flash`(非思考/思考模式),按 v4-flash 价格计费。
|
||||
|
||||
| 模型 | 输入 | 输出 | 缓存读取 |
|
||||
|------|------|------|----------|
|
||||
| **StepFun** | | | |
|
||||
| step-3.5-flash | $0.10 | $0.30 | $0.02 |
|
||||
| **DeepSeek** | | | |
|
||||
| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |
|
||||
| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |
|
||||
| deepseek-v3 | ¥2.00 | ¥8.00 | ¥0.40 |
|
||||
| deepseek-v4-flash | ¥1.00 | ¥2.00 | ¥0.20 |
|
||||
| deepseek-v4-pro | ¥12.00 | ¥24.00 | ¥1.00 |
|
||||
| **Kimi (月之暗面)** | | | |
|
||||
| kimi-k2-thinking | ¥4.00 | ¥16.00 | ¥1.00 |
|
||||
| kimi-k2 | ¥4.00 | ¥16.00 | ¥1.00 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.14.0",
|
||||
"version": "3.14.1",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -735,7 +735,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.14.0"
|
||||
version = "3.14.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -785,6 +785,7 @@ dependencies = [
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
@@ -5687,6 +5688,21 @@ dependencies = [
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-window-state"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.10.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.14.0"
|
||||
version = "3.14.1"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
@@ -35,6 +35,7 @@ tauri-plugin-updater = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
|
||||
@@ -29,6 +29,7 @@ impl McpApps {
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
|
||||
AppType::Hermes => self.hermes,
|
||||
AppType::ClaudeDesktop => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +42,7 @@ impl McpApps {
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
|
||||
AppType::Hermes => self.hermes = enabled,
|
||||
AppType::ClaudeDesktop => {} // Claude Desktop 3P provider config doesn't support MCP here
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +98,7 @@ impl SkillApps {
|
||||
AppType::OpenCode => self.opencode,
|
||||
AppType::Hermes => self.hermes,
|
||||
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
|
||||
AppType::ClaudeDesktop => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +111,7 @@ impl SkillApps {
|
||||
AppType::OpenCode => self.opencode = enabled,
|
||||
AppType::Hermes => self.hermes = enabled,
|
||||
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
|
||||
AppType::ClaudeDesktop => {} // Claude Desktop 3P profiles don't use CC Switch skill sync
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +259,14 @@ pub struct McpRoot {
|
||||
/// 旧的分应用存储(v3.6.x 及以前,保留用于迁移)
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub claude: McpConfig,
|
||||
#[serde(
|
||||
rename = "claude-desktop",
|
||||
alias = "claudeDesktop",
|
||||
alias = "claude_desktop",
|
||||
default,
|
||||
skip_serializing_if = "McpConfig::is_empty"
|
||||
)]
|
||||
pub claude_desktop: McpConfig,
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
pub codex: McpConfig,
|
||||
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
|
||||
@@ -277,6 +289,7 @@ impl Default for McpRoot {
|
||||
servers: Some(HashMap::new()),
|
||||
// 旧结构保持空,仅用于反序列化旧配置时的迁移
|
||||
claude: McpConfig::default(),
|
||||
claude_desktop: McpConfig::default(),
|
||||
codex: McpConfig::default(),
|
||||
gemini: McpConfig::default(),
|
||||
opencode: McpConfig::default(),
|
||||
@@ -298,6 +311,13 @@ pub struct PromptConfig {
|
||||
pub struct PromptRoot {
|
||||
#[serde(default)]
|
||||
pub claude: PromptConfig,
|
||||
#[serde(
|
||||
rename = "claude-desktop",
|
||||
alias = "claudeDesktop",
|
||||
alias = "claude_desktop",
|
||||
default
|
||||
)]
|
||||
pub claude_desktop: PromptConfig,
|
||||
#[serde(default)]
|
||||
pub codex: PromptConfig,
|
||||
#[serde(default)]
|
||||
@@ -316,10 +336,16 @@ use crate::prompt_files::prompt_file_path;
|
||||
use crate::provider::ProviderManager;
|
||||
|
||||
/// 应用类型
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AppType {
|
||||
Claude,
|
||||
#[serde(
|
||||
rename = "claude-desktop",
|
||||
alias = "claude_desktop",
|
||||
alias = "claudeDesktop"
|
||||
)]
|
||||
ClaudeDesktop,
|
||||
Codex,
|
||||
Gemini,
|
||||
OpenCode,
|
||||
@@ -331,6 +357,7 @@ impl AppType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
AppType::Claude => "claude",
|
||||
AppType::ClaudeDesktop => "claude-desktop",
|
||||
AppType::Codex => "codex",
|
||||
AppType::Gemini => "gemini",
|
||||
AppType::OpenCode => "opencode",
|
||||
@@ -354,6 +381,7 @@ impl AppType {
|
||||
pub fn all() -> impl Iterator<Item = AppType> {
|
||||
[
|
||||
AppType::Claude,
|
||||
AppType::ClaudeDesktop,
|
||||
AppType::Codex,
|
||||
AppType::Gemini,
|
||||
AppType::OpenCode,
|
||||
@@ -371,6 +399,7 @@ impl FromStr for AppType {
|
||||
let normalized = s.trim().to_lowercase();
|
||||
match normalized.as_str() {
|
||||
"claude" => Ok(AppType::Claude),
|
||||
"claude-desktop" | "claude_desktop" | "claudedesktop" => Ok(AppType::ClaudeDesktop),
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
@@ -378,8 +407,8 @@ impl FromStr for AppType {
|
||||
"hermes" => Ok(AppType::Hermes),
|
||||
other => Err(AppError::localized(
|
||||
"unsupported_app",
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw, hermes。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw, hermes."),
|
||||
format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes。"),
|
||||
format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, opencode, openclaw, hermes."),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -412,6 +441,7 @@ impl CommonConfigSnippets {
|
||||
pub fn get(&self, app: &AppType) -> Option<&String> {
|
||||
match app {
|
||||
AppType::Claude => self.claude.as_ref(),
|
||||
AppType::ClaudeDesktop => None,
|
||||
AppType::Codex => self.codex.as_ref(),
|
||||
AppType::Gemini => self.gemini.as_ref(),
|
||||
AppType::OpenCode => self.opencode.as_ref(),
|
||||
@@ -424,6 +454,7 @@ impl CommonConfigSnippets {
|
||||
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
|
||||
match app {
|
||||
AppType::Claude => self.claude = snippet,
|
||||
AppType::ClaudeDesktop => {}
|
||||
AppType::Codex => self.codex = snippet,
|
||||
AppType::Gemini => self.gemini = snippet,
|
||||
AppType::OpenCode => self.opencode = snippet,
|
||||
@@ -466,6 +497,7 @@ impl Default for MultiAppConfig {
|
||||
fn default() -> Self {
|
||||
let mut apps = HashMap::new();
|
||||
apps.insert("claude".to_string(), ProviderManager::default());
|
||||
apps.insert("claude-desktop".to_string(), ProviderManager::default());
|
||||
apps.insert("codex".to_string(), ProviderManager::default());
|
||||
apps.insert("gemini".to_string(), ProviderManager::default());
|
||||
apps.insert("opencode".to_string(), ProviderManager::default());
|
||||
@@ -627,6 +659,7 @@ impl MultiAppConfig {
|
||||
pub fn mcp_for(&self, app: &AppType) -> &McpConfig {
|
||||
match app {
|
||||
AppType::Claude => &self.mcp.claude,
|
||||
AppType::ClaudeDesktop => &self.mcp.claude_desktop,
|
||||
AppType::Codex => &self.mcp.codex,
|
||||
AppType::Gemini => &self.mcp.gemini,
|
||||
AppType::OpenCode => &self.mcp.opencode,
|
||||
@@ -639,6 +672,7 @@ impl MultiAppConfig {
|
||||
pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig {
|
||||
match app {
|
||||
AppType::Claude => &mut self.mcp.claude,
|
||||
AppType::ClaudeDesktop => &mut self.mcp.claude_desktop,
|
||||
AppType::Codex => &mut self.mcp.codex,
|
||||
AppType::Gemini => &mut self.mcp.gemini,
|
||||
AppType::OpenCode => &mut self.mcp.opencode,
|
||||
@@ -677,6 +711,7 @@ impl MultiAppConfig {
|
||||
fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result<bool, AppError> {
|
||||
// 如果任一应用已经有提示词配置,说明用户已经在使用 Prompt 功能,避免再次自动导入
|
||||
if !self.prompts.claude.prompts.is_empty()
|
||||
|| !self.prompts.claude_desktop.prompts.is_empty()
|
||||
|| !self.prompts.codex.prompts.is_empty()
|
||||
|| !self.prompts.gemini.prompts.is_empty()
|
||||
|| !self.prompts.opencode.prompts.is_empty()
|
||||
@@ -763,6 +798,7 @@ impl MultiAppConfig {
|
||||
// 插入到对应的应用配置中
|
||||
let prompts = match app {
|
||||
AppType::Claude => &mut config.prompts.claude.prompts,
|
||||
AppType::ClaudeDesktop => &mut config.prompts.claude_desktop.prompts,
|
||||
AppType::Codex => &mut config.prompts.codex.prompts,
|
||||
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||||
AppType::OpenCode => &mut config.prompts.opencode.prompts,
|
||||
@@ -804,6 +840,7 @@ impl MultiAppConfig {
|
||||
] {
|
||||
let old_servers = match app {
|
||||
AppType::Claude => &self.mcp.claude.servers,
|
||||
AppType::ClaudeDesktop => continue, // Claude Desktop 3P profiles don't use MCP here
|
||||
AppType::Codex => &self.mcp.codex.servers,
|
||||
AppType::Gemini => &self.mcp.gemini.servers,
|
||||
AppType::OpenCode => &self.mcp.opencode.servers,
|
||||
@@ -924,6 +961,23 @@ mod tests {
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn app_type_parses_claude_desktop_aliases() {
|
||||
assert_eq!(
|
||||
"claude-desktop".parse::<AppType>().unwrap(),
|
||||
AppType::ClaudeDesktop
|
||||
);
|
||||
assert_eq!(
|
||||
"claude_desktop".parse::<AppType>().unwrap(),
|
||||
AppType::ClaudeDesktop
|
||||
);
|
||||
assert_eq!(
|
||||
"claudeDesktop".parse::<AppType>().unwrap(),
|
||||
AppType::ClaudeDesktop
|
||||
);
|
||||
assert_eq!(AppType::ClaudeDesktop.as_str(), "claude-desktop");
|
||||
}
|
||||
|
||||
struct TempHome {
|
||||
#[allow(dead_code)] // 字段通过 Drop trait 管理临时目录生命周期
|
||||
dir: TempDir,
|
||||
|
||||
@@ -11,6 +11,20 @@ use std::fs;
|
||||
use std::path::Path;
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch";
|
||||
|
||||
/// Reserved built-in provider IDs from OpenAI Codex's config/model-provider
|
||||
/// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy
|
||||
/// removed provider aliases.
|
||||
const CODEX_RESERVED_MODEL_PROVIDER_IDS: &[&str] = &[
|
||||
"amazon-bedrock",
|
||||
"openai",
|
||||
"ollama",
|
||||
"lmstudio",
|
||||
"oss",
|
||||
"ollama-chat",
|
||||
];
|
||||
|
||||
/// 获取 Codex 配置目录路径
|
||||
pub fn get_codex_config_dir() -> PathBuf {
|
||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
||||
@@ -137,6 +151,268 @@ pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn active_codex_model_provider_id(doc: &DocumentMut) -> Option<String> {
|
||||
doc.get("model_provider")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn is_custom_codex_model_provider_id(id: &str) -> bool {
|
||||
let id = id.trim();
|
||||
!id.is_empty()
|
||||
&& !CODEX_RESERVED_MODEL_PROVIDER_IDS
|
||||
.iter()
|
||||
.any(|reserved| reserved.eq_ignore_ascii_case(id))
|
||||
}
|
||||
|
||||
fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option<String> {
|
||||
let doc = config_text.parse::<DocumentMut>().ok()?;
|
||||
let provider_id = active_codex_model_provider_id(&doc)?;
|
||||
|
||||
if is_custom_codex_model_provider_id(&provider_id) {
|
||||
Some(provider_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_model_provider_id_with_table_from_config(
|
||||
config_text: &str,
|
||||
) -> Result<Option<String>, AppError> {
|
||||
if config_text.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let doc = config_text
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
|
||||
let Some(provider_id) = active_codex_model_provider_id(&doc) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let has_provider_table = doc
|
||||
.get("model_providers")
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| table.get(provider_id.as_str()))
|
||||
.is_some();
|
||||
|
||||
Ok(has_provider_table.then_some(provider_id))
|
||||
}
|
||||
|
||||
fn normalize_codex_live_config_model_provider_with_anchors<'a>(
|
||||
config_text: &str,
|
||||
anchor_config_texts: impl IntoIterator<Item = &'a str>,
|
||||
) -> Result<String, AppError> {
|
||||
if config_text.trim().is_empty() {
|
||||
return Ok(config_text.to_string());
|
||||
}
|
||||
|
||||
let mut doc = config_text
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
|
||||
|
||||
let Some(source_provider_id) = active_codex_model_provider_id(&doc) else {
|
||||
return Ok(config_text.to_string());
|
||||
};
|
||||
|
||||
let has_source_provider_table = doc
|
||||
.get("model_providers")
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| table.get(source_provider_id.as_str()))
|
||||
.is_some();
|
||||
if !has_source_provider_table {
|
||||
return Ok(config_text.to_string());
|
||||
}
|
||||
|
||||
let stable_provider_id = anchor_config_texts
|
||||
.into_iter()
|
||||
.find_map(stable_codex_model_provider_id_from_config)
|
||||
.or_else(|| {
|
||||
is_custom_codex_model_provider_id(&source_provider_id)
|
||||
.then(|| source_provider_id.clone())
|
||||
})
|
||||
.unwrap_or_else(|| CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
|
||||
|
||||
if stable_provider_id == source_provider_id {
|
||||
return Ok(config_text.to_string());
|
||||
}
|
||||
|
||||
if let Some(model_providers) = doc
|
||||
.get_mut("model_providers")
|
||||
.and_then(|item| item.as_table_mut())
|
||||
{
|
||||
let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
|
||||
return Ok(config_text.to_string());
|
||||
};
|
||||
model_providers[stable_provider_id.as_str()] = provider_table;
|
||||
}
|
||||
|
||||
rewrite_codex_profile_model_provider_refs(&mut doc, &source_provider_id, &stable_provider_id);
|
||||
doc["model_provider"] = toml_edit::value(stable_provider_id.as_str());
|
||||
|
||||
Ok(doc.to_string())
|
||||
}
|
||||
|
||||
fn rewrite_codex_profile_model_provider_refs(
|
||||
doc: &mut DocumentMut,
|
||||
source_provider_id: &str,
|
||||
stable_provider_id: &str,
|
||||
) {
|
||||
let Some(profiles) = doc
|
||||
.get_mut("profiles")
|
||||
.and_then(|item| item.as_table_like_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let profile_keys: Vec<String> = profiles.iter().map(|(key, _)| key.to_string()).collect();
|
||||
for profile_key in profile_keys {
|
||||
let Some(profile_table) = profiles
|
||||
.get_mut(&profile_key)
|
||||
.and_then(|item| item.as_table_like_mut())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let references_source = profile_table
|
||||
.get("model_provider")
|
||||
.and_then(|item| item.as_str())
|
||||
== Some(source_provider_id);
|
||||
if references_source {
|
||||
profile_table.insert("model_provider", toml_edit::value(stable_provider_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep Codex's active `model_provider` stable across CC Switch provider changes.
|
||||
///
|
||||
/// Codex stores and filters resume history by `model_provider`, so switching between
|
||||
/// provider-specific ids like `rightcode` and `aihubmix` makes history appear to move.
|
||||
/// We preserve an existing custom provider id when possible and only rewrite the
|
||||
/// live config text that Codex sees at provider-driven write boundaries.
|
||||
pub fn normalize_codex_settings_config_model_provider(
|
||||
settings: &mut Value,
|
||||
anchor_config_text: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
let Some(config_text) = settings
|
||||
.get("config")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::to_string)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let current_config_text = read_codex_config_text().ok();
|
||||
let anchors = anchor_config_text
|
||||
.into_iter()
|
||||
.chain(current_config_text.as_deref());
|
||||
let normalized =
|
||||
normalize_codex_live_config_model_provider_with_anchors(&config_text, anchors)?;
|
||||
|
||||
if let Some(obj) = settings.as_object_mut() {
|
||||
obj.insert("config".to_string(), Value::String(normalized));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_codex_backfill_model_provider_id(
|
||||
config_text: &str,
|
||||
template_config_text: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let Some(template_provider_id) =
|
||||
codex_model_provider_id_with_table_from_config(template_config_text)?
|
||||
else {
|
||||
return Ok(config_text.to_string());
|
||||
};
|
||||
|
||||
if config_text.trim().is_empty() {
|
||||
return Ok(config_text.to_string());
|
||||
}
|
||||
|
||||
let mut doc = config_text
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
|
||||
let Some(live_provider_id) = active_codex_model_provider_id(&doc) else {
|
||||
return Ok(config_text.to_string());
|
||||
};
|
||||
|
||||
if live_provider_id == template_provider_id {
|
||||
return Ok(config_text.to_string());
|
||||
}
|
||||
|
||||
if let Some(model_providers) = doc
|
||||
.get_mut("model_providers")
|
||||
.and_then(|item| item.as_table_mut())
|
||||
{
|
||||
let Some(provider_table) = model_providers.remove(live_provider_id.as_str()) else {
|
||||
return Ok(config_text.to_string());
|
||||
};
|
||||
model_providers[template_provider_id.as_str()] = provider_table;
|
||||
} else {
|
||||
return Ok(config_text.to_string());
|
||||
}
|
||||
|
||||
rewrite_codex_profile_model_provider_refs(&mut doc, &live_provider_id, &template_provider_id);
|
||||
doc["model_provider"] = toml_edit::value(template_provider_id.as_str());
|
||||
|
||||
Ok(doc.to_string())
|
||||
}
|
||||
|
||||
/// Convert a Codex live config that was normalized for history stability back
|
||||
/// to the provider-specific id used by the stored provider template.
|
||||
pub fn restore_codex_settings_config_model_provider_for_backfill(
|
||||
settings: &mut Value,
|
||||
template_settings: &Value,
|
||||
) -> Result<(), AppError> {
|
||||
let Some(config_text) = settings
|
||||
.get("config")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::to_string)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(template_config_text) = template_settings
|
||||
.get("config")
|
||||
.and_then(|value| value.as_str())
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let restored = restore_codex_backfill_model_provider_id(&config_text, template_config_text)?;
|
||||
if let Some(obj) = settings.as_object_mut() {
|
||||
obj.insert("config".to_string(), Value::String(restored));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically write Codex live config after normalizing provider-specific ids.
|
||||
///
|
||||
/// Use this for provider-driven live writes. Keep `write_codex_live_atomic` available
|
||||
/// for exact restore/backup paths that must preserve the config text byte-for-byte.
|
||||
pub fn write_codex_live_atomic_with_stable_provider(
|
||||
auth: &Value,
|
||||
config_text_opt: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
match config_text_opt {
|
||||
Some(config_text) => {
|
||||
let mut settings = serde_json::Map::new();
|
||||
settings.insert("config".to_string(), Value::String(config_text.to_string()));
|
||||
let mut settings = Value::Object(settings);
|
||||
normalize_codex_settings_config_model_provider(&mut settings, None)?;
|
||||
let config_text = settings
|
||||
.get("config")
|
||||
.and_then(|value| value.as_str())
|
||||
.unwrap_or(config_text);
|
||||
write_codex_live_atomic(auth, Some(config_text))
|
||||
}
|
||||
None => write_codex_live_atomic(auth, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a field in Codex config.toml using toml_edit (syntax-preserving).
|
||||
///
|
||||
/// Supported fields:
|
||||
@@ -254,6 +530,241 @@ pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) ->
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_live_config_preserves_current_custom_model_provider_id() {
|
||||
let current = r#"model_provider = "rightcode"
|
||||
|
||||
[model_providers.rightcode]
|
||||
name = "RightCode"
|
||||
base_url = "https://rightcode.example/v1"
|
||||
wire_api = "responses"
|
||||
"#;
|
||||
let target = r#"model_provider = "aihubmix"
|
||||
model = "gpt-5.4"
|
||||
|
||||
[model_providers.aihubmix]
|
||||
name = "AiHubMix"
|
||||
base_url = "https://aihubmix.example/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true
|
||||
|
||||
[mcp_servers.context7]
|
||||
command = "npx"
|
||||
"#;
|
||||
|
||||
let result =
|
||||
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed.get("model_provider").and_then(|v| v.as_str()),
|
||||
Some("rightcode")
|
||||
);
|
||||
|
||||
let model_providers = parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.as_table())
|
||||
.expect("model_providers should exist");
|
||||
assert!(
|
||||
model_providers.get("aihubmix").is_none(),
|
||||
"source provider id should not remain in live config"
|
||||
);
|
||||
|
||||
let stable_provider = model_providers
|
||||
.get("rightcode")
|
||||
.expect("stable provider table should exist");
|
||||
assert_eq!(
|
||||
stable_provider.get("base_url").and_then(|v| v.as_str()),
|
||||
Some("https://aihubmix.example/v1")
|
||||
);
|
||||
assert!(
|
||||
parsed.get("mcp_servers").is_some(),
|
||||
"unrelated config should be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_live_config_uses_target_custom_provider_when_current_is_reserved() {
|
||||
let current = r#"model_provider = "openai""#;
|
||||
let target = r#"model_provider = "aihubmix"
|
||||
|
||||
[model_providers.aihubmix]
|
||||
name = "AiHubMix"
|
||||
base_url = "https://aihubmix.example/v1"
|
||||
wire_api = "responses"
|
||||
"#;
|
||||
|
||||
let result =
|
||||
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed.get("model_provider").and_then(|v| v.as_str()),
|
||||
Some("aihubmix")
|
||||
);
|
||||
assert!(
|
||||
parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("aihubmix"))
|
||||
.is_some(),
|
||||
"target provider id should be kept when there is no reusable live custom id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_live_config_leaves_official_empty_config_unchanged() {
|
||||
let current = r#"model_provider = "rightcode"
|
||||
|
||||
[model_providers.rightcode]
|
||||
base_url = "https://rightcode.example/v1"
|
||||
"#;
|
||||
|
||||
let result =
|
||||
normalize_codex_live_config_model_provider_with_anchors("", Some(current)).unwrap();
|
||||
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_live_config_rewrites_matching_profile_model_provider_refs() {
|
||||
let current = r#"model_provider = "session_anchor"
|
||||
|
||||
[model_providers.session_anchor]
|
||||
name = "Session Anchor"
|
||||
base_url = "https://anchor.example/v1"
|
||||
wire_api = "responses"
|
||||
"#;
|
||||
let target = r#"model_provider = "vendor_alpha"
|
||||
model = "gpt-5.4"
|
||||
profile = "work"
|
||||
|
||||
[model_providers.vendor_alpha]
|
||||
name = "Vendor Alpha"
|
||||
base_url = "https://alpha.example/v1"
|
||||
wire_api = "responses"
|
||||
|
||||
[profiles.work]
|
||||
model_provider = "vendor_alpha"
|
||||
model = "gpt-5.4"
|
||||
"#;
|
||||
|
||||
let result =
|
||||
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed.get("model_provider").and_then(|v| v.as_str()),
|
||||
Some("session_anchor")
|
||||
);
|
||||
assert_eq!(
|
||||
parsed
|
||||
.get("profiles")
|
||||
.and_then(|v| v.get("work"))
|
||||
.and_then(|v| v.get("model_provider"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("session_anchor"),
|
||||
"profile override matching the rewritten provider should stay valid"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_live_config_keeps_unrelated_profile_model_provider_refs() {
|
||||
let current = r#"model_provider = "session_anchor"
|
||||
|
||||
[model_providers.session_anchor]
|
||||
name = "Session Anchor"
|
||||
base_url = "https://anchor.example/v1"
|
||||
wire_api = "responses"
|
||||
"#;
|
||||
let target = r#"model_provider = "vendor_alpha"
|
||||
model = "gpt-5.4"
|
||||
|
||||
[model_providers.vendor_alpha]
|
||||
name = "Vendor Alpha"
|
||||
base_url = "https://alpha.example/v1"
|
||||
wire_api = "responses"
|
||||
|
||||
[model_providers.local_profile]
|
||||
name = "Local Profile"
|
||||
base_url = "http://localhost:11434/v1"
|
||||
wire_api = "responses"
|
||||
|
||||
[profiles.local]
|
||||
model_provider = "local_profile"
|
||||
model = "local-model"
|
||||
"#;
|
||||
|
||||
let result =
|
||||
normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed
|
||||
.get("profiles")
|
||||
.and_then(|v| v.get("local"))
|
||||
.and_then(|v| v.get("model_provider"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("local_profile"),
|
||||
"unrelated profile provider references should be preserved"
|
||||
);
|
||||
assert!(
|
||||
parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("local_profile"))
|
||||
.is_some(),
|
||||
"unrelated provider tables should also remain available"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_live_config_keeps_stable_provider_across_repeated_switches() {
|
||||
let anchor = r#"model_provider = "session_anchor"
|
||||
|
||||
[model_providers.session_anchor]
|
||||
name = "Session Anchor"
|
||||
base_url = "https://anchor.example/v1"
|
||||
wire_api = "responses"
|
||||
"#;
|
||||
let first_target = r#"model_provider = "vendor_alpha"
|
||||
|
||||
[model_providers.vendor_alpha]
|
||||
name = "Vendor Alpha"
|
||||
base_url = "https://alpha.example/v1"
|
||||
wire_api = "responses"
|
||||
"#;
|
||||
let second_target = r#"model_provider = "vendor_beta"
|
||||
|
||||
[model_providers.vendor_beta]
|
||||
name = "Vendor Beta"
|
||||
base_url = "https://beta.example/v1"
|
||||
wire_api = "responses"
|
||||
"#;
|
||||
|
||||
let first =
|
||||
normalize_codex_live_config_model_provider_with_anchors(first_target, Some(anchor))
|
||||
.unwrap();
|
||||
let second = normalize_codex_live_config_model_provider_with_anchors(
|
||||
second_target,
|
||||
Some(first.as_str()),
|
||||
)
|
||||
.unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&second).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed.get("model_provider").and_then(|v| v.as_str()),
|
||||
Some("session_anchor"),
|
||||
"stable provider id should not drift across repeated switches"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("session_anchor"))
|
||||
.and_then(|v| v.get("base_url"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("https://beta.example/v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_url_writes_into_correct_model_provider_section() {
|
||||
let input = r#"model_provider = "any"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::app_config::AppType;
|
||||
use crate::codex_config;
|
||||
use crate::config::{self, get_claude_settings_path, ConfigStatus};
|
||||
use crate::settings;
|
||||
use crate::store::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_claude_config_status() -> Result<ConfigStatus, String> {
|
||||
@@ -62,9 +63,23 @@ fn validate_common_config_snippet(app_type: &str, snippet: &str) -> Result<(), S
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||
pub async fn get_config_status(
|
||||
state: State<'_, AppState>,
|
||||
app: String,
|
||||
) -> Result<ConfigStatus, String> {
|
||||
match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||
AppType::Claude => Ok(config::get_claude_config_status()),
|
||||
AppType::ClaudeDesktop => {
|
||||
let status = crate::claude_desktop_config::get_status(
|
||||
state.db.as_ref(),
|
||||
state.proxy_service.is_running().await,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(ConfigStatus {
|
||||
exists: status.configured,
|
||||
path: status.config_library_path.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
AppType::Codex => {
|
||||
let auth_path = codex_config::get_codex_auth_path();
|
||||
let exists = auth_path.exists();
|
||||
@@ -122,6 +137,9 @@ pub async fn get_claude_code_config_path() -> Result<String, String> {
|
||||
pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||
let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||
AppType::Claude => config::get_claude_config_dir(),
|
||||
AppType::ClaudeDesktop => {
|
||||
crate::claude_desktop_config::get_config_library_path().map_err(|e| e.to_string())?
|
||||
}
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
@@ -136,6 +154,9 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
|
||||
pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {
|
||||
let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||
AppType::Claude => config::get_claude_config_dir(),
|
||||
AppType::ClaudeDesktop => {
|
||||
crate::claude_desktop_config::get_config_library_path().map_err(|e| e.to_string())?
|
||||
}
|
||||
AppType::Codex => codex_config::get_codex_config_dir(),
|
||||
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
|
||||
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
|
||||
|
||||
@@ -40,12 +40,6 @@ pub fn get_hermes_live_provider(
|
||||
hermes_config::get_provider(&providerId).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Scan config.yaml for known configuration hazards.
|
||||
#[tauri::command]
|
||||
pub fn scan_hermes_config_health() -> Result<Vec<hermes_config::HermesHealthWarning>, String> {
|
||||
hermes_config::scan_hermes_config_health().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Model Configuration Commands
|
||||
// ============================================================================
|
||||
|
||||
@@ -300,8 +300,13 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let output = {
|
||||
Command::new("sh")
|
||||
.arg("-c")
|
||||
let shell = std::env::var("SHELL")
|
||||
.ok()
|
||||
.filter(|s| is_valid_shell(s))
|
||||
.unwrap_or_else(|| "sh".to_string());
|
||||
let flag = default_flag_for_shell(&shell);
|
||||
Command::new(shell)
|
||||
.arg(flag)
|
||||
.arg(format!("{tool} --version"))
|
||||
.output()
|
||||
};
|
||||
@@ -345,7 +350,6 @@ fn is_valid_wsl_distro_name(name: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Validate that the given shell name is one of the allowed shells.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_valid_shell(shell: &str) -> bool {
|
||||
matches!(
|
||||
shell.rsplit('/').next().unwrap_or(shell),
|
||||
@@ -360,7 +364,6 @@ fn is_valid_shell_flag(flag: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Return the default invocation flag for the given shell.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn default_flag_for_shell(shell: &str) -> &'static str {
|
||||
match shell.rsplit('/').next().unwrap_or(shell) {
|
||||
"dash" | "sh" => "-c",
|
||||
@@ -781,7 +784,7 @@ fn extract_env_vars_from_config(
|
||||
|
||||
// 处理 base_url: 根据应用类型添加对应的环境变量
|
||||
let base_url_key = match app_type {
|
||||
AppType::Claude => Some("ANTHROPIC_BASE_URL"),
|
||||
AppType::Claude | AppType::ClaudeDesktop => Some("ANTHROPIC_BASE_URL"),
|
||||
AppType::Gemini => Some("GOOGLE_GEMINI_BASE_URL"),
|
||||
_ => None,
|
||||
};
|
||||
@@ -944,6 +947,7 @@ exec bash --norc --noprofile
|
||||
// Note: Kitty doesn't need the -e flag, others do
|
||||
let result = match terminal {
|
||||
"iterm2" => launch_macos_iterm2(&script_file),
|
||||
"warp" => launch_macos_warp(&script_file),
|
||||
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
|
||||
"kitty" => launch_macos_open_app("kitty", &script_file, false),
|
||||
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
|
||||
@@ -998,21 +1002,46 @@ end tell"#,
|
||||
|
||||
/// macOS: iTerm2
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let applescript = format!(
|
||||
r#"tell application "iTerm"
|
||||
activate
|
||||
tell current window
|
||||
create tab with default profile
|
||||
tell current session
|
||||
write text "bash '{}'"
|
||||
end tell
|
||||
fn build_macos_iterm2_applescript(script_file: &std::path::Path) -> String {
|
||||
format!(
|
||||
r#"set launcher_script to "bash '{}'"
|
||||
set was_running to application "iTerm" is running
|
||||
tell application "iTerm"
|
||||
if was_running then
|
||||
activate
|
||||
if (count of windows) = 0 then
|
||||
create window with default profile
|
||||
else
|
||||
tell current window
|
||||
create tab with default profile
|
||||
end tell
|
||||
end if
|
||||
else
|
||||
activate
|
||||
set waited to 0
|
||||
repeat while (count of windows) = 0
|
||||
delay 0.1
|
||||
set waited to waited + 1
|
||||
if waited >= 30 then exit repeat
|
||||
end repeat
|
||||
if (count of windows) = 0 then
|
||||
create window with default profile
|
||||
end if
|
||||
end if
|
||||
tell current session of current window
|
||||
write text launcher_script
|
||||
end tell
|
||||
end tell"#,
|
||||
script_file.display()
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
/// macOS: iTerm2
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_iterm2(script_file: &std::path::Path) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
let applescript = build_macos_iterm2_applescript(script_file);
|
||||
|
||||
let output = Command::new("osascript")
|
||||
.arg("-e")
|
||||
@@ -1066,6 +1095,57 @@ fn launch_macos_open_app(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_warp(script_file: &std::path::Path) -> Result<(), String> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::process::Command;
|
||||
|
||||
let mut cmd = Command::new("open");
|
||||
cmd.arg("-a").arg("Warp");
|
||||
|
||||
// Warp URI scheme cannot work well with script_file, because:
|
||||
//
|
||||
// 1. script_file's name ends up with .sh, so Warp would open the file rather than execute it
|
||||
// 2. script_file has no execution permission, so we need to add one more indirection
|
||||
let mut second_script_file = tempfile::Builder::new()
|
||||
.disable_cleanup(true)
|
||||
.permissions(std::fs::Permissions::from_mode(0o755))
|
||||
.tempfile()
|
||||
.map_err(|e| format!("Failed to create temporary script file: {e}"))?;
|
||||
|
||||
writeln!(
|
||||
&mut second_script_file,
|
||||
r#"#!/usr/bin/env sh
|
||||
|
||||
rm -- "$0"
|
||||
|
||||
exec bash {}
|
||||
"#,
|
||||
script_file.display(),
|
||||
)
|
||||
.map_err(|e| format!("Failed to write to temporary script file for Warp: {e}"))?;
|
||||
|
||||
let mut warp_url = url::Url::parse("warp://action/new_tab").unwrap();
|
||||
warp_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("path", &second_script_file.path().to_string_lossy());
|
||||
let warp_url = warp_url.to_string();
|
||||
cmd.arg(warp_url);
|
||||
|
||||
let output = cmd.output().map_err(|e| format!("启动 Warp 失败: {e}"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"Warp 启动失败 (exit code: {:?}): {}",
|
||||
output.status.code(),
|
||||
stderr
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Linux: 根据用户首选终端启动
|
||||
#[cfg(target_os = "linux")]
|
||||
fn launch_linux_terminal(config_file: &std::path::Path, cwd: Option<&Path>) -> Result<(), String> {
|
||||
@@ -1353,6 +1433,7 @@ read -n 1 -s
|
||||
|
||||
let result = match terminal {
|
||||
"iterm2" => launch_macos_iterm2(&script_file),
|
||||
"warp" => launch_macos_warp(&script_file),
|
||||
"alacritty" => launch_macos_open_app("Alacritty", &script_file, true),
|
||||
"kitty" => launch_macos_open_app("kitty", &script_file, false),
|
||||
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
|
||||
@@ -1510,7 +1591,7 @@ pub async fn set_window_theme(window: tauri::Window, theme: String) -> Result<()
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[test]
|
||||
fn test_extract_version() {
|
||||
@@ -1598,7 +1679,7 @@ mod tests {
|
||||
|
||||
let count = paths
|
||||
.iter()
|
||||
.filter(|path| **path == PathBuf::from("/same/path"))
|
||||
.filter(|path| path.as_path() == Path::new("/same/path"))
|
||||
.count();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
@@ -1610,7 +1691,7 @@ mod tests {
|
||||
|
||||
let count = paths
|
||||
.iter()
|
||||
.filter(|path| **path == PathBuf::from("/home/tester/.bun/bin"))
|
||||
.filter(|path| path.as_path() == Path::new("/home/tester/.bun/bin"))
|
||||
.count();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
@@ -1671,6 +1752,43 @@ mod tests {
|
||||
assert_eq!(command, "cd '/tmp/project O'\"'\"'Brien' || exit 1\n");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn iterm2_applescript_cold_start_avoids_current_window_before_one_exists() {
|
||||
let script = build_macos_iterm2_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
|
||||
|
||||
let cold_start_branch = script
|
||||
.split("else\n activate")
|
||||
.nth(1)
|
||||
.expect("cold start branch should be present")
|
||||
.split(" end if\n tell current session")
|
||||
.next()
|
||||
.expect("cold start branch should end before writing command");
|
||||
|
||||
assert!(cold_start_branch.contains("repeat while (count of windows) = 0"));
|
||||
assert!(cold_start_branch.contains("create window with default profile"));
|
||||
assert!(!cold_start_branch.contains("tell current window"));
|
||||
assert!(!cold_start_branch.contains("create tab with default profile"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn iterm2_applescript_keeps_new_tab_behavior_for_existing_windows() {
|
||||
let script = build_macos_iterm2_applescript(Path::new("/tmp/cc_switch_launcher.sh"));
|
||||
|
||||
let running_branch = script
|
||||
.split("if was_running then")
|
||||
.nth(1)
|
||||
.expect("already-running branch should be present")
|
||||
.split("else\n activate")
|
||||
.next()
|
||||
.expect("already-running branch should end before cold start branch");
|
||||
|
||||
assert!(running_branch.contains("if (count of windows) = 0 then"));
|
||||
assert!(running_branch.contains("create window with default profile"));
|
||||
assert!(running_branch.contains("create tab with default profile"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_windows_cwd_command_str_uses_cd_for_drive_paths() {
|
||||
let command = build_windows_cwd_command_str(r"C:\work\repo");
|
||||
|
||||
@@ -6,13 +6,20 @@ use crate::services::model_fetch::{self, FetchedModel};
|
||||
|
||||
/// 获取供应商的可用模型列表
|
||||
///
|
||||
/// 使用 OpenAI 兼容的 GET /v1/models 端点。
|
||||
/// 主要面向第三方聚合站(硅基流动、OpenRouter 等)。
|
||||
/// 使用 OpenAI 兼容的 GET /v1/models 端点。优先使用 `models_url` 精确覆写;
|
||||
/// 否则对 baseURL 生成候选列表(含「剥离 Anthropic 兼容子路径」兜底),按序尝试。
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn fetch_models_for_config(
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
is_full_url: Option<bool>,
|
||||
models_url: Option<String>,
|
||||
) -> Result<Vec<FetchedModel>, String> {
|
||||
model_fetch::fetch_models(&base_url, &api_key, is_full_url.unwrap_or(false)).await
|
||||
model_fetch::fetch_models(
|
||||
&base_url,
|
||||
&api_key,
|
||||
is_full_url.unwrap_or(false),
|
||||
models_url.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use indexmap::IndexMap;
|
||||
use tauri::State;
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::commands::copilot::CopilotAuthState;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::provider::{ClaudeDesktopMode, Provider};
|
||||
use crate::services::{
|
||||
EndpointLatency, ProviderService, ProviderSortUpdate, SpeedtestService, SwitchResult,
|
||||
};
|
||||
@@ -150,22 +150,206 @@ pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<
|
||||
import_default_config_internal(&state, app_type).map_err(Into::into)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_claude_desktop_status(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<crate::claude_desktop_config::ClaudeDesktopStatus, String> {
|
||||
let proxy_running = state.proxy_service.is_running().await;
|
||||
crate::claude_desktop_config::get_status(state.db.as_ref(), proxy_running)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_claude_desktop_default_routes(
|
||||
) -> Vec<crate::claude_desktop_config::ClaudeDesktopDefaultRoute> {
|
||||
crate::claude_desktop_config::default_proxy_routes()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_claude_desktop_providers_from_claude(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<usize, String> {
|
||||
let claude_providers = state
|
||||
.db
|
||||
.get_all_providers(AppType::Claude.as_str())
|
||||
.map_err(|e| e.to_string())?;
|
||||
let existing_ids = state
|
||||
.db
|
||||
.get_provider_ids(AppType::ClaudeDesktop.as_str())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut imported = 0usize;
|
||||
for provider in claude_providers.values() {
|
||||
if existing_ids.contains(&provider.id) {
|
||||
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);
|
||||
|
||||
if crate::claude_desktop_config::is_compatible_direct_provider(provider)
|
||||
&& claude_provider_models_are_claude_safe(provider)
|
||||
{
|
||||
meta.claude_desktop_mode = Some(ClaudeDesktopMode::Direct);
|
||||
} else if let Some(routes) = suggested_claude_desktop_routes(provider) {
|
||||
meta.claude_desktop_mode = Some(ClaudeDesktopMode::Proxy);
|
||||
meta.claude_desktop_model_routes = routes;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::ClaudeDesktop.as_str(), &desktop_provider)
|
||||
.map_err(|e| e.to_string())?;
|
||||
imported += 1;
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
fn claude_provider_models_are_claude_safe(provider: &Provider) -> bool {
|
||||
let Some(env) = provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|value| value.as_object())
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
|
||||
[
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|key| env.get(key).and_then(|value| value.as_str()))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.all(crate::claude_desktop_config::is_claude_safe_model_id)
|
||||
}
|
||||
|
||||
fn suggested_claude_desktop_routes(
|
||||
provider: &Provider,
|
||||
) -> Option<std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>> {
|
||||
let env = provider
|
||||
.settings_config
|
||||
.get("env")
|
||||
.and_then(|value| value.as_object())?;
|
||||
let mut routes = std::collections::HashMap::new();
|
||||
|
||||
fn add_route(
|
||||
routes: &mut std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>,
|
||||
env: &serde_json::Map<String, serde_json::Value>,
|
||||
route_id: &str,
|
||||
env_key: &str,
|
||||
display_name: &str,
|
||||
) {
|
||||
if let Some(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),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for spec in crate::claude_desktop_config::DEFAULT_PROXY_ROUTES {
|
||||
add_route(
|
||||
&mut routes,
|
||||
env,
|
||||
spec.route_id,
|
||||
spec.env_key,
|
||||
spec.display_name,
|
||||
);
|
||||
}
|
||||
|
||||
let primary_route = crate::claude_desktop_config::DEFAULT_PROXY_ROUTES[0];
|
||||
if !routes.contains_key(primary_route.route_id) {
|
||||
add_route(
|
||||
&mut routes,
|
||||
env,
|
||||
primary_route.route_id,
|
||||
"ANTHROPIC_MODEL",
|
||||
primary_route.display_name,
|
||||
);
|
||||
}
|
||||
|
||||
(!routes.is_empty()).then_some(routes)
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
#[tauri::command]
|
||||
pub async fn queryProviderUsage(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
|
||||
app: String,
|
||||
) -> Result<crate::provider::UsageResult, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
// inner 可能以两种形式失败:
|
||||
// 1) 返回 Ok(UsageResult { success: false, .. }) —— 业务失败(401、脚本报错等)
|
||||
// 2) 返回 Err(String) —— RPC/DB/Copilot fetch_usage 等 transport 层失败
|
||||
// 两种都要把"失败"写进 UsageCache 并刷新托盘,让 format_script_summary 的
|
||||
// success 守卫生效、suffix 自然消失,避免旧 success 快照长期滞留。
|
||||
// 同时保持原始 Err 返回给前端 React Query 的 onError 回调,不吞错误。
|
||||
let inner =
|
||||
query_provider_usage_inner(&state, &copilot_state, app_type.clone(), &providerId).await;
|
||||
let snapshot = match &inner {
|
||||
Ok(r) => r.clone(),
|
||||
Err(err_msg) => crate::provider::UsageResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(err_msg.clone()),
|
||||
},
|
||||
};
|
||||
let payload = serde_json::json!({
|
||||
"kind": "script",
|
||||
"appType": app_type.as_str(),
|
||||
"providerId": &providerId,
|
||||
"data": &snapshot,
|
||||
});
|
||||
if let Err(e) = app_handle.emit("usage-cache-updated", payload) {
|
||||
log::error!("emit usage-cache-updated (script) 失败: {e}");
|
||||
}
|
||||
state.usage_cache.put_script(app_type, providerId, snapshot);
|
||||
crate::tray::schedule_tray_refresh(&app_handle);
|
||||
inner
|
||||
}
|
||||
|
||||
async fn query_provider_usage_inner(
|
||||
state: &AppState,
|
||||
copilot_state: &CopilotAuthState,
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
) -> Result<crate::provider::UsageResult, String> {
|
||||
// 从数据库读取供应商信息,检查特殊模板类型
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers(app_type.as_str())
|
||||
.map_err(|e| format!("Failed to get providers: {e}"))?;
|
||||
let provider = providers.get(&providerId);
|
||||
let provider = providers.get(provider_id);
|
||||
let usage_script = provider
|
||||
.and_then(|p| p.meta.as_ref())
|
||||
.and_then(|m| m.usage_script.as_ref());
|
||||
@@ -294,7 +478,7 @@ pub async fn queryProviderUsage(
|
||||
}
|
||||
|
||||
// ── 通用 JS 脚本路径 ──
|
||||
ProviderService::query_usage(state.inner(), app_type, &providerId)
|
||||
ProviderService::query_usage(state, app_type, provider_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -406,7 +590,7 @@ pub fn update_providers_sort_order(
|
||||
|
||||
use crate::provider::UniversalProvider;
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri::AppHandle;
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
pub struct UniversalProviderSyncedEvent {
|
||||
|
||||
@@ -15,6 +15,24 @@ pub async fn start_proxy_server(
|
||||
state.proxy_service.start().await
|
||||
}
|
||||
|
||||
/// 停止代理服务器(仅停止服务,不恢复/清理 Live 接管状态)
|
||||
#[tauri::command]
|
||||
pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
||||
let takeover = state.proxy_service.get_takeover_status().await?;
|
||||
if takeover.claude
|
||||
|| takeover.codex
|
||||
|| takeover.gemini
|
||||
|| takeover.opencode
|
||||
|| takeover.openclaw
|
||||
{
|
||||
return Err(
|
||||
"仍有应用处于代理接管状态,请先在设置中关闭对应应用接管后再停止本地路由。".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
state.proxy_service.stop().await
|
||||
}
|
||||
|
||||
/// 停止代理服务器(恢复 Live 配置)
|
||||
#[tauri::command]
|
||||
pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
||||
|
||||
@@ -42,6 +42,8 @@ pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<boo
|
||||
/// 重启应用程序(当 app_config_dir 变更后使用)
|
||||
#[tauri::command]
|
||||
pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
|
||||
crate::save_window_state_before_exit(&app);
|
||||
|
||||
// 在后台延迟重启,让函数有时间返回响应
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
@@ -85,13 +87,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn save_settings_should_preserve_existing_webdav_when_payload_omits_it() {
|
||||
let mut existing = AppSettings::default();
|
||||
existing.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "secret".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
let existing = AppSettings {
|
||||
webdav_sync: Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "secret".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
|
||||
let incoming = AppSettings::default();
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
@@ -105,21 +109,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn save_settings_should_keep_incoming_webdav_when_present() {
|
||||
let mut existing = AppSettings::default();
|
||||
existing.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.old.example.com".to_string(),
|
||||
username: "old".to_string(),
|
||||
password: "old-pass".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
let existing = AppSettings {
|
||||
webdav_sync: Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.old.example.com".to_string(),
|
||||
username: "old".to_string(),
|
||||
password: "old-pass".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
|
||||
let mut incoming = AppSettings::default();
|
||||
incoming.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.new.example.com".to_string(),
|
||||
username: "new".to_string(),
|
||||
password: "new-pass".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
let incoming = AppSettings {
|
||||
webdav_sync: Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.new.example.com".to_string(),
|
||||
username: "new".to_string(),
|
||||
password: "new-pass".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
@@ -135,22 +143,26 @@ mod tests {
|
||||
/// must NOT overwrite the existing one.
|
||||
#[test]
|
||||
fn save_settings_should_preserve_password_when_incoming_has_empty_password() {
|
||||
let mut existing = AppSettings::default();
|
||||
existing.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "secret".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
let existing = AppSettings {
|
||||
webdav_sync: Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "secret".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
|
||||
// Simulate frontend sending settings with cleared password
|
||||
let mut incoming = AppSettings::default();
|
||||
incoming.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
let incoming = AppSettings {
|
||||
webdav_sync: Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
@@ -165,21 +177,25 @@ mod tests {
|
||||
/// work without panicking and keep the empty state.
|
||||
#[test]
|
||||
fn save_settings_should_handle_both_empty_passwords() {
|
||||
let mut existing = AppSettings::default();
|
||||
existing.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
let existing = AppSettings {
|
||||
webdav_sync: Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
|
||||
let mut incoming = AppSettings::default();
|
||||
incoming.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
let incoming = AppSettings {
|
||||
webdav_sync: Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::services::skill::{
|
||||
SkillsShSearchResult,
|
||||
};
|
||||
use crate::store::AppState;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
@@ -20,14 +21,7 @@ pub struct SkillServiceState(pub Arc<SkillService>);
|
||||
|
||||
/// 解析 app 参数为 AppType
|
||||
fn parse_app_type(app: &str) -> Result<AppType, String> {
|
||||
match app.to_lowercase().as_str() {
|
||||
"claude" => Ok(AppType::Claude),
|
||||
"codex" => Ok(AppType::Codex),
|
||||
"gemini" => Ok(AppType::Gemini),
|
||||
"opencode" => Ok(AppType::OpenCode),
|
||||
"hermes" => Ok(AppType::Hermes),
|
||||
_ => Err(format!("不支持的 app 类型: {app}")),
|
||||
}
|
||||
AppType::from_str(app).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ========== 统一管理命令 ==========
|
||||
|
||||
@@ -1,10 +1,41 @@
|
||||
use crate::services::subscription::SubscriptionQuota;
|
||||
use std::str::FromStr;
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::services::subscription::{CredentialStatus, SubscriptionQuota};
|
||||
use crate::store::AppState;
|
||||
|
||||
/// 查询官方订阅额度
|
||||
///
|
||||
/// 读取 CLI 工具已有的 OAuth 凭据并调用官方 API 获取使用额度。
|
||||
/// 不需要 AppState(不访问数据库),直接读文件 + 发 HTTP。
|
||||
/// 结果(无论业务失败还是 transport 层 Err)都会写入 `UsageCache`、通知托盘
|
||||
/// 刷新,并 emit `usage-cache-updated`,让前端 React Query 与托盘共享同一份
|
||||
/// 最新数据。失败快照写入后 `format_subscription_summary` 会通过 `success=false`
|
||||
/// 守卫返回 `None`,托盘 suffix 自然消失,避免长期滞留旧配额数字。
|
||||
/// Err 原样向前端返回,React Query 的 onError 不会被吞掉。
|
||||
#[tauri::command]
|
||||
pub async fn get_subscription_quota(tool: String) -> Result<SubscriptionQuota, String> {
|
||||
crate::services::subscription::get_subscription_quota(&tool).await
|
||||
pub async fn get_subscription_quota(
|
||||
app: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
tool: String,
|
||||
) -> Result<SubscriptionQuota, String> {
|
||||
let inner = crate::services::subscription::get_subscription_quota(&tool).await;
|
||||
let snapshot = match &inner {
|
||||
Ok(q) => q.clone(),
|
||||
// transport 层 Err —— 凭据状态不明,用 Valid 表达"凭据没问题,是通信/parse 出错"。
|
||||
Err(err_msg) => SubscriptionQuota::error(&tool, CredentialStatus::Valid, err_msg.clone()),
|
||||
};
|
||||
if let Ok(app_type) = AppType::from_str(&tool) {
|
||||
let payload = serde_json::json!({
|
||||
"kind": "subscription",
|
||||
"appType": app_type.as_str(),
|
||||
"data": &snapshot,
|
||||
});
|
||||
if let Err(e) = app.emit("usage-cache-updated", payload) {
|
||||
log::error!("emit usage-cache-updated (subscription) 失败: {e}");
|
||||
}
|
||||
state.usage_cache.put_subscription(app_type, snapshot);
|
||||
crate::tray::schedule_tray_refresh(&app);
|
||||
}
|
||||
inner
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -159,15 +160,34 @@ pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, AppE
|
||||
serde_json::from_str(&content).map_err(|e| AppError::json(path, e))
|
||||
}
|
||||
|
||||
/// 写入 JSON 配置文件
|
||||
/// 递归排序 JSON 对象的键(按字母顺序),确保序列化输出是确定性的
|
||||
fn sort_json_keys(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let mut sorted_map = Map::new();
|
||||
let mut keys: Vec<_> = map.keys().collect();
|
||||
keys.sort();
|
||||
for key in keys {
|
||||
sorted_map.insert(key.clone(), sort_json_keys(&map[key]));
|
||||
}
|
||||
Value::Object(sorted_map)
|
||||
}
|
||||
Value::Array(arr) => Value::Array(arr.iter().map(sort_json_keys).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 写入 JSON 配置文件(键按字母排序,确保确定性输出)
|
||||
pub fn write_json_file<T: Serialize>(path: &Path, data: &T) -> Result<(), AppError> {
|
||||
// 确保目录存在
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
||||
}
|
||||
|
||||
let json =
|
||||
serde_json::to_string_pretty(data).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
let value = serde_json::to_value(data).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
let sorted_value = sort_json_keys(&value);
|
||||
let json = serde_json::to_string_pretty(&sorted_value)
|
||||
.map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
|
||||
atomic_write(path, json.as_bytes())
|
||||
}
|
||||
@@ -271,6 +291,103 @@ mod tests {
|
||||
let override_dir = PathBuf::from("/");
|
||||
assert!(derive_mcp_path_from_override(&override_dir).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_json_keys_sorts_top_level_object() {
|
||||
let input = serde_json::json!({
|
||||
"z": 1,
|
||||
"a": 2,
|
||||
"m": 3,
|
||||
});
|
||||
let sorted = sort_json_keys(&input);
|
||||
let serialized = serde_json::to_string(&sorted).unwrap();
|
||||
assert_eq!(serialized, r#"{"a":2,"m":3,"z":1}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_json_keys_recurses_into_nested_objects() {
|
||||
let input = serde_json::json!({
|
||||
"outer_b": {"z": 1, "a": 2},
|
||||
"outer_a": {"y": 3, "b": 4},
|
||||
});
|
||||
let sorted = sort_json_keys(&input);
|
||||
let serialized = serde_json::to_string(&sorted).unwrap();
|
||||
assert_eq!(
|
||||
serialized,
|
||||
r#"{"outer_a":{"b":4,"y":3},"outer_b":{"a":2,"z":1}}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_json_keys_preserves_array_order() {
|
||||
let input = serde_json::json!([3, 1, 2]);
|
||||
let sorted = sort_json_keys(&input);
|
||||
let serialized = serde_json::to_string(&sorted).unwrap();
|
||||
assert_eq!(serialized, "[3,1,2]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_json_keys_sorts_objects_inside_arrays_but_keeps_array_order() {
|
||||
let input = serde_json::json!([
|
||||
{"z": 1, "a": 2},
|
||||
{"y": 3, "b": 4},
|
||||
]);
|
||||
let sorted = sort_json_keys(&input);
|
||||
let serialized = serde_json::to_string(&sorted).unwrap();
|
||||
assert_eq!(serialized, r#"[{"a":2,"z":1},{"b":4,"y":3}]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_json_keys_passes_through_primitives() {
|
||||
let cases = vec![
|
||||
serde_json::json!("hello"),
|
||||
serde_json::json!(42),
|
||||
serde_json::json!(3.5),
|
||||
serde_json::json!(true),
|
||||
serde_json::json!(null),
|
||||
];
|
||||
for value in cases {
|
||||
let sorted = sort_json_keys(&value);
|
||||
assert_eq!(sorted, value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_json_keys_handles_empty_collections() {
|
||||
let empty_obj = serde_json::json!({});
|
||||
assert_eq!(
|
||||
serde_json::to_string(&sort_json_keys(&empty_obj)).unwrap(),
|
||||
"{}"
|
||||
);
|
||||
|
||||
let empty_arr = serde_json::json!([]);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&sort_json_keys(&empty_arr)).unwrap(),
|
||||
"[]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_json_keys_produces_identical_output_for_different_insertion_orders() {
|
||||
// 核心保证:同一逻辑配置无论键的插入顺序如何,写出的字节序列必须一致。
|
||||
let mut a = Map::new();
|
||||
a.insert("env".to_string(), serde_json::json!({"PATH": "/usr/bin"}));
|
||||
a.insert("model".to_string(), serde_json::json!("claude-sonnet-4-5"));
|
||||
a.insert("permissions".to_string(), serde_json::json!({"allow": []}));
|
||||
|
||||
let mut b = Map::new();
|
||||
b.insert("permissions".to_string(), serde_json::json!({"allow": []}));
|
||||
b.insert("model".to_string(), serde_json::json!("claude-sonnet-4-5"));
|
||||
b.insert("env".to_string(), serde_json::json!({"PATH": "/usr/bin"}));
|
||||
|
||||
let sorted_a = sort_json_keys(&Value::Object(a));
|
||||
let sorted_b = sort_json_keys(&Value::Object(b));
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_string(&sorted_a).unwrap(),
|
||||
serde_json::to_string(&sorted_b).unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 复制文件
|
||||
|
||||
@@ -791,8 +791,10 @@ mod tests {
|
||||
std::fs::create_dir_all(&test_home).expect("create test home");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
|
||||
|
||||
let mut settings = AppSettings::default();
|
||||
settings.backup_interval_hours = Some(0);
|
||||
let settings = AppSettings {
|
||||
backup_interval_hours: Some(0),
|
||||
..AppSettings::default()
|
||||
};
|
||||
update_settings(settings).expect("disable auto backup");
|
||||
|
||||
let db = Database::memory()?;
|
||||
|
||||
@@ -535,6 +535,22 @@ impl Database {
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// 判断指定 app 下是否已存在任意 provider。
|
||||
///
|
||||
/// 启动阶段的 live import 需要使用这个更严格的判断:
|
||||
/// 只要该 app 已经有任何 provider(包括官方 seed),就不应再自动导入 `default`。
|
||||
pub fn has_any_provider_for_app(&self, app_type: &str) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM providers WHERE app_type = ?1)",
|
||||
params![app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
/// 判断指定 app 下是否存在非官方种子的供应商。
|
||||
///
|
||||
/// 比 `get_all_providers` 轻量得多:只读 id 列、无 endpoint 子查询、首条命中即返回。
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
use crate::app_config::AppType;
|
||||
|
||||
pub(crate) const CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID: &str = "claude-desktop-official";
|
||||
|
||||
/// 单条官方供应商种子定义。
|
||||
pub(crate) struct OfficialProviderSeed {
|
||||
pub id: &'static str,
|
||||
@@ -22,7 +24,7 @@ pub(crate) struct OfficialProviderSeed {
|
||||
pub settings_config_json: &'static str,
|
||||
}
|
||||
|
||||
/// Claude / Codex / Gemini 三个应用的官方预设。
|
||||
/// Claude / Claude Desktop / Codex / Gemini 的官方预设。
|
||||
///
|
||||
/// id 固定,便于幂等检查;name 直接用英文原名(与前端预设一致),不做 i18n。
|
||||
pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
|
||||
@@ -36,6 +38,16 @@ pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
|
||||
// 空 env 让用户走 Claude CLI 默认认证流程
|
||||
settings_config_json: r#"{"env":{}}"#,
|
||||
},
|
||||
OfficialProviderSeed {
|
||||
id: CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID,
|
||||
app_type: AppType::ClaudeDesktop,
|
||||
name: "Claude Desktop Official",
|
||||
website_url: "https://claude.ai/download",
|
||||
icon: "anthropic",
|
||||
icon_color: "#D4915D",
|
||||
// 空 env 只是占位;切换该 provider 时会恢复 Claude Desktop 1P 模式
|
||||
settings_config_json: r#"{"env":{}}"#,
|
||||
},
|
||||
OfficialProviderSeed {
|
||||
id: "codex-official",
|
||||
app_type: AppType::Codex,
|
||||
@@ -64,3 +76,19 @@ pub(crate) const OFFICIAL_SEEDS: &[OfficialProviderSeed] = &[
|
||||
pub(crate) fn is_official_seed_id(id: &str) -> bool {
|
||||
OFFICIAL_SEEDS.iter().any(|seed| seed.id == id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn official_seeds_include_claude_desktop() {
|
||||
let seed = OFFICIAL_SEEDS
|
||||
.iter()
|
||||
.find(|seed| seed.id == CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID)
|
||||
.expect("claude desktop official seed");
|
||||
|
||||
assert_eq!(seed.app_type, AppType::ClaudeDesktop);
|
||||
assert!(is_official_seed_id(CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::services::usage_stats::effective_usage_log_filter;
|
||||
use chrono::{Duration, Local, TimeZone};
|
||||
|
||||
/// Compute the rollup/prune cutoff aligned to a local-day boundary.
|
||||
@@ -101,7 +102,8 @@ impl Database {
|
||||
|
||||
fn do_rollup_and_prune(conn: &rusqlite::Connection, cutoff: i64) -> Result<u64, AppError> {
|
||||
// Aggregate old logs, merging with any pre-existing rollup rows via LEFT JOIN.
|
||||
conn.execute(
|
||||
let effective_filter = effective_usage_log_filter("l");
|
||||
let aggregation_sql = format!(
|
||||
"INSERT OR REPLACE INTO usage_daily_rollups
|
||||
(date, app_type, provider_id, model,
|
||||
request_count, success_count,
|
||||
@@ -124,27 +126,30 @@ impl Database {
|
||||
ELSE 0 END
|
||||
FROM (
|
||||
SELECT
|
||||
date(created_at, 'unixepoch', 'localtime') as d,
|
||||
app_type as a, provider_id as p, model as m,
|
||||
date(l.created_at, 'unixepoch', 'localtime') as d,
|
||||
l.app_type as a, l.provider_id as p, l.model as m,
|
||||
COUNT(*) as new_req,
|
||||
SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END) as new_succ,
|
||||
COALESCE(SUM(input_tokens), 0) as new_in,
|
||||
COALESCE(SUM(output_tokens), 0) as new_out,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as new_cr,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as new_cc,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as new_cost,
|
||||
COALESCE(AVG(latency_ms), 0) as new_lat
|
||||
FROM proxy_request_logs WHERE created_at < ?1
|
||||
SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END) as new_succ,
|
||||
COALESCE(SUM(l.input_tokens), 0) as new_in,
|
||||
COALESCE(SUM(l.output_tokens), 0) as new_out,
|
||||
COALESCE(SUM(l.cache_read_tokens), 0) as new_cr,
|
||||
COALESCE(SUM(l.cache_creation_tokens), 0) as new_cc,
|
||||
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as new_cost,
|
||||
COALESCE(AVG(l.latency_ms), 0) as new_lat
|
||||
FROM proxy_request_logs l
|
||||
WHERE l.created_at < ?1 AND {effective_filter}
|
||||
GROUP BY d, a, p, m
|
||||
) agg
|
||||
LEFT JOIN usage_daily_rollups old
|
||||
ON old.date = agg.d AND old.app_type = agg.a
|
||||
AND old.provider_id = agg.p AND old.model = agg.m",
|
||||
[cutoff],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Rollup aggregation failed: {e}")))?;
|
||||
AND old.provider_id = agg.p AND old.model = agg.m"
|
||||
);
|
||||
|
||||
// Delete the aggregated detail rows
|
||||
conn.execute(&aggregation_sql, [cutoff])
|
||||
.map_err(|e| AppError::Database(format!("Rollup aggregation failed: {e}")))?;
|
||||
|
||||
// INSERT uses the effective-log filter to exclude duplicate session rows.
|
||||
// DELETE intentionally prunes all old details so those duplicates are discarded.
|
||||
let deleted = conn
|
||||
.execute(
|
||||
"DELETE FROM proxy_request_logs WHERE created_at < ?1",
|
||||
@@ -254,6 +259,69 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollup_uses_effective_usage_logs() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let old_ts = now - 40 * 86400;
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
total_cost_usd, latency_ms, status_code, created_at, data_source
|
||||
) VALUES (?1, 'openai', 'codex', 'gpt-5.4', 'gpt-5.4', 100, 20, 10, 0, '0.10', 100, 200, ?2, 'proxy')",
|
||||
rusqlite::params!["codex-proxy-old", old_ts],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
total_cost_usd, latency_ms, status_code, created_at, data_source
|
||||
) VALUES (?1, '_codex_session', 'codex', 'gpt-5.4', 'gpt-5.4', 100, 20, 10, 0, '0.10', 0, 200, ?2, 'codex_session')",
|
||||
rusqlite::params!["codex-session-old-dup", old_ts + 60],
|
||||
)?;
|
||||
}
|
||||
|
||||
let deleted = db.rollup_and_prune(30)?;
|
||||
assert_eq!(deleted, 2);
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT provider_id, request_count, input_tokens, output_tokens, cache_read_tokens
|
||||
FROM usage_daily_rollups WHERE app_type = 'codex'",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, i64>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
row.get::<_, i64>(4)?,
|
||||
))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
assert_eq!(rows.len(), 1);
|
||||
let (provider_id, request_count, input_tokens, output_tokens, cache_read_tokens) = &rows[0];
|
||||
assert_eq!(provider_id, "openai");
|
||||
assert_eq!(*request_count, 1);
|
||||
assert_eq!(*input_tokens, 100);
|
||||
assert_eq!(*output_tokens, 20);
|
||||
assert_eq!(*cache_read_tokens, 10);
|
||||
|
||||
let remaining: i64 =
|
||||
conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
assert_eq!(remaining, 0);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollup_noop_when_no_old_data() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
@@ -32,6 +32,7 @@ mod schema;
|
||||
mod tests;
|
||||
|
||||
// DAO 类型导出供外部使用
|
||||
pub(crate) use dao::providers_seed::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID;
|
||||
pub use dao::FailoverQueueItem;
|
||||
|
||||
use crate::config::get_app_config_dir;
|
||||
|
||||
@@ -214,6 +214,7 @@ impl Database {
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Self::create_request_logs_usage_indexes_if_supported(conn)?;
|
||||
|
||||
// 11. Model Pricing 表
|
||||
conn.execute(
|
||||
@@ -1107,6 +1108,7 @@ impl Database {
|
||||
"data_source",
|
||||
"TEXT NOT NULL DEFAULT 'proxy'",
|
||||
)?;
|
||||
Self::create_request_logs_usage_indexes_if_supported(conn)?;
|
||||
}
|
||||
|
||||
// 2. 创建会话日志同步状态表
|
||||
@@ -1296,6 +1298,13 @@ impl Database {
|
||||
"0.30",
|
||||
"3.75",
|
||||
),
|
||||
// GPT-5.5 系列
|
||||
("gpt-5.5", "GPT-5.5", "5", "30", "0.50", "0"),
|
||||
("gpt-5.5-low", "GPT-5.5", "5", "30", "0.50", "0"),
|
||||
("gpt-5.5-medium", "GPT-5.5", "5", "30", "0.50", "0"),
|
||||
("gpt-5.5-high", "GPT-5.5", "5", "30", "0.50", "0"),
|
||||
("gpt-5.5-xhigh", "GPT-5.5", "5", "30", "0.50", "0"),
|
||||
("gpt-5.5-minimal", "GPT-5.5", "5", "30", "0.50", "0"),
|
||||
// GPT-5.4 系列
|
||||
("gpt-5.4", "GPT-5.4", "2.50", "15", "0.25", "0"),
|
||||
("gpt-5.4-mini", "GPT-5.4 Mini", "0.75", "4.50", "0.075", "0"),
|
||||
@@ -1620,6 +1629,23 @@ impl Database {
|
||||
"0.14",
|
||||
"0",
|
||||
),
|
||||
// DeepSeek V4 系列(官方 CNY 按 1 USD ≈ 7.14 折算)
|
||||
(
|
||||
"deepseek-v4-flash",
|
||||
"DeepSeek V4 Flash",
|
||||
"0.14",
|
||||
"0.28",
|
||||
"0.028",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"deepseek-v4-pro",
|
||||
"DeepSeek V4 Pro",
|
||||
"1.68",
|
||||
"3.36",
|
||||
"0.14",
|
||||
"0",
|
||||
),
|
||||
// Kimi (月之暗面)
|
||||
(
|
||||
"kimi-k2-thinking",
|
||||
@@ -1891,6 +1917,54 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_request_logs_usage_indexes_if_supported(conn: &Connection) -> Result<(), AppError> {
|
||||
if !Self::table_exists(conn, "proxy_request_logs")? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let has_app_type = Self::has_column(conn, "proxy_request_logs", "app_type")?;
|
||||
let has_created_at = Self::has_column(conn, "proxy_request_logs", "created_at")?;
|
||||
if has_app_type && has_created_at {
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_app_created_at
|
||||
ON proxy_request_logs(app_type, created_at DESC)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建使用量应用时间索引失败: {e}")))?;
|
||||
}
|
||||
|
||||
let required_columns = [
|
||||
"app_type",
|
||||
"data_source",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_tokens",
|
||||
"created_at",
|
||||
"cache_creation_tokens",
|
||||
];
|
||||
for column in required_columns {
|
||||
if !Self::has_column(conn, "proxy_request_logs", column)? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
conn.execute("DROP INDEX IF EXISTS idx_request_logs_dedup_lookup", [])
|
||||
.map_err(|e| AppError::Database(format!("删除旧使用量去重索引失败: {e}")))?;
|
||||
|
||||
// 查询层为了兼容历史 NULL data_source 行,会使用
|
||||
// COALESCE(data_source, 'proxy')。普通 data_source 索引无法匹配该表达式,
|
||||
// 会让跨源去重子查询退化成大量扫描;表达式索引让 SQLite 能按同一表达式查找。
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_dedup_lookup_expr
|
||||
ON proxy_request_logs(app_type, COALESCE(data_source, 'proxy'), input_tokens,
|
||||
output_tokens, cache_read_tokens, created_at,
|
||||
cache_creation_tokens)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建使用量去重表达式索引失败: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_identifier(s: &str, kind: &str) -> Result<(), AppError> {
|
||||
if s.is_empty() {
|
||||
return Err(AppError::Database(format!("{kind} 不能为空")));
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use super::utils::{decode_base64_param, infer_homepage_from_endpoint};
|
||||
use super::DeepLinkImportRequest;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::{Provider, ProviderMeta, UsageScript};
|
||||
use crate::provider::{ClaudeDesktopMode, Provider, ProviderMeta, UsageScript};
|
||||
use crate::services::ProviderService;
|
||||
use crate::store::AppState;
|
||||
use crate::AppType;
|
||||
@@ -142,7 +142,7 @@ pub(crate) fn build_provider_from_request(
|
||||
request: &DeepLinkImportRequest,
|
||||
) -> Result<Provider, AppError> {
|
||||
let settings_config = match app_type {
|
||||
AppType::Claude => build_claude_settings(request),
|
||||
AppType::Claude | AppType::ClaudeDesktop => build_claude_settings(request),
|
||||
AppType::Codex => build_codex_settings(request),
|
||||
AppType::Gemini => build_gemini_settings(request),
|
||||
AppType::OpenCode => build_opencode_settings(request),
|
||||
@@ -151,7 +151,11 @@ pub(crate) fn build_provider_from_request(
|
||||
};
|
||||
|
||||
// Build usage script configuration if provided
|
||||
let meta = build_provider_meta(request)?;
|
||||
let mut meta = build_provider_meta(request)?;
|
||||
if matches!(app_type, AppType::ClaudeDesktop) {
|
||||
meta.get_or_insert_with(ProviderMeta::default)
|
||||
.claude_desktop_mode = Some(ClaudeDesktopMode::Direct);
|
||||
}
|
||||
|
||||
let provider = Provider {
|
||||
id: String::new(), // Will be generated by caller
|
||||
|
||||
@@ -72,24 +72,12 @@ fn hermes_write_lock() -> &'static Mutex<()> {
|
||||
// Type Definitions
|
||||
// ============================================================================
|
||||
|
||||
/// Hermes 健康检查警告
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HermesHealthWarning {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// Hermes 写入结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HermesWriteOutcome {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub backup_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub warnings: Vec<HermesHealthWarning>,
|
||||
}
|
||||
|
||||
/// Hermes model section config
|
||||
@@ -343,8 +331,6 @@ fn write_yaml_section_to_config_locked(
|
||||
|
||||
atomic_write(&config_path, new_raw.as_bytes())?;
|
||||
|
||||
let warnings = scan_hermes_health_internal(&new_raw);
|
||||
|
||||
log::debug!(
|
||||
"Hermes config section '{}' written to {:?}",
|
||||
section_key,
|
||||
@@ -352,7 +338,6 @@ fn write_yaml_section_to_config_locked(
|
||||
);
|
||||
Ok(HermesWriteOutcome {
|
||||
backup_path: backup_path.map(|p| p.display().to_string()),
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -486,28 +471,6 @@ fn denormalize_provider_models_for_read(config: &mut serde_json::Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow a YAML scalar as a non-empty trimmed `&str`, or `None` when the
|
||||
/// value is absent, non-string, or blank after trimming.
|
||||
fn yaml_as_non_empty_str(v: &serde_yaml::Value) -> Option<&str> {
|
||||
v.as_str().map(str::trim).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Extract string keys from a YAML mapping as owned strings, dropping non-string keys.
|
||||
fn collect_mapping_string_keys(m: &serde_yaml::Mapping) -> Vec<String> {
|
||||
m.iter()
|
||||
.filter_map(|(k, _)| k.as_str().map(str::to_string))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Boilerplate-free constructor for [`HermesHealthWarning`].
|
||||
fn hermes_warning(code: &str, message: String, path: &str) -> HermesHealthWarning {
|
||||
HermesHealthWarning {
|
||||
code: code.to_string(),
|
||||
message,
|
||||
path: Some(path.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker field injected on provider payloads sourced from Hermes v12+
|
||||
/// `providers:` dict. CC Switch treats those as read-only — writes have to
|
||||
/// go through Hermes' own Web UI to keep its overlay semantics intact.
|
||||
@@ -856,160 +819,6 @@ pub fn apply_switch_defaults(
|
||||
set_model_config(&merged)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Health Check
|
||||
// ============================================================================
|
||||
|
||||
/// Scan Hermes config for known configuration hazards.
|
||||
///
|
||||
/// Parse failures are reported as warnings (not errors) so the UI can
|
||||
/// display them without blocking.
|
||||
pub fn scan_hermes_config_health() -> Result<Vec<HermesHealthWarning>, AppError> {
|
||||
let path = get_hermes_config_path();
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
|
||||
Ok(scan_hermes_health_internal(&content))
|
||||
}
|
||||
|
||||
fn scan_hermes_health_internal(content: &str) -> Vec<HermesHealthWarning> {
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
if content.trim().is_empty() {
|
||||
return warnings;
|
||||
}
|
||||
|
||||
let config = match serde_yaml::from_str::<serde_yaml::Value>(content) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(err) => {
|
||||
warnings.push(hermes_warning(
|
||||
"config_parse_failed",
|
||||
format!("Hermes config could not be parsed as YAML: {err}"),
|
||||
&get_hermes_config_path().display().to_string(),
|
||||
));
|
||||
return warnings;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(model) = config.get("model") {
|
||||
if model.get("default").is_none() && model.get("provider").is_none() {
|
||||
warnings.push(hermes_warning(
|
||||
"model_no_default",
|
||||
"No default model or provider configured in 'model' section".to_string(),
|
||||
"model",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let cp_is_mapping = config
|
||||
.get("custom_providers")
|
||||
.map(|v| v.is_mapping())
|
||||
.unwrap_or(false);
|
||||
if cp_is_mapping {
|
||||
warnings.push(hermes_warning(
|
||||
"custom_providers_not_list",
|
||||
"custom_providers should be a YAML list (sequence), not a mapping".to_string(),
|
||||
"custom_providers",
|
||||
));
|
||||
}
|
||||
|
||||
let mut provider_models: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let mut name_counts: HashMap<String, usize> = HashMap::new();
|
||||
let mut base_url_counts: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
if let Some(seq) = config.get("custom_providers").and_then(|v| v.as_sequence()) {
|
||||
for item in seq {
|
||||
if let Some(name) = item.get("name").and_then(yaml_as_non_empty_str) {
|
||||
*name_counts.entry(name.to_string()).or_insert(0) += 1;
|
||||
if let Some(models) = item.get("models").and_then(|m| m.as_mapping()) {
|
||||
provider_models
|
||||
.entry(name.to_string())
|
||||
.or_insert_with(|| collect_mapping_string_keys(models));
|
||||
}
|
||||
}
|
||||
if let Some(url) = item
|
||||
.get("base_url")
|
||||
.and_then(|n| n.as_str())
|
||||
.map(|s| s.trim().trim_end_matches('/').to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
*base_url_counts.entry(url).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// name_counts keys are unique by construction, so iterating it already
|
||||
// reports each duplicate name exactly once — no extra dedupe set needed.
|
||||
for (name, count) in &name_counts {
|
||||
if *count > 1 {
|
||||
warnings.push(hermes_warning(
|
||||
"duplicate_provider_name",
|
||||
format!(
|
||||
"Duplicate provider name '{name}' in custom_providers — only one entry will be used"
|
||||
),
|
||||
"custom_providers",
|
||||
));
|
||||
}
|
||||
}
|
||||
for (url, count) in &base_url_counts {
|
||||
if *count > 1 {
|
||||
warnings.push(hermes_warning(
|
||||
"duplicate_provider_base_url",
|
||||
format!(
|
||||
"Duplicate base_url '{url}' in custom_providers — possible accidental copy"
|
||||
),
|
||||
"custom_providers",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(model) = config.get("model") {
|
||||
if let Some(provider_ref) = model.get("provider").and_then(yaml_as_non_empty_str) {
|
||||
if !name_counts.contains_key(provider_ref) {
|
||||
warnings.push(hermes_warning(
|
||||
"model_provider_unknown",
|
||||
format!(
|
||||
"model.provider '{provider_ref}' does not match any configured provider"
|
||||
),
|
||||
"model.provider",
|
||||
));
|
||||
} else if let Some(default) = model.get("default").and_then(yaml_as_non_empty_str) {
|
||||
if let Some(ids) = provider_models.get(provider_ref) {
|
||||
if !ids.is_empty() && !ids.iter().any(|id| id == default) {
|
||||
warnings.push(hermes_warning(
|
||||
"model_default_not_in_provider",
|
||||
format!(
|
||||
"model.default '{default}' is not in provider '{provider_ref}' models list"
|
||||
),
|
||||
"model.default",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let version = config
|
||||
.get("_config_version")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let providers_dict_populated = config
|
||||
.get("providers")
|
||||
.and_then(|v| v.as_mapping())
|
||||
.map(|m| !m.is_empty())
|
||||
.unwrap_or(false);
|
||||
if version >= 12 && providers_dict_populated {
|
||||
warnings.push(hermes_warning(
|
||||
"schema_migrated_v12",
|
||||
"Hermes' newer schema moved some entries into the 'providers:' dict. They are shown read-only in CC Switch — edit or remove those entries via Hermes Web UI.".to_string(),
|
||||
"providers",
|
||||
));
|
||||
}
|
||||
|
||||
warnings
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP Section Access (for mcp/hermes.rs to use in Phase 4)
|
||||
// ============================================================================
|
||||
@@ -1727,45 +1536,6 @@ custom_providers:
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Health check tests ----
|
||||
|
||||
#[test]
|
||||
fn health_check_on_invalid_yaml() {
|
||||
let warnings = scan_hermes_health_internal("not: valid: yaml: [");
|
||||
assert!(!warnings.is_empty());
|
||||
assert_eq!(warnings[0].code, "config_parse_failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_model_no_default() {
|
||||
let yaml = "model:\n context_length: 200000\n";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
assert!(warnings.iter().any(|w| w.code == "model_no_default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_custom_providers_not_list() {
|
||||
let yaml = "custom_providers:\n foo:\n base_url: http://localhost\n";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
assert!(warnings
|
||||
.iter()
|
||||
.any(|w| w.code == "custom_providers_not_list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_valid_config() {
|
||||
let yaml = "\
|
||||
model:
|
||||
default: gpt-4
|
||||
provider: openrouter
|
||||
custom_providers:
|
||||
- name: openrouter
|
||||
base_url: https://openrouter.ai/api/v1
|
||||
";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
assert!(warnings.is_empty());
|
||||
}
|
||||
|
||||
// ---- yaml_to_json / json_to_yaml ----
|
||||
|
||||
#[test]
|
||||
@@ -2174,115 +1944,4 @@ user_profile_enabled: false
|
||||
assert_eq!(user, MemoryKind::User);
|
||||
assert!(serde_json::from_str::<MemoryKind>("\"bogus\"").is_err());
|
||||
}
|
||||
|
||||
// ---- Extended health scan rules ----
|
||||
|
||||
#[test]
|
||||
fn health_check_model_provider_unknown() {
|
||||
let yaml = "\
|
||||
model:
|
||||
default: gpt-4
|
||||
provider: ghost
|
||||
custom_providers:
|
||||
- name: real
|
||||
base_url: https://real.example.com
|
||||
";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
assert!(warnings.iter().any(|w| w.code == "model_provider_unknown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_model_default_not_in_provider() {
|
||||
let yaml = "\
|
||||
model:
|
||||
default: missing-model
|
||||
provider: real
|
||||
custom_providers:
|
||||
- name: real
|
||||
base_url: https://real.example.com
|
||||
models:
|
||||
present-model: {}
|
||||
";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
assert!(warnings
|
||||
.iter()
|
||||
.any(|w| w.code == "model_default_not_in_provider"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_duplicate_provider_name() {
|
||||
let yaml = "\
|
||||
custom_providers:
|
||||
- name: twin
|
||||
base_url: https://a.example.com
|
||||
- name: twin
|
||||
base_url: https://b.example.com
|
||||
";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
let dup_count = warnings
|
||||
.iter()
|
||||
.filter(|w| w.code == "duplicate_provider_name")
|
||||
.count();
|
||||
assert_eq!(dup_count, 1, "each duplicate name should be reported once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_duplicate_provider_base_url() {
|
||||
let yaml = "\
|
||||
custom_providers:
|
||||
- name: a
|
||||
base_url: https://same.example.com/
|
||||
- name: b
|
||||
base_url: https://SAME.example.com
|
||||
";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
assert!(warnings
|
||||
.iter()
|
||||
.any(|w| w.code == "duplicate_provider_base_url"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_schema_migrated_v12_warning() {
|
||||
let yaml = "\
|
||||
_config_version: 19
|
||||
providers:
|
||||
ds-1:
|
||||
base_url: https://api.deepseek.com
|
||||
";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
assert!(warnings.iter().any(|w| w.code == "schema_migrated_v12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_schema_migrated_not_reported_when_dict_empty() {
|
||||
// v19 with empty providers: {} should NOT surface the migration warning —
|
||||
// otherwise fresh installs would see it spuriously.
|
||||
let yaml = "\
|
||||
_config_version: 19
|
||||
providers: {}
|
||||
";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
assert!(!warnings.iter().any(|w| w.code == "schema_migrated_v12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_check_valid_config_with_matching_model_and_provider() {
|
||||
// Regression guard: none of the new rules should fire on a well-formed config.
|
||||
let yaml = "\
|
||||
model:
|
||||
default: gpt-4
|
||||
provider: openrouter
|
||||
custom_providers:
|
||||
- name: openrouter
|
||||
base_url: https://openrouter.ai/api/v1
|
||||
models:
|
||||
gpt-4: {}
|
||||
";
|
||||
let warnings = scan_hermes_health_internal(yaml);
|
||||
assert!(
|
||||
warnings.is_empty(),
|
||||
"expected no warnings, got: {:?}",
|
||||
warnings
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod app_config;
|
||||
mod app_store;
|
||||
mod auto_launch;
|
||||
mod claude_desktop_config;
|
||||
mod claude_mcp;
|
||||
mod claude_plugin;
|
||||
mod codex_config;
|
||||
@@ -64,6 +65,7 @@ use tauri::image::Image;
|
||||
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::RunEvent;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
|
||||
|
||||
fn redact_url_for_log(url_str: &str) -> String {
|
||||
match url::Url::parse(url_str) {
|
||||
@@ -264,6 +266,7 @@ pub fn run() {
|
||||
tray::apply_tray_policy(window.app_handle(), false);
|
||||
}
|
||||
} else {
|
||||
api.prevent_close();
|
||||
window.app_handle().exit(0);
|
||||
}
|
||||
}
|
||||
@@ -272,7 +275,14 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(
|
||||
tauri_plugin_window_state::Builder::default()
|
||||
.with_state_flags(window_state_flags())
|
||||
.build(),
|
||||
)
|
||||
.setup(|app| {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
// 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等)
|
||||
app_store::refresh_app_config_dir_override(app.handle());
|
||||
panic_hook::init_app_config_dir(crate::config::get_app_config_dir());
|
||||
@@ -485,6 +495,19 @@ pub fn run() {
|
||||
for app_type in
|
||||
crate::app_config::AppType::all().filter(|t| !t.is_additive_mode())
|
||||
{
|
||||
if !crate::services::provider::should_import_default_config_on_startup(
|
||||
&app_state,
|
||||
&app_type,
|
||||
)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
log::debug!(
|
||||
"○ {} already has providers; live import skipped",
|
||||
app_type.as_str()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
match crate::services::provider::import_default_config(
|
||||
&app_state,
|
||||
app_type.clone(),
|
||||
@@ -746,9 +769,17 @@ pub fn run() {
|
||||
|
||||
// 构建托盘
|
||||
let mut tray_builder = TrayIconBuilder::with_id(tray::TRAY_ID)
|
||||
.on_tray_icon_event(|_tray, event| match event {
|
||||
// 左键点击已通过 show_menu_on_left_click(true) 打开菜单,这里不再额外处理
|
||||
TrayIconEvent::Click { .. } => {}
|
||||
.tooltip("CC Switch") // 鼠标悬停提示
|
||||
.on_tray_icon_event(|tray, event| match event {
|
||||
// 鼠标悬停/点击到托盘图标时,后台异步刷新用量缓存,
|
||||
// 让用户下一次(或快速打开菜单的那一刻)看到较新的数字。
|
||||
// refresh_all_usage_in_tray 内部有 10 秒防抖。
|
||||
TrayIconEvent::Enter { .. } | TrayIconEvent::Click { .. } => {
|
||||
let app = tray.app_handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
crate::tray::refresh_all_usage_in_tray(&app).await;
|
||||
});
|
||||
}
|
||||
_ => log::debug!("unhandled event {event:?}"),
|
||||
})
|
||||
.menu(&menu)
|
||||
@@ -915,28 +946,31 @@ pub fn run() {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
const SESSION_SYNC_INTERVAL_SECS: u64 = 60;
|
||||
|
||||
fn run_step<T>(name: &str, result: Result<T, crate::error::AppError>) {
|
||||
if let Err(e) = result {
|
||||
log::warn!("{name} failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let db = &db_for_session_sync;
|
||||
|
||||
// 首次同步
|
||||
if let Err(e) =
|
||||
crate::services::session_usage::sync_claude_session_logs(
|
||||
&db_for_session_sync,
|
||||
)
|
||||
{
|
||||
log::warn!("Session usage initial sync failed: {e}");
|
||||
}
|
||||
if let Err(e) =
|
||||
crate::services::session_usage_codex::sync_codex_usage(
|
||||
&db_for_session_sync,
|
||||
)
|
||||
{
|
||||
log::warn!("Codex usage initial sync failed: {e}");
|
||||
}
|
||||
if let Err(e) =
|
||||
crate::services::session_usage_gemini::sync_gemini_usage(
|
||||
&db_for_session_sync,
|
||||
)
|
||||
{
|
||||
log::warn!("Gemini usage initial sync failed: {e}");
|
||||
}
|
||||
run_step(
|
||||
"Usage cost startup backfill",
|
||||
db.backfill_missing_usage_costs(),
|
||||
);
|
||||
run_step(
|
||||
"Session usage initial sync",
|
||||
crate::services::session_usage::sync_claude_session_logs(db),
|
||||
);
|
||||
run_step(
|
||||
"Codex usage initial sync",
|
||||
crate::services::session_usage_codex::sync_codex_usage(db),
|
||||
);
|
||||
run_step(
|
||||
"Gemini usage initial sync",
|
||||
crate::services::session_usage_gemini::sync_gemini_usage(db),
|
||||
);
|
||||
|
||||
// 定期同步
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(
|
||||
@@ -945,27 +979,18 @@ pub fn run() {
|
||||
interval.tick().await; // skip immediate first tick
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(e) =
|
||||
crate::services::session_usage::sync_claude_session_logs(
|
||||
&db_for_session_sync,
|
||||
)
|
||||
{
|
||||
log::warn!("Session usage periodic sync failed: {e}");
|
||||
}
|
||||
if let Err(e) =
|
||||
crate::services::session_usage_codex::sync_codex_usage(
|
||||
&db_for_session_sync,
|
||||
)
|
||||
{
|
||||
log::warn!("Codex usage periodic sync failed: {e}");
|
||||
}
|
||||
if let Err(e) =
|
||||
crate::services::session_usage_gemini::sync_gemini_usage(
|
||||
&db_for_session_sync,
|
||||
)
|
||||
{
|
||||
log::warn!("Gemini usage periodic sync failed: {e}");
|
||||
}
|
||||
run_step(
|
||||
"Session usage periodic sync",
|
||||
crate::services::session_usage::sync_claude_session_logs(db),
|
||||
);
|
||||
run_step(
|
||||
"Codex usage periodic sync",
|
||||
crate::services::session_usage_codex::sync_codex_usage(db),
|
||||
);
|
||||
run_step(
|
||||
"Gemini usage periodic sync",
|
||||
crate::services::session_usage_gemini::sync_gemini_usage(db),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1027,6 +1052,9 @@ pub fn run() {
|
||||
commands::remove_provider_from_live_config,
|
||||
commands::switch_provider,
|
||||
commands::import_default_config,
|
||||
commands::get_claude_desktop_status,
|
||||
commands::get_claude_desktop_default_routes,
|
||||
commands::import_claude_desktop_providers_from_claude,
|
||||
commands::get_claude_config_status,
|
||||
commands::get_config_status,
|
||||
commands::get_claude_code_config_path,
|
||||
@@ -1168,6 +1196,7 @@ pub fn run() {
|
||||
commands::get_auto_launch_status,
|
||||
// Proxy server management
|
||||
commands::start_proxy_server,
|
||||
commands::stop_proxy_server,
|
||||
commands::stop_proxy_with_restore,
|
||||
commands::get_proxy_takeover_status,
|
||||
commands::set_proxy_takeover_for_app,
|
||||
@@ -1255,7 +1284,6 @@ pub fn run() {
|
||||
commands::import_hermes_providers_from_live,
|
||||
commands::get_hermes_live_provider_ids,
|
||||
commands::get_hermes_live_provider,
|
||||
commands::scan_hermes_config_health,
|
||||
commands::get_hermes_model_config,
|
||||
commands::open_hermes_web_ui,
|
||||
commands::launch_hermes_dashboard,
|
||||
@@ -1340,6 +1368,7 @@ pub fn run() {
|
||||
|
||||
let app_handle = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
save_window_state_before_exit(&app_handle);
|
||||
cleanup_before_exit(&app_handle).await;
|
||||
log::info!("清理完成,退出应用");
|
||||
|
||||
@@ -1746,3 +1775,21 @@ fn show_database_init_error_dialog(
|
||||
))
|
||||
.blocking_show()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 在应用主动退出前显式持久化窗口状态
|
||||
// ============================================================
|
||||
|
||||
fn window_state_flags() -> StateFlags {
|
||||
StateFlags::POSITION | StateFlags::SIZE | StateFlags::MAXIMIZED
|
||||
}
|
||||
|
||||
/// 当前应用的退出路径会拦截 `ExitRequested` 并最终直接 `std::process::exit(0)`,
|
||||
/// 这里需要在真正结束进程前手动落盘,避免 window-state 插件的默认退出钩子被绕过。
|
||||
pub fn save_window_state_before_exit(app_handle: &tauri::AppHandle) {
|
||||
if let Err(err) = app_handle.save_window_state(window_state_flags()) {
|
||||
log::error!("退出前保存窗口状态失败: {err}");
|
||||
} else {
|
||||
log::info!("已在退出前保存窗口状态");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ pub fn enter_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
}
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
crate::save_window_state_before_exit(app);
|
||||
window
|
||||
.destroy()
|
||||
.map_err(|e| format!("销毁主窗口失败: {e}"))?;
|
||||
@@ -64,11 +65,12 @@ pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
|
||||
WebviewWindowBuilder::from_config(app, window_config)
|
||||
.map_err(|e| format!("加载主窗口配置失败: {e}"))?
|
||||
.visible(true)
|
||||
.build()
|
||||
.map_err(|e| format!("创建主窗口失败: {e}"))?;
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
|
||||
@@ -10,6 +10,14 @@ use crate::opencode_config::get_opencode_dir;
|
||||
|
||||
/// 返回指定应用所使用的提示词文件路径。
|
||||
pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
if matches!(app, AppType::ClaudeDesktop) {
|
||||
return Err(AppError::localized(
|
||||
"claude_desktop.prompts_unsupported",
|
||||
"Claude Desktop 暂不支持 Prompts",
|
||||
"Claude Desktop does not support Prompts",
|
||||
));
|
||||
}
|
||||
|
||||
let base_dir: PathBuf = match app {
|
||||
AppType::Claude => get_base_dir_with_fallback(get_claude_settings_path(), ".claude")?,
|
||||
AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?,
|
||||
@@ -17,6 +25,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::OpenCode => get_opencode_dir(),
|
||||
AppType::OpenClaw => get_openclaw_dir(),
|
||||
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
|
||||
AppType::ClaudeDesktop => unreachable!("handled above"),
|
||||
};
|
||||
|
||||
let filename = match app {
|
||||
@@ -24,6 +33,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
AppType::Codex => "AGENTS.md",
|
||||
AppType::Gemini => "GEMINI.md",
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
|
||||
AppType::ClaudeDesktop => unreachable!("handled above"),
|
||||
};
|
||||
|
||||
Ok(base_dir.join(filename))
|
||||
|
||||
@@ -65,6 +65,25 @@ impl Provider {
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_codex_oauth(&self) -> bool {
|
||||
self.meta.as_ref().and_then(|m| m.provider_type.as_deref()) == Some("codex_oauth")
|
||||
}
|
||||
|
||||
pub fn codex_fast_mode_enabled(&self) -> bool {
|
||||
self.meta
|
||||
.as_ref()
|
||||
.map(|m| m.codex_fast_mode_enabled())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn has_usage_script_enabled(&self) -> bool {
|
||||
self.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.usage_script.as_ref())
|
||||
.map(|s| s.enabled)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// 供应商管理器
|
||||
@@ -197,6 +216,28 @@ pub struct AuthBinding {
|
||||
pub account_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Claude Desktop 3P 写入模式。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ClaudeDesktopMode {
|
||||
Direct,
|
||||
Proxy,
|
||||
}
|
||||
|
||||
/// Claude Desktop 本地路由模式下暴露给 Desktop 的安全模型路由。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
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 3P 识别的 1M 上下文能力标记。
|
||||
#[serde(rename = "supports1m", skip_serializing_if = "Option::is_none")]
|
||||
pub supports_1m: Option<bool>,
|
||||
}
|
||||
|
||||
/// 供应商元数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderMeta {
|
||||
@@ -209,6 +250,16 @@ pub struct ProviderMeta {
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub common_config_enabled: Option<bool>,
|
||||
/// Claude Desktop 3P 写入模式:direct(直连)或 proxy(预留)
|
||||
#[serde(rename = "claudeDesktopMode", skip_serializing_if = "Option::is_none")]
|
||||
pub claude_desktop_mode: Option<ClaudeDesktopMode>,
|
||||
/// Claude Desktop proxy 模式的模型路由映射:Claude-safe route -> upstream model。
|
||||
#[serde(
|
||||
default,
|
||||
rename = "claudeDesktopModelRoutes",
|
||||
skip_serializing_if = "HashMap::is_empty"
|
||||
)]
|
||||
pub claude_desktop_model_routes: HashMap<String, ClaudeDesktopModelRoute>,
|
||||
/// 用量查询脚本配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage_script: Option<UsageScript>,
|
||||
@@ -258,9 +309,13 @@ pub struct ProviderMeta {
|
||||
pub is_full_url: Option<bool>,
|
||||
/// Prompt cache key for OpenAI Responses-compatible endpoints.
|
||||
/// When set, injected into converted Responses requests to improve cache hit rate.
|
||||
/// If not set, provider ID is used automatically during Claude -> Responses conversion.
|
||||
/// If not set, Claude -> Responses conversions use a client-provided session/thread
|
||||
/// identity when available; generated session IDs are not sent upstream.
|
||||
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
/// Codex OAuth FAST mode: inject `service_tier = "priority"` for ChatGPT Codex requests.
|
||||
#[serde(rename = "codexFastMode", skip_serializing_if = "Option::is_none")]
|
||||
pub codex_fast_mode: Option<bool>,
|
||||
/// 累加模式应用中,该 provider 是否已写入 live config。
|
||||
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
|
||||
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
|
||||
@@ -276,6 +331,12 @@ pub struct ProviderMeta {
|
||||
}
|
||||
|
||||
impl ProviderMeta {
|
||||
/// Codex OAuth FAST mode 是否启用。默认关闭,因为 `service_tier="priority"`
|
||||
/// 会按更高速率消耗 ChatGPT 订阅配额,用户需显式开启以换取更低延迟。
|
||||
pub fn codex_fast_mode_enabled(&self) -> bool {
|
||||
self.codex_fast_mode.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 解析指定托管认证供应商绑定的账号 ID。
|
||||
///
|
||||
/// 新版优先读取 authBinding,旧版继续兼容 githubAccountId。
|
||||
@@ -699,8 +760,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn provider_meta_serializes_pricing_model_source() {
|
||||
let mut meta = ProviderMeta::default();
|
||||
meta.pricing_model_source = Some("response".to_string());
|
||||
let meta = ProviderMeta {
|
||||
pricing_model_source: Some("response".to_string()),
|
||||
..ProviderMeta::default()
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
//! - 以 `_` 开头的字段被视为私有参数,会被递归过滤
|
||||
//! - 支持白名单机制,允许透传特定的 `_` 前缀字段
|
||||
//! - 支持嵌套对象和数组的深度过滤
|
||||
//! - JSON Schema 的 properties / patternProperties / definitions / $defs 名称
|
||||
//! 是用户定义的字段名,不按私有参数过滤
|
||||
//!
|
||||
//! ## 使用场景
|
||||
//! - `_internal_id`: 内部追踪 ID
|
||||
@@ -65,29 +67,35 @@ pub fn filter_private_params(body: Value) -> Value {
|
||||
/// ```
|
||||
pub fn filter_private_params_with_whitelist(body: Value, whitelist: &[String]) -> Value {
|
||||
let whitelist_set: HashSet<&str> = whitelist.iter().map(|s| s.as_str()).collect();
|
||||
filter_recursive_with_whitelist(body, &mut Vec::new(), &whitelist_set)
|
||||
filter_recursive_with_whitelist(body, &mut Vec::new(), &mut Vec::new(), &whitelist_set)
|
||||
}
|
||||
|
||||
/// 递归过滤实现(支持白名单)
|
||||
fn filter_recursive_with_whitelist(
|
||||
value: Value,
|
||||
path: &mut Vec<String>,
|
||||
removed_keys: &mut Vec<String>,
|
||||
whitelist: &HashSet<&str>,
|
||||
) -> Value {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let is_schema_name_map = path.last().is_some_and(|key| matches_schema_name_map(key));
|
||||
let filtered: serde_json::Map<String, Value> = map
|
||||
.into_iter()
|
||||
.filter_map(|(key, val)| {
|
||||
// 以 _ 开头且不在白名单中的字段被过滤
|
||||
if key.starts_with('_') && !whitelist.contains(key.as_str()) {
|
||||
if key.starts_with('_')
|
||||
&& !whitelist.contains(key.as_str())
|
||||
&& !is_schema_name_map
|
||||
{
|
||||
removed_keys.push(key);
|
||||
None
|
||||
} else {
|
||||
Some((
|
||||
key,
|
||||
filter_recursive_with_whitelist(val, removed_keys, whitelist),
|
||||
))
|
||||
path.push(key.clone());
|
||||
let filtered_value =
|
||||
filter_recursive_with_whitelist(val, path, removed_keys, whitelist);
|
||||
path.pop();
|
||||
Some((key, filtered_value))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -102,13 +110,20 @@ fn filter_recursive_with_whitelist(
|
||||
}
|
||||
Value::Array(arr) => Value::Array(
|
||||
arr.into_iter()
|
||||
.map(|v| filter_recursive_with_whitelist(v, removed_keys, whitelist))
|
||||
.map(|v| filter_recursive_with_whitelist(v, path, removed_keys, whitelist))
|
||||
.collect(),
|
||||
),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_schema_name_map(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"properties" | "patternProperties" | "definitions" | "$defs"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -282,6 +297,33 @@ mod tests {
|
||||
assert!(data.get("normal").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preserves_json_schema_property_names_with_underscore() {
|
||||
let input = json!({
|
||||
"tools": [
|
||||
{
|
||||
"name": "lookup",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_id": {"type": "string", "_internal_note": "remove"},
|
||||
"_meta": {"type": "object"}
|
||||
},
|
||||
"_private_schema_note": "remove"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let output = filter_private_params(input);
|
||||
let schema = &output["tools"][0]["input_schema"];
|
||||
|
||||
assert!(schema["properties"].get("_id").is_some());
|
||||
assert!(schema["properties"].get("_meta").is_some());
|
||||
assert!(schema["properties"]["_id"].get("_internal_note").is_none());
|
||||
assert!(schema.get("_private_schema_note").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_whitelist_same_as_default() {
|
||||
let input = json!({
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::{
|
||||
body_filter::filter_private_params_with_whitelist,
|
||||
error::*,
|
||||
failover_switch::FailoverSwitchManager,
|
||||
json_canonical::{canonicalize_value, short_value_hash},
|
||||
log_codes::fwd as log_fwd,
|
||||
provider_router::ProviderRouter,
|
||||
providers::{
|
||||
@@ -55,6 +56,8 @@ pub struct RequestForwarder {
|
||||
current_provider_id_at_start: String,
|
||||
/// 代理会话 ID(用于 Gemini Native shadow replay)
|
||||
session_id: String,
|
||||
/// Session ID 是否由客户端提供;生成值不能作为上游缓存身份。
|
||||
session_client_provided: bool,
|
||||
/// 整流器配置
|
||||
rectifier_config: RectifierConfig,
|
||||
/// 优化器配置
|
||||
@@ -63,6 +66,8 @@ pub struct RequestForwarder {
|
||||
copilot_optimizer_config: CopilotOptimizerConfig,
|
||||
/// 非流式请求超时(秒)
|
||||
non_streaming_timeout: std::time::Duration,
|
||||
/// 流式请求响应头等待超时(秒)
|
||||
streaming_first_byte_timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl RequestForwarder {
|
||||
@@ -77,7 +82,8 @@ impl RequestForwarder {
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
current_provider_id_at_start: String,
|
||||
session_id: String,
|
||||
_streaming_first_byte_timeout: u64,
|
||||
session_client_provided: bool,
|
||||
streaming_first_byte_timeout: u64,
|
||||
_streaming_idle_timeout: u64,
|
||||
rectifier_config: RectifierConfig,
|
||||
optimizer_config: OptimizerConfig,
|
||||
@@ -92,13 +98,51 @@ impl RequestForwarder {
|
||||
app_handle,
|
||||
current_provider_id_at_start,
|
||||
session_id,
|
||||
session_client_provided,
|
||||
rectifier_config,
|
||||
optimizer_config,
|
||||
copilot_optimizer_config,
|
||||
non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),
|
||||
streaming_first_byte_timeout: std::time::Duration::from_secs(
|
||||
streaming_first_byte_timeout,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_success_result(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
used_half_open_permit: bool,
|
||||
) {
|
||||
if used_half_open_permit {
|
||||
if let Err(e) = self
|
||||
.router
|
||||
.record_result(provider_id, app_type, true, true, None)
|
||||
.await
|
||||
{
|
||||
log::warn!(
|
||||
"[{app_type}] 记录 Provider 成功结果失败: provider_id={provider_id}, error={e}"
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let router = self.router.clone();
|
||||
let provider_id = provider_id.to_string();
|
||||
let app_type = app_type.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = router
|
||||
.record_result(&provider_id, &app_type, false, true, None)
|
||||
.await
|
||||
{
|
||||
log::warn!(
|
||||
"[{app_type}] 异步记录 Provider 成功结果失败: provider_id={provider_id}, error={e}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 转发请求(带故障转移)
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -186,6 +230,7 @@ impl RequestForwarder {
|
||||
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制)
|
||||
match self
|
||||
.forward(
|
||||
app_type,
|
||||
provider,
|
||||
endpoint,
|
||||
&provider_body,
|
||||
@@ -196,16 +241,9 @@ impl RequestForwarder {
|
||||
.await
|
||||
{
|
||||
Ok((response, claude_api_format)) => {
|
||||
// 成功:记录成功并更新熔断器
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
// 成功:普通闭合熔断状态异步记录,避免阻塞流式首包返回;
|
||||
// HalfOpen 探测仍同步等待,保证 permit 与熔断状态及时释放。
|
||||
self.record_success_result(&provider.id, app_type_str, used_half_open_permit)
|
||||
.await;
|
||||
|
||||
// 更新当前应用类型使用的 provider
|
||||
@@ -316,6 +354,7 @@ impl RequestForwarder {
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
.forward(
|
||||
app_type,
|
||||
provider,
|
||||
endpoint,
|
||||
&provider_body,
|
||||
@@ -327,17 +366,12 @@ impl RequestForwarder {
|
||||
{
|
||||
Ok((response, claude_api_format)) => {
|
||||
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||
// 记录成功
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
self.record_success_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 更新当前应用类型使用的 provider
|
||||
{
|
||||
@@ -515,6 +549,7 @@ impl RequestForwarder {
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
.forward(
|
||||
app_type,
|
||||
provider,
|
||||
endpoint,
|
||||
&provider_body,
|
||||
@@ -526,16 +561,12 @@ impl RequestForwarder {
|
||||
{
|
||||
Ok((response, claude_api_format)) => {
|
||||
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
self.record_success_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
|
||||
{
|
||||
let mut current_providers =
|
||||
@@ -752,8 +783,10 @@ impl RequestForwarder {
|
||||
}
|
||||
|
||||
/// 转发单个请求(使用适配器)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn forward(
|
||||
&self,
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
endpoint: &str,
|
||||
body: &Value,
|
||||
@@ -771,8 +804,16 @@ impl RequestForwarder {
|
||||
.unwrap_or(false);
|
||||
|
||||
// 应用模型映射(独立于格式转换)
|
||||
let (mapped_body, _original_model, _mapped_model) =
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
// Claude Desktop proxy 模式必须先把 Desktop 可见的 claude-* route
|
||||
// 映射成真实上游模型名,并且未知 route 要直接报错,不能使用默认模型兜底。
|
||||
let mapped_body = if matches!(app_type, AppType::ClaudeDesktop) {
|
||||
crate::claude_desktop_config::map_proxy_request_model(body.clone(), provider)
|
||||
.map_err(|e| ProxyError::InvalidRequest(e.to_string()))?
|
||||
} else {
|
||||
let (mapped_body, _original_model, _mapped_model) =
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
mapped_body
|
||||
};
|
||||
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mut mapped_body = normalize_thinking_type(mapped_body);
|
||||
@@ -786,6 +827,13 @@ impl RequestForwarder {
|
||||
== 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;
|
||||
}
|
||||
|
||||
// --- Copilot 优化器:分类 + 请求体优化(在格式转换之前执行) ---
|
||||
// 注意:确定性 ID 也在此处计算,因为 mapped_body 在格式转换时会被 move
|
||||
//
|
||||
@@ -966,7 +1014,8 @@ impl RequestForwarder {
|
||||
mapped_body,
|
||||
provider,
|
||||
api_format,
|
||||
Some(&self.session_id),
|
||||
self.session_client_provided
|
||||
.then_some(self.session_id.as_str()),
|
||||
Some(self.gemini_shadow.as_ref()),
|
||||
)?
|
||||
} else {
|
||||
@@ -978,12 +1027,21 @@ impl RequestForwarder {
|
||||
|
||||
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
|
||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||
let filtered_body = filter_private_params_with_whitelist(request_body, &[]);
|
||||
let filtered_body = prepare_upstream_request_body(request_body);
|
||||
log_prompt_cache_trace(
|
||||
app_type,
|
||||
provider,
|
||||
&effective_endpoint,
|
||||
resolved_claude_api_format.as_deref(),
|
||||
&filtered_body,
|
||||
self.session_client_provided,
|
||||
);
|
||||
let force_identity_encoding = needs_transform
|
||||
|| should_force_identity_encoding(&effective_endpoint, &filtered_body, headers);
|
||||
|
||||
// Codex OAuth 需要注入的 ChatGPT-Account-Id(在动态 token 获取期间填充)
|
||||
let mut codex_oauth_account_id: Option<String> = None;
|
||||
let mut should_send_codex_oauth_session_headers = false;
|
||||
|
||||
// 获取认证头(提前准备,用于内联替换)
|
||||
let mut auth_headers = if let Some(mut auth) = adapter.extract_auth(provider) {
|
||||
@@ -1065,6 +1123,7 @@ impl RequestForwarder {
|
||||
match token_result {
|
||||
Ok(token) => {
|
||||
auth = AuthInfo::new(token, AuthStrategy::CodexOAuth);
|
||||
should_send_codex_oauth_session_headers = true;
|
||||
// 解析使用的 account_id(用于注入 ChatGPT-Account-Id header)
|
||||
codex_oauth_account_id = match account_id {
|
||||
Some(id) => Some(id),
|
||||
@@ -1102,6 +1161,13 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
let codex_oauth_session_headers =
|
||||
if should_send_codex_oauth_session_headers && self.session_client_provided {
|
||||
build_codex_oauth_session_headers(&self.session_id)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// --- Copilot 优化器:动态 header 注入 ---
|
||||
if let Some((ref classification, ref det_request_id, ref interaction_id)) =
|
||||
copilot_optimization
|
||||
@@ -1166,6 +1232,10 @@ impl RequestForwarder {
|
||||
.parse::<http::Uri>()
|
||||
.ok()
|
||||
.and_then(|u| u.authority().map(|a| a.to_string()));
|
||||
let strip_openrouter_hop_by_hop_headers =
|
||||
should_strip_openrouter_hop_by_hop_request_headers(provider, &base_url);
|
||||
let connection_header_tokens =
|
||||
strip_openrouter_hop_by_hop_headers.then(|| collect_connection_header_tokens(headers));
|
||||
|
||||
let should_send_anthropic_headers = adapter.name() == "Claude"
|
||||
&& matches!(resolved_claude_api_format.as_deref(), Some("anthropic"));
|
||||
@@ -1246,6 +1316,13 @@ impl RequestForwarder {
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- OpenRouter 全请求:补充剥离 hop-by-hop 请求头 ---
|
||||
if let Some(connection_header_tokens) = connection_header_tokens.as_ref() {
|
||||
if should_strip_openrouter_request_header(key_str, connection_header_tokens) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 认证类 — 用 adapter 提供的认证头替换(在原始位置) ---
|
||||
if key_str.eq_ignore_ascii_case("authorization")
|
||||
|| key_str.eq_ignore_ascii_case("x-api-key")
|
||||
@@ -1342,6 +1419,12 @@ impl RequestForwarder {
|
||||
);
|
||||
}
|
||||
|
||||
// Codex OAuth 反代尽量对齐官方 Codex CLI 的会话路由信号。
|
||||
// 只发送客户端提供的 session_id;生成的 UUID 每次不同,反而会破坏前缀缓存。
|
||||
for (name, value) in codex_oauth_session_headers {
|
||||
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}")))?;
|
||||
@@ -1361,12 +1444,14 @@ impl RequestForwarder {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("<none>");
|
||||
log::info!("[{tag}] >>> 请求 URL: {url} (model={request_model})");
|
||||
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
|
||||
log::debug!(
|
||||
"[{tag}] >>> 请求体内容 ({}字节): {}",
|
||||
body_str.len(),
|
||||
body_str
|
||||
);
|
||||
if log::log_enabled!(log::Level::Debug) {
|
||||
if let Ok(body_str) = serde_json::to_string(&filtered_body) {
|
||||
log::debug!(
|
||||
"[{tag}] >>> 请求体内容 ({}字节): {}",
|
||||
body_str.len(),
|
||||
body_str
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 确定超时
|
||||
@@ -1385,35 +1470,60 @@ impl RequestForwarder {
|
||||
.map(|u| u.starts_with("socks5"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let uri: http::Uri = url
|
||||
.parse()
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid URL '{url}': {e}")))?;
|
||||
let preserve_exact_header_case = should_preserve_exact_header_case(
|
||||
adapter.name(),
|
||||
provider,
|
||||
resolved_claude_api_format.as_deref(),
|
||||
is_copilot,
|
||||
);
|
||||
|
||||
// 发送请求
|
||||
let response = if is_socks_proxy {
|
||||
// SOCKS5 代理:只能走 reqwest(不支持 header case 保留)
|
||||
log::debug!("[Forwarder] Using reqwest for SOCKS5 proxy");
|
||||
let response = if is_socks_proxy || !preserve_exact_header_case {
|
||||
// OpenAI / Copilot / Codex 类后端不依赖原始 header 大小写;走 reqwest
|
||||
// 连接池,避免 raw TCP/TLS path 每次请求都重新握手。SOCKS5 也只能走 reqwest。
|
||||
log::debug!(
|
||||
"[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);
|
||||
if !self.non_streaming_timeout.is_zero() {
|
||||
let request_is_streaming =
|
||||
is_streaming_request(&effective_endpoint, &filtered_body, headers);
|
||||
if request_is_streaming {
|
||||
// reqwest 的 timeout 是整请求超时;流式请求交给 response_processor
|
||||
// 的首包/静默期超时控制,避免长流被总时长误杀。
|
||||
request = request.timeout(std::time::Duration::from_secs(24 * 60 * 60));
|
||||
} else if !self.non_streaming_timeout.is_zero() {
|
||||
request = request.timeout(self.non_streaming_timeout);
|
||||
}
|
||||
for (key, value) in &ordered_headers {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
let reqwest_resp = request.body(body_bytes).send().await.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
ProxyError::Timeout(format!("请求超时: {e}"))
|
||||
} else if e.is_connect() {
|
||||
ProxyError::ForwardFailed(format!("连接失败: {e}"))
|
||||
let send = request.body(body_bytes).send();
|
||||
let send_result = if request_is_streaming {
|
||||
let header_timeout = if self.streaming_first_byte_timeout.is_zero() {
|
||||
timeout
|
||||
} else {
|
||||
ProxyError::ForwardFailed(e.to_string())
|
||||
}
|
||||
})?;
|
||||
self.streaming_first_byte_timeout
|
||||
};
|
||||
tokio::time::timeout(header_timeout, send)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ProxyError::Timeout(format!(
|
||||
"流式响应首包超时: {}s(上游未返回响应头)",
|
||||
header_timeout.as_secs()
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
send.await
|
||||
};
|
||||
let reqwest_resp = send_result.map_err(map_reqwest_send_error)?;
|
||||
ProxyResponse::Reqwest(reqwest_resp)
|
||||
} else {
|
||||
// HTTP 代理或直连:走 hyper raw write(保持 header 大小写)
|
||||
// 如果有 HTTP 代理,hyper_client 会用 CONNECT 隧道穿过代理
|
||||
let uri: http::Uri = url
|
||||
.parse()
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid URL '{url}': {e}")))?;
|
||||
super::hyper_client::send_request(
|
||||
uri,
|
||||
http::Method::POST,
|
||||
@@ -1465,6 +1575,49 @@ impl RequestForwarder {
|
||||
"openai_chat".to_string()
|
||||
}
|
||||
|
||||
/// 用 Copilot live `/models` 列表确认 model ID 真实可用,找不到时按 family 降级。
|
||||
/// 命中缓存后是同步的;首次请求或 5 min 缓存过期后会触发一次 HTTP。
|
||||
async fn apply_copilot_live_model_resolution(
|
||||
&self,
|
||||
provider: &Provider,
|
||||
body: &mut serde_json::Value,
|
||||
) {
|
||||
let Some(model_id) = body.get("model").and_then(|v| v.as_str()) else {
|
||||
return;
|
||||
};
|
||||
let model_id = model_id.to_string();
|
||||
|
||||
let Some(app_handle) = &self.app_handle else {
|
||||
return;
|
||||
};
|
||||
let copilot_state = app_handle.state::<CopilotAuthState>();
|
||||
let copilot_auth = copilot_state.0.read().await;
|
||||
let account_id = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.managed_account_id_for("github_copilot"));
|
||||
|
||||
let models_result = match account_id.as_deref() {
|
||||
Some(id) => copilot_auth.fetch_models_for_account(id).await,
|
||||
None => copilot_auth.fetch_models().await,
|
||||
};
|
||||
|
||||
let models = match models_result {
|
||||
Ok(m) => m,
|
||||
Err(err) => {
|
||||
log::debug!("[Copilot] live model list unavailable, skip resolution: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(resolved) =
|
||||
super::providers::copilot_model_map::resolve_against_models(&model_id, &models)
|
||||
{
|
||||
log::info!("[Copilot] live-model resolve: {model_id} → {resolved}");
|
||||
body["model"] = serde_json::Value::String(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_copilot_openai_vendor_model(&self, provider: &Provider, model_id: &str) -> bool {
|
||||
let Some(app_handle) = &self.app_handle else {
|
||||
log::debug!("[Copilot] AppHandle unavailable, fallback to chat/completions");
|
||||
@@ -1546,6 +1699,52 @@ fn is_bedrock_provider(provider: &Provider) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
const OPENROUTER_HOP_BY_HOP_REQUEST_HEADERS: &[&str] = &[
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"trailers",
|
||||
"upgrade",
|
||||
];
|
||||
|
||||
fn should_strip_openrouter_hop_by_hop_request_headers(provider: &Provider, base_url: &str) -> bool {
|
||||
let provider_type = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.provider_type.as_deref())
|
||||
.unwrap_or_default();
|
||||
|
||||
provider_type.eq_ignore_ascii_case(ProviderType::OpenRouter.as_str())
|
||||
|| base_url.to_ascii_lowercase().contains("openrouter.ai")
|
||||
}
|
||||
|
||||
fn collect_connection_header_tokens(
|
||||
headers: &axum::http::HeaderMap,
|
||||
) -> std::collections::HashSet<String> {
|
||||
headers
|
||||
.get_all("connection")
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.flat_map(|value| value.split(','))
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(|name| name.to_ascii_lowercase())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn should_strip_openrouter_request_header(
|
||||
key_str: &str,
|
||||
connection_header_tokens: &std::collections::HashSet<String>,
|
||||
) -> bool {
|
||||
let lower_key = key_str.to_ascii_lowercase();
|
||||
OPENROUTER_HOP_BY_HOP_REQUEST_HEADERS.contains(&lower_key.as_str())
|
||||
|| connection_header_tokens.contains(lower_key.as_str())
|
||||
}
|
||||
|
||||
fn build_retryable_failure_log(
|
||||
provider_name: &str,
|
||||
attempted_providers: usize,
|
||||
@@ -1773,11 +1972,46 @@ fn append_query_to_full_url(base_url: &str, query: Option<&str>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn should_force_identity_encoding(
|
||||
endpoint: &str,
|
||||
body: &Value,
|
||||
headers: &axum::http::HeaderMap,
|
||||
fn build_codex_oauth_session_headers(
|
||||
session_id: &str,
|
||||
) -> Vec<(http::HeaderName, http::HeaderValue)> {
|
||||
let session_id = session_id.trim();
|
||||
if session_id.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut headers = Vec::new();
|
||||
if let Ok(value) = http::HeaderValue::from_str(session_id) {
|
||||
headers.push((http::HeaderName::from_static("session_id"), value.clone()));
|
||||
headers.push((http::HeaderName::from_static("x-client-request-id"), value));
|
||||
}
|
||||
|
||||
let window_id = format!("{session_id}:0");
|
||||
if let Ok(value) = http::HeaderValue::from_str(&window_id) {
|
||||
headers.push((http::HeaderName::from_static("x-codex-window-id"), value));
|
||||
}
|
||||
|
||||
headers
|
||||
}
|
||||
|
||||
fn should_preserve_exact_header_case(
|
||||
adapter_name: &str,
|
||||
provider: &Provider,
|
||||
resolved_claude_api_format: Option<&str>,
|
||||
is_copilot: bool,
|
||||
) -> bool {
|
||||
if matches!(adapter_name, "Codex" | "Gemini") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if is_copilot || provider.is_codex_oauth() {
|
||||
return false;
|
||||
}
|
||||
|
||||
matches!(resolved_claude_api_format, None | Some("anthropic"))
|
||||
}
|
||||
|
||||
fn is_streaming_request(endpoint: &str, body: &Value, headers: &axum::http::HeaderMap) -> bool {
|
||||
if body
|
||||
.get("stream")
|
||||
.and_then(|value| value.as_bool())
|
||||
@@ -1797,6 +2031,24 @@ fn should_force_identity_encoding(
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn should_force_identity_encoding(
|
||||
endpoint: &str,
|
||||
body: &Value,
|
||||
headers: &axum::http::HeaderMap,
|
||||
) -> bool {
|
||||
is_streaming_request(endpoint, body, headers)
|
||||
}
|
||||
|
||||
fn map_reqwest_send_error(error: reqwest::Error) -> ProxyError {
|
||||
if error.is_timeout() {
|
||||
ProxyError::Timeout(format!("请求超时: {error}"))
|
||||
} else if error.is_connect() {
|
||||
ProxyError::ForwardFailed(format!("连接失败: {error}"))
|
||||
} else {
|
||||
ProxyError::ForwardFailed(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
|
||||
let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let trimmed = normalized.trim();
|
||||
@@ -1810,6 +2062,65 @@ fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
|
||||
format!("{truncated}...")
|
||||
}
|
||||
|
||||
fn prepare_upstream_request_body(request_body: Value) -> Value {
|
||||
canonicalize_value(filter_private_params_with_whitelist(request_body, &[]))
|
||||
}
|
||||
|
||||
fn log_prompt_cache_trace(
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
endpoint: &str,
|
||||
api_format: Option<&str>,
|
||||
body: &Value,
|
||||
session_client_provided: bool,
|
||||
) {
|
||||
if !log::log_enabled!(log::Level::Debug) {
|
||||
return;
|
||||
}
|
||||
|
||||
let prompt_cache_key = body
|
||||
.get("prompt_cache_key")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|key| format!("present(len={})", key.len()))
|
||||
.unwrap_or_else(|| "absent".to_string());
|
||||
let store = body
|
||||
.get("store")
|
||||
.map(value_for_log)
|
||||
.unwrap_or_else(|| "absent".to_string());
|
||||
let stream = body
|
||||
.get("stream")
|
||||
.map(value_for_log)
|
||||
.unwrap_or_else(|| "absent".to_string());
|
||||
|
||||
log::debug!(
|
||||
"[CacheTrace] app={}, provider={}, endpoint={}, api_format={}, session_client_provided={}, prompt_cache_key={}, store={}, stream={}, instructions_hash={}, tools_hash={}, input_hash={}, include_hash={}, body_hash={}",
|
||||
app_type.as_str(),
|
||||
provider.id,
|
||||
endpoint,
|
||||
api_format.unwrap_or("native"),
|
||||
session_client_provided,
|
||||
prompt_cache_key,
|
||||
store,
|
||||
stream,
|
||||
short_value_hash(body.get("instructions")),
|
||||
short_value_hash(body.get("tools")),
|
||||
short_value_hash(body.get("input")),
|
||||
short_value_hash(body.get("include")),
|
||||
short_value_hash(Some(body)),
|
||||
);
|
||||
}
|
||||
|
||||
fn value_for_log(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Bool(value) => value.to_string(),
|
||||
Value::Number(value) => value.to_string(),
|
||||
Value::String(value) => value.clone(),
|
||||
Value::Null => "null".to_string(),
|
||||
Value::Array(values) => format!("array(len={})", values.len()),
|
||||
Value::Object(values) => format!("object(len={})", values.len()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1817,6 +2128,26 @@ mod tests {
|
||||
use axum::http::HeaderMap;
|
||||
use serde_json::json;
|
||||
|
||||
fn test_provider_with_type(provider_type: Option<&str>) -> Provider {
|
||||
Provider {
|
||||
id: "provider-1".to_string(),
|
||||
name: "Provider 1".to_string(),
|
||||
settings_config: json!({}),
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: provider_type.map(|value| crate::provider::ProviderMeta {
|
||||
provider_type: Some(value.to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_provider_retryable_log_uses_single_provider_code() {
|
||||
let error = ProxyError::UpstreamError {
|
||||
@@ -1882,6 +2213,151 @@ mod tests {
|
||||
assert_eq!(summary, "line1 line2...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_json_sorts_object_keys_for_cache_trace_hashes() {
|
||||
let left = json!({
|
||||
"tools": [
|
||||
{
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"b": {"type": "string"},
|
||||
"a": {"type": "number"}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"name": "lookup"
|
||||
}
|
||||
]
|
||||
});
|
||||
let right = json!({
|
||||
"tools": [
|
||||
{
|
||||
"name": "lookup",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"a": {"type": "number"},
|
||||
"b": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
crate::proxy::json_canonical::canonical_json_string(&left),
|
||||
crate::proxy::json_canonical::canonical_json_string(&right)
|
||||
);
|
||||
assert_eq!(
|
||||
short_value_hash(Some(&left)),
|
||||
short_value_hash(Some(&right))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_upstream_request_body_filters_private_fields_and_canonicalizes_order() {
|
||||
let body = json!({
|
||||
"z": 1,
|
||||
"_internal": "drop",
|
||||
"tools": [
|
||||
{
|
||||
"name": "lookup",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_id": {
|
||||
"_private_note": "drop",
|
||||
"type": "string"
|
||||
},
|
||||
"b": {"type": "number"},
|
||||
"a": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"a": 2
|
||||
});
|
||||
|
||||
let prepared = prepare_upstream_request_body(body);
|
||||
|
||||
assert!(prepared.get("_internal").is_none());
|
||||
assert!(prepared["tools"][0]["parameters"]["properties"]
|
||||
.get("_id")
|
||||
.is_some());
|
||||
assert!(prepared["tools"][0]["parameters"]["properties"]["_id"]
|
||||
.get("_private_note")
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
serde_json::to_string(&prepared).unwrap(),
|
||||
r#"{"a":2,"tools":[{"name":"lookup","parameters":{"properties":{"_id":{"type":"string"},"a":{"type":"string"},"b":{"type":"number"}},"type":"object"}}],"z":1}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_oauth_session_headers_match_codex_cache_identity() {
|
||||
let headers = build_codex_oauth_session_headers("session-123");
|
||||
let mut map = HeaderMap::new();
|
||||
for (name, value) in headers {
|
||||
map.insert(name, value);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
map.get("session_id"),
|
||||
Some(&HeaderValue::from_static("session-123"))
|
||||
);
|
||||
assert_eq!(
|
||||
map.get("x-client-request-id"),
|
||||
Some(&HeaderValue::from_static("session-123"))
|
||||
);
|
||||
assert_eq!(
|
||||
map.get("x-codex-window-id"),
|
||||
Some(&HeaderValue::from_static("session-123:0"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_header_case_preserved_for_native_claude_only() {
|
||||
let provider = test_provider_with_type(None);
|
||||
|
||||
assert!(should_preserve_exact_header_case(
|
||||
"Claude",
|
||||
&provider,
|
||||
Some("anthropic"),
|
||||
false
|
||||
));
|
||||
assert!(!should_preserve_exact_header_case(
|
||||
"Claude",
|
||||
&provider,
|
||||
Some("openai_responses"),
|
||||
false
|
||||
));
|
||||
assert!(!should_preserve_exact_header_case(
|
||||
"Codex", &provider, None, false
|
||||
));
|
||||
assert!(!should_preserve_exact_header_case(
|
||||
"Gemini", &provider, None, false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_header_case_skipped_for_codex_oauth_and_copilot() {
|
||||
let codex_oauth = test_provider_with_type(Some("codex_oauth"));
|
||||
let copilot = test_provider_with_type(Some("github_copilot"));
|
||||
|
||||
assert!(!should_preserve_exact_header_case(
|
||||
"Claude",
|
||||
&codex_oauth,
|
||||
Some("openai_responses"),
|
||||
false
|
||||
));
|
||||
assert!(!should_preserve_exact_header_case(
|
||||
"Claude",
|
||||
&copilot,
|
||||
Some("openai_chat"),
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_claude_transform_endpoint_strips_beta_for_chat_completions() {
|
||||
let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint(
|
||||
@@ -2047,6 +2523,17 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_request_detects_gemini_sse_without_body_stream_flag() {
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
assert!(is_streaming_request(
|
||||
"/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse",
|
||||
&json!({ "model": "gemini-2.5-pro" }),
|
||||
&headers
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_identity_for_sse_accept_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
@@ -2070,6 +2557,90 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_connection_header_tokens_tracks_dynamic_hop_by_hop_headers() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"connection",
|
||||
HeaderValue::from_static("keep-alive, x-custom-hop, Upgrade"),
|
||||
);
|
||||
|
||||
let tokens = collect_connection_header_tokens(&headers);
|
||||
|
||||
assert!(tokens.contains("keep-alive"));
|
||||
assert!(tokens.contains("x-custom-hop"));
|
||||
assert!(tokens.contains("upgrade"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_strip_openrouter_hop_by_hop_request_headers_for_any_openrouter_base_url() {
|
||||
let mut custom_domain_openrouter = Provider::with_id(
|
||||
"openrouter-custom".to_string(),
|
||||
"OpenRouter Custom".to_string(),
|
||||
serde_json::json!({}),
|
||||
None,
|
||||
);
|
||||
custom_domain_openrouter.meta = Some(crate::provider::ProviderMeta {
|
||||
provider_type: Some("openrouter".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
assert!(should_strip_openrouter_hop_by_hop_request_headers(
|
||||
&Provider::with_id(
|
||||
"a".to_string(),
|
||||
"A".to_string(),
|
||||
serde_json::json!({}),
|
||||
None
|
||||
),
|
||||
"https://openrouter.ai/api"
|
||||
));
|
||||
assert!(should_strip_openrouter_hop_by_hop_request_headers(
|
||||
&Provider::with_id(
|
||||
"b".to_string(),
|
||||
"B".to_string(),
|
||||
serde_json::json!({}),
|
||||
None
|
||||
),
|
||||
"https://OPENROUTER.ai/api/v1"
|
||||
));
|
||||
assert!(should_strip_openrouter_hop_by_hop_request_headers(
|
||||
&custom_domain_openrouter,
|
||||
"https://relay.example/custom"
|
||||
));
|
||||
assert!(!should_strip_openrouter_hop_by_hop_request_headers(
|
||||
&Provider::with_id(
|
||||
"c".to_string(),
|
||||
"C".to_string(),
|
||||
serde_json::json!({}),
|
||||
None
|
||||
),
|
||||
"https://api.openai.com/v1"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_strip_openrouter_request_header_covers_static_and_dynamic_hop_by_hop_headers() {
|
||||
let mut connection_tokens = std::collections::HashSet::new();
|
||||
connection_tokens.insert("x-custom-hop".to_string());
|
||||
|
||||
assert!(should_strip_openrouter_request_header(
|
||||
"connection",
|
||||
&connection_tokens
|
||||
));
|
||||
assert!(should_strip_openrouter_request_header(
|
||||
"proxy-connection",
|
||||
&connection_tokens
|
||||
));
|
||||
assert!(should_strip_openrouter_request_header(
|
||||
"x-custom-hop",
|
||||
&connection_tokens
|
||||
));
|
||||
assert!(!should_strip_openrouter_request_header(
|
||||
"anthropic-version",
|
||||
&connection_tokens
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== Copilot 动态 endpoint 路由相关测试 ====================
|
||||
|
||||
/// 验证 is_copilot 检测逻辑:通过 provider_type 判断
|
||||
|
||||
@@ -81,6 +81,10 @@ fn should_normalize_gemini_full_url(base_url: &str) -> bool {
|
||||
let path = path.trim_end_matches('/');
|
||||
let on_google_host = is_google_gemini_host(extract_host(origin));
|
||||
|
||||
if matches_vertex_ai_publisher_model_path(path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Unconditional layer: only paths whose grammar is *intrinsically*
|
||||
// Gemini-specific — the `/models/...:generateContent` method-call
|
||||
// shape and the deep OpenAI-compat endpoints (`/openai/chat/completions`,
|
||||
@@ -238,6 +242,27 @@ fn matches_structured_gemini_models_path(path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Vertex AI endpoint paths include project/location/publisher routing before
|
||||
/// `models/*:generateContent`; in full-URL mode that routing is user-provided
|
||||
/// and must not be collapsed into the public Gemini `/v1beta/models/*` shape.
|
||||
fn matches_vertex_ai_publisher_model_path(path: &str) -> bool {
|
||||
let Some(projects_index) = path.find("/projects/") else {
|
||||
return false;
|
||||
};
|
||||
let Some(publisher_models_index) = path.find("/publishers/google/models/") else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if projects_index >= publisher_models_index
|
||||
|| !path[projects_index..publisher_models_index].contains("/locations/")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let after_model = &path[publisher_models_index + "/publishers/google/models/".len()..];
|
||||
after_model.contains(":generateContent") || after_model.contains(":streamGenerateContent")
|
||||
}
|
||||
|
||||
fn merge_queries(base_query: Option<&str>, endpoint_query: Option<&str>) -> Option<String> {
|
||||
let parts: Vec<&str> = [base_query, endpoint_query]
|
||||
.into_iter()
|
||||
@@ -334,6 +359,20 @@ mod tests {
|
||||
assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_cloudflare_vertex_ai_full_url_with_action() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://gateway.ai.cloudflare.com/v1/account/gateway/google-vertex-ai/v1/projects/project/locations/us-central1/publishers/google/models/gemini-3.1-pro-preview:streamGenerateContent",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://gateway.ai.cloudflare.com/v1/account/gateway/google-vertex-ai/v1/projects/project/locations/us-central1/publishers/google/models/gemini-3.1-pro-preview:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_opaque_full_url_containing_models_segment() {
|
||||
let url = resolve_gemini_native_url(
|
||||
|
||||
@@ -14,6 +14,12 @@ pub type ResponseUsageParser = fn(&Value) -> Option<TokenUsage>;
|
||||
/// 参数: (流式事件列表, 请求中的模型名称) -> 最终使用的模型名称
|
||||
pub type StreamModelExtractor = fn(&[Value], &str) -> String;
|
||||
|
||||
/// 流式 usage 事件预过滤器类型别名。
|
||||
///
|
||||
/// 参数是 SSE `data:` 原始字符串。返回 false 时跳过 JSON parse,避免在
|
||||
/// token/chunk 高频路径上解析与 usage 无关的事件。
|
||||
pub type StreamUsageEventFilter = fn(&str) -> bool;
|
||||
|
||||
/// 各 API 的使用量解析配置
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct UsageParserConfig {
|
||||
@@ -23,10 +29,32 @@ pub struct UsageParserConfig {
|
||||
pub response_parser: ResponseUsageParser,
|
||||
/// 流式响应中的模型提取器
|
||||
pub model_extractor: StreamModelExtractor,
|
||||
/// 流式 usage 事件预过滤器
|
||||
pub stream_event_filter: Option<StreamUsageEventFilter>,
|
||||
/// 应用类型字符串(用于日志记录)
|
||||
pub app_type_str: &'static str,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 流式 usage 事件预过滤
|
||||
// ============================================================================
|
||||
|
||||
pub fn claude_stream_usage_event_filter(data: &str) -> bool {
|
||||
data.contains("\"message_start\"") || data.contains("\"message_delta\"")
|
||||
}
|
||||
|
||||
fn openai_stream_usage_event_filter(data: &str) -> bool {
|
||||
data.contains("\"usage\"")
|
||||
}
|
||||
|
||||
fn codex_stream_usage_event_filter(data: &str) -> bool {
|
||||
data.contains("\"response.completed\"") || data.contains("\"usage\"")
|
||||
}
|
||||
|
||||
fn gemini_stream_usage_event_filter(data: &str) -> bool {
|
||||
data.contains("\"usageMetadata\"")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 模型提取器实现
|
||||
// ============================================================================
|
||||
@@ -104,6 +132,7 @@ pub const CLAUDE_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
||||
stream_parser: TokenUsage::from_claude_stream_events,
|
||||
response_parser: TokenUsage::from_claude_response,
|
||||
model_extractor: claude_model_extractor,
|
||||
stream_event_filter: Some(claude_stream_usage_event_filter),
|
||||
app_type_str: "claude",
|
||||
};
|
||||
|
||||
@@ -112,6 +141,7 @@ pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
||||
stream_parser: TokenUsage::from_openai_stream_events,
|
||||
response_parser: TokenUsage::from_openai_response,
|
||||
model_extractor: openai_model_extractor,
|
||||
stream_event_filter: Some(openai_stream_usage_event_filter),
|
||||
app_type_str: "codex",
|
||||
};
|
||||
|
||||
@@ -120,6 +150,7 @@ pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
||||
stream_parser: TokenUsage::from_codex_stream_events_auto,
|
||||
response_parser: TokenUsage::from_codex_response_auto,
|
||||
model_extractor: codex_auto_model_extractor,
|
||||
stream_event_filter: Some(codex_stream_usage_event_filter),
|
||||
app_type_str: "codex",
|
||||
};
|
||||
|
||||
@@ -128,6 +159,7 @@ pub const GEMINI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
|
||||
stream_parser: TokenUsage::from_gemini_stream_chunks,
|
||||
response_parser: TokenUsage::from_gemini_response,
|
||||
model_extractor: gemini_model_extractor,
|
||||
stream_event_filter: Some(gemini_stream_usage_event_filter),
|
||||
app_type_str: "gemini",
|
||||
};
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ pub struct RequestContext {
|
||||
pub app_type: AppType,
|
||||
/// Session ID(从客户端请求提取或新生成)
|
||||
pub session_id: String,
|
||||
/// Session ID 是否由客户端提供。生成的 UUID 不能作为上游缓存 key,否则每个请求都会换 key。
|
||||
pub session_client_provided: bool,
|
||||
/// 整流器配置
|
||||
pub rectifier_config: RectifierConfig,
|
||||
/// 优化器配置
|
||||
@@ -161,6 +163,7 @@ impl RequestContext {
|
||||
app_type_str,
|
||||
app_type,
|
||||
session_id,
|
||||
session_client_provided: session_result.client_provided,
|
||||
rectifier_config,
|
||||
optimizer_config,
|
||||
copilot_optimizer_config,
|
||||
@@ -223,6 +226,7 @@ impl RequestContext {
|
||||
state.app_handle.clone(),
|
||||
self.current_provider_id.clone(),
|
||||
self.session_id.clone(),
|
||||
self.session_client_provided,
|
||||
first_byte_timeout,
|
||||
idle_timeout,
|
||||
self.rectifier_config.clone(),
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
use super::{
|
||||
error_mapper::{get_error_message, map_proxy_error_to_status},
|
||||
handler_config::{
|
||||
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
|
||||
claude_stream_usage_event_filter, CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG,
|
||||
GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
|
||||
},
|
||||
handler_context::RequestContext,
|
||||
providers::{
|
||||
@@ -22,9 +23,10 @@ use super::{
|
||||
response_processor::{
|
||||
create_logged_passthrough_stream, process_response, read_decoded_body,
|
||||
strip_entity_headers_for_rebuilt_body, strip_hop_by_hop_response_headers,
|
||||
SseUsageCollector,
|
||||
usage_logging_enabled, SseUsageCollector,
|
||||
},
|
||||
server::ProxyState,
|
||||
sse::{strip_sse_field, take_sse_block},
|
||||
types::*,
|
||||
usage::parser::TokenUsage,
|
||||
ProxyError,
|
||||
@@ -68,6 +70,49 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
|
||||
pub async fn handle_messages(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
handle_messages_for_app(state, request, AppType::Claude, "Claude", "claude", None).await
|
||||
}
|
||||
|
||||
pub async fn handle_claude_desktop_messages(
|
||||
State(state): State<ProxyState>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
validate_claude_desktop_gateway_auth(&state, request.headers())?;
|
||||
handle_messages_for_app(
|
||||
state,
|
||||
request,
|
||||
AppType::ClaudeDesktop,
|
||||
"Claude Desktop",
|
||||
"claude-desktop",
|
||||
Some("/claude-desktop"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn handle_claude_desktop_models(
|
||||
State(state): State<ProxyState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Result<Json<Value>, ProxyError> {
|
||||
validate_claude_desktop_gateway_auth(&state, &headers)?;
|
||||
let providers = state
|
||||
.provider_router
|
||||
.select_providers("claude-desktop")
|
||||
.await
|
||||
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||
let provider = providers.first().ok_or(ProxyError::NoAvailableProvider)?;
|
||||
let response = crate::claude_desktop_config::model_list_response(provider)
|
||||
.map_err(|e| ProxyError::ConfigError(e.to_string()))?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
async fn handle_messages_for_app(
|
||||
state: ProxyState,
|
||||
request: axum::extract::Request,
|
||||
app_type: AppType,
|
||||
tag: &'static str,
|
||||
app_type_str: &'static str,
|
||||
strip_prefix: Option<&'static str>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, body) = request.into_parts();
|
||||
let uri = parts.uri;
|
||||
@@ -82,12 +127,15 @@ pub async fn handle_messages(
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
|
||||
RequestContext::new(&state, &body, &headers, app_type.clone(), tag, app_type_str).await?;
|
||||
|
||||
let endpoint = uri
|
||||
let raw_endpoint = uri
|
||||
.path_and_query()
|
||||
.map(|path_and_query| path_and_query.as_str())
|
||||
.unwrap_or(uri.path());
|
||||
let endpoint = strip_prefix
|
||||
.and_then(|prefix| raw_endpoint.strip_prefix(prefix))
|
||||
.unwrap_or(raw_endpoint);
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -98,7 +146,7 @@ pub async fn handle_messages(
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Claude,
|
||||
&app_type,
|
||||
endpoint,
|
||||
body.clone(),
|
||||
headers,
|
||||
@@ -126,7 +174,7 @@ pub async fn handle_messages(
|
||||
let response = result.response;
|
||||
|
||||
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
||||
let adapter = get_adapter(&AppType::Claude);
|
||||
let adapter = get_adapter(&app_type);
|
||||
let needs_transform = adapter.needs_transform(&ctx.provider);
|
||||
|
||||
// Claude 特有:格式转换处理
|
||||
@@ -139,6 +187,33 @@ pub async fn handle_messages(
|
||||
process_response(response, &ctx, &state, &CLAUDE_PARSER_CONFIG).await
|
||||
}
|
||||
|
||||
fn validate_claude_desktop_gateway_auth(
|
||||
state: &ProxyState,
|
||||
headers: &axum::http::HeaderMap,
|
||||
) -> Result<(), ProxyError> {
|
||||
let expected = crate::claude_desktop_config::get_or_create_gateway_token(state.db.as_ref())
|
||||
.map_err(|e| ProxyError::AuthError(e.to_string()))?;
|
||||
let Some(value) = headers.get(axum::http::header::AUTHORIZATION) else {
|
||||
return Err(ProxyError::AuthError(
|
||||
"Claude Desktop gateway 缺少 Authorization 头".to_string(),
|
||||
));
|
||||
};
|
||||
let value = value
|
||||
.to_str()
|
||||
.map_err(|_| ProxyError::AuthError("Authorization 头格式无效".to_string()))?;
|
||||
let token = value
|
||||
.strip_prefix("Bearer ")
|
||||
.or_else(|| value.strip_prefix("bearer "))
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if token != expected {
|
||||
return Err(ProxyError::AuthError(
|
||||
"Claude Desktop gateway token 无效".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Claude 格式转换处理(独有逻辑)
|
||||
///
|
||||
/// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换
|
||||
@@ -151,10 +226,33 @@ async fn handle_claude_transform(
|
||||
api_format: &str,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let status = response.status();
|
||||
let is_codex_oauth = ctx
|
||||
.provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.provider_type.as_deref())
|
||||
== Some("codex_oauth");
|
||||
// Codex OAuth 会把 openai_responses 响应强制升级为 SSE,即使客户端发的是 stream:false。
|
||||
// should_use_claude_transform_streaming 默认会把这个组合路由到流式转换器——虽然能避免
|
||||
// JSON parse 报 422,但会让非流客户端收到 text/event-stream,违反 Anthropic 非流语义。
|
||||
// 这里为这个特定组合打开 override:把上游 SSE 聚合成 Anthropic JSON 回给客户端,其它
|
||||
// 场景(任意上游 is_sse、非 Codex OAuth 等)仍沿用原有流式兜底。
|
||||
let aggregate_codex_oauth_responses_sse =
|
||||
!is_stream && is_codex_oauth && api_format == "openai_responses";
|
||||
let use_streaming = if aggregate_codex_oauth_responses_sse {
|
||||
false
|
||||
} else {
|
||||
should_use_claude_transform_streaming(
|
||||
is_stream,
|
||||
response.is_sse(),
|
||||
api_format,
|
||||
is_codex_oauth,
|
||||
)
|
||||
};
|
||||
let tool_schema_hints = transform_gemini::extract_anthropic_tool_schema_hints(original_body);
|
||||
let tool_schema_hints = (!tool_schema_hints.is_empty()).then_some(tool_schema_hints);
|
||||
|
||||
if is_stream {
|
||||
if use_streaming {
|
||||
// 根据 api_format 选择流式转换器
|
||||
let stream = response.bytes_stream();
|
||||
let sse_stream: Box<
|
||||
@@ -173,40 +271,49 @@ async fn handle_claude_transform(
|
||||
Box::new(Box::pin(create_anthropic_sse_stream(stream)))
|
||||
};
|
||||
|
||||
// 创建使用量收集器
|
||||
let usage_collector = {
|
||||
// 创建使用量收集器;关闭 usage logging 时不要再解析转换后的 SSE。
|
||||
let usage_collector = if usage_logging_enabled(state) {
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let model = ctx.request_model.clone();
|
||||
let status_code = status.as_u16();
|
||||
let start_time = ctx.start_time;
|
||||
let session_id = ctx.session_id.clone();
|
||||
|
||||
SseUsageCollector::new(start_time, move |events, first_token_ms| {
|
||||
if let Some(usage) = TokenUsage::from_claude_stream_events(&events) {
|
||||
let latency_ms = start_time.elapsed().as_millis() as u64;
|
||||
let state = state.clone();
|
||||
let provider_id = provider_id.clone();
|
||||
let model = model.clone();
|
||||
Some(SseUsageCollector::new(
|
||||
start_time,
|
||||
Some(claude_stream_usage_event_filter),
|
||||
move |events, first_token_ms| {
|
||||
if let Some(usage) = TokenUsage::from_claude_stream_events(&events) {
|
||||
let latency_ms = start_time.elapsed().as_millis() as u64;
|
||||
let state = state.clone();
|
||||
let provider_id = provider_id.clone();
|
||||
let model = model.clone();
|
||||
let session_id = session_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
log_usage(
|
||||
&state,
|
||||
&provider_id,
|
||||
"claude",
|
||||
&model,
|
||||
&model,
|
||||
usage,
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
true,
|
||||
status_code,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
} else {
|
||||
log::debug!("[Claude] OpenRouter 流式响应缺少 usage 统计,跳过消费记录");
|
||||
}
|
||||
})
|
||||
tokio::spawn(async move {
|
||||
log_usage(
|
||||
&state,
|
||||
&provider_id,
|
||||
"claude",
|
||||
&model,
|
||||
&model,
|
||||
usage,
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
true,
|
||||
status_code,
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
} else {
|
||||
log::debug!("[Claude] OpenRouter 流式响应缺少 usage 统计,跳过消费记录");
|
||||
}
|
||||
},
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 获取流式超时配置
|
||||
@@ -215,7 +322,7 @@ async fn handle_claude_transform(
|
||||
let logged_stream = create_logged_passthrough_stream(
|
||||
sse_stream,
|
||||
"Claude/OpenRouter",
|
||||
Some(usage_collector),
|
||||
usage_collector,
|
||||
timeout_config,
|
||||
);
|
||||
|
||||
@@ -245,10 +352,14 @@ async fn handle_claude_transform(
|
||||
|
||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||
|
||||
let upstream_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
|
||||
log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}");
|
||||
ProxyError::TransformError(format!("Failed to parse upstream response: {e}"))
|
||||
})?;
|
||||
let upstream_response: Value = if aggregate_codex_oauth_responses_sse {
|
||||
responses_sse_to_response_value(&body_str)?
|
||||
} else {
|
||||
serde_json::from_slice(&body_bytes).map_err(|e| {
|
||||
log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}");
|
||||
ProxyError::TransformError(format!("Failed to parse upstream response: {e}"))
|
||||
})?
|
||||
};
|
||||
|
||||
// 根据 api_format 选择非流式转换器
|
||||
let anthropic_response = if api_format == "openai_responses" {
|
||||
@@ -282,6 +393,7 @@ async fn handle_claude_transform(
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let model = model.to_string();
|
||||
let session_id = ctx.session_id.clone();
|
||||
async move {
|
||||
log_usage(
|
||||
&state,
|
||||
@@ -294,6 +406,7 @@ async fn handle_claude_transform(
|
||||
None,
|
||||
false,
|
||||
status.as_u16(),
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -561,6 +674,87 @@ pub async fn handle_gemini(
|
||||
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
|
||||
}
|
||||
|
||||
fn should_use_claude_transform_streaming(
|
||||
requested_streaming: bool,
|
||||
upstream_is_sse: bool,
|
||||
api_format: &str,
|
||||
is_codex_oauth: bool,
|
||||
) -> bool {
|
||||
requested_streaming || upstream_is_sse || (is_codex_oauth && api_format == "openai_responses")
|
||||
}
|
||||
|
||||
/// 把 OpenAI Responses SSE 流聚合成一个完整的 Responses JSON 对象,供下游转成 Anthropic
|
||||
/// 非流响应。仅在 Codex OAuth 把 `stream:false` 强制升级为 SSE 的场景下调用。
|
||||
///
|
||||
/// 复用 `proxy::sse` 的 `take_sse_block`/`strip_sse_field`:`take_sse_block` 同时支持
|
||||
/// `\n\n` 与 `\r\n\r\n` 两种分隔符,`strip_sse_field` 兼容带/不带空格的字段写法。
|
||||
fn responses_sse_to_response_value(body: &str) -> Result<Value, ProxyError> {
|
||||
let mut buffer = body.to_string();
|
||||
let mut completed_response: Option<Value> = None;
|
||||
let mut output_items = Vec::new();
|
||||
|
||||
while let Some(block) = take_sse_block(&mut buffer) {
|
||||
let mut event_name = "";
|
||||
let mut data_lines: Vec<&str> = Vec::new();
|
||||
|
||||
for line in block.lines() {
|
||||
if let Some(evt) = strip_sse_field(line, "event") {
|
||||
event_name = evt.trim();
|
||||
} else if let Some(d) = strip_sse_field(line, "data") {
|
||||
data_lines.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
if data_lines.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let data_str = data_lines.join("\n");
|
||||
if data_str.trim() == "[DONE]" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let data: Value = serde_json::from_str(&data_str).map_err(|e| {
|
||||
ProxyError::TransformError(format!("Failed to parse upstream SSE event: {e}"))
|
||||
})?;
|
||||
|
||||
match event_name {
|
||||
"response.output_item.done" => {
|
||||
if let Some(item) = data.get("item") {
|
||||
output_items.push(item.clone());
|
||||
}
|
||||
}
|
||||
"response.completed" => {
|
||||
completed_response = Some(data.get("response").cloned().unwrap_or(data));
|
||||
}
|
||||
"response.failed" => {
|
||||
let message = data
|
||||
.pointer("/response/error/message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("response.failed event received");
|
||||
return Err(ProxyError::TransformError(message.to_string()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut response = completed_response.ok_or_else(|| {
|
||||
ProxyError::TransformError("No response.completed event in upstream SSE".to_string())
|
||||
})?;
|
||||
|
||||
if !output_items.is_empty() {
|
||||
if let Some(obj) = response.as_object_mut() {
|
||||
obj.insert("output".to_string(), Value::Array(output_items));
|
||||
} else {
|
||||
return Err(ProxyError::TransformError(
|
||||
"response.completed payload is not an object".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 使用量记录(保留用于 Claude 转换逻辑)
|
||||
// ============================================================================
|
||||
@@ -607,9 +801,14 @@ async fn log_usage(
|
||||
first_token_ms: Option<u64>,
|
||||
is_streaming: bool,
|
||||
status_code: u16,
|
||||
session_id: Option<String>,
|
||||
) {
|
||||
use super::usage::logger::UsageLogger;
|
||||
|
||||
if !usage_logging_enabled(state) {
|
||||
return;
|
||||
}
|
||||
|
||||
let logger = UsageLogger::new(&state.db);
|
||||
|
||||
let (multiplier, pricing_model_source) =
|
||||
@@ -634,10 +833,101 @@ async fn log_usage(
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
status_code,
|
||||
None,
|
||||
session_id,
|
||||
None, // provider_type
|
||||
is_streaming,
|
||||
) {
|
||||
log::warn!("[USG-001] 记录使用量失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{responses_sse_to_response_value, should_use_claude_transform_streaming};
|
||||
use crate::proxy::ProxyError;
|
||||
|
||||
#[test]
|
||||
fn codex_oauth_responses_force_streaming_even_if_client_sent_false() {
|
||||
assert!(should_use_claude_transform_streaming(
|
||||
false,
|
||||
false,
|
||||
"openai_responses",
|
||||
true,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_sse_response_always_uses_streaming_path() {
|
||||
assert!(should_use_claude_transform_streaming(
|
||||
false,
|
||||
true,
|
||||
"openai_chat",
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_streaming_response_stays_non_streaming_for_regular_openai_responses() {
|
||||
assert!(!should_use_claude_transform_streaming(
|
||||
false,
|
||||
false,
|
||||
"openai_responses",
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_sse_to_response_value_collects_output_items() {
|
||||
let sse = r#"event: response.output_item.done
|
||||
data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hello"}]}}
|
||||
|
||||
event: response.completed
|
||||
data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","model":"gpt-5.4","output":[],"usage":{"input_tokens":10,"output_tokens":2}}}
|
||||
|
||||
"#;
|
||||
|
||||
let response = responses_sse_to_response_value(sse).unwrap();
|
||||
|
||||
assert_eq!(response["id"], "resp_1");
|
||||
assert_eq!(response["output"][0]["type"], "message");
|
||||
assert_eq!(response["output"][0]["content"][0]["text"], "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_sse_to_response_value_handles_crlf_delimiters() {
|
||||
// 真实 HTTP SSE 按规范使用 \r\n\r\n 分隔事件;take_sse_block 必须同时处理两种分隔符,
|
||||
// 否则此路径在任何标准上游(含 Codex OAuth HTTPS 后端)下都会 TransformError。
|
||||
let sse = "event: response.output_item.done\r\n\
|
||||
data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"hi\"}]}}\r\n\
|
||||
\r\n\
|
||||
event: response.completed\r\n\
|
||||
data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_crlf\",\"status\":\"completed\",\"model\":\"gpt-5.4\",\"output\":[],\"usage\":{\"input_tokens\":5,\"output_tokens\":1}}}\r\n\
|
||||
\r\n";
|
||||
|
||||
let response = responses_sse_to_response_value(sse).unwrap();
|
||||
|
||||
assert_eq!(response["id"], "resp_crlf");
|
||||
assert_eq!(response["output"][0]["type"], "message");
|
||||
assert_eq!(response["output"][0]["content"][0]["text"], "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_sse_to_response_value_returns_err_on_response_failed() {
|
||||
let sse = "event: response.failed\n\
|
||||
data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"message\":\"upstream blew up\"}}}\n\n";
|
||||
|
||||
let err = responses_sse_to_response_value(sse).unwrap_err();
|
||||
match err {
|
||||
ProxyError::TransformError(msg) => assert!(msg.contains("upstream blew up")),
|
||||
other => panic!("expected TransformError, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_sse_to_response_value_errors_when_no_completed_event() {
|
||||
let sse = "event: response.output_item.done\n\
|
||||
data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\"}}\n\n";
|
||||
|
||||
assert!(responses_sse_to_response_value(sse).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Stable JSON helpers for cache-sensitive request bodies.
|
||||
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub(crate) fn canonicalize_value(value: Value) -> Value {
|
||||
match value {
|
||||
Value::Array(values) => Value::Array(values.into_iter().map(canonicalize_value).collect()),
|
||||
Value::Object(map) => {
|
||||
let mut entries = map.into_iter().collect::<Vec<_>>();
|
||||
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
|
||||
|
||||
let mut sorted = serde_json::Map::new();
|
||||
for (key, value) in entries {
|
||||
sorted.insert(key, canonicalize_value(value));
|
||||
}
|
||||
Value::Object(sorted)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn canonical_json_string(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Null => "null".to_string(),
|
||||
Value::Bool(value) => value.to_string(),
|
||||
Value::Number(value) => value.to_string(),
|
||||
Value::String(value) => serde_json::to_string(value)
|
||||
.expect("serializing a JSON string for canonical output should not fail"),
|
||||
Value::Array(values) => {
|
||||
let parts = values.iter().map(canonical_json_string).collect::<Vec<_>>();
|
||||
format!("[{}]", parts.join(","))
|
||||
}
|
||||
Value::Object(map) => {
|
||||
let mut entries = map.iter().collect::<Vec<_>>();
|
||||
entries.sort_by_key(|(left, _)| *left);
|
||||
let parts = entries
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
let key = serde_json::to_string(key).expect(
|
||||
"serializing a JSON object key for canonical output should not fail",
|
||||
);
|
||||
format!("{key}:{}", canonical_json_string(value))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
format!("{{{}}}", parts.join(","))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn short_value_hash(value: Option<&Value>) -> String {
|
||||
let Some(value) = value else {
|
||||
return "absent".to_string();
|
||||
};
|
||||
short_sha256_hex(canonical_json_string(value).as_bytes())
|
||||
}
|
||||
|
||||
pub(crate) fn short_sha256_hex(bytes: &[u8]) -> String {
|
||||
let digest = Sha256::digest(bytes);
|
||||
digest
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn canonical_json_string_sorts_nested_object_keys() {
|
||||
let left = json!({
|
||||
"b": 2,
|
||||
"a": {
|
||||
"d": true,
|
||||
"c": [3, {"z": 1, "y": 2}]
|
||||
}
|
||||
});
|
||||
let right = json!({
|
||||
"a": {
|
||||
"c": [3, {"y": 2, "z": 1}],
|
||||
"d": true
|
||||
},
|
||||
"b": 2
|
||||
});
|
||||
|
||||
assert_eq!(canonical_json_string(&left), canonical_json_string(&right));
|
||||
assert_eq!(
|
||||
short_value_hash(Some(&left)),
|
||||
short_value_hash(Some(&right))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalize_value_sorts_map_storage_order() {
|
||||
let value = canonicalize_value(json!({"b": 2, "a": 1}));
|
||||
|
||||
assert_eq!(serde_json::to_string(&value).unwrap(), r#"{"a":1,"b":2}"#);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ mod handlers;
|
||||
mod health;
|
||||
pub mod http_client;
|
||||
pub mod hyper_client;
|
||||
pub(crate) mod json_canonical;
|
||||
pub mod log_codes;
|
||||
pub mod model_mapper;
|
||||
pub mod provider_router;
|
||||
|
||||
@@ -82,6 +82,40 @@ pub fn claude_api_format_needs_transform(api_format: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn is_reasoning_content_compatible_identifier(value: &str) -> bool {
|
||||
let value = value.to_ascii_lowercase();
|
||||
value.contains("moonshot") || value.contains("kimi") || value.contains("deepseek")
|
||||
}
|
||||
|
||||
fn should_preserve_reasoning_content_for_openai_chat(
|
||||
provider: &Provider,
|
||||
body: &serde_json::Value,
|
||||
) -> bool {
|
||||
if body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.is_some_and(is_reasoning_content_compatible_identifier)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let settings = &provider.settings_config;
|
||||
let base_urls = [
|
||||
settings
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
||||
.and_then(|v| v.as_str()),
|
||||
settings.get("base_url").and_then(|v| v.as_str()),
|
||||
settings.get("baseURL").and_then(|v| v.as_str()),
|
||||
settings.get("apiEndpoint").and_then(|v| v.as_str()),
|
||||
];
|
||||
|
||||
base_urls
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(is_reasoning_content_compatible_identifier)
|
||||
}
|
||||
|
||||
pub fn transform_claude_request_for_api_format(
|
||||
body: serde_json::Value,
|
||||
provider: &Provider,
|
||||
@@ -89,6 +123,8 @@ pub fn transform_claude_request_for_api_format(
|
||||
session_id: Option<&str>,
|
||||
shadow_store: Option<&super::gemini_shadow::GeminiShadowStore>,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
let is_codex_oauth = provider.is_codex_oauth();
|
||||
|
||||
// Copilot 场景:优先从 metadata.user_id 提取 session ID 作为 cache key
|
||||
// 格式: "uuid_sessionId" → 提取 "_" 后面的部分作为 session 标识
|
||||
// 同一会话的请求共享 cache key,提升 Copilot 缓存命中率
|
||||
@@ -119,35 +155,47 @@ pub fn transform_claude_request_for_api_format(
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
session_id
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToString::to_string)
|
||||
};
|
||||
|
||||
let cache_key = session_cache_key
|
||||
.as_deref()
|
||||
.or_else(|| {
|
||||
provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||||
})
|
||||
.unwrap_or(&provider.id);
|
||||
let explicit_cache_key = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.prompt_cache_key.as_deref());
|
||||
let (cache_key, cache_key_source) = if let Some(key) = explicit_cache_key {
|
||||
(Some(key), "explicit")
|
||||
} else if let Some(key) = session_cache_key.as_deref() {
|
||||
(Some(key), "session")
|
||||
} else {
|
||||
(None, "none")
|
||||
};
|
||||
match api_format {
|
||||
"openai_responses" => {
|
||||
log::debug!(
|
||||
"[Cache] OpenAI Responses prompt_cache_key source={cache_key_source}, provider={}, codex_oauth={is_codex_oauth}, has_key={}",
|
||||
provider.id,
|
||||
cache_key.is_some()
|
||||
);
|
||||
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false
|
||||
// + include: ["reasoning.encrypted_content"],由 transform 层统一处理。
|
||||
let is_codex_oauth = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("codex_oauth");
|
||||
let codex_fast_mode = provider.codex_fast_mode_enabled();
|
||||
super::transform_responses::anthropic_to_responses(
|
||||
body,
|
||||
Some(cache_key),
|
||||
cache_key,
|
||||
is_codex_oauth,
|
||||
codex_fast_mode,
|
||||
)
|
||||
}
|
||||
"openai_chat" => {
|
||||
let mut result = super::transform::anthropic_to_openai(body)?;
|
||||
let preserve_reasoning_content =
|
||||
should_preserve_reasoning_content_for_openai_chat(provider, &body);
|
||||
let mut result = super::transform::anthropic_to_openai_with_reasoning_content(
|
||||
body,
|
||||
preserve_reasoning_content,
|
||||
)?;
|
||||
// Inject prompt_cache_key only if explicitly configured in meta
|
||||
if let Some(key) = provider
|
||||
.meta
|
||||
@@ -332,6 +380,16 @@ impl ClaudeAdapter {
|
||||
log::debug!("[Claude] 使用 OPENAI_API_KEY");
|
||||
return Some(key.to_string());
|
||||
}
|
||||
// Gemini Native key
|
||||
if let Some(key) = env
|
||||
.get("GEMINI_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
log::debug!("[Claude] 使用 GEMINI_API_KEY");
|
||||
return Some(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试直接获取
|
||||
@@ -350,6 +408,33 @@ impl ClaudeAdapter {
|
||||
log::warn!("[Claude] 未找到有效的 API Key");
|
||||
None
|
||||
}
|
||||
|
||||
/// 根据 env 中填写的变量名推断 Anthropic 默认走哪种鉴权策略。
|
||||
///
|
||||
/// 与 Anthropic SDK 原生语义保持一致:
|
||||
/// - `ANTHROPIC_AUTH_TOKEN` → `ClaudeAuth`(发送 `Authorization: Bearer`)
|
||||
/// - `ANTHROPIC_API_KEY` → `Anthropic` (发送 `x-api-key`)
|
||||
///
|
||||
/// 优先级与 [`extract_key`] 一致;两者都缺时返回 `None` 由调用方决定 fallback。
|
||||
fn infer_anthropic_auth_strategy(&self, provider: &Provider) -> Option<AuthStrategy> {
|
||||
let env = provider.settings_config.get("env")?;
|
||||
|
||||
let has_value = |key: &str| -> bool {
|
||||
env.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.is_some()
|
||||
};
|
||||
|
||||
if has_value("ANTHROPIC_AUTH_TOKEN") {
|
||||
return Some(AuthStrategy::ClaudeAuth);
|
||||
}
|
||||
if has_value("ANTHROPIC_API_KEY") {
|
||||
return Some(AuthStrategy::Anthropic);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ClaudeAdapter {
|
||||
@@ -463,7 +548,16 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
ProviderType::Gemini => Some(AuthInfo::new(key, AuthStrategy::Google)),
|
||||
ProviderType::OpenRouter => Some(AuthInfo::new(key, AuthStrategy::Bearer)),
|
||||
ProviderType::ClaudeAuth => Some(AuthInfo::new(key, AuthStrategy::ClaudeAuth)),
|
||||
_ => Some(AuthInfo::new(key, AuthStrategy::Anthropic)),
|
||||
_ => {
|
||||
// 按 env 中的变量名推断鉴权策略,对齐 Anthropic SDK 语义:
|
||||
// ANTHROPIC_AUTH_TOKEN → Authorization: Bearer
|
||||
// ANTHROPIC_API_KEY → x-api-key
|
||||
// 其他来源(apiKey 直填等)默认走 x-api-key(Anthropic 官方协议)。
|
||||
let strategy = self
|
||||
.infer_anthropic_auth_strategy(provider)
|
||||
.unwrap_or(AuthStrategy::Anthropic);
|
||||
Some(AuthInfo::new(key, strategy))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,7 +594,13 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
// 注意:anthropic-version 由 forwarder.rs 统一处理(透传客户端值或设置默认值)
|
||||
let bearer = format!("Bearer {}", auth.api_key);
|
||||
match auth.strategy {
|
||||
AuthStrategy::Anthropic | AuthStrategy::ClaudeAuth | AuthStrategy::Bearer => {
|
||||
AuthStrategy::Anthropic => {
|
||||
vec![(
|
||||
HeaderName::from_static("x-api-key"),
|
||||
HeaderValue::from_str(&auth.api_key).unwrap(),
|
||||
)]
|
||||
}
|
||||
AuthStrategy::ClaudeAuth | AuthStrategy::Bearer => {
|
||||
vec![(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&bearer).unwrap(),
|
||||
@@ -701,7 +801,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_anthropic() {
|
||||
fn test_extract_auth_anthropic_auth_token_uses_claude_auth_strategy() {
|
||||
// ANTHROPIC_AUTH_TOKEN 在 Anthropic SDK 里语义就是 Authorization: Bearer,
|
||||
// 因此走 ClaudeAuth strategy 而不是 Anthropic(x-api-key)。
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
@@ -712,7 +814,7 @@ mod tests {
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "sk-ant-test-key");
|
||||
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
|
||||
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -730,6 +832,73 @@ mod tests {
|
||||
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_both_env_vars_prefer_auth_token() {
|
||||
// 两个变量都填时,extract_key 选 AUTH_TOKEN,strategy 推断也必须保持一致。
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-from-auth-token",
|
||||
"ANTHROPIC_API_KEY": "sk-from-api-key"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "sk-from-auth-token");
|
||||
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_apikey_field_fallback_uses_anthropic_strategy() {
|
||||
// 当用户没填任一 ANTHROPIC_* env,而是直接使用 apiKey 字段时,
|
||||
// 视为没有显式语义偏好,默认走 Anthropic 官方协议(x-api-key)。
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider(json!({
|
||||
"apiKey": "sk-direct",
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "sk-direct");
|
||||
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_auth_headers_anthropic_emits_x_api_key() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let auth = AuthInfo::new("sk-ant-test".to_string(), AuthStrategy::Anthropic);
|
||||
|
||||
let headers = adapter.get_auth_headers(&auth);
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_auth_headers_claude_auth_emits_authorization_bearer() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let auth = AuthInfo::new("sk-relay-test".to_string(), AuthStrategy::ClaudeAuth);
|
||||
|
||||
let headers = adapter.get_auth_headers(&auth);
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_auth_headers_bearer_emits_authorization_bearer() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let auth = AuthInfo::new("sk-or-test".to_string(), AuthStrategy::Bearer);
|
||||
|
||||
let headers = adapter.get_auth_headers(&auth);
|
||||
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_extract_auth_openrouter() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
@@ -745,6 +914,27 @@ mod tests {
|
||||
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_gemini_api_key() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com/v1beta",
|
||||
"GEMINI_API_KEY": "gemini-test-key"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("gemini_native".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let auth = adapter.extract_auth(&provider).unwrap();
|
||||
assert_eq!(auth.api_key, "gemini-test-key");
|
||||
assert_eq!(auth.strategy, AuthStrategy::Google);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_auth_claude_auth_mode() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
@@ -1222,6 +1412,202 @@ mod tests {
|
||||
assert!(transformed.get("max_output_tokens").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_codex_oauth_uses_session_cache_key() {
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("openai_responses".to_string()),
|
||||
provider_type: Some("codex_oauth".to_string()),
|
||||
..ProviderMeta::default()
|
||||
},
|
||||
);
|
||||
let body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 128
|
||||
});
|
||||
|
||||
let transformed = transform_claude_request_for_api_format(
|
||||
body,
|
||||
&provider,
|
||||
"openai_responses",
|
||||
Some("session-123"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(transformed["prompt_cache_key"], "session-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_codex_oauth_without_session_omits_cache_key() {
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("openai_responses".to_string()),
|
||||
provider_type: Some("codex_oauth".to_string()),
|
||||
..ProviderMeta::default()
|
||||
},
|
||||
);
|
||||
let body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 128
|
||||
});
|
||||
|
||||
let transformed = transform_claude_request_for_api_format(
|
||||
body,
|
||||
&provider,
|
||||
"openai_responses",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(transformed.get("prompt_cache_key").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_responses_uses_session_cache_key() {
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.openai.example.com"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("openai_responses".to_string()),
|
||||
..ProviderMeta::default()
|
||||
},
|
||||
);
|
||||
let body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 128
|
||||
});
|
||||
|
||||
let transformed = transform_claude_request_for_api_format(
|
||||
body,
|
||||
&provider,
|
||||
"openai_responses",
|
||||
Some("claude-session-123"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(transformed["prompt_cache_key"], "claude-session-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_responses_without_session_omits_cache_key() {
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.openai.example.com"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("openai_responses".to_string()),
|
||||
..ProviderMeta::default()
|
||||
},
|
||||
);
|
||||
let body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 128
|
||||
});
|
||||
|
||||
let transformed = transform_claude_request_for_api_format(
|
||||
body,
|
||||
&provider,
|
||||
"openai_responses",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(transformed.get("prompt_cache_key").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_codex_oauth_keeps_explicit_cache_key() {
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("openai_responses".to_string()),
|
||||
provider_type: Some("codex_oauth".to_string()),
|
||||
prompt_cache_key: Some("explicit-cache-key".to_string()),
|
||||
..ProviderMeta::default()
|
||||
},
|
||||
);
|
||||
let body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 128
|
||||
});
|
||||
|
||||
let transformed = transform_claude_request_for_api_format(
|
||||
body,
|
||||
&provider,
|
||||
"openai_responses",
|
||||
Some("session-123"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(transformed["prompt_cache_key"], "explicit-cache-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_api_format_codex_oauth_fast_mode_off() {
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
provider_type: Some("codex_oauth".to_string()),
|
||||
codex_fast_mode: Some(false),
|
||||
..ProviderMeta::default()
|
||||
},
|
||||
);
|
||||
let body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 128
|
||||
});
|
||||
|
||||
let transformed = transform_claude_request_for_api_format(
|
||||
body,
|
||||
&provider,
|
||||
"openai_responses",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(transformed["store"], json!(false));
|
||||
assert!(transformed.get("service_tier").is_none());
|
||||
assert_eq!(
|
||||
transformed["include"],
|
||||
json!(["reasoning.encrypted_content"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_api_format_gemini_native() {
|
||||
let provider = create_provider_with_meta(
|
||||
@@ -1310,4 +1696,109 @@ mod tests {
|
||||
|
||||
assert_eq!(transformed["prompt_cache_key"], "claude-cache-route");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_openai_chat_skips_reasoning_content_for_generic_provider() {
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("openai_chat".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 64,
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "I should call the tool."},
|
||||
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||||
.unwrap();
|
||||
|
||||
let msg = &transformed["messages"][0];
|
||||
assert!(msg.get("tool_calls").is_some());
|
||||
assert!(msg.get("reasoning_content").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_openai_chat_preserves_reasoning_content_for_kimi_provider() {
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.moonshot.cn/v1",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("openai_chat".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let body = json!({
|
||||
"model": "kimi-k2.6",
|
||||
"max_tokens": 64,
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "I should call the tool."},
|
||||
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||||
.unwrap();
|
||||
|
||||
let msg = &transformed["messages"][0];
|
||||
assert_eq!(msg["reasoning_content"], "I should call the tool.");
|
||||
assert!(msg.get("tool_calls").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_openai_chat_preserves_reasoning_content_for_deepseek_provider() {
|
||||
let provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/v1",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("openai_chat".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let body = json!({
|
||||
"model": "deepseek-v4-flash",
|
||||
"max_tokens": 64,
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "I should call the tool."},
|
||||
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||||
.unwrap();
|
||||
|
||||
let msg = &transformed["messages"][0];
|
||||
assert_eq!(msg["reasoning_content"], "I should call the tool.");
|
||||
assert!(msg.get("tool_calls").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! GitHub Copilot 模型 ID 归一化与 live-list 解析
|
||||
//!
|
||||
//! Copilot upstream 仅接受 dot 形式的 Claude 4.x 模型 ID(如 `claude-sonnet-4.6`),
|
||||
//! 而 Claude Code 客户端发出 dash 形式(如 `claude-sonnet-4-6`、`claude-sonnet-4-6[1m]`)。
|
||||
//! 不归一化会触发上游 400 `model_not_supported`。
|
||||
//!
|
||||
//! 仅做语法归一化不够:账号订阅级别可能不开放某个具体模型。
|
||||
//! `resolve_against_models` 用 `/models` live 列表做精确匹配,找不到时
|
||||
//! 按 family(haiku/sonnet/opus)+ 最高版本号 fallback。
|
||||
|
||||
use super::copilot_auth::CopilotModel;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 归一化客户端 model ID 为 Copilot upstream 接受的形式。
|
||||
/// 返回 `None` 表示无需变换(已归一化、非 Claude 4.x 系列、或空输入)。
|
||||
pub(super) fn normalize_to_copilot_id(client_id: &str) -> Option<String> {
|
||||
let trimmed = client_id.trim();
|
||||
let bytes = trimmed.as_bytes();
|
||||
|
||||
if bytes.len() < 8 || !bytes[..7].eq_ignore_ascii_case(b"claude-") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let has_one_m_bracket = ends_with_ascii_ci(bytes, b"[1m]");
|
||||
|
||||
// Fast path: 已含点 + 不带 [1m] → 已归一化(绝大多数请求走这里)
|
||||
if trimmed.contains('.') && !has_one_m_bracket {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (base, has_1m_suffix) = split_one_m_suffix(trimmed);
|
||||
let stripped = strip_trailing_date(base);
|
||||
let dotted = dashes_to_dot_in_last_version(stripped);
|
||||
|
||||
if dotted.is_none() && !has_1m_suffix {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut candidate = dotted.unwrap_or_else(|| stripped.to_string());
|
||||
if has_1m_suffix {
|
||||
candidate.push_str("-1m");
|
||||
}
|
||||
(candidate != trimmed).then_some(candidate)
|
||||
}
|
||||
|
||||
/// 在请求体中应用 model ID 归一化。
|
||||
pub fn apply_copilot_model_normalization(mut body: Value) -> Value {
|
||||
let Some(orig) = body.get("model").and_then(|v| v.as_str()) else {
|
||||
return body;
|
||||
};
|
||||
if let Some(normalized) = normalize_to_copilot_id(orig) {
|
||||
log::debug!("[CopilotNormalizer] {orig} → {normalized}");
|
||||
body["model"] = Value::String(normalized);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn ends_with_ascii_ci(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
haystack.len() >= needle.len()
|
||||
&& haystack[haystack.len() - needle.len()..].eq_ignore_ascii_case(needle)
|
||||
}
|
||||
|
||||
fn split_one_m_suffix(id: &str) -> (&str, bool) {
|
||||
let bytes = id.as_bytes();
|
||||
if ends_with_ascii_ci(bytes, b"[1m]") {
|
||||
return (&id[..bytes.len() - 4], true);
|
||||
}
|
||||
if ends_with_ascii_ci(bytes, b"-1m") {
|
||||
return (&id[..bytes.len() - 3], true);
|
||||
}
|
||||
(id, false)
|
||||
}
|
||||
|
||||
fn strip_trailing_date(id: &str) -> &str {
|
||||
let Some(last_dash) = id.rfind('-') else {
|
||||
return id;
|
||||
};
|
||||
let suffix = &id[last_dash + 1..];
|
||||
if suffix.len() == 8 && suffix.bytes().all(|b| b.is_ascii_digit()) {
|
||||
&id[..last_dash]
|
||||
} else {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 `…-X-Y`(X、Y 都是纯数字的末两段)变成 `…-X.Y`。
|
||||
/// 返回 `None` 表示模式不匹配(保守策略避免误伤 `claude-3-5-sonnet` 等历史 ID)。
|
||||
fn dashes_to_dot_in_last_version(id: &str) -> Option<String> {
|
||||
let last_dash = id.rfind('-')?;
|
||||
let last_segment = &id[last_dash + 1..];
|
||||
if last_segment.is_empty() || !last_segment.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let head = &id[..last_dash];
|
||||
let prev_dash = head.rfind('-')?;
|
||||
let prev_segment = &head[prev_dash + 1..];
|
||||
if prev_segment.is_empty() || !prev_segment.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{head}.{last_segment}"))
|
||||
}
|
||||
|
||||
/// 用 Copilot live 模型列表确认/降级 model ID。
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 先做语法归一化(dash→dot、`[1m]`→`-1m`)
|
||||
/// 2. 在 `models` 中精确匹配;找到则使用归一化后的 ID
|
||||
/// 3. 找不到时按 family(haiku/sonnet/opus)取最高版本号 fallback
|
||||
/// (优先保留 `-1m` 标志;都没有则取 base 版)
|
||||
///
|
||||
/// 返回 `None` 表示无需变换或无可降级的 family 候选(保留原 ID 让上游决定,
|
||||
/// 让用户拿到明确的 `model_not_supported` 而非被静默替换)。
|
||||
pub fn resolve_against_models(client_id: &str, models: &[CopilotModel]) -> Option<String> {
|
||||
let normalized = normalize_to_copilot_id(client_id);
|
||||
let target = normalized.as_deref().unwrap_or(client_id);
|
||||
|
||||
if models.iter().any(|m| m.id.eq_ignore_ascii_case(target)) {
|
||||
return normalized.filter(|s| s != client_id);
|
||||
}
|
||||
|
||||
let fallback = family_fallback(target, models)?;
|
||||
if fallback.eq_ignore_ascii_case(client_id) {
|
||||
None
|
||||
} else {
|
||||
Some(fallback)
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_family(id: &str) -> Option<&'static str> {
|
||||
let lower = id.to_ascii_lowercase();
|
||||
if lower.contains("haiku") {
|
||||
Some("haiku")
|
||||
} else if lower.contains("sonnet") {
|
||||
Some("sonnet")
|
||||
} else if lower.contains("opus") {
|
||||
Some("opus")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// 提取 family 后第一段 `MAJOR.MINOR` 版本号。
|
||||
/// 例:`claude-sonnet-4.6` → (4, 6);`claude-sonnet-4.6-1m` → (4, 6)。
|
||||
fn extract_major_minor(id: &str) -> Option<(u32, u32)> {
|
||||
let lower = id.to_ascii_lowercase();
|
||||
let family = detect_family(&lower)?;
|
||||
let after = &lower[lower.find(family)? + family.len()..];
|
||||
let after = after.strip_prefix('-')?;
|
||||
let segment = after.split(['-', '[', ' ']).next()?;
|
||||
let mut parts = segment.split('.');
|
||||
let major: u32 = parts.next()?.parse().ok()?;
|
||||
let minor: u32 = parts.next().unwrap_or("0").parse().ok()?;
|
||||
Some((major, minor))
|
||||
}
|
||||
|
||||
fn family_fallback(target: &str, models: &[CopilotModel]) -> Option<String> {
|
||||
let family = detect_family(target)?;
|
||||
let want_1m = target.ends_with("-1m");
|
||||
|
||||
let pick_best = |require_1m: bool| -> Option<String> {
|
||||
models
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
let lower = m.id.to_ascii_lowercase();
|
||||
lower.contains(family) && lower.ends_with("-1m") == require_1m
|
||||
})
|
||||
.filter_map(|m| extract_major_minor(&m.id).map(|v| (m, v)))
|
||||
.max_by_key(|(_, v)| *v)
|
||||
.map(|(m, _)| m.id.clone())
|
||||
};
|
||||
|
||||
if want_1m {
|
||||
pick_best(true).or_else(|| pick_best(false))
|
||||
} else {
|
||||
pick_best(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn dashes_to_dot_basic() {
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-sonnet-4-6"),
|
||||
Some("claude-sonnet-4.6".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-opus-4-6"),
|
||||
Some("claude-opus-4.6".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-haiku-4-5"),
|
||||
Some("claude-haiku-4.5".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_m_bracket_to_dash() {
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-sonnet-4-6[1m]"),
|
||||
Some("claude-sonnet-4.6-1m".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-opus-4-6[1m]"),
|
||||
Some("claude-opus-4.6-1m".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_m_bracket_on_already_dotted() {
|
||||
// claude-sonnet-4.6[1m] 走非 fast-path 分支(has_one_m_bracket=true),
|
||||
// 应被改写为 -1m 形式
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-sonnet-4.6[1m]"),
|
||||
Some("claude-sonnet-4.6-1m".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_suffix_stripped() {
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-haiku-4-5-20251001"),
|
||||
Some("claude-haiku-4.5".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-sonnet-4-5-20250929"),
|
||||
Some("claude-sonnet-4.5".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_copilot_format_returns_none() {
|
||||
assert_eq!(normalize_to_copilot_id("claude-sonnet-4.6"), None);
|
||||
assert_eq!(normalize_to_copilot_id("claude-opus-4.6-1m"), None);
|
||||
assert_eq!(normalize_to_copilot_id("claude-haiku-4.5"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_claude_models_untouched() {
|
||||
assert_eq!(normalize_to_copilot_id("gpt-5"), None);
|
||||
assert_eq!(normalize_to_copilot_id("gpt-4o-mini"), None);
|
||||
assert_eq!(normalize_to_copilot_id("o3"), None);
|
||||
assert_eq!(normalize_to_copilot_id(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_three_part_versions_untouched() {
|
||||
assert_eq!(normalize_to_copilot_id("claude-3-5-sonnet"), None);
|
||||
assert_eq!(normalize_to_copilot_id("claude-3-5-sonnet-20241022"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive_on_prefix_and_suffix() {
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("Claude-Sonnet-4-6"),
|
||||
Some("Claude-Sonnet-4.6".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-sonnet-4-6[1M]"),
|
||||
Some("claude-sonnet-4.6-1m".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bracket_one_m_with_date_combined() {
|
||||
assert_eq!(
|
||||
normalize_to_copilot_id("claude-haiku-4-5-20251001[1m]"),
|
||||
Some("claude-haiku-4.5-1m".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_rewrites_body() {
|
||||
let body = json!({"model": "claude-sonnet-4-6", "max_tokens": 1024});
|
||||
let out = apply_copilot_model_normalization(body);
|
||||
assert_eq!(out["model"], "claude-sonnet-4.6");
|
||||
assert_eq!(out["max_tokens"], 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_no_change_when_already_normalized() {
|
||||
let body = json!({"model": "claude-sonnet-4.6"});
|
||||
let out = apply_copilot_model_normalization(body);
|
||||
assert_eq!(out["model"], "claude-sonnet-4.6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_handles_missing_model() {
|
||||
let body = json!({"messages": []});
|
||||
let out = apply_copilot_model_normalization(body);
|
||||
assert!(out.get("model").is_none());
|
||||
}
|
||||
|
||||
fn model(id: &str) -> CopilotModel {
|
||||
CopilotModel {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
vendor: "anthropic".to_string(),
|
||||
model_picker_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_exact_match_after_normalize() {
|
||||
let models = vec![
|
||||
model("claude-sonnet-4.6"),
|
||||
model("claude-opus-4.6"),
|
||||
model("claude-haiku-4.5"),
|
||||
];
|
||||
assert_eq!(
|
||||
resolve_against_models("claude-sonnet-4-6", &models),
|
||||
Some("claude-sonnet-4.6".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_none_when_already_valid() {
|
||||
let models = vec![model("claude-sonnet-4.6")];
|
||||
assert_eq!(resolve_against_models("claude-sonnet-4.6", &models), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_falls_back_to_highest_family_version() {
|
||||
// 用户请求 opus 4.7 但 Copilot 账号只有 opus 4.6
|
||||
let models = vec![
|
||||
model("claude-opus-4.5"),
|
||||
model("claude-opus-4.6"),
|
||||
model("claude-sonnet-4.6"),
|
||||
];
|
||||
assert_eq!(
|
||||
resolve_against_models("claude-opus-4.7", &models),
|
||||
Some("claude-opus-4.6".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_prefers_1m_when_requested() {
|
||||
let models = vec![
|
||||
model("claude-sonnet-4.6"),
|
||||
model("claude-sonnet-4.6-1m"),
|
||||
model("claude-opus-4.6"),
|
||||
];
|
||||
assert_eq!(
|
||||
resolve_against_models("claude-sonnet-4-6[1m]", &models),
|
||||
Some("claude-sonnet-4.6-1m".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_falls_back_to_base_when_1m_unavailable() {
|
||||
// 账号没开 -1m 变体时降级到 base
|
||||
let models = vec![model("claude-sonnet-4.6")];
|
||||
assert_eq!(
|
||||
resolve_against_models("claude-sonnet-4-6[1m]", &models),
|
||||
Some("claude-sonnet-4.6".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_none_when_family_absent() {
|
||||
// 账号完全没有 opus 时不做强行替换,让上游报错
|
||||
let models = vec![model("claude-sonnet-4.6"), model("claude-haiku-4.5")];
|
||||
assert_eq!(resolve_against_models("claude-opus-4.6", &models), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_handles_non_claude_target() {
|
||||
let models = vec![model("claude-sonnet-4.6")];
|
||||
assert_eq!(resolve_against_models("gpt-5", &models), None);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ mod claude;
|
||||
mod codex;
|
||||
pub mod codex_oauth_auth;
|
||||
pub mod copilot_auth;
|
||||
pub mod copilot_model_map;
|
||||
mod gemini;
|
||||
pub(crate) mod gemini_schema;
|
||||
pub mod gemini_shadow;
|
||||
@@ -104,7 +105,7 @@ impl ProviderType {
|
||||
#[allow(dead_code)]
|
||||
pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
AppType::Claude | AppType::ClaudeDesktop => {
|
||||
if get_claude_api_format(provider) == "gemini_native" {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
return match adapter.extract_auth(provider).map(|auth| auth.strategy) {
|
||||
@@ -225,7 +226,7 @@ impl std::str::FromStr for ProviderType {
|
||||
/// 根据 AppType 获取对应的适配器
|
||||
pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||||
match app_type {
|
||||
AppType::Claude => Box::new(ClaudeAdapter::new()),
|
||||
AppType::Claude | AppType::ClaudeDesktop => Box::new(ClaudeAdapter::new()),
|
||||
AppType::Codex => Box::new(CodexAdapter::new()),
|
||||
AppType::Gemini => Box::new(GeminiAdapter::new()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
|
||||
@@ -6,14 +6,17 @@ use crate::proxy::sse::{strip_sse_field, take_sse_block};
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// OpenAI 流式响应数据结构
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAIStreamChunk {
|
||||
#[serde(default)]
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
model: String,
|
||||
#[serde(default)]
|
||||
choices: Vec<StreamChoice>,
|
||||
#[serde(default)]
|
||||
usage: Option<Usage>,
|
||||
@@ -30,8 +33,9 @@ struct StreamChoice {
|
||||
struct Delta {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
reasoning: Option<String>, // OpenRouter 的推理内容
|
||||
// OpenRouter/Kimi/其它 使用 reasoning,DeepSeek 使用 reasoning_content
|
||||
#[serde(default, alias = "reasoning_content")]
|
||||
reasoning: Option<String>,
|
||||
#[serde(default)]
|
||||
tool_calls: Option<Vec<DeltaToolCall>>,
|
||||
}
|
||||
@@ -95,6 +99,42 @@ struct ToolBlockState {
|
||||
/// 无限空白 bug 的连续空白字符阈值
|
||||
const INFINITE_WHITESPACE_THRESHOLD: usize = 20;
|
||||
|
||||
fn build_anthropic_usage_json(usage: &Usage) -> Value {
|
||||
let mut usage_json = json!({
|
||||
"input_tokens": usage.prompt_tokens,
|
||||
"output_tokens": usage.completion_tokens
|
||||
});
|
||||
if let Some(cached) = extract_cache_read_tokens(usage) {
|
||||
usage_json["cache_read_input_tokens"] = json!(cached);
|
||||
}
|
||||
if let Some(created) = usage.cache_creation_input_tokens {
|
||||
usage_json["cache_creation_input_tokens"] = json!(created);
|
||||
}
|
||||
usage_json
|
||||
}
|
||||
|
||||
fn default_anthropic_usage_json() -> Value {
|
||||
json!({
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
})
|
||||
}
|
||||
|
||||
fn build_message_delta_event(stop_reason: Option<String>, usage_json: Option<Value>) -> Value {
|
||||
let usage = usage_json
|
||||
.filter(|usage| usage.is_object())
|
||||
.unwrap_or_else(default_anthropic_usage_json);
|
||||
|
||||
json!({
|
||||
"type": "message_delta",
|
||||
"delta": {
|
||||
"stop_reason": stop_reason,
|
||||
"stop_sequence": null
|
||||
},
|
||||
"usage": usage
|
||||
})
|
||||
}
|
||||
|
||||
/// 创建 Anthropic SSE 流
|
||||
pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
|
||||
@@ -106,6 +146,16 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
let mut current_model = None;
|
||||
let mut next_content_index: u32 = 0;
|
||||
let mut has_sent_message_start = false;
|
||||
// 某些上游 provider(如 OpenRouter 的 kimi-k2.6)会在 tool_use 后发送多个
|
||||
// 带 finish_reason 的 SSE chunk。Anthropic 协议要求每个消息流只能有一个
|
||||
// message_delta,重复会导致 Claude Code abort 连接。因此需要:
|
||||
// 1) has_emitted_message_delta: 去重,只处理第一个 finish_reason
|
||||
// 2) pending_message_delta: 缓存延迟到 [DONE] 发送,确保 usage 完整
|
||||
let mut has_emitted_message_delta = false;
|
||||
let mut pending_message_delta: Option<(Option<String>, Option<Value>)> = None;
|
||||
let mut has_sent_message_stop = false;
|
||||
let mut stream_ended_with_error = false;
|
||||
let mut latest_usage: Option<Value> = None;
|
||||
let mut current_non_tool_block_type: Option<&'static str> = None;
|
||||
let mut current_non_tool_block_index: Option<u32> = None;
|
||||
let mut tool_blocks_by_index: HashMap<usize, ToolBlockState> = HashMap::new();
|
||||
@@ -127,24 +177,44 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
if let Some(data) = strip_sse_field(l, "data") {
|
||||
if data.trim() == "[DONE]" {
|
||||
log::debug!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
|
||||
|
||||
// 流正常结束,发出缓存的 message_delta(含完整 usage)。
|
||||
if let Some((stop_reason, usage_json)) = pending_message_delta.take() {
|
||||
let event = build_message_delta_event(stop_reason, usage_json);
|
||||
let sse_data = format!("event: message_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_delta (from pending)");
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
|
||||
let event = json!({"type": "message_stop"});
|
||||
let sse_data = format!("event: message_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop");
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
has_sent_message_stop = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(chunk) = serde_json::from_str::<OpenAIStreamChunk>(data) {
|
||||
log::debug!("[Claude/OpenRouter] <<< SSE chunk received");
|
||||
|
||||
if message_id.is_none() {
|
||||
if message_id.is_none() && !chunk.id.is_empty() {
|
||||
message_id = Some(chunk.id.clone());
|
||||
}
|
||||
if current_model.is_none() {
|
||||
if current_model.is_none() && !chunk.model.is_empty() {
|
||||
current_model = Some(chunk.model.clone());
|
||||
}
|
||||
|
||||
let chunk_usage_json =
|
||||
chunk.usage.as_ref().map(build_anthropic_usage_json);
|
||||
if let Some(usage_json) = &chunk_usage_json {
|
||||
latest_usage = Some(usage_json.clone());
|
||||
if let Some((_, pending_usage)) = pending_message_delta.as_mut() {
|
||||
*pending_usage = Some(usage_json.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(choice) = chunk.choices.first() {
|
||||
if !has_sent_message_start {
|
||||
// Build usage with cache tokens if available from first chunk
|
||||
@@ -420,8 +490,24 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 finish_reason
|
||||
// 处理 finish_reason。
|
||||
// 注意:OpenRouter 某些 provider 会发送多个带 finish_reason 的 chunk
|
||||
// (第一个 usage 为 null,后续才补全)。此处只做缓存,不立即发送,
|
||||
// 等到 [DONE] 或流末尾再统一发出,确保 usage 完整且只发一次。
|
||||
if let Some(finish_reason) = &choice.finish_reason {
|
||||
let stop_reason = map_stop_reason(Some(finish_reason));
|
||||
let usage_json =
|
||||
chunk_usage_json.clone().or_else(|| latest_usage.clone());
|
||||
|
||||
if has_emitted_message_delta {
|
||||
// 更新缓存的 message_delta usage(如果有更完整的 usage)
|
||||
if let (Some((_, ref mut usage)), Some(uj)) = (&mut pending_message_delta, usage_json) {
|
||||
*usage = Some(uj);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
has_emitted_message_delta = true;
|
||||
|
||||
if let Some(index) = current_non_tool_block_index.take() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
@@ -511,32 +597,8 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
open_tool_block_indices.clear();
|
||||
}
|
||||
|
||||
let stop_reason = map_stop_reason(Some(finish_reason));
|
||||
// Build usage with cache token fields
|
||||
let usage_json = chunk.usage.as_ref().map(|u| {
|
||||
let mut uj = json!({
|
||||
"input_tokens": u.prompt_tokens,
|
||||
"output_tokens": u.completion_tokens
|
||||
});
|
||||
if let Some(cached) = extract_cache_read_tokens(u) {
|
||||
uj["cache_read_input_tokens"] = json!(cached);
|
||||
}
|
||||
if let Some(created) = u.cache_creation_input_tokens {
|
||||
uj["cache_creation_input_tokens"] = json!(created);
|
||||
}
|
||||
uj
|
||||
});
|
||||
let event = json!({
|
||||
"type": "message_delta",
|
||||
"delta": {
|
||||
"stop_reason": stop_reason,
|
||||
"stop_sequence": null
|
||||
},
|
||||
"usage": usage_json
|
||||
});
|
||||
let sse_data = format!("event: message_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
// 缓存 message_delta,等到 [DONE] 时发送(以便收集完整的 usage)
|
||||
pending_message_delta = Some((stop_reason, usage_json));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -546,6 +608,7 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Stream error: {e}");
|
||||
stream_ended_with_error = true;
|
||||
let error_event = json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
@@ -560,6 +623,31 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 流自然结束但未收到 [DONE] 时,确保发送缓存的 message_delta 和 message_stop。
|
||||
// 若上游已显式报错,则只保留 error 事件,避免把失败伪装成成功完成。
|
||||
if !stream_ended_with_error {
|
||||
let emitted_pending_message_delta = if let Some((stop_reason, usage_json)) =
|
||||
pending_message_delta.take()
|
||||
{
|
||||
let event = build_message_delta_event(stop_reason, usage_json);
|
||||
let sse_data = format!("event: message_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_delta (at stream end)");
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if emitted_pending_message_delta && !has_sent_message_stop {
|
||||
let event = json!({"type": "message_stop"});
|
||||
let sse_data = format!("event: message_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
log::debug!("[Claude/OpenRouter] >>> Anthropic SSE: message_stop (at stream end)");
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,6 +690,32 @@ mod tests {
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
async fn collect_anthropic_events(input: &str) -> Vec<Value> {
|
||||
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
|
||||
input.as_bytes().to_vec(),
|
||||
))]);
|
||||
let converted = create_anthropic_sse_stream(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
merged
|
||||
.split("\n\n")
|
||||
.filter_map(|block| {
|
||||
let data = block
|
||||
.lines()
|
||||
.find_map(|line| strip_sse_field(line, "data"))?;
|
||||
serde_json::from_str::<Value>(data).ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn event_type(event: &Value) -> Option<&str> {
|
||||
event.get("type").and_then(|v| v.as_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_stop_reason_legacy_and_filtered_values() {
|
||||
assert_eq!(
|
||||
@@ -818,4 +932,210 @@ mod tests {
|
||||
"output must not contain U+FFFD replacement characters"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_duplicate_finish_reason_emits_only_one_message_delta() {
|
||||
// Simulates OpenRouter behavior where two chunks carry finish_reason:
|
||||
// first with null usage, second with populated usage.
|
||||
let input = concat!(
|
||||
"data: {\"id\":\"chatcmpl_dup\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_dup\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5}}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
|
||||
input.as_bytes().to_vec(),
|
||||
))]);
|
||||
let converted = create_anthropic_sse_stream(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
let events: Vec<Value> = merged
|
||||
.split("\n\n")
|
||||
.filter_map(|block| {
|
||||
let data = block
|
||||
.lines()
|
||||
.find_map(|line| strip_sse_field(line, "data"))?;
|
||||
serde_json::from_str::<Value>(data).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let message_deltas: Vec<&Value> = events
|
||||
.iter()
|
||||
.filter(|e| e.get("type").and_then(|v| v.as_str()) == Some("message_delta"))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
message_deltas.len(),
|
||||
1,
|
||||
"duplicate finish_reason chunks must produce exactly one message_delta, got {}: {:?}",
|
||||
message_deltas.len(),
|
||||
message_deltas
|
||||
);
|
||||
|
||||
assert_eq!(message_deltas[0]["usage"]["input_tokens"], 10);
|
||||
assert_eq!(message_deltas[0]["usage"]["output_tokens"], 5);
|
||||
|
||||
let message_stops = events
|
||||
.iter()
|
||||
.filter(|e| e.get("type").and_then(|v| v.as_str()) == Some("message_stop"))
|
||||
.count();
|
||||
assert_eq!(message_stops, 1, "message_stop must only be emitted once");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_usage_only_chunk_after_finish_reason_updates_message_delta_usage() {
|
||||
let input = concat!(
|
||||
"data: {\"id\":\"chatcmpl_split\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"tool-0924\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"{\\\"command\\\":\\\"pwd\\\"}\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_split\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
|
||||
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":13312,\"completion_tokens\":79,\"prompt_tokens_details\":{\"cached_tokens\":100}}}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
|
||||
let events = collect_anthropic_events(input).await;
|
||||
let message_deltas: Vec<&Value> = events
|
||||
.iter()
|
||||
.filter(|event| event_type(event) == Some("message_delta"))
|
||||
.collect();
|
||||
let message_stops = events
|
||||
.iter()
|
||||
.filter(|event| event_type(event) == Some("message_stop"))
|
||||
.count();
|
||||
|
||||
assert_eq!(message_deltas.len(), 1);
|
||||
assert_eq!(message_stops, 1);
|
||||
|
||||
let message_delta = message_deltas[0];
|
||||
assert_eq!(
|
||||
message_delta
|
||||
.pointer("/delta/stop_reason")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("tool_use")
|
||||
);
|
||||
assert_eq!(
|
||||
message_delta
|
||||
.pointer("/usage/input_tokens")
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(13312)
|
||||
);
|
||||
assert_eq!(
|
||||
message_delta
|
||||
.pointer("/usage/output_tokens")
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(79)
|
||||
);
|
||||
assert_eq!(
|
||||
message_delta
|
||||
.pointer("/usage/cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(100)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_message_delta_includes_zero_usage_when_stream_has_no_usage() {
|
||||
let input = concat!(
|
||||
"data: {\"id\":\"chatcmpl_no_usage\",\"model\":\"gpt-5.5\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_0\",\"type\":\"function\",\"function\":{\"name\":\"get_time\",\"arguments\":\"{}\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_no_usage\",\"model\":\"gpt-5.5\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
|
||||
let events = collect_anthropic_events(input).await;
|
||||
let message_deltas: Vec<&Value> = events
|
||||
.iter()
|
||||
.filter(|event| event_type(event) == Some("message_delta"))
|
||||
.collect();
|
||||
|
||||
assert_eq!(message_deltas.len(), 1);
|
||||
let message_delta = message_deltas[0];
|
||||
assert_eq!(
|
||||
message_delta
|
||||
.pointer("/delta/stop_reason")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("tool_use")
|
||||
);
|
||||
assert_eq!(
|
||||
message_delta
|
||||
.pointer("/usage/input_tokens")
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(0)
|
||||
);
|
||||
assert_eq!(
|
||||
message_delta
|
||||
.pointer("/usage/output_tokens")
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_finalizes_after_finish_when_done_is_missing() {
|
||||
let input = concat!(
|
||||
"data: {\"id\":\"chatcmpl_no_done\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_no_done\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n"
|
||||
);
|
||||
|
||||
let events = collect_anthropic_events(input).await;
|
||||
|
||||
assert!(events.iter().any(|event| {
|
||||
event_type(event) == Some("message_delta")
|
||||
&& event.pointer("/delta/stop_reason").and_then(|v| v.as_str()) == Some("end_turn")
|
||||
}));
|
||||
assert_eq!(
|
||||
events.last().and_then(|event| event_type(event)),
|
||||
Some("message_stop")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_stream_end_without_finish_reason_does_not_emit_success_terminal_events() {
|
||||
let input = "data: {\"id\":\"chatcmpl_truncated\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n";
|
||||
|
||||
let events = collect_anthropic_events(input).await;
|
||||
|
||||
assert!(!events
|
||||
.iter()
|
||||
.any(|event| event_type(event) == Some("message_delta")));
|
||||
assert!(!events
|
||||
.iter()
|
||||
.any(|event| event_type(event) == Some("message_stop")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_stream_error_does_not_emit_success_terminal_events() {
|
||||
let upstream = stream::iter(vec![Err::<Bytes, _>(std::io::Error::other(
|
||||
"upstream disconnected",
|
||||
))]);
|
||||
let converted = create_anthropic_sse_stream(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
let events: Vec<Value> = merged
|
||||
.split("\n\n")
|
||||
.filter_map(|block| {
|
||||
let data = block
|
||||
.lines()
|
||||
.find_map(|line| strip_sse_field(line, "data"))?;
|
||||
serde_json::from_str::<Value>(data).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|e| e.get("type").and_then(|v| v.as_str()) == Some("error")));
|
||||
assert!(!events
|
||||
.iter()
|
||||
.any(|e| e.get("type").and_then(|v| v.as_str()) == Some("message_delta")));
|
||||
assert!(!events
|
||||
.iter()
|
||||
.any(|e| e.get("type").and_then(|v| v.as_str()) == Some("message_stop")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
//!
|
||||
//! 与 Chat Completions 的 delta chunk 模型完全不同,需要独立的状态机处理。
|
||||
|
||||
use super::transform_responses::{build_anthropic_usage_from_responses, map_responses_stop_reason};
|
||||
use super::transform_responses::{
|
||||
build_anthropic_usage_from_responses, map_responses_stop_reason,
|
||||
sanitize_anthropic_tool_use_input_json,
|
||||
};
|
||||
use crate::proxy::sse::{strip_sse_field, take_sse_block};
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
@@ -112,6 +115,8 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
let mut fallback_open_index: Option<u32> = None;
|
||||
let mut current_text_index: Option<u32> = None;
|
||||
let mut tool_index_by_item_id: HashMap<String, u32> = HashMap::new();
|
||||
let mut tool_name_by_index: HashMap<u32, String> = HashMap::new();
|
||||
let mut tool_args_by_index: HashMap<u32, String> = HashMap::new();
|
||||
let mut last_tool_index: Option<u32> = None;
|
||||
|
||||
tokio::pin!(stream);
|
||||
@@ -170,9 +175,12 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
}
|
||||
|
||||
has_sent_message_start = true;
|
||||
// Build usage with cache tokens if available
|
||||
// Build usage with defensive null handling
|
||||
// Some() wrapper ensures build function always receives valid input
|
||||
// Fallback to empty object {} if usage field missing, ensuring message_start
|
||||
// event always has valid usage structure for VSCode Extension compatibility
|
||||
let start_usage = build_anthropic_usage_from_responses(
|
||||
response_obj.get("usage"),
|
||||
Some(response_obj.get("usage").unwrap_or(&json!({}))),
|
||||
);
|
||||
|
||||
let event = json!({
|
||||
@@ -410,12 +418,15 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
{
|
||||
tool_index_by_item_id.insert(item_id.to_string(), index);
|
||||
}
|
||||
tool_name_by_index.insert(index, name.to_string());
|
||||
last_tool_index = Some(index);
|
||||
|
||||
if open_indices.contains(&index) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tool_args_by_index.insert(index, String::new());
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
@@ -479,6 +490,14 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
open_indices.insert(index);
|
||||
}
|
||||
|
||||
if tool_name_by_index.get(&index).map(String::as_str) == Some("Read") {
|
||||
tool_args_by_index
|
||||
.entry(index)
|
||||
.or_default()
|
||||
.push_str(delta);
|
||||
continue;
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
@@ -512,6 +531,32 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
if !open_indices.remove(&index) {
|
||||
continue;
|
||||
}
|
||||
if tool_name_by_index.get(&index).map(String::as_str) == Some("Read") {
|
||||
let raw = data
|
||||
.get("arguments")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
tool_args_by_index
|
||||
.get(&index)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let sanitized = sanitize_anthropic_tool_use_input_json("Read", &raw);
|
||||
if !sanitized.is_empty() {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": sanitized
|
||||
}
|
||||
});
|
||||
let sse = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse));
|
||||
}
|
||||
}
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
@@ -522,6 +567,8 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
if let Some(item_id) = item_id {
|
||||
tool_index_by_item_id.remove(item_id);
|
||||
}
|
||||
tool_name_by_index.remove(&index);
|
||||
tool_args_by_index.remove(&index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,9 +717,12 @@ pub fn create_anthropic_sse_stream_from_responses<E: std::error::Error + Send +
|
||||
}
|
||||
fallback_open_index = None;
|
||||
|
||||
let usage_json = response_obj.get("usage").map(|u| {
|
||||
build_anthropic_usage_from_responses(Some(u))
|
||||
});
|
||||
// Defensive: Always build usage_json, even if usage field missing
|
||||
// Some() wrapper with fallback to {} ensures build_anthropic_usage_from_responses
|
||||
// always receives valid input, preventing null pointer errors in VSCode Extension
|
||||
let usage_json = build_anthropic_usage_from_responses(
|
||||
Some(response_obj.get("usage").unwrap_or(&json!({})))
|
||||
);
|
||||
|
||||
// Emit message_delta (with usage + stop_reason)
|
||||
let delta_event = json!({
|
||||
@@ -820,6 +870,71 @@ mod tests {
|
||||
assert!(merged.contains("\"type\":\"message_stop\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_read_tool_drops_empty_pages() {
|
||||
let input = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_read\",\"model\":\"gpt-5.5\"}}\n\n",
|
||||
"event: response.output_item.added\n",
|
||||
"data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_read\",\"type\":\"function_call\",\"call_id\":\"call_read\",\"name\":\"Read\"}}\n\n",
|
||||
"event: response.function_call_arguments.delta\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_read\",\"delta\":\"{\\\"file_path\\\":\\\"/tmp/demo.py\\\",\\\"limit\\\":2000,\\\"offset\\\":0,\\\"pages\\\":\\\"\\\"}\"}\n\n",
|
||||
"event: response.function_call_arguments.done\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.done\",\"item_id\":\"fc_read\",\"arguments\":\"{\\\"file_path\\\":\\\"/tmp/demo.py\\\",\\\"limit\\\":2000,\\\"offset\\\":0,\\\"pages\\\":\\\"\\\"}\"}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
|
||||
input.as_bytes().to_vec(),
|
||||
))]);
|
||||
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
assert!(merged.contains("\"name\":\"Read\""));
|
||||
assert!(merged.contains("\"partial_json\":\"{\\\"file_path\\\":\\\"/tmp/demo.py\\\",\\\"limit\\\":2000,\\\"offset\\\":0}"));
|
||||
assert!(!merged.contains("\\\"pages\\\":\\\"\\\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_read_tool_duplicate_start_preserves_buffered_args() {
|
||||
let input = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_read\",\"model\":\"gpt-5.5\"}}\n\n",
|
||||
"event: response.output_item.added\n",
|
||||
"data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_read\",\"type\":\"function_call\",\"call_id\":\"call_read\",\"name\":\"Read\"}}\n\n",
|
||||
"event: response.function_call_arguments.delta\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_read\",\"delta\":\"{\\\"file_path\\\":\\\"/tmp/demo.py\\\",\\\"limit\\\":2000,\\\"offset\\\":0,\\\"pages\\\":\\\"\\\"}\"}\n\n",
|
||||
"event: response.output_item.added\n",
|
||||
"data: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_read\",\"type\":\"function_call\",\"call_id\":\"call_read\",\"name\":\"Read\"}}\n\n",
|
||||
"event: response.function_call_arguments.done\n",
|
||||
"data: {\"type\":\"response.function_call_arguments.done\",\"item_id\":\"fc_read\"}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
|
||||
input.as_bytes().to_vec(),
|
||||
))]);
|
||||
let converted = create_anthropic_sse_stream_from_responses(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|c| String::from_utf8_lossy(c.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
assert_eq!(merged.matches("event: content_block_start").count(), 1);
|
||||
assert_eq!(merged.matches("event: content_block_stop").count(), 1);
|
||||
assert!(merged.contains("\"partial_json\":\"{\\\"file_path\\\":\\\"/tmp/demo.py\\\",\\\"limit\\\":2000,\\\"offset\\\":0}"));
|
||||
assert!(!merged.contains("\\\"pages\\\":\\\"\\\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_conversion_interleaved_tool_deltas_by_item_id() {
|
||||
let input = concat!(
|
||||
|
||||
@@ -3,9 +3,49 @@
|
||||
//! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持
|
||||
//! 参考: anthropic-proxy-rs
|
||||
|
||||
use crate::proxy::error::ProxyError;
|
||||
use crate::proxy::{error::ProxyError, json_canonical::canonical_json_string};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const ANTHROPIC_BILLING_HEADER_PREFIX: &str = "x-anthropic-billing-header:";
|
||||
|
||||
/// Strip only a leading Claude Code attribution line from system text.
|
||||
///
|
||||
/// Claude Code can send dynamic `x-anthropic-billing-header` metadata at the
|
||||
/// start of `system`. If forwarded into OpenAI Chat messages or Responses
|
||||
/// `instructions`, the rotating `cch=` value changes the prompt prefix on every
|
||||
/// request and prevents prefix cache reuse (#2350). Later occurrences are kept
|
||||
/// to avoid deleting user-authored prompt text.
|
||||
pub(crate) fn strip_leading_anthropic_billing_header(text: &str) -> &str {
|
||||
if !text.starts_with(ANTHROPIC_BILLING_HEADER_PREFIX) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let Some(line_end) = text
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.position(|byte| *byte == b'\n' || *byte == b'\r')
|
||||
else {
|
||||
return "";
|
||||
};
|
||||
|
||||
let bytes = text.as_bytes();
|
||||
let mut rest_start = line_end + 1;
|
||||
if bytes[line_end] == b'\r' && bytes.get(line_end + 1) == Some(&b'\n') {
|
||||
rest_start += 1;
|
||||
}
|
||||
|
||||
let rest = &text[rest_start..];
|
||||
if let Some(stripped) = rest.strip_prefix("\r\n") {
|
||||
stripped
|
||||
} else if let Some(stripped) = rest.strip_prefix('\n') {
|
||||
stripped
|
||||
} else if let Some(stripped) = rest.strip_prefix('\r') {
|
||||
stripped
|
||||
} else {
|
||||
rest
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect OpenAI o-series reasoning models (o1, o3, o4-mini, etc.)
|
||||
/// These models require `max_completion_tokens` instead of `max_tokens`.
|
||||
pub fn is_openai_o_series(model: &str) -> bool {
|
||||
@@ -73,6 +113,18 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
|
||||
|
||||
/// Anthropic 请求 → OpenAI Chat Completions 请求
|
||||
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
anthropic_to_openai_with_reasoning_content(body, false)
|
||||
}
|
||||
|
||||
/// Anthropic 请求 → OpenAI Chat Completions 请求
|
||||
///
|
||||
/// `preserve_reasoning_content` 仅用于明确需要 Moonshot/Kimi/DeepSeek
|
||||
/// `reasoning_content` 兼容字段的 provider。默认转换保持通用 OpenAI-compatible
|
||||
/// 请求体,避免向严格后端发送未知字段。
|
||||
pub fn anthropic_to_openai_with_reasoning_content(
|
||||
body: Value,
|
||||
preserve_reasoning_content: bool,
|
||||
) -> Result<Value, ProxyError> {
|
||||
let mut result = json!({});
|
||||
|
||||
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
|
||||
@@ -85,12 +137,18 @@ pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
// 处理 system prompt
|
||||
if let Some(system) = body.get("system") {
|
||||
if let Some(text) = system.as_str() {
|
||||
// 单个字符串
|
||||
messages.push(json!({"role": "system", "content": text}));
|
||||
let text = strip_leading_anthropic_billing_header(text);
|
||||
if !text.is_empty() {
|
||||
messages.push(json!({"role": "system", "content": text}));
|
||||
}
|
||||
} else if let Some(arr) = system.as_array() {
|
||||
// 多个 system message — preserve cache_control for compatible proxies
|
||||
for msg in arr {
|
||||
if let Some(text) = msg.get("text").and_then(|t| t.as_str()) {
|
||||
let text = strip_leading_anthropic_billing_header(text);
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut sys_msg = json!({"role": "system", "content": text});
|
||||
if let Some(cc) = msg.get("cache_control") {
|
||||
sys_msg["cache_control"] = cc.clone();
|
||||
@@ -106,7 +164,7 @@ pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
for msg in msgs {
|
||||
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("user");
|
||||
let content = msg.get("content");
|
||||
let converted = convert_message_to_openai(role, content)?;
|
||||
let converted = convert_message_to_openai(role, content, preserve_reasoning_content)?;
|
||||
messages.extend(converted);
|
||||
}
|
||||
}
|
||||
@@ -252,6 +310,7 @@ fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
|
||||
fn convert_message_to_openai(
|
||||
role: &str,
|
||||
content: Option<&Value>,
|
||||
preserve_reasoning_content: bool,
|
||||
) -> Result<Vec<Value>, ProxyError> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
@@ -273,6 +332,9 @@ fn convert_message_to_openai(
|
||||
if let Some(blocks) = content.as_array() {
|
||||
let mut content_parts = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
// reasoning_parts: 仅在兼容 Moonshot/Kimi/DeepSeek thinking tool-call 路径时
|
||||
// 生成 reasoning_content,通用 OpenAI-compatible 路径不发送该非标准字段。
|
||||
let mut reasoning_parts = Vec::new();
|
||||
|
||||
for block in blocks {
|
||||
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
@@ -309,7 +371,7 @@ fn convert_message_to_openai(
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"arguments": serde_json::to_string(&input).unwrap_or_default()
|
||||
"arguments": canonical_json_string(&input)
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -322,7 +384,7 @@ fn convert_message_to_openai(
|
||||
let content_val = block.get("content");
|
||||
let content_str = match content_val {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(v) => serde_json::to_string(v).unwrap_or_default(),
|
||||
Some(v) => canonical_json_string(v),
|
||||
None => String::new(),
|
||||
};
|
||||
result.push(json!({
|
||||
@@ -332,7 +394,12 @@ fn convert_message_to_openai(
|
||||
}));
|
||||
}
|
||||
"thinking" => {
|
||||
// 跳过 thinking blocks
|
||||
// 提取 thinking 内容,后续可作为 reasoning_content 传给需要它的上游。
|
||||
if let Some(thinking) = block.get("thinking").and_then(|t| t.as_str()) {
|
||||
if !thinking.is_empty() {
|
||||
reasoning_parts.push(thinking.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -366,6 +433,15 @@ fn convert_message_to_openai(
|
||||
msg["tool_calls"] = json!(tool_calls);
|
||||
}
|
||||
|
||||
if preserve_reasoning_content && role == "assistant" && !tool_calls.is_empty() {
|
||||
let reasoning_content = if reasoning_parts.is_empty() {
|
||||
"tool call".to_string()
|
||||
} else {
|
||||
reasoning_parts.join("\n")
|
||||
};
|
||||
msg["reasoning_content"] = json!(reasoning_content);
|
||||
}
|
||||
|
||||
result.push(msg);
|
||||
}
|
||||
|
||||
@@ -417,6 +493,13 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
let mut content = Vec::new();
|
||||
let mut has_tool_use = false;
|
||||
|
||||
// DeepSeek provider 会把思考内容放在 message.reasoning_content。
|
||||
if let Some(reasoning_content) = message.get("reasoning_content").and_then(|r| r.as_str()) {
|
||||
if !reasoning_content.is_empty() {
|
||||
content.push(json!({"type": "thinking", "thinking": reasoning_content}));
|
||||
}
|
||||
}
|
||||
|
||||
// 文本/拒绝内容
|
||||
if let Some(msg_content) = message.get("content") {
|
||||
if let Some(text) = msg_content.as_str() {
|
||||
@@ -608,6 +691,80 @@ mod tests {
|
||||
assert_eq!(result["messages"][1]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_strips_leading_billing_header_from_system_string() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\n\nYou are a helpful assistant.",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
"You are a helpful assistant."
|
||||
);
|
||||
assert_eq!(result["messages"][1]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_strips_billing_header_from_system_array_parts() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\n"},
|
||||
{"type": "text", "text": "Stable prompt"}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(result["messages"][0]["content"], "Stable prompt");
|
||||
assert_eq!(result["messages"][1]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_preserves_prompt_after_billing_header_in_same_part() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\n\nStable prompt part 1"},
|
||||
{"type": "text", "text": "Stable prompt part 2"}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
"Stable prompt part 1\nStable prompt part 2"
|
||||
);
|
||||
assert_eq!(result["messages"][1]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_keeps_non_leading_billing_header_text() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
"system": "Keep this literal:\nx-anthropic-billing-header: example",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
"Keep this literal:\nx-anthropic-billing-header: example"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_with_tools() {
|
||||
let input = json!({
|
||||
@@ -710,6 +867,88 @@ mod tests {
|
||||
assert_eq!(msg["role"], "assistant");
|
||||
assert!(msg.get("tool_calls").is_some());
|
||||
assert_eq!(msg["tool_calls"][0]["id"], "call_123");
|
||||
assert!(msg.get("reasoning_content").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_tool_use_preserves_reasoning_content() {
|
||||
let input = json!({
|
||||
"model": "kimi-k2.6",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "I should call the tool."},
|
||||
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai_with_reasoning_content(input, true).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "assistant");
|
||||
assert_eq!(msg["reasoning_content"], "I should call the tool.");
|
||||
assert!(msg.get("tool_calls").is_some());
|
||||
assert_eq!(msg["tool_calls"][0]["id"], "call_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_tool_use_injects_placeholder_reasoning_content_when_missing() {
|
||||
let input = json!({
|
||||
"model": "kimi-k2.6",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai_with_reasoning_content(input, true).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "assistant");
|
||||
assert_eq!(msg["reasoning_content"], "tool call");
|
||||
assert!(msg.get("tool_calls").is_some());
|
||||
assert_eq!(msg["tool_calls"][0]["id"], "call_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_does_not_emit_reasoning_content_by_default() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "I should call the tool."},
|
||||
{"type": "tool_use", "id": "call_123", "name": "get_weather", "input": {"location": "Tokyo"}}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "assistant");
|
||||
assert!(msg.get("tool_calls").is_some());
|
||||
assert!(msg.get("reasoning_content").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_skips_thinking_only_message() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "No visible content yet."}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
assert_eq!(result["messages"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -757,6 +996,34 @@ mod tests {
|
||||
assert_eq!(result["usage"]["output_tokens"], 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_preserves_id_for_usage_dedup() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-claude-compatible",
|
||||
"object": "chat.completion",
|
||||
"model": "claude-sonnet-4-5",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "Hello!"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
let usage = crate::proxy::usage::parser::TokenUsage::from_claude_response(&result)
|
||||
.expect("converted Anthropic response should parse usage");
|
||||
|
||||
assert_eq!(
|
||||
usage.message_id.as_deref(),
|
||||
Some("chatcmpl-claude-compatible")
|
||||
);
|
||||
assert_eq!(
|
||||
usage.dedup_request_id(),
|
||||
"session:chatcmpl-claude-compatible"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_with_tool_calls() {
|
||||
let input = json!({
|
||||
@@ -788,6 +1055,59 @@ mod tests {
|
||||
assert_eq!(result["stop_reason"], "tool_use");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deepseek_reasoning_content_round_trips_for_tool_calls() {
|
||||
let upstream_response = json!({
|
||||
"id": "chatcmpl-deepseek",
|
||||
"object": "chat.completion",
|
||||
"created": 1234567890,
|
||||
"model": "deepseek-v4-flash",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"reasoning_content": "Need the current date before calling weather.",
|
||||
"content": "Let me check the date first.",
|
||||
"tool_calls": [{
|
||||
"id": "call_date",
|
||||
"type": "function",
|
||||
"function": {"name": "get_date", "arguments": "{}"}
|
||||
}]
|
||||
},
|
||||
"finish_reason": "tool_calls"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||
});
|
||||
|
||||
let anthropic_response = openai_to_anthropic(upstream_response).unwrap();
|
||||
assert_eq!(anthropic_response["content"][0]["type"], "thinking");
|
||||
assert_eq!(
|
||||
anthropic_response["content"][0]["thinking"],
|
||||
"Need the current date before calling weather."
|
||||
);
|
||||
assert_eq!(anthropic_response["content"][1]["type"], "text");
|
||||
assert_eq!(anthropic_response["content"][2]["type"], "tool_use");
|
||||
assert_eq!(anthropic_response["content"][2]["id"], "call_date");
|
||||
|
||||
let follow_up_request = json!({
|
||||
"model": "deepseek-v4-flash",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": anthropic_response["content"].clone()
|
||||
}]
|
||||
});
|
||||
let replayed = anthropic_to_openai_with_reasoning_content(follow_up_request, true).unwrap();
|
||||
let msg = &replayed["messages"][0];
|
||||
|
||||
assert_eq!(
|
||||
msg["reasoning_content"],
|
||||
"Need the current date before calling weather."
|
||||
);
|
||||
assert_eq!(msg["tool_calls"][0]["id"], "call_date");
|
||||
assert_eq!(msg["tool_calls"][0]["function"]["name"], "get_date");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_model_passthrough() {
|
||||
// 格式转换层只做结构转换,模型映射由上游 proxy::model_mapper 处理
|
||||
|
||||
@@ -8,19 +8,51 @@
|
||||
//! - system prompt 使用 `instructions` 字段而非 system role message
|
||||
//! - usage 字段命名与 Anthropic 一致 (input_tokens/output_tokens)
|
||||
|
||||
use crate::proxy::error::ProxyError;
|
||||
use crate::proxy::{error::ProxyError, json_canonical::canonical_json_string};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub(crate) fn sanitize_anthropic_tool_use_input(name: &str, input: Value) -> Value {
|
||||
if name != "Read" {
|
||||
return input;
|
||||
}
|
||||
|
||||
match input {
|
||||
Value::Object(mut object) => {
|
||||
if matches!(object.get("pages"), Some(Value::String(value)) if value.is_empty()) {
|
||||
object.remove("pages");
|
||||
}
|
||||
Value::Object(object)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_anthropic_tool_use_input_json(name: &str, raw: &str) -> String {
|
||||
if name != "Read" || raw.is_empty() {
|
||||
return raw.to_string();
|
||||
}
|
||||
|
||||
let Ok(input) = serde_json::from_str::<Value>(raw) else {
|
||||
return raw.to_string();
|
||||
};
|
||||
|
||||
serde_json::to_string(&sanitize_anthropic_tool_use_input(name, input))
|
||||
.unwrap_or_else(|_| raw.to_string())
|
||||
}
|
||||
|
||||
/// Anthropic 请求 → OpenAI Responses 请求
|
||||
///
|
||||
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
|
||||
/// `is_codex_oauth`: 当目标后端是 ChatGPT Plus/Pro 反代 (`chatgpt.com/backend-api/codex`) 时为 true。
|
||||
/// 该后端强制要求 `store: false`,并要求 `include` 包含 `reasoning.encrypted_content`
|
||||
/// 以便在无服务端状态下保持多轮 reasoning 上下文。
|
||||
/// `codex_fast_mode`: 仅在 `is_codex_oauth` 为 true 时生效,控制是否注入
|
||||
/// `service_tier = "priority"`。
|
||||
pub fn anthropic_to_responses(
|
||||
body: Value,
|
||||
cache_key: Option<&str>,
|
||||
is_codex_oauth: bool,
|
||||
codex_fast_mode: bool,
|
||||
) -> Result<Value, ProxyError> {
|
||||
let mut result = json!({});
|
||||
|
||||
@@ -32,10 +64,12 @@ pub fn anthropic_to_responses(
|
||||
// system → instructions (Responses API 使用 instructions 字段)
|
||||
if let Some(system) = body.get("system") {
|
||||
let instructions = if let Some(text) = system.as_str() {
|
||||
text.to_string()
|
||||
super::transform::strip_leading_anthropic_billing_header(text).to_string()
|
||||
} else if let Some(arr) = system.as_array() {
|
||||
arr.iter()
|
||||
.filter_map(|msg| msg.get("text").and_then(|t| t.as_str()))
|
||||
.map(super::transform::strip_leading_anthropic_billing_header)
|
||||
.filter(|text| !text.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
} else {
|
||||
@@ -125,10 +159,15 @@ pub fn anthropic_to_responses(
|
||||
// (codex-rs 结构体根本没有这三个字段,OpenAI 自己的客户端不发它们)
|
||||
// - instructions / tools / parallel_tool_calls: 必填字段,缺则兜底默认值
|
||||
// (cc-switch 的 transform 当前是"条件写入",可能产生缺失)
|
||||
// - service_tier: 仅在 FAST mode 开启时写入 "priority"
|
||||
// (与 OpenAI 官方 codex-rs 当前请求结构保持一致)
|
||||
// - stream: 必须永远 true(codex-rs 硬编码 true,且 cc-switch 的
|
||||
// SSE 解析层只处理流式响应,强制覆盖避免客户端误传 false)
|
||||
if is_codex_oauth {
|
||||
result["store"] = json!(false);
|
||||
if codex_fast_mode {
|
||||
result["service_tier"] = json!("priority");
|
||||
}
|
||||
|
||||
const REASONING_MARKER: &str = "reasoning.encrypted_content";
|
||||
let mut includes: Vec<Value> = body
|
||||
@@ -210,12 +249,28 @@ pub(crate) fn map_responses_stop_reason(
|
||||
|
||||
/// Build Anthropic-style usage JSON from Responses API usage, including cache tokens.
|
||||
///
|
||||
/// Priority order:
|
||||
/// **Robustness Features**:
|
||||
/// - Handles null, missing, empty objects, and partial objects gracefully
|
||||
/// - Supports OpenAI field name variants (prompt_tokens/completion_tokens) as fallbacks
|
||||
/// - Always returns valid structure: {"input_tokens": N, "output_tokens": N}
|
||||
/// - Preserves cache token fields even when input/output tokens are missing
|
||||
///
|
||||
/// **Field Name Resolution Priority**:
|
||||
/// 1. input_tokens: Anthropic `input_tokens` → OpenAI `prompt_tokens` → default 0
|
||||
/// 2. output_tokens: Anthropic `output_tokens` → OpenAI `completion_tokens` → default 0
|
||||
/// 3. cache_read_input_tokens: Direct field → nested input_tokens_details.cached_tokens → prompt_tokens_details.cached_tokens
|
||||
/// 4. cache_creation_input_tokens: Direct field only
|
||||
///
|
||||
/// **Cache Token Priority Order**:
|
||||
/// 1. OpenAI nested details (`input_tokens_details.cached_tokens`, `prompt_tokens_details.cached_tokens`) as initial value
|
||||
/// 2. Direct Anthropic-style fields (`cache_read_input_tokens`, `cache_creation_input_tokens`) override if present
|
||||
///
|
||||
/// **Logging**:
|
||||
/// - Warns on empty objects {} or partial objects (only one field present)
|
||||
/// - Debug logs when using OpenAI field name fallbacks
|
||||
pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Value {
|
||||
let u = match usage {
|
||||
Some(v) if !v.is_null() => v,
|
||||
Some(v) if !v.is_null() && v.is_object() => v,
|
||||
_ => {
|
||||
return json!({
|
||||
"input_tokens": 0,
|
||||
@@ -224,15 +279,56 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val
|
||||
}
|
||||
};
|
||||
|
||||
let input = u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let output = u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
// Detect empty object {} and log warning
|
||||
if u.as_object().map(|obj| obj.is_empty()).unwrap_or(false) {
|
||||
log::warn!("[Responses] Empty usage object received, using defaults");
|
||||
return json!({
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
});
|
||||
}
|
||||
|
||||
// Extract input_tokens with OpenAI field name fallback
|
||||
// Priority: input_tokens (Anthropic) → prompt_tokens (OpenAI) → 0
|
||||
let input = u
|
||||
.get("input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.or_else(|| {
|
||||
let prompt_tokens = u.get("prompt_tokens").and_then(|v| v.as_u64());
|
||||
if prompt_tokens.is_some() {
|
||||
log::debug!(
|
||||
"[Responses] Using OpenAI field name fallback 'prompt_tokens' for input_tokens"
|
||||
);
|
||||
}
|
||||
prompt_tokens
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
// Extract output_tokens with OpenAI field name fallback
|
||||
// Priority: output_tokens (Anthropic) → completion_tokens (OpenAI) → 0
|
||||
let output = u.get("output_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.or_else(|| {
|
||||
let completion_tokens = u.get("completion_tokens").and_then(|v| v.as_u64());
|
||||
if completion_tokens.is_some() {
|
||||
log::debug!("[Responses] Using OpenAI field name fallback 'completion_tokens' for output_tokens");
|
||||
}
|
||||
completion_tokens
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
// Log if only one field present (partial object). Streaming chunks legitimately
|
||||
// arrive with partial usage, so this stays at debug level to avoid noise.
|
||||
if (input == 0 && output > 0) || (input > 0 && output == 0) {
|
||||
log::debug!("[Responses] Partial usage object: {:?}", u);
|
||||
}
|
||||
|
||||
let mut result = json!({
|
||||
"input_tokens": input,
|
||||
"output_tokens": output
|
||||
});
|
||||
|
||||
// Step 1: OpenAI nested details as fallback
|
||||
// Step 1: OpenAI nested details as fallback for cache tokens
|
||||
// OpenAI Responses API: input_tokens_details.cached_tokens
|
||||
if let Some(cached) = u
|
||||
.pointer("/input_tokens_details/cached_tokens")
|
||||
@@ -251,6 +347,7 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val
|
||||
}
|
||||
|
||||
// Step 2: Direct Anthropic-style fields override (authoritative if present)
|
||||
// These preserve cache tokens even if input/output_tokens are missing
|
||||
if let Some(v) = u.get("cache_read_input_tokens") {
|
||||
result["cache_read_input_tokens"] = v.clone();
|
||||
}
|
||||
@@ -344,7 +441,7 @@ fn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyErro
|
||||
"type": "function_call",
|
||||
"call_id": id,
|
||||
"name": name,
|
||||
"arguments": serde_json::to_string(&arguments).unwrap_or_default()
|
||||
"arguments": canonical_json_string(&arguments)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -365,7 +462,7 @@ fn convert_messages_to_input(messages: &[Value]) -> Result<Vec<Value>, ProxyErro
|
||||
.unwrap_or("");
|
||||
let output = match block.get("content") {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(v) => serde_json::to_string(v).unwrap_or_default(),
|
||||
Some(v) => canonical_json_string(v),
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
@@ -446,6 +543,7 @@ pub fn responses_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("{}");
|
||||
let input: Value = serde_json::from_str(args_str).unwrap_or(json!({}));
|
||||
let input = sanitize_anthropic_tool_use_input(name, input);
|
||||
|
||||
content.push(json!({
|
||||
"type": "tool_use",
|
||||
@@ -520,7 +618,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(result["model"], "gpt-4o");
|
||||
assert_eq!(result["max_output_tokens"], 1024);
|
||||
assert_eq!(result["input"][0]["role"], "user");
|
||||
@@ -539,12 +637,54 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(result["instructions"], "You are a helpful assistant.");
|
||||
// system should not appear in input
|
||||
assert_eq!(result["input"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_strips_leading_billing_header_from_system_string() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"system": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\n\nYou are a helpful assistant.",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(result["instructions"], "You are a helpful assistant.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_strips_billing_header_with_crlf() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"system": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\r\n\r\nYou are a helpful assistant.",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(result["instructions"], "You are a helpful assistant.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_keeps_non_leading_billing_header_text() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"system": "Keep this literal:\nx-anthropic-billing-header: example",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(
|
||||
result["instructions"],
|
||||
"Keep this literal:\nx-anthropic-billing-header: example"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_with_system_array() {
|
||||
let input = json!({
|
||||
@@ -557,10 +697,45 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(result["instructions"], "Part 1\n\nPart 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_strips_billing_header_from_system_array_parts() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\n"},
|
||||
{"type": "text", "text": "Stable prompt"}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(result["instructions"], "Stable prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_preserves_prompt_after_billing_header_in_same_part() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.119.47e; cc_entrypoint=sdk-cli; cch=a7754;\n\nStable prompt part 1"},
|
||||
{"type": "text", "text": "Stable prompt part 2"}
|
||||
],
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(
|
||||
result["instructions"],
|
||||
"Stable prompt part 1\n\nStable prompt part 2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_with_tools() {
|
||||
let input = json!({
|
||||
@@ -574,7 +749,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(result["tools"][0]["type"], "function");
|
||||
assert_eq!(result["tools"][0]["name"], "get_weather");
|
||||
assert!(result["tools"][0].get("parameters").is_some());
|
||||
@@ -591,7 +766,7 @@ mod tests {
|
||||
"tool_choice": {"type": "any"}
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(result["tool_choice"], "required");
|
||||
}
|
||||
|
||||
@@ -604,7 +779,7 @@ mod tests {
|
||||
"tool_choice": {"type": "tool", "name": "get_weather"}
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(result["tool_choice"]["type"], "function");
|
||||
assert_eq!(result["tool_choice"]["name"], "get_weather");
|
||||
}
|
||||
@@ -623,7 +798,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
let input_arr = result["input"].as_array().unwrap();
|
||||
|
||||
// Should produce: assistant message (text) + function_call item
|
||||
@@ -653,7 +828,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
let input_arr = result["input"].as_array().unwrap();
|
||||
|
||||
// Should produce: function_call_output item (lifted)
|
||||
@@ -677,7 +852,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
let input_arr = result["input"].as_array().unwrap();
|
||||
|
||||
// thinking should be discarded, only text remains
|
||||
@@ -700,7 +875,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
let content = result["input"][0]["content"].as_array().unwrap();
|
||||
|
||||
assert_eq!(content[0]["type"], "input_text");
|
||||
@@ -760,6 +935,56 @@ mod tests {
|
||||
assert_eq!(result["stop_reason"], "tool_use");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_to_anthropic_read_drops_empty_pages() {
|
||||
let input = json!({
|
||||
"id": "resp_read",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"model": "gpt-5.5",
|
||||
"output": [{
|
||||
"type": "function_call",
|
||||
"id": "fc_read",
|
||||
"call_id": "call_read",
|
||||
"name": "Read",
|
||||
"arguments": "{\"file_path\":\"/tmp/demo.py\",\"limit\":2000,\"offset\":0,\"pages\":\"\"}",
|
||||
"status": "completed"
|
||||
}]
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
let tool_input = &result["content"][0]["input"];
|
||||
|
||||
assert_eq!(result["content"][0]["type"], "tool_use");
|
||||
assert_eq!(result["content"][0]["name"], "Read");
|
||||
assert_eq!(tool_input["file_path"], "/tmp/demo.py");
|
||||
assert_eq!(tool_input["limit"], 2000);
|
||||
assert_eq!(tool_input["offset"], 0);
|
||||
assert!(tool_input.get("pages").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_to_anthropic_preserves_empty_strings_for_other_tools() {
|
||||
let input = json!({
|
||||
"id": "resp_other",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"model": "gpt-5.5",
|
||||
"output": [{
|
||||
"type": "function_call",
|
||||
"id": "fc_other",
|
||||
"call_id": "call_other",
|
||||
"name": "search",
|
||||
"arguments": "{\"query\":\"\"}",
|
||||
"status": "completed"
|
||||
}]
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
|
||||
assert_eq!(result["content"][0]["input"]["query"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_responses_to_anthropic_with_refusal_block() {
|
||||
let input = json!({
|
||||
@@ -858,7 +1083,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(result["model"], "o3-mini");
|
||||
}
|
||||
|
||||
@@ -870,7 +1095,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, Some("my-provider-id"), false).unwrap();
|
||||
let result = anthropic_to_responses(input, Some("my-provider-id"), false, false).unwrap();
|
||||
assert_eq!(result["prompt_cache_key"], "my-provider-id");
|
||||
}
|
||||
|
||||
@@ -888,7 +1113,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert!(result["tools"][0].get("cache_control").is_none());
|
||||
}
|
||||
|
||||
@@ -905,7 +1130,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert!(result["input"][0]["content"][0]
|
||||
.get("cache_control")
|
||||
.is_none());
|
||||
@@ -967,7 +1192,7 @@ mod tests {
|
||||
"max_tokens": 4096,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(result["max_output_tokens"], 4096);
|
||||
assert!(result.get("max_completion_tokens").is_none());
|
||||
}
|
||||
@@ -981,7 +1206,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "xhigh");
|
||||
}
|
||||
|
||||
@@ -995,7 +1220,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "low");
|
||||
}
|
||||
|
||||
@@ -1008,7 +1233,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "low");
|
||||
}
|
||||
|
||||
@@ -1021,7 +1246,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "medium");
|
||||
}
|
||||
|
||||
@@ -1034,7 +1259,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "high");
|
||||
}
|
||||
|
||||
@@ -1047,7 +1272,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert_eq!(result["reasoning"]["effort"], "xhigh");
|
||||
}
|
||||
|
||||
@@ -1060,7 +1285,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
assert!(result.get("reasoning").is_none());
|
||||
}
|
||||
|
||||
@@ -1074,10 +1299,11 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
let result = anthropic_to_responses(input, None, true, true).unwrap();
|
||||
|
||||
// store 必须显式为 false(ChatGPT 后端拒绝 true)
|
||||
assert_eq!(result["store"], json!(false));
|
||||
assert_eq!(result["service_tier"], json!("priority"));
|
||||
|
||||
// include 必须包含 reasoning.encrypted_content(无服务端状态下保持多轮 reasoning)
|
||||
assert_eq!(result["include"], json!(["reasoning.encrypted_content"]));
|
||||
@@ -1093,9 +1319,10 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
|
||||
assert!(result.get("store").is_none());
|
||||
assert!(result.get("service_tier").is_none());
|
||||
assert!(result.get("include").is_none());
|
||||
}
|
||||
|
||||
@@ -1109,7 +1336,7 @@ mod tests {
|
||||
"include": ["something.else", "reasoning.encrypted_content"]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
let result = anthropic_to_responses(input, None, true, true).unwrap();
|
||||
let includes = result["include"]
|
||||
.as_array()
|
||||
.expect("include should be array");
|
||||
@@ -1130,6 +1357,21 @@ mod tests {
|
||||
assert_eq!(marker_count, 1, "marker 不应被重复添加(idempotent 失败)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_codex_oauth_fast_mode_can_be_disabled() {
|
||||
let input = json!({
|
||||
"model": "gpt-5-codex",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true, false).unwrap();
|
||||
|
||||
assert_eq!(result["store"], json!(false));
|
||||
assert!(result.get("service_tier").is_none());
|
||||
assert_eq!(result["include"], json!(["reasoning.encrypted_content"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_responses_codex_oauth_strips_max_output_tokens() {
|
||||
// ChatGPT Plus/Pro 反代不接受 max_output_tokens(OpenAI 官方 codex-rs 的
|
||||
@@ -1141,7 +1383,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
let result = anthropic_to_responses(input, None, true, true).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("max_output_tokens").is_none(),
|
||||
@@ -1159,7 +1401,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
|
||||
assert_eq!(result["max_output_tokens"], json!(1024));
|
||||
}
|
||||
@@ -1177,7 +1419,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
let result = anthropic_to_responses(input, None, true, true).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("temperature").is_none(),
|
||||
@@ -1195,7 +1437,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
let result = anthropic_to_responses(input, None, true, true).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("top_p").is_none(),
|
||||
@@ -1212,7 +1454,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
let result = anthropic_to_responses(input, None, true, true).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result["instructions"],
|
||||
@@ -1246,7 +1488,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
let result = anthropic_to_responses(input, None, true, true).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result["instructions"],
|
||||
@@ -1270,7 +1512,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, true).unwrap();
|
||||
let result = anthropic_to_responses(input, None, true, true).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result["stream"],
|
||||
@@ -1291,7 +1533,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
|
||||
assert_eq!(result["temperature"], json!(0.7));
|
||||
assert_eq!(result["top_p"], json!(0.9));
|
||||
@@ -1307,7 +1549,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_responses(input, None, false).unwrap();
|
||||
let result = anthropic_to_responses(input, None, false, false).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("parallel_tool_calls").is_none(),
|
||||
@@ -1327,4 +1569,106 @@ mod tests {
|
||||
"非 Codex OAuth 路径下 tools 在客户端未送时不应被注入"
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Usage Field Robustness Tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_build_usage_from_null_parameter() {
|
||||
let result = build_anthropic_usage_from_responses(None);
|
||||
assert_eq!(result["input_tokens"], json!(0));
|
||||
assert_eq!(result["output_tokens"], json!(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_usage_from_null_json_value() {
|
||||
let result = build_anthropic_usage_from_responses(Some(&json!(null)));
|
||||
assert_eq!(result["input_tokens"], json!(0));
|
||||
assert_eq!(result["output_tokens"], json!(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_usage_from_empty_object() {
|
||||
let result = build_anthropic_usage_from_responses(Some(&json!({})));
|
||||
assert_eq!(result["input_tokens"], json!(0));
|
||||
assert_eq!(result["output_tokens"], json!(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_usage_from_partial_input_only() {
|
||||
let result = build_anthropic_usage_from_responses(Some(&json!({
|
||||
"input_tokens": 100
|
||||
})));
|
||||
assert_eq!(result["input_tokens"], json!(100));
|
||||
assert_eq!(result["output_tokens"], json!(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_usage_from_partial_output_only() {
|
||||
let result = build_anthropic_usage_from_responses(Some(&json!({
|
||||
"output_tokens": 50
|
||||
})));
|
||||
assert_eq!(result["input_tokens"], json!(0));
|
||||
assert_eq!(result["output_tokens"], json!(50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_usage_with_openai_field_names() {
|
||||
let result = build_anthropic_usage_from_responses(Some(&json!({
|
||||
"prompt_tokens": 120,
|
||||
"completion_tokens": 45
|
||||
})));
|
||||
assert_eq!(result["input_tokens"], json!(120));
|
||||
assert_eq!(result["output_tokens"], json!(45));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_usage_anthropic_names_precedence() {
|
||||
let result = build_anthropic_usage_from_responses(Some(&json!({
|
||||
"input_tokens": 100,
|
||||
"prompt_tokens": 120,
|
||||
"output_tokens": 50,
|
||||
"completion_tokens": 45
|
||||
})));
|
||||
assert_eq!(result["input_tokens"], json!(100)); // Anthropic name takes precedence
|
||||
assert_eq!(result["output_tokens"], json!(50)); // Anthropic name takes precedence
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_usage_cache_tokens_from_nested_details() {
|
||||
let result = build_anthropic_usage_from_responses(Some(&json!({
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 80
|
||||
}
|
||||
})));
|
||||
assert_eq!(result["input_tokens"], json!(100));
|
||||
assert_eq!(result["output_tokens"], json!(50));
|
||||
assert_eq!(result["cache_read_input_tokens"], json!(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_usage_cache_tokens_direct_override() {
|
||||
let result = build_anthropic_usage_from_responses(Some(&json!({
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 50,
|
||||
"input_tokens_details": {
|
||||
"cached_tokens": 80
|
||||
},
|
||||
"cache_read_input_tokens": 100
|
||||
})));
|
||||
assert_eq!(result["cache_read_input_tokens"], json!(100)); // Direct field overrides nested
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_usage_cache_tokens_without_input_output() {
|
||||
let result = build_anthropic_usage_from_responses(Some(&json!({
|
||||
"cache_read_input_tokens": 60,
|
||||
"cache_creation_input_tokens": 20
|
||||
})));
|
||||
assert_eq!(result["input_tokens"], json!(0));
|
||||
assert_eq!(result["output_tokens"], json!(0));
|
||||
assert_eq!(result["cache_read_input_tokens"], json!(60));
|
||||
assert_eq!(result["cache_creation_input_tokens"], json!(20));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 统一处理流式和非流式 API 响应
|
||||
|
||||
use super::{
|
||||
handler_config::UsageParserConfig,
|
||||
handler_config::{StreamUsageEventFilter, UsageParserConfig},
|
||||
handler_context::{RequestContext, StreamingTimeoutConfig},
|
||||
hyper_client::ProxyResponse,
|
||||
server::ProxyState,
|
||||
@@ -211,7 +211,7 @@ pub async fn handle_streaming(
|
||||
// 创建字节流
|
||||
let stream = response.bytes_stream();
|
||||
|
||||
// 创建使用量收集器
|
||||
// 创建使用量收集器;关闭 usage logging 时不要在流式热路径上解析每个 SSE event。
|
||||
let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config);
|
||||
|
||||
// 获取流式超时配置
|
||||
@@ -219,7 +219,7 @@ pub async fn handle_streaming(
|
||||
|
||||
// 创建带日志和超时的透传流
|
||||
let logged_stream =
|
||||
create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector), timeout_config);
|
||||
create_logged_passthrough_stream(stream, ctx.tag, usage_collector, timeout_config);
|
||||
|
||||
let body = axum::body::Body::from_stream(logged_stream);
|
||||
match builder.body(body) {
|
||||
@@ -255,63 +255,67 @@ pub async fn handle_non_streaming(
|
||||
String::from_utf8_lossy(&body_bytes)
|
||||
);
|
||||
|
||||
// 解析并记录使用量
|
||||
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
|
||||
// 解析使用量
|
||||
if let Some(usage) = (parser_config.response_parser)(&json_value) {
|
||||
// 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
|
||||
let model = if let Some(ref m) = usage.model {
|
||||
m.clone()
|
||||
} else if let Some(m) = json_value.get("model").and_then(|m| m.as_str()) {
|
||||
m.to_string()
|
||||
} else {
|
||||
ctx.request_model.clone()
|
||||
};
|
||||
// 解析并记录使用量。关闭 usage logging 时直接跳过,避免非流式响应整包 JSON parse。
|
||||
if usage_logging_enabled(state) {
|
||||
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
|
||||
// 解析使用量
|
||||
if let Some(usage) = (parser_config.response_parser)(&json_value) {
|
||||
// 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
|
||||
let model = if let Some(ref m) = usage.model {
|
||||
m.clone()
|
||||
} else if let Some(m) = json_value.get("model").and_then(|m| m.as_str()) {
|
||||
m.to_string()
|
||||
} else {
|
||||
ctx.request_model.clone()
|
||||
};
|
||||
|
||||
spawn_log_usage(
|
||||
state,
|
||||
ctx,
|
||||
usage,
|
||||
&model,
|
||||
&ctx.request_model,
|
||||
status.as_u16(),
|
||||
false,
|
||||
);
|
||||
spawn_log_usage(
|
||||
state,
|
||||
ctx,
|
||||
usage,
|
||||
&model,
|
||||
&ctx.request_model,
|
||||
status.as_u16(),
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
let model = json_value
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or(&ctx.request_model)
|
||||
.to_string();
|
||||
spawn_log_usage(
|
||||
state,
|
||||
ctx,
|
||||
TokenUsage::default(),
|
||||
&model,
|
||||
&ctx.request_model,
|
||||
status.as_u16(),
|
||||
false,
|
||||
);
|
||||
log::debug!(
|
||||
"[{}] 未能解析 usage 信息,跳过记录",
|
||||
parser_config.app_type_str
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let model = json_value
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or(&ctx.request_model)
|
||||
.to_string();
|
||||
log::debug!(
|
||||
"[{}] <<< 响应 (非 JSON): {} bytes",
|
||||
ctx.tag,
|
||||
body_bytes.len()
|
||||
);
|
||||
spawn_log_usage(
|
||||
state,
|
||||
ctx,
|
||||
TokenUsage::default(),
|
||||
&model,
|
||||
&ctx.request_model,
|
||||
&ctx.request_model,
|
||||
status.as_u16(),
|
||||
false,
|
||||
);
|
||||
log::debug!(
|
||||
"[{}] 未能解析 usage 信息,跳过记录",
|
||||
parser_config.app_type_str
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log::debug!(
|
||||
"[{}] <<< 响应 (非 JSON): {} bytes",
|
||||
ctx.tag,
|
||||
body_bytes.len()
|
||||
);
|
||||
spawn_log_usage(
|
||||
state,
|
||||
ctx,
|
||||
TokenUsage::default(),
|
||||
&ctx.request_model,
|
||||
&ctx.request_model,
|
||||
status.as_u16(),
|
||||
false,
|
||||
);
|
||||
log::debug!("[{}] usage logging 已关闭,跳过非流式 usage 解析", ctx.tag);
|
||||
}
|
||||
|
||||
// 构建响应
|
||||
@@ -360,13 +364,15 @@ struct SseUsageCollectorInner {
|
||||
first_event_time: Mutex<Option<std::time::Instant>>,
|
||||
start_time: std::time::Instant,
|
||||
on_complete: UsageCallbackWithTiming,
|
||||
should_collect: Option<StreamUsageEventFilter>,
|
||||
finished: AtomicBool,
|
||||
}
|
||||
|
||||
impl SseUsageCollector {
|
||||
/// 创建新的使用量收集器
|
||||
/// 创建使用量收集器;`should_collect` 用来在 hot path 跳过与 usage 无关的事件。
|
||||
pub fn new(
|
||||
start_time: std::time::Instant,
|
||||
should_collect: Option<StreamUsageEventFilter>,
|
||||
callback: impl Fn(Vec<Value>, Option<u64>) + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
let on_complete: UsageCallbackWithTiming = Arc::new(callback);
|
||||
@@ -376,11 +382,19 @@ impl SseUsageCollector {
|
||||
first_event_time: Mutex::new(None),
|
||||
start_time,
|
||||
on_complete,
|
||||
should_collect,
|
||||
finished: AtomicBool::new(false),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_collect(&self, data: &str) -> bool {
|
||||
self.inner
|
||||
.should_collect
|
||||
.map(|filter| filter(data))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// 推送 SSE 事件
|
||||
pub async fn push(&self, event: Value) {
|
||||
// 记录首个事件时间
|
||||
@@ -424,12 +438,16 @@ fn create_usage_collector(
|
||||
state: &ProxyState,
|
||||
status_code: u16,
|
||||
parser_config: &UsageParserConfig,
|
||||
) -> SseUsageCollector {
|
||||
) -> Option<SseUsageCollector> {
|
||||
let logging_enabled = state
|
||||
.config
|
||||
.try_read()
|
||||
.map(|c| c.enable_logging)
|
||||
.unwrap_or(true);
|
||||
if !logging_enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let request_model = ctx.request_model.clone();
|
||||
@@ -440,62 +458,63 @@ fn create_usage_collector(
|
||||
let model_extractor = parser_config.model_extractor;
|
||||
let session_id = ctx.session_id.clone();
|
||||
|
||||
SseUsageCollector::new(start_time, move |events, first_token_ms| {
|
||||
if !logging_enabled {
|
||||
return;
|
||||
}
|
||||
if let Some(usage) = stream_parser(&events) {
|
||||
let model = model_extractor(&events, &request_model);
|
||||
let latency_ms = start_time.elapsed().as_millis() as u64;
|
||||
Some(SseUsageCollector::new(
|
||||
start_time,
|
||||
parser_config.stream_event_filter,
|
||||
move |events, first_token_ms| {
|
||||
if let Some(usage) = stream_parser(&events) {
|
||||
let model = model_extractor(&events, &request_model);
|
||||
let latency_ms = start_time.elapsed().as_millis() as u64;
|
||||
|
||||
let state = state.clone();
|
||||
let provider_id = provider_id.clone();
|
||||
let session_id = session_id.clone();
|
||||
let request_model = request_model.clone();
|
||||
let state = state.clone();
|
||||
let provider_id = provider_id.clone();
|
||||
let session_id = session_id.clone();
|
||||
let request_model = request_model.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
log_usage_internal(
|
||||
&state,
|
||||
&provider_id,
|
||||
app_type_str,
|
||||
&model,
|
||||
&request_model,
|
||||
usage,
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
true, // is_streaming
|
||||
status_code,
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
} else {
|
||||
let model = model_extractor(&events, &request_model);
|
||||
let latency_ms = start_time.elapsed().as_millis() as u64;
|
||||
let state = state.clone();
|
||||
let provider_id = provider_id.clone();
|
||||
let session_id = session_id.clone();
|
||||
let request_model = request_model.clone();
|
||||
tokio::spawn(async move {
|
||||
log_usage_internal(
|
||||
&state,
|
||||
&provider_id,
|
||||
app_type_str,
|
||||
&model,
|
||||
&request_model,
|
||||
usage,
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
true, // is_streaming
|
||||
status_code,
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
} else {
|
||||
let model = model_extractor(&events, &request_model);
|
||||
let latency_ms = start_time.elapsed().as_millis() as u64;
|
||||
let state = state.clone();
|
||||
let provider_id = provider_id.clone();
|
||||
let session_id = session_id.clone();
|
||||
let request_model = request_model.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
log_usage_internal(
|
||||
&state,
|
||||
&provider_id,
|
||||
app_type_str,
|
||||
&model,
|
||||
&request_model,
|
||||
TokenUsage::default(),
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
true, // is_streaming
|
||||
status_code,
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
log::debug!("[{tag}] 流式响应缺少 usage 统计,跳过消费记录");
|
||||
}
|
||||
})
|
||||
tokio::spawn(async move {
|
||||
log_usage_internal(
|
||||
&state,
|
||||
&provider_id,
|
||||
app_type_str,
|
||||
&model,
|
||||
&request_model,
|
||||
TokenUsage::default(),
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
true, // is_streaming
|
||||
status_code,
|
||||
Some(session_id),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
log::debug!("[{tag}] 流式响应缺少 usage 统计,跳过消费记录");
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// 异步记录使用量
|
||||
@@ -541,6 +560,14 @@ fn spawn_log_usage(
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn usage_logging_enabled(state: &ProxyState) -> bool {
|
||||
state
|
||||
.config
|
||||
.try_read()
|
||||
.map(|config| config.enable_logging)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// 内部使用量记录函数
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn log_usage_internal(
|
||||
@@ -609,6 +636,8 @@ pub fn create_logged_passthrough_stream(
|
||||
let mut buffer = String::new();
|
||||
let mut utf8_remainder: Vec<u8> = Vec::new();
|
||||
let mut collector = usage_collector;
|
||||
let inspect_sse_events =
|
||||
collector.is_some() || log::log_enabled!(log::Level::Debug);
|
||||
let mut is_first_chunk = true;
|
||||
|
||||
// 超时配置
|
||||
@@ -659,25 +688,36 @@ pub fn create_logged_passthrough_stream(
|
||||
);
|
||||
}
|
||||
is_first_chunk = false;
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
if inspect_sse_events {
|
||||
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
|
||||
|
||||
// 尝试解析并记录完整的 SSE 事件
|
||||
while let Some(event_text) = take_sse_block(&mut buffer) {
|
||||
if !event_text.trim().is_empty() {
|
||||
// 提取 data 部分并尝试解析为 JSON
|
||||
for line in event_text.lines() {
|
||||
if let Some(data) = strip_sse_field(line, "data") {
|
||||
if data.trim() != "[DONE]" {
|
||||
if let Ok(json_value) = serde_json::from_str::<Value>(data) {
|
||||
if let Some(c) = &collector {
|
||||
c.push(json_value.clone()).await;
|
||||
// 尝试解析并记录完整的 SSE 事件
|
||||
while let Some(event_text) = take_sse_block(&mut buffer) {
|
||||
if !event_text.trim().is_empty() {
|
||||
// 提取 data 部分;只有 usage collector 存在时才解析 JSON。
|
||||
for line in event_text.lines() {
|
||||
if let Some(data) = strip_sse_field(line, "data") {
|
||||
if data.trim() != "[DONE]" {
|
||||
let collected = match &collector {
|
||||
Some(c) if c.should_collect(data) => {
|
||||
match serde_json::from_str::<Value>(data) {
|
||||
Ok(json_value) => {
|
||||
c.push(json_value).await;
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if collected {
|
||||
log::debug!("[{tag}] <<< SSE 事件: {data}");
|
||||
} else {
|
||||
log::debug!("[{tag}] <<< SSE 数据: {data}");
|
||||
}
|
||||
log::debug!("[{tag}] <<< SSE 事件: {data}");
|
||||
} else {
|
||||
log::debug!("[{tag}] <<< SSE 数据: {data}");
|
||||
log::debug!("[{tag}] <<< SSE: [DONE]");
|
||||
}
|
||||
} else {
|
||||
log::debug!("[{tag}] <<< SSE: [DONE]");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -894,9 +934,11 @@ mod tests {
|
||||
db.set_pricing_model_source(app_type, "response").await?;
|
||||
seed_pricing(&db)?;
|
||||
|
||||
let mut meta = ProviderMeta::default();
|
||||
meta.cost_multiplier = Some("2".to_string());
|
||||
meta.pricing_model_source = Some("request".to_string());
|
||||
let meta = ProviderMeta {
|
||||
cost_multiplier: Some("2".to_string()),
|
||||
pricing_model_source: Some("request".to_string()),
|
||||
..ProviderMeta::default()
|
||||
};
|
||||
insert_provider(&db, "provider-1", app_type, meta)?;
|
||||
|
||||
let state = build_state(db.clone());
|
||||
|
||||
@@ -285,6 +285,15 @@ impl ProxyServer {
|
||||
// Claude API (支持带前缀和不带前缀两种格式)
|
||||
.route("/v1/messages", post(handlers::handle_messages))
|
||||
.route("/claude/v1/messages", post(handlers::handle_messages))
|
||||
// Claude Desktop 3P 本地 gateway(独立 provider namespace)
|
||||
.route(
|
||||
"/claude-desktop/v1/models",
|
||||
get(handlers::handle_claude_desktop_models),
|
||||
)
|
||||
.route(
|
||||
"/claude-desktop/v1/messages",
|
||||
post(handlers::handle_claude_desktop_messages),
|
||||
)
|
||||
// OpenAI Chat Completions API (Codex CLI,支持带前缀和不带前缀)
|
||||
.route("/chat/completions", post(handlers::handle_chat_completions))
|
||||
.route(
|
||||
|
||||
@@ -233,14 +233,21 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult {
|
||||
|
||||
let total_balance = parse_f64_field(data, "totalBalance").unwrap_or(0.0);
|
||||
|
||||
let unit = if is_cn { "CNY" } else { "USD" };
|
||||
let plan_name = if is_cn {
|
||||
"SiliconFlow"
|
||||
} else {
|
||||
"SiliconFlow (EN)"
|
||||
};
|
||||
|
||||
UsageResult {
|
||||
success: true,
|
||||
data: Some(vec![UsageData {
|
||||
plan_name: Some("SiliconFlow".to_string()),
|
||||
plan_name: Some(plan_name.to_string()),
|
||||
remaining: Some(total_balance),
|
||||
total: None,
|
||||
used: None,
|
||||
unit: Some("CNY".to_string()),
|
||||
unit: Some(unit.to_string()),
|
||||
is_valid: Some(true),
|
||||
invalid_message: None,
|
||||
extra: None,
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
//! 支持 Kimi For Coding、智谱 GLM、MiniMax 的 Token Plan 额度查询。
|
||||
//! 复用 subscription 模块的 SubscriptionQuota / QuotaTier 类型。
|
||||
|
||||
use super::subscription::{CredentialStatus, QuotaTier, SubscriptionQuota};
|
||||
use super::subscription::{
|
||||
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT,
|
||||
};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// ── 供应商检测 ──────────────────────────────────────────────
|
||||
@@ -181,6 +183,60 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
|
||||
|
||||
// ── 智谱 GLM ────────────────────────────────────────────────
|
||||
|
||||
/// 把智谱 `data` 里的 `limits[]` 解析成 tier 列表。
|
||||
///
|
||||
/// 按 `nextResetTime` 升序后:第 0 条 = 五小时桶(`five_hour`)、
|
||||
/// 第 1 条 = 每周桶(`weekly_limit`)。老套餐(2026-02-12 前订阅)只回 1 条
|
||||
/// `TOKENS_LIMIT`,自然降级为仅展示 `five_hour`;新套餐回 2 条。
|
||||
/// 缺失 `nextResetTime` 时按 `i64::MAX` 排到末位。
|
||||
fn parse_zhipu_token_tiers(data: &serde_json::Value) -> Vec<QuotaTier> {
|
||||
let mut token_limits: Vec<(i64, f64, Option<String>)> = Vec::new();
|
||||
if let Some(limits) = data.get("limits").and_then(|v| v.as_array()) {
|
||||
for limit_item in limits {
|
||||
let limit_type = limit_item
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
// 大小写不敏感比较:上游若把 "TOKENS_LIMIT" 改成小写或驼峰,依然能识别
|
||||
if !limit_type.eq_ignore_ascii_case("TOKENS_LIMIT") {
|
||||
continue;
|
||||
}
|
||||
let percentage = limit_item
|
||||
.get("percentage")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let reset_ms = limit_item
|
||||
.get("nextResetTime")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(i64::MAX);
|
||||
let reset_iso = if reset_ms == i64::MAX {
|
||||
None
|
||||
} else {
|
||||
millis_to_iso8601(reset_ms)
|
||||
};
|
||||
token_limits.push((reset_ms, percentage, reset_iso));
|
||||
}
|
||||
}
|
||||
token_limits.sort_by_key(|(reset, _, _)| *reset);
|
||||
|
||||
token_limits
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, (_, percentage, resets_at))| {
|
||||
let name = match idx {
|
||||
0 => TIER_FIVE_HOUR,
|
||||
1 => TIER_WEEKLY_LIMIT,
|
||||
_ => return None, // 智谱当前最多两条 TOKENS_LIMIT,多余的忽略
|
||||
};
|
||||
Some(QuotaTier {
|
||||
name: name.to_string(),
|
||||
utilization: percentage,
|
||||
resets_at,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn query_zhipu(api_key: &str) -> SubscriptionQuota {
|
||||
let client = crate::proxy::http_client::get();
|
||||
|
||||
@@ -237,34 +293,7 @@ async fn query_zhipu(api_key: &str) -> SubscriptionQuota {
|
||||
None => return make_error("Missing 'data' field in response".to_string()),
|
||||
};
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
|
||||
if let Some(limits) = data.get("limits").and_then(|v| v.as_array()) {
|
||||
for limit_item in limits {
|
||||
let limit_type = limit_item
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let percentage = limit_item
|
||||
.get("percentage")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let next_reset = limit_item
|
||||
.get("nextResetTime")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(millis_to_iso8601);
|
||||
|
||||
if limit_type != "TOKENS_LIMIT" {
|
||||
continue;
|
||||
}
|
||||
|
||||
tiers.push(QuotaTier {
|
||||
name: "five_hour".to_string(),
|
||||
utilization: percentage,
|
||||
resets_at: next_reset,
|
||||
});
|
||||
}
|
||||
}
|
||||
let tiers = parse_zhipu_token_tiers(data);
|
||||
|
||||
// 套餐等级存入 credential_message
|
||||
let level = data
|
||||
@@ -449,3 +478,135 @@ pub async fn get_coding_plan_quota(
|
||||
|
||||
Ok(quota)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_zhipu_token_tiers, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn zhipu_new_plan_two_tiers_sorted_by_reset_time() {
|
||||
// 新套餐:两条 TOKENS_LIMIT,nextResetTime 较近的归 five_hour、较远的归 weekly_limit。
|
||||
// 故意把"周限"放数组前面,验证不依赖输入顺序。
|
||||
let data = json!({
|
||||
"limits": [
|
||||
{ "type": "TOKENS_LIMIT", "percentage": 53.0, "nextResetTime": 2_000_000_000_000_i64 },
|
||||
{ "type": "TOKENS_LIMIT", "percentage": 44.0, "nextResetTime": 1_000_000_000_000_i64 },
|
||||
{ "type": "TIME_LIMIT", "percentage": 7.0 },
|
||||
]
|
||||
});
|
||||
let tiers = parse_zhipu_token_tiers(&data);
|
||||
assert_eq!(tiers.len(), 2);
|
||||
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
|
||||
assert_eq!(tiers[0].utilization, 44.0);
|
||||
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
|
||||
assert_eq!(tiers[1].utilization, 53.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_old_plan_single_tier_falls_back_to_five_hour() {
|
||||
// 老套餐(2026-02-12 前订阅):仅一条 TOKENS_LIMIT,无周限。
|
||||
let data = json!({
|
||||
"limits": [
|
||||
{
|
||||
"type": "TOKENS_LIMIT",
|
||||
"percentage": 2.0,
|
||||
"nextResetTime": 1_774_967_594_803_i64
|
||||
},
|
||||
{ "type": "TIME_LIMIT", "percentage": 0.0 }
|
||||
]
|
||||
});
|
||||
let tiers = parse_zhipu_token_tiers(&data);
|
||||
assert_eq!(tiers.len(), 1);
|
||||
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
|
||||
assert_eq!(tiers[0].utilization, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_no_token_limits_returns_empty() {
|
||||
let data = json!({ "limits": [{ "type": "TIME_LIMIT", "percentage": 5.0 }] });
|
||||
assert!(parse_zhipu_token_tiers(&data).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_missing_reset_time_sorts_last() {
|
||||
// 防御性:没有 nextResetTime 的条目排到末位,避免抢占 five_hour 槽位。
|
||||
let data = json!({
|
||||
"limits": [
|
||||
{ "type": "TOKENS_LIMIT", "percentage": 99.0 },
|
||||
{ "type": "TOKENS_LIMIT", "percentage": 10.0, "nextResetTime": 1_000_000_000_000_i64 }
|
||||
]
|
||||
});
|
||||
let tiers = parse_zhipu_token_tiers(&data);
|
||||
assert_eq!(tiers.len(), 2);
|
||||
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
|
||||
assert_eq!(tiers[0].utilization, 10.0);
|
||||
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
|
||||
assert_eq!(tiers[1].utilization, 99.0);
|
||||
assert!(tiers[1].resets_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_type_is_case_insensitive() {
|
||||
// 防御性:上游若把 "TOKENS_LIMIT" 改成 "tokens_limit"(仅大小写变化)仍能识别。
|
||||
// 注意:分隔符差异(如 "TokensLimit" 去掉下划线)不在兼容范围。
|
||||
let data = json!({
|
||||
"limits": [
|
||||
{ "type": "tokens_limit", "percentage": 12.0, "nextResetTime": 1_000_000_000_000_i64 },
|
||||
{ "type": "Tokens_Limit", "percentage": 34.0, "nextResetTime": 2_000_000_000_000_i64 }
|
||||
]
|
||||
});
|
||||
let tiers = parse_zhipu_token_tiers(&data);
|
||||
assert_eq!(tiers.len(), 2);
|
||||
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
|
||||
assert_eq!(tiers[0].utilization, 12.0);
|
||||
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
|
||||
assert_eq!(tiers[1].utilization, 34.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_invalid_percentage_falls_back_to_zero() {
|
||||
// percentage 为字符串或 null 时不应崩溃,按 0 处理(仍展示 tier,但用量为 0)。
|
||||
let data = json!({
|
||||
"limits": [
|
||||
{ "type": "TOKENS_LIMIT", "percentage": "invalid", "nextResetTime": 1_000_000_000_000_i64 },
|
||||
{ "type": "TOKENS_LIMIT", "percentage": null, "nextResetTime": 2_000_000_000_000_i64 }
|
||||
]
|
||||
});
|
||||
let tiers = parse_zhipu_token_tiers(&data);
|
||||
assert_eq!(tiers.len(), 2);
|
||||
assert_eq!(tiers[0].utilization, 0.0);
|
||||
assert_eq!(tiers[1].utilization, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_extreme_percentage_values_pass_through() {
|
||||
// 负数 / 超 100 不做范围裁剪——下游渲染层负责显示策略,解析层只负责忠实搬运。
|
||||
let data = json!({
|
||||
"limits": [
|
||||
{ "type": "TOKENS_LIMIT", "percentage": -5.0, "nextResetTime": 1_000_000_000_000_i64 },
|
||||
{ "type": "TOKENS_LIMIT", "percentage": 150.0, "nextResetTime": 2_000_000_000_000_i64 }
|
||||
]
|
||||
});
|
||||
let tiers = parse_zhipu_token_tiers(&data);
|
||||
assert_eq!(tiers.len(), 2);
|
||||
assert_eq!(tiers[0].utilization, -5.0);
|
||||
assert_eq!(tiers[1].utilization, 150.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_more_than_two_token_limits_keeps_first_two() {
|
||||
// 防御性:智谱当前最多两条 TOKENS_LIMIT,若上游意外增加第三条应被丢弃,避免命名空缺。
|
||||
let data = json!({
|
||||
"limits": [
|
||||
{ "type": "TOKENS_LIMIT", "percentage": 1.0, "nextResetTime": 1_000_000_000_000_i64 },
|
||||
{ "type": "TOKENS_LIMIT", "percentage": 2.0, "nextResetTime": 2_000_000_000_000_i64 },
|
||||
{ "type": "TOKENS_LIMIT", "percentage": 3.0, "nextResetTime": 3_000_000_000_000_i64 }
|
||||
]
|
||||
});
|
||||
let tiers = parse_zhipu_token_tiers(&data);
|
||||
assert_eq!(tiers.len(), 2);
|
||||
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
|
||||
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,9 @@ impl ConfigService {
|
||||
match app_type {
|
||||
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
||||
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
||||
AppType::ClaudeDesktop => {
|
||||
// Claude Desktop 3P profiles are managed by claude_desktop_config.
|
||||
}
|
||||
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
||||
AppType::OpenCode => {
|
||||
// OpenCode uses additive mode, no live sync needed
|
||||
@@ -156,7 +159,7 @@ impl ConfigService {
|
||||
}
|
||||
let cfg_text = settings.get("config").and_then(Value::as_str);
|
||||
|
||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
||||
crate::codex_config::write_codex_live_atomic_with_stable_provider(auth, cfg_text)?;
|
||||
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
|
||||
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
|
||||
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
|
||||
|
||||
@@ -112,6 +112,9 @@ impl McpService {
|
||||
AppType::Claude => {
|
||||
mcp::sync_single_server_to_claude(&Default::default(), &server.id, &server.server)?;
|
||||
}
|
||||
AppType::ClaudeDesktop => {
|
||||
log::debug!("Claude Desktop 3P profiles do not use CC Switch MCP sync, skipping");
|
||||
}
|
||||
AppType::Codex => {
|
||||
// Codex uses TOML format, must use the correct function
|
||||
mcp::sync_single_server_to_codex(&Default::default(), &server.id, &server.server)?;
|
||||
@@ -154,6 +157,9 @@ impl McpService {
|
||||
fn remove_server_from_app(_state: &AppState, id: &str, app: &AppType) -> Result<(), AppError> {
|
||||
match app {
|
||||
AppType::Claude => mcp::remove_server_from_claude(id)?,
|
||||
AppType::ClaudeDesktop => {
|
||||
log::debug!("Claude Desktop 3P profiles do not use CC Switch MCP sync, skipping");
|
||||
}
|
||||
AppType::Codex => mcp::remove_server_from_codex(id)?,
|
||||
AppType::Gemini => mcp::remove_server_from_gemini(id)?,
|
||||
AppType::OpenCode => {
|
||||
@@ -175,7 +181,7 @@ impl McpService {
|
||||
let servers = Self::get_all_servers(state)?;
|
||||
|
||||
for app in AppType::all() {
|
||||
if matches!(app, AppType::OpenClaw) {
|
||||
if matches!(app, AppType::OpenClaw | AppType::ClaudeDesktop) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -268,8 +274,8 @@ impl McpService {
|
||||
state.db.save_mcp_server(&to_save)?;
|
||||
existing.insert(to_save.id.clone(), to_save.clone());
|
||||
|
||||
// 同步到对应应用 live 配置
|
||||
Self::sync_server_to_apps(state, &to_save)?;
|
||||
// 导入是读取已有配置,不应反向写回任何应用的 live 配置。
|
||||
// 显式编辑、启用/禁用或手动同步时再执行写回。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,8 +312,8 @@ impl McpService {
|
||||
state.db.save_mcp_server(&to_save)?;
|
||||
existing.insert(to_save.id.clone(), to_save.clone());
|
||||
|
||||
// 同步到对应应用 live 配置
|
||||
Self::sync_server_to_apps(state, &to_save)?;
|
||||
// 导入是读取已有配置,不应反向写回任何应用的 live 配置。
|
||||
// 显式编辑、启用/禁用或手动同步时再执行写回。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,8 +350,8 @@ impl McpService {
|
||||
state.db.save_mcp_server(&to_save)?;
|
||||
existing.insert(to_save.id.clone(), to_save.clone());
|
||||
|
||||
// 同步到对应应用 live 配置
|
||||
Self::sync_server_to_apps(state, &to_save)?;
|
||||
// 导入是读取已有配置,不应反向写回任何应用的 live 配置。
|
||||
// 显式编辑、启用/禁用或手动同步时再执行写回。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,8 +388,8 @@ impl McpService {
|
||||
state.db.save_mcp_server(&to_save)?;
|
||||
existing.insert(to_save.id.clone(), to_save.clone());
|
||||
|
||||
// 同步到对应应用 live 配置
|
||||
Self::sync_server_to_apps(state, &to_save)?;
|
||||
// 导入是读取已有配置,不应反向写回任何应用的 live 配置。
|
||||
// 显式编辑、启用/禁用或手动同步时再执行写回。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -420,8 +426,8 @@ impl McpService {
|
||||
state.db.save_mcp_server(&to_save)?;
|
||||
existing.insert(to_save.id.clone(), to_save.clone());
|
||||
|
||||
// 同步到对应应用 live 配置
|
||||
Self::sync_server_to_apps(state, &to_save)?;
|
||||
// 导入是读取已有配置,不应反向写回任何应用的 live 配置。
|
||||
// 显式编辑、启用/禁用或手动同步时再执行写回。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod skill;
|
||||
pub mod speedtest;
|
||||
pub mod stream_check;
|
||||
pub mod subscription;
|
||||
pub mod usage_cache;
|
||||
pub mod usage_stats;
|
||||
pub mod webdav;
|
||||
pub mod webdav_auto_sync;
|
||||
@@ -30,6 +31,7 @@ pub use proxy::ProxyService;
|
||||
#[allow(unused_imports)]
|
||||
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
||||
pub use speedtest::{EndpointLatency, SpeedtestService};
|
||||
pub use usage_cache::UsageCache;
|
||||
#[allow(unused_imports)]
|
||||
pub use usage_stats::{
|
||||
DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! 模型列表获取服务
|
||||
//!
|
||||
//! 通过 OpenAI 兼容的 GET /v1/models 端点获取供应商可用模型列表。
|
||||
//! 主要面向第三方聚合站(硅基流动、OpenRouter 等)。
|
||||
//! 主要面向第三方聚合站(硅基流动、OpenRouter 等),以及把 Anthropic
|
||||
//! 协议挂在兼容子路径上的官方供应商(DeepSeek、Kimi、智谱 GLM 等)。
|
||||
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -28,85 +30,184 @@ struct ModelEntry {
|
||||
|
||||
const FETCH_TIMEOUT_SECS: u64 = 15;
|
||||
|
||||
/// 404/405 响应体截断长度:避免把几十 KB HTML 404 页整页保留到错误串里。
|
||||
const ERROR_BODY_MAX_CHARS: usize = 512;
|
||||
|
||||
/// 已知的「Anthropic 协议兼容子路径」后缀;按长度降序,最长前缀优先匹配。
|
||||
/// baseURL 命中这些后缀时,候选列表会追加「剥离后缀再拼 /v1/models / /models」的版本。
|
||||
const KNOWN_COMPAT_SUFFIXES: &[&str] = &[
|
||||
"/api/claudecode",
|
||||
"/api/anthropic",
|
||||
"/apps/anthropic",
|
||||
"/api/coding",
|
||||
"/claudecode",
|
||||
"/anthropic",
|
||||
"/step_plan",
|
||||
"/coding",
|
||||
"/claude",
|
||||
];
|
||||
|
||||
/// 获取供应商的可用模型列表
|
||||
///
|
||||
/// 使用 OpenAI 兼容的 GET /v1/models 端点。
|
||||
/// 使用 OpenAI 兼容的 GET /v1/models 端点,按候选列表顺序尝试。
|
||||
pub async fn fetch_models(
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
is_full_url: bool,
|
||||
models_url_override: Option<&str>,
|
||||
) -> Result<Vec<FetchedModel>, String> {
|
||||
if api_key.is_empty() {
|
||||
return Err("API Key is required to fetch models".to_string());
|
||||
}
|
||||
|
||||
let models_url = build_models_url(base_url, is_full_url)?;
|
||||
let candidates = build_models_url_candidates(base_url, is_full_url, models_url_override)?;
|
||||
let client = crate::proxy::http_client::get();
|
||||
let mut last_err: Option<String> = None;
|
||||
|
||||
let response = client
|
||||
.get(&models_url)
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Request failed: {e}"))?;
|
||||
for url in &candidates {
|
||||
log::debug!("[ModelFetch] Trying endpoint: {url}");
|
||||
let response = match client
|
||||
.get(url)
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Err(format!("Request failed: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let status = response.status();
|
||||
|
||||
if status.is_success() {
|
||||
let resp: ModelsResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {e}"))?;
|
||||
|
||||
let mut models: Vec<FetchedModel> = resp
|
||||
.data
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|m| FetchedModel {
|
||||
id: m.id,
|
||||
owned_by: m.owned_by,
|
||||
})
|
||||
.collect();
|
||||
|
||||
models.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
return Ok(models);
|
||||
}
|
||||
|
||||
if status == StatusCode::NOT_FOUND || status == StatusCode::METHOD_NOT_ALLOWED {
|
||||
let body = truncate_body(response.text().await.unwrap_or_default());
|
||||
last_err = Some(format!("HTTP {status}: {body}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
let body = truncate_body(response.text().await.unwrap_or_default());
|
||||
return Err(format!("HTTP {status}: {body}"));
|
||||
}
|
||||
|
||||
let resp: ModelsResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {e}"))?;
|
||||
|
||||
let mut models: Vec<FetchedModel> = resp
|
||||
.data
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|m| FetchedModel {
|
||||
id: m.id,
|
||||
owned_by: m.owned_by,
|
||||
})
|
||||
.collect();
|
||||
|
||||
models.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
Ok(models)
|
||||
Err(format!(
|
||||
"All candidates failed: {}",
|
||||
last_err.unwrap_or_else(|| "no candidates".to_string())
|
||||
))
|
||||
}
|
||||
|
||||
/// 构造 /v1/models 的完整 URL
|
||||
fn build_models_url(base_url: &str, is_full_url: bool) -> Result<String, String> {
|
||||
let trimmed = base_url.trim().trim_end_matches('/');
|
||||
/// 构造「模型列表端点」的候选 URL 列表
|
||||
///
|
||||
/// 候选顺序:
|
||||
/// 1. `models_url_override` 非空 → 只返回它
|
||||
/// 2. baseURL 直接拼 `/v1/models`(若已有 `/v1` 结尾则拼 `/models`)
|
||||
/// 3. 若 baseURL 命中 [`KNOWN_COMPAT_SUFFIXES`],剥离后缀再拼 `/v1/models`
|
||||
/// 4. 同上,但拼 `/models`(部分站点如 DeepSeek 官方只暴露 `/models`)
|
||||
///
|
||||
/// 结果已去重且保持首次出现顺序。
|
||||
pub fn build_models_url_candidates(
|
||||
base_url: &str,
|
||||
is_full_url: bool,
|
||||
models_url_override: Option<&str>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if let Some(raw) = models_url_override {
|
||||
let trimmed = raw.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(vec![trimmed.to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = base_url.trim().trim_end_matches('/');
|
||||
if trimmed.is_empty() {
|
||||
return Err("Base URL is empty".to_string());
|
||||
}
|
||||
|
||||
let mut candidates: Vec<String> = Vec::new();
|
||||
|
||||
if is_full_url {
|
||||
// 尝试从完整端点 URL 推导 API 根路径
|
||||
// 例如: https://proxy.example.com/v1/chat/completions → https://proxy.example.com/v1/models
|
||||
if let Some(idx) = trimmed.find("/v1/") {
|
||||
return Ok(format!("{}/v1/models", &trimmed[..idx]));
|
||||
}
|
||||
// 如果没有 /v1/ 路径,直接去掉最后一段路径
|
||||
if let Some(idx) = trimmed.rfind('/') {
|
||||
candidates.push(format!("{}/v1/models", &trimmed[..idx]));
|
||||
} else if let Some(idx) = trimmed.rfind('/') {
|
||||
let root = &trimmed[..idx];
|
||||
if root.contains("://") && root.len() > root.find("://").unwrap() + 3 {
|
||||
return Ok(format!("{root}/v1/models"));
|
||||
candidates.push(format!("{root}/v1/models"));
|
||||
}
|
||||
}
|
||||
return Err("Cannot derive models endpoint from full URL".to_string());
|
||||
if candidates.is_empty() {
|
||||
return Err("Cannot derive models endpoint from full URL".to_string());
|
||||
}
|
||||
return Ok(candidates);
|
||||
}
|
||||
|
||||
// 常规情况: base_url 是 API 根路径
|
||||
// 如果已经包含 /v1 路径,直接追加 /models
|
||||
if trimmed.ends_with("/v1") {
|
||||
return Ok(format!("{trimmed}/models"));
|
||||
let primary = if trimmed.ends_with("/v1") {
|
||||
format!("{trimmed}/models")
|
||||
} else {
|
||||
format!("{trimmed}/v1/models")
|
||||
};
|
||||
candidates.push(primary);
|
||||
|
||||
if let Some(stripped) = strip_compat_suffix(trimmed) {
|
||||
let root = stripped.trim_end_matches('/');
|
||||
if !root.is_empty() && root.contains("://") {
|
||||
candidates.push(format!("{root}/v1/models"));
|
||||
candidates.push(format!("{root}/models"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(format!("{trimmed}/v1/models"))
|
||||
// 候选最多 3 条,线性去重即可,不值得上 HashSet。
|
||||
let mut unique: Vec<String> = Vec::with_capacity(candidates.len());
|
||||
for url in candidates {
|
||||
if !unique.iter().any(|u| u == &url) {
|
||||
unique.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(unique)
|
||||
}
|
||||
|
||||
/// 截断响应体到 [`ERROR_BODY_MAX_CHARS`] 字符,避免 HTML 404 页占用错误串。
|
||||
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('…');
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// 若 baseURL 以任一已知兼容子路径结尾,返回剥离后的剩余部分;否则 `None`。
|
||||
///
|
||||
/// 依赖 [`KNOWN_COMPAT_SUFFIXES`] 按长度降序排列,确保最长前缀优先命中
|
||||
/// (否则 `/anthropic` 会提前匹配掉 `/api/anthropic` 的场景)。
|
||||
fn strip_compat_suffix(base_url: &str) -> Option<&str> {
|
||||
for suffix in KNOWN_COMPAT_SUFFIXES {
|
||||
if base_url.ends_with(*suffix) {
|
||||
return Some(&base_url[..base_url.len() - suffix.len()]);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -114,40 +215,174 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_basic() {
|
||||
fn test_candidates_plain_root() {
|
||||
let c = build_models_url_candidates("https://api.siliconflow.cn", false, None).unwrap();
|
||||
assert_eq!(c, vec!["https://api.siliconflow.cn/v1/models"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_candidates_trailing_slash() {
|
||||
let c = build_models_url_candidates("https://api.example.com/", false, None).unwrap();
|
||||
assert_eq!(c, vec!["https://api.example.com/v1/models"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_candidates_with_v1() {
|
||||
let c = build_models_url_candidates("https://api.example.com/v1", false, None).unwrap();
|
||||
assert_eq!(c, vec!["https://api.example.com/v1/models"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_candidates_full_url() {
|
||||
let c = build_models_url_candidates(
|
||||
"https://proxy.example.com/v1/chat/completions",
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(c, vec!["https://proxy.example.com/v1/models"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_candidates_empty() {
|
||||
assert!(build_models_url_candidates("", false, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_candidates_override_returns_single() {
|
||||
let c = build_models_url_candidates(
|
||||
"https://api.deepseek.com/anthropic",
|
||||
false,
|
||||
Some("https://api.deepseek.com/models"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(c, vec!["https://api.deepseek.com/models"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_candidates_override_empty_falls_through() {
|
||||
let c =
|
||||
build_models_url_candidates("https://api.siliconflow.cn", false, Some(" ")).unwrap();
|
||||
assert_eq!(c, vec!["https://api.siliconflow.cn/v1/models"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_candidates_deepseek_strip_anthropic() {
|
||||
let c =
|
||||
build_models_url_candidates("https://api.deepseek.com/anthropic", false, None).unwrap();
|
||||
assert_eq!(
|
||||
build_models_url("https://api.siliconflow.cn", false).unwrap(),
|
||||
"https://api.siliconflow.cn/v1/models"
|
||||
c,
|
||||
vec![
|
||||
"https://api.deepseek.com/anthropic/v1/models",
|
||||
"https://api.deepseek.com/v1/models",
|
||||
"https://api.deepseek.com/models",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_trailing_slash() {
|
||||
fn test_candidates_zhipu_strip_api_anthropic() {
|
||||
let c = build_models_url_candidates("https://open.bigmodel.cn/api/anthropic", false, None)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
build_models_url("https://api.example.com/", false).unwrap(),
|
||||
"https://api.example.com/v1/models"
|
||||
c,
|
||||
vec![
|
||||
"https://open.bigmodel.cn/api/anthropic/v1/models",
|
||||
"https://open.bigmodel.cn/v1/models",
|
||||
"https://open.bigmodel.cn/models",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_with_v1() {
|
||||
fn test_candidates_bailian_strip_apps_anthropic() {
|
||||
let c = build_models_url_candidates(
|
||||
"https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
build_models_url("https://api.example.com/v1", false).unwrap(),
|
||||
"https://api.example.com/v1/models"
|
||||
c,
|
||||
vec![
|
||||
"https://dashscope.aliyuncs.com/apps/anthropic/v1/models",
|
||||
"https://dashscope.aliyuncs.com/v1/models",
|
||||
"https://dashscope.aliyuncs.com/models",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_full_url() {
|
||||
fn test_candidates_stepfun_strip_step_plan() {
|
||||
let c =
|
||||
build_models_url_candidates("https://api.stepfun.com/step_plan", false, None).unwrap();
|
||||
assert_eq!(
|
||||
build_models_url("https://proxy.example.com/v1/chat/completions", true).unwrap(),
|
||||
"https://proxy.example.com/v1/models"
|
||||
c,
|
||||
vec![
|
||||
"https://api.stepfun.com/step_plan/v1/models",
|
||||
"https://api.stepfun.com/v1/models",
|
||||
"https://api.stepfun.com/models",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_models_url_empty() {
|
||||
assert!(build_models_url("", false).is_err());
|
||||
fn test_candidates_doubao_strip_api_coding() {
|
||||
let c = build_models_url_candidates(
|
||||
"https://ark.cn-beijing.volces.com/api/coding",
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
c,
|
||||
vec![
|
||||
"https://ark.cn-beijing.volces.com/api/coding/v1/models",
|
||||
"https://ark.cn-beijing.volces.com/v1/models",
|
||||
"https://ark.cn-beijing.volces.com/models",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_candidates_rightcode_strip_claude() {
|
||||
let c = build_models_url_candidates("https://www.right.codes/claude", false, None).unwrap();
|
||||
assert_eq!(
|
||||
c,
|
||||
vec![
|
||||
"https://www.right.codes/claude/v1/models",
|
||||
"https://www.right.codes/v1/models",
|
||||
"https://www.right.codes/models",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_candidates_longer_suffix_wins() {
|
||||
// baseURL 以 /api/anthropic 结尾时,应剥离整个 /api/anthropic,
|
||||
// 而不是只剥离 /anthropic(那样会得到残缺的 https://.../api 根)。
|
||||
let c = build_models_url_candidates("https://api.z.ai/api/anthropic", false, None).unwrap();
|
||||
assert_eq!(
|
||||
c,
|
||||
vec![
|
||||
"https://api.z.ai/api/anthropic/v1/models",
|
||||
"https://api.z.ai/v1/models",
|
||||
"https://api.z.ai/models",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_candidates_no_suffix_no_strip() {
|
||||
let c = build_models_url_candidates("https://openrouter.ai/api", false, None).unwrap();
|
||||
assert_eq!(c, vec!["https://openrouter.ai/api/v1/models"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_candidates_deduplicate() {
|
||||
// 虚构 case:baseURL 就是 "scheme://host",剥不出子路径,应只有一个候选。
|
||||
let c = build_models_url_candidates("https://host.example.com", false, None).unwrap();
|
||||
assert_eq!(c.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,7 +8,9 @@ use serde_json::{json, Value};
|
||||
use toml_edit::{DocumentMut, Item, TableLike};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
|
||||
use crate::codex_config::{
|
||||
get_codex_auth_path, get_codex_config_path, write_codex_live_atomic_with_stable_provider,
|
||||
};
|
||||
use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file};
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
@@ -347,7 +349,7 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet:
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => false,
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,7 +419,9 @@ pub(crate) fn remove_common_config_from_settings(
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Ok(settings.clone()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
|
||||
Ok(settings.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,7 +476,9 @@ fn apply_common_config_to_settings(
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Ok(settings.clone()),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {
|
||||
Ok(settings.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,6 +517,16 @@ pub(crate) fn write_live_with_common_config(
|
||||
effective_provider.settings_config =
|
||||
build_effective_settings_with_common_config(db, app_type, provider)?;
|
||||
|
||||
if matches!(app_type, AppType::ClaudeDesktop) {
|
||||
crate::claude_desktop_config::apply_provider(db, &effective_provider)?;
|
||||
log::info!(
|
||||
"Claude Desktop 3P profile '{}' written for provider '{}'",
|
||||
crate::claude_desktop_config::PROFILE_ID,
|
||||
effective_provider.id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
write_live_snapshot(app_type, &effective_provider)
|
||||
}
|
||||
|
||||
@@ -528,29 +544,55 @@ pub(crate) fn strip_common_config_from_live_settings(
|
||||
app_type.as_str(),
|
||||
provider.id
|
||||
);
|
||||
return live_settings;
|
||||
return restore_live_settings_for_provider_backfill(app_type, provider, live_settings);
|
||||
}
|
||||
};
|
||||
|
||||
if !provider_uses_common_config(app_type, provider, snippet.as_deref()) {
|
||||
return live_settings;
|
||||
}
|
||||
|
||||
let Some(snippet_text) = snippet.as_deref() else {
|
||||
return live_settings;
|
||||
let backfill_settings = if provider_uses_common_config(app_type, provider, snippet.as_deref()) {
|
||||
match snippet.as_deref() {
|
||||
Some(snippet_text) => {
|
||||
match remove_common_config_from_settings(app_type, &live_settings, snippet_text) {
|
||||
Ok(settings) => settings,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to strip common config for {} provider '{}': {err}",
|
||||
app_type.as_str(),
|
||||
provider.id
|
||||
);
|
||||
live_settings
|
||||
}
|
||||
}
|
||||
}
|
||||
None => live_settings,
|
||||
}
|
||||
} else {
|
||||
live_settings
|
||||
};
|
||||
|
||||
match remove_common_config_from_settings(app_type, &live_settings, snippet_text) {
|
||||
Ok(settings) => settings,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Failed to strip common config for {} provider '{}': {err}",
|
||||
app_type.as_str(),
|
||||
provider.id
|
||||
);
|
||||
live_settings
|
||||
}
|
||||
restore_live_settings_for_provider_backfill(app_type, provider, backfill_settings)
|
||||
}
|
||||
|
||||
fn restore_live_settings_for_provider_backfill(
|
||||
app_type: &AppType,
|
||||
provider: &Provider,
|
||||
live_settings: Value,
|
||||
) -> Value {
|
||||
if !matches!(app_type, AppType::Codex) {
|
||||
return live_settings;
|
||||
}
|
||||
|
||||
let mut settings = live_settings;
|
||||
if let Err(err) = crate::codex_config::restore_codex_settings_config_model_provider_for_backfill(
|
||||
&mut settings,
|
||||
&provider.settings_config,
|
||||
) {
|
||||
log::warn!(
|
||||
"Failed to restore Codex provider id while backfilling '{}': {err}",
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
|
||||
settings
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_provider_common_config_for_storage(
|
||||
@@ -671,6 +713,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
|
||||
write_json_file(&path, &settings)?;
|
||||
}
|
||||
AppType::ClaudeDesktop => {
|
||||
return Err(AppError::localized(
|
||||
"claude_desktop.live.requires_db_context",
|
||||
"Claude Desktop 配置写入需要通过供应商切换流程执行",
|
||||
"Claude Desktop configuration must be written through the provider switch flow",
|
||||
));
|
||||
}
|
||||
AppType::Codex => {
|
||||
let obj = provider
|
||||
.settings_config
|
||||
@@ -683,10 +732,7 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
||||
AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string())
|
||||
})?;
|
||||
|
||||
let auth_path = get_codex_auth_path();
|
||||
write_json_file(&auth_path, auth)?;
|
||||
let config_path = get_codex_config_path();
|
||||
std::fs::write(&config_path, config_str).map_err(|e| AppError::io(&config_path, e))?;
|
||||
write_codex_live_atomic_with_stable_provider(auth, Some(config_str))?;
|
||||
}
|
||||
AppType::Gemini => {
|
||||
// Delegate to write_gemini_live which handles env file writing correctly
|
||||
@@ -928,6 +974,11 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
||||
}
|
||||
read_json_file(&path)
|
||||
}
|
||||
AppType::ClaudeDesktop => Err(AppError::localized(
|
||||
"claude_desktop.live.read_unsupported",
|
||||
"Claude Desktop 3P 配置不支持作为通用 live 配置导入,请使用“从 Claude 导入兼容供应商”。",
|
||||
"Claude Desktop 3P configuration cannot be imported as a generic live config. Use 'Import compatible providers from Claude' instead.",
|
||||
)),
|
||||
AppType::Gemini => {
|
||||
use crate::gemini_config::{
|
||||
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
|
||||
@@ -1053,6 +1104,13 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
||||
let _ = normalize_claude_models_in_value(&mut v);
|
||||
v
|
||||
}
|
||||
AppType::ClaudeDesktop => {
|
||||
return Err(AppError::localized(
|
||||
"claude_desktop.import_unsupported",
|
||||
"Claude Desktop 3P 配置不能通过通用导入读取,请使用“从 Claude 导入兼容供应商”。",
|
||||
"Claude Desktop 3P config cannot be imported through the generic import flow. Use 'Import compatible providers from Claude' instead.",
|
||||
));
|
||||
}
|
||||
AppType::Gemini => {
|
||||
use crate::gemini_config::{
|
||||
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
|
||||
@@ -1104,10 +1162,27 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
||||
state
|
||||
.db
|
||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||
crate::settings::set_current_provider(&app_type, Some(provider.id.as_str()))?;
|
||||
|
||||
Ok(true) // 真正导入了
|
||||
}
|
||||
|
||||
/// Decide whether startup should auto-import the current live config as `default`.
|
||||
///
|
||||
/// This is intentionally stricter than the manual import path:
|
||||
/// if the app already has any provider row at all (including official seeds),
|
||||
/// startup must skip auto-import to avoid recreating `default` on each launch.
|
||||
pub fn should_import_default_config_on_startup(
|
||||
state: &AppState,
|
||||
app_type: &AppType,
|
||||
) -> Result<bool, AppError> {
|
||||
if app_type.is_additive_mode() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(!state.db.has_any_provider_for_app(app_type.as_str())?)
|
||||
}
|
||||
|
||||
/// Write Gemini live configuration with authentication handling
|
||||
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
||||
use crate::gemini_config::{
|
||||
|
||||
@@ -22,7 +22,8 @@ use crate::store::AppState;
|
||||
// Re-export sub-module functions for external access
|
||||
pub use live::{
|
||||
import_default_config, import_hermes_providers_from_live, import_openclaw_providers_from_live,
|
||||
import_opencode_providers_from_live, read_live_settings, sync_current_to_live,
|
||||
import_opencode_providers_from_live, read_live_settings,
|
||||
should_import_default_config_on_startup, sync_current_to_live,
|
||||
};
|
||||
|
||||
// Internal re-exports (pub(crate))
|
||||
@@ -1396,6 +1397,10 @@ impl ProviderService {
|
||||
return Self::switch_normal(state, app_type, id, &providers);
|
||||
}
|
||||
|
||||
if matches!(app_type, AppType::ClaudeDesktop) {
|
||||
return Self::switch_normal(state, app_type, id, &providers);
|
||||
}
|
||||
|
||||
// Check if proxy takeover mode is active AND proxy server is actually running
|
||||
// Both conditions must be true to use hot-switch mode
|
||||
// Use blocking wait since this is a sync function
|
||||
@@ -1729,6 +1734,7 @@ impl ProviderService {
|
||||
|
||||
match app_type {
|
||||
AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),
|
||||
AppType::ClaudeDesktop => Ok(String::new()),
|
||||
AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
|
||||
@@ -1744,6 +1750,7 @@ impl ProviderService {
|
||||
) -> Result<String, AppError> {
|
||||
match app_type {
|
||||
AppType::Claude => Self::extract_claude_common_config(settings_config),
|
||||
AppType::ClaudeDesktop => Ok(String::new()),
|
||||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
||||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
||||
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
|
||||
@@ -1933,6 +1940,13 @@ impl ProviderService {
|
||||
import_default_config(state, app_type)
|
||||
}
|
||||
|
||||
pub fn should_import_default_config_on_startup(
|
||||
state: &AppState,
|
||||
app_type: &AppType,
|
||||
) -> Result<bool, AppError> {
|
||||
should_import_default_config_on_startup(state, app_type)
|
||||
}
|
||||
|
||||
/// Read current live settings (re-export)
|
||||
pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
|
||||
read_live_settings(app_type)
|
||||
@@ -2048,6 +2062,9 @@ impl ProviderService {
|
||||
));
|
||||
}
|
||||
}
|
||||
AppType::ClaudeDesktop => {
|
||||
crate::claude_desktop_config::validate_provider(provider)?;
|
||||
}
|
||||
AppType::Codex => {
|
||||
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
||||
AppError::localized(
|
||||
@@ -2182,6 +2199,11 @@ impl ProviderService {
|
||||
|
||||
Ok((api_key, base_url))
|
||||
}
|
||||
AppType::ClaudeDesktop => {
|
||||
let credentials =
|
||||
crate::claude_desktop_config::direct_gateway_credentials(provider)?;
|
||||
Ok((credentials.api_key, credentials.base_url))
|
||||
}
|
||||
AppType::Codex => {
|
||||
let auth = provider
|
||||
.settings_config
|
||||
|
||||
@@ -466,10 +466,7 @@ impl ProxyService {
|
||||
AppType::Claude => self.read_claude_live()?,
|
||||
AppType::Codex => self.read_codex_live()?,
|
||||
AppType::Gemini => self.read_gemini_live()?,
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy features
|
||||
return Err("该应用不支持代理功能".to_string());
|
||||
}
|
||||
_ => return Err("该应用不支持代理功能".to_string()),
|
||||
};
|
||||
|
||||
self.sync_live_config_to_provider(app_type, &live_config)
|
||||
@@ -683,9 +680,7 @@ impl ProxyService {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy features, skip silently
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -864,10 +859,7 @@ impl ProxyService {
|
||||
AppType::Claude => ("claude", self.read_claude_live()?),
|
||||
AppType::Codex => ("codex", self.read_codex_live()?),
|
||||
AppType::Gemini => ("gemini", self.read_gemini_live()?),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy features
|
||||
return Err("该应用不支持代理功能".to_string());
|
||||
}
|
||||
_ => return Err("该应用不支持代理功能".to_string()),
|
||||
};
|
||||
|
||||
let json_str = serde_json::to_string(&config)
|
||||
@@ -1008,10 +1000,7 @@ impl ProxyService {
|
||||
self.write_gemini_live(&live_config)?;
|
||||
log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy features
|
||||
return Err("该应用不支持代理功能".to_string());
|
||||
}
|
||||
_ => return Err("该应用不支持代理功能".to_string()),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1061,9 +1050,7 @@ impl ProxyService {
|
||||
let _ = self.write_gemini_live(&live_config);
|
||||
}
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy features, skip silently
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1101,9 +1088,7 @@ impl ProxyService {
|
||||
log::info!("Gemini Live 配置已恢复");
|
||||
}
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy features, skip silently
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1192,10 +1177,7 @@ impl ProxyService {
|
||||
AppType::Claude => self.write_claude_live(config),
|
||||
AppType::Codex => self.write_codex_live(config),
|
||||
AppType::Gemini => self.write_gemini_live(config),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy features
|
||||
Err("该应用不支持代理功能".to_string())
|
||||
}
|
||||
_ => Err("该应用不支持代理功能".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1213,10 +1195,7 @@ impl ProxyService {
|
||||
Ok(config) => Self::is_gemini_live_taken_over(&config),
|
||||
Err(_) => false,
|
||||
},
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy takeover
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1256,10 +1235,7 @@ impl ProxyService {
|
||||
AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),
|
||||
AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),
|
||||
AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
// These apps don't support proxy features
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1476,20 +1452,33 @@ impl ProxyService {
|
||||
.map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?;
|
||||
|
||||
if matches!(app_type_enum, AppType::Codex) {
|
||||
let existing_backup = self
|
||||
let existing_backup_value = self
|
||||
.db
|
||||
.get_live_backup(app_type)
|
||||
.await
|
||||
.map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))?;
|
||||
.map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))?
|
||||
.map(|backup| {
|
||||
serde_json::from_str::<Value>(&backup.original_config)
|
||||
.map_err(|e| format!("解析 {app_type} 现有备份失败: {e}"))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
if let Some(existing_backup) = existing_backup {
|
||||
let existing_value: Value = serde_json::from_str(&existing_backup.original_config)
|
||||
.map_err(|e| format!("解析 {app_type} 现有备份失败: {e}"))?;
|
||||
if let Some(existing_value) = existing_backup_value.as_ref() {
|
||||
Self::preserve_codex_mcp_servers_in_backup(
|
||||
&mut effective_settings,
|
||||
&existing_value,
|
||||
existing_value,
|
||||
)?;
|
||||
}
|
||||
|
||||
let anchor_config_text = existing_backup_value
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("config"))
|
||||
.and_then(|value| value.as_str());
|
||||
crate::codex_config::normalize_codex_settings_config_model_provider(
|
||||
&mut effective_settings,
|
||||
anchor_config_text,
|
||||
)
|
||||
.map_err(|e| format!("归一化 Codex restore backup 失败: {e}"))?;
|
||||
}
|
||||
|
||||
let backup_json = match app_type_enum {
|
||||
@@ -1507,9 +1496,7 @@ impl ProxyService {
|
||||
serde_json::to_string(&env_backup)
|
||||
.map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?
|
||||
}
|
||||
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
|
||||
return Err(format!("未知的应用类型: {app_type}"));
|
||||
}
|
||||
_ => return Err(format!("未知的应用类型: {app_type}")),
|
||||
};
|
||||
|
||||
self.db
|
||||
@@ -1749,6 +1736,8 @@ impl ProxyService {
|
||||
let auth = config.get("auth");
|
||||
let config_str = config.get("config").and_then(|v| v.as_str());
|
||||
|
||||
// Proxy restore writes saved live backups verbatim. Provider-driven writes go
|
||||
// through write_live_with_common_config(), which normalizes Codex provider ids.
|
||||
match (auth, config_str) {
|
||||
(Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))
|
||||
.map_err(|e| format!("写入 Codex 配置失败: {e}"))?,
|
||||
@@ -2693,6 +2682,147 @@ base_url = "https://new.example/v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
let service = ProxyService::new(db.clone());
|
||||
|
||||
let provider_a = Provider::with_id(
|
||||
"a".to_string(),
|
||||
"RightCode".to_string(),
|
||||
json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "rightcode-key"
|
||||
},
|
||||
"config": r#"model_provider = "rightcode"
|
||||
model = "gpt-5.4"
|
||||
|
||||
[model_providers.rightcode]
|
||||
name = "RightCode"
|
||||
base_url = "https://rightcode.example/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true
|
||||
"#
|
||||
}),
|
||||
None,
|
||||
);
|
||||
let provider_b = Provider::with_id(
|
||||
"b".to_string(),
|
||||
"AiHubMix".to_string(),
|
||||
json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "aihubmix-key"
|
||||
},
|
||||
"config": r#"model_provider = "aihubmix"
|
||||
model = "gpt-5.4"
|
||||
|
||||
[model_providers.aihubmix]
|
||||
name = "AiHubMix"
|
||||
base_url = "https://aihubmix.example/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true
|
||||
"#
|
||||
}),
|
||||
None,
|
||||
);
|
||||
|
||||
db.save_provider("codex", &provider_a)
|
||||
.expect("save provider a");
|
||||
db.save_provider("codex", &provider_b)
|
||||
.expect("save provider b");
|
||||
db.set_current_provider("codex", "a")
|
||||
.expect("set current provider");
|
||||
crate::settings::set_current_provider(&AppType::Codex, Some("a"))
|
||||
.expect("set local current provider");
|
||||
db.save_live_backup(
|
||||
"codex",
|
||||
&serde_json::to_string(&provider_a.settings_config).expect("serialize provider a"),
|
||||
)
|
||||
.await
|
||||
.expect("seed live backup");
|
||||
service
|
||||
.write_codex_live(&json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": PROXY_TOKEN_PLACEHOLDER
|
||||
},
|
||||
"config": r#"model_provider = "rightcode"
|
||||
model = "gpt-5.4"
|
||||
|
||||
[model_providers.rightcode]
|
||||
name = "RightCode"
|
||||
base_url = "http://127.0.0.1:15721/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true
|
||||
"#
|
||||
}))
|
||||
.expect("seed taken-over Codex live config");
|
||||
|
||||
service
|
||||
.hot_switch_provider("codex", "b")
|
||||
.await
|
||||
.expect("hot switch Codex provider");
|
||||
|
||||
let backup = db
|
||||
.get_live_backup("codex")
|
||||
.await
|
||||
.expect("get live backup")
|
||||
.expect("backup exists");
|
||||
let stored: Value =
|
||||
serde_json::from_str(&backup.original_config).expect("parse backup json");
|
||||
let backup_config = stored
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("backup config string");
|
||||
let parsed_backup: toml::Value =
|
||||
toml::from_str(backup_config).expect("parse backup config");
|
||||
assert_eq!(
|
||||
parsed_backup.get("model_provider").and_then(|v| v.as_str()),
|
||||
Some("rightcode"),
|
||||
"provider-derived restore backup should retain stable Codex model_provider"
|
||||
);
|
||||
let backup_model_providers = parsed_backup
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.as_table())
|
||||
.expect("backup model_providers");
|
||||
assert!(backup_model_providers.get("aihubmix").is_none());
|
||||
assert_eq!(
|
||||
backup_model_providers
|
||||
.get("rightcode")
|
||||
.and_then(|v| v.get("base_url"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("https://aihubmix.example/v1"),
|
||||
"stable provider id should point at the hot-switched provider endpoint"
|
||||
);
|
||||
|
||||
service
|
||||
.restore_live_config_for_app_with_fallback(&AppType::Codex)
|
||||
.await
|
||||
.expect("restore Codex live config");
|
||||
|
||||
let live = service.read_codex_live().expect("read Codex live config");
|
||||
let live_config = live
|
||||
.get("config")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("live config string");
|
||||
let parsed_live: toml::Value = toml::from_str(live_config).expect("parse live config");
|
||||
assert_eq!(
|
||||
parsed_live.get("model_provider").and_then(|v| v.as_str()),
|
||||
Some("rightcode"),
|
||||
"restored Codex live config should not switch history buckets"
|
||||
);
|
||||
assert_eq!(
|
||||
live.get("auth")
|
||||
.and_then(|auth| auth.get("OPENAI_API_KEY"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("aihubmix-key"),
|
||||
"restore should still use the hot-switched provider auth"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {
|
||||
|
||||
@@ -13,6 +13,9 @@ 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::usage_stats::{
|
||||
effective_usage_log_filter, should_skip_session_insert, DedupKey,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -305,8 +308,10 @@ fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppEr
|
||||
Ok((imported, skipped))
|
||||
}
|
||||
|
||||
/// 获取文件的同步状态
|
||||
fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> {
|
||||
/// 获取 session_log_sync 表中某条目的同步进度。
|
||||
///
|
||||
/// Shared by all session_usage_* parsers.
|
||||
pub(crate) fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> {
|
||||
let conn = lock_conn!(db.conn);
|
||||
let result = conn.query_row(
|
||||
"SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1",
|
||||
@@ -316,8 +321,10 @@ fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError
|
||||
Ok(result.unwrap_or((0, 0)))
|
||||
}
|
||||
|
||||
/// 更新文件的同步状态
|
||||
fn update_sync_state(
|
||||
/// 更新 session_log_sync 表中某条目的同步进度。
|
||||
///
|
||||
/// Shared by all session_usage_* parsers.
|
||||
pub(crate) fn update_sync_state(
|
||||
db: &Database,
|
||||
file_path: &str,
|
||||
last_modified: i64,
|
||||
@@ -346,25 +353,10 @@ fn insert_session_log_entry(
|
||||
) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(db.conn);
|
||||
|
||||
// 检查是否已存在
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = ?1",
|
||||
rusqlite::params![request_id],
|
||||
|row| row.get::<_, i64>(0).map(|c| c > 0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 解析时间戳
|
||||
let created_at = msg
|
||||
.timestamp
|
||||
.as_ref()
|
||||
.and_then(|ts| {
|
||||
// 尝试解析 ISO 8601 时间戳
|
||||
chrono::DateTime::parse_from_rfc3339(ts)
|
||||
.ok()
|
||||
.map(|dt| dt.timestamp())
|
||||
@@ -376,6 +368,19 @@ fn insert_session_log_entry(
|
||||
.unwrap_or(0)
|
||||
});
|
||||
|
||||
let dedup_key = DedupKey {
|
||||
app_type: "claude",
|
||||
model: &msg.model,
|
||||
input_tokens: msg.input_tokens,
|
||||
output_tokens: msg.output_tokens,
|
||||
cache_read_tokens: msg.cache_read_tokens,
|
||||
cache_creation_tokens: msg.cache_creation_tokens,
|
||||
created_at,
|
||||
};
|
||||
if should_skip_session_insert(&conn, request_id, &dedup_key)? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 计算费用
|
||||
let usage = TokenUsage {
|
||||
input_tokens: msg.input_tokens,
|
||||
@@ -531,13 +536,17 @@ fn try_find_pricing(
|
||||
pub fn get_data_source_breakdown(db: &Database) -> Result<Vec<DataSourceSummary>, AppError> {
|
||||
let conn = lock_conn!(db.conn);
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT COALESCE(data_source, 'proxy') as ds, COUNT(*) as cnt,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as cost
|
||||
FROM proxy_request_logs
|
||||
let effective_filter = effective_usage_log_filter("l");
|
||||
let sql = format!(
|
||||
"SELECT COALESCE(l.data_source, 'proxy') as ds, COUNT(*) as cnt,
|
||||
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as cost
|
||||
FROM proxy_request_logs l
|
||||
WHERE {effective_filter}
|
||||
GROUP BY ds
|
||||
ORDER BY cnt DESC",
|
||||
)?;
|
||||
ORDER BY cnt DESC"
|
||||
);
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(DataSourceSummary {
|
||||
@@ -636,4 +645,58 @@ mod tests {
|
||||
messages.insert("msg_1".to_string(), final_entry);
|
||||
assert_eq!(messages.get("msg_1").unwrap().output_tokens, 1349);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_claude_session_skips_matching_proxy_log() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
total_cost_usd, latency_ms, status_code, created_at, data_source
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
rusqlite::params![
|
||||
"proxy-different-id",
|
||||
"openai-compatible",
|
||||
"claude",
|
||||
"claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5",
|
||||
100,
|
||||
20,
|
||||
10,
|
||||
5,
|
||||
"0.10",
|
||||
100,
|
||||
200,
|
||||
1000,
|
||||
"proxy"
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
let msg = ParsedAssistantUsage {
|
||||
message_id: "msg_1".to_string(),
|
||||
model: "claude-sonnet-4-5".to_string(),
|
||||
input_tokens: 100,
|
||||
output_tokens: 20,
|
||||
cache_read_tokens: 10,
|
||||
cache_creation_tokens: 5,
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
timestamp: Some("1970-01-01T00:16:45Z".to_string()),
|
||||
session_id: Some("session-1".to_string()),
|
||||
};
|
||||
|
||||
let inserted = insert_session_log_entry(&db, "session:msg_1", &msg)?;
|
||||
assert!(!inserted);
|
||||
|
||||
let conn = lock_conn!(db.conn);
|
||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
assert_eq!(count, 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ 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::SessionSyncResult;
|
||||
use crate::services::session_usage::{get_sync_state, update_sync_state, SessionSyncResult};
|
||||
use crate::services::usage_stats::{should_skip_session_insert, DedupKey};
|
||||
use rust_decimal::Decimal;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
@@ -171,7 +172,7 @@ pub fn sync_codex_usage(db: &Database) -> Result<SessionSyncResult, AppError> {
|
||||
|
||||
if result.imported > 0 {
|
||||
log::info!(
|
||||
"[CODEX-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, ��描 {} 个文件",
|
||||
"[CODEX-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, 扫描 {} 个文件",
|
||||
result.imported,
|
||||
result.skipped,
|
||||
result.files_scanned
|
||||
@@ -185,7 +186,7 @@ pub fn sync_codex_usage(db: &Database) -> Result<SessionSyncResult, AppError> {
|
||||
fn collect_codex_session_files(codex_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
// 1. 扫描 sessions/YYYY/MM/DD/*.jsonl(日期分区目录��
|
||||
// 1. 扫描 sessions/YYYY/MM/DD/*.jsonl(日期分区目录)
|
||||
let sessions_dir = codex_dir.join("sessions");
|
||||
if sessions_dir.is_dir() {
|
||||
collect_jsonl_recursive(&sessions_dir, &mut files, 0, 3);
|
||||
@@ -224,13 +225,13 @@ fn collect_jsonl_recursive(dir: &Path, files: &mut Vec<PathBuf>, depth: u32, max
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步单�� Codex JSONL 文件,返回 (imported, skipped)
|
||||
/// 同步单个 Codex JSONL 文件,返回 (imported, skipped)
|
||||
fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppError> {
|
||||
let file_path_str = file_path.to_string_lossy().to_string();
|
||||
|
||||
// 获取文件元数据
|
||||
let metadata = fs::metadata(file_path)
|
||||
.map_err(|e| AppError::Config(format!("无法读取文���元数据: {e}")))?;
|
||||
.map_err(|e| AppError::Config(format!("无法读取文件元数据: {e}")))?;
|
||||
let file_modified = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
@@ -333,7 +334,7 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32),
|
||||
|
||||
let info = match payload.get("info") {
|
||||
Some(i) if !i.is_null() => i,
|
||||
_ => continue, // info 为 null 的首个事件跳��
|
||||
_ => continue, // 跳过 info 为 null 的首个事件
|
||||
};
|
||||
|
||||
// 提取模型(token_count 事件也可能携带 model)
|
||||
@@ -438,20 +439,6 @@ fn insert_codex_session_entry(
|
||||
) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(db.conn);
|
||||
|
||||
// 检查是否已存在
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM proxy_request_logs WHERE request_id = ?1",
|
||||
rusqlite::params![request_id],
|
||||
|row| row.get::<_, i64>(0).map(|c| c > 0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 解析时间戳
|
||||
let created_at = timestamp
|
||||
.and_then(|ts| {
|
||||
chrono::DateTime::parse_from_rfc3339(ts)
|
||||
@@ -465,6 +452,19 @@ fn insert_codex_session_entry(
|
||||
.unwrap_or(0)
|
||||
});
|
||||
|
||||
let dedup_key = DedupKey {
|
||||
app_type: "codex",
|
||||
model,
|
||||
input_tokens: delta.input,
|
||||
output_tokens: delta.output,
|
||||
cache_read_tokens: delta.cached_input,
|
||||
cache_creation_tokens: 0,
|
||||
created_at,
|
||||
};
|
||||
if should_skip_session_insert(&conn, request_id, &dedup_key)? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 计算费用
|
||||
let usage = TokenUsage {
|
||||
input_tokens: delta.input,
|
||||
@@ -538,40 +538,7 @@ fn insert_codex_session_entry(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取文件的同步状态
|
||||
fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> {
|
||||
let conn = lock_conn!(db.conn);
|
||||
let result = conn.query_row(
|
||||
"SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1",
|
||||
rusqlite::params![file_path],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
||||
);
|
||||
Ok(result.unwrap_or((0, 0)))
|
||||
}
|
||||
|
||||
/// 更新文件的同步状态
|
||||
fn update_sync_state(
|
||||
db: &Database,
|
||||
file_path: &str,
|
||||
last_modified: i64,
|
||||
last_offset: i64,
|
||||
) -> Result<(), AppError> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let conn = lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params![file_path, last_modified, last_offset, now],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("更新同步状态失败: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// ��找 Codex 模型定价(带归一化)
|
||||
/// 查找 Codex 模型定价(带归一化)
|
||||
fn find_codex_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
|
||||
let normalized = normalize_codex_model(model_id);
|
||||
|
||||
@@ -733,6 +700,60 @@ mod tests {
|
||||
assert!(files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_codex_session_skips_matching_proxy_log() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
total_cost_usd, latency_ms, status_code, created_at, data_source
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
rusqlite::params![
|
||||
"codex-proxy",
|
||||
"openai",
|
||||
"codex",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4",
|
||||
10,
|
||||
2,
|
||||
1,
|
||||
7,
|
||||
"0.01",
|
||||
100,
|
||||
200,
|
||||
1000,
|
||||
"proxy"
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
let delta = DeltaTokens {
|
||||
input: 10,
|
||||
cached_input: 1,
|
||||
output: 2,
|
||||
};
|
||||
let inserted = insert_codex_session_entry(
|
||||
&db,
|
||||
"codex-session-dup",
|
||||
&delta,
|
||||
"gpt-5.4",
|
||||
Some("session-1"),
|
||||
Some("1970-01-01T00:16:45Z"),
|
||||
)?;
|
||||
assert!(!inserted);
|
||||
|
||||
let conn = lock_conn!(db.conn);
|
||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
assert_eq!(count, 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── 模型名归一化测试 ──
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -18,7 +18,8 @@ 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::SessionSyncResult;
|
||||
use crate::services::session_usage::{get_sync_state, update_sync_state, SessionSyncResult};
|
||||
use crate::services::usage_stats::{should_skip_session_insert, DedupKey};
|
||||
use rust_decimal::Decimal;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -237,7 +238,6 @@ fn insert_gemini_session_entry(
|
||||
) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(db.conn);
|
||||
|
||||
// 解析时间戳
|
||||
let created_at = timestamp
|
||||
.and_then(|ts| {
|
||||
chrono::DateTime::parse_from_rfc3339(ts)
|
||||
@@ -254,6 +254,19 @@ fn insert_gemini_session_entry(
|
||||
// 合并 thoughts 到 output(思考 token 按输出计费)
|
||||
let output_tokens = tokens.output + tokens.thoughts;
|
||||
|
||||
let dedup_key = DedupKey {
|
||||
app_type: "gemini",
|
||||
model,
|
||||
input_tokens: tokens.input,
|
||||
output_tokens,
|
||||
cache_read_tokens: tokens.cached,
|
||||
cache_creation_tokens: 0,
|
||||
created_at,
|
||||
};
|
||||
if should_skip_session_insert(&conn, request_id, &dedup_key)? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 计算费用
|
||||
let usage = TokenUsage {
|
||||
input_tokens: tokens.input,
|
||||
@@ -343,39 +356,6 @@ fn insert_gemini_session_entry(
|
||||
Ok(conn.changes() > 0)
|
||||
}
|
||||
|
||||
/// 获取文件的同步状态
|
||||
fn get_sync_state(db: &Database, file_path: &str) -> Result<(i64, i64), AppError> {
|
||||
let conn = lock_conn!(db.conn);
|
||||
let result = conn.query_row(
|
||||
"SELECT last_modified, last_line_offset FROM session_log_sync WHERE file_path = ?1",
|
||||
rusqlite::params![file_path],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
|
||||
);
|
||||
Ok(result.unwrap_or((0, 0)))
|
||||
}
|
||||
|
||||
/// 更新文件的同步状态
|
||||
fn update_sync_state(
|
||||
db: &Database,
|
||||
file_path: &str,
|
||||
last_modified: i64,
|
||||
last_offset: i64,
|
||||
) -> Result<(), AppError> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let conn = lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO session_log_sync (file_path, last_modified, last_line_offset, last_synced_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
rusqlite::params![file_path, last_modified, last_offset, now],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("更新同步状态失败: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 查找 Gemini 模型定价
|
||||
fn find_gemini_pricing(conn: &rusqlite::Connection, model_id: &str) -> Option<ModelPricing> {
|
||||
// 精确匹配
|
||||
@@ -433,6 +413,61 @@ mod tests {
|
||||
assert!(files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_gemini_session_skips_matching_proxy_log() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
total_cost_usd, latency_ms, status_code, created_at, data_source
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
rusqlite::params![
|
||||
"gemini-proxy",
|
||||
"google",
|
||||
"gemini",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-pro",
|
||||
10,
|
||||
7,
|
||||
1,
|
||||
0,
|
||||
"0.01",
|
||||
100,
|
||||
200,
|
||||
1000,
|
||||
"proxy"
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
let tokens = GeminiTokens {
|
||||
input: 10,
|
||||
output: 2,
|
||||
cached: 1,
|
||||
thoughts: 5,
|
||||
};
|
||||
let inserted = insert_gemini_session_entry(
|
||||
&db,
|
||||
"gemini-session-dup",
|
||||
&tokens,
|
||||
"gemini-2.5-pro",
|
||||
Some("session-1"),
|
||||
Some("1970-01-01T00:16:45Z"),
|
||||
)?;
|
||||
assert!(!inserted);
|
||||
|
||||
let conn = lock_conn!(db.conn);
|
||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
assert_eq!(count, 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_gemini_tokens() {
|
||||
let json: serde_json::Value = serde_json::json!({
|
||||
|
||||
@@ -509,6 +509,7 @@ impl SkillService {
|
||||
return Ok(custom.join("skills"));
|
||||
}
|
||||
}
|
||||
AppType::ClaudeDesktop => {}
|
||||
AppType::Codex => {
|
||||
if let Some(custom) = crate::settings::get_codex_override_dir() {
|
||||
return Ok(custom.join("skills"));
|
||||
@@ -545,6 +546,7 @@ impl SkillService {
|
||||
|
||||
Ok(match app {
|
||||
AppType::Claude => home.join(".claude").join("skills"),
|
||||
AppType::ClaudeDesktop => home.join(".claude-desktop").join("skills"),
|
||||
AppType::Codex => home.join(".codex").join("skills"),
|
||||
AppType::Gemini => home.join(".gemini").join("skills"),
|
||||
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
|
||||
@@ -672,36 +674,16 @@ impl SkillService {
|
||||
repo_branch = used_branch;
|
||||
|
||||
// 复制到 SSOT
|
||||
let mut source = temp_dir.join(&source_rel);
|
||||
if !source.exists() {
|
||||
// 回退:在 temp_dir 中递归查找名称匹配的目录(含 SKILL.md)
|
||||
let target_name = source_rel
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
if let Some(found) = Self::find_skill_dir_by_name(&temp_dir, &target_name) {
|
||||
log::info!(
|
||||
"Skill directory '{}' not found at direct path, using fallback: {}",
|
||||
target_name,
|
||||
found.display()
|
||||
);
|
||||
source = found;
|
||||
} else if temp_dir.join("SKILL.md").exists() {
|
||||
// 根级 Skill:仓库本身就是 skill,SKILL.md 直接在解压根目录
|
||||
log::info!(
|
||||
"Skill directory '{}' not found, but SKILL.md exists at root, using temp_dir",
|
||||
target_name,
|
||||
);
|
||||
source = temp_dir.clone();
|
||||
} else {
|
||||
let source =
|
||||
Self::resolve_skill_source_dir(&temp_dir, &skill.directory).ok_or_else(|| {
|
||||
let missing = temp_dir.join(&source_rel).display().to_string();
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
return Err(anyhow!(format_skill_error(
|
||||
anyhow!(format_skill_error(
|
||||
"SKILL_DIR_NOT_FOUND",
|
||||
&[("path", &source.display().to_string())],
|
||||
&[("path", &missing)],
|
||||
Some("checkRepoUrl"),
|
||||
)));
|
||||
}
|
||||
}
|
||||
))
|
||||
})?;
|
||||
|
||||
let canonical_temp = temp_dir.canonicalize().unwrap_or_else(|_| temp_dir.clone());
|
||||
let canonical_source = source.canonicalize().map_err(|_| {
|
||||
@@ -954,14 +936,13 @@ impl SkillService {
|
||||
});
|
||||
|
||||
let remote_skill_dir = match remote_match {
|
||||
Some(rs) => temp_dir.join(&rs.directory),
|
||||
Some(rs) => match Self::resolve_skill_source_dir(&temp_dir, &rs.directory) {
|
||||
Some(path) => path,
|
||||
None => continue,
|
||||
},
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if !remote_skill_dir.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let remote_hash = match Self::compute_dir_hash(&remote_skill_dir) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
@@ -1065,15 +1046,16 @@ impl SkillService {
|
||||
))
|
||||
})?;
|
||||
|
||||
let source = temp_dir.join(&remote_match.directory);
|
||||
if !source.exists() {
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
return Err(anyhow!(format_skill_error(
|
||||
"SKILL_DIR_NOT_FOUND",
|
||||
&[("path", &source.display().to_string())],
|
||||
Some("checkRepoUrl"),
|
||||
)));
|
||||
}
|
||||
let source = Self::resolve_skill_source_dir(&temp_dir, &remote_match.directory)
|
||||
.ok_or_else(|| {
|
||||
let missing = temp_dir.join(&remote_match.directory).display().to_string();
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
anyhow!(format_skill_error(
|
||||
"SKILL_DIR_NOT_FOUND",
|
||||
&[("path", &missing)],
|
||||
Some("checkRepoUrl"),
|
||||
))
|
||||
})?;
|
||||
|
||||
// 备份旧文件
|
||||
let _ = Self::create_uninstall_backup(&skill);
|
||||
@@ -1556,19 +1538,6 @@ impl SkillService {
|
||||
// 保存到数据库
|
||||
db.save_skill(&skill)?;
|
||||
|
||||
// 同步到已启用的应用目录(创建 symlink 或复制文件)
|
||||
for app in AppType::all() {
|
||||
if skill.apps.is_enabled_for(&app) {
|
||||
if let Err(e) = Self::sync_to_app_dir(&skill.directory, &app) {
|
||||
log::warn!(
|
||||
"导入后同步 Skill '{}' 到 {:?} 失败: {e:#}",
|
||||
skill.directory,
|
||||
app
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
imported.push(skill);
|
||||
}
|
||||
|
||||
@@ -1614,6 +1583,10 @@ impl SkillService {
|
||||
/// - Symlink: 仅使用 symlink
|
||||
/// - Copy: 仅使用文件复制
|
||||
pub fn sync_to_app_dir(directory: &str, app: &AppType) -> Result<()> {
|
||||
if matches!(app, AppType::ClaudeDesktop) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let ssot_dir = Self::get_ssot_dir()?;
|
||||
let source = ssot_dir.join(directory);
|
||||
|
||||
@@ -1719,6 +1692,10 @@ impl SkillService {
|
||||
|
||||
/// 从应用目录删除 Skill(支持 symlink 和真实目录)
|
||||
pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> {
|
||||
if matches!(app, AppType::ClaudeDesktop) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let app_dir = Self::get_app_skills_dir(app)?;
|
||||
let skill_path = app_dir.join(directory);
|
||||
|
||||
@@ -1732,6 +1709,10 @@ impl SkillService {
|
||||
|
||||
/// 同步所有已启用的 Skills 到指定应用
|
||||
pub fn sync_to_app(db: &Arc<Database>, app: &AppType) -> Result<()> {
|
||||
if matches!(app, AppType::ClaudeDesktop) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let skills = db.get_all_installed_skills()?;
|
||||
let ssot_dir = Self::get_ssot_dir()?;
|
||||
let app_dir = Self::get_app_skills_dir(app)?;
|
||||
@@ -2108,6 +2089,40 @@ impl SkillService {
|
||||
walk(root, target_name, 0)
|
||||
}
|
||||
|
||||
/// 将 discoverable skill 的目录信息重新解析为解压目录中的真实源目录。
|
||||
///
|
||||
/// 兼容三种情况:
|
||||
/// 1. `skills/foo` 这类直接相对路径;
|
||||
/// 2. 仅持有安装名 `foo`,需要在仓库中递归查找真实目录;
|
||||
/// 3. 仓库根目录本身就是 skill,此时回退到解压根目录。
|
||||
fn resolve_skill_source_dir(root: &Path, raw_directory: &str) -> Option<PathBuf> {
|
||||
let source_rel = Self::sanitize_skill_source_path(raw_directory)?;
|
||||
let direct = root.join(&source_rel);
|
||||
if direct.is_dir() {
|
||||
return Some(direct);
|
||||
}
|
||||
|
||||
let target_name = source_rel.file_name()?.to_string_lossy().to_string();
|
||||
if let Some(found) = Self::find_skill_dir_by_name(root, &target_name) {
|
||||
log::info!(
|
||||
"Skill directory '{}' not found at direct path, using fallback: {}",
|
||||
target_name,
|
||||
found.display()
|
||||
);
|
||||
return Some(found);
|
||||
}
|
||||
|
||||
if root.is_dir() && root.join("SKILL.md").exists() {
|
||||
log::info!(
|
||||
"Skill directory '{}' not found, but SKILL.md exists at root, using repo root",
|
||||
target_name,
|
||||
);
|
||||
return Some(root.to_path_buf());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示)
|
||||
fn deduplicate_discoverable_skills(skills: &mut Vec<DiscoverableSkill>) {
|
||||
let mut seen = HashMap::new();
|
||||
@@ -2975,3 +2990,53 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn write_skill(dir: &Path, name: &str) {
|
||||
fs::create_dir_all(dir).expect("create skill dir");
|
||||
fs::write(
|
||||
dir.join("SKILL.md"),
|
||||
format!("---\nname: {name}\ndescription: Test skill\n---\n"),
|
||||
)
|
||||
.expect("write SKILL.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_skill_source_dir_returns_repo_root_for_root_level_skill() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
write_skill(temp.path(), "Root Skill");
|
||||
|
||||
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "last30days-skill-cn")
|
||||
.expect("root-level skill should resolve to the extracted repo root");
|
||||
|
||||
assert_eq!(resolved, temp.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_skill_source_dir_returns_direct_nested_directory_when_present() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let nested = temp.path().join("skills").join("nested-skill");
|
||||
write_skill(&nested, "Nested Skill");
|
||||
|
||||
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "skills/nested-skill")
|
||||
.expect("nested skill should resolve from its relative source path");
|
||||
|
||||
assert_eq!(resolved, nested);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_skill_source_dir_falls_back_to_matching_install_name() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let nested = temp.path().join("skills").join("nested-skill");
|
||||
write_skill(&nested, "Nested Skill");
|
||||
|
||||
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "nested-skill")
|
||||
.expect("install name should fall back to the matching discovered skill directory");
|
||||
|
||||
assert_eq!(resolved, nested);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。
|
||||
|
||||
use futures::StreamExt;
|
||||
use regex::Regex;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@@ -17,7 +16,9 @@ use crate::proxy::providers::copilot_auth;
|
||||
use crate::proxy::providers::transform::anthropic_to_openai;
|
||||
use crate::proxy::providers::transform_gemini::anthropic_to_gemini;
|
||||
use crate::proxy::providers::transform_responses::anthropic_to_responses;
|
||||
use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy};
|
||||
use crate::proxy::providers::{
|
||||
get_adapter, AuthInfo, AuthStrategy, ClaudeAdapter, ProviderAdapter,
|
||||
};
|
||||
|
||||
/// 健康状态枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -214,7 +215,11 @@ impl StreamCheckService {
|
||||
return Self::check_once_without_adapter(app_type, provider, config, start).await;
|
||||
}
|
||||
|
||||
let adapter = get_adapter(app_type);
|
||||
let adapter: Box<dyn ProviderAdapter> = if matches!(app_type, AppType::ClaudeDesktop) {
|
||||
Box::new(ClaudeAdapter::new())
|
||||
} else {
|
||||
get_adapter(app_type)
|
||||
};
|
||||
|
||||
let base_url = match base_url_override {
|
||||
Some(base_url) => base_url,
|
||||
@@ -235,7 +240,7 @@ impl StreamCheckService {
|
||||
let test_prompt = &config.test_prompt;
|
||||
|
||||
let result = match app_type {
|
||||
AppType::Claude => {
|
||||
AppType::Claude | AppType::ClaudeDesktop => {
|
||||
Self::check_claude_stream(
|
||||
&client,
|
||||
&base_url,
|
||||
@@ -356,15 +361,17 @@ impl StreamCheckService {
|
||||
});
|
||||
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要 store:false + include 标记,
|
||||
// 否则 Stream Check 会和生产路径一样被服务端 400 拒绝。
|
||||
let is_codex_oauth = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.provider_type.as_deref())
|
||||
== Some("codex_oauth");
|
||||
let is_codex_oauth = provider.is_codex_oauth();
|
||||
let codex_fast_mode = provider.codex_fast_mode_enabled();
|
||||
|
||||
let body = if is_openai_responses {
|
||||
anthropic_to_responses(anthropic_body, Some(&provider.id), is_codex_oauth)
|
||||
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
|
||||
anthropic_to_responses(
|
||||
anthropic_body,
|
||||
Some(&provider.id),
|
||||
is_codex_oauth,
|
||||
codex_fast_mode,
|
||||
)
|
||||
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
|
||||
} else if is_gemini_native {
|
||||
anthropic_to_gemini(anthropic_body)
|
||||
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
|
||||
@@ -432,12 +439,13 @@ impl StreamCheckService {
|
||||
let os_name = Self::get_os_name();
|
||||
let arch_name = Self::get_arch_name();
|
||||
|
||||
request_builder =
|
||||
request_builder.header("authorization", format!("Bearer {}", auth.api_key));
|
||||
|
||||
// Only Anthropic official strategy adds x-api-key
|
||||
if auth.strategy == AuthStrategy::Anthropic {
|
||||
request_builder = request_builder.header("x-api-key", &auth.api_key);
|
||||
// 鉴权头复用 ClaudeAdapter::get_auth_headers,与代理路径(forwarder)保持单一真理来源。
|
||||
// - AuthStrategy::Anthropic → x-api-key
|
||||
// - AuthStrategy::ClaudeAuth → Authorization: Bearer
|
||||
// - AuthStrategy::Bearer → Authorization: Bearer
|
||||
// 避免之前"无条件 Bearer + 条件 x-api-key 双发"导致的假阴性 / auth conflict。
|
||||
for (name, value) in ClaudeAdapter::new().get_auth_headers(auth) {
|
||||
request_builder = request_builder.header(name, value);
|
||||
}
|
||||
|
||||
request_builder = request_builder
|
||||
@@ -788,6 +796,15 @@ impl StreamCheckService {
|
||||
return None;
|
||||
}
|
||||
let lower = body.to_lowercase();
|
||||
let qianfan_quota_indicators = [
|
||||
"coding_plan_hour_quota_exceeded",
|
||||
"coding_plan_week_quota_exceeded",
|
||||
"coding_plan_month_quota_exceeded",
|
||||
];
|
||||
if qianfan_quota_indicators.iter().any(|s| lower.contains(s)) {
|
||||
return Some("quotaExceeded");
|
||||
}
|
||||
|
||||
// 必须提到 "model",避免通用 404 / 400 被误判
|
||||
if !lower.contains("model") {
|
||||
return None;
|
||||
@@ -1348,8 +1365,10 @@ impl StreamCheckService {
|
||||
config: &StreamCheckConfig,
|
||||
) -> String {
|
||||
match app_type {
|
||||
AppType::Claude => Self::extract_env_model(provider, "ANTHROPIC_MODEL")
|
||||
.unwrap_or_else(|| config.claude_model.clone()),
|
||||
AppType::Claude | AppType::ClaudeDesktop => {
|
||||
Self::extract_env_model(provider, "ANTHROPIC_MODEL")
|
||||
.unwrap_or_else(|| config.claude_model.clone())
|
||||
}
|
||||
AppType::Codex => {
|
||||
Self::extract_codex_model(provider).unwrap_or_else(|| config.codex_model.clone())
|
||||
}
|
||||
@@ -1412,10 +1431,11 @@ impl StreamCheckService {
|
||||
return None;
|
||||
}
|
||||
|
||||
let re = Regex::new(r#"^model\s*=\s*["']([^"']+)["']"#).ok()?;
|
||||
re.captures(config_text)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.map(|m| m.as_str().trim().to_string())
|
||||
let table = toml::from_str::<toml::Table>(config_text).ok()?;
|
||||
table
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
@@ -1731,6 +1751,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_qianfan_coding_plan_quota_errors() {
|
||||
let cases = [
|
||||
r#"{"error":{"code":"coding_plan_hour_quota_exceeded","message":"hour quota exceeded"}}"#,
|
||||
r#"{"error":{"code":"coding_plan_week_quota_exceeded","message":"week quota exceeded"}}"#,
|
||||
r#"{"error":{"code":"coding_plan_month_quota_exceeded","message":"month quota exceeded"}}"#,
|
||||
];
|
||||
|
||||
for body in cases {
|
||||
assert_eq!(
|
||||
StreamCheckService::detect_error_category(429, body),
|
||||
Some("quotaExceeded")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_os_name() {
|
||||
let os_name = StreamCheckService::get_os_name();
|
||||
@@ -1901,6 +1937,22 @@ mod tests {
|
||||
assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_claude_stream_url_for_gemini_native_cloudflare_vertex_full_url() {
|
||||
let url = StreamCheckService::resolve_claude_stream_url(
|
||||
"https://gateway.ai.cloudflare.com/v1/account/gateway/google-vertex-ai/v1/projects/project/locations/us-central1/publishers/google/models/gemini-3.1-pro-preview:streamGenerateContent",
|
||||
AuthStrategy::Google,
|
||||
"gemini_native",
|
||||
true,
|
||||
"gemini-2.5-flash",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://gateway.ai.cloudflare.com/v1/account/gateway/google-vertex-ai/v1/projects/project/locations/us-central1/publishers/google/models/gemini-3.1-pro-preview:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: Gemini SDK outputs commonly surface model ids as the
|
||||
/// resource-name form `models/gemini-2.5-pro`. Interpolating that raw
|
||||
/// value used to produce `/v1beta/models/models/gemini-2.5-pro:...`
|
||||
|
||||