mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98f4788458 | |||
| a3598fd976 | |||
| c1aa6c3917 | |||
| daa5595f36 | |||
| 819c2e5dfe | |||
| a6d718d0fc | |||
| e776160912 | |||
| 596019505f | |||
| 8b925c2f2f | |||
| 25983f3420 | |||
| ff706e9e96 | |||
| e8b07cb2a5 | |||
| feea81e5bb | |||
| 4282856683 | |||
| 65d6929993 | |||
| 1ca01bcd10 | |||
| bc01f44514 | |||
| 3390fe7ea0 | |||
| cb01593f7d | |||
| 36a103bbe4 | |||
| 05bc14e82b | |||
| 0396cd5491 | |||
| f97347fe6e | |||
| 9ea303b224 | |||
| 4f911727d2 | |||
| edc597ab23 | |||
| 955ea26da9 | |||
| 5beb63e67d | |||
| fa17194d84 | |||
| f1118d370f | |||
| 4f5250fc4d | |||
| 5c36ae066b | |||
| f59fab6c24 | |||
| 6940a4b208 | |||
| ea6123adf7 | |||
| e96eab5278 | |||
| 2985ad2c14 | |||
| aa09c9cb62 | |||
| 27c41f7416 | |||
| 6716a4c408 | |||
| 2626eeebe6 | |||
| ab6266f745 | |||
| 1392ef6238 | |||
| 3cd9a0dec5 | |||
| 8e0e9ac319 | |||
| bda625a4f1 | |||
| 473f21971d | |||
| 03a9296c1f | |||
| 8e7d167ace | |||
| dadefdee77 | |||
| ad030da3b1 | |||
| 8047f95416 | |||
| 0527002cca | |||
| 2a24da517f | |||
| ea95f39adf | |||
| f5acef32fd | |||
| 084857ce25 | |||
| 6692343d1e | |||
| 33eafbad51 | |||
| c2337d6857 | |||
| ce538265cb | |||
| e458e77e30 | |||
| ae90b53454 |
+1
-1
@@ -8,7 +8,7 @@ release/
|
||||
*.tsbuildinfo
|
||||
.npmrc
|
||||
CLAUDE.md
|
||||
# AGENTS.md
|
||||
AGENTS.md
|
||||
GEMINI.md
|
||||
/.claude
|
||||
/.codex
|
||||
|
||||
@@ -5,6 +5,65 @@ 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).
|
||||
|
||||
## [3.16.2] - 2026-06-07
|
||||
|
||||
Development since v3.16.1 focuses on broadening data portability and usage observability — S3-compatible cloud sync, OpenCode session usage import, and an opt-in official-subscription quota template — while hardening Codex Chat Completions routing (stream truncation, `tool_choice` / custom-tool / reasoning-token edge cases, file and audio attachments, and a Codex CLI models endpoint), strengthening proxy robustness (ephemeral ports, takeover/placeholder restore, system-message normalization, clearer upstream errors, and a text-only image fallback), fixing coding-plan quota lookups (Zhipu, MiniMax) and several Windows/macOS issues, adding the CherryIN and ZenMux providers, and refreshing the user manual.
|
||||
|
||||
**Stats**: 41 commits | 132 files changed | +11,116 insertions | -1,636 deletions
|
||||
|
||||
### Added
|
||||
|
||||
- **S3-Compatible Cloud Sync**: Cloud Sync now supports S3-compatible object storage as a second backend alongside WebDAV, using hand-rolled AWS Signature V4 signing for broad compatibility. The settings panel offers one-click presets for AWS S3, MinIO, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS, and Huawei OBS plus a custom endpoint, with connection testing, manual upload/download, and auto-sync on configuration changes (provider, endpoint, MCP, prompt, skill, settings, and proxy tables — not high-frequency data like usage logs); enabling S3 sync disables active WebDAV sync and vice versa (#1351).
|
||||
- **OpenCode Session Usage Sync**: Added OpenCode as a usage-statistics source that imports per-message token, cost, and model data from OpenCode's local SQLite database, with a new "OpenCode" app filter tab and an "OpenCode Session" data-source label. The database path respects `OPENCODE_DB` and `XDG_DATA_HOME` (defaulting to `~/.local/share/opencode` on all platforms), only finalized messages are imported, and the freshness check accounts for the WAL file so newly written sessions are not skipped (#3215).
|
||||
- **Official Subscription Quota Template**: Added an explicit, opt-in "official subscription" usage template for Claude, Codex, and Gemini official providers that queries plan quota via CLI/OAuth credentials, replacing the previous implicit auto-query. It is disabled by default and enabled from the usage-script modal with a configurable refresh interval.
|
||||
- **Unsupported Image Fallback Rectifier**: Added a proxy rectifier that replaces Anthropic image blocks with an `[Unsupported Image]` marker when the routed model is text-only (declared, or detected via a built-in model-name heuristic) or when the upstream rejects image input, so conversations are not interrupted. A new Settings toggle controls the fallback, with a separate toggle for the heuristic detection.
|
||||
- **ZenMux Token Plan Provider**: Added ZenMux as a Token Plan coding-plan provider that accepts a manually entered API key and base URL in the usage-script modal and renders its quota with USD-denominated used / limit values (#2709).
|
||||
- **CherryIN Preset**: Added the CherryIN aggregator gateway as a quick-config preset across all seven supported apps — Anthropic-format endpoint for Claude Code / Claude Desktop / OpenClaw / Hermes, `@ai-sdk/anthropic` for OpenCode, the OpenAI-compatible endpoint for Codex, and the Gemini-compatible endpoint for Gemini CLI — with the official brand icon, placed next to AiHubMix (#3643).
|
||||
- **CCSub Preset**: Added CCSub, a multi-model aggregator partner, as a quick-config preset across six apps — Claude Code, Claude Desktop, Codex, OpenCode, OpenClaw, and Hermes — with the official brand icon and the partner referral link prefilled as the API-key signup URL (`gpt-5.5` for the OpenAI-compatible Codex and OpenCode endpoints).
|
||||
- **Codex CLI Models Endpoint**: The local proxy now answers `GET /v1/models`, which Codex CLI probes at startup, returning the cc-switch-managed Codex model catalog. A stale-catalog guard parses the live `config.toml` and only serves the catalog when `model_catalog_json` still references the cc-switch-owned file, so a leftover catalog from a previous provider is not advertised (#3818).
|
||||
- **Codex Chat File and Audio Attachments**: The Codex Responses-to-Chat converter now maps `input_file` parts (carrying `file_id` or inline `file_data`) and `input_audio` parts into their Chat Completions equivalents, and emits top-level `input_*` items that were previously dropped, so file and audio attachments reach Chat-only Codex upstreams.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Usage Dashboard Hero Redesign**: Restructured the Usage Dashboard hero and summary cards into a more compact layout, consolidating the real-token total, request count, and cost into a single top row (#3426).
|
||||
- **SSSAiCode Endpoint Refresh**: Updated the SSSAiCode preset's website, signup, and API base URLs to the `sssaicodeapi.com` domain and refreshed its endpoint nodes (default `node-hk.sssaicodeapi.com`, plus `node-hk.sssaiapi.com` and `node-cf.sssaicodeapi.com`) across all seven app presets.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Codex Chat Truncated Stream Detection**: When a Chat Completions upstream ends a stream without a `finish_reason` or `[DONE]`, CC Switch no longer reports it as a normal completion — it finalizes normally only when the stream truly finished, emits an incomplete (`max_output_tokens`) response when partial output was produced, and emits a failed `stream_truncated` event when nothing was produced. Late-arriving reasoning is also attached to still-active streamed tool calls.
|
||||
- **Codex Chat `tool_choice` Without Tools**: The Responses-to-Chat converter now drops `tool_choice` and `parallel_tool_calls` whenever the resulting tools array is absent or empty, so strict OpenAI-compatible upstreams (vLLM, enterprise gateways) no longer reject the request with "When using `tool_choice`, `tools` must be set." (#3640).
|
||||
- **Codex Custom Tool Metadata Over Chat Routing**: Custom Codex tools (such as the freeform `apply_patch` tool) now preserve their full original definition — including format and grammar metadata — as a compact, order-stable JSON block in the generated Chat function description instead of a generic placeholder, keeping them usable on Chat Completions upstreams (#3644).
|
||||
- **Codex Chat `reasoning_tokens` in Usage**: The Chat-to-Responses usage conversion now always includes `output_tokens_details.reasoning_tokens` (defaulting to 0), even when a provider omits `completion_tokens_details` or returns it as a non-object, satisfying the Codex CLI's strict requirement and avoiding repeated parse failures and retries (#3514).
|
||||
- **Codex Cross-Turn Reasoning for Custom and Search Tools**: The cross-turn reasoning cache in Codex Chat history now covers the full tool-call set (`function_call`, `custom_tool_call`, `tool_search_call`) and their outputs, so `apply_patch` and tool-search calls keep their `reasoning_content` when restored via `previous_response_id`.
|
||||
- **Ephemeral Proxy Port Resolution**: When the proxy listens on port 0 (OS-assigned), takeover now starts the proxy first to learn the real port and writes it into the Live configs and database, so client URLs no longer point at a broken `:0` address; the Claude Desktop gateway URL is rejected if no concrete port has been resolved.
|
||||
- **Proxy Placeholder Backup/Restore Loop**: If a previous proxy stop left the proxy placeholders in Live, taking over again no longer overwrites a good backup with the proxy config, and restore no longer writes the placeholder back to Live — both paths detect the placeholder state and rebuild Live from the current provider, fixing cases where the proxy toggle became a no-op and clients stayed pinned to the local proxy (#3689).
|
||||
- **Official Provider Block Under Proxy Takeover**: While Local Routing takeover is active, only providers explicitly categorized as official are blocked from switching, instead of also disabling custom providers whose endpoint lives in metadata or whose fields are unfilled. The disabled Enable button now shows a lighter hint tooltip in place of the red "Blocked" badge.
|
||||
- **Localhost Listen Address Normalization**: Saving the proxy with a listen address of `localhost` now normalizes it to `127.0.0.1` before persisting, avoiding binding inconsistencies (#3016).
|
||||
- **Anthropic System Message Normalization**: For Anthropic-format providers, system-role entries inside the `messages` array are collapsed and merged into the top-level `system` field (preserving order and any existing top-level system), preventing strict upstreams from rejecting non-leading system messages; OpenAI Chat routing is untouched (#3775).
|
||||
- **Claude Desktop 1M-Context Model Routing**: Claude Desktop appends a `[1m]` marker to the model name when the 1M-context beta is active (e.g. `claude-opus-4-8[1m]`). The proxy now strips that suffix before route lookup so exact, alias, legacy, and role-keyword matching resolve correctly, fixing `route_unknown` (HTTP 400) failures when switching to a 1M-capable model mid-conversation.
|
||||
- **Codex 413 Error Clarity**: When a Codex upstream gateway rejects an oversized request with HTTP 413, the proxy now returns a dedicated message identifying it as the provider's server-side body-size limit (not a CC Switch limit) with recovery steps (run `/compact`, drop large logs or inline images, or ask the provider to raise its limit), instead of echoing the raw upstream HTML page.
|
||||
- **Proxy Panel Error Detail**: When toggling proxy takeover fails, the proxy panel toast now includes the underlying backend error detail instead of only a generic failure message (#3656).
|
||||
- **Copilot Infinite-Whitespace Threshold**: Raised the streaming infinite-whitespace abort threshold from 20 to 500 consecutive whitespace characters, so legitimate tool calls with deeply indented code arguments are no longer falsely aborted while still catching the real Copilot infinite-whitespace bug (#2647).
|
||||
- **Subscription Tier Tray Rendering**: Fixed tray and quota rendering for official subscription tiers via a unified tier-to-label mapping: Claude/Codex no longer drop the seven-day window, Gemini Pro/Flash/Flash-Lite tiers no longer leak raw machine names, and multi-window plans (e.g. Opus + Sonnet) now display the worst utilization instead of the first match.
|
||||
- **Inflated Claude Stream Input Tokens**: Some Anthropic-compatible streaming providers (e.g. Qwen, MiniMax) report the full context as `input_tokens` in `message_start`, double-counting the cached portion and artificially lowering the displayed cache hit rate. The parser now prefers a smaller positive `input_tokens` from `message_delta` and adopts the paired cache counts from the same usage block; native Claude and OpenRouter-converted paths are unchanged.
|
||||
- **Zhipu Quota Query Endpoint Routing**: The Zhipu coding-plan quota lookup was hard-coded to `api.z.ai`, so users on the mainland China preset (`open.bigmodel.cn`) could not retrieve usage when the international endpoint was unreachable. The quota request now routes to the host matching the user's configured base URL (#3702).
|
||||
- **MiniMax Balance API and Pricing**: Adapted MiniMax coding-plan quota to its new balance API (which returns remaining-percent fields instead of usage counts that broke the old parser and left the tray blank), filtered out non-coding models (e.g. video), handled plans without a weekly limit, and seeded default pricing for MiniMax M3 (#3518).
|
||||
- **GLM Coding Plan Endpoints and Model Fetch**: Corrected the ZhiPu / Z.AI GLM Coding Plan presets to the `/api/coding/paas/v4` endpoints across Codex, OpenCode, OpenClaw, and Hermes, and taught the model-list probe to query `{base}/models` for base URLs that already end in a `/v{N}` segment (keeping `/v1/models` as a fallback), so the Fetch Models button no longer 404s on versioned endpoints (#3524).
|
||||
- **Codex Model Catalog Path Portability**: Codex now writes only the relative filename `cc-switch-model-catalog.json` to `config.toml` instead of an absolute path (Codex CLI resolves it from the config directory), fixing the model catalog breaking on WSL and symlinked setups where the absolute path could not be translated (#3614).
|
||||
- **APINebula OpenCode SDK**: The APINebula OpenCode preset now loads `@ai-sdk/openai-compatible` instead of `@ai-sdk/openai`, so requests use the OpenAI Chat Completions format the relay expects rather than the Responses API.
|
||||
- **Windows Tray Icon Residue on Exit**: Quitting CC Switch on Windows could leave a dead tray icon until hovered; the app now removes the tray icon before exiting so it disappears cleanly (#3797).
|
||||
- **Windows Taskbar Icon**: Set an explicit Windows AppUserModelID at runtime and stamped the installer's desktop and start-menu shortcuts with the same ID and product icon, so CC Switch shows the correct icon and groups properly in the taskbar (#3457).
|
||||
- **Windows Subdirectory Skill Updates**: Normalized backslash path separators to forward slashes when scanning installed skills on Windows, so skills nested in subdirectories (e.g. `skills/my-skill`) are matched by the update check instead of being silently skipped (#3430).
|
||||
- **macOS Input Auto-Capitalization**: Disabled autocomplete, autocorrect, autocapitalize, and spellcheck on the shared text Input component so macOS no longer auto-capitalizes or auto-corrects the first letter typed into configuration fields (#3626).
|
||||
- **Codex VS Code Session Previews**: Codex session previews for requests sent from VS Code could show selection or open-file content instead of the prompt when a markdown heading preceded the injected request. Both the backend title and frontend preview now match the last "## My request for Codex:" heading (the IDE injects the real request as the final section) (#3593).
|
||||
- **VS Code Wording in Chinese UI**: Corrected the "Apply to Claude Code plugin" description in the Simplified and Traditional Chinese locales to write "VS Code" properly instead of "Vscode", aligning with the English and Japanese strings (#3228).
|
||||
|
||||
### Docs
|
||||
|
||||
- **User Manual Refresh**: Refreshed the README locales and the en / zh / ja user manuals to reflect all seven supported apps (adding Claude Desktop and Hermes), corrected the OpenCode config path to `~/.config/opencode/` (`opencode.json`), documented Hermes configuration files, updated the language docs to four languages, revised per-app MCP / Prompts / Skills availability, noted that export produces a timestamped SQL backup including usage logs, and documented the pricing model-ID matching rules (#3411).
|
||||
- **Codex Official Auth Preservation Guide**: Added a trilingual (en / zh / ja) guide explaining how to keep Codex official remote control and plugins working while routing model traffic to third-party APIs, and linked it from the v3.16.1 release notes.
|
||||
- **README Release-Note Links and Sponsor Markup**: Updated the Release Notes links in all README locales to point at v3.16.1 and fixed broken smart-quote characters in the README_ZH sponsor blocks so their HTML attributes render correctly (#3772).
|
||||
|
||||
## [3.16.1] - 2026-06-01
|
||||
|
||||
Development since v3.16.0 focuses on hardening Codex provider switching and Local Routing takeover: preserving official OAuth auth and model catalogs across normal switches, hot-switches, backup restore, and edit flows; restoring Codex Chat tool/plugin compatibility over Chat Completions upstreams; improving Codex proxy diagnostics and CLI discovery; and documenting DeepSeek routing.
|
||||
|
||||
@@ -146,6 +146,16 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
|
||||
<td>Atlas Cloud is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out Atlas Cloud's new <a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">coding plan</a> promotion for more budget-friendly API access!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.jpg" alt="CCSub" width="150"></a></td>
|
||||
<td>Thanks to CCSub for sponsoring this project! CCSub is a stable, affordable AI API relay platform — your drop-in replacement for a Claude.ai subscription. One API key gives you access to Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini, and DeepSeek at roughly 30% of direct API cost, with no VPN required from anywhere in the world. Compatible with Claude Code, Codex, Cursor, Cline, Continue, Windsurf, and all major AI coding tools. Register via <a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">this link</a> and get $5 free credit on sign-up.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>Thanks to Unity2.ai for sponsoring this project! Unity2.ai is a high-performance AI model API relay platform for individual developers, teams, and enterprises. Long trusted by leading companies in China, it serves over 30 billion tokens per day and supports high concurrency at the 5,000 RPM level. It offers balance-based billing, first top-up bonuses, bundle subscriptions, corporate invoicing, and dedicated support. Register via <a href="https://unity2.ai/register?source=ccs">this link</a> to get $2 in credits, plus another $10 for joining the official group — up to $12 in free credits!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
@@ -172,7 +182,7 @@ Modern AI-powered coding relies on tools like Claude Code, Claude Desktop, Codex
|
||||
|
||||
## Features
|
||||
|
||||
[Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.16.0-en.md)
|
||||
[Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.16.1-en.md)
|
||||
|
||||
### Provider Management
|
||||
|
||||
|
||||
+11
-1
@@ -146,6 +146,16 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
|
||||
<td>Atlas Cloud ist eine vollmodale KI-Inferenzplattform, die Entwicklern über eine einzige KI-API Zugriff auf Videogenerierung, Bildgenerierung und LLM-APIs bietet. Statt mehrere Anbieterintegrationen zu verwalten, verbinden Sie sich einmal und erhalten einheitlichen Zugriff auf mehr als 300 kuratierte Modelle über alle Modalitäten hinweg. Sehen Sie sich die neue <a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">Coding-Plan</a>-Aktion von Atlas Cloud für kostengünstigeren API-Zugang an!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.jpg" alt="CCSub" width="150"></a></td>
|
||||
<td>Danke an CCSub für die Unterstützung dieses Projekts! CCSub ist eine zuverlässige und kostengünstige AI-API-Relay-Plattform — Ihr direkter Ersatz für ein Claude.ai-Abonnement. Mit einem einzigen API-Schlüssel erhalten Sie Zugriff auf Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini und DeepSeek zu etwa 30 % der Kosten der direkten API-Nutzung — ohne VPN, weltweit nutzbar. Kompatibel mit Claude Code, Codex, Cursor, Cline, Continue, Windsurf und allen gängigen AI-Coding-Tools. Registrieren Sie sich über <a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">diesen Link</a> und erhalten Sie $5 Startguthaben bei der Anmeldung.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>Danke an Unity2.ai für die Unterstützung dieses Projekts! Unity2.ai ist eine leistungsstarke AI-Modell-API-Relay-Plattform für Einzelentwickler, Teams und Unternehmen. Sie wird seit Langem von führenden Unternehmen in China genutzt, verarbeitet täglich über 30 Milliarden Tokens und unterstützt hohe Parallelität auf 5.000-RPM-Niveau. Geboten werden Guthaben-Abrechnung, Ersteinzahlungsbonus, Kombi-Abonnements, Firmenrechnungen und persönliche Betreuung. Registrieren Sie sich über <a href="https://unity2.ai/register?source=ccs">diesen Link</a> und erhalten Sie $2 Guthaben, plus weitere $10 für den Beitritt zur offiziellen Gruppe — bis zu $12 Gratis-Guthaben!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
@@ -172,7 +182,7 @@ Modernes KI-gestütztes Programmieren stützt sich auf Werkzeuge wie Claude Code
|
||||
|
||||
## Funktionen
|
||||
|
||||
[Vollständiges Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.16.0-en.md)
|
||||
[Vollständiges Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.16.1-en.md)
|
||||
|
||||
### Anbieterverwaltung
|
||||
|
||||
|
||||
+11
-1
@@ -145,6 +145,16 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
<td>Atlas Cloud は、1 つの API で動画・画像生成や LLM(大規模言語モデル)を利用できる全モーダル対応の AI 推論プラットフォームです。複数のベンダーを個別に管理する手間を省き、一度の接続で 300 以上の厳選されたマルチモーダルモデルにアクセスできます。より低コストで API を利用できる、開発者向けの新しい<a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">「コーディングプラン」</a>プロモーションをぜひチェックしてください!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.jpg" alt="CCSub" width="150"></a></td>
|
||||
<td>CCSub のご支援に感謝します!CCSub は安定した低価格の AI API リレープラットフォームで、Claude Code 公式サブスクリプションの強力な代替です。1 つの API キーで Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek の全モデルを公式直接利用の約 1/3 のコストでご利用いただけます。VPN 不要で世界中から直接接続可能。Claude Code、Codex、Cursor、Cline、Continue、Windsurf など主要な AI コーディングツールすべてに対応しています。<a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">こちらのリンク</a>から登録すると $5 の無料クレジットがもらえます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>Unity2.ai のご支援に感謝します!Unity2.ai は個人開発者・チーム・企業向けの高性能 AI モデル API リレープラットフォームです。中国の大手企業に長年利用されており、1 日 300 億トークン以上を処理し、5000 RPM クラスの高並列に対応しています。残高課金、初回チャージボーナス、組み合わせサブスクリプション、企業向け請求書発行、専任サポートを提供。<a href="https://unity2.ai/register?source=ccs">こちらのリンク</a>から登録すると $2 のクレジット、公式グループへの参加でさらに $10、最大 $12 の無料クレジットがもらえます!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
@@ -171,7 +181,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
|
||||
## 特長
|
||||
|
||||
[完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-notes/v3.16.0-ja.md)
|
||||
[完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-notes/v3.16.1-ja.md)
|
||||
|
||||
### プロバイダ管理
|
||||
|
||||
|
||||
+15
-5
@@ -112,13 +112,13 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width=”180”><a href=”https://ctok.ai”><img src=”assets/partners/logos/ctok.png” alt=”CTok” width=”150”></a></td>
|
||||
<td>感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href=”https://ctok.ai”>这里</a>注册!</td>
|
||||
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
|
||||
<td>感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://ctok.ai">这里</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width=”180”><a href=”https://console.claudeapi.com/register?aff=pCLD”><img src=”assets/partners/logos/claudeapi.png” alt=”ClaudeAPI” width=”150”></a></td>
|
||||
<td>本项目由 <a href=”https://console.claudeapi.com/register?aff=pCLD”>Claude API</a> 赞助。Claude API 直连,三分钟接入 Claude Code 与 Agent 应用 新用户可领取测试额度。基于 Anthropic 官方 Key + AWS Bedrock 官方渠道,非逆向、非降智,支持 Opus / Sonnet / Haiku 全系列模型,保留 Tool Use、1M 上下文等官方能力。适合 Claude Code 深度用户、Agent 工程师与企业技术团队,支持开票和团队对接。点击<a href=”https://console.claudeapi.com/register?aff=pCLD”>这里</a>注册!</td>
|
||||
<td width="180"><a href="https://console.claudeapi.com/register?aff=pCLD"><img src="assets/partners/logos/claudeapi.png" alt="ClaudeAPI" width="150"></a></td>
|
||||
<td>本项目由 <a href="https://console.claudeapi.com/register?aff=pCLD">Claude API</a> 赞助。Claude API 直连,三分钟接入 Claude Code 与 Agent 应用 新用户可领取测试额度。基于 Anthropic 官方 Key + AWS Bedrock 官方渠道,非逆向、非降智,支持 Opus / Sonnet / Haiku 全系列模型,保留 Tool Use、1M 上下文等官方能力。适合 Claude Code 深度用户、Agent 工程师与企业技术团队,支持开票和团队对接。点击<a href="https://console.claudeapi.com/register?aff=pCLD">这里</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -146,6 +146,16 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
<td>Atlas Cloud 是一个全模态 AI 推理平台,通过单一 API 为开发者提供视频生成、图像生成及 LLM 接入。免去繁琐的多供应商对接,一次连接即可调用 300+ 款全模态精选模型。立即查看 Atlas Cloud 全新<a href="https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch">“编程计划”</a>优惠,获取更具性价比的 API 接入!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.jpg" alt="CCSub" width="150"></a></td>
|
||||
<td>感谢 CCSub 赞助本项目!CCSub 是稳定、实惠的 AI API 中转平台,是 Claude Code 官方订阅的超强平替。一个 API Key 即可调用 Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek 全系列模型,价格约为官方直连的 1/3,全球直连无需梯子。兼容 Claude Code、Codex、Cursor、Cline、Continue、Windsurf 等所有主流 AI 编程工具。通过<a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">此链接</a>注册即送 $5 体验额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||
<td>感谢 Unity2.ai 赞助了本项目!Unity2.ai 是面向个人开发者、团队和企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。支持余额计费、首充赠额、组合订阅、企业开票和专属对接。通过<a href="https://unity2.ai/register?source=ccs">此链接</a>注册可领取 $2 余额,加入官方群再送 $10 余额,最高可领 $12 免费额度!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
@@ -172,7 +182,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
|
||||
## 功能特性
|
||||
|
||||
[完整更新日志](CHANGELOG.md) | [发布说明](docs/release-notes/v3.16.0-zh.md)
|
||||
[完整更新日志](CHANGELOG.md) | [发布说明](docs/release-notes/v3.16.1-zh.md)
|
||||
|
||||
### 供应商管理
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
@@ -105,7 +105,7 @@ Fixed multiple preserve-mode takeover paths that could clear or overwrite offici
|
||||
|
||||
Fixed cases where `modelCatalog` could be cleared during live backfill, active-provider editing, provider switching, and takeover shutdown restore. Snapshot backups preserve existing `model_catalog_json` pointers; backups rebuilt from providers regenerate catalog projections from the database source of truth; editing the active provider now prefers the database model catalog instead of trusting a live reverse-parse result that may have lost its projection.
|
||||
|
||||
Provider switching also now always refreshes the generated Codex model catalog JSON ([#3360](https://github.com/farion1231/cc-switch/pull/3360), thanks [@Postroggy](https://github.com/Postroggy)).
|
||||
Provider switching also now always refreshes the generated Codex model catalog JSON ([#3360](https://github.com/farion1231/cc-switch/pull/3360), thanks @Postroggy).
|
||||
|
||||
### Codex Chat Tools, Plugins, and Custom Tools Restored
|
||||
|
||||
@@ -117,19 +117,19 @@ When Codex forwarding fails, CC Switch now returns JSON errors that include prov
|
||||
|
||||
### Codex Native Balance / Coding Plan Credential Lookup
|
||||
|
||||
Fixed native balance and Coding Plan queries using credentials from the wrong app. Each app now resolves its own provider credentials instead of carrying authentication assumptions from another app surface into the query flow ([#3355](https://github.com/farion1231/cc-switch/pull/3355), thanks [@SiskonEmilia](https://github.com/SiskonEmilia)).
|
||||
Fixed native balance and Coding Plan queries using credentials from the wrong app. Each app now resolves its own provider credentials instead of carrying authentication assumptions from another app surface into the query flow ([#3355](https://github.com/farion1231/cc-switch/pull/3355), thanks @SiskonEmilia).
|
||||
|
||||
### Codex CLI Discovery and Model Catalog Template Fallback
|
||||
|
||||
Fixed a too-narrow Codex CLI discovery path for third-party Codex model catalog projection. The backend now searches common Codex CLI install locations across platforms, and falls back to a built-in GPT-5.5 model catalog template if no template can be found ([#3382](https://github.com/farion1231/cc-switch/pull/3382), thanks [@chofuhoyu](https://github.com/chofuhoyu)).
|
||||
Fixed a too-narrow Codex CLI discovery path for third-party Codex model catalog projection. The backend now searches common Codex CLI install locations across platforms, and falls back to a built-in GPT-5.5 model catalog template if no template can be found ([#3382](https://github.com/farion1231/cc-switch/pull/3382), thanks @chofuhoyu).
|
||||
|
||||
### Claude Desktop Official Provider Add Failure
|
||||
|
||||
Fixed an error when adding the Claude Desktop Official provider ([#3405](https://github.com/farion1231/cc-switch/pull/3405), thanks [@Eunknight](https://github.com/Eunknight)).
|
||||
Fixed an error when adding the Claude Desktop Official provider ([#3405](https://github.com/farion1231/cc-switch/pull/3405), thanks @Eunknight).
|
||||
|
||||
### Kimi / Moonshot Tool-Thinking History Normalization
|
||||
|
||||
Added Kimi / Moonshot to the Anthropic-compatible tool-thinking history normalizer. Later turns can now correctly replay reasoning and tool-call context, avoiding failures caused by history messages that do not match upstream requirements ([#3377](https://github.com/farion1231/cc-switch/pull/3377), thanks [@Neon-Wang](https://github.com/Neon-Wang)).
|
||||
Added Kimi / Moonshot to the Anthropic-compatible tool-thinking history normalizer. Later turns can now correctly replay reasoning and tool-call context, avoiding failures caused by history messages that do not match upstream requirements ([#3377](https://github.com/farion1231/cc-switch/pull/3377), thanks @Neon-Wang).
|
||||
|
||||
### Windows Tool Version Detection
|
||||
|
||||
@@ -171,11 +171,11 @@ By enabling these features, users accept the related risks. CC Switch is not res
|
||||
|
||||
Thanks to the following contributors for fixes in v3.16.1:
|
||||
|
||||
- [#3360](https://github.com/farion1231/cc-switch/pull/3360): always update Codex model catalog JSON when switching providers, thanks [@Postroggy](https://github.com/Postroggy).
|
||||
- [#3355](https://github.com/farion1231/cc-switch/pull/3355): resolve native balance / Coding Plan credentials per app, thanks [@SiskonEmilia](https://github.com/SiskonEmilia).
|
||||
- [#3405](https://github.com/farion1231/cc-switch/pull/3405): fix Claude Desktop Official provider add failure, thanks [@Eunknight](https://github.com/Eunknight).
|
||||
- [#3382](https://github.com/farion1231/cc-switch/pull/3382): Codex CLI multi-platform discovery and GPT-5.5 model template fallback, thanks [@chofuhoyu](https://github.com/chofuhoyu).
|
||||
- [#3377](https://github.com/farion1231/cc-switch/pull/3377): Kimi / Moonshot tool-thinking history normalization, thanks [@Neon-Wang](https://github.com/Neon-Wang).
|
||||
- [#3360](https://github.com/farion1231/cc-switch/pull/3360): always update Codex model catalog JSON when switching providers, thanks @Postroggy.
|
||||
- [#3355](https://github.com/farion1231/cc-switch/pull/3355): resolve native balance / Coding Plan credentials per app, thanks @SiskonEmilia.
|
||||
- [#3405](https://github.com/farion1231/cc-switch/pull/3405): fix Claude Desktop Official provider add failure, thanks @Eunknight.
|
||||
- [#3382](https://github.com/farion1231/cc-switch/pull/3382): Codex CLI multi-platform discovery and GPT-5.5 model template fallback, thanks @chofuhoyu.
|
||||
- [#3377](https://github.com/farion1231/cc-switch/pull/3377): Kimi / Moonshot tool-thinking history normalization, thanks @Neon-Wang.
|
||||
|
||||
Thanks also to everyone who reported Codex OAuth, model catalog, local routing takeover, and Chat Completions tool-call issues after v3.16.0. Many of these fixes came directly from real-world reproduction details.
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ Codex がローカルルーティングのテイクオーバー状態にある
|
||||
|
||||
live バックフィル、現在のプロバイダー編集、プロバイダー切り替え、テイクオーバー解除時の復元などで `modelCatalog` が空になる問題を修正しました。スナップショットバックアップは既存の `model_catalog_json` ポインターを保持します。プロバイダーから再構築されるバックアップは、データベースの信頼できる情報源からカタログ投影を再生成します。現在のプロバイダー編集時は、投影を失っている可能性のある live の逆解析結果ではなく、データベース内のモデルカタログを優先します。
|
||||
|
||||
また、プロバイダー切り替え時には生成済みの Codex モデルカタログ JSON を常に更新するようになりました([#3360](https://github.com/farion1231/cc-switch/pull/3360)、[@Postroggy](https://github.com/Postroggy) に感謝)。
|
||||
また、プロバイダー切り替え時には生成済みの Codex モデルカタログ JSON を常に更新するようになりました([#3360](https://github.com/farion1231/cc-switch/pull/3360)、@Postroggy に感謝)。
|
||||
|
||||
### Codex Chat ツール、プラグイン、カスタムツールの復元
|
||||
|
||||
@@ -117,19 +117,19 @@ Codex の転送に失敗したとき、provider、model、endpoint、上流 HTTP
|
||||
|
||||
### Codex ネイティブ残高 / Coding Plan の認証情報検索
|
||||
|
||||
ネイティブ残高と Coding Plan の照会時に、別アプリの認証情報を誤って使う問題を修正しました。各 app は自分自身のプロバイダー認証情報を解析し、別のアプリ面の認証前提を照会フローへ持ち込まなくなりました([#3355](https://github.com/farion1231/cc-switch/pull/3355)、[@SiskonEmilia](https://github.com/SiskonEmilia) に感謝)。
|
||||
ネイティブ残高と Coding Plan の照会時に、別アプリの認証情報を誤って使う問題を修正しました。各 app は自分自身のプロバイダー認証情報を解析し、別のアプリ面の認証前提を照会フローへ持ち込まなくなりました([#3355](https://github.com/farion1231/cc-switch/pull/3355)、@SiskonEmilia に感謝)。
|
||||
|
||||
### Codex CLI 探索とモデルカタログテンプレートのフォールバック
|
||||
|
||||
サードパーティ Codex モデルカタログ投影における Codex CLI の探索パスが狭すぎる問題を修正しました。バックエンドは複数プラットフォームの一般的な Codex CLI インストール場所を探し、それでもテンプレートが見つからない場合は内蔵の GPT-5.5 モデルカタログテンプレートへフォールバックします([#3382](https://github.com/farion1231/cc-switch/pull/3382)、[@chofuhoyu](https://github.com/chofuhoyu) に感謝)。
|
||||
サードパーティ Codex モデルカタログ投影における Codex CLI の探索パスが狭すぎる問題を修正しました。バックエンドは複数プラットフォームの一般的な Codex CLI インストール場所を探し、それでもテンプレートが見つからない場合は内蔵の GPT-5.5 モデルカタログテンプレートへフォールバックします([#3382](https://github.com/farion1231/cc-switch/pull/3382)、@chofuhoyu に感謝)。
|
||||
|
||||
### Claude Desktop Official プロバイダー追加失敗
|
||||
|
||||
Claude Desktop Official プロバイダー追加時のエラーを修正しました([#3405](https://github.com/farion1231/cc-switch/pull/3405)、[@Eunknight](https://github.com/Eunknight) に感謝)。
|
||||
Claude Desktop Official プロバイダー追加時のエラーを修正しました([#3405](https://github.com/farion1231/cc-switch/pull/3405)、@Eunknight に感謝)。
|
||||
|
||||
### Kimi / Moonshot ツール思考履歴の正規化
|
||||
|
||||
Kimi / Moonshot を Anthropic 互換ツール思考履歴 normalizer に追加しました。後続ターンで reasoning と tool-call コンテキストを正しく再生できるようになり、履歴メッセージの形が上流要件に合わず失敗する問題を避けます([#3377](https://github.com/farion1231/cc-switch/pull/3377)、[@Neon-Wang](https://github.com/Neon-Wang) に感謝)。
|
||||
Kimi / Moonshot を Anthropic 互換ツール思考履歴 normalizer に追加しました。後続ターンで reasoning と tool-call コンテキストを正しく再生できるようになり、履歴メッセージの形が上流要件に合わず失敗する問題を避けます([#3377](https://github.com/farion1231/cc-switch/pull/3377)、@Neon-Wang に感謝)。
|
||||
|
||||
### Windows ツールバージョン検出
|
||||
|
||||
@@ -171,11 +171,11 @@ Codex は起動時に `model_catalog_json` を読み込みます。v3.16.1 で
|
||||
|
||||
v3.16.1 で修正を届けてくださった以下のコントリビューターに感謝します:
|
||||
|
||||
- [#3360](https://github.com/farion1231/cc-switch/pull/3360): Codex プロバイダー切り替え時にモデルカタログ JSON を常に更新、[@Postroggy](https://github.com/Postroggy) に感謝。
|
||||
- [#3355](https://github.com/farion1231/cc-switch/pull/3355): ネイティブ残高 / Coding Plan 照会の認証情報を app ごとに解析、[@SiskonEmilia](https://github.com/SiskonEmilia) に感謝。
|
||||
- [#3405](https://github.com/farion1231/cc-switch/pull/3405): Claude Desktop Official プロバイダー追加エラーを修正、[@Eunknight](https://github.com/Eunknight) に感謝。
|
||||
- [#3382](https://github.com/farion1231/cc-switch/pull/3382): Codex CLI の複数プラットフォーム探索と GPT-5.5 モデルテンプレートフォールバック、[@chofuhoyu](https://github.com/chofuhoyu) に感謝。
|
||||
- [#3377](https://github.com/farion1231/cc-switch/pull/3377): Kimi / Moonshot ツール思考履歴の正規化、[@Neon-Wang](https://github.com/Neon-Wang) に感謝。
|
||||
- [#3360](https://github.com/farion1231/cc-switch/pull/3360): Codex プロバイダー切り替え時にモデルカタログ JSON を常に更新、@Postroggy に感謝。
|
||||
- [#3355](https://github.com/farion1231/cc-switch/pull/3355): ネイティブ残高 / Coding Plan 照会の認証情報を app ごとに解析、@SiskonEmilia に感謝。
|
||||
- [#3405](https://github.com/farion1231/cc-switch/pull/3405): Claude Desktop Official プロバイダー追加エラーを修正、@Eunknight に感謝。
|
||||
- [#3382](https://github.com/farion1231/cc-switch/pull/3382): Codex CLI の複数プラットフォーム探索と GPT-5.5 モデルテンプレートフォールバック、@chofuhoyu に感謝。
|
||||
- [#3377](https://github.com/farion1231/cc-switch/pull/3377): Kimi / Moonshot ツール思考履歴の正規化、@Neon-Wang に感謝。
|
||||
|
||||
v3.16.0 リリース後に Codex OAuth、モデルカタログ、ローカルルーティングのテイクオーバー、Chat Completions ツール呼び出しの問題を報告してくださったすべてのユーザーにも感謝します。今回の多くの修正は、実際の利用シーンから得られた再現情報に基づいています。
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ Codex / Claude / Gemini 的供应商切换与本地路由接管开关现在共
|
||||
|
||||
修复 `modelCatalog` 在 live 回填、当前供应商编辑弹窗、供应商切换、关闭接管恢复等场景被清空的问题。快照备份会保留已有 `model_catalog_json` 指针;由供应商重建的备份会从数据库真相来源重新生成目录投影;编辑当前供应商时会优先使用数据库里的模型目录,而不是信任可能已经丢失投影的 live 反解结果。
|
||||
|
||||
同时,供应商切换现在会始终刷新生成的 Codex 模型目录 JSON([#3360](https://github.com/farion1231/cc-switch/pull/3360),感谢 [@Postroggy](https://github.com/Postroggy))。
|
||||
同时,供应商切换现在会始终刷新生成的 Codex 模型目录 JSON([#3360](https://github.com/farion1231/cc-switch/pull/3360),感谢 @Postroggy)。
|
||||
|
||||
### Codex Chat 工具、插件和自定义工具恢复
|
||||
|
||||
@@ -117,19 +117,19 @@ Codex 转发失败时,现在返回包含 provider、model、endpoint、上游
|
||||
|
||||
### Codex 原生余额 / Coding Plan 查询凭据
|
||||
|
||||
修复原生余额与 Coding Plan 查询时跨 app 错用凭据的问题。现在每个 app 会解析自己的供应商凭据,不再把其他应用面的认证假设带进查询流程([#3355](https://github.com/farion1231/cc-switch/pull/3355),感谢 [@SiskonEmilia](https://github.com/SiskonEmilia))。
|
||||
修复原生余额与 Coding Plan 查询时跨 app 错用凭据的问题。现在每个 app 会解析自己的供应商凭据,不再把其他应用面的认证假设带进查询流程([#3355](https://github.com/farion1231/cc-switch/pull/3355),感谢 @SiskonEmilia)。
|
||||
|
||||
### Codex CLI 发现与模型目录模板兜底
|
||||
|
||||
修复第三方 Codex 模型目录投影对 Codex CLI 发现路径过窄的问题。现在后端会在多平台常见安装位置寻找 Codex CLI,并在仍找不到模板时使用内置 GPT-5.5 模型目录模板兜底([#3382](https://github.com/farion1231/cc-switch/pull/3382),感谢 [@chofuhoyu](https://github.com/chofuhoyu))。
|
||||
修复第三方 Codex 模型目录投影对 Codex CLI 发现路径过窄的问题。现在后端会在多平台常见安装位置寻找 Codex CLI,并在仍找不到模板时使用内置 GPT-5.5 模型目录模板兜底([#3382](https://github.com/farion1231/cc-switch/pull/3382),感谢 @chofuhoyu)。
|
||||
|
||||
### Claude Desktop 官方供应商添加失败
|
||||
|
||||
修复添加 Claude Desktop 官方供应商时报错的问题([#3405](https://github.com/farion1231/cc-switch/pull/3405),感谢 [@Eunknight](https://github.com/Eunknight))。
|
||||
修复添加 Claude Desktop 官方供应商时报错的问题([#3405](https://github.com/farion1231/cc-switch/pull/3405),感谢 @Eunknight)。
|
||||
|
||||
### Kimi / Moonshot 工具思考历史规范化
|
||||
|
||||
把 Kimi / Moonshot 加入 Anthropic 兼容工具思考历史 normalizer。后续轮次现在能正确重放 reasoning 与 tool-call 上下文,避免因为历史消息形态不符合上游要求而失败([#3377](https://github.com/farion1231/cc-switch/pull/3377),感谢 [@Neon-Wang](https://github.com/Neon-Wang))。
|
||||
把 Kimi / Moonshot 加入 Anthropic 兼容工具思考历史 normalizer。后续轮次现在能正确重放 reasoning 与 tool-call 上下文,避免因为历史消息形态不符合上游要求而失败([#3377](https://github.com/farion1231/cc-switch/pull/3377),感谢 @Neon-Wang)。
|
||||
|
||||
### Windows 工具版本探测
|
||||
|
||||
@@ -171,11 +171,11 @@ Codex 在启动时读取 `model_catalog_json`。因此即使 v3.16.1 已修复
|
||||
|
||||
感谢以下贡献者在 v3.16.1 中提交修复:
|
||||
|
||||
- [#3360](https://github.com/farion1231/cc-switch/pull/3360):Codex 供应商切换时始终更新模型目录 JSON,感谢 [@Postroggy](https://github.com/Postroggy)。
|
||||
- [#3355](https://github.com/farion1231/cc-switch/pull/3355):原生余额 / Coding Plan 查询按 app 解析凭据,感谢 [@SiskonEmilia](https://github.com/SiskonEmilia)。
|
||||
- [#3405](https://github.com/farion1231/cc-switch/pull/3405):修复 Claude Desktop 官方供应商添加报错,感谢 [@Eunknight](https://github.com/Eunknight)。
|
||||
- [#3382](https://github.com/farion1231/cc-switch/pull/3382):Codex CLI 多平台发现与 GPT-5.5 模型模板兜底,感谢 [@chofuhoyu](https://github.com/chofuhoyu)。
|
||||
- [#3377](https://github.com/farion1231/cc-switch/pull/3377):Kimi / Moonshot 工具思考历史规范化,感谢 [@Neon-Wang](https://github.com/Neon-Wang)。
|
||||
- [#3360](https://github.com/farion1231/cc-switch/pull/3360):Codex 供应商切换时始终更新模型目录 JSON,感谢 @Postroggy。
|
||||
- [#3355](https://github.com/farion1231/cc-switch/pull/3355):原生余额 / Coding Plan 查询按 app 解析凭据,感谢 @SiskonEmilia。
|
||||
- [#3405](https://github.com/farion1231/cc-switch/pull/3405):修复 Claude Desktop 官方供应商添加报错,感谢 @Eunknight。
|
||||
- [#3382](https://github.com/farion1231/cc-switch/pull/3382):Codex CLI 多平台发现与 GPT-5.5 模型模板兜底,感谢 @chofuhoyu。
|
||||
- [#3377](https://github.com/farion1231/cc-switch/pull/3377):Kimi / Moonshot 工具思考历史规范化,感谢 @Neon-Wang。
|
||||
|
||||
也感谢所有在 v3.16.0 发布后反馈 Codex OAuth、模型目录、本地路由接管和 Chat Completions 工具调用问题的用户。很多补丁都来自这些真实使用场景里的复现线索。
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
# CC Switch v3.16.2
|
||||
|
||||
> Following the v3.16.1 Codex stability patch, this release mainly broadens data portability and usage observability — adding S3-compatible cloud sync, OpenCode session usage sync, and an official-subscription quota template — while continuing to harden Codex's Chat Completions routing for third-party providers, fixing a batch of Windows / macOS platform issues, adding the CherryIN and ZenMux providers, and fully refreshing the trilingual user manual.
|
||||
|
||||
**[中文版 →](v3.16.2-zh.md) | [日本語版 →](v3.16.2-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Usage Guides
|
||||
|
||||
This release adds an S3 backend for cloud sync and more usage data sources. If you want to use them, start with these docs:
|
||||
|
||||
- **[Settings](../user-manual/en/1-getting-started/1.5-settings.md)**: configure cloud sync (WebDAV / S3-compatible storage) on the settings page to back up and restore providers, MCP, prompts, skills, and other config across multiple devices.
|
||||
- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: understand the Usage Dashboard's data sources (proxy logs, Codex / Gemini / OpenCode session sync) and how the statistics are counted.
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## Only Official Channels (Please Read)
|
||||
>
|
||||
> CC Switch is a **fully free and open-source** desktop app, and we **do not charge users any fees**. Please only obtain the software through the official channels listed below:
|
||||
>
|
||||
> | Channel | Only Official |
|
||||
> | ------------------ | ------------------------------------------------------------------------------ |
|
||||
> | Website | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | Source | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | Downloads | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | Author | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | Report an Imposter | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **Any "CC Switch" website or client that asks you for payment, top-ups, or login credentials is fake.** If you have been tricked into paying, stop the transaction immediately and file a report through GitHub Issues.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.16.2 is a maintenance update following v3.16.1. After the previous release focused on the security of Codex official authentication and local routing takeover, this release concentrates on two things. First, broadening data portability and usage observability — adding S3-compatible cloud sync (a second cloud-backup backend alongside WebDAV), OpenCode session usage sync, and a quota-statistics template for official subscriptions. Second, continuing to polish the edges exposed when Codex routes third-party providers through Chat Completions — stream-truncation detection, `tool_choice` when tools is empty, custom-tool metadata, reasoning-token statistics, file / audio attachment conversion, and more.
|
||||
|
||||
This release also fixes a batch of local proxy robustness issues (ephemeral port resolution, the takeover placeholder restore loop, Anthropic `system` message normalization, the upstream 413 message, and Claude Desktop's `[1m]` model routing), addresses several Windows / macOS platform experience issues, adds the CherryIN and ZenMux providers, and fully refreshes the trilingual user manual.
|
||||
|
||||
**Release date**: 2026-06-07
|
||||
|
||||
**Stats**: 41 commits | 132 files changed | +11,116 / -1,636 lines
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **S3-compatible cloud sync**: adds S3-compatible object storage as a second cloud-backup backend alongside WebDAV, with one-click presets for AWS S3, MinIO, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS, Huawei OBS, and more.
|
||||
- **More usage data sources**: added OpenCode session usage sync, plus an official-subscription quota template for Claude / Codex / Gemini official providers (explicit toggle, off by default).
|
||||
- **Continued Codex Chat Completions routing hardening**: fixed stream-truncation misdetection, `tool_choice` rejection when tools is empty, custom-tool metadata loss, and missing reasoning-token stats, and added file / audio attachment conversion plus a `/v1/models` reachability endpoint.
|
||||
- **A more robust local proxy**: fixed ephemeral port (port 0) resolution, the takeover placeholder restore loop, Anthropic `system` message normalization, the upstream 413 message, and Claude Desktop 1M-context model routing.
|
||||
- **Platform and providers**: fixed Windows tray / taskbar icons, subdirectory skill updates, and macOS input auto-capitalization, and added the CherryIN and ZenMux providers.
|
||||
|
||||
---
|
||||
|
||||
## Added
|
||||
|
||||
### S3-Compatible Cloud Sync
|
||||
|
||||
Cloud Sync now supports S3-compatible object storage as a second backend alongside WebDAV, signing requests with a self-implemented AWS Signature V4 for the broadest possible compatibility. The settings page offers one-click presets for AWS S3, MinIO, Cloudflare R2, Alibaba Cloud OSS, Tencent Cloud COS, Huawei OBS, and a custom endpoint, with connection testing, manual upload / download, and auto-sync on configuration changes (the providers, endpoint, MCP, prompt, skill, settings, and proxy tables — **not** high-frequency data like usage logs). Enabling S3 sync disables a running WebDAV sync and vice versa (#1351).
|
||||
|
||||
### OpenCode Session Usage Sync
|
||||
|
||||
Added OpenCode as a usage-statistics source that reads per-message token, cost, and model data from OpenCode's local SQLite database and imports it into the usage records, with a dedicated "OpenCode" app filter tab and an "OpenCode Session" data-source label. The database path respects `OPENCODE_DB` and `XDG_DATA_HOME` (defaulting to `~/.local/share/opencode` on all platforms), only finalized messages are imported, and the freshness check includes the WAL file so just-written sessions are not skipped (#3215).
|
||||
|
||||
### Official Subscription Quota Template
|
||||
|
||||
Because some users were concerned that the IP issuing the usage query could differ from the IP issuing in-app requests, risking an account ban, the official-subscription usage template for Claude / Codex / Gemini official providers is now an explicit, opt-in template that queries plan quota via CLI / OAuth credentials, replacing the previous implicit auto-query for official providers. The template is off by default, is enabled from the usage-script modal, and supports a configurable refresh interval. When using this feature, enabling the proxy's TUN mode is recommended.
|
||||
|
||||
### Text-Only Model Image Fallback Rectifier
|
||||
|
||||
Added a proxy rectifier that replaces Anthropic image blocks with an `[Unsupported Image]` placeholder when the routed model is text-only (declared, or detected by a built-in model-name heuristic) or the upstream rejects image input, so conversations are not interrupted. The settings page provides a toggle for this fallback, plus a separate toggle for the heuristic detection (which can be turned off to avoid misjudging multimodal models).
|
||||
|
||||
### ZenMux Token Plan Provider
|
||||
|
||||
Added ZenMux as a Token Plan Coding Plan provider. You can manually enter its API key and base URL in the usage-script modal, and it renders used / quota in USD (#2709).
|
||||
|
||||
### CherryIN Preset
|
||||
|
||||
Added the CherryIN aggregator gateway as a quick-config preset across all 7 managed apps — Claude Code / Claude Desktop / OpenClaw / Hermes use the Anthropic-format endpoint (open.cherryin.net), OpenCode uses `@ai-sdk/anthropic` (`/v1`), Codex uses the OpenAI-compatible endpoint, and Gemini CLI uses the Gemini-compatible endpoint — with the official brand icon, placed next to AiHubMix (#3643).
|
||||
|
||||
### Codex CLI Reachability Endpoint `/v1/models`
|
||||
|
||||
The local proxy now responds to `GET /v1/models`, which Codex CLI probes at startup, returning the CC Switch-managed Codex model catalog. A stale-catalog guard was added: it parses the live `config.toml` and only serves the catalog when `model_catalog_json` still points at the CC Switch-owned catalog file, avoiding exposing a previous provider's leftover catalog to Codex (#3818).
|
||||
|
||||
### Codex Chat File and Audio Attachments
|
||||
|
||||
Codex's Responses→Chat conversion now maps `input_file` parts (carrying `file_id` or inline `file_data`) and `input_audio` parts into their Chat Completions equivalents, and emits top-level `input_*` items that were previously dropped, so file and audio attachments reach Chat-only Codex upstreams.
|
||||
|
||||
---
|
||||
|
||||
## Changed
|
||||
|
||||
### Usage Dashboard Hero Redesign
|
||||
|
||||
Rearranged the Usage Dashboard hero and summary cards into a more compact layout, consolidating the real-token total, request count, and cost into a single top row (#3426).
|
||||
|
||||
### SSSAiCode Endpoint Refresh
|
||||
|
||||
Updated the SSSAiCode preset's website, signup, and API base URLs to the `sssaicodeapi.com` domain, and refreshed its candidate endpoint nodes (default `node-hk.sssaicodeapi.com`, plus `node-hk.sssaiapi.com` and `node-cf.sssaicodeapi.com`) across all 7 app presets.
|
||||
|
||||
---
|
||||
|
||||
## Fixed
|
||||
|
||||
### Codex Chat Stream Truncation Detection
|
||||
|
||||
When a Chat Completions upstream ends a stream without a `finish_reason` or `[DONE]`, CC Switch no longer treats it as a normal completion: it finalizes normally only when the stream truly ended; emits an incomplete (`max_output_tokens`) response when partial output was produced; and emits a failed `stream_truncated` event when nothing was produced. Late-arriving reasoning is also backfilled onto still-active streaming tool calls.
|
||||
|
||||
### Codex Chat `tool_choice` Without Tools
|
||||
|
||||
The Responses→Chat conversion now drops `tool_choice` and `parallel_tool_calls` when the final tools array is missing or empty (including when all tools are filtered out), avoiding 503/400 errors from strict OpenAI-compatible upstreams (vLLM, enterprise gateways) with "When using `tool_choice`, `tools` must be set." (#3640).
|
||||
|
||||
### Codex Custom Tool Metadata Preserved
|
||||
|
||||
Custom Codex tools (such as the freeform `apply_patch` tool) now embed their full original definition — including format and grammar metadata — as a compact, order-stable JSON block in the generated Chat function description, instead of being replaced with a generic placeholder, so they remain usable on Chat Completions upstreams (#3644).
|
||||
|
||||
### Codex Chat Usage Missing `reasoning_tokens`
|
||||
|
||||
The Chat→Responses usage conversion now always includes `output_tokens_details.reasoning_tokens` (defaulting to 0), even when a provider omits `completion_tokens_details` or returns a non-object, satisfying Codex CLI's strict requirement and avoiding repeated response-parse failures and retries (#3514).
|
||||
|
||||
### Cross-Turn Reasoning for Codex Custom / Search Tools
|
||||
|
||||
The cross-turn reasoning cache in Codex Chat history now covers the full tool-call set (`function_call`, `custom_tool_call`, `tool_search_call`) and their outputs, not just plain function calls, so `apply_patch` and tool-search calls keep their own `reasoning_content` when restored via `previous_response_id`.
|
||||
|
||||
### Ephemeral Port (port 0) Resolution
|
||||
|
||||
When the proxy is configured to listen on port 0 (OS-assigned), takeover now starts the proxy first to obtain the real port before writing live configs and the database, avoiding client URLs pointing at an invalid `:0` address; if no concrete port has been resolved yet, the Claude Desktop gateway URL is rejected outright.
|
||||
|
||||
### Proxy Placeholder Backup / Restore Loop
|
||||
|
||||
If a previous proxy stop failed to restore the original live config and left proxy placeholders in live, taking over again no longer overwrites the good backup with the proxy config, and restore no longer writes the placeholder back to live: both paths detect the placeholder state and rebuild live from the current provider as the source of truth, fixing cases where the proxy toggle became a no-op and the client was pinned to the local proxy address (#3689).
|
||||
|
||||
### Provider Switching Wrongly Blocked During Proxy Takeover
|
||||
|
||||
During local routing takeover, only providers explicitly classified as official are now blocked from switching, instead of also disabling custom providers whose endpoint lives in meta or whose fields are simply unfilled. The disabled "Enable" button now shows a lighter hint tooltip instead of the previous red "Blocked" badge.
|
||||
|
||||
### localhost Listen Address Normalization
|
||||
|
||||
When saving the proxy with a listen address of `localhost`, it is now normalized to `127.0.0.1` before persisting, avoiding binding inconsistencies (#3016).
|
||||
|
||||
### Anthropic `system` Message Normalization
|
||||
|
||||
For Anthropic-format providers, system-role entries inside the `messages` array are now collapsed and merged into the top-level `system` field (preserving original order and any existing top-level system), avoiding strict upstreams rejecting non-leading system messages; OpenAI Chat routing is unaffected (#3775).
|
||||
|
||||
### Claude Desktop 1M-Context Model Routing
|
||||
|
||||
Claude Desktop appends a `[1m]` marker to the model name when the 1M-context beta is active (e.g. `claude-opus-4-8[1m]`). The proxy now strips that suffix before route matching so exact, alias, legacy, and role-keyword matching all resolve correctly, fixing `route_unknown` (HTTP 400) failures when switching to a 1M model mid-conversation; the original model name is still kept in the `route_unknown` error for diagnostics.
|
||||
|
||||
### Codex 413 Error Message
|
||||
|
||||
When a Codex upstream gateway rejects an oversized request body with HTTP 413, the proxy now returns a dedicated message explaining that this is the provider's server-side body-size limit (not a CC Switch local limit), with actionable recovery steps (run `/compact`, remove large logs or inline images, or ask the provider to raise the limit), instead of echoing the upstream's raw HTML error page.
|
||||
|
||||
### Proxy Panel Error Detail
|
||||
|
||||
When toggling proxy takeover fails, the proxy panel toast now includes the specific error detail returned by the backend, instead of only a generic failure message (#3656).
|
||||
|
||||
### Copilot Infinite-Whitespace Threshold
|
||||
|
||||
Raised the streaming infinite-whitespace abort threshold from 20 to 500 consecutive whitespace characters, avoiding false aborts of legitimate tool calls whose arguments contain deeply indented code (Python, YAML, Rust, Markdown), while still catching the real Copilot infinite-whitespace bug (#2647).
|
||||
|
||||
### Subscription Tier Tray Rendering
|
||||
|
||||
Via a unified tier-to-label mapping, fixed rendering of official subscription tiers in the tray and quota display: Claude / Codex no longer drop the 7-day window, Gemini Pro / Flash / Flash-Lite tiers no longer leak raw machine names, and multi-window plans (e.g. Opus + Sonnet) now show the worst utilization instead of the first match.
|
||||
|
||||
### Inflated Claude Stream input_tokens
|
||||
|
||||
Some Anthropic-compatible streaming providers (e.g. Qwen, MiniMax) report the full context as `input_tokens` in `message_start`, double-counting the cached portion already reported separately and artificially lowering the displayed cache hit rate. The parser now prefers the smaller positive `input_tokens` from `message_delta` and adopts the paired cache counts from the same usage block; native Claude and OpenRouter-converted paths are unchanged.
|
||||
|
||||
### Zhipu Quota Query Endpoint Routing
|
||||
|
||||
The Zhipu Coding Plan quota query was hard-coded to `api.z.ai`, so users on the mainland preset (`open.bigmodel.cn`) could not retrieve usage when the international endpoint was unreachable. The quota request now routes to the host matching the user's configured base URL (#3702).
|
||||
|
||||
### MiniMax Balance API and Pricing
|
||||
|
||||
Adapted MiniMax Coding Plan quota to its new balance API (which returns remaining-percent fields instead of the usage counts the old parser relied on, which left tiers empty and the tray showing no usage), filtered out non-coding models (such as video), handled plans without a weekly limit, and added default pricing for the MiniMax M3 model (#3518).
|
||||
|
||||
### GLM Coding Plan Endpoints and Model Fetch
|
||||
|
||||
Fixed the Zhipu / Z.AI GLM Coding Plan presets to the `/api/coding/paas/v4` endpoints (covering Codex, OpenCode, OpenClaw, Hermes), and made the model-list probe query `{base}/models` first for base URLs that already end in a `/v{N}` version segment (keeping `/v1/models` as a fallback), so the "Fetch models" button no longer 404s on versioned endpoints (#3524).
|
||||
|
||||
### Codex Model Catalog Path Portability
|
||||
|
||||
Codex now writes only the relative filename `cc-switch-model-catalog.json` to `config.toml` instead of an absolute path (Codex CLI resolves it from the config directory), fixing the model catalog breaking on WSL and symlinked setups where the absolute path could not be translated (#3614).
|
||||
|
||||
### APINebula's OpenCode SDK
|
||||
|
||||
The APINebula OpenCode preset now loads `@ai-sdk/openai-compatible` instead of `@ai-sdk/openai`, so requests use the OpenAI Chat Completions format the relay expects, rather than the Responses API that fails against chat-completions-only upstreams.
|
||||
|
||||
### Windows Tray Icon Residue After Exit
|
||||
|
||||
On Windows, quitting CC Switch could leave a dead tray icon behind until the mouse passed over it. The app now explicitly removes the tray icon before exiting, so it disappears cleanly when the process ends (#3797).
|
||||
|
||||
### Windows Taskbar Icon
|
||||
|
||||
Sets an explicit Windows AppUserModelID at runtime and writes the same ID and product icon onto the installer's desktop and start-menu shortcuts, so CC Switch shows the correct icon and groups properly in the taskbar (#3457).
|
||||
|
||||
### Windows Update Check for Subdirectory Skills
|
||||
|
||||
When scanning installed skills on Windows, backslash path separators are now normalized to forward slashes, so skills nested in subdirectories (e.g. `skills/my-skill`) are matched by the update check instead of being silently skipped (#3430).
|
||||
|
||||
### macOS Input Auto-Capitalization
|
||||
|
||||
Disabled autocomplete, autocorrect, autocapitalize, and spellcheck on the shared text Input component, so macOS no longer auto-capitalizes or auto-corrects the first letter typed into configuration fields (#3626).
|
||||
|
||||
### Codex VS Code Session Previews
|
||||
|
||||
For Codex requests sent from VS Code, the session preview could show selection or open-file content instead of the real prompt when a markdown heading preceded the injected request. The backend title and frontend preview now both match the last "## My request for Codex:" heading (the IDE injects the real request as the final section), so the preview reflects the user's prompt (#3593).
|
||||
|
||||
### VS Code Wording in the Chinese UI
|
||||
|
||||
Corrected the "Apply to Claude Code plugin" description in Simplified and Traditional Chinese to write "VS Code" properly instead of "Vscode", aligning with the English and Japanese strings (#3228).
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
### User Manual Refresh
|
||||
|
||||
Refreshed the README localizations and the en / zh / ja user manuals to reflect all 7 managed apps (adding Claude Desktop and Hermes to the intro and overview copy), corrected the OpenCode config path to `~/.config/opencode/` (`opencode.json`), documented Hermes config files, updated the language docs to four languages, corrected per-app MCP / prompt / skill support, noted that export now produces a timestamped SQL backup that includes usage logs, and documented the pricing model-ID matching rules (#3411).
|
||||
|
||||
### Codex Official Auth Preservation Guide
|
||||
|
||||
Added a Chinese / English / Japanese guide explaining how to keep Codex official remote control and official plugins working while routing model traffic to third-party APIs, and linked it from the v3.16.1 release notes.
|
||||
|
||||
### README Links and Sponsor Markup
|
||||
|
||||
Updated the Release Notes links in each language README to v3.16.1, and fixed broken curly-quote characters in the README_ZH sponsor blocks so their HTML attributes render correctly (#3772).
|
||||
|
||||
---
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
### S3 and WebDAV Cloud Sync Are Mutually Exclusive
|
||||
|
||||
Cloud Sync runs only one backend at a time. Enabling S3 auto-sync disables a running WebDAV auto-sync and vice versa. If you previously used WebDAV, make sure both ends are aligned before switching to S3, so you don't assume the old backend is still backing up.
|
||||
|
||||
### Restart Codex After Editing Model Mappings
|
||||
|
||||
Codex reads `model_catalog_json` at startup. Even though this release rewrites the model catalog to a relative path and adds the `/v1/models` reachability endpoint, you still need to restart Codex after editing the model mapping table for the `/model` menu to refresh.
|
||||
|
||||
---
|
||||
|
||||
## Risk Notice
|
||||
|
||||
This release continues the risk notices from previous versions for reverse-proxy-style features.
|
||||
|
||||
**Codex OAuth reverse proxy**: using a ChatGPT subscription's Codex OAuth through a reverse proxy may violate OpenAI's terms of service. See the [v3.13.0 release notes](v3.13.0-en.md#️-risk-notice) for details.
|
||||
|
||||
**Codex third-party provider Chat routing**: when CC Switch local proxy converts and forwards Codex requests to third-party providers, each provider may have different requirements for billing, compliance, and data retention. Read the target provider's terms before use.
|
||||
|
||||
**Claude Desktop third-party provider proxy switching**: when CC Switch's built-in proxy gateway forwards Claude Desktop requests to third-party providers, you must also follow the target provider's billing, compliance, and data-retention terms.
|
||||
|
||||
By enabling these features, users accept the related risks. CC Switch is not responsible for account restrictions, warnings, or service suspensions caused by using these features.
|
||||
|
||||
---
|
||||
|
||||
## Thanks
|
||||
|
||||
Thanks to the following contributors for the features and fixes in v3.16.2:
|
||||
|
||||
- [#1351](https://github.com/farion1231/cc-switch/pull/1351): add S3-compatible cloud storage sync, thanks @keithyt06.
|
||||
- [#3215](https://github.com/farion1231/cc-switch/pull/3215): add OpenCode session usage sync, thanks @nothingness0db.
|
||||
- [#2709](https://github.com/farion1231/cc-switch/pull/2709): add the ZenMux Token Plan provider, thanks @Eter365.
|
||||
- [#3643](https://github.com/farion1231/cc-switch/pull/3643): add the CherryIN preset provider, thanks @zhibisora.
|
||||
- [#3818](https://github.com/farion1231/cc-switch/pull/3818): add the Codex CLI reachability `GET /v1/models` endpoint, thanks @CSberlin.
|
||||
- [#3426](https://github.com/farion1231/cc-switch/pull/3426): Usage Dashboard hero redesign, thanks @allenxu09.
|
||||
- [#3640](https://github.com/farion1231/cc-switch/pull/3640): drop `tool_choice` when tools is empty, thanks @Postroggy.
|
||||
- [#3644](https://github.com/farion1231/cc-switch/pull/3644): preserve Codex custom tool metadata in chat routing, thanks @LanternCX.
|
||||
- [#3514](https://github.com/farion1231/cc-switch/pull/3514): always include `reasoning_tokens` in Chat→Responses, thanks @yeeyzy.
|
||||
- [#3689](https://github.com/farion1231/cc-switch/pull/3689): skip backup / restore when live is already a proxy placeholder, thanks @YongmaoLuo.
|
||||
- [#3016](https://github.com/farion1231/cc-switch/pull/3016): normalize the localhost listen address, thanks @Alexlangl.
|
||||
- [#3775](https://github.com/farion1231/cc-switch/pull/3775): normalize Anthropic `system` messages, thanks @Dearli666.
|
||||
- [#3656](https://github.com/farion1231/cc-switch/pull/3656): improve error message display in the proxy panel, thanks @lzcndm.
|
||||
- [#2647](https://github.com/farion1231/cc-switch/pull/2647): raise the infinite-whitespace threshold 20 → 500, thanks @NiuBlibing.
|
||||
- [#3702](https://github.com/farion1231/cc-switch/pull/3702): route the Zhipu quota query to the configured base URL, thanks @YongmaoLuo.
|
||||
- [#3518](https://github.com/farion1231/cc-switch/pull/3518): adapt to the MiniMax new balance API and default pricing, thanks @LaoYueHanNi.
|
||||
- [#3524](https://github.com/farion1231/cc-switch/pull/3524): fix the Zhipu Coding Plan presets and model probing for versioned endpoints, thanks @makoMakoGo.
|
||||
- [#3614](https://github.com/farion1231/cc-switch/pull/3614): use a relative filename for the model catalog, thanks @steponeerror.
|
||||
- [#3797](https://github.com/farion1231/cc-switch/pull/3797): fix the Windows tray icon residue after exit, thanks @iAJue.
|
||||
- [#3457](https://github.com/farion1231/cc-switch/pull/3457): fix the Windows taskbar icon, thanks @ZhangNanNan1018.
|
||||
- [#3430](https://github.com/farion1231/cc-switch/pull/3430): normalize Windows path separators to match subdirectory skill updates, thanks @Ninthless.
|
||||
- [#3626](https://github.com/farion1231/cc-switch/pull/3626): disable macOS input auto-capitalization, thanks @ZHLHZHU.
|
||||
- [#3593](https://github.com/farion1231/cc-switch/pull/3593): fix Codex VS Code session previews, thanks @xwil1.
|
||||
- [#3228](https://github.com/farion1231/cc-switch/pull/3228): align the VS Code wording in the Chinese UI, thanks @Games55k.
|
||||
- [#3411](https://github.com/farion1231/cc-switch/pull/3411): refresh the user manual to reflect current app support, thanks @makoMakoGo.
|
||||
- [#3772](https://github.com/farion1231/cc-switch/pull/3772): fix README release-note links and sponsor markup, thanks @null-easy.
|
||||
|
||||
Thanks also to everyone who reported Codex Chat routing, local proxy takeover, usage statistics, and platform compatibility issues after v3.16.1. Many of these fixes came directly from real-world reproduction details.
|
||||
|
||||
---
|
||||
|
||||
## Download & Install
|
||||
|
||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) and download the build for your system.
|
||||
|
||||
### System Requirements
|
||||
|
||||
| System | Minimum Version | Architecture |
|
||||
| ------- | ------------------------ | ----------------------------------- |
|
||||
| Windows | Windows 10 and later | x64 |
|
||||
| macOS | macOS 12 (Monterey)+ | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | See table below | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------------------- | ------------------------------------------------ |
|
||||
| `CC-Switch-v3.16.2-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||
| `CC-Switch-v3.16.2-Windows-Portable.zip` | Portable build, unzip and run |
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| -------------------------------- | ----------------------------------------------------- |
|
||||
| `CC-Switch-v3.16.2-macOS.dmg` | **Recommended** - DMG installer, drag to Applications |
|
||||
| `CC-Switch-v3.16.2-macOS.zip` | Unzip and drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.16.2-macOS.tar.gz` | For Homebrew install and auto-update |
|
||||
|
||||
Homebrew install:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Upgrade:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux assets are available for both **x86_64** and **ARM64** (`aarch64`). Choose the file whose architecture tag matches your machine's `uname -m` output:
|
||||
|
||||
- `CC-Switch-v3.16.2-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.16.2-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| Distribution | Recommended Format | Install Command |
|
||||
| --------------------------------------- | ------------------ | --------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | Make executable and run directly, or use AUR |
|
||||
| Other distributions / unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,347 @@
|
||||
# CC Switch v3.16.2
|
||||
|
||||
> v3.16.1 の Codex 安定性パッチに続き、本リリースはデータの可搬性と用量の可観測性の拡張を主眼としています。S3 互換クラウド同期、OpenCode セッション用量同期、公式サブスクリプション残量テンプレートを追加し、Codex がサードパーティプロバイダーを Chat Completions ルーティングする際の堅牢性を引き続き強化しました。あわせて Windows / macOS のプラットフォーム問題を一括修正し、CherryIN・ZenMux プロバイダーを追加し、3 言語のユーザーマニュアルを全面的に刷新しました。
|
||||
|
||||
**[English →](v3.16.2-en.md) | [中文 →](v3.16.2-zh.md)**
|
||||
|
||||
---
|
||||
|
||||
## 利用ガイド
|
||||
|
||||
本リリースではクラウド同期の S3 バックエンドと、より多くの用量統計ソースを追加しました。利用したい場合は、まず以下のドキュメントをご覧ください:
|
||||
|
||||
- **[設定](../user-manual/ja/1-getting-started/1.5-settings.md)**: 設定ページでクラウド同期(WebDAV / S3 互換ストレージ)を構成し、プロバイダー、MCP、プロンプト、スキルなどの設定を複数デバイス間でバックアップ・復元します。
|
||||
- **[用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**: 用量ダッシュボードのデータソース(プロキシログ、Codex / Gemini / OpenCode セッション同期)と統計の数え方を確認できます。
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## 唯一の公式チャネル(必ずお読みください)
|
||||
>
|
||||
> CC Switch は**完全に無料・オープンソース**のデスクトップアプリで、**ユーザーから料金を徴収することはありません**。本ソフトウェアは下記の公式チャネルからのみ入手してください:
|
||||
>
|
||||
> | チャネル | 唯一の公式 |
|
||||
> | ------------ | ------------------------------------------------------------------------------ |
|
||||
> | 公式サイト | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | ソースコード | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | ダウンロード | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | 偽サイト通報 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **料金請求・チャージ・認証情報の提供を求める「CC Switch」サイトやクライアントはすべて偽物です。** 支払いを誘導された場合は直ちに操作を中止し、GitHub Issues からご報告ください。
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.16.2 は v3.16.1 に続くメンテナンスアップデートです。前リリースでは Codex 公式認証とローカルルーティングのテイクオーバーのセキュリティ問題に集中しましたが、本リリースは 2 点に重きを置いています。1 つ目はデータの可搬性と用量の可観測性の拡張で、S3 互換クラウド同期(WebDAV に並ぶ 2 つ目のクラウドバックアップバックエンド)、OpenCode セッション用量同期、公式サブスクリプション向けの残量統計テンプレートを追加しました。2 つ目は、Codex がサードパーティプロバイダーを Chat Completions ルーティングする際に露呈したエッジケースの継続的な改善で、ストリーム切断の判定、tools が空のときの `tool_choice`、カスタムツールのメタデータ、推論トークン統計、ファイル / 音声添付の変換などです。
|
||||
|
||||
本リリースではローカルプロキシの堅牢性に関する問題(一時ポートの解決、テイクオーバーのプレースホルダー復元ループ、Anthropic `system` メッセージの正規化、上流 413 の文言、Claude Desktop の `[1m]` モデルルーティング)を一括修正し、いくつかの Windows / macOS のプラットフォーム体験の問題に対処し、CherryIN・ZenMux の 2 プロバイダーを追加し、3 言語のユーザーマニュアルを全面的に刷新しました。
|
||||
|
||||
**リリース日**: 2026-06-07
|
||||
|
||||
**Stats**: 41 commits | 132 files changed | +11,116 / -1,636 lines
|
||||
|
||||
---
|
||||
|
||||
## ハイライト
|
||||
|
||||
- **S3 互換クラウド同期**: WebDAV に並ぶ 2 つ目のクラウドバックアップバックエンドとして S3 互換オブジェクトストレージを追加。AWS S3、MinIO、Cloudflare R2、Alibaba Cloud OSS、Tencent Cloud COS、Huawei OBS などのワンクリックプリセットを内蔵します。
|
||||
- **より多くの用量データソース**: OpenCode セッション用量同期と、Claude / Codex / Gemini 公式プロバイダー向けの公式サブスクリプション残量テンプレート(明示的なトグル、デフォルトでオフ)を追加しました。
|
||||
- **Codex Chat Completions ルーティングの継続的な強化**: ストリーム切断の誤判定、tools が空のときの `tool_choice` 拒否、カスタムツールメタデータの欠落、推論トークン統計の欠落を修正し、ファイル / 音声添付の変換と `/v1/models` 到達性エンドポイントを追加しました。
|
||||
- **より堅牢なローカルプロキシ**: 一時ポート(port 0)の解決、テイクオーバーのプレースホルダー復元ループ、Anthropic `system` メッセージの正規化、上流 413 の文言、Claude Desktop の 1M コンテキストモデルルーティングを修正しました。
|
||||
- **プラットフォームとプロバイダー**: Windows のトレイ / タスクバーアイコン、サブディレクトリスキルの更新、macOS の入力自動大文字化を修正し、CherryIN・ZenMux プロバイダーを追加しました。
|
||||
|
||||
---
|
||||
|
||||
## 追加機能
|
||||
|
||||
### S3 互換クラウド同期
|
||||
|
||||
クラウド同期は WebDAV に並ぶ 2 つ目のバックエンドとして S3 互換オブジェクトストレージに対応しました。署名は自前実装の AWS Signature V4 を用い、できるだけ多くのサービスと互換性を持たせています。設定ページでは AWS S3、MinIO、Cloudflare R2、Alibaba Cloud OSS、Tencent Cloud COS、Huawei OBS、およびカスタム endpoint のワンクリックプリセットを提供し、接続テスト、手動アップロード / ダウンロード、設定変更時の自動同期(providers、endpoint、MCP、プロンプト、スキル、設定、プロキシなどの設定テーブル。用量ログのような高頻度書き込みデータは**含みません**)に対応します。S3 同期を有効化すると、実行中の WebDAV 同期は停止し、その逆も同様です(#1351)。
|
||||
|
||||
### OpenCode セッション用量同期
|
||||
|
||||
OpenCode を用量統計のソースとして追加しました。OpenCode のローカル SQLite データベースからメッセージごとの token、コスト、モデルのデータを読み取り、用量レコードへインポートします。専用の「OpenCode」アプリフィルタタブと「OpenCode Session」データソースラベルを備えます。データベースパスは `OPENCODE_DB` と `XDG_DATA_HOME` を尊重し(全プラットフォームで既定は `~/.local/share/opencode`)、完了済みのメッセージのみをインポートし、新鮮度判定で WAL ファイルも含めるため、書き込み直後のセッションがスキップされません(#3215)。
|
||||
|
||||
### 公式サブスクリプション残量テンプレート
|
||||
|
||||
用量照会を発行する IP とアプリ内リクエストを発行する IP が異なるとアカウント停止のリスクがある、という一部ユーザーの懸念を受けて、Claude / Codex / Gemini 公式プロバイダー向けに、CLI / OAuth 認証情報でプラン残量を照会する明示的・任意の「公式サブスクリプション」用量テンプレートを追加し、これまでの公式プロバイダーに対する暗黙の自動照会を置き換えました。このテンプレートはデフォルトでオフで、用量スクリプトのモーダルから有効化でき、更新間隔を設定できます。本機能を利用する際は、プロキシの TUN モードを有効化することを推奨します。
|
||||
|
||||
### テキスト専用モデルの画像フォールバック整流器
|
||||
|
||||
ルーティング先のモデルがテキスト専用(明示的な宣言、または内蔵のモデル名ヒューリスティックで判定)の場合、または上流が画像入力を拒否する場合に、Anthropic の画像ブロックを `[Unsupported Image]` プレースホルダーへ置き換えるプロキシ整流器を追加し、会話の中断を防ぎます。設定ページにこのフォールバックのトグルを用意し、さらにヒューリスティック検出を制御する別のトグル(マルチモーダルモデルの誤判定を避けるためオフにできます)を用意しました。
|
||||
|
||||
### ZenMux Token Plan プロバイダー
|
||||
|
||||
ZenMux を Token Plan 系の Coding Plan プロバイダーとして追加しました。用量スクリプトのモーダルで API key と base URL を手動入力でき、使用量 / 残量を米ドル建てでリッチに表示します(#2709)。
|
||||
|
||||
### CherryIN プリセット
|
||||
|
||||
CherryIN アグリゲーターゲートウェイをクイック設定プリセットとして、受管 7 アプリすべてに追加しました。Claude Code / Claude Desktop / OpenClaw / Hermes は Anthropic 形式の endpoint(open.cherryin.net)、OpenCode は `@ai-sdk/anthropic`(`/v1`)、Codex は OpenAI 互換 endpoint、Gemini CLI は Gemini 互換 endpoint を使用します。公式ブランドアイコン付きで、AiHubMix の隣に配置されます(#3643)。
|
||||
|
||||
### Codex CLI 到達性エンドポイント `/v1/models`
|
||||
|
||||
ローカルプロキシは、Codex CLI が起動時にプローブする `GET /v1/models` に応答し、CC Switch が管理する Codex モデルカタログを返すようになりました。あわせて古いカタログのガードを追加: live の `config.toml` を解析し、`model_catalog_json` が CC Switch 所有のカタログファイルを指している場合のみ提供することで、前のプロバイダーが残したカタログを Codex に見せてしまうことを防ぎます(#3818)。
|
||||
|
||||
### Codex Chat のファイル・音声添付
|
||||
|
||||
Codex の Responses→Chat 変換は、`input_file`(`file_id` またはインライン `file_data` を持つ)と `input_audio` のコンテンツ部分を Chat Completions の対応形態へマッピングし、これまで破棄されていたトップレベルの `input_*` 項目も出力するようになりました。これにより、ファイルと音声の添付が Chat のみ対応の Codex 上流へ届きます。
|
||||
|
||||
---
|
||||
|
||||
## 変更
|
||||
|
||||
### 用量ダッシュボードのヒーロー再設計
|
||||
|
||||
用量ダッシュボードのヒーロー領域とサマリーカードをよりコンパクトなレイアウトに再構成し、実トークン総量、リクエスト数、コストを最上部の 1 行にまとめました(#3426)。
|
||||
|
||||
### SSSAiCode エンドポイント刷新
|
||||
|
||||
SSSAiCode プリセットの公式サイト、登録、API base URL を `sssaicodeapi.com` ドメインへ更新し、endpoint 候補ノード(既定 `node-hk.sssaicodeapi.com`、ほかに `node-hk.sssaiapi.com` と `node-cf.sssaicodeapi.com`)を全 7 アプリのプリセットで刷新しました。
|
||||
|
||||
---
|
||||
|
||||
## 修正
|
||||
|
||||
### Codex Chat ストリーム切断の判定
|
||||
|
||||
Chat Completions 上流が `finish_reason` も `[DONE]` もなくストリームを終了した場合、CC Switch はこれを正常完了として扱わなくなりました: 本当に終了したときのみ正常に締め、部分的な出力があった場合は incomplete(`max_output_tokens`)レスポンスを、何も出力されなかった場合は失敗 `stream_truncated` イベントを発行します。遅れて届いた推論も、まだアクティブなストリーミングのツール呼び出しへバックフィルされます。
|
||||
|
||||
### tools が空のときの Codex Chat `tool_choice`
|
||||
|
||||
Responses→Chat 変換は、最終的な tools 配列が欠落または空(すべてのツールがフィルタで除外された場合を含む)のときに `tool_choice` と `parallel_tool_calls` を破棄するようになりました。これにより、厳格な OpenAI 互換上流(vLLM、エンタープライズゲートウェイ)が「When using `tool_choice`, `tools` must be set.」で 503/400 を返すことを避けます(#3640)。
|
||||
|
||||
### Codex カスタムツールメタデータの保持
|
||||
|
||||
カスタム Codex ツール(自由形式の `apply_patch` ツールなど)は、汎用プレースホルダーへ置き換えられる代わりに、format と grammar のメタデータを含む完全な元定義を、生成される Chat 関数の説明にコンパクトで順序の安定した JSON ブロックとして埋め込むようになりました。これにより Chat Completions 上流でも引き続き利用できます(#3644)。
|
||||
|
||||
### Codex Chat 用量の `reasoning_tokens` 欠落
|
||||
|
||||
Chat→Responses の用量変換は、プロバイダーが `completion_tokens_details` を省略したり非オブジェクトを返したりしても、常に `output_tokens_details.reasoning_tokens`(既定 0)を含めるようになりました。これにより Codex CLI の厳格な要件を満たし、レスポンス解析の失敗と再試行の繰り返しを避けます(#3514)。
|
||||
|
||||
### Codex カスタム / 検索ツールのターン跨ぎ推論
|
||||
|
||||
Codex Chat 履歴のターン跨ぎ推論キャッシュが、通常の関数呼び出しだけでなく、ツール呼び出しの全集合(`function_call`、`custom_tool_call`、`tool_search_call`)とその出力をカバーするようになりました。これにより `apply_patch` とツール検索の呼び出しは、`previous_response_id` で復元されるときにそれぞれの `reasoning_content` を保持します。
|
||||
|
||||
### 一時ポート(port 0)の解決
|
||||
|
||||
プロキシが port 0(OS 割り当て)でリッスンするよう構成されている場合、テイクオーバーはまずプロキシを起動して実際のポートを取得してから live 設定とデータベースへ書き込むようになり、クライアント URL が無効な `:0` アドレスを指すことを避けます。具体的なポートがまだ解決されていない場合、Claude Desktop のゲートウェイ URL は拒否されます。
|
||||
|
||||
### プロキシプレースホルダーのバックアップ / 復元ループ
|
||||
|
||||
前回プロキシ停止時に元の live 設定の復元に失敗し、プロキシプレースホルダーが live に残ってしまった場合でも、再度テイクオーバーする際に正常なバックアップをプロキシ設定で上書きすることはなくなり、復元時にプレースホルダーを live へ書き戻すこともなくなりました: いずれの経路もプレースホルダー状態を検知し、現在のプロバイダーを信頼できる情報源として live を再構築します。これにより、プロキシのトグルが何もしない状態になり、クライアントがローカルプロキシアドレスに固定されてしまう問題を修正しました(#3689)。
|
||||
|
||||
### プロキシテイクオーバー中のプロバイダー切り替え誤ブロック
|
||||
|
||||
ローカルルーティングのテイクオーバー中、明示的に official と分類されたプロバイダーのみが切り替えをブロックされるようになり、endpoint が meta に存在する、またはフィールドが未入力なだけのカスタムプロバイダーまで無効化することはなくなりました。無効化された「有効化」ボタンは、以前の赤い「ブロック済み」バッジの代わりに、より軽いヒントのツールチップを表示します。
|
||||
|
||||
### localhost リッスンアドレスの正規化
|
||||
|
||||
プロキシのリッスンアドレスを `localhost` で保存した場合、永続化前に `127.0.0.1` へ正規化されるようになり、バインドの不整合を避けます(#3016)。
|
||||
|
||||
### Anthropic `system` メッセージの正規化
|
||||
|
||||
Anthropic 形式のプロバイダーでは、`messages` 配列内の system ロールのエントリを折りたたんでトップレベルの `system` フィールドへマージするようになり(元の順序と既存のトップレベル system を保持)、厳格な上流が先頭以外の system メッセージを拒否することを避けます。OpenAI Chat ルーティングは影響を受けません(#3775)。
|
||||
|
||||
### Claude Desktop 1M コンテキストモデルルーティング
|
||||
|
||||
Claude Desktop は 1M コンテキスト beta が有効なとき、モデル名に `[1m]` マーカーを付加します(例: `claude-opus-4-8[1m]`)。プロキシはルーティング照合の前にこの接尾辞を除去するようになり、完全一致・エイリアス・旧名・ロールキーワードの照合がすべて正しく解決されます。これにより、会話の途中で 1M モデルへ切り替えたときの `route_unknown`(HTTP 400)の失敗を修正しました。診断用に、`route_unknown` エラーには元のモデル名を引き続き保持します。
|
||||
|
||||
### Codex 413 エラーの文言
|
||||
|
||||
Codex 上流ゲートウェイが過大なリクエストボディを HTTP 413 で拒否したとき、プロキシはこれが CC Switch のローカル制限ではなくプロバイダーのサーバー側ボディサイズ制限であることを説明する専用メッセージを返し、実行可能な回復手順(`/compact` の実行、大きなログやインライン画像の削除、プロバイダーへの上限引き上げ依頼)を提示するようになりました。上流の生の HTML エラーページをそのまま返すことはなくなりました。
|
||||
|
||||
### プロキシパネルのエラー詳細
|
||||
|
||||
プロキシのテイクオーバー切り替えに失敗したとき、プロキシパネルのトーストは、汎用の失敗メッセージだけでなく、バックエンドが返す具体的なエラー詳細を含めるようになりました(#3656)。
|
||||
|
||||
### Copilot 無限空白検出のしきい値
|
||||
|
||||
ストリーミングの無限空白の中断しきい値を、連続する空白文字 20 から 500 へ引き上げました。これにより、引数に深くインデントされたコード(Python、YAML、Rust、Markdown)を含む正当なツール呼び出しが誤って中断されることを避けつつ、本物の Copilot 無限空白バグは引き続き捕捉します(#2647)。
|
||||
|
||||
### サブスクリプション階層のトレイ表示
|
||||
|
||||
統一された階層→ラベルのマッピングにより、トレイと残量表示における公式サブスクリプション階層の表示を修正しました: Claude / Codex は 7 日ウィンドウを取りこぼさなくなり、Gemini Pro / Flash / Flash-Lite の階層は生のマシン名を漏らさなくなり、複数ウィンドウのプラン(Opus + Sonnet など)は最初の一致ではなく最悪の利用率を表示するようになりました。
|
||||
|
||||
### Claude ストリームの input_tokens 過大計上
|
||||
|
||||
一部の Anthropic 互換ストリーミングプロバイダー(Qwen、MiniMax など)は `message_start` で完全なコンテキストを `input_tokens` として報告し、別途報告済みのキャッシュ分を二重計上して、表示上のキャッシュヒット率を不当に低下させていました。パーサーは `message_delta` のより小さい正の `input_tokens` を優先し、同じ usage ブロックのキャッシュカウントを採用するようになりました。ネイティブ Claude と OpenRouter 変換の経路は変更ありません。
|
||||
|
||||
### 智譜(Zhipu)残量照会の endpoint ルーティング
|
||||
|
||||
智譜 Coding Plan の残量照会は `api.z.ai` にハードコードされていたため、本土プリセット(`open.bigmodel.cn`)のユーザーは国際 endpoint が到達不能なときに用量を取得できませんでした。残量リクエストは、ユーザーが構成した base URL に一致するホストへルーティングされるようになりました(#3702)。
|
||||
|
||||
### MiniMax 残量 API と価格
|
||||
|
||||
MiniMax Coding Plan の残量を新しい残量 API に対応させました(新 API は、旧パーサーが依存していた用量カウント(階層が空になりトレイに用量が表示されなくなる)の代わりに、残り割合のフィールドを返します)。非コーディングモデル(動画など)を除外し、週次上限のないプランに対応し、MiniMax M3 モデルの既定価格を追加しました(#3518)。
|
||||
|
||||
### GLM Coding Plan の endpoint とモデル取得
|
||||
|
||||
智譜 / Z.AI の GLM Coding Plan プリセットを `/api/coding/paas/v4` endpoint に修正し(Codex、OpenCode、OpenClaw、Hermes をカバー)、すでに `/v{N}` のバージョンセグメントで終わる base URL については、モデル一覧プローブが `{base}/models` を先に照会するようにしました(`/v1/models` はフォールバックとして保持)。これにより「モデル取得」ボタンがバージョン付き endpoint で 404 にならなくなりました(#3524)。
|
||||
|
||||
### Codex モデルカタログパスの可搬性
|
||||
|
||||
Codex は `config.toml` に絶対パスではなく相対ファイル名 `cc-switch-model-catalog.json` のみを書き込むようになりました(Codex CLI は設定ディレクトリから解決します)。これにより、絶対パスを変換できない WSL やシンボリックリンク環境でモデルカタログが壊れる問題を修正しました(#3614)。
|
||||
|
||||
### APINebula の OpenCode SDK
|
||||
|
||||
APINebula の OpenCode プリセットは `@ai-sdk/openai` ではなく `@ai-sdk/openai-compatible` を読み込むようになり、chat-completions のみ対応の上流で失敗する Responses API ではなく、このリレーが期待する OpenAI Chat Completions 形式でリクエストを行います。
|
||||
|
||||
### Windows 終了後のトレイアイコン残留
|
||||
|
||||
Windows では CC Switch を終了すると、マウスを重ねるまで無効なトレイアイコンが残ることがありました。アプリは終了前にトレイアイコンを明示的に削除するようになり、プロセス終了とともにきれいに消えます(#3797)。
|
||||
|
||||
### Windows タスクバーアイコン
|
||||
|
||||
実行時に Windows AppUserModelID を明示的に設定し、インストーラーが生成するデスクトップとスタートメニューのショートカットに同じ ID と製品アイコンを書き込みます。これにより CC Switch がタスクバーで正しいアイコンを表示し、正しくグループ化されます(#3457)。
|
||||
|
||||
### Windows サブディレクトリスキルの更新チェック
|
||||
|
||||
Windows でインストール済みスキルをスキャンする際、バックスラッシュのパス区切りをスラッシュへ正規化するようになり、サブディレクトリにネストされたスキル(`skills/my-skill` など)が静かにスキップされず、更新チェックで一致するようになりました(#3430)。
|
||||
|
||||
### macOS の入力自動大文字化
|
||||
|
||||
共有のテキスト Input コンポーネントで autocomplete、autocorrect、autocapitalize、spellcheck を無効化し、macOS が設定フィールドに入力された最初の文字を自動で大文字化・自動修正しないようにしました(#3626)。
|
||||
|
||||
### Codex VS Code セッションプレビュー
|
||||
|
||||
VS Code から送信された Codex リクエストでは、注入されたリクエストの前に markdown 見出しがあると、セッションプレビューが本当のプロンプトではなく選択範囲や開いているファイルの内容を表示することがありました。バックエンドのタイトルとフロントエンドのプレビューはいずれも、最後の「## My request for Codex:」見出しに一致するようになり(IDE は本当のリクエストを最後のセクションとして注入します)、プレビューがユーザーのプロンプトを反映します(#3593)。
|
||||
|
||||
### 中国語 UI の VS Code 表記
|
||||
|
||||
簡体字・繁体字中国語の「Claude Code プラグインに適用」の説明を、「Vscode」ではなく正しく「VS Code」と表記するよう修正し、英語・日本語の文言と揃えました(#3228)。
|
||||
|
||||
---
|
||||
|
||||
## ドキュメント
|
||||
|
||||
### ユーザーマニュアル刷新
|
||||
|
||||
README の各言語版と en / zh / ja のユーザーマニュアルを刷新し、受管 7 アプリすべてを反映(紹介と概要の文面に Claude Desktop と Hermes を追加)、OpenCode の設定パスを `~/.config/opencode/`(`opencode.json`)に修正、Hermes の設定ファイルの説明を追加、言語ドキュメントを 4 言語に更新、アプリごとの MCP / プロンプト / スキルの対応状況を訂正、エクスポートがタイムスタンプ付きで用量ログを含む SQL バックアップを生成することを記載、価格モデル ID のマッチングルールを追記しました(#3411)。
|
||||
|
||||
### Codex 公式認証保持ガイド
|
||||
|
||||
モデル通信をサードパーティ API へ切り替えつつ、Codex の公式リモート操作と公式プラグインを動作させ続ける方法を説明する中国語 / 英語 / 日本語のガイドを追加し、v3.16.1 のリリースノートからリンクしました。
|
||||
|
||||
### README リンクとスポンサー表記
|
||||
|
||||
各言語の README のリリースノートリンクを v3.16.1 に更新し、README_ZH のスポンサーブロックで壊れていた曲線引用符文字を修正して、HTML 属性が正しくレンダリングされるようにしました(#3772)。
|
||||
|
||||
---
|
||||
|
||||
## アップグレード時の注意
|
||||
|
||||
### S3 と WebDAV のクラウド同期は排他
|
||||
|
||||
クラウド同期は同時に 1 つのバックエンドのみを実行します。S3 自動同期を有効化すると、実行中の WebDAV 自動同期は停止し、その逆も同様です。以前 WebDAV を使っていた場合は、S3 へ切り替える前に両端のデータが揃っていることを確認し、旧バックエンドがまだバックアップしていると誤解しないようにしてください。
|
||||
|
||||
### モデルマッピング変更後は Codex の再起動が必要
|
||||
|
||||
Codex は起動時に `model_catalog_json` を読み込みます。本リリースでモデルカタログを相対パスへ書き換え、`/v1/models` 到達性エンドポイントを追加しましたが、モデルマッピングテーブルを変更した後は、`/model` メニューを更新するために Codex の再起動が必要です。
|
||||
|
||||
---
|
||||
|
||||
## リスク通知
|
||||
|
||||
本リリースは、リバースプロキシ系機能に関する以前のリスク通知を引き続き適用します。
|
||||
|
||||
**Codex OAuth リバースプロキシ**: ChatGPT サブスクリプションの Codex OAuth をリバースプロキシ経由で使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 release notes](v3.13.0-ja.md#️-リスクに関する注意事項) を参照してください。
|
||||
|
||||
**Codex サードパーティプロバイダー Chat ルーティング**: CC Switch ローカルプロキシで Codex リクエストを変換し、サードパーティプロバイダーへ転送する場合、課金、コンプライアンス、データ保持に関する制約はプロバイダーごとに異なります。利用前に対象プロバイダーの利用規約を確認してください。
|
||||
|
||||
**Claude Desktop サードパーティプロバイダープロキシ切り替え**: CC Switch 内蔵のプロキシゲートウェイで Claude Desktop のリクエストをサードパーティプロバイダーへ転送する場合も、対象プロバイダーの課金、コンプライアンス、データ保持に関する規約に従う必要があります。
|
||||
|
||||
上記機能を有効化したユーザーは、関連するリスクを自ら負うものとします。CC Switch は、これらの機能の利用によって発生したアカウント制限、警告、サービス停止について責任を負いません。
|
||||
|
||||
---
|
||||
|
||||
## 謝辞
|
||||
|
||||
v3.16.2 で機能と修正を届けてくださった以下のコントリビューターに感謝します:
|
||||
|
||||
- [#1351](https://github.com/farion1231/cc-switch/pull/1351): S3 互換クラウドストレージ同期を追加、@keithyt06 に感謝。
|
||||
- [#3215](https://github.com/farion1231/cc-switch/pull/3215): OpenCode セッション用量同期を追加、@nothingness0db に感謝。
|
||||
- [#2709](https://github.com/farion1231/cc-switch/pull/2709): ZenMux Token Plan プロバイダーを追加、@Eter365 に感謝。
|
||||
- [#3643](https://github.com/farion1231/cc-switch/pull/3643): CherryIN プリセットプロバイダーを追加、@zhibisora に感謝。
|
||||
- [#3818](https://github.com/farion1231/cc-switch/pull/3818): Codex CLI 到達性確認用の `GET /v1/models` エンドポイントを追加、@CSberlin に感謝。
|
||||
- [#3426](https://github.com/farion1231/cc-switch/pull/3426): 用量ダッシュボードのヒーロー再設計、@allenxu09 に感謝。
|
||||
- [#3640](https://github.com/farion1231/cc-switch/pull/3640): tools が空のとき `tool_choice` を破棄、@Postroggy に感謝。
|
||||
- [#3644](https://github.com/farion1231/cc-switch/pull/3644): Chat ルーティングで Codex カスタムツールメタデータを保持、@LanternCX に感謝。
|
||||
- [#3514](https://github.com/farion1231/cc-switch/pull/3514): Chat→Responses で常に `reasoning_tokens` を含める、@yeeyzy に感謝。
|
||||
- [#3689](https://github.com/farion1231/cc-switch/pull/3689): live がすでにプロキシプレースホルダーのときバックアップ / 復元をスキップ、@YongmaoLuo に感謝。
|
||||
- [#3016](https://github.com/farion1231/cc-switch/pull/3016): localhost リッスンアドレスを正規化、@Alexlangl に感謝。
|
||||
- [#3775](https://github.com/farion1231/cc-switch/pull/3775): Anthropic `system` メッセージを正規化、@Dearli666 に感謝。
|
||||
- [#3656](https://github.com/farion1231/cc-switch/pull/3656): プロキシパネルのエラー表示を改善、@lzcndm に感謝。
|
||||
- [#2647](https://github.com/farion1231/cc-switch/pull/2647): 無限空白検出のしきい値を 20 → 500 へ引き上げ、@NiuBlibing に感謝。
|
||||
- [#3702](https://github.com/farion1231/cc-switch/pull/3702): 智譜の残量照会を構成済み base URL へルーティング、@YongmaoLuo に感謝。
|
||||
- [#3518](https://github.com/farion1231/cc-switch/pull/3518): MiniMax の新残量 API と既定価格に対応、@LaoYueHanNi に感謝。
|
||||
- [#3524](https://github.com/farion1231/cc-switch/pull/3524): 智譜 Coding Plan プリセットとバージョン付き endpoint のモデル探索を修正、@makoMakoGo に感謝。
|
||||
- [#3614](https://github.com/farion1231/cc-switch/pull/3614): モデルカタログを相対ファイル名に変更、@steponeerror に感謝。
|
||||
- [#3797](https://github.com/farion1231/cc-switch/pull/3797): Windows 終了後のトレイアイコン残留を修正、@iAJue に感謝。
|
||||
- [#3457](https://github.com/farion1231/cc-switch/pull/3457): Windows タスクバーアイコンを修正、@ZhangNanNan1018 に感謝。
|
||||
- [#3430](https://github.com/farion1231/cc-switch/pull/3430): Windows のパス区切りを正規化してサブディレクトリスキルの更新に対応、@Ninthless に感謝。
|
||||
- [#3626](https://github.com/farion1231/cc-switch/pull/3626): macOS の入力自動大文字化を無効化、@ZHLHZHU に感謝。
|
||||
- [#3593](https://github.com/farion1231/cc-switch/pull/3593): Codex VS Code セッションプレビューを修正、@xwil1 に感謝。
|
||||
- [#3228](https://github.com/farion1231/cc-switch/pull/3228): 中国語 UI の VS Code 表記を揃える、@Games55k に感謝。
|
||||
- [#3411](https://github.com/farion1231/cc-switch/pull/3411): 現行のアプリ対応を反映してユーザーマニュアルを刷新、@makoMakoGo に感謝。
|
||||
- [#3772](https://github.com/farion1231/cc-switch/pull/3772): README のリリースノートリンクとスポンサー表記を修正、@null-easy に感謝。
|
||||
|
||||
v3.16.1 リリース後に Codex Chat ルーティング、ローカルプロキシのテイクオーバー、用量統計、プラットフォーム互換性の問題を報告してくださったすべてのユーザーにも感謝します。今回の多くの修正は、実際の利用シーンから得られた再現情報に基づいています。
|
||||
|
||||
---
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から、お使いのシステムに対応するビルドをダウンロードしてください。
|
||||
|
||||
### システム要件
|
||||
|
||||
| システム | 最低バージョン | アーキテクチャ |
|
||||
| -------- | ------------------------ | ----------------------------------- |
|
||||
| Windows | Windows 10 以降 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 下表を参照 | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ---------------------------------------- | ------------------------------------------ |
|
||||
| `CC-Switch-v3.16.2-Windows.msi` | **推奨** - 自動更新対応の MSI インストーラー |
|
||||
| `CC-Switch-v3.16.2-Windows-Portable.zip` | ポータブル版、展開してそのまま実行できます |
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| -------------------------------- | ------------------------------------------------------ |
|
||||
| `CC-Switch-v3.16.2-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ |
|
||||
| `CC-Switch-v3.16.2-macOS.zip` | 展開して Applications へドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.16.2-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||
|
||||
Homebrew インストール:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux アセットは **x86_64** と **ARM64**(`aarch64`)の両方を提供します。ファイル名にアーキテクチャ識別子が含まれているため、マシンの `uname -m` 出力に合わせて選択してください:
|
||||
|
||||
- `CC-Switch-v3.16.2-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.16.2-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||
| --------------------------------------- | ----------- | ------------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して直接起動、または AUR を使用 |
|
||||
| その他 / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,347 @@
|
||||
# CC Switch v3.16.2
|
||||
|
||||
> 在 v3.16.1 的 Codex 稳定性补丁之后,这一版主要拓宽了数据的可携带性与用量观测能力——新增 S3 兼容云同步、OpenCode 会话用量同步、官方订阅额度模板——并继续加固 Codex 通过 Chat Completions 路由第三方供应商的稳健性,同时修复了一批 Windows / macOS 平台问题,新增 CherryIN、ZenMux 供应商,并全面刷新了三语用户手册。
|
||||
|
||||
**[English →](v3.16.2-en.md) | [日本語版 →](v3.16.2-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 使用攻略
|
||||
|
||||
这一版新增了云同步的 S3 后端和更多用量统计来源,如果你想用上,可以先看这些文档:
|
||||
|
||||
- **[设置](../user-manual/zh/1-getting-started/1.5-settings.md)**:在设置页配置云同步(WebDAV / S3 兼容存储),用于在多台设备间备份和恢复供应商、MCP、提示词、技能等配置。
|
||||
- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:了解用量看板的数据来源(代理日志、Codex / Gemini / OpenCode 会话同步)与统计口径。
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> ## 唯一官方渠道声明(请务必阅读)
|
||||
>
|
||||
> CC Switch 是**完全免费、开源**的桌面应用,**不会向用户收取任何费用**。请仅通过下列官方渠道获取本软件:
|
||||
>
|
||||
> | 类别 | 唯一官方 |
|
||||
> | -------- | ------------------------------------------------------------------------------ |
|
||||
> | 官网 | **[ccswitch.io](https://ccswitch.io)** |
|
||||
> | 源码 | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||
> | 下载 | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
|
||||
> | 举报山寨 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||
>
|
||||
> **任何向你收费、要求充值、或索取登录凭据的"CC Switch"网站或客户端均为假冒**。如果你被诱导支付了费用,请立即停止操作并通过 GitHub Issues 反馈。
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.16.2 是 v3.16.1 之后的一版维护更新。在上一版集中处理 Codex 官方鉴权与本地路由接管的安全问题之后,这一版把重心放在两件事上:一是拓宽数据的可携带性和用量观测——新增 S3 兼容云同步(WebDAV 之外的第二套云备份后端)、OpenCode 会话用量同步,以及面向官方订阅的额度统计模板;二是继续打磨 Codex 通过 Chat Completions 路由第三方供应商时暴露出来的边角问题——流式截断判定、空 tools 下的 `tool_choice`、自定义工具元数据、推理 token 统计、文件 / 音频附件转换等。
|
||||
|
||||
此外,本版还修复了一批本地代理的稳健性问题(临时端口解析、接管占位符还原死循环、Anthropic `system` 消息归一化、上游 413 文案、Claude Desktop 的 `[1m]` 模型路由),处理了若干 Windows / macOS 平台体验问题,并新增 CherryIN、ZenMux 两个供应商,同时全面刷新了三语用户手册。
|
||||
|
||||
**发布日期**:2026-06-07
|
||||
|
||||
**更新规模**:41 commits | 132 files changed | +11,116 / -1,636 lines
|
||||
|
||||
---
|
||||
|
||||
## 重点内容
|
||||
|
||||
- **S3 兼容云同步**:在 WebDAV 之外新增 S3 兼容对象存储作为第二套云备份后端,内置 AWS S3、MinIO、Cloudflare R2、阿里云 OSS、腾讯云 COS、华为 OBS 等一键预设。
|
||||
- **更多用量统计来源**:新增 OpenCode 会话用量同步,以及面向 Claude / Codex / Gemini 官方订阅的额度统计模板(显式开关、默认关闭)。
|
||||
- **Codex Chat Completions 路由继续加固**:修复流式截断误判、空 tools 下 `tool_choice` 被拒、自定义工具元数据丢失、推理 token 统计缺失,并支持文件 / 音频附件转换与 `/v1/models` 探活端点。
|
||||
- **本地代理更稳**:修复临时端口(port 0)解析、接管占位符还原死循环、Anthropic `system` 消息归一化、上游 413 文案,以及 Claude Desktop 1M 上下文模型路由。
|
||||
- **平台与供应商**:修复 Windows 托盘 / 任务栏图标、子目录技能更新、macOS 输入自动大写等问题,并新增 CherryIN、ZenMux 供应商。
|
||||
|
||||
---
|
||||
|
||||
## 新功能
|
||||
|
||||
### S3 兼容云同步
|
||||
|
||||
云同步现在支持 S3 兼容对象存储作为 WebDAV 之外的第二套后端,签名采用自实现的 AWS Signature V4,以兼容尽可能多的服务。设置页提供 AWS S3、MinIO、Cloudflare R2、阿里云 OSS、腾讯云 COS、华为 OBS 以及自定义 endpoint 的一键预设,支持连接测试、手动上传 / 下载,以及在配置变更时自动同步(providers、endpoint、MCP、提示词、技能、设置、代理等配置表,**不含**用量日志这类高频写入数据)。开启 S3 同步会停用正在运行的 WebDAV 同步,反之亦然([#1351](https://github.com/farion1231/cc-switch/pull/1351))。
|
||||
|
||||
### OpenCode 会话用量同步
|
||||
|
||||
新增 OpenCode 作为用量统计来源,从 OpenCode 本地 SQLite 数据库读取每条消息的 token、成本和模型数据并导入用量记录,并提供独立的「OpenCode」应用筛选页签和「OpenCode Session」数据来源标签。数据库路径会遵循 `OPENCODE_DB` 和 `XDG_DATA_HOME`(在所有平台默认 `~/.local/share/opencode`),只导入已完成的消息,并在判断新鲜度时把 WAL 文件一并计入,避免刚写入的会话被跳过([#3215](https://github.com/farion1231/cc-switch/pull/3215))。
|
||||
|
||||
### 官方订阅额度模板
|
||||
|
||||
由于部分用户担心发起用量查询的 IP 和发起应用内请求的不一致导致封号风险,因此为 Claude / Codex / Gemini 官方供应商新增一个显式、可选的「官方订阅」用量模板,通过 CLI / OAuth 凭据查询套餐额度,替代此前对官方供应商的隐式自动查询。该模板默认关闭,需要在用量脚本弹窗里开启,并可配置刷新间隔。使用此功能建议开启代理的 TUN 模式。
|
||||
|
||||
### 文本模型图片回退整流器
|
||||
|
||||
新增一个代理整流器:当路由到的模型仅支持文本(显式声明,或由内置的模型名启发式判定),或上游拒绝图片输入时,会把 Anthropic 图片块替换为 `[Unsupported Image]` 占位标记,避免对话被中断。设置页提供该回退功能的开关,并单独提供一个开关控制启发式检测(可关闭以避免误判多模态模型)。
|
||||
|
||||
### ZenMux Token Plan 供应商
|
||||
|
||||
新增 ZenMux 作为 Token Plan 类的 Coding Plan 供应商,可在用量脚本弹窗里手动填写 API key 和 base URL,并以美元口径富展示已用 / 额度([#2709](https://github.com/farion1231/cc-switch/pull/2709))。
|
||||
|
||||
### CherryIN 预设
|
||||
|
||||
新增 CherryIN 聚合网关作为快捷配置预设,覆盖全部 7 个受管应用——Claude Code / Claude Desktop / OpenClaw / Hermes 使用 Anthropic 格式端点(open.cherryin.net),OpenCode 使用 `@ai-sdk/anthropic`(`/v1`),Codex 使用 OpenAI 兼容端点,Gemini CLI 使用 Gemini 兼容端点,附带官方品牌图标,位置紧挨 AiHubMix([#3643](https://github.com/farion1231/cc-switch/pull/3643))。
|
||||
|
||||
### Codex CLI 模型探活端点 `/v1/models`
|
||||
|
||||
本地代理现在会响应 Codex CLI 启动时探测的 `GET /v1/models`,返回 CC Switch 托管的 Codex 模型目录。同时加入了过期目录守卫:解析 live 的 `config.toml`,仅当 `model_catalog_json` 仍指向 CC Switch 持有的目录文件时才提供,避免把上一个供应商遗留的目录暴露给 Codex([#3818](https://github.com/farion1231/cc-switch/pull/3818))。
|
||||
|
||||
### Codex Chat 文件与音频附件
|
||||
|
||||
Codex 的 Responses→Chat 转换现在会把 `input_file`(携带 `file_id` 或内联 `file_data`)和 `input_audio` 内容部分映射为 Chat Completions 的对应形态,并补发此前会被丢弃的顶层 `input_*` 项,让文件和音频附件能够送达只支持 Chat 的 Codex 上游。
|
||||
|
||||
---
|
||||
|
||||
## 变更
|
||||
|
||||
### 用量看板 Hero 重新设计
|
||||
|
||||
把用量看板的 Hero 区与汇总卡片重排为更紧凑的布局,将真实 token 总量、请求数和成本合并到顶部一行展示([#3426](https://github.com/farion1231/cc-switch/pull/3426))。
|
||||
|
||||
### SSSAiCode 端点刷新
|
||||
|
||||
把 SSSAiCode 预设的官网、注册和 API base URL 更新到 `sssaicodeapi.com` 域名,并刷新其端点候选节点(默认 `node-hk.sssaicodeapi.com`,另含 `node-hk.sssaiapi.com` 和 `node-cf.sssaicodeapi.com`),覆盖全部 7 个应用预设。
|
||||
|
||||
---
|
||||
|
||||
## 修复
|
||||
|
||||
### Codex Chat 流式截断判定
|
||||
|
||||
当 Chat Completions 上游在没有 `finish_reason` 或 `[DONE]` 的情况下结束流时,CC Switch 不再把它当作正常完成:只有流真正结束才正常收尾;已产出部分内容时发出 incomplete(`max_output_tokens`)响应;完全没有产出时发出失败的 `stream_truncated` 事件。晚到的推理内容也会回填到仍在进行的流式工具调用上。
|
||||
|
||||
### Codex Chat 空 tools 下的 `tool_choice`
|
||||
|
||||
Responses→Chat 转换现在会在最终 tools 数组缺失或为空(包括所有工具被过滤掉)时一并丢弃 `tool_choice` 和 `parallel_tool_calls`,避免严格的 OpenAI 兼容上游(vLLM、企业网关)以"When using `tool_choice`, `tools` must be set."报 503/400([#3640](https://github.com/farion1231/cc-switch/pull/3640))。
|
||||
|
||||
### Codex 自定义工具元数据保留
|
||||
|
||||
自定义 Codex 工具(如自由格式的 `apply_patch` 工具)现在会把完整的原始定义——包括 format 和 grammar 元数据——以紧凑、顺序稳定的 JSON 块嵌入生成的 Chat 函数描述中,而不是替换成通用占位符,从而在 Chat Completions 上游上仍可正常使用([#3644](https://github.com/farion1231/cc-switch/pull/3644))。
|
||||
|
||||
### Codex Chat 用量缺少 `reasoning_tokens`
|
||||
|
||||
Chat→Responses 的用量转换现在总会包含 `output_tokens_details.reasoning_tokens`(默认 0),即使供应商省略 `completion_tokens_details` 或返回非对象也是如此,满足 Codex CLI 的严格要求,避免反复的响应解析失败和重试([#3514](https://github.com/farion1231/cc-switch/pull/3514))。
|
||||
|
||||
### Codex 自定义工具 / 搜索工具的跨轮推理
|
||||
|
||||
Codex Chat 历史里的跨轮推理缓存现在覆盖完整的工具调用集合(`function_call`、`custom_tool_call`、`tool_search_call`)及其输出,而不再仅限普通函数调用,因此 `apply_patch` 和工具搜索调用在通过 `previous_response_id` 恢复时能保留各自的 `reasoning_content`。
|
||||
|
||||
### 临时端口(port 0)解析
|
||||
|
||||
当代理被配置为监听 0 端口(由系统分配)时,接管流程现在会先启动代理以拿到真实端口,再写入 live 配置和数据库,避免客户端 URL 指向无效的 `:0` 地址;若还没解析出具体端口,Claude Desktop 的网关 URL 会被直接拒绝。
|
||||
|
||||
### 代理占位符备份 / 恢复死循环
|
||||
|
||||
如果上一次停止代理时未能还原原始 live 配置、把代理占位符遗留在了 live 中,再次接管时不会再用代理配置覆盖掉正常备份,恢复时也不会把占位符写回 live:两条路径都会识别占位符状态并以当前供应商为真相来源重建 live,修复了代理开关变成空操作、客户端被钉死在本地代理地址的问题([#3689](https://github.com/farion1231/cc-switch/pull/3689))。
|
||||
|
||||
### 代理接管期间误拦截供应商切换
|
||||
|
||||
在本地路由接管期间,现在只有显式归类为官方的供应商会被禁止切换,而不会再把端点存在 meta 里、或字段尚未填写的自定义供应商一并禁用。被禁用的「启用」按钮现在以更轻量的提示气泡替代原先的红色「已拦截」标记。
|
||||
|
||||
### localhost 监听地址归一化
|
||||
|
||||
保存代理时如果监听地址填的是 `localhost`,现在会先归一化为 `127.0.0.1` 再持久化,避免绑定不一致([#3016](https://github.com/farion1231/cc-switch/pull/3016))。
|
||||
|
||||
### Anthropic `system` 消息归一化
|
||||
|
||||
对 Anthropic 格式的供应商,`messages` 数组里的 system 角色条目现在会被折叠并合并到顶层 `system` 字段(保留原顺序以及已有的顶层 system),避免严格上游拒绝非首位的 system 消息;OpenAI Chat 路由不受影响([#3775](https://github.com/farion1231/cc-switch/pull/3775))。
|
||||
|
||||
### Claude Desktop 1M 上下文模型路由
|
||||
|
||||
Claude Desktop 在 1M 上下文 beta 激活时会给模型名追加 `[1m]` 标记(如 `claude-opus-4-8[1m]`)。代理现在会在路由匹配前先剥掉该后缀,让精确、别名、旧名和角色关键词匹配都能正确命中,修复了对话中途切换到 1M 模型时的 `route_unknown`(HTTP 400)失败;诊断用的 `route_unknown` 错误里仍保留原始模型名。
|
||||
|
||||
### Codex 413 错误文案
|
||||
|
||||
当 Codex 上游网关以 HTTP 413 拒绝过大的请求体时,代理现在返回专门的提示,说明这是供应商服务端的请求体大小限制(而非 CC Switch 本地限制),并给出可操作的恢复步骤(运行 `/compact`、移除大段日志或内联图片,或请供应商调高限制),不再原样回显上游的 HTML 错误页。
|
||||
|
||||
### 代理面板错误详情
|
||||
|
||||
切换代理接管失败时,代理面板的提示现在会带上后端返回的具体错误详情,而不是只显示一句笼统的失败信息([#3656](https://github.com/farion1231/cc-switch/pull/3656))。
|
||||
|
||||
### Copilot 无限空白检测阈值
|
||||
|
||||
把流式无限空白的中断阈值从 20 调高到 500 个连续空白字符,避免参数里含深层缩进代码(Python、YAML、Rust、Markdown)的正常工具调用被误判中断,同时仍能捕获真正的 Copilot 无限空白 bug([#2647](https://github.com/farion1231/cc-switch/pull/2647))。
|
||||
|
||||
### 订阅档位托盘渲染
|
||||
|
||||
通过统一的档位到标签映射,修复官方订阅档位在托盘和额度展示上的渲染问题:Claude / Codex 不再漏掉 7 天窗口,Gemini Pro / Flash / Flash-Lite 档位不再泄露原始机器名,多窗口套餐(如 Opus + Sonnet)现在按最差利用率展示而非取第一个匹配。
|
||||
|
||||
### Claude 流式 input_tokens 虚高
|
||||
|
||||
部分 Anthropic 兼容的流式供应商(如 Qwen、MiniMax)会在 `message_start` 里把完整上下文当作 `input_tokens` 上报,重复计入了已经单独统计的缓存部分,导致显示的缓存命中率被人为拉低。现在解析器会优先采用 `message_delta` 中更小的正 `input_tokens`,并采用同一 usage 块里配套的缓存计数;原生 Claude 和 OpenRouter 转换路径不变。
|
||||
|
||||
### 智谱配额查询端点路由
|
||||
|
||||
智谱 Coding Plan 的配额查询此前被硬编码到 `api.z.ai`,导致使用大陆预设(`open.bigmodel.cn`)的用户在国际端点不可达时查不到用量。现在配额请求会路由到与用户所配 base URL 匹配的主机([#3702](https://github.com/farion1231/cc-switch/pull/3702))。
|
||||
|
||||
### MiniMax 余额接口与定价
|
||||
|
||||
适配 MiniMax Coding Plan 配额的新余额接口(新接口返回剩余百分比字段,而非旧解析器依赖、会导致档位为空、托盘不再显示用量的用量计数),过滤掉非编程模型(如视频),兼容无周限额的套餐,并为 MiniMax M3 模型补充了默认定价([#3518](https://github.com/farion1231/cc-switch/pull/3518))。
|
||||
|
||||
### GLM Coding Plan 端点与模型拉取
|
||||
|
||||
把智谱 / Z.AI 的 GLM Coding Plan 预设修正到 `/api/coding/paas/v4` 端点(覆盖 Codex、OpenCode、OpenClaw、Hermes),并让模型列表探测对已经以 `/v{N}` 版本段结尾的 base URL 改为先查 `{base}/models`(保留 `/v1/models` 作为兜底),让「拉取模型」按钮不再在带版本号的端点上 404([#3524](https://github.com/farion1231/cc-switch/pull/3524))。
|
||||
|
||||
### Codex 模型目录路径可移植性
|
||||
|
||||
Codex 现在只把相对文件名 `cc-switch-model-catalog.json` 写入 `config.toml`,而不是绝对路径(Codex CLI 会从配置目录解析它),修复了在 WSL 和符号链接环境下绝对路径无法转换、导致模型目录失效的问题([#3614](https://github.com/farion1231/cc-switch/pull/3614))。
|
||||
|
||||
### APINebula 的 OpenCode SDK
|
||||
|
||||
APINebula 的 OpenCode 预设现在加载 `@ai-sdk/openai-compatible` 而非 `@ai-sdk/openai`,让请求使用该中转期望的 OpenAI Chat Completions 格式,而不是只支持 chat-completions 的上游会失败的 Responses API。
|
||||
|
||||
### Windows 退出后托盘图标残留
|
||||
|
||||
在 Windows 上退出 CC Switch 可能会留下一个失效的托盘图标,直到鼠标划过才消失。现在应用会在退出前显式移除托盘图标,让它随进程结束干净消失([#3797](https://github.com/farion1231/cc-switch/pull/3797))。
|
||||
|
||||
### Windows 任务栏图标
|
||||
|
||||
在运行时显式设置 Windows AppUserModelID,并给安装器生成的桌面和开始菜单快捷方式写入相同的 ID 和产品图标,让 CC Switch 在任务栏上显示正确图标并正确归组([#3457](https://github.com/farion1231/cc-switch/pull/3457))。
|
||||
|
||||
### Windows 子目录技能的更新检查
|
||||
|
||||
在 Windows 上扫描已安装技能时,把反斜杠路径分隔符归一化为正斜杠,让嵌套在子目录里的技能(如 `skills/my-skill`)能被更新检查匹配到,而不是被静默跳过([#3430](https://github.com/farion1231/cc-switch/pull/3430))。
|
||||
|
||||
### macOS 输入自动大写
|
||||
|
||||
为共享的文本 Input 组件关闭自动完成、自动纠错、自动大写和拼写检查,让 macOS 不再对配置字段里输入的首字母自动大写或自动纠正([#3626](https://github.com/farion1231/cc-switch/pull/3626))。
|
||||
|
||||
### Codex VS Code 会话预览
|
||||
|
||||
从 VS Code 发起的 Codex 请求,其会话预览在注入请求前存在 markdown 标题时,可能显示选区或打开文件的内容而非真实提示。现在后端标题和前端预览都会匹配最后一个「## My request for Codex:」标题(IDE 把真实请求作为最后一节注入),让预览反映用户的提示([#3593](https://github.com/farion1231/cc-switch/pull/3593))。
|
||||
|
||||
### 中文界面 VS Code 文案
|
||||
|
||||
把简体和繁体中文里「应用到 Claude Code 插件」的描述改为正确书写「VS Code」而非「Vscode」,与英文、日文文案对齐([#3228](https://github.com/farion1231/cc-switch/pull/3228))。
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
### 用户手册刷新
|
||||
|
||||
刷新了 README 各语言版本以及 en / zh / ja 用户手册,使其反映全部 7 个受管应用(在介绍和总览文案里补上 Claude Desktop 与 Hermes),把 OpenCode 配置路径修正为 `~/.config/opencode/`(`opencode.json`),补充了 Hermes 配置文件说明,把语言文档更新为四种语言,订正各应用 MCP / 提示词 / 技能的支持情况,说明导出现在会生成带时间戳、含用量日志的 SQL 备份,并补充了定价模型 ID 匹配规则([#3411](https://github.com/farion1231/cc-switch/pull/3411))。
|
||||
|
||||
### Codex 官方认证保留指南
|
||||
|
||||
新增中 / 英 / 日三语指南,说明如何在把模型流量切到第三方 API 的同时,保留 Codex 官方远程操作和官方插件的可用性,并从 v3.16.1 release notes 链接到该指南。
|
||||
|
||||
### README 链接与赞助商标记
|
||||
|
||||
把各语言 README 里的 Release Notes 链接更新到 v3.16.1,并修复 README_ZH 赞助商区块里损坏的弯引号字符,让其 HTML 属性能正确渲染([#3772](https://github.com/farion1231/cc-switch/pull/3772))。
|
||||
|
||||
---
|
||||
|
||||
## 升级提醒
|
||||
|
||||
### S3 与 WebDAV 云同步互斥
|
||||
|
||||
云同步同一时间只会运行一套后端。开启 S3 自动同步会停用正在运行的 WebDAV 自动同步,反之亦然。如果你之前用的是 WebDAV,切到 S3 前请确认两端数据已对齐,避免误以为旧后端仍在备份。
|
||||
|
||||
### 修改模型映射后仍需重启 Codex
|
||||
|
||||
Codex 在启动时读取 `model_catalog_json`。即使本版已把模型目录改写为相对路径并新增了 `/v1/models` 探活端点,只要你修改了模型映射表,仍然需要重启 Codex 才能让 `/model` 菜单刷新。
|
||||
|
||||
---
|
||||
|
||||
## 风险提示
|
||||
|
||||
本版本继续沿用此前版本对反向代理类功能的风险提示。
|
||||
|
||||
**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#️-风险提示)。
|
||||
|
||||
**Codex 第三方供应商 Chat 路由**:通过 CC Switch 本地代理把 Codex 请求转换并转发到第三方供应商时,各供应商对计费、合规与数据留存的约束不同,请在使用前阅读目标供应商的服务条款。
|
||||
|
||||
**Claude Desktop 第三方供应商代理切换**:通过 CC Switch 内置代理网关把 Claude Desktop 的请求转到第三方供应商时,同样需要遵守目标供应商的计费、合规与数据留存约束。
|
||||
|
||||
用户启用上述功能即表示自行承担相关风险。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
感谢以下贡献者在 v3.16.2 中提交的功能与修复:
|
||||
|
||||
- [#1351](https://github.com/farion1231/cc-switch/pull/1351):新增 S3 兼容云存储同步,感谢 @keithyt06。
|
||||
- [#3215](https://github.com/farion1231/cc-switch/pull/3215):新增 OpenCode 会话用量同步,感谢 @nothingness0db。
|
||||
- [#2709](https://github.com/farion1231/cc-switch/pull/2709):新增 ZenMux Token Plan 供应商,感谢 @Eter365。
|
||||
- [#3643](https://github.com/farion1231/cc-switch/pull/3643):新增 CherryIN 预设供应商,感谢 @zhibisora。
|
||||
- [#3818](https://github.com/farion1231/cc-switch/pull/3818):新增 Codex CLI 探活用的 `GET /v1/models` 端点,感谢 @CSberlin。
|
||||
- [#3426](https://github.com/farion1231/cc-switch/pull/3426):用量看板 Hero 重新设计,感谢 @allenxu09。
|
||||
- [#3640](https://github.com/farion1231/cc-switch/pull/3640):空 tools 时丢弃 `tool_choice`,感谢 @Postroggy。
|
||||
- [#3644](https://github.com/farion1231/cc-switch/pull/3644):Chat 路由保留 Codex 自定义工具元数据,感谢 @LanternCX。
|
||||
- [#3514](https://github.com/farion1231/cc-switch/pull/3514):Chat→Responses 始终包含 `reasoning_tokens`,感谢 @yeeyzy。
|
||||
- [#3689](https://github.com/farion1231/cc-switch/pull/3689):live 已是代理占位符时跳过备份 / 恢复,感谢 @YongmaoLuo。
|
||||
- [#3016](https://github.com/farion1231/cc-switch/pull/3016):归一化 localhost 监听地址,感谢 @Alexlangl。
|
||||
- [#3775](https://github.com/farion1231/cc-switch/pull/3775):规范化 Anthropic `system` 消息,感谢 @Dearli666。
|
||||
- [#3656](https://github.com/farion1231/cc-switch/pull/3656):改进代理面板错误信息展示,感谢 @lzcndm。
|
||||
- [#2647](https://github.com/farion1231/cc-switch/pull/2647):调高无限空白检测阈值 20 → 500,感谢 @NiuBlibing。
|
||||
- [#3702](https://github.com/farion1231/cc-switch/pull/3702):智谱配额查询按所配 base URL 路由,感谢 @YongmaoLuo。
|
||||
- [#3518](https://github.com/farion1231/cc-switch/pull/3518):适配 MiniMax 余额查询新接口与默认定价,感谢 @LaoYueHanNi。
|
||||
- [#3524](https://github.com/farion1231/cc-switch/pull/3524):修复智谱 Coding Plan 预设与带版本号端点的模型探测,感谢 @makoMakoGo。
|
||||
- [#3614](https://github.com/farion1231/cc-switch/pull/3614):模型目录改用相对文件名,感谢 @steponeerror。
|
||||
- [#3797](https://github.com/farion1231/cc-switch/pull/3797):修复 Windows 退出后托盘图标残留,感谢 @iAJue。
|
||||
- [#3457](https://github.com/farion1231/cc-switch/pull/3457):修复 Windows 任务栏图标,感谢 @ZhangNanNan1018。
|
||||
- [#3430](https://github.com/farion1231/cc-switch/pull/3430):归一化 Windows 路径分隔符以匹配子目录技能更新,感谢 @Ninthless。
|
||||
- [#3626](https://github.com/farion1231/cc-switch/pull/3626):关闭 macOS 输入框自动大写,感谢 @ZHLHZHU。
|
||||
- [#3593](https://github.com/farion1231/cc-switch/pull/3593):修复 Codex VS Code 会话预览,感谢 @xwil1。
|
||||
- [#3228](https://github.com/farion1231/cc-switch/pull/3228):对齐中文界面 VS Code 文案,感谢 @Games55k。
|
||||
- [#3411](https://github.com/farion1231/cc-switch/pull/3411):刷新用户手册以反映当前应用支持,感谢 @makoMakoGo。
|
||||
- [#3772](https://github.com/farion1231/cc-switch/pull/3772):修复 README release note 链接与赞助商标记,感谢 @null-easy。
|
||||
|
||||
也感谢所有在 v3.16.1 发布后反馈 Codex Chat 路由、本地代理接管、用量统计和平台兼容性问题的用户,很多补丁都来自这些真实使用场景里的复现线索。
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | -------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 / ARM64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------------- | ----------------------------------- |
|
||||
| `CC-Switch-v3.16.2-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.16.2-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| -------------------------------- | --------------------------------------------- |
|
||||
| `CC-Switch-v3.16.2-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
|
||||
| `CC-Switch-v3.16.2-macOS.zip` | 解压后拖入 Applications,Universal Binary |
|
||||
| `CC-Switch-v3.16.2-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
Homebrew 安装:
|
||||
|
||||
```bash
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
Linux 资产同时提供 **x86_64** 和 **ARM64**(`aarch64`)两种架构。资产文件名中包含架构标识,请按你机器的 `uname -m` 输出选择对应版本:
|
||||
|
||||
- `CC-Switch-v3.16.2-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||
- `CC-Switch-v3.16.2-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||
|
||||
| 发行版 | 推荐格式 | 安装方式 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |
|
||||
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` |
|
||||
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
|
||||
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.16.1",
|
||||
"version": "3.16.2",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+2
-1
@@ -735,7 +735,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc-switch"
|
||||
version = "3.16.1"
|
||||
version = "3.16.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -749,6 +749,7 @@ dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"flate2",
|
||||
"futures",
|
||||
"hmac",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.16.1"
|
||||
version = "3.16.2"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
@@ -77,6 +77,7 @@ indexmap = { version = "2", features = ["serde"] }
|
||||
rust_decimal = "1.33"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
json5 = "0.4"
|
||||
json-five = "0.3.1"
|
||||
|
||||
@@ -88,7 +89,7 @@ webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.52"
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_UI_Shell"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.5"
|
||||
|
||||
@@ -664,7 +664,7 @@ pub fn model_list_response(provider: &Provider) -> Result<Value, AppError> {
|
||||
}
|
||||
|
||||
pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<Value, AppError> {
|
||||
let requested = body
|
||||
let requested_raw = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
@@ -677,6 +677,7 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
|
||||
"Claude Desktop request is missing the model field",
|
||||
)
|
||||
})?;
|
||||
let requested = strip_one_m_suffix_for_route_lookup(&requested_raw);
|
||||
|
||||
let routes = proxy_model_routes(provider)?;
|
||||
let upstream_model = routes
|
||||
@@ -685,20 +686,21 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
|
||||
.or_else(|| {
|
||||
routes
|
||||
.iter()
|
||||
.find(|r| is_compatible_opus_route_alias(&r.route_id, &requested))
|
||||
.find(|r| is_compatible_opus_route_alias(&r.route_id, requested))
|
||||
})
|
||||
.map(|route| route.upstream_model.clone())
|
||||
.or_else(|| legacy_raw_route_upstream_model(provider, &requested))
|
||||
.or_else(|| legacy_raw_route_upstream_model(provider, requested))
|
||||
.or_else(|| {
|
||||
// 角色关键词回落:Claude Desktop 的部分调用(如子 agent)会请求带发布
|
||||
// 日期后缀的完整官方名(claude-haiku-4-5-20251001),与 manifest 暴露的
|
||||
// 简短 route_id(claude-haiku-4-5)不精确相等。按 opus/haiku/sonnet 归类
|
||||
// 到同档已配置路由,对齐 Claude Code model_mapper 的宽松匹配。
|
||||
// 仅对 Claude Desktop 认可的安全模型名回落(排除 [1m] 标记等非法形式)。
|
||||
if !is_claude_safe_model_id(&requested) {
|
||||
// 匹配前已剥离本地 [1m] 标记;这里仍只对 Claude Desktop 认可的
|
||||
// 安全模型名回落,避免非 Claude route 被误映射。
|
||||
if !is_claude_safe_model_id(requested) {
|
||||
return None;
|
||||
}
|
||||
let role = claude_role_keyword(&requested)?;
|
||||
let role = claude_role_keyword(requested)?;
|
||||
routes
|
||||
.iter()
|
||||
.find(|route| claude_role_keyword(&route.route_id) == Some(role))
|
||||
@@ -707,8 +709,8 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
|
||||
.ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"claude_desktop.provider.route_unknown",
|
||||
format!("Claude Desktop 模型路由未配置: {requested}"),
|
||||
format!("Claude Desktop model route is not configured: {requested}"),
|
||||
format!("Claude Desktop 模型路由未配置: {requested_raw}"),
|
||||
format!("Claude Desktop model route is not configured: {requested_raw}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -719,6 +721,18 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
fn strip_one_m_suffix_for_route_lookup(model: &str) -> &str {
|
||||
let trimmed = model.trim();
|
||||
let marker = ONE_M_CONTEXT_MARKER.as_bytes();
|
||||
let bytes = trimmed.as_bytes();
|
||||
if bytes.len() >= marker.len()
|
||||
&& bytes[bytes.len() - marker.len()..].eq_ignore_ascii_case(marker)
|
||||
{
|
||||
return trimmed[..trimmed.len() - marker.len()].trim_end();
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
|
||||
fn legacy_raw_route_upstream_model(provider: &Provider, requested: &str) -> Option<String> {
|
||||
provider
|
||||
.meta
|
||||
@@ -873,6 +887,11 @@ pub fn proxy_gateway_base_url_from_db(db: &Database) -> Result<String, AppError>
|
||||
// get_proxy_config is async-tagged but its body is fully synchronous (rusqlite
|
||||
// under a Mutex), so block_on cannot deadlock the calling thread.
|
||||
let config = futures::executor::block_on(db.get_proxy_config())?;
|
||||
if config.listen_port == 0 {
|
||||
return Err(AppError::Config(
|
||||
"Claude Desktop 代理地址需要真实监听端口;请先启动本地代理或使用固定端口".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(format!(
|
||||
"{}{}",
|
||||
proxy_origin_from_parts(&config.listen_address, config.listen_port),
|
||||
@@ -1290,6 +1309,12 @@ mod tests {
|
||||
Database::memory().expect("memory db")
|
||||
}
|
||||
|
||||
fn set_proxy_port(db: &Database, port: u16) {
|
||||
let mut config = crate::proxy::types::ProxyConfig::default();
|
||||
config.listen_port = port;
|
||||
futures::executor::block_on(db.update_proxy_config(config)).expect("update proxy config");
|
||||
}
|
||||
|
||||
fn direct_provider(id: &str) -> Provider {
|
||||
let mut provider = Provider::with_id(
|
||||
id.to_string(),
|
||||
@@ -1310,6 +1335,19 @@ mod tests {
|
||||
provider
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_gateway_base_url_rejects_unresolved_ephemeral_port() {
|
||||
let db = test_db();
|
||||
set_proxy_port(&db, 0);
|
||||
|
||||
let err = proxy_gateway_base_url_from_db(&db)
|
||||
.expect_err("unresolved ephemeral port should not produce a :0 URL");
|
||||
assert!(
|
||||
err.to_string().contains("真实监听端口"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
fn official_provider() -> Provider {
|
||||
let mut provider = Provider::with_id(
|
||||
CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID.to_string(),
|
||||
@@ -1820,15 +1858,48 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_desktop_proxy_rejects_1m_suffix_route() {
|
||||
let provider = proxy_provider("proxy");
|
||||
fn claude_desktop_proxy_strips_1m_suffix_before_route_lookup() {
|
||||
let mut provider = proxy_provider("proxy");
|
||||
provider
|
||||
.meta
|
||||
.as_mut()
|
||||
.expect("meta")
|
||||
.claude_desktop_model_routes = std::collections::HashMap::from([
|
||||
(
|
||||
"claude-sonnet-4-6".to_string(),
|
||||
ClaudeDesktopModelRoute {
|
||||
model: "upstream-sonnet".to_string(),
|
||||
label_override: None,
|
||||
supports_1m: Some(true),
|
||||
},
|
||||
),
|
||||
(
|
||||
"claude-opus-4-8".to_string(),
|
||||
ClaudeDesktopModelRoute {
|
||||
model: "upstream-opus".to_string(),
|
||||
label_override: None,
|
||||
supports_1m: Some(true),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
let err = map_proxy_request_model(
|
||||
let mapped = map_proxy_request_model(
|
||||
json!({"model": "claude-opus-4-8[1m]", "messages": []}),
|
||||
&provider,
|
||||
)
|
||||
.expect("compact 1M suffix should map to Opus route");
|
||||
assert_eq!(mapped["model"], json!("upstream-opus"));
|
||||
|
||||
let mapped = map_proxy_request_model(
|
||||
json!({"model": "claude-sonnet-4-6 [1M]", "messages": []}),
|
||||
&provider,
|
||||
)
|
||||
.expect_err("1M suffix route should not be accepted");
|
||||
assert!(err.to_string().contains("claude-sonnet-4-6 [1M]"));
|
||||
.expect("spaced uppercase 1M suffix should map to Sonnet route");
|
||||
assert_eq!(mapped["model"], json!("upstream-sonnet"));
|
||||
|
||||
let err = map_proxy_request_model(json!({"model": "gpt-5[1m]", "messages": []}), &provider)
|
||||
.expect_err("non-Claude route should still fail after stripping 1M suffix");
|
||||
assert!(err.to_string().contains("gpt-5[1m]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -208,7 +208,10 @@ pub fn extract_codex_api_key(auth: Option<&Value>, config_text: Option<&str>) ->
|
||||
/// Extract the upstream base URL from a Codex `config.toml` string.
|
||||
///
|
||||
/// Prefers the active `[model_providers.<model_provider>].base_url`, falling
|
||||
/// back to a top-level `base_url` when no model provider is selected.
|
||||
/// back to a top-level `base_url`. Deliberately never reads a non-active
|
||||
/// `[model_providers.*]` section — the frontend `extractCodexBaseUrl`
|
||||
/// (`getRecoverableBaseUrlAssignments`) excludes those too, and a leftover
|
||||
/// section unrelated to the active provider must not leak into `{{baseUrl}}`.
|
||||
pub fn extract_codex_base_url(config_text: &str) -> Option<String> {
|
||||
let doc = config_text.parse::<toml::Value>().ok()?;
|
||||
|
||||
@@ -766,7 +769,10 @@ pub fn read_codex_model_catalog_simplified_from_live() -> Result<Option<Value>,
|
||||
/// Given `config.toml` text, resolve the on-disk path of the cc-switch–owned
|
||||
/// catalog file (returns `None` if `model_catalog_json` is absent or points at
|
||||
/// a file we don't own). Relative paths fall back to `generated_path`.
|
||||
fn resolve_cc_switch_catalog_path(config_text: &str, generated_path: &Path) -> Option<PathBuf> {
|
||||
pub(crate) fn resolve_cc_switch_catalog_path(
|
||||
config_text: &str,
|
||||
generated_path: &Path,
|
||||
) -> Option<PathBuf> {
|
||||
if config_text.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -1251,6 +1257,50 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn extract_base_url_prefers_active_provider_section() {
|
||||
let input = r#"model_provider = "azure"
|
||||
|
||||
[model_providers.azure]
|
||||
base_url = "https://azure.example.com/v1"
|
||||
|
||||
[model_providers.other]
|
||||
base_url = "https://other.example.com/v1"
|
||||
"#;
|
||||
|
||||
assert_eq!(
|
||||
extract_codex_base_url(input).as_deref(),
|
||||
Some("https://azure.example.com/v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_base_url_falls_back_to_top_level_only() {
|
||||
let top_level = r#"base_url = "https://top-level.example.com/v1""#;
|
||||
assert_eq!(
|
||||
extract_codex_base_url(top_level).as_deref(),
|
||||
Some("https://top-level.example.com/v1")
|
||||
);
|
||||
}
|
||||
|
||||
// Mirrors the frontend extractCodexBaseUrl: a non-active provider section
|
||||
// is never a credential source, whether the active provider points
|
||||
// elsewhere (e.g. the built-in "openai") or none is selected at all.
|
||||
#[test]
|
||||
fn extract_base_url_ignores_non_active_provider_sections() {
|
||||
let mismatched = r#"model_provider = "openai"
|
||||
|
||||
[model_providers.custom]
|
||||
base_url = "https://leftover.example.com/v1"
|
||||
"#;
|
||||
assert_eq!(extract_codex_base_url(mismatched), None);
|
||||
|
||||
let no_active = r#"[model_providers.any]
|
||||
base_url = "https://single.example.com/v1"
|
||||
"#;
|
||||
assert_eq!(extract_codex_base_url(no_active), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_provider_live_config_rejects_key_without_config() {
|
||||
let err = prepare_codex_provider_live_config(&json!({"OPENAI_API_KEY": "sk-test"}), "")
|
||||
|
||||
@@ -29,6 +29,7 @@ mod subscription;
|
||||
mod sync_support;
|
||||
|
||||
mod lightweight;
|
||||
mod s3_sync;
|
||||
mod usage;
|
||||
mod webdav_sync;
|
||||
mod workspace;
|
||||
@@ -61,6 +62,7 @@ pub use stream_check::*;
|
||||
pub use subscription::*;
|
||||
|
||||
pub use lightweight::*;
|
||||
pub use s3_sync::*;
|
||||
pub use usage::*;
|
||||
pub use webdav_sync::*;
|
||||
pub use workspace::*;
|
||||
|
||||
@@ -14,12 +14,18 @@ pub async fn fetch_models_for_config(
|
||||
api_key: String,
|
||||
is_full_url: Option<bool>,
|
||||
models_url: Option<String>,
|
||||
custom_user_agent: Option<String>,
|
||||
) -> Result<Vec<FetchedModel>, String> {
|
||||
// 与转发 / 检测路径共用 parse_custom_user_agent:非法 UA 静默忽略(不阻断取模型)。
|
||||
let user_agent = crate::provider::parse_custom_user_agent(custom_user_agent.as_deref())
|
||||
.ok()
|
||||
.flatten();
|
||||
model_fetch::fetch_models(
|
||||
&base_url,
|
||||
&api_key,
|
||||
is_full_url.unwrap_or(false),
|
||||
models_url.as_deref(),
|
||||
user_agent,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use std::str::FromStr;
|
||||
const TEMPLATE_TYPE_GITHUB_COPILOT: &str = "github_copilot";
|
||||
const TEMPLATE_TYPE_TOKEN_PLAN: &str = "token_plan";
|
||||
const TEMPLATE_TYPE_BALANCE: &str = "balance";
|
||||
const TEMPLATE_TYPE_OFFICIAL_SUBSCRIPTION: &str = "official_subscription";
|
||||
const COPILOT_UNIT_PREMIUM: &str = "requests";
|
||||
|
||||
/// 获取所有供应商
|
||||
@@ -596,6 +597,50 @@ async fn query_provider_usage_inner(
|
||||
.map_err(|e| format!("Failed to query balance: {e}"));
|
||||
}
|
||||
|
||||
// ── 官方订阅额度查询路径 ──
|
||||
if template_type == TEMPLATE_TYPE_OFFICIAL_SUBSCRIPTION {
|
||||
if !usage_script.map(|s| s.enabled).unwrap_or(false) {
|
||||
return Ok(crate::provider::UsageResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some("Usage query is disabled".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
let quota = crate::services::subscription::get_subscription_quota(app_type.as_str())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to query subscription quota: {e}"))?;
|
||||
|
||||
if !quota.success {
|
||||
return Ok(crate::provider::UsageResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: quota.error.or(quota.credential_message),
|
||||
});
|
||||
}
|
||||
|
||||
let data: Vec<crate::provider::UsageData> = quota
|
||||
.tiers
|
||||
.iter()
|
||||
.map(|tier| crate::provider::UsageData {
|
||||
plan_name: Some(tier.name.clone()),
|
||||
remaining: Some(100.0 - tier.utilization),
|
||||
total: Some(100.0),
|
||||
used: Some(tier.utilization),
|
||||
unit: Some("%".to_string()),
|
||||
is_valid: Some(true),
|
||||
invalid_message: None,
|
||||
extra: tier.resets_at.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
return Ok(crate::provider::UsageResult {
|
||||
success: true,
|
||||
data: if data.is_empty() { None } else { Some(data) },
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 通用 JS 脚本路径 ──
|
||||
ProviderService::query_usage(state, app_type, provider_id)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::sync_support::{
|
||||
attach_warning, post_sync_warning_from_result, run_post_import_sync,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::services::s3_sync as s3_sync_service;
|
||||
use crate::settings::{self, S3SyncSettings};
|
||||
use crate::store::AppState;
|
||||
|
||||
fn persist_sync_error(settings: &mut S3SyncSettings, error: &AppError, source: &str) {
|
||||
settings.status.last_error = Some(error.to_string());
|
||||
settings.status.last_error_source = Some(source.to_string());
|
||||
let _ = settings::update_s3_sync_status(settings.status.clone());
|
||||
}
|
||||
|
||||
fn s3_not_configured_error() -> String {
|
||||
AppError::localized(
|
||||
"s3.sync.not_configured",
|
||||
"未配置 S3 同步",
|
||||
"S3 sync is not configured.",
|
||||
)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn s3_sync_disabled_error() -> String {
|
||||
AppError::localized("s3.sync.disabled", "S3 同步未启用", "S3 sync is disabled.").to_string()
|
||||
}
|
||||
|
||||
fn require_enabled_s3_settings() -> Result<S3SyncSettings, String> {
|
||||
let settings = settings::get_s3_sync_settings().ok_or_else(s3_not_configured_error)?;
|
||||
if !settings.enabled {
|
||||
return Err(s3_sync_disabled_error());
|
||||
}
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
fn resolve_secret_for_request(
|
||||
mut incoming: S3SyncSettings,
|
||||
existing: Option<S3SyncSettings>,
|
||||
preserve_empty_secret: bool,
|
||||
) -> S3SyncSettings {
|
||||
if let Some(existing_settings) = existing {
|
||||
if preserve_empty_secret && incoming.secret_access_key.is_empty() {
|
||||
incoming.secret_access_key = existing_settings.secret_access_key;
|
||||
}
|
||||
}
|
||||
incoming
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn s3_sync_mutex() -> &'static tokio::sync::Mutex<()> {
|
||||
s3_sync_service::sync_mutex()
|
||||
}
|
||||
|
||||
async fn run_with_s3_lock<T, Fut>(operation: Fut) -> Result<T, AppError>
|
||||
where
|
||||
Fut: std::future::Future<Output = Result<T, AppError>>,
|
||||
{
|
||||
s3_sync_service::run_with_sync_lock(operation).await
|
||||
}
|
||||
|
||||
fn map_sync_result<T, F>(result: Result<T, AppError>, on_error: F) -> Result<T, String>
|
||||
where
|
||||
F: FnOnce(&AppError),
|
||||
{
|
||||
match result {
|
||||
Ok(value) => Ok(value),
|
||||
Err(err) => {
|
||||
on_error(&err);
|
||||
Err(err.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_test_connection(
|
||||
settings: S3SyncSettings,
|
||||
#[allow(non_snake_case)] preserveEmptyPassword: Option<bool>,
|
||||
) -> Result<Value, String> {
|
||||
let preserve_empty = preserveEmptyPassword.unwrap_or(true);
|
||||
let resolved =
|
||||
resolve_secret_for_request(settings, settings::get_s3_sync_settings(), preserve_empty);
|
||||
s3_sync_service::check_connection(&resolved)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"message": "S3 connection ok"
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_sync_upload(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
let db = state.db.clone();
|
||||
let mut settings = require_enabled_s3_settings()?;
|
||||
|
||||
let result = run_with_s3_lock(s3_sync_service::upload(&db, &mut settings)).await;
|
||||
map_sync_result(result, |error| {
|
||||
persist_sync_error(&mut settings, error, "manual")
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_sync_download(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
let db = state.db.clone();
|
||||
let db_for_sync = db.clone();
|
||||
let mut settings = require_enabled_s3_settings()?;
|
||||
let _auto_sync_suppression = crate::services::s3_auto_sync::AutoSyncSuppressionGuard::new();
|
||||
|
||||
let sync_result = run_with_s3_lock(s3_sync_service::download(&db, &mut settings)).await;
|
||||
let mut result = map_sync_result(sync_result, |error| {
|
||||
persist_sync_error(&mut settings, error, "manual")
|
||||
})?;
|
||||
|
||||
// Post-download sync is best-effort: snapshot restore has already succeeded.
|
||||
let warning = post_sync_warning_from_result(
|
||||
tauri::async_runtime::spawn_blocking(move || run_post_import_sync(db_for_sync))
|
||||
.await
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
if let Some(msg) = warning.as_ref() {
|
||||
log::warn!("[S3] post-download sync warning: {msg}");
|
||||
}
|
||||
result = attach_warning(result, warning);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_sync_save_settings(
|
||||
settings: S3SyncSettings,
|
||||
#[allow(non_snake_case)] passwordTouched: Option<bool>,
|
||||
) -> Result<Value, String> {
|
||||
let password_touched = passwordTouched.unwrap_or(false);
|
||||
let existing = settings::get_s3_sync_settings();
|
||||
let mut sync_settings =
|
||||
resolve_secret_for_request(settings, existing.clone(), !password_touched);
|
||||
|
||||
// Preserve server-owned fields that the frontend does not manage
|
||||
if let Some(existing_settings) = existing {
|
||||
sync_settings.status = existing_settings.status;
|
||||
}
|
||||
|
||||
sync_settings.normalize();
|
||||
sync_settings.validate().map_err(|e| e.to_string())?;
|
||||
settings::set_s3_sync_settings(Some(sync_settings)).map_err(|e| e.to_string())?;
|
||||
Ok(json!({ "success": true }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_sync_fetch_remote_info() -> Result<Value, String> {
|
||||
let settings = require_enabled_s3_settings()?;
|
||||
let info = s3_sync_service::fetch_remote_info(&settings)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(info.unwrap_or(json!({ "empty": true })))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
map_sync_result, persist_sync_error, require_enabled_s3_settings,
|
||||
resolve_secret_for_request, run_with_s3_lock, s3_sync_mutex,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::settings::{AppSettings, S3SyncSettings};
|
||||
use serial_test::serial;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn s3_sync_mutex_is_singleton() {
|
||||
let a = s3_sync_mutex() as *const _;
|
||||
let b = s3_sync_mutex() as *const _;
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn s3_sync_mutex_serializes_concurrent_access() {
|
||||
let guard = s3_sync_mutex().lock().await;
|
||||
let acquired = Arc::new(AtomicBool::new(false));
|
||||
let acquired_bg = Arc::clone(&acquired);
|
||||
|
||||
let waiter = tokio::spawn(async move {
|
||||
let _inner_guard = s3_sync_mutex().lock().await;
|
||||
acquired_bg.store(true, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(40)).await;
|
||||
assert!(!acquired.load(Ordering::SeqCst));
|
||||
|
||||
drop(guard);
|
||||
tokio::time::timeout(Duration::from_secs(1), waiter)
|
||||
.await
|
||||
.expect("background task should complete after lock release")
|
||||
.expect("background task should not panic");
|
||||
|
||||
assert!(acquired.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn map_sync_result_runs_error_handler_after_lock_release() {
|
||||
let result =
|
||||
run_with_s3_lock(async { Err::<(), AppError>(AppError::Config("boom".to_string())) })
|
||||
.await;
|
||||
|
||||
let mut lock_released = false;
|
||||
let mapped = map_sync_result(result, |_| {
|
||||
lock_released = s3_sync_mutex().try_lock().is_ok();
|
||||
});
|
||||
|
||||
assert!(mapped.is_err());
|
||||
assert!(lock_released);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_secret_for_request_preserves_existing_when_requested() {
|
||||
let incoming = S3SyncSettings {
|
||||
region: "us-east-1".to_string(),
|
||||
bucket: "my-bucket".to_string(),
|
||||
access_key_id: "AKID".to_string(),
|
||||
secret_access_key: String::new(),
|
||||
..S3SyncSettings::default()
|
||||
};
|
||||
let existing = Some(S3SyncSettings {
|
||||
secret_access_key: "SECRET".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
});
|
||||
let resolved = resolve_secret_for_request(incoming, existing, true);
|
||||
assert_eq!(resolved.secret_access_key, "SECRET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_secret_for_request_allows_explicit_empty_secret() {
|
||||
let incoming = S3SyncSettings {
|
||||
region: "us-east-1".to_string(),
|
||||
bucket: "my-bucket".to_string(),
|
||||
access_key_id: "AKID".to_string(),
|
||||
secret_access_key: String::new(),
|
||||
..S3SyncSettings::default()
|
||||
};
|
||||
let existing = Some(S3SyncSettings {
|
||||
secret_access_key: "SECRET".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
});
|
||||
let resolved = resolve_secret_for_request(incoming, existing, false);
|
||||
assert!(resolved.secret_access_key.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn persist_sync_error_updates_status_without_overwriting_credentials() {
|
||||
let test_home = std::env::temp_dir().join("cc-switch-s3-sync-error-status-test");
|
||||
let _ = std::fs::remove_dir_all(&test_home);
|
||||
std::fs::create_dir_all(&test_home).expect("create test home");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
|
||||
|
||||
crate::settings::update_settings(AppSettings::default()).expect("reset settings");
|
||||
let mut current = S3SyncSettings {
|
||||
enabled: true,
|
||||
region: "us-east-1".to_string(),
|
||||
bucket: "my-bucket".to_string(),
|
||||
access_key_id: "AKID".to_string(),
|
||||
secret_access_key: "SECRET".to_string(),
|
||||
remote_root: "cc-switch-sync".to_string(),
|
||||
profile: "default".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
};
|
||||
crate::settings::set_s3_sync_settings(Some(current.clone())).expect("seed s3 settings");
|
||||
|
||||
persist_sync_error(
|
||||
&mut current,
|
||||
&crate::error::AppError::Config("boom".to_string()),
|
||||
"manual",
|
||||
);
|
||||
|
||||
let after = crate::settings::get_s3_sync_settings().expect("read s3 settings");
|
||||
assert_eq!(after.region, "us-east-1");
|
||||
assert_eq!(after.bucket, "my-bucket");
|
||||
assert_eq!(after.access_key_id, "AKID");
|
||||
assert_eq!(after.secret_access_key, "SECRET");
|
||||
assert_eq!(after.remote_root, "cc-switch-sync");
|
||||
assert_eq!(after.profile, "default");
|
||||
assert!(
|
||||
after
|
||||
.status
|
||||
.last_error
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("boom"),
|
||||
"status error should be updated"
|
||||
);
|
||||
assert_eq!(after.status.last_error_source.as_deref(), Some("manual"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn require_enabled_s3_settings_rejects_disabled_config() {
|
||||
let test_home = std::env::temp_dir().join("cc-switch-s3-sync-enabled-disabled-test");
|
||||
let _ = std::fs::remove_dir_all(&test_home);
|
||||
std::fs::create_dir_all(&test_home).expect("create test home");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
|
||||
|
||||
crate::settings::update_settings(AppSettings::default()).expect("reset settings");
|
||||
crate::settings::set_s3_sync_settings(Some(S3SyncSettings {
|
||||
enabled: false,
|
||||
region: "us-east-1".to_string(),
|
||||
bucket: "my-bucket".to_string(),
|
||||
access_key_id: "AKID".to_string(),
|
||||
secret_access_key: "SECRET".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
}))
|
||||
.expect("seed disabled s3 settings");
|
||||
|
||||
let err = require_enabled_s3_settings().expect_err("disabled settings should fail");
|
||||
assert!(
|
||||
err.contains("disabled") || err.contains("未启用"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn require_enabled_s3_settings_returns_settings_when_enabled() {
|
||||
let test_home = std::env::temp_dir().join("cc-switch-s3-sync-enabled-ok-test");
|
||||
let _ = std::fs::remove_dir_all(&test_home);
|
||||
std::fs::create_dir_all(&test_home).expect("create test home");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
|
||||
|
||||
crate::settings::update_settings(AppSettings::default()).expect("reset settings");
|
||||
crate::settings::set_s3_sync_settings(Some(S3SyncSettings {
|
||||
enabled: true,
|
||||
region: "us-east-1".to_string(),
|
||||
bucket: "my-bucket".to_string(),
|
||||
access_key_id: "AKID".to_string(),
|
||||
secret_access_key: "SECRET".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
}))
|
||||
.expect("seed enabled s3 settings");
|
||||
|
||||
let settings = require_enabled_s3_settings().expect("enabled settings should be accepted");
|
||||
assert!(settings.enabled);
|
||||
assert_eq!(settings.region, "us-east-1");
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,20 @@ fn merge_settings_for_save(
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match (&mut incoming.s3_sync, &existing.s3_sync) {
|
||||
// incoming 没有 s3 → 保留现有
|
||||
(None, _) => {
|
||||
incoming.s3_sync = existing.s3_sync.clone();
|
||||
}
|
||||
// incoming 有 s3 但密钥为空,且现有有密钥 → 填回现有密钥
|
||||
(Some(incoming_sync), Some(existing_sync))
|
||||
if incoming_sync.secret_access_key.is_empty()
|
||||
&& !existing_sync.secret_access_key.is_empty() =>
|
||||
{
|
||||
incoming_sync.secret_access_key = existing_sync.secret_access_key.clone();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if incoming.local_migrations.is_none() {
|
||||
incoming.local_migrations = existing.local_migrations.clone();
|
||||
} else if let (Some(incoming_migrations), Some(existing_migrations)) =
|
||||
@@ -65,6 +79,12 @@ pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
|
||||
// 在后台延迟重启,让函数有时间返回响应
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
// app.restart() 走 RESTART_EXIT_CODE 路径,ExitRequested 处理器会直接
|
||||
// 放行给 Tauri 默认 re-exec,不执行代理/Live 清理。但本命令用于
|
||||
// app_config_dir 变更后的重启:新实例会切到新数据库,拿不到旧库里的
|
||||
// Live 备份,无法恢复被接管的 Live 配置。因此必须趁旧实例的事件循环
|
||||
// 仍存活,在这里同步完成恢复(保留代理状态,新实例启动时自动重新接管)。
|
||||
crate::cleanup_before_exit(&app).await;
|
||||
app.restart();
|
||||
});
|
||||
Ok(true)
|
||||
@@ -103,7 +123,7 @@ mod tests {
|
||||
use super::merge_settings_for_save;
|
||||
use crate::settings::{
|
||||
AppSettings, CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration,
|
||||
LocalMigrations, WebDavSyncSettings,
|
||||
LocalMigrations, S3SyncSettings, WebDavSyncSettings,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -226,6 +246,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_settings_should_preserve_existing_s3_when_payload_omits_it() {
|
||||
let existing = AppSettings {
|
||||
s3_sync: Some(S3SyncSettings {
|
||||
bucket: "bucket".to_string(),
|
||||
access_key_id: "ak".to_string(),
|
||||
secret_access_key: "secret".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
|
||||
let incoming = AppSettings::default();
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
assert!(merged.s3_sync.is_some());
|
||||
assert_eq!(
|
||||
merged
|
||||
.s3_sync
|
||||
.as_ref()
|
||||
.map(|v| v.secret_access_key.as_str()),
|
||||
Some("secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_settings_should_preserve_s3_secret_when_incoming_has_empty_secret() {
|
||||
let existing = AppSettings {
|
||||
s3_sync: Some(S3SyncSettings {
|
||||
bucket: "bucket".to_string(),
|
||||
access_key_id: "ak".to_string(),
|
||||
secret_access_key: "secret".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
|
||||
let incoming = AppSettings {
|
||||
s3_sync: Some(S3SyncSettings {
|
||||
bucket: "bucket".to_string(),
|
||||
access_key_id: "ak".to_string(),
|
||||
secret_access_key: "".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
}),
|
||||
..AppSettings::default()
|
||||
};
|
||||
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
assert_eq!(
|
||||
merged
|
||||
.s3_sync
|
||||
.as_ref()
|
||||
.map(|v| v.secret_access_key.as_str()),
|
||||
Some("secret")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_settings_should_preserve_local_migrations_when_payload_omits_it() {
|
||||
let existing = AppSettings {
|
||||
|
||||
@@ -276,6 +276,19 @@ pub fn sync_session_usage(
|
||||
}
|
||||
}
|
||||
|
||||
// 同步 OpenCode 使用数据
|
||||
match crate::services::session_usage_opencode::sync_opencode_usage(&state.db) {
|
||||
Ok(opencode_result) => {
|
||||
result.imported += opencode_result.imported;
|
||||
result.skipped += opencode_result.skipped;
|
||||
result.files_scanned += opencode_result.files_scanned;
|
||||
result.errors.extend(opencode_result.errors);
|
||||
}
|
||||
Err(e) => {
|
||||
result.errors.push(format!("OpenCode 同步失败: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,15 @@ impl Database {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// 剪枝是不可逆的:明细一旦汇总删除,0 成本行就永远失去按 pricing_model
|
||||
// 补价重算的机会(启动序列里 seed 定价先于 rollup、但启动回填在 rollup
|
||||
// 之后;周期任务同理)。所以剪枝前先尽力回填一次。失败仅告警不阻断——
|
||||
// 否则一行损坏的定价数据会永久卡死日志清理。
|
||||
// 注意必须在 SAVEPOINT 之外调用:回填内部自己开顶层事务。
|
||||
if let Err(e) = Self::backfill_missing_usage_costs_on_conn(&conn, None) {
|
||||
log::warn!("Pre-prune cost backfill failed, pruning anyway: {e}");
|
||||
}
|
||||
|
||||
// Use a savepoint for atomicity
|
||||
conn.execute("SAVEPOINT rollup_prune;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -106,15 +115,18 @@ impl Database {
|
||||
fn do_rollup_and_prune(conn: &rusqlite::Connection, cutoff: i64) -> Result<u64, AppError> {
|
||||
// Aggregate old logs, merging with any pre-existing rollup rows via LEFT JOIN.
|
||||
let effective_filter = effective_usage_log_filter("l");
|
||||
// request_model 维度保留路由接管的「客户端别名 → 真实模型」映射,
|
||||
// pricing_model 维度保留写入时的计价基准(request 计价模式下与 model 分叉);
|
||||
// 明细行的这两列可能为 NULL(历史/手工数据),归一为 ''。
|
||||
let aggregation_sql = format!(
|
||||
"INSERT OR REPLACE INTO usage_daily_rollups
|
||||
(date, app_type, provider_id, model,
|
||||
(date, app_type, provider_id, model, request_model, pricing_model,
|
||||
request_count, success_count,
|
||||
input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_creation_tokens,
|
||||
total_cost_usd, avg_latency_ms)
|
||||
SELECT
|
||||
d, a, p, m,
|
||||
d, a, p, m, rm, pm,
|
||||
COALESCE(old.request_count, 0) + new_req,
|
||||
COALESCE(old.success_count, 0) + new_succ,
|
||||
COALESCE(old.input_tokens, 0) + new_in,
|
||||
@@ -131,6 +143,8 @@ impl Database {
|
||||
SELECT
|
||||
date(l.created_at, 'unixepoch', 'localtime') as d,
|
||||
l.app_type as a, l.provider_id as p, l.model as m,
|
||||
COALESCE(l.request_model, '') as rm,
|
||||
COALESCE(l.pricing_model, '') as pm,
|
||||
COUNT(*) as new_req,
|
||||
SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END) as new_succ,
|
||||
COALESCE(SUM(l.input_tokens), 0) as new_in,
|
||||
@@ -141,11 +155,12 @@ impl Database {
|
||||
COALESCE(AVG(l.latency_ms), 0) as new_lat
|
||||
FROM proxy_request_logs l
|
||||
WHERE l.created_at < ?1 AND {effective_filter}
|
||||
GROUP BY d, a, p, m
|
||||
GROUP BY d, a, p, m, rm, pm
|
||||
) agg
|
||||
LEFT JOIN usage_daily_rollups old
|
||||
ON old.date = agg.d AND old.app_type = agg.a
|
||||
AND old.provider_id = agg.p AND old.model = agg.m"
|
||||
AND old.provider_id = agg.p AND old.model = agg.m
|
||||
AND old.request_model = agg.rm AND old.pricing_model = agg.pm"
|
||||
);
|
||||
|
||||
conn.execute(&aggregation_sql, [cutoff])
|
||||
@@ -325,6 +340,144 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollup_preserves_request_model_dimension() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let old_ts = now - 40 * 86400;
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
// 路由接管行:model 是真实上游模型,request_model 是客户端别名。
|
||||
// 同 model 下两个不同别名必须各自成行,prune 后映射关系仍可审计。
|
||||
for (i, request_model) in [
|
||||
("a", "claude-sonnet-4-6"),
|
||||
("b", "claude-sonnet-4-6"),
|
||||
("c", "claude-haiku-4-5"),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES (?1, 'p1', 'claude', 'kimi-k2', ?2, 100, 50, '0.01', 100, 200, ?3)",
|
||||
rusqlite::params![format!("takeover-{i}"), request_model, old_ts],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
let deleted = db.rollup_and_prune(30)?;
|
||||
assert_eq!(deleted, 3);
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT request_model, request_count FROM usage_daily_rollups
|
||||
WHERE model = 'kimi-k2' ORDER BY request_model",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
assert_eq!(
|
||||
rows,
|
||||
vec![
|
||||
("claude-haiku-4-5".to_string(), 1),
|
||||
("claude-sonnet-4-6".to_string(), 2),
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollup_preserves_pricing_model_dimension() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let old_ts = now - 40 * 86400;
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
// request 计价模式下 pricing_model 与 model 分叉,必须各自成行
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model, pricing_model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES ('pm-a', 'p1', 'claude', 'kimi-k2', 'claude-sonnet-4-6', 'kimi-k2',
|
||||
100, 50, '0.01', 100, 200, ?1)",
|
||||
rusqlite::params![old_ts],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model, pricing_model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES ('pm-b', 'p1', 'claude', 'kimi-k2', 'claude-sonnet-4-6', 'claude-sonnet-4-6',
|
||||
100, 50, '0.30', 100, 200, ?1)",
|
||||
rusqlite::params![old_ts],
|
||||
)?;
|
||||
}
|
||||
|
||||
let deleted = db.rollup_and_prune(30)?;
|
||||
assert_eq!(deleted, 2);
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT pricing_model, total_cost_usd FROM usage_daily_rollups
|
||||
WHERE model = 'kimi-k2' ORDER BY pricing_model",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].0, "claude-sonnet-4-6");
|
||||
assert_eq!(rows[1].0, "kimi-k2");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollup_backfills_costs_before_pruning() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let old_ts = now - 40 * 86400;
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
// >30 天的 0 成本行:pricing_model(gpt-5.5)在 seed 定价表中有价。
|
||||
// 剪枝是不可逆的,rollup 必须先回填再汇总,否则按 0 永久入账。
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model, pricing_model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES ('prune-backfill', 'p1', 'codex', 'gpt-5.5', 'gpt-5.5', 'gpt-5.5',
|
||||
1000000, 0, '0', 100, 200, ?1)",
|
||||
rusqlite::params![old_ts],
|
||||
)?;
|
||||
}
|
||||
|
||||
let deleted = db.rollup_and_prune(30)?;
|
||||
assert_eq!(deleted, 1);
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let total_cost: f64 = conn.query_row(
|
||||
"SELECT CAST(total_cost_usd AS REAL) FROM usage_daily_rollups
|
||||
WHERE model = 'gpt-5.5'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
// gpt-5.5 input $5/M × 1M tokens,回填后再汇总
|
||||
assert!(
|
||||
(total_cost - 5.0).abs() < 1e-6,
|
||||
"expected backfilled cost 5.0, got {total_cost}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollup_noop_when_no_old_data() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
@@ -49,7 +49,7 @@ use std::sync::Mutex;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 10;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 11;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
@@ -82,6 +82,7 @@ fn register_db_change_hook(conn: &Connection) {
|
||||
|action: Action, _database: &str, table: &str, _row_id: i64| match action {
|
||||
Action::SQLITE_INSERT | Action::SQLITE_UPDATE | Action::SQLITE_DELETE => {
|
||||
crate::services::webdav_auto_sync::notify_db_changed(table);
|
||||
crate::services::s3_auto_sync::notify_db_changed(table);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
|
||||
@@ -181,9 +181,12 @@ impl Database {
|
||||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 10. Proxy Request Logs 表
|
||||
// pricing_model = 写入时实际用于计价的模型名(pricing_model_source 解析结果),
|
||||
// 回填按它重算;NULL 表示 v11 之前的历史行,'' 表示未计价的错误行。
|
||||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
||||
request_model TEXT,
|
||||
pricing_model TEXT,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
@@ -255,12 +258,17 @@ impl Database {
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 17. Usage Daily Rollups 表 (日聚合统计)
|
||||
// request_model 保留路由接管的「客户端别名 → 真实模型」映射维度,
|
||||
// pricing_model 保留写入时的计价基准(request 计价模式下与 model 分叉),
|
||||
// 否则明细被 prune 后接管计费不可审计;历史行迁移时填 ''(未知)。
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS usage_daily_rollups (
|
||||
date TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
request_model TEXT NOT NULL DEFAULT '',
|
||||
pricing_model TEXT NOT NULL DEFAULT '',
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
success_count INTEGER NOT NULL DEFAULT 0,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -269,7 +277,7 @@ impl Database {
|
||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (date, app_type, provider_id, model)
|
||||
PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
@@ -431,6 +439,11 @@ impl Database {
|
||||
Self::migrate_v9_to_v10(conn)?;
|
||||
Self::set_user_version(conn, 10)?;
|
||||
}
|
||||
10 => {
|
||||
log::info!("迁移数据库从 v10 到 v11(usage_daily_rollups 保留 request_model 维度)");
|
||||
Self::migrate_v10_to_v11(conn)?;
|
||||
Self::set_user_version(conn, 11)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -1200,11 +1213,77 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v10 -> v11:usage_daily_rollups 增加 request_model 维度(进入主键),
|
||||
/// proxy_request_logs 增加 pricing_model 列(写入时的计价基准,回填依据)。
|
||||
///
|
||||
/// 路由接管下 model(真实上游模型)≠ request_model(客户端别名),
|
||||
/// 旧 rollup 只按 model 聚合,明细 prune 后映射关系永久丢失、计费不可审计。
|
||||
/// SQLite 改主键必须重建表;历史行的 request_model 已不可知,填 ''。
|
||||
fn migrate_v10_to_v11(conn: &Connection) -> Result<(), AppError> {
|
||||
// proxy_request_logs.pricing_model:NULL = v11 前的历史行(回填走
|
||||
// model → 占位符回退 request_model 的旧逻辑),'' = 未计价的错误行
|
||||
if Self::table_exists(conn, "proxy_request_logs")? {
|
||||
Self::add_column_if_missing(conn, "proxy_request_logs", "pricing_model", "TEXT")?;
|
||||
}
|
||||
|
||||
if !Self::table_exists(conn, "usage_daily_rollups")? {
|
||||
log::info!("v10 -> v11:usage_daily_rollups 不存在,跳过重建");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE usage_daily_rollups RENAME TO usage_daily_rollups_v10;
|
||||
CREATE TABLE usage_daily_rollups (
|
||||
date TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
request_model TEXT NOT NULL DEFAULT '',
|
||||
pricing_model TEXT NOT NULL DEFAULT '',
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
success_count INTEGER NOT NULL DEFAULT 0,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model)
|
||||
);
|
||||
INSERT INTO usage_daily_rollups
|
||||
(date, app_type, provider_id, model, request_model, pricing_model,
|
||||
request_count, success_count, input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms)
|
||||
SELECT date, app_type, provider_id, model, '', '',
|
||||
request_count, success_count, input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
|
||||
FROM usage_daily_rollups_v10;
|
||||
DROP TABLE usage_daily_rollups_v10;",
|
||||
)
|
||||
.map_err(|e| {
|
||||
AppError::Database(format!("v10 -> v11 重建 usage_daily_rollups 失败: {e}"))
|
||||
})?;
|
||||
|
||||
log::info!(
|
||||
"v10 -> v11 迁移完成:usage_daily_rollups 已保留 request_model/pricing_model 维度"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
||||
let pricing_data = [
|
||||
// Claude Fable 5(Opus 之上的新档)
|
||||
(
|
||||
"claude-fable-5",
|
||||
"Claude Fable 5",
|
||||
"10",
|
||||
"50",
|
||||
"1.00",
|
||||
"12.50",
|
||||
),
|
||||
// Claude 4.8 系列
|
||||
(
|
||||
"claude-opus-4-8",
|
||||
@@ -1571,6 +1650,14 @@ impl Database {
|
||||
"0",
|
||||
),
|
||||
// StepFun 系列
|
||||
(
|
||||
"step-3.7-flash",
|
||||
"Step 3.7 Flash",
|
||||
"0.19",
|
||||
"1.13",
|
||||
"0.04",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"step-3.5-flash",
|
||||
"Step 3.5 Flash",
|
||||
@@ -1602,7 +1689,7 @@ impl Database {
|
||||
"Doubao Seed 2.0 Pro",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0",
|
||||
"0.09",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
@@ -1610,7 +1697,7 @@ impl Database {
|
||||
"Doubao Seed 2.0 Code",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0",
|
||||
"0.09",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
@@ -1618,15 +1705,15 @@ impl Database {
|
||||
"Doubao Seed 2.0 Code Preview",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0",
|
||||
"0.09",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-lite",
|
||||
"Doubao Seed 2.0 Lite",
|
||||
"0.25",
|
||||
"2",
|
||||
"0",
|
||||
"0.08",
|
||||
"0.50",
|
||||
"0.017",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
@@ -1634,7 +1721,7 @@ impl Database {
|
||||
"Doubao Seed 2.0 Mini",
|
||||
"0.03",
|
||||
"0.31",
|
||||
"0",
|
||||
"0.0056",
|
||||
"0",
|
||||
),
|
||||
// DeepSeek 系列
|
||||
@@ -1706,7 +1793,7 @@ impl Database {
|
||||
"0.14",
|
||||
"0",
|
||||
),
|
||||
("kimi-k2.5", "Kimi K2.5", "0.60", "2.50", "0.10", "0"),
|
||||
("kimi-k2.5", "Kimi K2.5", "0.60", "3.00", "0.10", "0"),
|
||||
("kimi-k2.6", "Kimi K2.6", "0.95", "4.00", "0.16", "0"),
|
||||
// MiniMax 系列
|
||||
("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"),
|
||||
@@ -1719,7 +1806,7 @@ impl Database {
|
||||
"0",
|
||||
),
|
||||
("minimax-m2", "MiniMax M2", "0.27", "0.95", "0.03", "0"),
|
||||
("minimax-m2.5", "MiniMax M2.5", "0.12", "0.95", "0.03", "0"),
|
||||
("minimax-m2.5", "MiniMax M2.5", "0.15", "0.95", "0.03", "0"),
|
||||
(
|
||||
"minimax-m2.5-lightning",
|
||||
"MiniMax M2.5 Lightning",
|
||||
@@ -1746,8 +1833,8 @@ impl Database {
|
||||
),
|
||||
("minimax-m3", "MiniMax M3", "0.60", "2.40", "0.12", "0"),
|
||||
// GLM (智谱)
|
||||
("glm-4.7", "GLM-4.7", "0.39", "1.75", "0.04", "0"),
|
||||
("glm-4.6", "GLM-4.6", "0.28", "1.11", "0.03", "0"),
|
||||
("glm-4.7", "GLM-4.7", "0.6", "2.2", "0.11", "0"),
|
||||
("glm-4.6", "GLM-4.6", "0.6", "2.2", "0.11", "0"),
|
||||
("glm-5", "GLM-5", "1", "3.2", "0.2", "0"),
|
||||
("glm-5.1", "GLM-5.1", "1.4", "4.4", "0.26", "0"),
|
||||
// MiMo (小米)
|
||||
@@ -1759,12 +1846,28 @@ impl Database {
|
||||
"0.009",
|
||||
"0",
|
||||
),
|
||||
("mimo-v2-pro", "MiMo V2 Pro", "1", "3", "0", "0"),
|
||||
("mimo-v2.5", "MiMo V2.5", "0.09", "0.29", "0.009", "0"),
|
||||
("mimo-v2.5-pro", "MiMo V2.5 Pro", "1", "3", "0", "0"),
|
||||
("mimo-v2-pro", "MiMo V2 Pro", "0.435", "0.87", "0.0036", "0"),
|
||||
("mimo-v2.5", "MiMo V2.5", "0.14", "0.29", "0.0028", "0"),
|
||||
(
|
||||
"mimo-v2.5-pro",
|
||||
"MiMo V2.5 Pro",
|
||||
"0.435",
|
||||
"0.87",
|
||||
"0.0036",
|
||||
"0",
|
||||
),
|
||||
// Qwen 系列 (阿里巴巴)
|
||||
("qwen3.6-plus", "Qwen3.6 Plus", "0.325", "1.95", "0", "0"),
|
||||
("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0", "0"),
|
||||
("qwen3.7-max", "Qwen3.7 Max", "2.50", "7.50", "0.25", "0"),
|
||||
("qwen3.7-plus", "Qwen3.7 Plus", "0.40", "1.60", "0.08", "0"),
|
||||
(
|
||||
"qwen3.6-plus",
|
||||
"Qwen3.6 Plus",
|
||||
"0.325",
|
||||
"1.95",
|
||||
"0.065",
|
||||
"0",
|
||||
),
|
||||
("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0.052", "0"),
|
||||
("qwen3-max", "Qwen3 Max", "0.78", "3.90", "0", "0"),
|
||||
(
|
||||
"qwen3-235b-a22b",
|
||||
@@ -1779,7 +1882,7 @@ impl Database {
|
||||
"Qwen3 Coder Plus",
|
||||
"0.65",
|
||||
"3.25",
|
||||
"0",
|
||||
"0.13",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
@@ -1803,7 +1906,7 @@ impl Database {
|
||||
"Qwen3 Coder Flash",
|
||||
"0.195",
|
||||
"0.975",
|
||||
"0",
|
||||
"0.039",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
@@ -1818,19 +1921,20 @@ impl Database {
|
||||
("qwq-32b", "QwQ 32B", "0.20", "0.60", "0", "0"),
|
||||
("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"),
|
||||
// Grok 系列 (xAI)
|
||||
("grok-4.3", "Grok 4.3", "1.25", "2.50", "0.20", "0"),
|
||||
(
|
||||
"grok-4.20-0309-reasoning",
|
||||
"Grok 4.20 Reasoning",
|
||||
"2",
|
||||
"6",
|
||||
"1.25",
|
||||
"2.50",
|
||||
"0.20",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"grok-4.20-0309-non-reasoning",
|
||||
"Grok 4.20",
|
||||
"2",
|
||||
"6",
|
||||
"1.25",
|
||||
"2.50",
|
||||
"0.20",
|
||||
"0",
|
||||
),
|
||||
@@ -1863,6 +1967,38 @@ impl Database {
|
||||
("grok-3", "Grok 3", "3", "15", "0.75", "0"),
|
||||
("grok-3-mini", "Grok 3 Mini", "0.25", "0.50", "0.075", "0"),
|
||||
// Mistral 系列
|
||||
(
|
||||
"mistral-medium-3.5",
|
||||
"Mistral Medium 3.5",
|
||||
"1.50",
|
||||
"7.50",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"mistral-small-4",
|
||||
"Mistral Small 4",
|
||||
"0.10",
|
||||
"0.30",
|
||||
"0.01",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"devstral-small-2-2512",
|
||||
"Devstral Small 2",
|
||||
"0.10",
|
||||
"0.30",
|
||||
"0.01",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"magistral-small",
|
||||
"Magistral Small",
|
||||
"0.50",
|
||||
"1.50",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
("codestral-2508", "Codestral", "0.30", "0.90", "0.03", "0"),
|
||||
(
|
||||
"devstral-small-1.1",
|
||||
@@ -1872,7 +2008,7 @@ impl Database {
|
||||
"0.01",
|
||||
"0",
|
||||
),
|
||||
("devstral-2-2512", "Devstral 2", "0.40", "0.90", "0.04", "0"),
|
||||
("devstral-2-2512", "Devstral 2", "0.40", "2", "0.04", "0"),
|
||||
(
|
||||
"devstral-medium",
|
||||
"Devstral Medium",
|
||||
@@ -1953,6 +2089,225 @@ impl Database {
|
||||
|
||||
fn repair_current_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
||||
let pricing_fixes = [
|
||||
// 2026-06-10 全量核价(厂商官方 list 价;CNY 按 ~7.14 折算)
|
||||
// GLM 4.6/4.7:旧值是中转/OpenRouter 折扣价,统一到 Z.ai 官方(与 glm-5/5.1 一致)
|
||||
(
|
||||
"glm-4.7", "GLM-4.7", "0.6", "2.2", "0.11", "0", "0.39", "1.75", "0.04", "0",
|
||||
),
|
||||
(
|
||||
"glm-4.6", "GLM-4.6", "0.6", "2.2", "0.11", "0", "0.28", "1.11", "0.03", "0",
|
||||
),
|
||||
// Grok 4.20:xAI 已降价 2/6 → 1.25/2.50
|
||||
(
|
||||
"grok-4.20-0309-reasoning",
|
||||
"Grok 4.20 Reasoning",
|
||||
"1.25",
|
||||
"2.50",
|
||||
"0.20",
|
||||
"0",
|
||||
"2",
|
||||
"6",
|
||||
"0.20",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"grok-4.20-0309-non-reasoning",
|
||||
"Grok 4.20",
|
||||
"1.25",
|
||||
"2.50",
|
||||
"0.20",
|
||||
"0",
|
||||
"2",
|
||||
"6",
|
||||
"0.20",
|
||||
"0",
|
||||
),
|
||||
// Kimi K2.5 官方 output 3.00
|
||||
(
|
||||
"kimi-k2.5",
|
||||
"Kimi K2.5",
|
||||
"0.60",
|
||||
"3.00",
|
||||
"0.10",
|
||||
"0",
|
||||
"0.60",
|
||||
"2.50",
|
||||
"0.10",
|
||||
"0",
|
||||
),
|
||||
// MiniMax M2.5 input 0.15
|
||||
(
|
||||
"minimax-m2.5",
|
||||
"MiniMax M2.5",
|
||||
"0.15",
|
||||
"0.95",
|
||||
"0.03",
|
||||
"0",
|
||||
"0.12",
|
||||
"0.95",
|
||||
"0.03",
|
||||
"0",
|
||||
),
|
||||
// Mistral Devstral 2 output 0.90 → 2(与同表 devstral-medium 一致)
|
||||
(
|
||||
"devstral-2-2512",
|
||||
"Devstral 2",
|
||||
"0.40",
|
||||
"2",
|
||||
"0.04",
|
||||
"0",
|
||||
"0.40",
|
||||
"0.90",
|
||||
"0.04",
|
||||
"0",
|
||||
),
|
||||
// Doubao Seed 2.0:lite 旧价贵 3-4 倍 + 全系补 cache 命中价
|
||||
(
|
||||
"doubao-seed-2-0-lite",
|
||||
"Doubao Seed 2.0 Lite",
|
||||
"0.08",
|
||||
"0.50",
|
||||
"0.017",
|
||||
"0",
|
||||
"0.25",
|
||||
"2",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-pro",
|
||||
"Doubao Seed 2.0 Pro",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0.09",
|
||||
"0",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-code",
|
||||
"Doubao Seed 2.0 Code",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0.09",
|
||||
"0",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-code-preview-latest",
|
||||
"Doubao Seed 2.0 Code Preview",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0.09",
|
||||
"0",
|
||||
"0.47",
|
||||
"2.37",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"doubao-seed-2-0-mini",
|
||||
"Doubao Seed 2.0 Mini",
|
||||
"0.03",
|
||||
"0.31",
|
||||
"0.0056",
|
||||
"0",
|
||||
"0.03",
|
||||
"0.31",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
// MiMo:5/27 永久降价,旧值是旧价
|
||||
(
|
||||
"mimo-v2-pro",
|
||||
"MiMo V2 Pro",
|
||||
"0.435",
|
||||
"0.87",
|
||||
"0.0036",
|
||||
"0",
|
||||
"1",
|
||||
"3",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"mimo-v2.5",
|
||||
"MiMo V2.5",
|
||||
"0.14",
|
||||
"0.29",
|
||||
"0.0028",
|
||||
"0",
|
||||
"0.09",
|
||||
"0.29",
|
||||
"0.009",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"mimo-v2.5-pro",
|
||||
"MiMo V2.5 Pro",
|
||||
"0.435",
|
||||
"0.87",
|
||||
"0.0036",
|
||||
"0",
|
||||
"1",
|
||||
"3",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
// Qwen:官方"隐式缓存 = 输入 20%"补 cache 命中价
|
||||
(
|
||||
"qwen3.6-plus",
|
||||
"Qwen3.6 Plus",
|
||||
"0.325",
|
||||
"1.95",
|
||||
"0.065",
|
||||
"0",
|
||||
"0.325",
|
||||
"1.95",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"qwen3.5-plus",
|
||||
"Qwen3.5 Plus",
|
||||
"0.26",
|
||||
"1.56",
|
||||
"0.052",
|
||||
"0",
|
||||
"0.26",
|
||||
"1.56",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"qwen3-coder-plus",
|
||||
"Qwen3 Coder Plus",
|
||||
"0.65",
|
||||
"3.25",
|
||||
"0.13",
|
||||
"0",
|
||||
"0.65",
|
||||
"3.25",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"qwen3-coder-flash",
|
||||
"Qwen3 Coder Flash",
|
||||
"0.195",
|
||||
"0.975",
|
||||
"0.039",
|
||||
"0",
|
||||
"0.195",
|
||||
"0.975",
|
||||
"0",
|
||||
"0",
|
||||
),
|
||||
(
|
||||
"deepseek-v4-flash",
|
||||
"DeepSeek V4 Flash",
|
||||
|
||||
@@ -345,6 +345,87 @@ fn schema_migration_v4_adds_pricing_model_columns() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v10_to_v11_rebuilds_rollups_with_request_model_dimension() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
|
||||
// 模拟 v10 形状的 rollup 表(主键不含 request_model)+ 一行历史聚合数据,
|
||||
// 以及 v10 形状的明细表(无 pricing_model 列)
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE proxy_request_logs (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
model TEXT NOT NULL,
|
||||
request_model TEXT
|
||||
);
|
||||
CREATE TABLE usage_daily_rollups (
|
||||
date TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
success_count INTEGER NOT NULL DEFAULT 0,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (date, app_type, provider_id, model)
|
||||
);
|
||||
INSERT INTO usage_daily_rollups
|
||||
(date, app_type, provider_id, model, request_count, success_count,
|
||||
input_tokens, output_tokens, total_cost_usd, avg_latency_ms)
|
||||
VALUES ('2026-05-01', 'claude', 'p1', 'kimi-k2', 7, 7, 1000, 500, '0.07', 120);
|
||||
"#,
|
||||
)
|
||||
.expect("seed v10 rollup table");
|
||||
|
||||
Database::set_user_version(&conn, 10).expect("set user_version=10");
|
||||
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
||||
|
||||
// 新列存在且 NOT NULL DEFAULT ''
|
||||
let request_model = get_column_info(&conn, "usage_daily_rollups", "request_model");
|
||||
assert_eq!(request_model.r#type, "TEXT");
|
||||
assert_eq!(request_model.notnull, 1);
|
||||
let rollup_pricing_model = get_column_info(&conn, "usage_daily_rollups", "pricing_model");
|
||||
assert_eq!(rollup_pricing_model.r#type, "TEXT");
|
||||
assert_eq!(rollup_pricing_model.notnull, 1);
|
||||
|
||||
// 明细表补上 pricing_model 列(可空,历史行 NULL)
|
||||
let pricing_model = get_column_info(&conn, "proxy_request_logs", "pricing_model");
|
||||
assert_eq!(pricing_model.r#type, "TEXT");
|
||||
assert_eq!(pricing_model.notnull, 0);
|
||||
|
||||
// 历史行保留,request_model 填 ''(未知)
|
||||
let (rm, count, input, cost): (String, i64, i64, String) = conn
|
||||
.query_row(
|
||||
"SELECT request_model, request_count, input_tokens, total_cost_usd
|
||||
FROM usage_daily_rollups WHERE model = 'kimi-k2'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.expect("migrated row");
|
||||
assert_eq!(rm, "");
|
||||
assert_eq!(count, 7);
|
||||
assert_eq!(input, 1000);
|
||||
assert_eq!(cost, "0.07");
|
||||
|
||||
// 主键包含 request_model:同 model 不同别名可共存
|
||||
conn.execute(
|
||||
"INSERT INTO usage_daily_rollups
|
||||
(date, app_type, provider_id, model, request_model, request_count)
|
||||
VALUES ('2026-05-01', 'claude', 'p1', 'kimi-k2', 'claude-sonnet-4-6', 1)",
|
||||
[],
|
||||
)
|
||||
.expect("insert row with same model but different request_model");
|
||||
|
||||
assert_eq!(
|
||||
Database::get_user_version(&conn).expect("version after migration"),
|
||||
SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
|
||||
@@ -9,7 +9,51 @@ use super::DeepLinkImportRequest;
|
||||
use crate::AppType;
|
||||
use crate::{store::AppState, Database};
|
||||
use base64::prelude::*;
|
||||
use std::sync::Arc;
|
||||
use std::{env, ffi::OsString, sync::Arc};
|
||||
|
||||
struct TestHomeGuard {
|
||||
_dir: tempfile::TempDir,
|
||||
original_home: Option<OsString>,
|
||||
original_userprofile: Option<OsString>,
|
||||
original_test_home: Option<OsString>,
|
||||
}
|
||||
|
||||
impl TestHomeGuard {
|
||||
fn new() -> Self {
|
||||
let dir = tempfile::tempdir().expect("create isolated test home");
|
||||
let original_home = env::var_os("HOME");
|
||||
let original_userprofile = env::var_os("USERPROFILE");
|
||||
let original_test_home = env::var_os("CC_SWITCH_TEST_HOME");
|
||||
|
||||
env::set_var("HOME", dir.path());
|
||||
env::set_var("USERPROFILE", dir.path());
|
||||
env::set_var("CC_SWITCH_TEST_HOME", dir.path());
|
||||
|
||||
Self {
|
||||
_dir: dir,
|
||||
original_home,
|
||||
original_userprofile,
|
||||
original_test_home,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestHomeGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.original_test_home {
|
||||
Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
match &self.original_userprofile {
|
||||
Some(value) => env::set_var("USERPROFILE", value),
|
||||
None => env::remove_var("USERPROFILE"),
|
||||
}
|
||||
match &self.original_home {
|
||||
Some(value) => env::set_var("HOME", value),
|
||||
None => env::remove_var("HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Parser Tests
|
||||
@@ -477,8 +521,12 @@ fn test_build_claude_provider_without_config_unchanged() {
|
||||
// Prompt Tests
|
||||
// =============================================================================
|
||||
|
||||
// Integration-style unit test: prompt import reaches PromptService and resolves
|
||||
// live config file paths, so HOME must be isolated before it runs.
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_import_prompt_allows_space_in_base64_content() {
|
||||
let _test_home = TestHomeGuard::new();
|
||||
let url = "ccswitch://v1/import?resource=prompt&app=codex&name=PromptPlus&content=Pj4+";
|
||||
let request = parse_deeplink_url(url).unwrap();
|
||||
|
||||
|
||||
@@ -116,10 +116,76 @@ pub fn read_hermes_config() -> Result<serde_yaml::Value, AppError> {
|
||||
return Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
|
||||
}
|
||||
|
||||
serde_yaml::from_str(&content)
|
||||
// Heal duplicate top-level keys left behind by the pre-CRLF-fix append
|
||||
// bug (#3633); serde_yaml rejects them outright, which bricked the panel.
|
||||
let deduped = deduplicate_top_level_keys(&content);
|
||||
|
||||
serde_yaml::from_str(&deduped)
|
||||
.map_err(|e| AppError::Config(format!("Failed to parse Hermes config as YAML: {e}")))
|
||||
}
|
||||
|
||||
/// Remove duplicate top-level YAML sections, keeping the LAST occurrence of
|
||||
/// each key.
|
||||
///
|
||||
/// Keep-last is deliberate, not arbitrary: the duplicates come from section
|
||||
/// replacement degrading into appends (#3633), so the last block is the
|
||||
/// newest data — and Hermes itself reads the file with PyYAML, whose
|
||||
/// duplicate-key semantics are last-wins. Keeping the first occurrence would
|
||||
/// silently roll the user back to stale config and diverge from what Hermes
|
||||
/// actually runs with.
|
||||
fn deduplicate_top_level_keys(raw: &str) -> String {
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Pass 1: locate every top-level key line as (key, byte offset).
|
||||
let mut sections: Vec<(&str, usize)> = Vec::new();
|
||||
let mut offset = 0;
|
||||
for line in raw.split('\n') {
|
||||
if is_top_level_key_line(line) {
|
||||
if let Some(colon_pos) = line.find(':') {
|
||||
sections.push((&line[..colon_pos], offset));
|
||||
}
|
||||
}
|
||||
offset += line.len() + 1;
|
||||
}
|
||||
|
||||
let mut remaining: HashMap<&str, usize> = HashMap::new();
|
||||
for (key, _) in §ions {
|
||||
*remaining.entry(key).or_insert(0) += 1;
|
||||
}
|
||||
if remaining.values().all(|&count| count <= 1) {
|
||||
return raw.to_string();
|
||||
}
|
||||
|
||||
// Pass 2: re-emit, dropping every section that has a later occurrence of
|
||||
// the same key. A section spans from its key line to the next top-level
|
||||
// key line (or EOF), matching find_yaml_section_range. Content before the
|
||||
// first section (comments, document markers) is always kept.
|
||||
let mut result = String::with_capacity(raw.len());
|
||||
let head_end = sections
|
||||
.first()
|
||||
.map(|&(_, start)| start)
|
||||
.unwrap_or(raw.len());
|
||||
result.push_str(&raw[..head_end]);
|
||||
|
||||
for (i, &(key, start)) in sections.iter().enumerate() {
|
||||
let end = sections
|
||||
.get(i + 1)
|
||||
.map(|&(_, next_start)| next_start)
|
||||
.unwrap_or(raw.len());
|
||||
let count = remaining.get_mut(key).expect("key collected in pass 1");
|
||||
*count -= 1;
|
||||
if *count > 0 {
|
||||
log::warn!(
|
||||
"Hermes config: dropped duplicate top-level section '{key}' (keeping the last occurrence)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
result.push_str(&raw[start..end]);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// YAML Section-Level Replacement
|
||||
// ============================================================================
|
||||
@@ -132,6 +198,11 @@ pub fn read_hermes_config() -> Result<serde_yaml::Value, AppError> {
|
||||
/// - Not be a comment (starting with `#`)
|
||||
/// - Not be a sequence item (starting with `-`)
|
||||
/// - Contain `:` followed by space, tab, newline, or end-of-line
|
||||
///
|
||||
/// Lines may carry a trailing `\r` (CRLF files split on `\n`) or `\n`
|
||||
/// (callers using `split_inclusive`); both count as end-of-line after the
|
||||
/// colon. Rejecting `\r` here used to make every section lookup miss on
|
||||
/// CRLF configs, turning section replacement into endless appends (#3633).
|
||||
fn is_top_level_key_line(line: &str) -> bool {
|
||||
if line.is_empty() {
|
||||
return false;
|
||||
@@ -142,7 +213,7 @@ fn is_top_level_key_line(line: &str) -> bool {
|
||||
}
|
||||
if let Some(colon_pos) = line.find(':') {
|
||||
let after_colon = &line[colon_pos + 1..];
|
||||
after_colon.is_empty() || after_colon.starts_with(' ') || after_colon.starts_with('\t')
|
||||
after_colon.is_empty() || after_colon.starts_with([' ', '\t', '\r', '\n'])
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -196,6 +267,21 @@ fn serialize_yaml_section(key: &str, value: &serde_yaml::Value) -> Result<String
|
||||
Ok(yaml_str)
|
||||
}
|
||||
|
||||
/// Remove every top-level section with the given key from raw YAML text.
|
||||
/// Used to clean residual duplicates of a key after replacing its first
|
||||
/// occurrence; safe values come from the keep-last healed read, so dropping
|
||||
/// all on-disk copies here loses nothing.
|
||||
fn remove_all_sections(raw: &str, section_key: &str) -> String {
|
||||
let mut result = String::with_capacity(raw.len());
|
||||
let mut rest = raw;
|
||||
while let Some((start, end)) = find_yaml_section_range(rest, section_key) {
|
||||
result.push_str(&rest[..start]);
|
||||
rest = &rest[end..];
|
||||
}
|
||||
result.push_str(rest);
|
||||
result
|
||||
}
|
||||
|
||||
/// Replace a YAML section in raw text, or append it if not found.
|
||||
fn replace_yaml_section(
|
||||
raw: &str,
|
||||
@@ -208,12 +294,14 @@ fn replace_yaml_section(
|
||||
let mut result = String::with_capacity(raw.len());
|
||||
result.push_str(&raw[..start]);
|
||||
result.push_str(&serialized);
|
||||
// Drop duplicate sections of this key from the remainder — configs
|
||||
// written before the CRLF fix may carry several appended copies.
|
||||
let remainder = remove_all_sections(&raw[end..], section_key);
|
||||
// Ensure proper separation between sections
|
||||
let remainder = &raw[end..];
|
||||
if !serialized.ends_with('\n') && !remainder.is_empty() && !remainder.starts_with('\n') {
|
||||
result.push('\n');
|
||||
}
|
||||
result.push_str(remainder);
|
||||
result.push_str(&remainder);
|
||||
Ok(result)
|
||||
} else {
|
||||
// Section not found — append at end
|
||||
@@ -1204,6 +1292,111 @@ model:
|
||||
assert!(!section.starts_with("model_extra:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_section_handles_crlf() {
|
||||
// Regression for #3633: CRLF line endings must not hide sections.
|
||||
let yaml = "model:\r\n default: gpt-4\r\nagent:\r\n max_turns: 10\r\n";
|
||||
let (start, end) = find_yaml_section_range(yaml, "model").unwrap();
|
||||
let section = &yaml[start..end];
|
||||
assert!(section.starts_with("model:"));
|
||||
assert!(section.contains("default: gpt-4"));
|
||||
assert!(!section.contains("agent:"));
|
||||
}
|
||||
|
||||
// ---- deduplicate_top_level_keys tests ----
|
||||
|
||||
#[test]
|
||||
fn dedup_keeps_last_occurrence() {
|
||||
// Duplicates come from replace-degraded-to-append, so the last block
|
||||
// is the newest data and must win (PyYAML last-wins, like Hermes).
|
||||
let yaml = "\
|
||||
model:
|
||||
default: gpt-4
|
||||
agent:
|
||||
max_turns: 10
|
||||
model:
|
||||
default: claude-opus-4-8
|
||||
";
|
||||
let result = deduplicate_top_level_keys(yaml);
|
||||
assert_eq!(
|
||||
result.lines().filter(|l| *l == "model:").count(),
|
||||
1,
|
||||
"duplicate model: section was not removed"
|
||||
);
|
||||
assert!(result.contains("claude-opus-4-8"));
|
||||
assert!(!result.contains("gpt-4"));
|
||||
assert!(result.contains("max_turns"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_handles_crlf() {
|
||||
let yaml = "model:\r\n default: gpt-4\r\nagent:\r\n max_turns: 10\r\nmodel:\r\n default: claude\r\n";
|
||||
let result = deduplicate_top_level_keys(yaml);
|
||||
assert_eq!(result.lines().filter(|l| l.trim() == "model:").count(), 1);
|
||||
assert!(result.contains("default: claude"));
|
||||
assert!(!result.contains("gpt-4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_is_identity_without_duplicates() {
|
||||
let yaml = "\
|
||||
# Hermes config
|
||||
model:
|
||||
default: gpt-4
|
||||
|
||||
agent:
|
||||
max_turns: 10
|
||||
";
|
||||
assert_eq!(deduplicate_top_level_keys(yaml), yaml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_result_parses_with_last_value() {
|
||||
// End-to-end: a config that serde_yaml rejects today must parse after
|
||||
// healing, and expose the newest (last) value.
|
||||
let yaml = "\
|
||||
custom_providers:
|
||||
- name: old-provider
|
||||
model:
|
||||
default: gpt-4
|
||||
custom_providers:
|
||||
- name: old-provider
|
||||
- name: new-provider
|
||||
";
|
||||
let healed = deduplicate_top_level_keys(yaml);
|
||||
let value: serde_yaml::Value = serde_yaml::from_str(&healed).unwrap();
|
||||
let providers = value
|
||||
.get("custom_providers")
|
||||
.unwrap()
|
||||
.as_sequence()
|
||||
.unwrap();
|
||||
assert_eq!(providers.len(), 2);
|
||||
assert_eq!(
|
||||
providers[1].get("name").unwrap().as_str().unwrap(),
|
||||
"new-provider"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- remove_all_sections tests ----
|
||||
|
||||
#[test]
|
||||
fn remove_all_sections_strips_every_occurrence() {
|
||||
let yaml = "\
|
||||
model:
|
||||
default: gpt-4
|
||||
agent:
|
||||
max_turns: 10
|
||||
model:
|
||||
default: claude
|
||||
model:
|
||||
default: gemini
|
||||
";
|
||||
let result = remove_all_sections(yaml, "model");
|
||||
assert!(!result.contains("model:"));
|
||||
assert!(result.contains("agent:"));
|
||||
assert!(result.contains("max_turns"));
|
||||
}
|
||||
|
||||
// ---- replace_yaml_section tests ----
|
||||
|
||||
#[test]
|
||||
@@ -1239,6 +1432,62 @@ agent:
|
||||
assert!(!result.contains("openai"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_section_in_crlf_config_replaces_in_place() {
|
||||
// Regression for #3633: on CRLF configs every "replace" used to
|
||||
// degrade into an append, piling up duplicate sections.
|
||||
let yaml = "model:\r\n default: gpt-4\r\nagent:\r\n max_turns: 10\r\n";
|
||||
let new_model = serde_yaml::Value::Mapping({
|
||||
let mut m = serde_yaml::Mapping::new();
|
||||
m.insert(
|
||||
serde_yaml::Value::String("default".to_string()),
|
||||
serde_yaml::Value::String("claude-opus-4-8".to_string()),
|
||||
);
|
||||
m
|
||||
});
|
||||
|
||||
let result = replace_yaml_section(yaml, "model", &new_model).unwrap();
|
||||
assert_eq!(
|
||||
result.lines().filter(|l| l.trim() == "model:").count(),
|
||||
1,
|
||||
"model: must be replaced in place, not appended"
|
||||
);
|
||||
assert!(result.contains("claude-opus-4-8"));
|
||||
assert!(!result.contains("gpt-4"));
|
||||
assert!(result.contains("max_turns"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_section_removes_residual_duplicates() {
|
||||
// A config already broken by the append bug: replacing the section
|
||||
// must also clean the stale duplicate copies after it.
|
||||
let yaml = "\
|
||||
model:
|
||||
default: gpt-4
|
||||
agent:
|
||||
max_turns: 10
|
||||
model:
|
||||
default: stale-copy
|
||||
";
|
||||
let new_model = serde_yaml::Value::Mapping({
|
||||
let mut m = serde_yaml::Mapping::new();
|
||||
m.insert(
|
||||
serde_yaml::Value::String("default".to_string()),
|
||||
serde_yaml::Value::String("claude-opus-4-8".to_string()),
|
||||
);
|
||||
m
|
||||
});
|
||||
|
||||
let result = replace_yaml_section(yaml, "model", &new_model).unwrap();
|
||||
assert_eq!(result.lines().filter(|l| *l == "model:").count(), 1);
|
||||
assert!(result.contains("claude-opus-4-8"));
|
||||
assert!(!result.contains("stale-copy"));
|
||||
assert!(result.contains("agent:"));
|
||||
// The healed output must be valid YAML again
|
||||
let parsed: Result<serde_yaml::Value, _> = serde_yaml::from_str(&result);
|
||||
assert!(parsed.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_new_section() {
|
||||
let yaml = "\
|
||||
|
||||
+151
-8
@@ -69,6 +69,22 @@ use tauri::RunEvent;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn set_windows_app_user_model_id(app: &tauri::AppHandle) {
|
||||
let app_id = app.config().identifier.clone();
|
||||
let wide_app_id: Vec<u16> = app_id.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
|
||||
let result = unsafe {
|
||||
windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID(wide_app_id.as_ptr())
|
||||
};
|
||||
|
||||
if result < 0 {
|
||||
log::warn!("设置 Windows AppUserModelID 失败: 0x{result:08X}");
|
||||
} else {
|
||||
log::debug!("Windows AppUserModelID 已设置为 {app_id}");
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_url_for_log(url_str: &str) -> String {
|
||||
match url::Url::parse(url_str) {
|
||||
Ok(url) => {
|
||||
@@ -288,6 +304,8 @@ pub fn run() {
|
||||
// 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等)
|
||||
app_store::refresh_app_config_dir_override(app.handle());
|
||||
panic_hook::init_app_config_dir(crate::config::get_app_config_dir());
|
||||
#[cfg(target_os = "windows")]
|
||||
set_windows_app_user_model_id(app.handle());
|
||||
|
||||
// 注册 Updater 插件(桌面端)
|
||||
#[cfg(desktop)]
|
||||
@@ -865,6 +883,10 @@ pub fn run() {
|
||||
app_state.db.clone(),
|
||||
app.handle().clone(),
|
||||
);
|
||||
crate::services::s3_auto_sync::start_worker(
|
||||
app_state.db.clone(),
|
||||
app.handle().clone(),
|
||||
);
|
||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||
app.manage(app_state);
|
||||
|
||||
@@ -1021,6 +1043,10 @@ pub fn run() {
|
||||
"Gemini usage initial sync",
|
||||
crate::services::session_usage_gemini::sync_gemini_usage(db),
|
||||
);
|
||||
run_step(
|
||||
"OpenCode usage initial sync",
|
||||
crate::services::session_usage_opencode::sync_opencode_usage(db),
|
||||
);
|
||||
|
||||
// 定期同步
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(
|
||||
@@ -1041,6 +1067,10 @@ pub fn run() {
|
||||
"Gemini usage periodic sync",
|
||||
crate::services::session_usage_gemini::sync_gemini_usage(db),
|
||||
);
|
||||
run_step(
|
||||
"OpenCode usage periodic sync",
|
||||
crate::services::session_usage_opencode::sync_opencode_usage(db),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1198,6 +1228,11 @@ pub fn run() {
|
||||
commands::webdav_sync_download,
|
||||
commands::webdav_sync_save_settings,
|
||||
commands::webdav_sync_fetch_remote_info,
|
||||
commands::s3_test_connection,
|
||||
commands::s3_sync_upload,
|
||||
commands::s3_sync_download,
|
||||
commands::s3_sync_save_settings,
|
||||
commands::s3_sync_fetch_remote_info,
|
||||
commands::save_file_dialog,
|
||||
commands::open_file_dialog,
|
||||
commands::open_zip_file_dialog,
|
||||
@@ -1408,14 +1443,37 @@ pub fn run() {
|
||||
app.run(|app_handle, event| {
|
||||
// 处理退出请求(所有平台)
|
||||
if let RunEvent::ExitRequested { api, code, .. } = &event {
|
||||
// code 为 None 表示运行时自动触发(如隐藏窗口的 WebView 被回收导致无存活窗口),
|
||||
// 此时应仅阻止退出、保持托盘后台运行;
|
||||
// code 为 Some(_) 表示用户主动调用 app.exit() 退出(如托盘菜单"退出"),
|
||||
// 此时执行清理后退出。
|
||||
if code.is_none() {
|
||||
log::info!("运行时触发退出请求(无存活窗口),阻止退出以保持托盘后台运行");
|
||||
api.prevent_exit();
|
||||
return;
|
||||
match classify_exit_request(*code) {
|
||||
// code 为 None 表示运行时自动触发(如隐藏窗口的 WebView 被回收导致无存活窗口),
|
||||
// 此时应仅阻止退出、保持托盘后台运行。
|
||||
ExitRequestAction::StayInTray => {
|
||||
log::info!("运行时触发退出请求(无存活窗口),阻止退出以保持托盘后台运行");
|
||||
api.prevent_exit();
|
||||
return;
|
||||
}
|
||||
// code 为 RESTART_EXIT_CODE:app.restart() / 自更新 relaunch 发起的重启。
|
||||
// 这条路径上 prevent_exit() 会被 Tauri 忽略,事件循环必定退出,随后由
|
||||
// Tauri 在 RunEvent::Exit 后用新二进制 re-exec(macOS 会按更新后的
|
||||
// Info.plist 解析可执行名)。
|
||||
//
|
||||
// 绝不能复用下面的异步清理任务:该任务在 tokio 线程调 save_window_state,
|
||||
// 持有 window-state 插件锁的同时向主线程查询窗口几何;而主线程此刻正在
|
||||
// 退出事件循环,并在插件自带的 RunEvent::Exit 钩子里等待同一把锁——双方
|
||||
// 互等造成进程永久卡死(更新已安装但应用冻结、不再重启,见 #3998)。
|
||||
//
|
||||
// 重启路径交还 Tauri 默认流程即可:
|
||||
// - 窗口状态:插件 Exit 钩子在主线程保存(同线程读取窗口几何,无死锁)
|
||||
// - 托盘图标:Tauri 内部 cleanup_before_exit 清理,正常走 Drop
|
||||
// - 代理/Live 配置:无需恢复,重启后新实例立即接管并恢复代理状态
|
||||
// - 100ms 落盘等待:重启前的 DB 写入均为命令驱动、此刻已完成,
|
||||
// 与所有 Tauri 应用默认重启路径的行为一致,无需额外等待
|
||||
ExitRequestAction::DeferToTauriRestart => {
|
||||
log::info!("收到重启请求 (code={code:?}),交由 Tauri 默认重启流程 re-exec");
|
||||
return;
|
||||
}
|
||||
// 其它 Some(_):用户主动调用 app.exit() 退出(如托盘菜单"退出"),
|
||||
// 此时执行清理后退出。
|
||||
ExitRequestAction::CleanupAndExit => {}
|
||||
}
|
||||
|
||||
log::info!("收到用户主动退出请求 (code={code:?}),开始清理...");
|
||||
@@ -1425,6 +1483,11 @@ pub fn run() {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
save_window_state_before_exit(&app_handle);
|
||||
cleanup_before_exit(&app_handle).await;
|
||||
// 先于 std::process::exit 显式移除托盘图标。
|
||||
// 进程直接退出时 Tauri 运行时不走正常 Drop 流程,
|
||||
// 不会向 Windows Shell 发送 NIM_DELETE,导致已退出的进程
|
||||
// 注册的图标仍残留在系统托盘(鼠标悬停 Shell 才会重绘发现进程已死)。
|
||||
remove_tray_icon_before_exit(&app_handle);
|
||||
log::info!("清理完成,退出应用");
|
||||
|
||||
// 短暂等待确保所有 I/O 操作(如数据库写入)刷新到磁盘
|
||||
@@ -1572,6 +1635,26 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 主动从系统托盘移除托盘图标。
|
||||
///
|
||||
/// `std::process::exit` 会绕过 Tauri 运行时,触发不了 `TrayIcon::drop()`,
|
||||
/// 也就不会向 Windows Shell 发 `NIM_DELETE`。结果是进程退出后托盘里
|
||||
/// 仍保留一个死图标的缓存占位(Shell 不会主动重绘,需要鼠标悬停才刷新)。
|
||||
///
|
||||
/// 通过 `set_visible(false)` 走 `WM_USER_HIDE_TRAYICON` 消息路径,
|
||||
/// 触发 tray-icon 内部的 `remove_tray_icon` → `Shell_NotifyIconW(NIM_DELETE)`,
|
||||
/// 在进程结束前干净地把图标摘掉。其它平台 `set_visible(false)` 也是
|
||||
/// 正常的隐藏/移除语义,作为跨平台兜底也安全。
|
||||
fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) {
|
||||
if let Some(tray) = app_handle.tray_by_id(tray::TRAY_ID) {
|
||||
if let Err(e) = tray.set_visible(false) {
|
||||
log::warn!("退出时移除托盘图标失败: {e}");
|
||||
} else {
|
||||
log::info!("已显式从系统托盘移除图标");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 启动时恢复代理状态
|
||||
// ============================================================
|
||||
@@ -1831,6 +1914,36 @@ fn show_database_init_error_dialog(
|
||||
.blocking_show()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 退出请求分类
|
||||
// ============================================================
|
||||
|
||||
/// `RunEvent::ExitRequested` 的三类来源,处理方式必须区分。
|
||||
///
|
||||
/// 关键约束:重启请求(`code == RESTART_EXIT_CODE`)上 `prevent_exit()` 会被
|
||||
/// Tauri 静默忽略(见 `ExitRequestApi::prevent_exit` 文档),事件循环必定继续
|
||||
/// 退出并触发各插件的 `RunEvent::Exit` 钩子;任何与之并发的自定义清理任务都
|
||||
/// 可能与插件退出钩子争用同一状态而死锁。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ExitRequestAction {
|
||||
/// `code` 为 `None`:运行时自动触发(如隐藏窗口的 WebView 被回收导致无存活
|
||||
/// 窗口),阻止退出、保持托盘后台运行。
|
||||
StayInTray,
|
||||
/// `code` 为 `RESTART_EXIT_CODE`:`app.restart()` / 自更新 relaunch 发起的
|
||||
/// 重启,不拦截、不做自定义清理,交还 Tauri 默认 re-exec 流程。
|
||||
DeferToTauriRestart,
|
||||
/// 其它 `Some(_)`:用户主动退出(托盘「退出」等),执行完整异步清理后结束进程。
|
||||
CleanupAndExit,
|
||||
}
|
||||
|
||||
fn classify_exit_request(code: Option<i32>) -> ExitRequestAction {
|
||||
match code {
|
||||
None => ExitRequestAction::StayInTray,
|
||||
Some(tauri::RESTART_EXIT_CODE) => ExitRequestAction::DeferToTauriRestart,
|
||||
Some(_) => ExitRequestAction::CleanupAndExit,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 在应用主动退出前显式持久化窗口状态
|
||||
// ============================================================
|
||||
@@ -1848,3 +1961,33 @@ pub fn save_window_state_before_exit(app_handle: &tauri::AppHandle) {
|
||||
log::info!("已在退出前保存窗口状态");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{classify_exit_request, ExitRequestAction};
|
||||
|
||||
#[test]
|
||||
fn no_code_keeps_app_alive_in_tray() {
|
||||
assert_eq!(classify_exit_request(None), ExitRequestAction::StayInTray);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_exit_code_defers_to_tauri_default_restart() {
|
||||
assert_eq!(
|
||||
classify_exit_request(Some(tauri::RESTART_EXIT_CODE)),
|
||||
ExitRequestAction::DeferToTauriRestart
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_exit_codes_run_cleanup_then_exit() {
|
||||
assert_eq!(
|
||||
classify_exit_request(Some(0)),
|
||||
ExitRequestAction::CleanupAndExit
|
||||
);
|
||||
assert_eq!(
|
||||
classify_exit_request(Some(1)),
|
||||
ExitRequestAction::CleanupAndExit
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,40 @@ pub fn get_opencode_config_path() -> PathBuf {
|
||||
get_opencode_dir().join("opencode.json")
|
||||
}
|
||||
|
||||
/// 获取 OpenCode SQLite 数据库路径
|
||||
/// 优先级: OPENCODE_DB 环境变量 > XDG_DATA_HOME > ~/.local/share/opencode
|
||||
pub fn get_opencode_db_path() -> PathBuf {
|
||||
// 支持 OPENCODE_DB 环境变量覆盖(忽略空字符串)
|
||||
if let Ok(custom_path) = std::env::var("OPENCODE_DB") {
|
||||
if !custom_path.is_empty() {
|
||||
let path = PathBuf::from(&custom_path);
|
||||
if path.is_absolute() {
|
||||
return path;
|
||||
}
|
||||
// 相对路径基于数据目录
|
||||
return get_opencode_data_dir().join(path);
|
||||
}
|
||||
}
|
||||
|
||||
get_opencode_data_dir().join("opencode.db")
|
||||
}
|
||||
|
||||
fn get_opencode_data_dir() -> PathBuf {
|
||||
// 尊重 XDG_DATA_HOME(按 XDG 规范,空字符串视为未设置)
|
||||
if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME") {
|
||||
if !xdg_data.is_empty() {
|
||||
return PathBuf::from(xdg_data).join("opencode");
|
||||
}
|
||||
}
|
||||
|
||||
// OpenCode 使用 xdg-basedir,不遵守 macOS/Windows 平台约定,
|
||||
// 所有平台默认都落在 ~/.local/share/opencode
|
||||
crate::config::get_home_dir()
|
||||
.join(".local")
|
||||
.join("share")
|
||||
.join("opencode")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_opencode_env_path() -> PathBuf {
|
||||
get_opencode_dir().join(".env")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use http::header::{HeaderValue, InvalidHeaderValue};
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -108,17 +109,13 @@ impl Provider {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Resolve `(base_url, api_key)` for native usage queries (balance /
|
||||
/// coding-plan) from the stored provider config.
|
||||
/// Resolve `(base_url, api_key)` for usage queries (native balance /
|
||||
/// coding-plan and the JS-script `{{apiKey}}`/`{{baseUrl}}` fallback)
|
||||
/// from the stored provider config.
|
||||
///
|
||||
/// Each app persists credentials in a different shape, so callers must pass
|
||||
/// the owning app type. This mirrors the frontend `getProviderCredentials`
|
||||
/// in `UsageScriptModal.tsx`.
|
||||
///
|
||||
/// TODO: the env-only helpers in `services/provider/usage.rs`
|
||||
/// (`extract_api_key_from_provider` / `extract_base_url_from_provider`)
|
||||
/// duplicate this per-app logic on the JS-script path and could delegate
|
||||
/// here in a follow-up to remove the remaining copy.
|
||||
pub fn resolve_usage_credentials(
|
||||
&self,
|
||||
app_type: &crate::app_config::AppType,
|
||||
@@ -464,6 +461,9 @@ pub struct ProviderMeta {
|
||||
/// Codex Responses -> Chat Completions reasoning capability metadata.
|
||||
#[serde(rename = "codexChatReasoning", skip_serializing_if = "Option::is_none")]
|
||||
pub codex_chat_reasoning: Option<CodexChatReasoningConfig>,
|
||||
/// Custom User-Agent for local proxy routing.
|
||||
#[serde(rename = "customUserAgent", skip_serializing_if = "Option::is_none")]
|
||||
pub custom_user_agent: Option<String>,
|
||||
/// 累加模式应用中,该 provider 是否已写入 live config。
|
||||
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
|
||||
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
|
||||
@@ -478,6 +478,30 @@ pub struct ProviderMeta {
|
||||
pub github_account_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 解析 Provider 级自定义 User-Agent 字符串(单一真理来源)。
|
||||
///
|
||||
/// 转发(forwarder)、流式检测(stream_check)、获取模型列表(model_fetch)三条路径
|
||||
/// 共用同一口径,避免出现"某条路径用了 UA、另一条没用 / 报错"的不一致。
|
||||
///
|
||||
/// 合法性由 `http::HeaderValue::from_str` 按**字节**判定(`b >= 32 && b != 127 || b == '\t'`),
|
||||
/// 与前端 `src/lib/userAgent.ts::isValidUserAgentHeader` 严格一致:
|
||||
/// - `Ok(None)`:未设置或纯空白(trim 后为空)。
|
||||
/// - `Ok(Some(hv))`:合法。制表符、可见 ASCII(0x20–0x7E)、以及任意非 ASCII 字符
|
||||
/// (UTF-8 字节均 ≥ 0x80)都合法。
|
||||
/// - `Err(_)`:仅含控制字符时——除 `\t` 外的 0x00–0x1F(含换行)与 0x7F(DEL)。
|
||||
///
|
||||
/// 非法值的处理:三条运行时路径**均静默忽略**(`.ok().flatten()`,绝不让某条路径报错而
|
||||
/// 另一条放行);前端在输入框处给出非阻断提示。当前**不在保存时阻断**——deeplink 导入等
|
||||
/// 非表单路径应宽容,运行时静默忽略即为安全网。
|
||||
pub fn parse_custom_user_agent(
|
||||
raw: Option<&str>,
|
||||
) -> Result<Option<HeaderValue>, InvalidHeaderValue> {
|
||||
match raw.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(ua) => HeaderValue::from_str(ua).map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderMeta {
|
||||
/// Codex OAuth FAST mode 是否启用。默认关闭,因为 `service_tier="priority"`
|
||||
/// 会按更高速率消耗 ChatGPT 订阅配额,用户需显式开启以换取更低延迟。
|
||||
@@ -485,6 +509,11 @@ impl ProviderMeta {
|
||||
self.codex_fast_mode.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 经校验的 Provider 级自定义 User-Agent。见 [`parse_custom_user_agent`]。
|
||||
pub fn custom_user_agent_header(&self) -> Result<Option<HeaderValue>, InvalidHeaderValue> {
|
||||
parse_custom_user_agent(self.custom_user_agent.as_deref())
|
||||
}
|
||||
|
||||
/// 解析指定托管认证供应商绑定的账号 ID。
|
||||
///
|
||||
/// 新版优先读取 authBinding,旧版继续兼容 githubAccountId。
|
||||
|
||||
@@ -38,6 +38,11 @@ pub struct ForwardResult {
|
||||
pub response: ProxyResponse,
|
||||
pub provider: Provider,
|
||||
pub claude_api_format: Option<String>,
|
||||
/// 实际发往上游的模型名(路由接管/模型映射后的真值)。
|
||||
///
|
||||
/// usage 归因不能依赖 ctx.request_model(映射前的客户端别名):上游响应
|
||||
/// 缺失 model 或回显别名时,接管流量会被记成 claude-* 并按其定价计费。
|
||||
pub outbound_model: Option<String>,
|
||||
/// 活跃连接 RAII guard:随响应一起流转到 response_processor / handle_claude_transform,
|
||||
/// 最终被 move 进流式 body future(或非流式响应作用域),覆盖整个响应生命周期。
|
||||
pub(crate) connection_guard: Option<ActiveConnectionGuard>,
|
||||
@@ -122,6 +127,51 @@ pub struct RequestForwarder {
|
||||
}
|
||||
|
||||
impl RequestForwarder {
|
||||
/// 预防式 media 降级:发送前对 text-only 模型把图片块替换为标记。
|
||||
///
|
||||
/// 受 `enabled && request_media_fallback` 管辖;其中"启发式模型名单预测"
|
||||
/// 再受 `request_media_heuristic` 单独管辖(显式声明 text-only 始终生效)。
|
||||
/// 返回被替换的图片块数量(0 = 未触发或开关关闭)。
|
||||
fn apply_media_prevention(&self, body: &mut Value, provider: &Provider) -> usize {
|
||||
if !(self.rectifier_config.enabled && self.rectifier_config.request_media_fallback) {
|
||||
return 0;
|
||||
}
|
||||
let replaced_images = super::media_sanitizer::replace_images_for_text_only_model(
|
||||
body,
|
||||
provider,
|
||||
self.rectifier_config.request_media_heuristic,
|
||||
);
|
||||
if replaced_images > 0 {
|
||||
let model = body.get("model").and_then(Value::as_str).unwrap_or("");
|
||||
log::info!(
|
||||
"[Media] Replaced {replaced_images} image block(s) with {} for text-only provider={}, model={}",
|
||||
super::media_sanitizer::UNSUPPORTED_IMAGE_MARKER,
|
||||
provider.id,
|
||||
model
|
||||
);
|
||||
}
|
||||
replaced_images
|
||||
}
|
||||
|
||||
/// 反应式 media 重试判定:上游因图片输入报错后,是否应替换图片块并对同一供应商重试一次。
|
||||
///
|
||||
/// 受 `enabled && request_media_fallback` 管辖;不涉及 `request_media_heuristic`——
|
||||
/// 这里是上游"实测"错误后的纯恢复,不是预测,故启发式开关与它无关。
|
||||
fn media_retry_should_trigger(
|
||||
&self,
|
||||
adapter_name: &str,
|
||||
already_retried: bool,
|
||||
provider_body: &Value,
|
||||
error: &ProxyError,
|
||||
) -> bool {
|
||||
matches!(adapter_name, "Claude" | "Codex")
|
||||
&& self.rectifier_config.enabled
|
||||
&& self.rectifier_config.request_media_fallback
|
||||
&& !already_retried
|
||||
&& super::media_sanitizer::contains_image_blocks(provider_body)
|
||||
&& super::media_sanitizer::is_unsupported_image_error(error)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
router: Arc<ProviderRouter>,
|
||||
@@ -346,6 +396,7 @@ impl RequestForwarder {
|
||||
// —— 首家 provider 整流后被 5xx/timeout 击落时,下家仍能用整流后的请求体走整流流程
|
||||
let mut rectifier_retried = false;
|
||||
let mut budget_rectifier_retried = false;
|
||||
let mut media_rectifier_retried = false;
|
||||
|
||||
// 上限检查:尊重用户在 AppProxyConfig.max_retries 上配置的「重试次数」。
|
||||
// 放在熔断器 allow 检查之前,避免在已经超限时还占用 HalfOpen 探测名额。
|
||||
@@ -417,7 +468,7 @@ impl RequestForwarder {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((response, claude_api_format)) => {
|
||||
Ok((response, claude_api_format, outbound_model)) => {
|
||||
// 成功:普通闭合熔断状态异步记录,避免阻塞流式首包返回;
|
||||
// HalfOpen 探测仍同步等待,保证 permit 与熔断状态及时释放。
|
||||
self.record_success_result(&provider.id, app_type_str, used_half_open_permit)
|
||||
@@ -465,6 +516,7 @@ impl RequestForwarder {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
outbound_model,
|
||||
connection_guard: None,
|
||||
});
|
||||
}
|
||||
@@ -477,6 +529,124 @@ impl RequestForwarder {
|
||||
);
|
||||
let mut signature_rectifier_non_retryable_client_error = false;
|
||||
|
||||
if self.media_retry_should_trigger(
|
||||
adapter.name(),
|
||||
media_rectifier_retried,
|
||||
&provider_body,
|
||||
&e,
|
||||
) {
|
||||
let mut media_body = provider_body.clone();
|
||||
let replaced_images =
|
||||
super::media_sanitizer::replace_image_blocks_with_marker(
|
||||
&mut media_body,
|
||||
);
|
||||
|
||||
if replaced_images > 0 {
|
||||
let _ = std::mem::replace(&mut media_rectifier_retried, true);
|
||||
let model = media_body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
log::info!(
|
||||
"[{app_type_str}] [Media] Upstream rejected image input; retrying provider={} model={} with {replaced_images} image block(s) replaced by {}",
|
||||
provider.id,
|
||||
model,
|
||||
super::media_sanitizer::UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
|
||||
match self
|
||||
.forward(
|
||||
app_type,
|
||||
&method,
|
||||
provider,
|
||||
endpoint,
|
||||
&media_body,
|
||||
&headers,
|
||||
&extensions,
|
||||
adapter.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((response, claude_api_format, outbound_model)) => {
|
||||
log::info!(
|
||||
"[{app_type_str}] [Media] Unsupported-image retry succeeded"
|
||||
);
|
||||
self.record_success_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
|
||||
{
|
||||
let mut current_providers =
|
||||
self.current_providers.write().await;
|
||||
current_providers.insert(
|
||||
app_type_str.to_string(),
|
||||
(provider.id.clone(), provider.name.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.success_requests += 1;
|
||||
status.last_error = None;
|
||||
let should_switch =
|
||||
self.current_provider_id_at_start.as_str()
|
||||
!= provider.id.as_str();
|
||||
if should_switch {
|
||||
status.failover_count += 1;
|
||||
let fm = self.failover_manager.clone();
|
||||
let ah = self.app_handle.clone();
|
||||
let pid = provider.id.clone();
|
||||
let pname = provider.name.clone();
|
||||
let at = app_type_str.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = fm
|
||||
.try_switch(ah.as_ref(), &at, &pid, &pname)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(ForwardResult {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
outbound_model,
|
||||
connection_guard: None,
|
||||
});
|
||||
}
|
||||
Err(retry_err) => {
|
||||
log::warn!(
|
||||
"[{app_type_str}] [Media] Unsupported-image retry still failed: {retry_err}"
|
||||
);
|
||||
if let Some(err) = self
|
||||
.handle_rectifier_retry_failure(
|
||||
retry_err,
|
||||
provider,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
"media 降级",
|
||||
&mut last_error,
|
||||
&mut last_provider,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(err);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is_anthropic_provider {
|
||||
let error_message = extract_error_message(&e);
|
||||
if should_rectify_thinking_signature(
|
||||
@@ -543,7 +713,7 @@ impl RequestForwarder {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((response, claude_api_format)) => {
|
||||
Ok((response, claude_api_format, outbound_model)) => {
|
||||
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||
self.record_success_result(
|
||||
&provider.id,
|
||||
@@ -598,6 +768,7 @@ impl RequestForwarder {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
outbound_model,
|
||||
connection_guard: None,
|
||||
});
|
||||
}
|
||||
@@ -708,7 +879,7 @@ impl RequestForwarder {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((response, claude_api_format)) => {
|
||||
Ok((response, claude_api_format, outbound_model)) => {
|
||||
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
|
||||
self.record_success_result(
|
||||
&provider.id,
|
||||
@@ -757,6 +928,7 @@ impl RequestForwarder {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
outbound_model,
|
||||
connection_guard: None,
|
||||
});
|
||||
}
|
||||
@@ -914,6 +1086,9 @@ impl RequestForwarder {
|
||||
}
|
||||
|
||||
/// 转发单个请求(使用适配器)
|
||||
///
|
||||
/// 成功时返回 `(response, claude_api_format, outbound_model)`,其中
|
||||
/// `outbound_model` 是最终发往上游的模型名(所有映射/改写之后)。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn forward(
|
||||
&self,
|
||||
@@ -925,7 +1100,7 @@ impl RequestForwarder {
|
||||
headers: &axum::http::HeaderMap,
|
||||
extensions: &Extensions,
|
||||
adapter: &dyn ProviderAdapter,
|
||||
) -> Result<(ProxyResponse, Option<String>), ProxyError> {
|
||||
) -> Result<(ProxyResponse, Option<String>, Option<String>), ProxyError> {
|
||||
// 使用适配器提取 base_url
|
||||
let mut base_url = adapter.extract_base_url(provider)?;
|
||||
|
||||
@@ -1109,11 +1284,12 @@ impl RequestForwarder {
|
||||
};
|
||||
if adapter.name() == "Claude" {
|
||||
if let Some(api_format) = resolved_claude_api_format.as_deref() {
|
||||
super::providers::normalize_anthropic_tool_thinking_history_for_provider(
|
||||
super::providers::normalize_anthropic_messages_for_provider(
|
||||
&mut mapped_body,
|
||||
provider,
|
||||
api_format,
|
||||
);
|
||||
self.apply_media_prevention(&mut mapped_body, provider);
|
||||
}
|
||||
}
|
||||
let needs_transform = match resolved_claude_api_format.as_deref() {
|
||||
@@ -1156,8 +1332,17 @@ impl RequestForwarder {
|
||||
adapter.build_url(&base_url, &effective_endpoint)
|
||||
};
|
||||
|
||||
// 记录映射后的出站模型名(此时 mapped_body 已完成接管映射 / [1m] 剥离 /
|
||||
// Copilot 归一化)。格式转换后若 body 仍带 model 字段会在下方刷新覆盖;
|
||||
// gemini_native 等模型在 URL 中的格式则保留此处的转换前真值。
|
||||
let mut outbound_model = mapped_body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.filter(|m| !m.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
// 转换请求体(如果需要)
|
||||
let request_body = if codex_responses_to_chat {
|
||||
let mut request_body = if codex_responses_to_chat {
|
||||
let mut mapped_body = mapped_body;
|
||||
let restored = self
|
||||
.codex_chat_history
|
||||
@@ -1195,9 +1380,21 @@ impl RequestForwarder {
|
||||
mapped_body
|
||||
};
|
||||
|
||||
if matches!(app_type, AppType::Codex) {
|
||||
self.apply_media_prevention(&mut request_body, provider);
|
||||
}
|
||||
|
||||
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
|
||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||
let filtered_body = prepare_upstream_request_body(request_body);
|
||||
// 出站 body 定稿后刷新真值(覆盖 Codex chat 上游模型覆写、转换层模型改写)
|
||||
if let Some(m) = filtered_body
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.filter(|m| !m.is_empty())
|
||||
{
|
||||
outbound_model = Some(m.to_string());
|
||||
}
|
||||
log_prompt_cache_trace(
|
||||
app_type,
|
||||
provider,
|
||||
@@ -1340,6 +1537,18 @@ impl RequestForwarder {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// 自定义 User-Agent:与 stream_check / model_fetch 共用 parse_custom_user_agent,
|
||||
// 运行时静默忽略非法值(前端在输入处给非阻断提示,不在保存时阻断)。
|
||||
// Copilot 指纹 UA 不可覆盖。
|
||||
let custom_user_agent = if is_copilot {
|
||||
None
|
||||
} else {
|
||||
provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.custom_user_agent_header().ok().flatten())
|
||||
};
|
||||
|
||||
// --- Copilot 优化器:动态 header 注入 ---
|
||||
if let Some((ref classification, ref det_request_id, ref interaction_id)) =
|
||||
copilot_optimization
|
||||
@@ -1434,6 +1643,7 @@ impl RequestForwarder {
|
||||
let mut ordered_headers = http::HeaderMap::new();
|
||||
let mut saw_auth = false;
|
||||
let mut saw_accept_encoding = false;
|
||||
let mut saw_user_agent = false;
|
||||
let mut saw_anthropic_beta = false;
|
||||
let mut saw_anthropic_version = false;
|
||||
|
||||
@@ -1514,6 +1724,19 @@ impl RequestForwarder {
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- user-agent: provider-level override for local proxy routing ---
|
||||
if !is_copilot && key_str.eq_ignore_ascii_case("user-agent") {
|
||||
if !saw_user_agent {
|
||||
saw_user_agent = true;
|
||||
if let Some(ref ua) = custom_user_agent {
|
||||
ordered_headers.append(http::header::USER_AGENT, ua.clone());
|
||||
} else {
|
||||
ordered_headers.append(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- anthropic-beta — 用重建值替换(确保含 claude-code 标记) ---
|
||||
if key_str.eq_ignore_ascii_case("anthropic-beta") {
|
||||
if !saw_anthropic_beta {
|
||||
@@ -1563,6 +1786,12 @@ impl RequestForwarder {
|
||||
);
|
||||
}
|
||||
|
||||
if !saw_user_agent {
|
||||
if let Some(ref ua) = custom_user_agent {
|
||||
ordered_headers.append(http::header::USER_AGENT, ua.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// 如果原始请求中没有 anthropic-beta 且有值需要添加,追加
|
||||
if !saw_anthropic_beta {
|
||||
if let Some(ref beta_val) = anthropic_beta_value {
|
||||
@@ -1710,7 +1939,7 @@ impl RequestForwarder {
|
||||
let response = self
|
||||
.prepare_success_response_for_failover(response, request_is_streaming)
|
||||
.await?;
|
||||
Ok((response, resolved_claude_api_format))
|
||||
Ok((response, resolved_claude_api_format, outbound_model))
|
||||
} else {
|
||||
let status_code = status.as_u16();
|
||||
let body_text = String::from_utf8(response.bytes().await?.to_vec()).ok();
|
||||
@@ -3106,4 +3335,187 @@ mod tests {
|
||||
assert_eq!(will_replace, should_replace, "{desc}");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== P3: forwarder 层 media 开关回归测试 =====
|
||||
// 验证 gate 在 forwarder 这一层的"接线",而非 media_sanitizer 纯函数本身。
|
||||
|
||||
fn forwarder_with_rectifier(config: RectifierConfig) -> RequestForwarder {
|
||||
let mut fwd = test_forwarder(Duration::from_secs(1), Duration::from_secs(1));
|
||||
fwd.rectifier_config = config;
|
||||
fwd
|
||||
}
|
||||
|
||||
fn provider_with_settings(settings_config: Value) -> Provider {
|
||||
let mut p = test_provider_with_type(Some("anthropic"));
|
||||
p.settings_config = settings_config;
|
||||
p
|
||||
}
|
||||
|
||||
fn body_with_image(model: &str) -> Value {
|
||||
json!({
|
||||
"model": model,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
fn body_with_codex_input_image(model: &str) -> Value {
|
||||
json!({
|
||||
"model": model,
|
||||
"input": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "input_image", "image_url": "data:image/png;base64,abc" }
|
||||
]
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
fn image_unsupported_error() -> ProxyError {
|
||||
ProxyError::UpstreamError {
|
||||
status: 400,
|
||||
body: Some(
|
||||
r#"{"error":{"message":"This model does not support image input"}}"#.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn prevention_replaces_when_all_switches_on_and_model_in_heuristic_list() {
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig::default());
|
||||
let provider = provider_with_settings(json!({}));
|
||||
let mut body = body_with_image("deepseek-v4-pro");
|
||||
|
||||
let replaced = fwd.apply_media_prevention(&mut body, &provider);
|
||||
|
||||
assert_eq!(replaced, 1, "默认全开 + 名单内模型应预替换");
|
||||
assert_eq!(body["messages"][0]["content"][0]["type"], "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prevention_skipped_when_media_fallback_off() {
|
||||
// 关闭 request_media_fallback:即使名单命中也不预替换。
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig {
|
||||
request_media_fallback: false,
|
||||
..RectifierConfig::default()
|
||||
});
|
||||
let provider = provider_with_settings(json!({}));
|
||||
let mut body = body_with_image("deepseek-v4-pro");
|
||||
|
||||
let replaced = fwd.apply_media_prevention(&mut body, &provider);
|
||||
|
||||
assert_eq!(replaced, 0);
|
||||
assert_eq!(body["messages"][0]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prevention_skipped_when_master_switch_off() {
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig {
|
||||
enabled: false,
|
||||
..RectifierConfig::default()
|
||||
});
|
||||
let provider = provider_with_settings(json!({}));
|
||||
let mut body = body_with_image("deepseek-v4-pro");
|
||||
|
||||
assert_eq!(fwd.apply_media_prevention(&mut body, &provider), 0);
|
||||
assert_eq!(body["messages"][0]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prevention_heuristic_off_skips_list_but_keeps_explicit_text_only() {
|
||||
// 关闭 request_media_heuristic:名单预测失效,但显式声明 text-only 仍预替换。
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig {
|
||||
request_media_heuristic: false,
|
||||
..RectifierConfig::default()
|
||||
});
|
||||
|
||||
// (a) 名单内模型、无显式声明 → 不再预替换
|
||||
let bare_provider = provider_with_settings(json!({}));
|
||||
let mut list_body = body_with_image("deepseek-v4-pro");
|
||||
assert_eq!(
|
||||
fwd.apply_media_prevention(&mut list_body, &bare_provider),
|
||||
0,
|
||||
"heuristic 关闭后名单模型不应被预替换"
|
||||
);
|
||||
assert_eq!(list_body["messages"][0]["content"][0]["type"], "image");
|
||||
|
||||
// (b) 显式声明 text-only → 仍预替换(声明驱动,不受 heuristic 开关影响)
|
||||
let declared_provider = provider_with_settings(json!({
|
||||
"models": [ { "id": "some-text-model", "input": ["text"] } ]
|
||||
}));
|
||||
let mut declared_body = body_with_image("some-text-model");
|
||||
assert_eq!(
|
||||
fwd.apply_media_prevention(&mut declared_body, &declared_provider),
|
||||
1,
|
||||
"显式 text-only 即使关闭 heuristic 也应预替换"
|
||||
);
|
||||
assert_eq!(declared_body["messages"][0]["content"][0]["type"], "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reactive_triggers_when_all_switches_on() {
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig::default());
|
||||
let body = body_with_image("any-model");
|
||||
assert!(fwd.media_retry_should_trigger("Claude", false, &body, &image_unsupported_error()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reactive_triggers_for_codex_image_url_deserialize_errors() {
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig::default());
|
||||
let body = body_with_codex_input_image("deepseek-v4-flash");
|
||||
let error = ProxyError::UpstreamError {
|
||||
status: 400,
|
||||
body: Some(
|
||||
r#"{"error":{"message":"Failed to deserialize the JSON body into the target type: messages[11]: unknown variant image_url, expected text"}}"#
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
assert!(fwd.media_retry_should_trigger("Codex", false, &body, &error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reactive_skipped_when_media_fallback_off() {
|
||||
// 关闭 request_media_fallback:上游报图片错误也不触发兜底重试。
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig {
|
||||
request_media_fallback: false,
|
||||
..RectifierConfig::default()
|
||||
});
|
||||
let body = body_with_image("any-model");
|
||||
assert!(!fwd.media_retry_should_trigger(
|
||||
"Claude",
|
||||
false,
|
||||
&body,
|
||||
&image_unsupported_error()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reactive_skipped_when_master_switch_off() {
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig {
|
||||
enabled: false,
|
||||
..RectifierConfig::default()
|
||||
});
|
||||
let body = body_with_image("any-model");
|
||||
assert!(!fwd.media_retry_should_trigger(
|
||||
"Claude",
|
||||
false,
|
||||
&body,
|
||||
&image_unsupported_error()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reactive_unaffected_by_heuristic_switch() {
|
||||
// 关闭 request_media_heuristic 不影响反应式兜底——它是上游实测错误后的恢复,不是预测。
|
||||
let fwd = forwarder_with_rectifier(RectifierConfig {
|
||||
request_media_heuristic: false,
|
||||
..RectifierConfig::default()
|
||||
});
|
||||
let body = body_with_image("any-model");
|
||||
assert!(fwd.media_retry_should_trigger("Claude", false, &body, &image_unsupported_error()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,37 +60,40 @@ fn gemini_stream_usage_event_filter(data: &str) -> bool {
|
||||
// ============================================================================
|
||||
|
||||
/// Claude 流式响应模型提取(优先使用 usage.model)
|
||||
fn claude_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
///
|
||||
/// 空字符串模型名视为缺失(转换层对无回显上游会合成 model:""),
|
||||
/// 落到 fallback_model(映射后的出站模型或客户端请求模型)。
|
||||
fn claude_model_extractor(events: &[Value], fallback_model: &str) -> String {
|
||||
// 首先尝试从解析的 usage 中获取模型
|
||||
if let Some(usage) = TokenUsage::from_claude_stream_events(events) {
|
||||
if let Some(model) = usage.model {
|
||||
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
request_model.to_string()
|
||||
fallback_model.to_string()
|
||||
}
|
||||
|
||||
/// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model)
|
||||
fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
fn openai_model_extractor(events: &[Value], fallback_model: &str) -> String {
|
||||
// 首先尝试从解析的 usage 中获取模型
|
||||
if let Some(usage) = TokenUsage::from_openai_stream_events(events) {
|
||||
if let Some(model) = usage.model {
|
||||
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
// 回退:从事件中直接提取
|
||||
events
|
||||
.iter()
|
||||
.find_map(|e| e.get("model")?.as_str())
|
||||
.unwrap_or(request_model)
|
||||
.find_map(|e| e.get("model")?.as_str().filter(|m| !m.is_empty()))
|
||||
.unwrap_or(fallback_model)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Codex 智能流式响应模型提取(自动检测格式)
|
||||
fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
fn codex_auto_model_extractor(events: &[Value], fallback_model: &str) -> String {
|
||||
// 首先尝试从解析的 usage 中获取模型
|
||||
if let Some(usage) = TokenUsage::from_codex_stream_events_auto(events) {
|
||||
if let Some(model) = usage.model {
|
||||
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
@@ -99,28 +102,33 @@ fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
.iter()
|
||||
.find_map(|e| {
|
||||
if e.get("type")?.as_str()? == "response.completed" {
|
||||
e.get("response")?.get("model")?.as_str()
|
||||
e.get("response")?
|
||||
.get("model")?
|
||||
.as_str()
|
||||
.filter(|m| !m.is_empty())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or_else(|| {
|
||||
// 再回退:从 OpenAI 格式事件中提取
|
||||
events.iter().find_map(|e| e.get("model")?.as_str())
|
||||
events
|
||||
.iter()
|
||||
.find_map(|e| e.get("model")?.as_str().filter(|m| !m.is_empty()))
|
||||
})
|
||||
.unwrap_or(request_model)
|
||||
.unwrap_or(fallback_model)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Gemini 流式响应模型提取(优先使用 usage.model)
|
||||
fn gemini_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||
fn gemini_model_extractor(events: &[Value], fallback_model: &str) -> String {
|
||||
// 首先尝试从解析的 usage 中获取模型
|
||||
if let Some(usage) = TokenUsage::from_gemini_stream_chunks(events) {
|
||||
if let Some(model) = usage.model {
|
||||
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
request_model.to_string()
|
||||
fallback_model.to_string()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -48,6 +48,11 @@ pub struct RequestContext {
|
||||
pub current_provider_id: String,
|
||||
/// 请求中的模型名称
|
||||
pub request_model: String,
|
||||
/// 实际发往上游的模型名(路由接管/模型映射后的真值,forward 成功后回填)。
|
||||
///
|
||||
/// usage 归因的兜底顺序:上游响应回显 → outbound_model → request_model。
|
||||
/// 不能直接用 request_model 兜底:接管场景下它是映射前的客户端别名。
|
||||
pub outbound_model: Option<String>,
|
||||
/// 日志标签(如 "Claude"、"Codex"、"Gemini")
|
||||
pub tag: &'static str,
|
||||
/// 应用类型字符串(如 "claude"、"codex"、"gemini")
|
||||
@@ -159,6 +164,7 @@ impl RequestContext {
|
||||
providers,
|
||||
current_provider_id,
|
||||
request_model,
|
||||
outbound_model: None,
|
||||
tag,
|
||||
app_type_str,
|
||||
app_type,
|
||||
|
||||
+1244
-45
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,831 @@
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub const UNSUPPORTED_IMAGE_MARKER: &str = "[Unsupported Image]";
|
||||
|
||||
/// Replace image blocks before sending when the routed model is text-only.
|
||||
///
|
||||
/// Two paths, both reached only when the caller's media-fallback switch is on:
|
||||
/// - explicit capability from the provider config (modelCatalog / modalities) is
|
||||
/// always trusted — it is declaration-driven, never a guess;
|
||||
/// - the curated `known_text_only_model` list is a heuristic *prediction* and only
|
||||
/// runs when `allow_heuristic` is true, so a mislabeled multimodal model cannot
|
||||
/// have its images silently stripped when the user opts out.
|
||||
pub fn replace_images_for_text_only_model(
|
||||
body: &mut Value,
|
||||
provider: &Provider,
|
||||
allow_heuristic: bool,
|
||||
) -> usize {
|
||||
if !contains_image_blocks(body) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let model = body
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("");
|
||||
|
||||
match explicit_model_image_support(provider, model) {
|
||||
Some(true) => return 0,
|
||||
Some(false) => return replace_images_in_body(body),
|
||||
None => {}
|
||||
}
|
||||
|
||||
if !allow_heuristic || !known_text_only_model(model) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
replace_images_in_body(body)
|
||||
}
|
||||
|
||||
pub fn contains_image_blocks(body: &Value) -> bool {
|
||||
messages_have_image_blocks(body) || responses_input_has_image_blocks(body.get("input"))
|
||||
}
|
||||
|
||||
pub fn replace_image_blocks_with_marker(body: &mut Value) -> usize {
|
||||
replace_images_in_body(body)
|
||||
}
|
||||
|
||||
pub fn is_unsupported_image_error(error: &ProxyError) -> bool {
|
||||
let ProxyError::UpstreamError { status, body } = error else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if !matches!(*status, 400 | 415 | 422 | 501) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(body) = body.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let message = extract_error_text(body);
|
||||
let message = message.to_ascii_lowercase();
|
||||
let mentions_image = message.contains("image")
|
||||
|| message.contains("vision")
|
||||
|| message.contains("multimodal")
|
||||
|| message.contains("multi-modal")
|
||||
|| message.contains("modality")
|
||||
|| message.contains("modalities")
|
||||
|| message.contains("media")
|
||||
|| message.contains("attachment");
|
||||
|
||||
if !mentions_image {
|
||||
return false;
|
||||
}
|
||||
|
||||
const UNSUPPORTED_HINTS: &[&str] = &[
|
||||
"unsupported",
|
||||
"not supported",
|
||||
"does not support",
|
||||
"doesn't support",
|
||||
"do not support",
|
||||
"don't support",
|
||||
"only supports text",
|
||||
"text only",
|
||||
"text-only",
|
||||
"invalid content type",
|
||||
"invalid message content",
|
||||
"unknown variant",
|
||||
"unknown content type",
|
||||
"unrecognized content type",
|
||||
"cannot process",
|
||||
"cannot handle",
|
||||
"can't process",
|
||||
"can't handle",
|
||||
"unable to process",
|
||||
];
|
||||
|
||||
UNSUPPORTED_HINTS.iter().any(|hint| message.contains(hint))
|
||||
}
|
||||
|
||||
fn content_has_image_blocks(content: &Value) -> bool {
|
||||
let Some(blocks) = content.as_array() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
blocks.iter().any(|block| {
|
||||
is_image_block_type(block.get("type").and_then(Value::as_str))
|
||||
|| block.get("content").is_some_and(content_has_image_blocks)
|
||||
})
|
||||
}
|
||||
|
||||
fn replace_images_in_body(body: &mut Value) -> usize {
|
||||
let message_replacements = body
|
||||
.get_mut("messages")
|
||||
.and_then(Value::as_array_mut)
|
||||
.map(|messages| {
|
||||
messages
|
||||
.iter_mut()
|
||||
.filter_map(|message| message.get_mut("content"))
|
||||
.map(replace_images_in_content)
|
||||
.sum()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
message_replacements
|
||||
+ body
|
||||
.get_mut("input")
|
||||
.map(replace_images_in_responses_input)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn replace_images_in_content(content: &mut Value) -> usize {
|
||||
replace_images_in_content_with_text_type(content, "text")
|
||||
}
|
||||
|
||||
fn replace_images_in_content_with_text_type(content: &mut Value, text_type: &str) -> usize {
|
||||
let Some(blocks) = content.as_array_mut() else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let mut replaced = 0usize;
|
||||
for block in blocks {
|
||||
if is_image_block_type(block.get("type").and_then(Value::as_str)) {
|
||||
replace_image_block_with_text_marker(block, text_type);
|
||||
replaced += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(nested_content) = block.get_mut("content") {
|
||||
replaced += replace_images_in_content_with_text_type(nested_content, text_type);
|
||||
}
|
||||
}
|
||||
|
||||
replaced
|
||||
}
|
||||
|
||||
fn messages_have_image_blocks(body: &Value) -> bool {
|
||||
body.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|messages| {
|
||||
messages
|
||||
.iter()
|
||||
.filter_map(|message| message.get("content"))
|
||||
.any(content_has_image_blocks)
|
||||
})
|
||||
}
|
||||
|
||||
fn responses_input_has_image_blocks(input: Option<&Value>) -> bool {
|
||||
match input {
|
||||
Some(Value::Array(items)) => items.iter().any(responses_input_item_has_image_blocks),
|
||||
Some(item @ Value::Object(_)) => responses_input_item_has_image_blocks(item),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn responses_input_item_has_image_blocks(item: &Value) -> bool {
|
||||
if item.get("type").and_then(Value::as_str) == Some("input_image") {
|
||||
return true;
|
||||
}
|
||||
|
||||
item.get("content").is_some_and(content_has_image_blocks)
|
||||
}
|
||||
|
||||
fn replace_images_in_responses_input(input: &mut Value) -> usize {
|
||||
match input {
|
||||
Value::Array(items) => items
|
||||
.iter_mut()
|
||||
.map(replace_images_in_responses_input_item)
|
||||
.sum(),
|
||||
Value::Object(_) => replace_images_in_responses_input_item(input),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_images_in_responses_input_item(item: &mut Value) -> usize {
|
||||
let mut replaced = 0usize;
|
||||
|
||||
if item.get("type").and_then(Value::as_str) == Some("input_image") {
|
||||
replace_image_block_with_text_marker(item, "input_text");
|
||||
replaced += 1;
|
||||
}
|
||||
|
||||
if let Some(content) = item.get_mut("content") {
|
||||
replaced += replace_images_in_content_with_text_type(content, "input_text");
|
||||
}
|
||||
|
||||
replaced
|
||||
}
|
||||
|
||||
fn is_image_block_type(block_type: Option<&str>) -> bool {
|
||||
matches!(block_type, Some("image" | "image_url" | "input_image"))
|
||||
}
|
||||
|
||||
fn replace_image_block_with_text_marker(block: &mut Value, text_type: &str) {
|
||||
let cache_control = block.get("cache_control").cloned();
|
||||
*block = json!({
|
||||
"type": text_type,
|
||||
"text": UNSUPPORTED_IMAGE_MARKER
|
||||
});
|
||||
if let (Some(cache_control), Some(object)) = (cache_control, block.as_object_mut()) {
|
||||
object.insert("cache_control".to_string(), cache_control);
|
||||
}
|
||||
}
|
||||
|
||||
fn explicit_model_image_support(provider: &Provider, model: &str) -> Option<bool> {
|
||||
let settings = &provider.settings_config;
|
||||
[
|
||||
settings
|
||||
.get("modelCatalog")
|
||||
.and_then(|catalog| catalog.get("models")),
|
||||
settings.get("modelCatalog"),
|
||||
settings.get("models"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|value| explicit_model_image_support_in_value(value, model))
|
||||
}
|
||||
|
||||
fn known_text_only_model(model: &str) -> bool {
|
||||
let normalized = normalize_model_id(model);
|
||||
let tail = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
|
||||
|
||||
const EXACT_TAILS: &[&str] = &[
|
||||
"ark-code-latest",
|
||||
"deepseek-chat",
|
||||
"deepseek-reasoner",
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-pro",
|
||||
"glm-5.1",
|
||||
"kat-coder",
|
||||
"kat-coder-pro",
|
||||
"kat-coder-pro v1",
|
||||
"kat-coder-pro v2",
|
||||
"kat-coder-pro-v1",
|
||||
"kat-coder-pro-v2",
|
||||
"ling-2.5-1t",
|
||||
"longcat-flash-chat",
|
||||
"mimo-v2.5-pro",
|
||||
"us.deepseek.r1-v1",
|
||||
];
|
||||
|
||||
const TAIL_PREFIXES: &[&str] = &["minimax-m2.7", "qwen3-coder", "step-3.5-flash"];
|
||||
|
||||
EXACT_TAILS.contains(&tail) || TAIL_PREFIXES.iter().any(|prefix| tail.starts_with(prefix))
|
||||
}
|
||||
|
||||
fn explicit_model_image_support_in_value(value: &Value, model: &str) -> Option<bool> {
|
||||
if let Some(models) = value.as_array() {
|
||||
return models.iter().find_map(|entry| {
|
||||
model_entry_matches(entry, None, model).then(|| explicit_image_support(entry))?
|
||||
});
|
||||
}
|
||||
|
||||
let object = value.as_object()?;
|
||||
object.iter().find_map(|(key, entry)| {
|
||||
model_entry_matches(entry, Some(key), model).then(|| explicit_image_support(entry))?
|
||||
})
|
||||
}
|
||||
|
||||
fn explicit_image_support(entry: &Value) -> Option<bool> {
|
||||
if let Some(value) = entry
|
||||
.get("supportsImage")
|
||||
.or_else(|| entry.get("supports_image"))
|
||||
.or_else(|| entry.get("vision"))
|
||||
.and_then(Value::as_bool)
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
|
||||
[
|
||||
entry.get("input"),
|
||||
entry.pointer("/modalities/input"),
|
||||
entry.get("input_modalities"),
|
||||
entry.get("inputModalities"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(input_modalities_support_image)
|
||||
}
|
||||
|
||||
fn input_modalities_support_image(value: &Value) -> Option<bool> {
|
||||
let modalities = value.as_array()?;
|
||||
Some(modalities.iter().any(|item| {
|
||||
item.as_str()
|
||||
.map(str::trim)
|
||||
.is_some_and(|item| item.eq_ignore_ascii_case("image"))
|
||||
}))
|
||||
}
|
||||
|
||||
fn extract_error_text(body: &str) -> String {
|
||||
if let Ok(value) = serde_json::from_str::<Value>(body) {
|
||||
let candidates = [
|
||||
value.pointer("/error/message"),
|
||||
value.pointer("/message"),
|
||||
value.pointer("/detail"),
|
||||
value.pointer("/error"),
|
||||
];
|
||||
if let Some(message) = candidates
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|value| value.as_str())
|
||||
{
|
||||
return message.to_string();
|
||||
}
|
||||
|
||||
if let Ok(compact) = serde_json::to_string(&value) {
|
||||
return compact;
|
||||
}
|
||||
}
|
||||
|
||||
body.to_string()
|
||||
}
|
||||
|
||||
fn model_entry_matches(entry: &Value, key: Option<&str>, model: &str) -> bool {
|
||||
key.is_some_and(|key| model_ids_match(key, model))
|
||||
|| ["model", "id", "name"]
|
||||
.into_iter()
|
||||
.filter_map(|field| entry.get(field).and_then(Value::as_str))
|
||||
.any(|candidate| model_ids_match(candidate, model))
|
||||
}
|
||||
|
||||
fn model_ids_match(candidate: &str, model: &str) -> bool {
|
||||
let candidate = normalize_model_id(candidate);
|
||||
let model = normalize_model_id(model);
|
||||
if candidate.is_empty() || model.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if candidate == model {
|
||||
return true;
|
||||
}
|
||||
|
||||
let candidate_tail = candidate.rsplit('/').next().unwrap_or(candidate.as_str());
|
||||
let model_tail = model.rsplit('/').next().unwrap_or(model.as_str());
|
||||
candidate_tail == model_tail || candidate == model_tail || candidate_tail == model
|
||||
}
|
||||
|
||||
fn normalize_model_id(value: &str) -> String {
|
||||
let mut normalized = value
|
||||
.trim()
|
||||
.trim_start_matches("models/")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if let Some(stripped) =
|
||||
normalized.strip_suffix(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)
|
||||
{
|
||||
normalized = stripped.trim().to_string();
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::provider::Provider;
|
||||
use serde_json::json;
|
||||
|
||||
fn provider(settings_config: Value) -> Provider {
|
||||
Provider {
|
||||
id: "test".to_string(),
|
||||
name: "Test".to_string(),
|
||||
settings_config,
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: None,
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_images_when_model_capability_is_unknown() {
|
||||
let provider = provider(json!({}));
|
||||
let mut body = json!({
|
||||
"model": "unknown-model",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "text", "text": "look" },
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 0);
|
||||
assert_eq!(body["messages"][0]["content"][1]["type"], "image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_text_only_models_replace_images_before_send() {
|
||||
let provider = provider(json!({}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek/deepseek-v4-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][0]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_text_only_models_replace_chat_image_url_before_send() {
|
||||
let provider = provider(json!({}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-flash",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "text", "text": "look" },
|
||||
{ "type": "image_url", "image_url": { "url": "data:image/png;base64,abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(body["messages"][0]["content"][1]["type"], "text");
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][1]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_text_only_models_replace_codex_input_image_before_send() {
|
||||
let provider = provider(json!({}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-flash",
|
||||
"input": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "input_text", "text": "look" },
|
||||
{ "type": "input_image", "image_url": "data:image/png;base64,abc" }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(body["input"][0]["content"][1]["type"], "input_text");
|
||||
assert_eq!(
|
||||
body["input"][0]["content"][1]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_text_modalities_replace_images_before_send() {
|
||||
let provider = provider(json!({
|
||||
"models": [
|
||||
{ "id": "deepseek-v4-pro", "input": ["text"] }
|
||||
]
|
||||
}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "text", "text": "look" },
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(body["messages"][0]["content"][0]["text"], "look");
|
||||
assert_eq!(body["messages"][0]["content"][1]["type"], "text");
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][1]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_images_without_explicit_capability_even_for_unknown_models() {
|
||||
let provider = provider(json!({}));
|
||||
let mut body = json!({
|
||||
"model": "unknown-model",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 0);
|
||||
assert_eq!(body["messages"][0]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_text_modalities_can_override_visual_model_ids() {
|
||||
let provider = provider(json!({
|
||||
"models": [
|
||||
{ "id": "gpt-4o", "input": ["text"] }
|
||||
]
|
||||
}));
|
||||
let mut body = json!({
|
||||
"model": "gpt-4o",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][0]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_image_modalities_preserve_model_images() {
|
||||
let provider = provider(json!({
|
||||
"modelCatalog": {
|
||||
"models": [
|
||||
{ "model": "deepseek-v4-pro", "modalities": { "input": ["text", "image"] } }
|
||||
]
|
||||
}
|
||||
}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 0);
|
||||
assert_eq!(body["messages"][0]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_mimo_pro_replaces_but_mimo_multimodal_preserves() {
|
||||
let provider = provider(json!({}));
|
||||
let mut pro_body = json!({
|
||||
"model": "xiaomi-mimo-token-plan/mimo-v2.5-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
let mut multimodal_body = json!({
|
||||
"model": "xiaomi-mimo-token-plan/mimo-v2.5",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let pro_count = replace_images_for_text_only_model(&mut pro_body, &provider, true);
|
||||
let multimodal_count =
|
||||
replace_images_for_text_only_model(&mut multimodal_body, &provider, true);
|
||||
|
||||
assert_eq!(pro_count, 1);
|
||||
assert_eq!(multimodal_count, 0);
|
||||
assert_eq!(
|
||||
multimodal_body["messages"][0]["content"][0]["type"],
|
||||
"image"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multimodal_kimi_model_is_not_on_text_only_list() {
|
||||
let provider = provider(json!({}));
|
||||
let mut body = json!({
|
||||
"model": "kimi/kimi-k2.6",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 0);
|
||||
assert_eq!(body["messages"][0]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_text_only_prefixes_replace_images_before_send() {
|
||||
let provider = provider(json!({}));
|
||||
let mut body = json!({
|
||||
"model": "therouter/qwen/qwen3-coder-480b",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][0]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unconditional_marker_replacement_handles_retry_path() {
|
||||
let mut body = json!({
|
||||
"model": "xiaomi-mimo-token-plan/mimo-v2.5-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
assert!(contains_image_blocks(&body));
|
||||
let count = replace_image_blocks_with_marker(&mut body);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][0]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaces_nested_tool_result_image_blocks() {
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_1",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_image_blocks_with_marker(&mut body);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][0]["content"][0]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_unsupported_image_errors() {
|
||||
let error = ProxyError::UpstreamError {
|
||||
status: 400,
|
||||
body: Some(
|
||||
r#"{"error":{"message":"This model does not support image input"}}"#.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
assert!(is_unsupported_image_error(&error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_image_errors() {
|
||||
let error = ProxyError::UpstreamError {
|
||||
status: 400,
|
||||
body: Some(r#"{"error":{"message":"Invalid API key"}}"#.to_string()),
|
||||
};
|
||||
|
||||
assert!(!is_unsupported_image_error(&error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_cache_control_when_replacing_image() {
|
||||
// image block 可能承载 prompt cache 断点;替换成标记时必须把
|
||||
// cache_control 迁移到新的 text block,否则会断掉缓存命中。
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "image",
|
||||
"source": { "type": "base64", "media_type": "image/png", "data": "abc" },
|
||||
"cache_control": { "type": "ephemeral" }
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_image_blocks_with_marker(&mut body);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
let block = &body["messages"][0]["content"][0];
|
||||
assert_eq!(block["type"], "text");
|
||||
assert_eq!(block["text"], UNSUPPORTED_IMAGE_MARKER);
|
||||
assert_eq!(block["cache_control"]["type"], "ephemeral");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_media_and_attachment_error_phrasings() {
|
||||
let media_error = ProxyError::UpstreamError {
|
||||
status: 400,
|
||||
body: Some(
|
||||
r#"{"error":{"message":"This model cannot process media inputs"}}"#.to_string(),
|
||||
),
|
||||
};
|
||||
assert!(is_unsupported_image_error(&media_error));
|
||||
|
||||
let attachment_error = ProxyError::UpstreamError {
|
||||
status: 422,
|
||||
body: Some(r#"{"message":"attachments are not supported by this model"}"#.to_string()),
|
||||
};
|
||||
assert!(is_unsupported_image_error(&attachment_error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_chat_content_unknown_variant_image_url_errors() {
|
||||
let error = ProxyError::UpstreamError {
|
||||
status: 400,
|
||||
body: Some(
|
||||
r#"{"error":{"message":"Failed to deserialize the JSON body into the target type: messages[11]: unknown variant image_url, expected text"}}"#
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
assert!(is_unsupported_image_error(&error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heuristic_disabled_keeps_images_for_listed_text_only_models() {
|
||||
// allow_heuristic = false:内置列表不再预测性剥图,避免误判多模态模型时静默丢图。
|
||||
let provider = provider(json!({}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek/deepseek-v4-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, false);
|
||||
|
||||
assert_eq!(count, 0);
|
||||
assert_eq!(body["messages"][0]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_text_capability_replaces_even_when_heuristic_disabled() {
|
||||
// 显式声明 text-only 是声明驱动、零误判,即使关掉启发式也应生效。
|
||||
let provider = provider(json!({
|
||||
"models": [
|
||||
{ "id": "deepseek-v4-pro", "input": ["text"] }
|
||||
]
|
||||
}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "abc" } }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let count = replace_images_for_text_only_model(&mut body, &provider, false);
|
||||
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][0]["text"],
|
||||
UNSUPPORTED_IMAGE_MARKER
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ pub mod http_client;
|
||||
pub mod hyper_client;
|
||||
pub(crate) mod json_canonical;
|
||||
pub mod log_codes;
|
||||
pub mod media_sanitizer;
|
||||
pub mod model_mapper;
|
||||
pub mod provider_router;
|
||||
pub mod providers;
|
||||
|
||||
@@ -145,6 +145,86 @@ pub fn normalize_anthropic_tool_thinking_history_for_provider(
|
||||
normalize_anthropic_tool_thinking_history(body)
|
||||
}
|
||||
|
||||
pub fn normalize_anthropic_messages_for_provider(
|
||||
body: &mut Value,
|
||||
provider: &Provider,
|
||||
api_format: &str,
|
||||
) -> bool {
|
||||
if api_format.trim() != "anthropic" {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut changed = normalize_anthropic_system_role_messages(body);
|
||||
changed |= normalize_anthropic_tool_thinking_history_for_provider(body, provider, api_format);
|
||||
changed
|
||||
}
|
||||
|
||||
fn normalize_anthropic_system_role_messages(body: &mut Value) -> bool {
|
||||
let mut system_parts = Vec::new();
|
||||
let changed = {
|
||||
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let original_len = messages.len();
|
||||
let mut kept_messages = Vec::with_capacity(messages.len());
|
||||
for message in std::mem::take(messages) {
|
||||
if message.get("role").and_then(Value::as_str) == Some("system") {
|
||||
if let Some(content) = message.get("content") {
|
||||
append_anthropic_system_parts(content, &mut system_parts);
|
||||
}
|
||||
} else {
|
||||
kept_messages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
let changed = kept_messages.len() != original_len;
|
||||
*messages = kept_messages;
|
||||
changed
|
||||
};
|
||||
|
||||
if !changed || system_parts.is_empty() {
|
||||
return changed;
|
||||
}
|
||||
|
||||
let mut merged_parts = Vec::new();
|
||||
if let Some(existing) = body.get("system") {
|
||||
append_anthropic_system_parts(existing, &mut merged_parts);
|
||||
}
|
||||
merged_parts.extend(system_parts);
|
||||
|
||||
if !merged_parts.is_empty() {
|
||||
body["system"] = Value::Array(merged_parts);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn append_anthropic_system_parts(content: &Value, parts: &mut Vec<Value>) {
|
||||
match content {
|
||||
Value::String(text) if !text.trim().is_empty() => {
|
||||
parts.push(json!({
|
||||
"type": "text",
|
||||
"text": text
|
||||
}));
|
||||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
append_anthropic_system_parts(item, parts);
|
||||
}
|
||||
}
|
||||
Value::Object(obj)
|
||||
if obj
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|text| !text.trim().is_empty()) =>
|
||||
{
|
||||
parts.push(Value::Object(obj.clone()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_anthropic_tool_thinking_history(body: &mut Value) -> bool {
|
||||
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
|
||||
return false;
|
||||
@@ -329,6 +409,10 @@ pub fn transform_claude_request_for_api_format(
|
||||
{
|
||||
result["prompt_cache_key"] = serde_json::json!(key);
|
||||
}
|
||||
// 流式请求必须注入 stream_options.include_usage,否则 OpenAI 兼容上游
|
||||
// 不在 SSE 末尾吐 usage → 转换出的 Anthropic message_delta 全 0 →
|
||||
// 整笔 input/output/cache 漏记(与 Codex Responses→Chat 路径同源)。
|
||||
super::transform::inject_openai_stream_include_usage(&mut result);
|
||||
Ok(result)
|
||||
}
|
||||
"gemini_native" => super::transform_gemini::anthropic_to_gemini_with_shadow(
|
||||
@@ -1537,6 +1621,43 @@ mod tests {
|
||||
assert!(transformed.get("max_output_tokens").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_openai_chat_streaming_injects_include_usage() {
|
||||
let provider = create_provider(json!({
|
||||
"env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1" }
|
||||
}));
|
||||
// 流式请求必须注入 stream_options.include_usage,否则 OpenAI 兼容上游不在
|
||||
// SSE 末尾吐 usage → 转换出的 Anthropic message_delta 全 0 → 整笔 usage 漏记。
|
||||
let body = json!({
|
||||
"model": "moonshotai/kimi-k2",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 128,
|
||||
"stream": true
|
||||
});
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||||
.unwrap();
|
||||
assert_eq!(transformed["stream"], true);
|
||||
assert_eq!(transformed["stream_options"]["include_usage"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_openai_chat_non_streaming_omits_stream_options() {
|
||||
let provider = create_provider(json!({
|
||||
"env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1" }
|
||||
}));
|
||||
// 非流式请求不应注入 stream_options(usage 在非流式响应体里恒有)。
|
||||
let body = json!({
|
||||
"model": "moonshotai/kimi-k2",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 128
|
||||
});
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||||
.unwrap();
|
||||
assert!(transformed.get("stream_options").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_codex_oauth_uses_session_cache_key() {
|
||||
let provider = create_provider_with_meta(
|
||||
@@ -1995,6 +2116,64 @@ mod tests {
|
||||
assert_eq!(content[2]["type"], "tool_use");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_system_role_messages_move_to_top_level_system() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}));
|
||||
let mut body = json!({
|
||||
"system": "Existing top-level system.",
|
||||
"model": "deepseek-v4-pro",
|
||||
"messages": [
|
||||
{ "role": "system", "content": "Message system one." },
|
||||
{ "role": "user", "content": "hello" },
|
||||
{
|
||||
"role": "system",
|
||||
"content": [{ "type": "text", "text": "Message system two." }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let changed = normalize_anthropic_messages_for_provider(&mut body, &provider, "anthropic");
|
||||
|
||||
assert!(changed);
|
||||
let messages = body["messages"].as_array().unwrap();
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0]["role"], "user");
|
||||
|
||||
let system = body["system"].as_array().unwrap();
|
||||
assert_eq!(system[0]["text"], "Existing top-level system.");
|
||||
assert_eq!(system[1]["text"], "Message system one.");
|
||||
assert_eq!(system[2]["text"], "Message system two.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_system_role_messages_skip_non_anthropic_format() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/v1",
|
||||
"ANTHROPIC_API_KEY": "test-key"
|
||||
}
|
||||
}));
|
||||
let mut body = json!({
|
||||
"model": "deepseek-v4-pro",
|
||||
"messages": [
|
||||
{ "role": "system", "content": "Keep in messages." },
|
||||
{ "role": "user", "content": "hello" }
|
||||
]
|
||||
});
|
||||
|
||||
let changed =
|
||||
normalize_anthropic_messages_for_provider(&mut body, &provider, "openai_chat");
|
||||
|
||||
assert!(!changed);
|
||||
assert!(body.get("system").is_none());
|
||||
assert_eq!(body["messages"][0]["role"], "system");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kimi_anthropic_tool_history_injects_missing_thinking() {
|
||||
let provider = create_provider(json!({
|
||||
|
||||
@@ -60,7 +60,7 @@ impl CodexChatHistoryStore {
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(cached_function_call)
|
||||
.filter_map(cached_call_item)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
@@ -73,8 +73,8 @@ impl CodexChatHistoryStore {
|
||||
inner.insert_calls(response_id, calls)
|
||||
}
|
||||
|
||||
async fn record_function_call(&self, response_id: Option<&str>, item: &Value) -> bool {
|
||||
let Some(call) = cached_function_call(item) else {
|
||||
async fn record_call_item(&self, response_id: Option<&str>, item: &Value) -> bool {
|
||||
let Some(call) = cached_call_item(item) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -110,14 +110,18 @@ impl CodexChatHistoryStore {
|
||||
let output_call_ids = items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
item.get("type").and_then(|value| value.as_str()) == Some("function_call_output")
|
||||
item.get("type")
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(is_call_output_item_type)
|
||||
})
|
||||
.filter_map(response_item_call_id)
|
||||
.collect::<HashSet<_>>();
|
||||
let existing_call_ids = items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
item.get("type").and_then(|value| value.as_str()) == Some("function_call")
|
||||
item.get("type")
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(is_call_item_type)
|
||||
})
|
||||
.filter_map(response_item_call_id)
|
||||
.collect::<HashSet<_>>();
|
||||
@@ -143,10 +147,10 @@ impl CodexChatHistoryStore {
|
||||
|
||||
for mut item in items {
|
||||
match item.get("type").and_then(|value| value.as_str()) {
|
||||
Some("function_call") => {
|
||||
Some(item_type) if is_call_item_type(item_type) => {
|
||||
if let Some(call_id) = response_item_call_id(&item) {
|
||||
if let Some(cached) = lookup.call(&call_id) {
|
||||
if enrich_function_call_reasoning(&mut item, cached) {
|
||||
if enrich_call_item_reasoning(&mut item, cached) {
|
||||
enriched += 1;
|
||||
}
|
||||
}
|
||||
@@ -154,7 +158,7 @@ impl CodexChatHistoryStore {
|
||||
}
|
||||
new_items.push(item);
|
||||
}
|
||||
Some("function_call_output") => {
|
||||
Some(item_type) if is_call_output_item_type(item_type) => {
|
||||
if let Some(group) = restore_group.take().filter(|group| !group.is_empty()) {
|
||||
for (call_id, cached_item) in group {
|
||||
seen_call_ids.insert(call_id);
|
||||
@@ -423,7 +427,7 @@ async fn inspect_sse_block(
|
||||
Some("response.output_item.done") => {
|
||||
if let Some(item) = value.get("item") {
|
||||
history
|
||||
.record_function_call(current_response_id.as_deref(), item)
|
||||
.record_call_item(current_response_id.as_deref(), item)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -436,15 +440,33 @@ async fn inspect_sse_block(
|
||||
}
|
||||
}
|
||||
|
||||
fn cached_function_call(item: &Value) -> Option<(String, Value)> {
|
||||
if item.get("type").and_then(|value| value.as_str()) != Some("function_call") {
|
||||
fn cached_call_item(item: &Value) -> Option<(String, Value)> {
|
||||
if !item
|
||||
.get("type")
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(is_call_item_type)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let call_id = response_item_call_id(item)?;
|
||||
Some((call_id, item.clone()))
|
||||
}
|
||||
|
||||
fn enrich_function_call_reasoning(item: &mut Value, cached: &Value) -> bool {
|
||||
fn is_call_item_type(item_type: &str) -> bool {
|
||||
matches!(
|
||||
item_type,
|
||||
"function_call" | "custom_tool_call" | "tool_search_call"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_call_output_item_type(item_type: &str) -> bool {
|
||||
matches!(
|
||||
item_type,
|
||||
"function_call_output" | "custom_tool_call_output" | "tool_search_output"
|
||||
)
|
||||
}
|
||||
|
||||
fn enrich_call_item_reasoning(item: &mut Value, cached: &Value) -> bool {
|
||||
let mut changed = false;
|
||||
for key in ["reasoning_content", "reasoning"] {
|
||||
if item.get(key).is_some_and(|value| !is_empty_value(value)) {
|
||||
@@ -704,6 +726,58 @@ mod tests {
|
||||
assert_eq!(input[3]["type"], "function_call_output");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restores_custom_and_tool_search_calls_from_previous_response() {
|
||||
let history = CodexChatHistoryStore::default();
|
||||
history
|
||||
.record_response(&json!({
|
||||
"id": "resp_1",
|
||||
"output": [
|
||||
{
|
||||
"type": "custom_tool_call",
|
||||
"call_id": "call_patch",
|
||||
"name": "apply_patch",
|
||||
"input": "*** Begin Patch\n*** End Patch",
|
||||
"reasoning_content": "Need to patch the file."
|
||||
},
|
||||
{
|
||||
"type": "tool_search_call",
|
||||
"call_id": "call_search",
|
||||
"status": "completed",
|
||||
"execution": "client",
|
||||
"arguments": {"query": "Gmail tools"},
|
||||
"reasoning_content": "Need to discover tools."
|
||||
}
|
||||
]
|
||||
}))
|
||||
.await;
|
||||
|
||||
let mut request = json!({
|
||||
"previous_response_id": "resp_1",
|
||||
"input": [
|
||||
{
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call_patch",
|
||||
"output": "patched"
|
||||
},
|
||||
{
|
||||
"type": "tool_search_output",
|
||||
"call_id": "call_search",
|
||||
"tools": []
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
assert_eq!(history.enrich_request(&mut request).await, 2);
|
||||
let input = request["input"].as_array().unwrap();
|
||||
assert_eq!(input[0]["type"], "custom_tool_call");
|
||||
assert_eq!(input[0]["call_id"], "call_patch");
|
||||
assert_eq!(input[1]["type"], "tool_search_call");
|
||||
assert_eq!(input[1]["call_id"], "call_search");
|
||||
assert_eq!(input[2]["type"], "custom_tool_call_output");
|
||||
assert_eq!(input[3]["type"], "tool_search_output");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn records_streamed_function_call_done_items() {
|
||||
let history = Arc::new(CodexChatHistoryStore::default());
|
||||
|
||||
@@ -42,8 +42,8 @@ pub use adapter::ProviderAdapter;
|
||||
pub use auth::{AuthInfo, AuthStrategy};
|
||||
pub use claude::{
|
||||
claude_api_format_needs_transform, get_claude_api_format,
|
||||
normalize_anthropic_tool_thinking_history_for_provider,
|
||||
transform_claude_request_for_api_format, ClaudeAdapter,
|
||||
normalize_anthropic_messages_for_provider, transform_claude_request_for_api_format,
|
||||
ClaudeAdapter,
|
||||
};
|
||||
pub use codex::CodexAdapter;
|
||||
pub use codex::{
|
||||
|
||||
@@ -90,25 +90,33 @@ struct ToolBlockState {
|
||||
started: bool,
|
||||
pending_args: String,
|
||||
/// 连续空白字符计数 — 用于检测 Copilot 无限换行 bug
|
||||
/// 当 function call 参数中出现连续 20+ 空白字符时,强制终止流
|
||||
/// 当 function call 参数中的连续空白字符达到阈值时,强制终止流
|
||||
consecutive_whitespace: usize,
|
||||
/// 是否已因无限空白 bug 被中止
|
||||
aborted: bool,
|
||||
}
|
||||
|
||||
/// 无限空白 bug 的连续空白字符阈值
|
||||
const INFINITE_WHITESPACE_THRESHOLD: usize = 20;
|
||||
const INFINITE_WHITESPACE_THRESHOLD: usize = 500;
|
||||
|
||||
fn build_anthropic_usage_json(usage: &Usage) -> Value {
|
||||
// OpenAI prompt_tokens 含缓存,Anthropic input_tokens 不含,需减去 cache_read 与 cache_creation
|
||||
// (三桶互斥,恒等 input + cache_read + cache_creation == prompt_tokens)。
|
||||
let cached = extract_cache_read_tokens(usage).unwrap_or(0);
|
||||
let cache_creation = usage.cache_creation_input_tokens.unwrap_or(0);
|
||||
let input_tokens = usage
|
||||
.prompt_tokens
|
||||
.saturating_sub(cached)
|
||||
.saturating_sub(cache_creation);
|
||||
let mut usage_json = json!({
|
||||
"input_tokens": usage.prompt_tokens,
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": usage.completion_tokens
|
||||
});
|
||||
if let Some(cached) = extract_cache_read_tokens(usage) {
|
||||
if cached > 0 {
|
||||
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);
|
||||
if cache_creation > 0 {
|
||||
usage_json["cache_creation_input_tokens"] = json!(cache_creation);
|
||||
}
|
||||
usage_json
|
||||
}
|
||||
@@ -223,12 +231,20 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
"output_tokens": 0
|
||||
});
|
||||
if let Some(u) = &chunk.usage {
|
||||
start_usage["input_tokens"] = json!(u.prompt_tokens);
|
||||
if let Some(cached) = extract_cache_read_tokens(u) {
|
||||
let cached = extract_cache_read_tokens(u).unwrap_or(0);
|
||||
let cache_creation =
|
||||
u.cache_creation_input_tokens.unwrap_or(0);
|
||||
let input = u
|
||||
.prompt_tokens
|
||||
.saturating_sub(cached)
|
||||
.saturating_sub(cache_creation);
|
||||
start_usage["input_tokens"] = json!(input);
|
||||
if cached > 0 {
|
||||
start_usage["cache_read_input_tokens"] = json!(cached);
|
||||
}
|
||||
if let Some(created) = u.cache_creation_input_tokens {
|
||||
start_usage["cache_creation_input_tokens"] = json!(created);
|
||||
if cache_creation > 0 {
|
||||
start_usage["cache_creation_input_tokens"] =
|
||||
json!(cache_creation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1022,7 +1038,7 @@ mod tests {
|
||||
message_delta
|
||||
.pointer("/usage/input_tokens")
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(13312)
|
||||
Some(13212)
|
||||
);
|
||||
assert_eq!(
|
||||
message_delta
|
||||
@@ -1038,6 +1054,81 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_usage_chunk_subtracts_cache_read_and_creation_from_input() {
|
||||
// prompt_tokens(1000) 含 cache_read(600) 与 cache_creation(300);转 Anthropic 后
|
||||
// input 应为 fresh,守恒:input(100) + cache_read(600) + cache_creation(300) == prompt(1000)。
|
||||
let input = concat!(
|
||||
"data: {\"id\":\"chatcmpl_cc\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"tool-1\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"{\\\"command\\\":\\\"pwd\\\"}\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_cc\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
|
||||
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000,\"completion_tokens\":50,\"prompt_tokens_details\":{\"cached_tokens\":600},\"cache_creation_input_tokens\":300}}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
|
||||
let events = collect_anthropic_events(input).await;
|
||||
let message_delta = events
|
||||
.iter()
|
||||
.find(|event| event_type(event) == Some("message_delta"))
|
||||
.expect("should emit message_delta with usage");
|
||||
|
||||
// fresh input = 1000 - 600 - 300 = 100
|
||||
assert_eq!(
|
||||
message_delta
|
||||
.pointer("/usage/input_tokens")
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(100)
|
||||
);
|
||||
assert_eq!(
|
||||
message_delta
|
||||
.pointer("/usage/cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(600)
|
||||
);
|
||||
assert_eq!(
|
||||
message_delta
|
||||
.pointer("/usage/cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(300)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_usage_chunk_clamps_input_to_zero_when_cache_exceeds_prompt() {
|
||||
// prompt(100) < cache_read(80)+cache_creation(50)=130:saturating 钳到 0,防下溢。
|
||||
// 钉桩:阻止未来把 saturating_sub 误改成普通减法(debug panic / release wrap)。
|
||||
let input = concat!(
|
||||
"data: {\"id\":\"chatcmpl_uf\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"tool-1\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"{\\\"command\\\":\\\"pwd\\\"}\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_uf\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
|
||||
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":50,\"prompt_tokens_details\":{\"cached_tokens\":80},\"cache_creation_input_tokens\":50}}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
|
||||
let events = collect_anthropic_events(input).await;
|
||||
let message_delta = events
|
||||
.iter()
|
||||
.find(|event| event_type(event) == Some("message_delta"))
|
||||
.expect("should emit message_delta with usage");
|
||||
|
||||
assert_eq!(
|
||||
message_delta
|
||||
.pointer("/usage/input_tokens")
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(0)
|
||||
);
|
||||
assert_eq!(
|
||||
message_delta
|
||||
.pointer("/usage/cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(80)
|
||||
);
|
||||
assert_eq!(
|
||||
message_delta
|
||||
.pointer("/usage/cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64()),
|
||||
Some(50)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_message_delta_includes_zero_usage_when_stream_has_no_usage() {
|
||||
let input = concat!(
|
||||
|
||||
@@ -141,6 +141,7 @@ impl ChatToResponsesState {
|
||||
if let Some(delta) = choice.get("delta") {
|
||||
if let Some(reasoning) = chat_delta_reasoning_text(delta) {
|
||||
events.extend(self.push_reasoning_delta(&reasoning));
|
||||
self.append_reasoning_to_active_tools(&reasoning);
|
||||
}
|
||||
|
||||
if let Some(content) = delta.get("content").and_then(|v| v.as_str()) {
|
||||
@@ -522,6 +523,34 @@ impl ChatToResponsesState {
|
||||
events
|
||||
}
|
||||
|
||||
fn append_reasoning_to_active_tools(&mut self, delta: &str) {
|
||||
if delta.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for state in self.tools.values_mut().filter(|state| !state.done) {
|
||||
if state.reasoning_content.is_empty() {
|
||||
state.reasoning_content = delta.trim_start().to_string();
|
||||
} else {
|
||||
state.reasoning_content.push_str(delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_substantive_output(&self) -> bool {
|
||||
!self.text.text.trim().is_empty()
|
||||
|| !self.reasoning.text.trim().is_empty()
|
||||
|| !self.inline_think.buffer.trim().is_empty()
|
||||
|| !self.output_items.is_empty()
|
||||
|| self.tools.values().any(|state| {
|
||||
state.added
|
||||
|| !state.call_id.trim().is_empty()
|
||||
|| !state.name.trim().is_empty()
|
||||
|| !state.arguments.trim().is_empty()
|
||||
|| !state.reasoning_content.trim().is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
fn finalize(&mut self) -> Vec<Bytes> {
|
||||
if self.completed {
|
||||
return Vec::new();
|
||||
@@ -949,8 +978,20 @@ pub fn create_responses_sse_stream_from_chat_with_context<E: std::error::Error +
|
||||
}
|
||||
|
||||
if !stream_failed {
|
||||
for event in state.finalize() {
|
||||
yield Ok(event);
|
||||
if state.completed || state.finish_reason.is_some() {
|
||||
for event in state.finalize() {
|
||||
yield Ok(event);
|
||||
}
|
||||
} else if state.has_substantive_output() {
|
||||
state.finish_reason = Some("length".to_string());
|
||||
for event in state.finalize() {
|
||||
yield Ok(event);
|
||||
}
|
||||
} else {
|
||||
yield Ok(state.failed_event(
|
||||
"Upstream Chat Completions stream ended before sending finish_reason".to_string(),
|
||||
Some("stream_truncated".to_string()),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1134,6 +1175,22 @@ mod tests {
|
||||
assert!(output.contains("\"reasoning_content\":\"Need file.\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preserves_late_reasoning_content_on_streamed_tool_call_items() {
|
||||
let output = collect(vec![
|
||||
"data: {\"id\":\"chatcmpl_tool_late_reasoning\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_tool_late_reasoning\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"path\\\":\\\"README.md\\\"}\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_tool_late_reasoning\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"reasoning_content\":\"Need file.\"}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_tool_late_reasoning\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
|
||||
"data: [DONE]\n\n",
|
||||
])
|
||||
.await;
|
||||
|
||||
assert!(output.contains("event: response.output_item.done"));
|
||||
assert!(output.contains("\"type\":\"function_call\""));
|
||||
assert!(output.contains("\"reasoning_content\":\"Need file.\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restores_namespace_on_streamed_tool_call_items() {
|
||||
let request = json!({
|
||||
@@ -1209,6 +1266,31 @@ mod tests {
|
||||
assert!(!output.contains("event: response.completed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_end_with_output_without_finish_reason_emits_incomplete_without_failed() {
|
||||
let output = collect(vec![
|
||||
"data: {\"id\":\"chatcmpl_truncated\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n",
|
||||
])
|
||||
.await;
|
||||
|
||||
assert!(output.contains("event: response.completed"));
|
||||
assert!(output.contains("\"status\":\"incomplete\""));
|
||||
assert!(output.contains("\"incomplete_details\":{\"reason\":\"max_output_tokens\"}"));
|
||||
assert!(!output.contains("event: response.failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_end_without_output_or_finish_reason_emits_failed_without_completed() {
|
||||
let output = collect(vec![
|
||||
"data: {\"id\":\"chatcmpl_truncated\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{}}]}\n\n",
|
||||
])
|
||||
.await;
|
||||
|
||||
assert!(output.contains("event: response.failed"));
|
||||
assert!(output.contains("stream_truncated"));
|
||||
assert!(!output.contains("event: response.completed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_sse_error_event_emits_failed_without_completed() {
|
||||
let output = collect(vec![
|
||||
|
||||
@@ -142,18 +142,13 @@ pub fn anthropic_to_openai_with_reasoning_content(
|
||||
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();
|
||||
}
|
||||
messages.push(sys_msg);
|
||||
messages.push(json!({"role": "system", "content": text}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,18 +202,14 @@ pub fn anthropic_to_openai_with_reasoning_content(
|
||||
.iter()
|
||||
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
|
||||
.map(|t| {
|
||||
let mut tool = json!({
|
||||
json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""),
|
||||
"description": t.get("description"),
|
||||
"parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({})))
|
||||
}
|
||||
});
|
||||
if let Some(cc) = t.get("cache_control") {
|
||||
tool["cache_control"] = cc.clone();
|
||||
}
|
||||
tool
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -234,6 +225,33 @@ pub fn anthropic_to_openai_with_reasoning_content(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 为 OpenAI Chat Completions 流式请求注入 `stream_options.include_usage`。
|
||||
///
|
||||
/// OpenAI 兼容上游在流式下默认不在 SSE 里返回 usage,必须显式声明 include_usage
|
||||
/// 才会在末尾吐 usage chunk。缺这一注入会导致流式请求的 token/成本/缓存全部漏记
|
||||
/// (input/output/cache 全为 0)。保留客户端可能透传的其它 stream_options 字段,
|
||||
/// 仅补 include_usage;非流式请求不动。
|
||||
///
|
||||
/// 由 Claude→openai_chat(claude.rs)与 Codex Responses→Chat(transform_codex_chat.rs)
|
||||
/// 两条转换路径共用,确保两个客户端方向行为一致。
|
||||
pub(crate) fn inject_openai_stream_include_usage(result: &mut Value) {
|
||||
let is_stream = result
|
||||
.get("stream")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if !is_stream {
|
||||
return;
|
||||
}
|
||||
match result.get_mut("stream_options") {
|
||||
Some(Value::Object(opts)) => {
|
||||
opts.insert("include_usage".to_string(), json!(true));
|
||||
}
|
||||
_ => {
|
||||
result["stream_options"] = json!({ "include_usage": true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate an Anthropic `tool_choice` into the OpenAI Chat Completions form.
|
||||
///
|
||||
/// Anthropic forms:
|
||||
@@ -294,10 +312,6 @@ fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
let mut inherited_cache_control: Option<Value> = None;
|
||||
let mut cache_control_conflict = false;
|
||||
let mut saw_cache_control = false;
|
||||
let mut saw_missing_cache_control = false;
|
||||
messages.retain(|message| {
|
||||
if message.get("role").and_then(|value| value.as_str()) != Some("system") {
|
||||
return true;
|
||||
@@ -318,28 +332,11 @@ fn normalize_openai_system_messages(messages: &mut Vec<Value>) {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some(cache_control) = message.get("cache_control") {
|
||||
saw_cache_control = true;
|
||||
match &inherited_cache_control {
|
||||
None => inherited_cache_control = Some(cache_control.clone()),
|
||||
Some(existing) if existing == cache_control => {}
|
||||
Some(_) => cache_control_conflict = true,
|
||||
}
|
||||
} else {
|
||||
saw_missing_cache_control = true;
|
||||
}
|
||||
|
||||
false
|
||||
});
|
||||
|
||||
if !parts.is_empty() {
|
||||
let mut merged = json!({"role": "system", "content": parts.join("\n")});
|
||||
if !(cache_control_conflict || (saw_cache_control && saw_missing_cache_control)) {
|
||||
if let Some(cache_control) = inherited_cache_control {
|
||||
merged["cache_control"] = cache_control;
|
||||
}
|
||||
}
|
||||
messages.insert(0, merged);
|
||||
messages.insert(0, json!({"role": "system", "content": parts.join("\n")}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,11 +376,7 @@ fn convert_message_to_openai(
|
||||
match block_type {
|
||||
"text" => {
|
||||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||||
let mut part = json!({"type": "text", "text": text});
|
||||
if let Some(cc) = block.get("cache_control") {
|
||||
part["cache_control"] = cc.clone();
|
||||
}
|
||||
content_parts.push(part);
|
||||
content_parts.push(json!({"type": "text", "text": text}));
|
||||
}
|
||||
}
|
||||
"image" => {
|
||||
@@ -458,14 +451,9 @@ fn convert_message_to_openai(
|
||||
if content_parts.is_empty() {
|
||||
msg["content"] = Value::Null;
|
||||
} else if content_parts.len() == 1 {
|
||||
// When cache_control is present, keep array format to preserve it
|
||||
let has_cache_control = content_parts[0].get("cache_control").is_some();
|
||||
if !has_cache_control {
|
||||
if let Some(text) = content_parts[0].get("text") {
|
||||
msg["content"] = text.clone();
|
||||
} else {
|
||||
msg["content"] = json!(content_parts);
|
||||
}
|
||||
// 单 text block 简化为纯字符串
|
||||
if let Some(text) = content_parts[0].get("text") {
|
||||
msg["content"] = text.clone();
|
||||
} else {
|
||||
msg["content"] = json!(content_parts);
|
||||
}
|
||||
@@ -656,10 +644,31 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
|
||||
// usage — map cache tokens from OpenAI format to Anthropic format
|
||||
let usage = body.get("usage").cloned().unwrap_or(json!({}));
|
||||
// OpenAI prompt_tokens 含缓存命中,Anthropic input_tokens 不含 → 减去 cache_read 与
|
||||
// cache_creation,使 input 成为 fresh input。本路径以 app_type="claude" 记账(calculator
|
||||
// 不再扣减),若不减则缓存会被计入 input 与各 cache 桶两次。三桶互斥,恒等:
|
||||
// input + cache_read + cache_creation == prompt_tokens(inclusive 上游)。
|
||||
// 与流式 build_anthropic_usage_json (#2774) 及 transform_gemini 的 saturating_sub 对称。
|
||||
// 最终 cache_read:直传字段优先于 nested;cache_creation 仅来自直传字段(OpenAI 无此概念)。
|
||||
let cached = usage
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.or_else(|| {
|
||||
usage
|
||||
.pointer("/prompt_tokens_details/cached_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let cache_creation = usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let input_tokens = usage
|
||||
.get("prompt_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
.unwrap_or(0)
|
||||
.saturating_sub(cached)
|
||||
.saturating_sub(cache_creation) as u32;
|
||||
let output_tokens = usage
|
||||
.get("completion_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
@@ -670,19 +679,11 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
"output_tokens": output_tokens
|
||||
});
|
||||
|
||||
// OpenAI standard: prompt_tokens_details.cached_tokens
|
||||
if let Some(cached) = usage
|
||||
.pointer("/prompt_tokens_details/cached_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
{
|
||||
if cached > 0 {
|
||||
usage_json["cache_read_input_tokens"] = json!(cached);
|
||||
}
|
||||
// Some compatible servers return these fields directly
|
||||
if let Some(v) = usage.get("cache_read_input_tokens") {
|
||||
usage_json["cache_read_input_tokens"] = v.clone();
|
||||
}
|
||||
if let Some(v) = usage.get("cache_creation_input_tokens") {
|
||||
usage_json["cache_creation_input_tokens"] = v.clone();
|
||||
if cache_creation > 0 {
|
||||
usage_json["cache_creation_input_tokens"] = json!(cache_creation);
|
||||
}
|
||||
|
||||
let result = json!({
|
||||
@@ -829,7 +830,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_preserves_matching_system_cache_control_when_merging() {
|
||||
fn test_anthropic_to_openai_strips_cache_control_from_merged_system() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
@@ -847,12 +848,12 @@ mod tests {
|
||||
result["messages"][0]["content"],
|
||||
"You are Claude Code.\nBe concise."
|
||||
);
|
||||
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
|
||||
assert!(result["messages"][0].get("cache_control").is_none());
|
||||
assert_eq!(result["messages"][1]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_drops_mixed_present_absent_system_cache_control_when_merging() {
|
||||
fn test_anthropic_to_openai_strips_cache_control_from_mixed_system() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
@@ -873,7 +874,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_drops_conflicting_system_cache_control_when_merging() {
|
||||
fn test_anthropic_to_openai_strips_cache_control_from_conflicting_system() {
|
||||
let input = json!({
|
||||
"model": "claude-3-sonnet",
|
||||
"max_tokens": 1024,
|
||||
@@ -1199,7 +1200,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_cache_control_preserved() {
|
||||
fn test_anthropic_to_openai_strips_all_cache_control() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
@@ -1221,19 +1222,89 @@ mod tests {
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
// System message cache_control preserved
|
||||
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
|
||||
// Text block cache_control preserved
|
||||
assert_eq!(
|
||||
result["messages"][1]["content"][0]["cache_control"]["type"],
|
||||
"ephemeral"
|
||||
// System message: no cache_control
|
||||
assert!(result["messages"][0].get("cache_control").is_none());
|
||||
// User message: content simplified to string (no cache_control → flat string)
|
||||
assert_eq!(result["messages"][1]["content"], "Hello");
|
||||
// Tool: no cache_control
|
||||
assert!(result["tools"][0].get("cache_control").is_none());
|
||||
}
|
||||
|
||||
/// 精确复现 Issue #3805 报告的 400 错误场景:
|
||||
/// GLM/Qwen 等严格校验模型拒绝 cache_control 和 content 数组格式
|
||||
#[test]
|
||||
fn test_regression_gh3805_no_cache_control_leak_to_openai() {
|
||||
let input = json!({
|
||||
"model": "glm-5.1",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "You are helpful.", "cache_control": {"type": "ephemeral"}}
|
||||
],
|
||||
"messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral"}}
|
||||
]}
|
||||
],
|
||||
"tools": [{
|
||||
"name": "search",
|
||||
"description": "Search the web",
|
||||
"input_schema": {"type": "object"},
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
|
||||
// 验证: messages 中不存在 cache_control
|
||||
for (i, msg) in result["messages"].as_array().unwrap().iter().enumerate() {
|
||||
assert!(
|
||||
msg.get("cache_control").is_none(),
|
||||
"messages[{i}] must not have cache_control"
|
||||
);
|
||||
}
|
||||
|
||||
// 验证: content 中没有 cache_control
|
||||
for (i, msg) in result["messages"].as_array().unwrap().iter().enumerate() {
|
||||
if let Some(content) = msg.get("content") {
|
||||
assert!(
|
||||
!content.is_array()
|
||||
|| content
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|part| part.get("cache_control").is_none()),
|
||||
"messages[{i}] content parts must not have cache_control"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证: system content 为纯字符串格式(不是数组)
|
||||
let sys_msg = &result["messages"][0];
|
||||
assert_eq!(sys_msg["role"], "system");
|
||||
assert!(
|
||||
sys_msg["content"].is_string(),
|
||||
"system content must be string, got: {}",
|
||||
sys_msg["content"]
|
||||
);
|
||||
assert_eq!(
|
||||
result["messages"][1]["content"][0]["cache_control"]["ttl"],
|
||||
"5m"
|
||||
|
||||
// 验证: user content 为纯字符串格式(不是数组)
|
||||
let user_msg = &result["messages"][1];
|
||||
assert_eq!(user_msg["role"], "user");
|
||||
assert!(
|
||||
user_msg["content"].is_string(),
|
||||
"user content must be string, got: {}",
|
||||
user_msg["content"]
|
||||
);
|
||||
// Tool cache_control preserved
|
||||
assert_eq!(result["tools"][0]["cache_control"]["type"], "ephemeral");
|
||||
|
||||
// 验证: tools 中不存在 cache_control
|
||||
if let Some(tools) = result["tools"].as_array() {
|
||||
for (i, tool) in tools.iter().enumerate() {
|
||||
assert!(
|
||||
tool.get("cache_control").is_none(),
|
||||
"tools[{i}] must not have cache_control"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1256,7 +1327,8 @@ mod tests {
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["usage"]["input_tokens"], 100);
|
||||
// prompt_tokens(100) 含 cached(80),转换后 input 应为 fresh = 100 - 80 = 20
|
||||
assert_eq!(result["usage"]["input_tokens"], 20);
|
||||
assert_eq!(result["usage"]["output_tokens"], 50);
|
||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 80);
|
||||
}
|
||||
@@ -1280,10 +1352,38 @@ mod tests {
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
// cache_read(60)+cache_creation(20) 均从 prompt(100) 扣除,fresh = 100 - 60 - 20 = 20
|
||||
// 守恒:input(20) + cache_read(60) + cache_creation(20) == prompt(100)
|
||||
assert_eq!(result["usage"]["input_tokens"], 20);
|
||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
|
||||
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_clamps_input_when_cache_exceeds_prompt() {
|
||||
// prompt(100) < cache_read(60)+cache_creation(50)=110:saturating 钳到 0,防下溢。
|
||||
// 钉桩:阻止未来把 saturating_sub 误改成普通减法(debug panic / release wrap)。
|
||||
let input = json!({
|
||||
"id": "chatcmpl-uf",
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "x"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 10,
|
||||
"cache_read_input_tokens": 60,
|
||||
"cache_creation_input_tokens": 50
|
||||
}
|
||||
});
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["usage"]["input_tokens"], 0);
|
||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
|
||||
assert_eq!(result["usage"]["cache_creation_input_tokens"], 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_finish_reason_content_filter_maps_end_turn() {
|
||||
let input = json!({
|
||||
|
||||
@@ -40,6 +40,8 @@ const EXTRA_CHAT_PASSTHROUGH_FIELDS: &[&str] = &[
|
||||
const TOOL_SEARCH_PROXY_NAME: &str = "tool_search";
|
||||
const CUSTOM_TOOL_INPUT_FIELD: &str = "input";
|
||||
const CHAT_TOOL_NAME_MAX_LEN: usize = 64;
|
||||
const CUSTOM_TOOL_INPUT_DESCRIPTION: &str = "Raw string input for the original custom tool. Preserve formatting exactly and follow the original tool definition embedded in the description.";
|
||||
const CUSTOM_TOOL_PRESERVED_METADATA_HEADING: &str = "Original tool definition:";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum CodexToolKind {
|
||||
@@ -132,10 +134,7 @@ impl CodexToolContext {
|
||||
let Some(name) = responses_tool_name(tool) else {
|
||||
return;
|
||||
};
|
||||
let description = tool
|
||||
.get("description")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!("Custom Codex tool."));
|
||||
let description = json!(responses_custom_tool_description(tool));
|
||||
let chat_tool = json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -146,7 +145,7 @@ impl CodexToolContext {
|
||||
"properties": {
|
||||
CUSTOM_TOOL_INPUT_FIELD: {
|
||||
"type": "string",
|
||||
"description": "Input to pass to the custom Codex tool."
|
||||
"description": CUSTOM_TOOL_INPUT_DESCRIPTION
|
||||
}
|
||||
},
|
||||
"required": [CUSTOM_TOOL_INPUT_FIELD]
|
||||
@@ -320,25 +319,25 @@ pub fn responses_to_chat_completions_with_reasoning(
|
||||
}
|
||||
}
|
||||
|
||||
// Strict OpenAI-compatible upstreams (vLLM, enterprise gateways) reject
|
||||
// requests that carry tool_choice or parallel_tool_calls without a non-empty
|
||||
// tools array. Drop both fields when tools ended up absent or empty after
|
||||
// conversion to avoid 503/400 from such providers.
|
||||
let has_tools = result
|
||||
.get("tools")
|
||||
.is_some_and(|v| v.as_array().is_some_and(|a| !a.is_empty()));
|
||||
if !has_tools {
|
||||
if let Some(obj) = result.as_object_mut() {
|
||||
obj.remove("tool_choice");
|
||||
obj.remove("parallel_tool_calls");
|
||||
}
|
||||
}
|
||||
// OpenAI 兼容上游在流式下默认不在 SSE 里返回 usage,必须显式声明
|
||||
// include_usage 才会在末尾吐 usage chunk。Codex CLI 用 Responses 协议、
|
||||
// 自身不带 stream_options,缺这一注入会导致 kimi/MiniMax 等第三方流式请求的
|
||||
// token/成本/缓存命中率全部漏记(input/output/cache 全为 0)。
|
||||
let is_stream = result
|
||||
.get("stream")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if is_stream {
|
||||
match result.get_mut("stream_options") {
|
||||
// 保留客户端可能透传的其它 stream_options 字段,仅补 include_usage。
|
||||
Some(Value::Object(opts)) => {
|
||||
opts.insert("include_usage".to_string(), json!(true));
|
||||
}
|
||||
_ => {
|
||||
result["stream_options"] = json!({ "include_usage": true });
|
||||
}
|
||||
}
|
||||
}
|
||||
// 与 Claude→openai_chat 路径共用同一 helper,保证两个客户端方向一致。
|
||||
super::transform::inject_openai_stream_include_usage(&mut result);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -654,6 +653,34 @@ fn append_responses_item_as_chat_message(
|
||||
append_pending_reasoning(pending_reasoning, reasoning);
|
||||
}
|
||||
}
|
||||
Some("input_text" | "input_image" | "input_file" | "input_audio") => {
|
||||
flush_pending_tool_calls(
|
||||
messages,
|
||||
pending_tool_calls,
|
||||
pending_reasoning,
|
||||
last_assistant_index,
|
||||
);
|
||||
let role = item
|
||||
.get("role")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(responses_role_to_chat_role)
|
||||
.unwrap_or("user");
|
||||
let message = json!({
|
||||
"role": role,
|
||||
"content": responses_content_to_chat_content(role, &Value::Array(vec![item.clone()]))
|
||||
});
|
||||
if role == "assistant" {
|
||||
let mut message = message;
|
||||
attach_pending_reasoning_to_assistant(&mut message, pending_reasoning);
|
||||
update_last_assistant_index(messages, &message, last_assistant_index);
|
||||
messages.push(message);
|
||||
return Ok(());
|
||||
} else if pending_reasoning.is_some() {
|
||||
pending_reasoning.take();
|
||||
}
|
||||
update_last_assistant_index(messages, &message, last_assistant_index);
|
||||
messages.push(message);
|
||||
}
|
||||
Some("message") | None => {
|
||||
flush_pending_tool_calls(
|
||||
messages,
|
||||
@@ -947,6 +974,24 @@ fn responses_content_to_chat_content(_role: &str, content: &Value) -> Value {
|
||||
has_non_text_part = true;
|
||||
}
|
||||
}
|
||||
"input_file" => {
|
||||
if let Some(file) = responses_input_file_to_chat_file(part) {
|
||||
chat_parts.push(json!({
|
||||
"type": "file",
|
||||
"file": file
|
||||
}));
|
||||
has_non_text_part = true;
|
||||
}
|
||||
}
|
||||
"input_audio" => {
|
||||
if let Some(input_audio) = part.get("input_audio") {
|
||||
chat_parts.push(json!({
|
||||
"type": "input_audio",
|
||||
"input_audio": input_audio.clone()
|
||||
}));
|
||||
has_non_text_part = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -964,6 +1009,21 @@ fn responses_content_to_chat_content(_role: &str, content: &Value) -> Value {
|
||||
Value::Array(chat_parts)
|
||||
}
|
||||
|
||||
fn responses_input_file_to_chat_file(part: &Value) -> Option<Value> {
|
||||
let mut file = serde_json::Map::new();
|
||||
let has_supported_file_ref = part.get("file_id").is_some() || part.get("file_data").is_some();
|
||||
if !has_supported_file_ref {
|
||||
return None;
|
||||
}
|
||||
|
||||
for key in ["file_id", "file_data", "filename"] {
|
||||
if let Some(value) = part.get(key) {
|
||||
file.insert(key.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
Some(Value::Object(file))
|
||||
}
|
||||
|
||||
fn collect_tool_search_output_tools(value: &Value, context: &mut CodexToolContext) {
|
||||
match value {
|
||||
Value::Array(items) => {
|
||||
@@ -1016,6 +1076,22 @@ fn responses_tool_name(tool: &Value) -> Option<String> {
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn responses_custom_tool_description(tool: &Value) -> String {
|
||||
let mut description = String::new();
|
||||
description.push_str(CUSTOM_TOOL_PRESERVED_METADATA_HEADING);
|
||||
description.push_str("\n```json\n");
|
||||
description.push_str(&serialize_tool_definition_for_description(tool));
|
||||
description.push_str("\n```");
|
||||
description
|
||||
}
|
||||
|
||||
fn serialize_tool_definition_for_description(tool: &Value) -> String {
|
||||
// Keep the embedded definition compact to reduce tool-description token
|
||||
// overhead for chat-only upstreams, while remaining stable across map
|
||||
// storage order.
|
||||
canonical_json_string(tool)
|
||||
}
|
||||
|
||||
fn responses_function_tool_to_chat_tool(tool: &Value, chat_name: &str) -> Option<Value> {
|
||||
if tool.get("type").and_then(|v| v.as_str()) != Some("function") {
|
||||
return None;
|
||||
@@ -1700,6 +1776,122 @@ mod tests {
|
||||
assert_eq!(result["stream_options"]["continuous_usage_stats"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_maps_input_file_content_parts() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"input": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "Summarize this."},
|
||||
{
|
||||
"type": "input_file",
|
||||
"file_id": "file_123",
|
||||
"file_url": "https://example.com/spec.pdf",
|
||||
"filename": "spec.pdf"
|
||||
},
|
||||
{
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": "UklGRg==",
|
||||
"format": "wav"
|
||||
}
|
||||
}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
let content = result["messages"][0]["content"].as_array().unwrap();
|
||||
|
||||
assert_eq!(content[0]["type"], "text");
|
||||
assert_eq!(content[1]["type"], "file");
|
||||
assert_eq!(content[1]["file"]["file_id"], "file_123");
|
||||
assert!(content[1]["file"].get("file_url").is_none());
|
||||
assert_eq!(content[1]["file"]["filename"], "spec.pdf");
|
||||
assert_eq!(content[2]["type"], "input_audio");
|
||||
assert_eq!(content[2]["input_audio"]["format"], "wav");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_does_not_emit_chat_file_for_url_only_input_file() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"input": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "Summarize this URL file."},
|
||||
{
|
||||
"type": "input_file",
|
||||
"file_url": "https://example.com/spec.pdf"
|
||||
}
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert_eq!(result["messages"][0]["content"], "Summarize this URL file.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_maps_top_level_input_file_item() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"input": [
|
||||
{
|
||||
"type": "input_file",
|
||||
"file_id": "file_top",
|
||||
"filename": "top.pdf"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
let content = result["messages"][0]["content"].as_array().unwrap();
|
||||
|
||||
assert_eq!(result["messages"][0]["role"], "user");
|
||||
assert_eq!(content[0]["type"], "file");
|
||||
assert_eq!(content[0]["file"]["file_id"], "file_top");
|
||||
assert_eq!(content[0]["file"]["filename"], "top.pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_user_content_part_clears_pending_reasoning() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"input": [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"summary": [{"text": "stale reasoning"}]
|
||||
},
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": "Please run the tool."
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "lookup",
|
||||
"arguments": "{}"
|
||||
}
|
||||
],
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
"parameters": {"type": "object"}
|
||||
}]
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
let messages = result["messages"].as_array().unwrap();
|
||||
|
||||
assert_eq!(messages[0]["role"], "user");
|
||||
assert_eq!(messages[0]["content"], "Please run the tool.");
|
||||
assert_eq!(messages[1]["role"], "assistant");
|
||||
assert_eq!(messages[1]["reasoning_content"], "tool call");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_maps_messages_tools_and_limits() {
|
||||
let input = json!({
|
||||
@@ -1857,6 +2049,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_preserves_custom_tool_metadata_in_description() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"tools": [{
|
||||
"type": "custom",
|
||||
"name": "apply_patch",
|
||||
"description": "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.",
|
||||
"format": {
|
||||
"type": "grammar",
|
||||
"syntax": "lark",
|
||||
"definition": "start: begin_patch hunk+ end_patch"
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
let description = result["tools"][0]["function"]["description"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
|
||||
assert!(description.starts_with("Original tool definition:"));
|
||||
assert!(!description.contains("Original Codex tool definition"));
|
||||
assert!(description.contains("\"type\":\"custom\""));
|
||||
assert!(description.contains("\"format\":"));
|
||||
assert!(description.contains("\"syntax\":\"lark\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_uses_provider_reasoning_effort_for_deepseek_model() {
|
||||
let input = json!({
|
||||
@@ -2825,4 +3045,238 @@ mod tests {
|
||||
assert_eq!(result["error"]["message"], "rate limit exceeded");
|
||||
assert_eq!(result["error"]["type"], "upstream_error");
|
||||
}
|
||||
// Regression tests for tool_choice without tools guard
|
||||
// https://github.com/farion1231/cc-switch/issues/3557
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_drops_tool_choice_when_no_tools() {
|
||||
// When tools is absent from the request, tool_choice must be dropped
|
||||
// to avoid 503/400 from strict OpenAI-compatible upstreams.
|
||||
let input = json!({
|
||||
"model": "qwen3-7-max",
|
||||
"tool_choice": "auto",
|
||||
"input": "hi"
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("tool_choice").is_none(),
|
||||
"tool_choice should be dropped when tools is absent"
|
||||
);
|
||||
assert!(result.get("tools").is_none(), "tools should be absent");
|
||||
assert_eq!(result["model"], "qwen3-7-max");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_drops_tool_choice_when_tools_empty_array() {
|
||||
// When tools is an empty array, tool_choice must be dropped.
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"tools": [],
|
||||
"tool_choice": "auto",
|
||||
"input": "hi"
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("tool_choice").is_none(),
|
||||
"tool_choice should be dropped when tools is empty"
|
||||
);
|
||||
assert!(
|
||||
result.get("tools").is_none(),
|
||||
"tools should be absent when input tools was empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_drops_parallel_tool_calls_when_no_tools() {
|
||||
// parallel_tool_calls must also be dropped when tools is absent,
|
||||
// as it is part of EXTRA_CHAT_PASSTHROUGH_FIELDS.
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": true,
|
||||
"input": "hi"
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("tool_choice").is_none(),
|
||||
"tool_choice should be dropped"
|
||||
);
|
||||
assert!(
|
||||
result.get("parallel_tool_calls").is_none(),
|
||||
"parallel_tool_calls should be dropped"
|
||||
);
|
||||
assert!(result.get("tools").is_none(), "tools should be absent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_drops_tool_choice_when_all_tools_filtered() {
|
||||
// When all tools are filtered out (e.g., missing name), tool_choice must be dropped.
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"tools": [
|
||||
{"type": "function"}
|
||||
],
|
||||
"tool_choice": "auto",
|
||||
"input": "hi"
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("tool_choice").is_none(),
|
||||
"tool_choice should be dropped when all tools filtered"
|
||||
);
|
||||
assert!(
|
||||
result.get("tools").is_none(),
|
||||
"tools should be absent when all filtered"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_keeps_tool_choice_when_tools_present() {
|
||||
// When tools is present and non-empty, tool_choice must be preserved.
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {"type": "object"}
|
||||
}],
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": true,
|
||||
"input": "hi"
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("tool_choice").is_some(),
|
||||
"tool_choice should be kept when tools present"
|
||||
);
|
||||
assert_eq!(result["tool_choice"], "auto");
|
||||
assert!(
|
||||
result.get("parallel_tool_calls").is_some(),
|
||||
"parallel_tool_calls should be kept"
|
||||
);
|
||||
assert_eq!(result["parallel_tool_calls"], true);
|
||||
assert!(
|
||||
result
|
||||
.get("tools")
|
||||
.is_some_and(|v| v.as_array().is_some_and(|a| !a.is_empty())),
|
||||
"tools should be present"
|
||||
);
|
||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_keeps_tool_choice_function_when_tools_present() {
|
||||
// When tools is present, function-type tool_choice must be preserved and mapped.
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {"type": "object"}
|
||||
}],
|
||||
"tool_choice": {"type": "function", "name": "get_weather"},
|
||||
"input": "hi"
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("tool_choice").is_some(),
|
||||
"tool_choice should be kept"
|
||||
);
|
||||
assert_eq!(result["tool_choice"]["type"], "function");
|
||||
assert_eq!(result["tool_choice"]["function"]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_no_tool_choice_no_tools_stays_clean() {
|
||||
// When neither tool_choice nor tools are present, the output should be clean.
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"input": "hi"
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("tool_choice").is_none(),
|
||||
"tool_choice should be absent"
|
||||
);
|
||||
assert!(result.get("tools").is_none(), "tools should be absent");
|
||||
assert!(
|
||||
result.get("parallel_tool_calls").is_none(),
|
||||
"parallel_tool_calls should be absent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_tool_choice_none_dropped_when_no_tools() {
|
||||
// Even tool_choice: "none" should be dropped when tools is absent,
|
||||
// because strict upstreams reject the combination regardless of value.
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"tool_choice": "none",
|
||||
"input": "hi"
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("tool_choice").is_none(),
|
||||
"tool_choice 'none' should be dropped when no tools"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_to_chat_tool_search_output_provides_tools_keeps_tool_choice() {
|
||||
// When tool_search_output in input provides tools, tool_choice should be kept.
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"tool_choice": "auto",
|
||||
"input": [{
|
||||
"type": "tool_search_output",
|
||||
"call_id": "call_ts_1",
|
||||
"status": "completed",
|
||||
"execution": "client",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "search_docs",
|
||||
"description": "Search documentation.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = responses_to_chat_completions(input).unwrap();
|
||||
|
||||
assert!(
|
||||
result.get("tool_choice").is_some(),
|
||||
"tool_choice should be kept when tool_search_output provides tools"
|
||||
);
|
||||
assert_eq!(result["tool_choice"], "auto");
|
||||
assert!(
|
||||
result
|
||||
.get("tools")
|
||||
.is_some_and(|v| v.as_array().is_some_and(|a| !a.is_empty())),
|
||||
"tools should be present from tool_search_output"
|
||||
);
|
||||
assert_eq!(result["tools"][0]["function"]["name"], "search_docs");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1101,7 +1101,7 @@ pub(crate) fn build_anthropic_usage(usage: Option<&Value>) -> Value {
|
||||
});
|
||||
};
|
||||
|
||||
let input_tokens = usage
|
||||
let prompt_tokens = usage
|
||||
.get("promptTokenCount")
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or(0);
|
||||
@@ -1109,18 +1109,26 @@ pub(crate) fn build_anthropic_usage(usage: Option<&Value>) -> Value {
|
||||
.get("totalTokenCount")
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or(0);
|
||||
let output_tokens = total_tokens.saturating_sub(input_tokens);
|
||||
let cached_tokens = usage
|
||||
.get("cachedContentTokenCount")
|
||||
.and_then(|value| value.as_u64())
|
||||
.unwrap_or(0);
|
||||
// Gemini 的 promptTokenCount 含缓存命中(cachedContentTokenCount);而 Anthropic
|
||||
// 语义下 input_tokens 必须是不含 cache 的 fresh input、cache_read 单列。本路径转成
|
||||
// Anthropic 后以 app_type=claude 记账,calculator 对 claude 设 input_includes_cache_read
|
||||
// =false 不再从 input 扣 cache,因此这里必须先扣减,否则缓存 token 会被双重计费
|
||||
// (一次按完整 input 价、一次按 cache_read 价)。output 仍按 total-prompt 计算
|
||||
// (prompt 是总输入,扣减只作用于 input/cache 的拆分,不影响 output)。
|
||||
let input_tokens = prompt_tokens.saturating_sub(cached_tokens);
|
||||
let output_tokens = total_tokens.saturating_sub(prompt_tokens);
|
||||
|
||||
let mut result = json!({
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens
|
||||
});
|
||||
|
||||
if let Some(cached) = usage
|
||||
.get("cachedContentTokenCount")
|
||||
.and_then(|value| value.as_u64())
|
||||
{
|
||||
result["cache_read_input_tokens"] = json!(cached);
|
||||
if cached_tokens > 0 {
|
||||
result["cache_read_input_tokens"] = json!(cached_tokens);
|
||||
}
|
||||
|
||||
result
|
||||
@@ -1370,7 +1378,11 @@ mod tests {
|
||||
assert_eq!(result["content"][0]["type"], "text");
|
||||
assert_eq!(result["content"][0]["text"], "Hello from Gemini");
|
||||
assert_eq!(result["stop_reason"], "end_turn");
|
||||
assert_eq!(result["usage"]["input_tokens"], 12);
|
||||
// input_tokens = promptTokenCount(12) - cachedContentTokenCount(3) = 9(fresh input)。
|
||||
// Gemini 的 promptTokenCount 含缓存命中,但 Anthropic 语义要求 input 不含 cache、
|
||||
// cache_read 单列;二者相加(9+3)=总输入 12。扣减避免本路径以 app_type=claude
|
||||
// 记账时把缓存 token 双重计费。
|
||||
assert_eq!(result["usage"]["input_tokens"], 9);
|
||||
assert_eq!(result["usage"]["output_tokens"], 8);
|
||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 3);
|
||||
}
|
||||
|
||||
@@ -355,6 +355,23 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val
|
||||
result["cache_creation_input_tokens"] = v.clone();
|
||||
}
|
||||
|
||||
// OpenAI/Responses 的 input(prompt_tokens/input_tokens)含缓存命中,Anthropic input_tokens 不含
|
||||
// → 减去 cache_read 与 cache_creation,使其成为 fresh input。本函数在计量意义上是 claude 专属
|
||||
// (Codex Responses 透传走 from_codex_response_*,不调用本函数),故可安全在此扣减。三桶互斥,
|
||||
// 恒等:input + cache_read + cache_creation == 上游 input(inclusive)。与 build_anthropic_usage_json
|
||||
// (#2774) 及 transform_gemini 的 saturating_sub 对称;一处同时覆盖非流式与流式(streaming_responses)。
|
||||
let cached = result
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let cache_creation = result
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
if cached > 0 || cache_creation > 0 {
|
||||
result["input_tokens"] = json!(input.saturating_sub(cached).saturating_sub(cache_creation));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
@@ -1156,7 +1173,8 @@ mod tests {
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["usage"]["input_tokens"], 100);
|
||||
// input_tokens(100) 含 cached(80),转换后 input 应为 fresh = 100 - 80 = 20
|
||||
assert_eq!(result["usage"]["input_tokens"], 20);
|
||||
assert_eq!(result["usage"]["output_tokens"], 50);
|
||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 80);
|
||||
}
|
||||
@@ -1180,6 +1198,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let result = responses_to_anthropic(input).unwrap();
|
||||
// cache_read(60)+cache_creation(20) 均从 input(100) 扣除,fresh = 100 - 60 - 20 = 20
|
||||
// 守恒:input(20) + cache_read(60) + cache_creation(20) == 上游 input(100)
|
||||
assert_eq!(result["usage"]["input_tokens"], 20);
|
||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
|
||||
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
|
||||
}
|
||||
@@ -1642,7 +1663,8 @@ mod tests {
|
||||
"cached_tokens": 80
|
||||
}
|
||||
})));
|
||||
assert_eq!(result["input_tokens"], json!(100));
|
||||
// input_tokens(100) 含 nested cached(80),转换后 input 应为 fresh = 100 - 80 = 20
|
||||
assert_eq!(result["input_tokens"], json!(20));
|
||||
assert_eq!(result["output_tokens"], json!(50));
|
||||
assert_eq!(result["cache_read_input_tokens"], json!(80));
|
||||
}
|
||||
@@ -1657,9 +1679,26 @@ mod tests {
|
||||
},
|
||||
"cache_read_input_tokens": 100
|
||||
})));
|
||||
// 直传 cache_read(100) 优先于 nested(80);input(100) - 100 = 0(fresh)
|
||||
assert_eq!(result["input_tokens"], json!(0));
|
||||
assert_eq!(result["cache_read_input_tokens"], json!(100)); // Direct field overrides nested
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_usage_clamps_input_when_cache_exceeds_input() {
|
||||
// input(100) < cache_read(60)+cache_creation(50)=110:saturating 钳到 0,防下溢。
|
||||
// 钉桩:阻止未来把 saturating_sub 误改成普通减法(debug panic / release wrap)。
|
||||
let result = build_anthropic_usage_from_responses(Some(&json!({
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 10,
|
||||
"cache_read_input_tokens": 60,
|
||||
"cache_creation_input_tokens": 50
|
||||
})));
|
||||
assert_eq!(result["input_tokens"], json!(0));
|
||||
assert_eq!(result["cache_read_input_tokens"], json!(60));
|
||||
assert_eq!(result["cache_creation_input_tokens"], json!(50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_usage_cache_tokens_without_input_output() {
|
||||
let result = build_anthropic_usage_from_responses(Some(&json!({
|
||||
|
||||
@@ -35,28 +35,41 @@ use tokio::sync::Mutex;
|
||||
/// 根据 content-encoding 解压响应体字节
|
||||
///
|
||||
/// reqwest 自动解压已禁用(为了透传 accept-encoding),需要手动解压。
|
||||
fn decompress_body(content_encoding: &str, body: &[u8]) -> Result<Vec<u8>, std::io::Error> {
|
||||
/// 返回 `Ok(None)` 表示编码不受支持、原样透传——此时调用方必须保留
|
||||
/// content-encoding 头,否则下游(诊断/客户端)会把压缩字节误当明文。
|
||||
fn decompress_body(content_encoding: &str, body: &[u8]) -> Result<Option<Vec<u8>>, std::io::Error> {
|
||||
match content_encoding {
|
||||
"gzip" | "x-gzip" => {
|
||||
let mut decoder = flate2::read::GzDecoder::new(body);
|
||||
let mut decompressed = Vec::new();
|
||||
decoder.read_to_end(&mut decompressed)?;
|
||||
Ok(decompressed)
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
"deflate" => {
|
||||
let mut decoder = flate2::read::DeflateDecoder::new(body);
|
||||
// RFC 9110: deflate 指 zlib 包裹格式;但部分上游发 raw deflate 流。
|
||||
// 先按规范尝试 zlib,失败再回退 raw —— 否则合规上游必然解压失败,
|
||||
// 原始压缩字节会被 fail-open 透传给 JSON 解析(#2234 形态 C 之一)。
|
||||
let mut decompressed = Vec::new();
|
||||
decoder.read_to_end(&mut decompressed)?;
|
||||
Ok(decompressed)
|
||||
let mut zlib = flate2::read::ZlibDecoder::new(body);
|
||||
match zlib.read_to_end(&mut decompressed) {
|
||||
Ok(_) => Ok(Some(decompressed)),
|
||||
Err(zlib_err) => {
|
||||
log::debug!("deflate 按 zlib 解压失败({zlib_err}),回退 raw deflate");
|
||||
let mut decompressed = Vec::new();
|
||||
let mut raw = flate2::read::DeflateDecoder::new(body);
|
||||
raw.read_to_end(&mut decompressed)?;
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
}
|
||||
}
|
||||
"br" => {
|
||||
let mut decompressed = Vec::new();
|
||||
brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decompressed)?;
|
||||
Ok(decompressed)
|
||||
Ok(Some(decompressed))
|
||||
}
|
||||
_ => {
|
||||
log::warn!("未知的 content-encoding: {content_encoding},跳过解压");
|
||||
Ok(body.to_vec())
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,10 +163,13 @@ pub(crate) async fn read_decoded_body(
|
||||
if let Some(encoding) = get_content_encoding(&headers) {
|
||||
log::debug!("[{tag}] 解压非流式响应: content-encoding={encoding}");
|
||||
match decompress_body(&encoding, &raw_bytes) {
|
||||
Ok(decompressed) => {
|
||||
Ok(Some(decompressed)) => {
|
||||
body_bytes = Bytes::from(decompressed);
|
||||
decoded = true;
|
||||
}
|
||||
// 不支持的编码:原样透传且保留 content-encoding 头,
|
||||
// 让下游诊断/客户端知道这仍是压缩字节
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
log::warn!("[{tag}] 解压失败 ({encoding}): {e},使用原始数据");
|
||||
}
|
||||
@@ -270,14 +286,21 @@ pub async fn handle_non_streaming(
|
||||
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 解析出的模型 → 响应 model 字段 → 映射后的出站
|
||||
// 模型(路由接管真值)→ 客户端请求模型。空字符串视为缺失。
|
||||
let model = usage
|
||||
.model
|
||||
.clone()
|
||||
.filter(|m| !m.is_empty())
|
||||
.or_else(|| {
|
||||
json_value
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.filter(|m| !m.is_empty())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.or_else(|| ctx.outbound_model.clone())
|
||||
.unwrap_or_else(|| ctx.request_model.clone());
|
||||
|
||||
spawn_log_usage(
|
||||
state,
|
||||
@@ -292,8 +315,10 @@ pub async fn handle_non_streaming(
|
||||
let model = json_value
|
||||
.get("model")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or(&ctx.request_model)
|
||||
.to_string();
|
||||
.filter(|m| !m.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| ctx.outbound_model.clone())
|
||||
.unwrap_or_else(|| ctx.request_model.clone());
|
||||
spawn_log_usage(
|
||||
state,
|
||||
ctx,
|
||||
@@ -318,7 +343,7 @@ pub async fn handle_non_streaming(
|
||||
state,
|
||||
ctx,
|
||||
TokenUsage::default(),
|
||||
&ctx.request_model,
|
||||
ctx.outbound_model.as_deref().unwrap_or(&ctx.request_model),
|
||||
&ctx.request_model,
|
||||
status.as_u16(),
|
||||
false,
|
||||
@@ -500,7 +525,16 @@ fn create_usage_collector(
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let request_model = ctx.request_model.clone();
|
||||
let app_type_str = parser_config.app_type_str;
|
||||
// 流式事件缺失模型名时的归因兜底:映射后的出站模型(路由接管真值)优先,
|
||||
// 其次才是客户端请求别名
|
||||
let fallback_model = ctx
|
||||
.outbound_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| ctx.request_model.clone());
|
||||
// 用 ctx 的 app_type 而不是 parser_config 的:Claude Desktop 流式透传复用
|
||||
// CLAUDE_PARSER_CONFIG(app_type_str="claude"),按 parser_config 记账会把
|
||||
// claude-desktop 的行错记到 claude 名下,导致供应商计价覆盖解析不到。
|
||||
let app_type_str = ctx.app_type_str;
|
||||
let tag = ctx.tag;
|
||||
let start_time = ctx.start_time;
|
||||
let stream_parser = parser_config.stream_parser;
|
||||
@@ -512,13 +546,14 @@ fn create_usage_collector(
|
||||
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 model = model_extractor(&events, &fallback_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 outbound_model = fallback_model.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
log_usage_internal(
|
||||
@@ -527,6 +562,7 @@ fn create_usage_collector(
|
||||
app_type_str,
|
||||
&model,
|
||||
&request_model,
|
||||
&outbound_model,
|
||||
usage,
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
@@ -537,12 +573,13 @@ fn create_usage_collector(
|
||||
.await;
|
||||
});
|
||||
} else {
|
||||
let model = model_extractor(&events, &request_model);
|
||||
let model = model_extractor(&events, &fallback_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 outbound_model = fallback_model.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
log_usage_internal(
|
||||
@@ -551,6 +588,7 @@ fn create_usage_collector(
|
||||
app_type_str,
|
||||
&model,
|
||||
&request_model,
|
||||
&outbound_model,
|
||||
TokenUsage::default(),
|
||||
latency_ms,
|
||||
first_token_ms,
|
||||
@@ -588,6 +626,11 @@ fn spawn_log_usage(
|
||||
let app_type_str = ctx.app_type_str.to_string();
|
||||
let model = model.to_string();
|
||||
let request_model = request_model.to_string();
|
||||
// 「按请求计价」模式的锚点:映射后的出站模型,无映射时等于 request_model
|
||||
let outbound_model = ctx
|
||||
.outbound_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| ctx.request_model.clone());
|
||||
let latency_ms = ctx.latency_ms();
|
||||
let session_id = ctx.session_id.clone();
|
||||
|
||||
@@ -598,6 +641,7 @@ fn spawn_log_usage(
|
||||
&app_type_str,
|
||||
&model,
|
||||
&request_model,
|
||||
&outbound_model,
|
||||
usage,
|
||||
latency_ms,
|
||||
None,
|
||||
@@ -618,6 +662,11 @@ pub(crate) fn usage_logging_enabled(state: &ProxyState) -> bool {
|
||||
}
|
||||
|
||||
/// 内部使用量记录函数
|
||||
///
|
||||
/// `outbound_model` 是「按请求计价」模式的锚点:实际发往上游的模型
|
||||
/// (路由接管映射后的真值,无映射时等于 request_model)。该模式的语义是
|
||||
/// 「按代理发出的请求计价、不信任上游回显」,接管场景下发出的请求模型是
|
||||
/// 映射后的 Y 而非客户端别名 X,按 X 计价会用错定价表行。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn log_usage_internal(
|
||||
state: &ProxyState,
|
||||
@@ -625,6 +674,7 @@ async fn log_usage_internal(
|
||||
app_type: &str,
|
||||
model: &str,
|
||||
request_model: &str,
|
||||
outbound_model: &str,
|
||||
usage: TokenUsage,
|
||||
latency_ms: u64,
|
||||
first_token_ms: Option<u64>,
|
||||
@@ -638,7 +688,7 @@ async fn log_usage_internal(
|
||||
let (multiplier, pricing_model_source) =
|
||||
logger.resolve_pricing_config(provider_id, app_type).await;
|
||||
let pricing_model = if pricing_model_source == PRICING_SOURCE_REQUEST {
|
||||
request_model
|
||||
outbound_model
|
||||
} else {
|
||||
model
|
||||
};
|
||||
@@ -828,6 +878,40 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[test]
|
||||
fn decompress_body_deflate_handles_zlib_wrapped_per_rfc9110() {
|
||||
// RFC 9110 规范的 deflate = zlib 包裹格式(合规上游发的就是这个)
|
||||
let payload = br#"{"ok":true}"#;
|
||||
let mut encoder =
|
||||
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut encoder, payload).unwrap();
|
||||
let compressed = encoder.finish().unwrap();
|
||||
|
||||
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
|
||||
assert_eq!(decompressed, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_deflate_falls_back_to_raw_stream() {
|
||||
// 部分上游违规发 raw deflate 流,保持兼容
|
||||
let payload = br#"{"ok":true}"#;
|
||||
let mut encoder =
|
||||
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut encoder, payload).unwrap();
|
||||
let compressed = encoder.finish().unwrap();
|
||||
|
||||
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
|
||||
assert_eq!(decompressed, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompress_body_unknown_encoding_returns_none_to_keep_headers() {
|
||||
// 未知编码必须返回 None(而非伪装成"已解码"),否则 content-encoding
|
||||
// 头被剥掉,下游诊断会把压缩字节误报成明文
|
||||
let result = decompress_body("zstd", b"\x28\xb5\x2f\xfd").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_sse_field_accepts_optional_space() {
|
||||
assert_eq!(
|
||||
@@ -1015,6 +1099,7 @@ mod tests {
|
||||
app_type,
|
||||
"resp-model",
|
||||
"req-model",
|
||||
"req-model",
|
||||
usage,
|
||||
10,
|
||||
None,
|
||||
@@ -1047,6 +1132,95 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_request_pricing_mode_anchors_to_outbound_model() -> Result<(), AppError> {
|
||||
let db = Arc::new(Database::memory()?);
|
||||
let app_type = "claude";
|
||||
|
||||
db.set_pricing_model_source(app_type, "request").await?;
|
||||
seed_pricing(&db)?;
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
|
||||
VALUES ('outbound-model', 'Outbound Model', '4.0', '0')",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
|
||||
insert_provider(&db, "provider-3", app_type, ProviderMeta::default())?;
|
||||
|
||||
let state = build_state(db.clone());
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 1_000_000,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
model: None,
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
// 路由接管场景:客户端请求 req-model($2/M),代理实际发出 outbound-model
|
||||
// ($4/M),上游回显 resp-model。「按请求计价」必须锚定实际发出的模型。
|
||||
log_usage_internal(
|
||||
&state,
|
||||
"provider-3",
|
||||
app_type,
|
||||
"resp-model",
|
||||
"req-model",
|
||||
"outbound-model",
|
||||
usage,
|
||||
10,
|
||||
None,
|
||||
false,
|
||||
200,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let (model, request_model, total_cost): (String, String, String) = conn
|
||||
.query_row(
|
||||
"SELECT model, request_model, total_cost_usd
|
||||
FROM proxy_request_logs WHERE provider_id = ?1",
|
||||
["provider-3"],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// model / request_model 列不受计价锚点影响
|
||||
assert_eq!(model, "resp-model");
|
||||
assert_eq!(request_model, "req-model");
|
||||
// 按 outbound-model($4/M)计价,而不是 req-model($2/M)或 resp-model($1/M)
|
||||
assert_eq!(
|
||||
Decimal::from_str(&total_cost).unwrap(),
|
||||
Decimal::from_str("4").unwrap()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_claude_desktop_inherits_claude_global_defaults() -> Result<(), AppError> {
|
||||
use crate::proxy::usage::logger::UsageLogger;
|
||||
|
||||
let db = Arc::new(Database::memory()?);
|
||||
|
||||
// 全局计费配置只有 claude/codex/gemini 三行;claude-desktop 的
|
||||
// 全局默认必须继承 claude,而不是静默落回工厂默认(1 / response)
|
||||
db.set_default_cost_multiplier("claude", "1.5").await?;
|
||||
db.set_pricing_model_source("claude", "request").await?;
|
||||
|
||||
let logger = UsageLogger::new(&db);
|
||||
let (multiplier, source) = logger
|
||||
.resolve_pricing_config("nonexistent-provider", "claude-desktop")
|
||||
.await;
|
||||
|
||||
assert_eq!(multiplier, Decimal::from_str("1.5").unwrap());
|
||||
assert_eq!(source, "request");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_log_usage_falls_back_to_global_defaults() -> Result<(), AppError> {
|
||||
let db = Arc::new(Database::memory()?);
|
||||
@@ -1075,6 +1249,7 @@ mod tests {
|
||||
app_type,
|
||||
"resp-model",
|
||||
"req-model",
|
||||
"req-model",
|
||||
usage,
|
||||
10,
|
||||
None,
|
||||
|
||||
@@ -112,11 +112,15 @@ impl ProxyServer {
|
||||
let listener = tokio::net::TcpListener::bind(&addr)
|
||||
.await
|
||||
.map_err(|e| ProxyError::BindFailed(e.to_string()))?;
|
||||
let local_addr = listener
|
||||
.local_addr()
|
||||
.map_err(|e| ProxyError::BindFailed(e.to_string()))?;
|
||||
let actual_port = local_addr.port();
|
||||
|
||||
log::info!("[{}] 代理服务器启动于 {addr}", log_srv::STARTED);
|
||||
log::info!("[{}] 代理服务器启动于 {local_addr}", log_srv::STARTED);
|
||||
|
||||
// 更新全局代理端口,用于系统代理检测
|
||||
crate::proxy::http_client::set_proxy_port(self.config.listen_port);
|
||||
crate::proxy::http_client::set_proxy_port(actual_port);
|
||||
|
||||
// 保存关闭句柄
|
||||
*self.shutdown_tx.write().await = Some(shutdown_tx);
|
||||
@@ -125,7 +129,7 @@ impl ProxyServer {
|
||||
let mut status = self.state.status.write().await;
|
||||
status.running = true;
|
||||
status.address = self.config.listen_address.clone();
|
||||
status.port = self.config.listen_port;
|
||||
status.port = actual_port;
|
||||
drop(status);
|
||||
|
||||
// 记录启动时间
|
||||
@@ -213,7 +217,7 @@ impl ProxyServer {
|
||||
|
||||
Ok(ProxyServerInfo {
|
||||
address: self.config.listen_address.clone(),
|
||||
port: self.config.listen_port,
|
||||
port: actual_port,
|
||||
started_at: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
}
|
||||
@@ -315,6 +319,9 @@ impl ProxyServer {
|
||||
"/codex/v1/chat/completions",
|
||||
post(handlers::handle_chat_completions),
|
||||
)
|
||||
// OpenAI Models API (Codex CLI reachability check)
|
||||
.route("/models", get(handlers::handle_models))
|
||||
.route("/v1/models", get(handlers::handle_models))
|
||||
// OpenAI Responses API (Codex CLI,支持带前缀和不带前缀)
|
||||
.route("/responses", post(handlers::handle_responses))
|
||||
.route("/v1/responses", post(handlers::handle_responses))
|
||||
|
||||
@@ -148,6 +148,8 @@ mod tests {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
request_media_fallback: true,
|
||||
request_media_heuristic: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +158,8 @@ mod tests {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: false,
|
||||
request_media_fallback: true,
|
||||
request_media_heuristic: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +168,8 @@ mod tests {
|
||||
enabled: false,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
request_media_fallback: true,
|
||||
request_media_heuristic: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -251,6 +251,8 @@ mod tests {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
request_media_fallback: true,
|
||||
request_media_heuristic: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +261,8 @@ mod tests {
|
||||
enabled: true,
|
||||
request_thinking_signature: false,
|
||||
request_thinking_budget: false,
|
||||
request_media_fallback: true,
|
||||
request_media_heuristic: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +271,8 @@ mod tests {
|
||||
enabled: false,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
request_media_fallback: true,
|
||||
request_media_heuristic: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +213,19 @@ pub struct RectifierConfig {
|
||||
/// 处理错误:budget_tokens + thinking 相关约束
|
||||
#[serde(default = "default_true")]
|
||||
pub request_thinking_budget: bool,
|
||||
/// 请求整流:不支持的图片降级(默认开启)
|
||||
///
|
||||
/// 上游拒绝图片输入时,把图片块替换为 [Unsupported Image] 标记,
|
||||
/// 让对话不中断。总开关,管辖「显式声明 text-only」与「上游报错后兜底」两条事实驱动路径。
|
||||
#[serde(default = "default_true")]
|
||||
pub request_media_fallback: bool,
|
||||
/// 请求整流:启发式 text-only 模型名匹配(默认开启)
|
||||
///
|
||||
/// 在模型未声明能力时,按内置模型名列表预测性地剥离图片(发送前)。
|
||||
/// 受 request_media_fallback 管辖;单独关闭后仅保留「显式声明」与「上游兜底」,
|
||||
/// 避免内置列表把多模态模型误判成 text-only 而静默剥图。
|
||||
#[serde(default = "default_true")]
|
||||
pub request_media_heuristic: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -229,6 +242,8 @@ impl Default for RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
request_media_fallback: true,
|
||||
request_media_heuristic: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -386,6 +401,14 @@ mod tests {
|
||||
config.request_thinking_budget,
|
||||
"thinking budget 整流器默认应为 true"
|
||||
);
|
||||
assert!(
|
||||
config.request_media_fallback,
|
||||
"media 降级总开关默认应为 true"
|
||||
);
|
||||
assert!(
|
||||
config.request_media_heuristic,
|
||||
"启发式 text-only 模型识别默认应为 true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -396,6 +419,14 @@ mod tests {
|
||||
assert!(config.enabled);
|
||||
assert!(config.request_thinking_signature);
|
||||
assert!(config.request_thinking_budget);
|
||||
assert!(
|
||||
config.request_media_fallback,
|
||||
"缺 requestMediaFallback 时应回退默认值 true"
|
||||
);
|
||||
assert!(
|
||||
config.request_media_heuristic,
|
||||
"缺 requestMediaHeuristic 时应回退默认值 true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -419,6 +450,19 @@ mod tests {
|
||||
assert!(config.request_thinking_budget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_media_explicit_false() {
|
||||
// 验证 media 两字段显式 false 时被如实反序列化(用户主动关闭须生效,不能被默认值覆盖)
|
||||
let json = r#"{"requestMediaFallback": false, "requestMediaHeuristic": false}"#;
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(!config.request_media_fallback);
|
||||
assert!(!config.request_media_heuristic);
|
||||
// 其余字段仍走默认 true
|
||||
assert!(config.enabled);
|
||||
assert!(config.request_thinking_signature);
|
||||
assert!(config.request_thinking_budget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_config_default() {
|
||||
let config = LogConfig::default();
|
||||
|
||||
@@ -16,6 +16,11 @@ pub struct RequestLog {
|
||||
pub app_type: String,
|
||||
pub model: String,
|
||||
pub request_model: String,
|
||||
/// 写入时实际用于计价的模型名(pricing_model_source 解析后的结果)。
|
||||
/// 落库供回填使用:缺价行补价后必须按写入时的基准重算,而不是
|
||||
/// 用 model/request_model 猜——路由接管下三者可能各不相同。
|
||||
/// 错误行(未计价)为空字符串。
|
||||
pub pricing_model: String,
|
||||
pub usage: TokenUsage,
|
||||
pub cost: Option<CostBreakdown>,
|
||||
pub latency_ms: u64,
|
||||
@@ -68,18 +73,19 @@ impl<'a> UsageLogger<'a> {
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model,
|
||||
request_id, provider_id, app_type, model, request_model, pricing_model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||
latency_ms, first_token_ms, status_code, error_message, session_id,
|
||||
provider_type, is_streaming, cost_multiplier, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23)",
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)",
|
||||
rusqlite::params![
|
||||
log.request_id,
|
||||
log.provider_id,
|
||||
log.app_type,
|
||||
log.model,
|
||||
log.request_model,
|
||||
log.pricing_model,
|
||||
log.usage.input_tokens,
|
||||
log.usage.output_tokens,
|
||||
log.usage.cache_read_tokens,
|
||||
@@ -129,6 +135,8 @@ impl<'a> UsageLogger<'a> {
|
||||
app_type,
|
||||
model,
|
||||
request_model,
|
||||
// 错误行未经过计价,留空(回填的 has_usage 闸门也不会碰全 0 行)
|
||||
pricing_model: String::new(),
|
||||
usage: TokenUsage::default(),
|
||||
cost: None,
|
||||
latency_ms,
|
||||
@@ -168,6 +176,8 @@ impl<'a> UsageLogger<'a> {
|
||||
app_type,
|
||||
model,
|
||||
request_model,
|
||||
// 错误行未经过计价,留空(回填的 has_usage 闸门也不会碰全 0 行)
|
||||
pricing_model: String::new(),
|
||||
usage: TokenUsage::default(),
|
||||
cost: None,
|
||||
latency_ms,
|
||||
@@ -203,13 +213,22 @@ impl<'a> UsageLogger<'a> {
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
) -> (Decimal, String) {
|
||||
let default_multiplier_raw = match self.db.get_default_cost_multiplier(app_type).await {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
log::warn!("[USG-003] 获取默认倍率失败 (app_type={app_type}): {e}");
|
||||
"1".to_string()
|
||||
}
|
||||
// Claude Desktop 网关没有独立的全局计费配置(proxy_config 的 CHECK 仅
|
||||
// 允许 claude/codex/gemini,前端也只暴露三项),全局默认继承 claude;
|
||||
// 供应商级 meta 覆盖仍按 claude-desktop 查找(providers 表按该 app_type 存)。
|
||||
let default_app_type = if app_type == "claude-desktop" {
|
||||
"claude"
|
||||
} else {
|
||||
app_type
|
||||
};
|
||||
let default_multiplier_raw =
|
||||
match self.db.get_default_cost_multiplier(default_app_type).await {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
log::warn!("[USG-003] 获取默认倍率失败 (app_type={app_type}): {e}");
|
||||
"1".to_string()
|
||||
}
|
||||
};
|
||||
let default_multiplier = match Decimal::from_str(&default_multiplier_raw) {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
@@ -220,13 +239,14 @@ impl<'a> UsageLogger<'a> {
|
||||
}
|
||||
};
|
||||
|
||||
let default_pricing_source_raw = match self.db.get_pricing_model_source(app_type).await {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
log::warn!("[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}");
|
||||
PRICING_SOURCE_RESPONSE.to_string()
|
||||
}
|
||||
};
|
||||
let default_pricing_source_raw =
|
||||
match self.db.get_pricing_model_source(default_app_type).await {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
log::warn!("[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}");
|
||||
PRICING_SOURCE_RESPONSE.to_string()
|
||||
}
|
||||
};
|
||||
let default_pricing_source = if default_pricing_source_raw == PRICING_SOURCE_RESPONSE
|
||||
|| default_pricing_source_raw == PRICING_SOURCE_REQUEST
|
||||
{
|
||||
@@ -325,6 +345,7 @@ impl<'a> UsageLogger<'a> {
|
||||
app_type,
|
||||
model,
|
||||
request_model,
|
||||
pricing_model,
|
||||
usage,
|
||||
cost,
|
||||
latency_ms,
|
||||
|
||||
@@ -37,6 +37,18 @@ impl TokenUsage {
|
||||
.map(|mid| format!("{SESSION_REQUEST_ID_PREFIX}{mid}"))
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
|
||||
}
|
||||
|
||||
/// 是否产生了任一计费维度的 token。
|
||||
///
|
||||
/// 用于在写入前过滤全 0 的空 usage:当 OpenAI 兼容上游在流式下省略 usage 时,
|
||||
/// 转换器会合成一个全 0 的终止事件,若无 message_id 则 `dedup_request_id`
|
||||
/// 退化为随机 UUID,导致每笔请求插入一条无意义的空行、虚增请求数。
|
||||
pub fn has_billable_tokens(&self) -> bool {
|
||||
self.input_tokens > 0
|
||||
|| self.output_tokens > 0
|
||||
|| self.cache_read_tokens > 0
|
||||
|| self.cache_creation_tokens > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// API 类型
|
||||
@@ -85,6 +97,7 @@ impl TokenUsage {
|
||||
let mut usage = Self::default();
|
||||
let mut model: Option<String> = None;
|
||||
let mut message_id: Option<String> = None;
|
||||
let mut input_from_delta = false;
|
||||
|
||||
for event in events {
|
||||
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
|
||||
@@ -129,32 +142,52 @@ impl TokenUsage {
|
||||
{
|
||||
usage.output_tokens = output as u32;
|
||||
}
|
||||
// OpenRouter 转换后的流式响应:input_tokens 也在 message_delta 中
|
||||
// 如果 message_start 中没有 input_tokens,则从 message_delta 获取
|
||||
if usage.input_tokens == 0 {
|
||||
if let Some(input) =
|
||||
delta_usage.get("input_tokens").and_then(|v| v.as_u64())
|
||||
{
|
||||
usage.input_tokens = input as u32;
|
||||
|
||||
let delta_input = delta_usage
|
||||
.get("input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
let delta_cache_read = delta_usage
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
let delta_cache_creation = delta_usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
|
||||
// 部分 Anthropic-compatible SSE provider 会在 message_start 上报完整上下文,
|
||||
// 但在 message_delta 上报修正后的 fresh input。遇到更小的正数 delta input
|
||||
// 时采用 delta;若同一 usage 块带有缓存计数,也同步采用以避免重复计数。
|
||||
// 若 delta 缺少缓存字段,则保留 start 中已有的缓存值作为 best-effort fallback。
|
||||
if let Some(input) = delta_input {
|
||||
let should_use_delta_input = input > 0
|
||||
&& (usage.input_tokens == 0
|
||||
|| input < usage.input_tokens
|
||||
|| (input_from_delta && input <= usage.input_tokens));
|
||||
|
||||
if should_use_delta_input {
|
||||
usage.input_tokens = input;
|
||||
input_from_delta = true;
|
||||
if let Some(cache_read) = delta_cache_read {
|
||||
usage.cache_read_tokens = cache_read;
|
||||
}
|
||||
if let Some(cache_creation) = delta_cache_creation {
|
||||
usage.cache_creation_tokens = cache_creation;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 从 message_delta 中处理缓存命中(cache_read_input_tokens)
|
||||
if usage.cache_read_tokens == 0 {
|
||||
if let Some(cache_read) = delta_usage
|
||||
.get("cache_read_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
{
|
||||
usage.cache_read_tokens = cache_read as u32;
|
||||
if let Some(cache_read) = delta_cache_read {
|
||||
usage.cache_read_tokens = cache_read;
|
||||
}
|
||||
}
|
||||
// 从 message_delta 中处理缓存创建(cache_creation_input_tokens)
|
||||
// 注: 现在 zhipu 没有返回 cache_creation_input_tokens 字段
|
||||
if usage.cache_creation_tokens == 0 {
|
||||
if let Some(cache_creation) = delta_usage
|
||||
.get("cache_creation_input_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
{
|
||||
usage.cache_creation_tokens = cache_creation as u32;
|
||||
if let Some(cache_creation) = delta_cache_creation {
|
||||
usage.cache_creation_tokens = cache_creation;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,7 +197,11 @@ impl TokenUsage {
|
||||
}
|
||||
}
|
||||
|
||||
if usage.input_tokens > 0 || usage.output_tokens > 0 {
|
||||
// 用 has_billable_tokens 而非仅看 input/output:完全缓存命中、无输出的流式请求
|
||||
// (input==0 && output==0 但 cache_read>0)是真实的 cache-read 计费,必须保留。
|
||||
// Gemini→Anthropic 路径在 input 改为 fresh(promptTokenCount - cachedContentTokenCount)
|
||||
// 后尤其会出现这种全缓存场景;旧 gate 会把它当成"无 usage"丢弃。
|
||||
if usage.has_billable_tokens() {
|
||||
usage.model = model;
|
||||
usage.message_id = message_id;
|
||||
Some(usage)
|
||||
@@ -501,6 +538,71 @@ mod tests {
|
||||
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_billable_tokens_gates_empty_usage() {
|
||||
// 全 0 usage(如上游省略 usage 时合成的全 0 终止事件)不应计费——
|
||||
// 这是 Codex 流式空行多记修复(D)的闸门依据。
|
||||
assert!(!TokenUsage::default().has_billable_tokens());
|
||||
// 仅有 cache_read 也属于真实计费 token,必须计入。
|
||||
let only_cache = TokenUsage {
|
||||
cache_read_tokens: 100,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(only_cache.has_billable_tokens());
|
||||
let normal = TokenUsage {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(normal.has_billable_tokens());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_stream_cache_only_request_is_recorded() {
|
||||
// P2 回归:完全缓存命中、无输出的流式请求(input==0 && output==0 但 cache_read>0)
|
||||
// 是真实计费,必须保留——旧 gate `input>0 || output>0` 会把它丢弃。
|
||||
let events = vec![
|
||||
json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": "msg_cacheonly",
|
||||
"model": "claude-opus-4-8",
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"cache_read_input_tokens": 50000,
|
||||
"cache_creation_input_tokens": 0
|
||||
}
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "message_delta",
|
||||
"usage": { "output_tokens": 0 }
|
||||
}),
|
||||
];
|
||||
let usage = TokenUsage::from_claude_stream_events(&events)
|
||||
.expect("cache-only 流式请求必须被记录,不能被 input/output gate 丢弃");
|
||||
assert_eq!(usage.input_tokens, 0);
|
||||
assert_eq!(usage.output_tokens, 0);
|
||||
assert_eq!(usage.cache_read_tokens, 50000);
|
||||
assert_eq!(usage.message_id, Some("msg_cacheonly".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_response_auto_returns_some_for_synthetic_all_zero() {
|
||||
// P3 回归:上游非流式 Chat 省略 usage 时转换器合成的全 0 usage,from_codex_response_auto
|
||||
// 仍返回 Some(字段存在、无 positivity check)——证明 handlers 必须用 has_billable_tokens
|
||||
// 闸门才能挡住空行,单靠 `if let Some` 不够。
|
||||
let synthetic = json!({
|
||||
"usage": { "input_tokens": 0, "output_tokens": 0, "total_tokens": 0 }
|
||||
});
|
||||
let usage = TokenUsage::from_codex_response_auto(&synthetic)
|
||||
.expect("全 0 usage 字段存在时 from_codex_response_auto 返回 Some");
|
||||
assert!(
|
||||
!usage.has_billable_tokens(),
|
||||
"全 0 usage 必须被 has_billable_tokens 判为非计费,由 handlers 闸门跳过"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_response_parsing_no_model() {
|
||||
let response = json!({
|
||||
@@ -798,6 +900,115 @@ mod tests {
|
||||
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_stream_prefers_smaller_delta_input_and_cache_pair() {
|
||||
// 部分 Anthropic-compatible provider 会在 message_start 给出包含缓存的总上下文,
|
||||
// 再在 message_delta 给出修正后的 fresh input,需要以 delta usage 为准。
|
||||
let events = vec![
|
||||
json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"model": "qwen-max",
|
||||
"usage": {
|
||||
"input_tokens": 200_000,
|
||||
"cache_read_input_tokens": 180_000,
|
||||
"cache_creation_input_tokens": 2_000
|
||||
}
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "message_delta",
|
||||
"usage": {
|
||||
"input_tokens": 80_000,
|
||||
"output_tokens": 1_000,
|
||||
"cache_read_input_tokens": 120_000,
|
||||
"cache_creation_input_tokens": 500
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
|
||||
assert_eq!(usage.input_tokens, 80_000);
|
||||
assert_eq!(usage.output_tokens, 1_000);
|
||||
assert_eq!(usage.cache_read_tokens, 120_000);
|
||||
assert_eq!(usage.cache_creation_tokens, 500);
|
||||
assert_eq!(usage.model, Some("qwen-max".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_stream_updates_cache_pair_from_later_delta_input() {
|
||||
// 有些 provider 会多次发送带 input 的 message_delta;一旦采用过 delta input,
|
||||
// 后续相同/更小 input 的 delta 应继续更新同一块里的缓存计数。
|
||||
let events = vec![
|
||||
json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"model": "qwen-max",
|
||||
"usage": {
|
||||
"input_tokens": 200_000,
|
||||
"cache_read_input_tokens": 180_000,
|
||||
"cache_creation_input_tokens": 2_000
|
||||
}
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "message_delta",
|
||||
"usage": {
|
||||
"input_tokens": 80_000,
|
||||
"output_tokens": 100,
|
||||
"cache_read_input_tokens": 110_000,
|
||||
"cache_creation_input_tokens": 300
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "message_delta",
|
||||
"usage": {
|
||||
"input_tokens": 80_000,
|
||||
"output_tokens": 1_000,
|
||||
"cache_read_input_tokens": 120_000,
|
||||
"cache_creation_input_tokens": 500
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
|
||||
assert_eq!(usage.input_tokens, 80_000);
|
||||
assert_eq!(usage.output_tokens, 1_000);
|
||||
assert_eq!(usage.cache_read_tokens, 120_000);
|
||||
assert_eq!(usage.cache_creation_tokens, 500);
|
||||
assert_eq!(usage.model, Some("qwen-max".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_stream_keeps_start_when_delta_input_is_larger() {
|
||||
// 正常 Anthropic 语义下,message_start 的 input_tokens 已经可信;
|
||||
// 如果 delta input 变大,不应覆盖 start input/cache。
|
||||
let events = vec![
|
||||
json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"usage": {
|
||||
"input_tokens": 100,
|
||||
"cache_read_input_tokens": 20
|
||||
}
|
||||
}
|
||||
}),
|
||||
json!({
|
||||
"type": "message_delta",
|
||||
"usage": {
|
||||
"input_tokens": 150,
|
||||
"output_tokens": 75,
|
||||
"cache_read_input_tokens": 30
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
assert_eq!(usage.output_tokens, 75);
|
||||
assert_eq!(usage.cache_read_tokens, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_native_claude_stream_parsing() {
|
||||
// 测试原生 Claude API 流式响应解析
|
||||
|
||||
@@ -190,14 +190,46 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
|
||||
|
||||
// ── 智谱 GLM ────────────────────────────────────────────────
|
||||
|
||||
/// 智谱 TOKENS_LIMIT 条目按 `unit` 字段的显式窗口分类。
|
||||
enum ZhipuWindow {
|
||||
FiveHour,
|
||||
Weekly,
|
||||
}
|
||||
|
||||
/// 按 `unit` 字段判定 TOKENS_LIMIT 条目所属窗口。
|
||||
///
|
||||
/// 实测形态(bigmodel.cn 与 z.ai 共用同一后端,字段一致):
|
||||
/// - `unit: 3, number: 5` → 5 小时滚动窗口(老/新套餐均有)
|
||||
/// - `unit: 6, number: 7` 与 `unit: 6, number: 1` → 每周窗口(两种取值都被
|
||||
/// 实测过,故只锚定 `unit`、不绑 `number`)
|
||||
///
|
||||
/// `unit` 缺失或值不认识时返回 None,由调用方走重置时间启发式兜底。
|
||||
fn classify_zhipu_window(item: &serde_json::Value) -> Option<ZhipuWindow> {
|
||||
match item.get("unit").and_then(|v| v.as_i64()) {
|
||||
Some(3) => Some(ZhipuWindow::FiveHour),
|
||||
Some(6) => Some(ZhipuWindow::Weekly),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 把智谱 `data` 里的 `limits[]` 解析成 tier 列表。
|
||||
///
|
||||
/// 双桶响应中,5 小时桶在 0% 等状态下可能没有 `nextResetTime`;
|
||||
/// 这类无 reset 条目应优先归为五小时桶。其余条目按 `nextResetTime` 升序。
|
||||
/// 分类优先级:
|
||||
/// 1. 显式字段:`unit` 标识窗口类型(见 [`classify_zhipu_window`])。不能按
|
||||
/// `nextResetTime` 排序代替——周期末尾每周窗口会比 5 小时窗口更早重置
|
||||
/// (issue #3036),时间排序在该场景必然把两桶标反。
|
||||
/// 2. 兜底启发式(`unit` 缺失或不识别):无 `nextResetTime` 的条目优先归
|
||||
/// five_hour(5 小时桶在 0% 等状态下可能没有 reset),其余按 reset 升序
|
||||
/// 依次填入仍空缺的槽位。
|
||||
///
|
||||
/// 老套餐(2026-02-12 前订阅)只回 1 条
|
||||
/// `TOKENS_LIMIT`,自然降级为仅展示 `five_hour`;新套餐回 2 条。
|
||||
fn parse_zhipu_token_tiers(data: &serde_json::Value) -> Vec<QuotaTier> {
|
||||
let mut token_limits: Vec<(Option<i64>, f64, Option<String>)> = Vec::new();
|
||||
type Entry = (Option<i64>, f64, Option<String>);
|
||||
let mut five_hour: Option<Entry> = None;
|
||||
let mut weekly: Option<Entry> = None;
|
||||
let mut unclassified: Vec<Entry> = 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
|
||||
@@ -214,37 +246,65 @@ fn parse_zhipu_token_tiers(data: &serde_json::Value) -> Vec<QuotaTier> {
|
||||
.unwrap_or(0.0);
|
||||
let reset_ms = limit_item.get("nextResetTime").and_then(|v| v.as_i64());
|
||||
let reset_iso = reset_ms.and_then(millis_to_iso8601);
|
||||
token_limits.push((reset_ms, percentage, reset_iso));
|
||||
let entry = (reset_ms, percentage, reset_iso);
|
||||
match classify_zhipu_window(limit_item) {
|
||||
Some(ZhipuWindow::FiveHour) if five_hour.is_none() => five_hour = Some(entry),
|
||||
Some(ZhipuWindow::Weekly) if weekly.is_none() => weekly = Some(entry),
|
||||
_ => unclassified.push(entry),
|
||||
}
|
||||
}
|
||||
}
|
||||
token_limits.sort_by_key(|(reset, _, _)| (reset.is_some(), reset.unwrap_or(i64::MIN)));
|
||||
|
||||
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 {
|
||||
unclassified.sort_by_key(|(reset, _, _)| (reset.is_some(), reset.unwrap_or(i64::MIN)));
|
||||
for entry in unclassified {
|
||||
if five_hour.is_none() {
|
||||
five_hour = Some(entry);
|
||||
} else if weekly.is_none() {
|
||||
weekly = Some(entry);
|
||||
}
|
||||
// 智谱当前最多两条 TOKENS_LIMIT,多余的忽略
|
||||
}
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
for (name, slot) in [(TIER_FIVE_HOUR, five_hour), (TIER_WEEKLY_LIMIT, weekly)] {
|
||||
if let Some((_, percentage, resets_at)) = slot {
|
||||
tiers.push(QuotaTier {
|
||||
name: name.to_string(),
|
||||
utilization: percentage,
|
||||
resets_at,
|
||||
used_value_usd: None,
|
||||
max_value_usd: None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
}
|
||||
}
|
||||
tiers
|
||||
}
|
||||
|
||||
async fn query_zhipu(api_key: &str) -> SubscriptionQuota {
|
||||
let client = crate::proxy::http_client::get();
|
||||
/// Resolve the Zhipu quota endpoint from the user's configured `base_url`.
|
||||
///
|
||||
/// Zhipu ships as two distinct presets (Zhipu GLM = `open.bigmodel.cn`,
|
||||
/// Zhipu GLM en = `api.z.ai`) that share the same quota path and JSON shape.
|
||||
/// The quota endpoint lives on the same host as the user's coding endpoint,
|
||||
/// so we route by `base_url` and let the caller's existing reachability
|
||||
/// (they're already using this host to run coding) determine success — no
|
||||
/// cross-host fallback, no auth-error heuristics.
|
||||
fn zhipu_quota_base(base_url: &str) -> &'static str {
|
||||
if base_url.to_lowercase().contains("bigmodel.cn") {
|
||||
"https://open.bigmodel.cn"
|
||||
} else {
|
||||
"https://api.z.ai"
|
||||
}
|
||||
}
|
||||
|
||||
async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
||||
let client = crate::proxy::http_client::get();
|
||||
let url = format!(
|
||||
"{}/api/monitor/usage/quota/limit",
|
||||
zhipu_quota_base(base_url)
|
||||
);
|
||||
|
||||
// 统一走 api.z.ai 国际站(中国站 bigmodel.cn 有反爬机制)
|
||||
let resp = client
|
||||
.get("https://api.z.ai/api/monitor/usage/quota/limit")
|
||||
.get(&url)
|
||||
.header("Authorization", api_key) // 注意:智谱不加 Bearer 前缀
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept-Language", "en-US,en")
|
||||
@@ -629,7 +689,9 @@ pub async fn get_coding_plan_quota(
|
||||
|
||||
let quota = match provider {
|
||||
CodingPlanProvider::Kimi => query_kimi(api_key).await,
|
||||
CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => query_zhipu(api_key).await,
|
||||
CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => {
|
||||
query_zhipu(base_url, api_key).await
|
||||
}
|
||||
CodingPlanProvider::MiniMaxCn => query_minimax(api_key, true).await,
|
||||
CodingPlanProvider::MiniMaxEn => query_minimax(api_key, false).await,
|
||||
CodingPlanProvider::ZenMux => query_zenmux(base_url, api_key).await,
|
||||
@@ -640,7 +702,10 @@ pub async fn get_coding_plan_quota(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_minimax_tiers, parse_zhipu_token_tiers, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT};
|
||||
use super::{
|
||||
parse_minimax_tiers, parse_zhipu_token_tiers, zhipu_quota_base, TIER_FIVE_HOUR,
|
||||
TIER_WEEKLY_LIMIT,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -755,6 +820,96 @@ mod tests {
|
||||
assert_eq!(tiers[1].utilization, 150.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_unit_field_overrides_reset_order_when_weekly_resets_sooner() {
|
||||
// 真实案例(issue #3036,2026-06-10 再次复现):每周周期末尾,周桶比
|
||||
// 5 小时桶更早重置。官网真实值:5h 用 1%(约 5h 后重置)、每周用 42%
|
||||
// (约 1h 后重置)。旧逻辑按 reset 升序必然标反,unit 字段须优先。
|
||||
let data = json!({
|
||||
"limits": [
|
||||
{ "type": "TOKENS_LIMIT", "unit": 6, "number": 7, "percentage": 42.0, "nextResetTime": 1_000_003_600_000_i64 },
|
||||
{ "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 1.0, "nextResetTime": 1_000_018_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, 1.0);
|
||||
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
|
||||
assert_eq!(tiers[1].utilization, 42.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_weekly_unit_six_number_one_variant() {
|
||||
// z.ai 也观测过 (unit:6, number:1) 表示每周窗口(按"1 周"计),
|
||||
// 分类只看 unit,number 取值不影响。
|
||||
let data = json!({
|
||||
"limits": [
|
||||
{ "type": "TOKENS_LIMIT", "unit": 6, "number": 1, "percentage": 30.0, "nextResetTime": 1_000_000_000_000_i64 },
|
||||
{ "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 10.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, 10.0);
|
||||
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
|
||||
assert_eq!(tiers[1].utilization, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_partial_unit_fields_fill_remaining_slot() {
|
||||
// 只有周桶带 unit 时,缺 unit 的另一条应填入剩下的 five_hour 槽位,
|
||||
// 即便它的 reset 更晚——显式分类结果不受时间排序干扰。
|
||||
let data = json!({
|
||||
"limits": [
|
||||
{ "type": "TOKENS_LIMIT", "unit": 6, "number": 7, "percentage": 42.0, "nextResetTime": 1_000_000_000_000_i64 },
|
||||
{ "type": "TOKENS_LIMIT", "percentage": 1.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, 1.0);
|
||||
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
|
||||
assert_eq!(tiers[1].utilization, 42.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_unknown_unit_values_fall_back_to_reset_order() {
|
||||
// 未识别的 unit 枚举值不猜语义,整体回落旧的重置时间启发式。
|
||||
let data = json!({
|
||||
"limits": [
|
||||
{ "type": "TOKENS_LIMIT", "unit": 9, "percentage": 44.0, "nextResetTime": 1_000_000_000_000_i64 },
|
||||
{ "type": "TOKENS_LIMIT", "unit": 9, "percentage": 53.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, 44.0);
|
||||
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
|
||||
assert_eq!(tiers[1].utilization, 53.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_duplicate_unit_classification_fills_other_slot() {
|
||||
// 防御性:两条都标成 5 小时窗(上游异常)时,第一条占 five_hour,
|
||||
// 第二条降级走兜底填入 weekly,保证不丢数据也不 panic。
|
||||
let data = json!({
|
||||
"limits": [
|
||||
{ "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 10.0, "nextResetTime": 1_000_000_000_000_i64 },
|
||||
{ "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 20.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, 10.0);
|
||||
assert_eq!(tiers[1].name, TIER_WEEKLY_LIMIT);
|
||||
assert_eq!(tiers[1].utilization, 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_more_than_two_token_limits_keeps_first_two() {
|
||||
// 防御性:智谱当前最多两条 TOKENS_LIMIT,若上游意外增加第三条应被丢弃,避免命名空缺。
|
||||
@@ -940,4 +1095,42 @@ mod tests {
|
||||
assert_eq!(tiers[0].name, TIER_FIVE_HOUR);
|
||||
assert_eq!(tiers[0].utilization, 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_quota_base_routes_bigmodel_url_to_cn_endpoint() {
|
||||
assert_eq!(
|
||||
zhipu_quota_base("https://open.bigmodel.cn/api/paas/v4"),
|
||||
"https://open.bigmodel.cn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_quota_base_routes_z_ai_url_to_en_endpoint() {
|
||||
assert_eq!(
|
||||
zhipu_quota_base("https://api.z.ai/api/paas/v4"),
|
||||
"https://api.z.ai"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_quota_base_defaults_to_en_for_unknown_url() {
|
||||
// 没有明显 Zhipu 域名特征时,默认走国际站(更通用的入口)
|
||||
assert_eq!(
|
||||
zhipu_quota_base("https://example.com/zhipu"),
|
||||
"https://api.z.ai"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_quota_base_routes_uppercase_cn_url_to_cn_endpoint() {
|
||||
// 大小写不敏感:与 detect_provider 保持一致的约定,避免大写 preset URL 静默路由到国际站
|
||||
assert_eq!(
|
||||
zhipu_quota_base("HTTPS://OPEN.BIGMODEL.CN/api/paas/v4"),
|
||||
"https://open.bigmodel.cn"
|
||||
);
|
||||
assert_eq!(
|
||||
zhipu_quota_base("https://Open.BigModel.cn/api/paas/v4"),
|
||||
"https://open.bigmodel.cn"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,19 @@ pub mod omo;
|
||||
pub mod prompt;
|
||||
pub mod provider;
|
||||
pub mod proxy;
|
||||
pub mod s3;
|
||||
pub mod s3_auto_sync;
|
||||
pub mod s3_sync;
|
||||
pub mod session_usage;
|
||||
pub mod session_usage_codex;
|
||||
pub mod session_usage_gemini;
|
||||
pub mod session_usage_opencode;
|
||||
pub mod skill;
|
||||
pub mod speedtest;
|
||||
pub mod sql_helpers;
|
||||
pub mod stream_check;
|
||||
pub mod subscription;
|
||||
pub mod sync_protocol;
|
||||
pub mod usage_cache;
|
||||
pub mod usage_stats;
|
||||
pub mod webdav;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! 主要面向第三方聚合站(硅基流动、OpenRouter 等),以及把 Anthropic
|
||||
//! 协议挂在兼容子路径上的官方供应商(DeepSeek、Kimi、智谱 GLM 等)。
|
||||
|
||||
use reqwest::header::{HeaderValue, USER_AGENT};
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
@@ -55,6 +56,7 @@ pub async fn fetch_models(
|
||||
api_key: &str,
|
||||
is_full_url: bool,
|
||||
models_url_override: Option<&str>,
|
||||
user_agent: Option<HeaderValue>,
|
||||
) -> Result<Vec<FetchedModel>, String> {
|
||||
if api_key.is_empty() {
|
||||
return Err("API Key is required to fetch models".to_string());
|
||||
@@ -66,13 +68,16 @@ pub async fn fetch_models(
|
||||
|
||||
for url in &candidates {
|
||||
log::debug!("[ModelFetch] Trying endpoint: {url}");
|
||||
let response = match client
|
||||
let mut request = client
|
||||
.get(url)
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS));
|
||||
// 自定义 User-Agent:部分 /models 端点同样有 UA 白名单(如 Kimi Coding Plan),
|
||||
// 与转发 / 检测路径共用同一 UA,避免"代理可用但取模型失败"。
|
||||
if let Some(ua) = &user_agent {
|
||||
request = request.header(USER_AGENT, ua.clone());
|
||||
}
|
||||
let response = match request.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Err(format!("Request failed: {e}"));
|
||||
|
||||
@@ -1133,6 +1133,22 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 拒绝把"被代理接管的 Live"导入为供应商:接管期间 Live 里只有
|
||||
// PROXY_MANAGED 占位符和本地代理地址,不是用户的真实配置。一旦导入,
|
||||
// 它会成为 current provider(SSOT),后续"无备份恢复"路径会把占位符
|
||||
// 当真实配置写回 Live,永久卡在已失效的本地代理上。
|
||||
// 典型触发场景:代理接管开启时切换 app_config_dir 并重启,新数据库首启导入。
|
||||
if state
|
||||
.proxy_service
|
||||
.detect_takeover_in_live_config_for_app(&app_type)
|
||||
{
|
||||
return Err(AppError::localized(
|
||||
"provider.import.live_taken_over",
|
||||
"Live 配置当前处于代理接管状态(包含占位符),不能导入为供应商。请先关闭代理接管或恢复 Live 配置后重试。",
|
||||
"The live config is currently taken over by the proxy (contains placeholders) and cannot be imported as a provider. Disable proxy takeover or restore the live config first.",
|
||||
));
|
||||
}
|
||||
|
||||
let settings_config = match app_type {
|
||||
AppType::Codex => crate::codex_config::read_codex_live_settings()?,
|
||||
AppType::Claude => {
|
||||
|
||||
@@ -81,32 +81,33 @@ pub(crate) async fn execute_and_format_usage_result(
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract API key from provider configuration
|
||||
fn extract_api_key_from_provider(provider: &crate::provider::Provider) -> Option<String> {
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
// Try multiple possible API key fields
|
||||
env.get("ANTHROPIC_AUTH_TOKEN")
|
||||
.or_else(|| env.get("ANTHROPIC_API_KEY"))
|
||||
.or_else(|| env.get("OPENROUTER_API_KEY"))
|
||||
.or_else(|| env.get("GOOGLE_API_KEY"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// Resolve `(api_key, base_url)` for the JS-script path: explicit non-empty
|
||||
/// script values win, otherwise fall back to the provider's stored config via
|
||||
/// `Provider::resolve_usage_credentials` — the same per-app resolver the
|
||||
/// native balance/coding-plan path and the frontend `getProviderCredentials`
|
||||
/// use, so `{{apiKey}}`/`{{baseUrl}}` match what the UI shows for them.
|
||||
fn resolve_script_credentials(
|
||||
app_type: &AppType,
|
||||
provider: &crate::provider::Provider,
|
||||
api_key: Option<&str>,
|
||||
base_url: Option<&str>,
|
||||
) -> (String, String) {
|
||||
let (provider_base_url, provider_api_key) = provider.resolve_usage_credentials(app_type);
|
||||
|
||||
/// Extract base URL from provider configuration
|
||||
fn extract_base_url_from_provider(provider: &crate::provider::Provider) -> Option<String> {
|
||||
if let Some(env) = provider.settings_config.get("env") {
|
||||
// Try multiple possible base URL fields
|
||||
env.get("ANTHROPIC_BASE_URL")
|
||||
.or_else(|| env.get("GOOGLE_GEMINI_BASE_URL"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim_end_matches('/').to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
let api_key = api_key
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_owned)
|
||||
.unwrap_or(provider_api_key);
|
||||
|
||||
let base_url = base_url
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
// Trim like the provider path so `{{baseUrl}}/path` never doubles the slash.
|
||||
.map(|value| value.trim_end_matches('/').to_owned())
|
||||
.unwrap_or(provider_base_url);
|
||||
|
||||
(api_key, base_url)
|
||||
}
|
||||
|
||||
/// Query provider usage (using saved script configuration)
|
||||
@@ -145,19 +146,12 @@ pub async fn query_usage(
|
||||
}
|
||||
|
||||
// Get credentials: prioritize UsageScript values, fallback to provider config
|
||||
let api_key = usage_script
|
||||
.api_key
|
||||
.clone()
|
||||
.filter(|k| !k.is_empty())
|
||||
.or_else(|| extract_api_key_from_provider(provider))
|
||||
.unwrap_or_default();
|
||||
|
||||
let base_url = usage_script
|
||||
.base_url
|
||||
.clone()
|
||||
.filter(|u| !u.is_empty())
|
||||
.or_else(|| extract_base_url_from_provider(provider))
|
||||
.unwrap_or_default();
|
||||
let (api_key, base_url) = resolve_script_credentials(
|
||||
&app_type,
|
||||
provider,
|
||||
usage_script.api_key.as_deref(),
|
||||
usage_script.base_url.as_deref(),
|
||||
);
|
||||
|
||||
(
|
||||
usage_script.code.clone(),
|
||||
@@ -185,9 +179,9 @@ pub async fn query_usage(
|
||||
/// Test usage script (using temporary script content, not saved)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn test_usage_script(
|
||||
_state: &AppState,
|
||||
_app_type: AppType,
|
||||
_provider_id: &str,
|
||||
state: &AppState,
|
||||
app_type: AppType,
|
||||
provider_id: &str,
|
||||
script_code: &str,
|
||||
timeout: u64,
|
||||
api_key: Option<&str>,
|
||||
@@ -196,11 +190,23 @@ pub async fn test_usage_script(
|
||||
user_id: Option<&str>,
|
||||
template_type: Option<&str>,
|
||||
) -> Result<UsageResult, AppError> {
|
||||
// Use provided credential parameters directly for testing
|
||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||
let provider = providers.get(provider_id).ok_or_else(|| {
|
||||
AppError::localized(
|
||||
"provider.not_found",
|
||||
format!("供应商不存在: {provider_id}"),
|
||||
format!("Provider not found: {provider_id}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Resolve like the real query so testing matches what a saved script does:
|
||||
// explicit values win, empty ones fall back to the provider config.
|
||||
let (api_key, base_url) = resolve_script_credentials(&app_type, provider, api_key, base_url);
|
||||
|
||||
execute_and_format_usage_result(
|
||||
script_code,
|
||||
api_key.unwrap_or(""),
|
||||
base_url.unwrap_or(""),
|
||||
&api_key,
|
||||
&base_url,
|
||||
timeout,
|
||||
access_token,
|
||||
user_id,
|
||||
@@ -226,3 +232,76 @@ pub(crate) fn validate_usage_script(script: &UsageScript) -> Result<(), AppError
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_script_credentials;
|
||||
use crate::app_config::AppType;
|
||||
use crate::provider::Provider;
|
||||
use serde_json::json;
|
||||
|
||||
fn provider_with_settings(settings_config: serde_json::Value) -> Provider {
|
||||
Provider::with_id(
|
||||
"provider-1".to_string(),
|
||||
"Provider".to_string(),
|
||||
settings_config,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_values_override_provider_credentials() {
|
||||
let provider = provider_with_settings(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "provider-key",
|
||||
"ANTHROPIC_BASE_URL": "https://provider.example.com/"
|
||||
}
|
||||
}));
|
||||
|
||||
let (api_key, base_url) = resolve_script_credentials(
|
||||
&AppType::Claude,
|
||||
&provider,
|
||||
Some(" script-key "),
|
||||
Some(" https://script.example.com/ "),
|
||||
);
|
||||
assert_eq!(api_key, "script-key");
|
||||
assert_eq!(base_url, "https://script.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_script_values_fall_back_to_provider_credentials() {
|
||||
let provider = provider_with_settings(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "provider-key",
|
||||
"ANTHROPIC_BASE_URL": "https://provider.example.com/"
|
||||
}
|
||||
}));
|
||||
|
||||
let (api_key, base_url) =
|
||||
resolve_script_credentials(&AppType::Claude, &provider, Some(""), None);
|
||||
assert_eq!(api_key, "provider-key");
|
||||
assert_eq!(base_url, "https://provider.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_fallback_reads_auth_and_config_toml() {
|
||||
let provider = provider_with_settings(json!({
|
||||
"auth": {
|
||||
"OPENAI_API_KEY": "openai-key"
|
||||
},
|
||||
"config": r#"model_provider = "azure"
|
||||
|
||||
[model_providers.azure]
|
||||
base_url = "https://azure.example.com/v1/"
|
||||
|
||||
[model_providers.other]
|
||||
base_url = "https://other.example.com/v1"
|
||||
"#
|
||||
}));
|
||||
|
||||
let (api_key, base_url) =
|
||||
resolve_script_credentials(&AppType::Codex, &provider, None, None);
|
||||
assert_eq!(api_key, "openai-key");
|
||||
assert_eq!(base_url, "https://azure.example.com/v1");
|
||||
}
|
||||
}
|
||||
|
||||
+499
-27
@@ -438,6 +438,13 @@ impl ProxyService {
|
||||
.start()
|
||||
.await
|
||||
.map_err(|e| format!("启动代理服务器失败: {e}"))?;
|
||||
if let Err(e) = self
|
||||
.persist_ephemeral_listen_port_if_needed(&config, info.port)
|
||||
.await
|
||||
{
|
||||
let _ = server.stop().await;
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// 5. 保存服务器实例
|
||||
*self.server.write().await = Some(server);
|
||||
@@ -446,6 +453,37 @@ impl ProxyService {
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
async fn persist_ephemeral_listen_port_if_needed(
|
||||
&self,
|
||||
config: &ProxyConfig,
|
||||
actual_port: u16,
|
||||
) -> Result<(), String> {
|
||||
if config.listen_port != 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut resolved_config = config.clone();
|
||||
resolved_config.listen_port = actual_port;
|
||||
self.db
|
||||
.update_proxy_config(resolved_config)
|
||||
.await
|
||||
.map_err(|e| format!("保存动态代理端口失败: {e}"))
|
||||
}
|
||||
|
||||
async fn start_before_takeover_if_ephemeral_port(&self) -> Result<bool, String> {
|
||||
let config = self
|
||||
.db
|
||||
.get_proxy_config()
|
||||
.await
|
||||
.map_err(|e| format!("获取代理配置失败: {e}"))?;
|
||||
if config.listen_port != 0 || self.is_running().await {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.start().await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 启动代理服务器(带 Live 配置接管)
|
||||
pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {
|
||||
// 1. 备份各应用的 Live 配置
|
||||
@@ -460,12 +498,27 @@ impl ProxyService {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// 端口 0 需要先启动代理拿到 OS 分配的真实端口,否则接管 Live 配置会写出 :0。
|
||||
let started_proxy_before_takeover =
|
||||
match self.start_before_takeover_if_ephemeral_port().await {
|
||||
Ok(started) => started,
|
||||
Err(e) => {
|
||||
if let Err(clean_err) = self.db.delete_all_live_backups().await {
|
||||
log::warn!("清理 Live 备份失败: {clean_err}");
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// 3. 在写入接管配置之前先落盘接管标志:
|
||||
// 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。
|
||||
if let Err(e) = self.db.set_live_takeover_active(true).await {
|
||||
if let Err(clean_err) = self.db.delete_all_live_backups().await {
|
||||
log::warn!("清理 Live 备份失败: {clean_err}");
|
||||
}
|
||||
if started_proxy_before_takeover {
|
||||
let _ = self.stop().await;
|
||||
}
|
||||
return Err(format!("设置接管状态失败: {e}"));
|
||||
}
|
||||
|
||||
@@ -482,6 +535,9 @@ impl ProxyService {
|
||||
log::error!("恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}");
|
||||
}
|
||||
}
|
||||
if started_proxy_before_takeover {
|
||||
let _ = self.stop().await;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
@@ -500,6 +556,9 @@ impl ProxyService {
|
||||
log::error!("恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}");
|
||||
}
|
||||
}
|
||||
if started_proxy_before_takeover {
|
||||
let _ = self.stop().await;
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
@@ -1086,32 +1145,48 @@ impl ProxyService {
|
||||
async fn backup_live_configs(&self) -> Result<(), String> {
|
||||
// Claude
|
||||
if let Ok(config) = self.read_claude_live() {
|
||||
let json_str = serde_json::to_string(&config)
|
||||
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?;
|
||||
self.db
|
||||
.save_live_backup("claude", &json_str)
|
||||
.await
|
||||
.map_err(|e| format!("备份 Claude 配置失败: {e}"))?;
|
||||
// 跳过已被代理接管的 Live:避免把代理占位符当作"原始 Live"存进备份槽。
|
||||
// 否则下次 start_with_takeover 在异常历史状态下(Live 已是占位符)再次
|
||||
// 调用本函数,会用代理配置覆盖一个原本正常的备份;之后 stop 恢复时
|
||||
// 即便走到备份路径也会把代理占位符再写回 Live,永久卡在 127.0.0.1:15721。
|
||||
if Self::live_has_proxy_placeholder_for_app(&AppType::Claude, &config) {
|
||||
log::warn!("claude Live 已被代理接管,不备份(避免把代理配置固化进备份槽);下次 stop 会从 SSOT 重建 Live");
|
||||
} else {
|
||||
let json_str = serde_json::to_string(&config)
|
||||
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?;
|
||||
self.db
|
||||
.save_live_backup("claude", &json_str)
|
||||
.await
|
||||
.map_err(|e| format!("备份 Claude 配置失败: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Codex
|
||||
if let Ok(config) = self.read_codex_live() {
|
||||
let json_str = serde_json::to_string(&config)
|
||||
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?;
|
||||
self.db
|
||||
.save_live_backup("codex", &json_str)
|
||||
.await
|
||||
.map_err(|e| format!("备份 Codex 配置失败: {e}"))?;
|
||||
if Self::live_has_proxy_placeholder_for_app(&AppType::Codex, &config) {
|
||||
log::warn!("codex Live 已被代理接管,不备份(避免把代理配置固化进备份槽);下次 stop 会从 SSOT 重建 Live");
|
||||
} else {
|
||||
let json_str = serde_json::to_string(&config)
|
||||
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?;
|
||||
self.db
|
||||
.save_live_backup("codex", &json_str)
|
||||
.await
|
||||
.map_err(|e| format!("备份 Codex 配置失败: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini
|
||||
if let Ok(config) = self.read_gemini_live() {
|
||||
let json_str = serde_json::to_string(&config)
|
||||
.map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?;
|
||||
self.db
|
||||
.save_live_backup("gemini", &json_str)
|
||||
.await
|
||||
.map_err(|e| format!("备份 Gemini 配置失败: {e}"))?;
|
||||
if Self::live_has_proxy_placeholder_for_app(&AppType::Gemini, &config) {
|
||||
log::warn!("gemini Live 已被代理接管,不备份(避免把代理配置固化进备份槽);下次 stop 会从 SSOT 重建 Live");
|
||||
} else {
|
||||
let json_str = serde_json::to_string(&config)
|
||||
.map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?;
|
||||
self.db
|
||||
.save_live_backup("gemini", &json_str)
|
||||
.await
|
||||
.map_err(|e| format!("备份 Gemini 配置失败: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("已备份所有应用的 Live 配置");
|
||||
@@ -1127,6 +1202,15 @@ impl ProxyService {
|
||||
_ => return Err("该应用不支持代理功能".to_string()),
|
||||
};
|
||||
|
||||
// 跳过已被代理接管的 Live:避免把代理占位符当作"原始 Live"存进备份槽
|
||||
// (见 backup_live_configs 中的注释)。
|
||||
if Self::live_has_proxy_placeholder_for_app(app_type, &config) {
|
||||
log::warn!(
|
||||
"{app_type_str} Live 已被代理接管,不备份(避免把代理配置固化进备份槽);下次 stop 会从 SSOT 重建 Live"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let json_str = serde_json::to_string(&config)
|
||||
.map_err(|e| format!("序列化 {app_type_str} 配置失败: {e}"))?;
|
||||
self.db
|
||||
@@ -1158,7 +1242,18 @@ impl ProxyService {
|
||||
connect_host
|
||||
};
|
||||
|
||||
let proxy_origin = format!("http://{}:{}", connect_host_for_url, config.listen_port);
|
||||
let mut listen_port = config.listen_port;
|
||||
if let Some(server) = self.server.read().await.as_ref() {
|
||||
let status = server.get_status().await;
|
||||
if status.running {
|
||||
listen_port = status.port;
|
||||
}
|
||||
}
|
||||
if listen_port == 0 {
|
||||
return Err("代理监听端口为 0,但代理服务器尚未运行,无法生成接管地址".to_string());
|
||||
}
|
||||
|
||||
let proxy_origin = format!("http://{}:{}", connect_host_for_url, listen_port);
|
||||
let proxy_url = proxy_origin.clone();
|
||||
let proxy_codex_base_url = format!("{}/v1", proxy_origin.trim_end_matches('/'));
|
||||
|
||||
@@ -1462,9 +1557,19 @@ impl ProxyService {
|
||||
if let Some(backup) = backup {
|
||||
let config: Value = serde_json::from_str(&backup.original_config)
|
||||
.map_err(|e| format!("解析 {app_type_str} 备份失败: {e}"))?;
|
||||
self.write_live_config_for_app(app_type, &config)?;
|
||||
log::info!("{app_type_str} Live 配置已从备份恢复");
|
||||
return Ok(());
|
||||
|
||||
// 备份若是代理占位符(异常历史:上次 stop 失败导致 Live 留在了代理状态,
|
||||
// 下次接管时又被错误地备份成"原始 Live"),不能直接用 — 否则 stop 后
|
||||
// Live 永远卡在 127.0.0.1:15721。落到下面的 SSOT 兜底重建。
|
||||
if Self::live_has_proxy_placeholder_for_app(app_type, &config) {
|
||||
log::warn!(
|
||||
"{app_type_str} 备份本身已是代理占位符(异常历史状态),跳过备份,改走 SSOT 重建 Live"
|
||||
);
|
||||
} else {
|
||||
self.write_live_config_for_app(app_type, &config)?;
|
||||
log::info!("{app_type_str} Live 配置已从备份恢复");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)
|
||||
@@ -1527,7 +1632,7 @@ impl ProxyService {
|
||||
///
|
||||
/// 返回值:
|
||||
/// - Ok(true):已成功写回
|
||||
/// - Ok(false):缺少当前供应商/供应商不存在,无法写回
|
||||
/// - Ok(false):缺少当前供应商/供应商不存在/供应商本身含占位符,无法写回
|
||||
fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {
|
||||
let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)
|
||||
.map_err(|e| format!("获取 {app_type:?} 当前供应商失败: {e}"))?;
|
||||
@@ -1545,6 +1650,16 @@ impl ProxyService {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
// 供应商配置本身含接管占位符时不可写回(历史异常:接管期间 Live 被
|
||||
// 误导入成了供应商)。写回只会把占位符固化进 Live;返回 Ok(false)
|
||||
// 让调用方落到"清理占位符"兜底。
|
||||
if Self::live_has_proxy_placeholder_for_app(app_type, &provider.settings_config) {
|
||||
log::warn!(
|
||||
"{app_type:?} 当前供应商配置含代理接管占位符(疑似接管期间被导入的残留),跳过 SSOT 写回,改走占位符清理"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
write_live_with_common_config(self.db.as_ref(), app_type, provider)
|
||||
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
|
||||
|
||||
@@ -1845,6 +1960,21 @@ impl ProxyService {
|
||||
env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)
|
||||
}
|
||||
|
||||
/// 判断给定的 Live/备份配置是否已被代理接管(包含占位符)
|
||||
///
|
||||
/// 用途:检测"备份里存的其实是代理配置"这种异常历史状态。
|
||||
/// 如果发现,备份不可信,备份路径不能写入(否则会把代理配置固化进备份槽),
|
||||
/// 恢复路径不能读取(否则会把代理占位符原样写回 Live,永久卡在代理地址)。
|
||||
/// 两种情况下都应该走 SSOT 兜底重建 Live。
|
||||
fn live_has_proxy_placeholder_for_app(app_type: &AppType, config: &Value) -> bool {
|
||||
match app_type {
|
||||
AppType::Claude => Self::is_claude_live_taken_over(config),
|
||||
AppType::Codex => Self::codex_live_has_proxy_placeholder(config),
|
||||
AppType::Gemini => Self::is_gemini_live_taken_over(config),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从供应商配置更新 Live 备份(用于代理模式下的热切换)
|
||||
///
|
||||
/// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,
|
||||
@@ -2438,11 +2568,18 @@ impl ProxyService {
|
||||
}
|
||||
|
||||
let app_handle = self.app_handle.read().await.clone();
|
||||
let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);
|
||||
new_server
|
||||
let new_server = ProxyServer::new(new_config.clone(), self.db.clone(), app_handle);
|
||||
let info = new_server
|
||||
.start()
|
||||
.await
|
||||
.map_err(|e| format!("重启代理服务器失败: {e}"))?;
|
||||
if let Err(e) = self
|
||||
.persist_ephemeral_listen_port_if_needed(&new_config, info.port)
|
||||
.await
|
||||
{
|
||||
let _ = new_server.stop().await;
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
*server_guard = Some(new_server);
|
||||
log::info!("代理配置已更新,服务器已自动重启应用最新配置");
|
||||
@@ -2597,6 +2734,19 @@ mod tests {
|
||||
assert_eq!(env.get(key).and_then(|value| value.as_str()), expected);
|
||||
}
|
||||
|
||||
async fn use_ephemeral_proxy_port(db: &Arc<Database>) {
|
||||
let mut proxy_config = db.get_proxy_config().await.expect("get test proxy config");
|
||||
proxy_config.listen_port = 0;
|
||||
db.update_proxy_config(proxy_config)
|
||||
.await
|
||||
.expect("set test proxy config to an ephemeral port");
|
||||
}
|
||||
|
||||
async fn running_codex_base_url(service: &ProxyService) -> String {
|
||||
let status = service.get_status().await.expect("get proxy status");
|
||||
format!("http://127.0.0.1:{}/v1", status.port)
|
||||
}
|
||||
|
||||
fn seed_codex_model_template() {
|
||||
let codex_dir = crate::codex_config::get_codex_config_dir();
|
||||
std::fs::create_dir_all(&codex_dir).expect("create codex dir");
|
||||
@@ -2825,6 +2975,72 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn start_with_takeover_ephemeral_port_writes_actual_live_url() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
use_ephemeral_proxy_port(&db).await;
|
||||
let service = ProxyService::new(db.clone());
|
||||
|
||||
let provider = Provider::with_id(
|
||||
"p1".to_string(),
|
||||
"P1".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "provider-key",
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("claude", &provider)
|
||||
.expect("save provider");
|
||||
db.set_current_provider("claude", "p1")
|
||||
.expect("set db current provider");
|
||||
crate::settings::set_current_provider(&AppType::Claude, Some("p1"))
|
||||
.expect("set local current provider");
|
||||
service
|
||||
.write_claude_live(&json!({
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "live-key",
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||
}
|
||||
}))
|
||||
.expect("seed claude live config");
|
||||
|
||||
let info = service
|
||||
.start_with_takeover()
|
||||
.await
|
||||
.expect("start proxy with takeover");
|
||||
assert_ne!(info.port, 0, "OS should assign a concrete port");
|
||||
|
||||
let stored_config = db.get_proxy_config().await.expect("read proxy config");
|
||||
assert_eq!(
|
||||
stored_config.listen_port, info.port,
|
||||
"resolved dynamic port should be persisted for DB-only proxy URL paths"
|
||||
);
|
||||
|
||||
let live = service.read_claude_live().expect("read taken-over live");
|
||||
let base_url = live
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
||||
.and_then(|value| value.as_str())
|
||||
.expect("taken-over base url");
|
||||
assert_eq!(base_url, format!("http://127.0.0.1:{}", info.port));
|
||||
assert!(
|
||||
!base_url.contains(":0"),
|
||||
"takeover must never write an unresolved :0 port"
|
||||
);
|
||||
|
||||
service
|
||||
.stop_with_restore()
|
||||
.await
|
||||
.expect("stop proxy and restore live config");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn codex_custom_provider_live_write_preserves_oauth_auth_json() {
|
||||
@@ -3095,6 +3311,7 @@ wire_api = "responses"
|
||||
.expect("enable Codex official auth preservation");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
use_ephemeral_proxy_port(&db).await;
|
||||
let service = ProxyService::new(db.clone());
|
||||
let oauth_auth = json!({
|
||||
"auth_mode": "chatgpt",
|
||||
@@ -3174,6 +3391,7 @@ wire_api = "responses"
|
||||
.expect("enable Codex official auth preservation");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
use_ephemeral_proxy_port(&db).await;
|
||||
let state = crate::store::AppState::new(db.clone());
|
||||
let oauth_auth = json!({
|
||||
"auth_mode": "chatgpt",
|
||||
@@ -3394,6 +3612,7 @@ wire_api = "responses"
|
||||
.expect("enable Codex official auth preservation");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("init db"));
|
||||
use_ephemeral_proxy_port(&db).await;
|
||||
let service = ProxyService::new(db.clone());
|
||||
let oauth_auth = json!({
|
||||
"auth_mode": "chatgpt",
|
||||
@@ -3482,8 +3701,9 @@ wire_api = "responses"
|
||||
|
||||
let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())
|
||||
.expect("read live config");
|
||||
let expected_base_url = running_codex_base_url(&service).await;
|
||||
assert!(
|
||||
live_config.contains("http://127.0.0.1:15721/v1"),
|
||||
live_config.contains(&expected_base_url),
|
||||
"stale enabled takeover must be rebuilt to the current proxy base_url"
|
||||
);
|
||||
assert!(
|
||||
@@ -5361,7 +5581,7 @@ requires_openai_auth = true
|
||||
)
|
||||
.expect("seed generated catalog file");
|
||||
|
||||
let pointer = catalog_path.to_string_lossy().to_string();
|
||||
let pointer = catalog_path.to_string_lossy().replace('\\', "/");
|
||||
let backup_config = format!(
|
||||
"model_provider = \"custom\"\n\
|
||||
model = \"deepseek-v4-flash\"\n\
|
||||
@@ -5528,4 +5748,256 @@ requires_openai_auth = true
|
||||
"empty-auth restore must delete auth.json rather than write an empty one"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: when the backup row itself contains the proxy placeholder
|
||||
/// (a corrupted state where previous start/stop cycles saved the proxy
|
||||
/// config as the "original Live"), restore must NOT write it back to Live.
|
||||
/// It should fall through to the SSOT (current provider) path and rebuild
|
||||
/// Live from the provider DB instead.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn restore_falls_through_to_ssot_when_backup_is_proxy_placeholder() {
|
||||
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());
|
||||
|
||||
// Seed DB with a current provider that has a real API key
|
||||
let provider = Provider::with_id(
|
||||
"p1".to_string(),
|
||||
"P1".to_string(),
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.minimaxi.com/anthropic",
|
||||
"ANTHROPIC_API_KEY": "real-key-from-db"
|
||||
}
|
||||
}),
|
||||
None,
|
||||
);
|
||||
db.save_provider("claude", &provider)
|
||||
.expect("save provider");
|
||||
db.set_current_provider("claude", "p1")
|
||||
.expect("set current provider");
|
||||
|
||||
// Seed backup with proxy placeholder (the corrupted state)
|
||||
let corrupted_backup = serde_json::to_string(&json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER,
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
|
||||
}
|
||||
}))
|
||||
.expect("serialize corrupted backup");
|
||||
db.save_live_backup("claude", &corrupted_backup)
|
||||
.await
|
||||
.expect("seed corrupted backup");
|
||||
|
||||
// Seed Live with the same proxy placeholder (matches the corrupted state)
|
||||
service
|
||||
.write_claude_live(&json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER,
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
|
||||
}
|
||||
}))
|
||||
.expect("seed taken-over live file");
|
||||
|
||||
// Restore: must NOT use the corrupted backup
|
||||
service
|
||||
.restore_live_config_for_app_with_fallback(&AppType::Claude)
|
||||
.await
|
||||
.expect("restore should succeed via SSOT");
|
||||
|
||||
// The backup should still be the corrupted one (we didn't touch it on this path)
|
||||
let backup_after = db
|
||||
.get_live_backup("claude")
|
||||
.await
|
||||
.expect("get backup")
|
||||
.expect("backup still exists");
|
||||
assert_eq!(
|
||||
backup_after.original_config, corrupted_backup,
|
||||
"restore must NOT overwrite the corrupted backup"
|
||||
);
|
||||
|
||||
// Live should now reflect the SSOT (provider DB), NOT the proxy URL
|
||||
let restored_live = service.read_claude_live().expect("read live");
|
||||
let restored_url = restored_live
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
||||
.and_then(|v| v.as_str());
|
||||
assert_eq!(
|
||||
restored_url,
|
||||
Some("https://api.minimaxi.com/anthropic"),
|
||||
"Live must be rebuilt from SSOT, not from the corrupted backup"
|
||||
);
|
||||
let restored_key = restored_live
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||
.and_then(|v| v.as_str());
|
||||
assert_eq!(
|
||||
restored_key,
|
||||
Some("real-key-from-db"),
|
||||
"Live must carry the real API key from the provider DB"
|
||||
);
|
||||
assert_ne!(
|
||||
restored_live
|
||||
.get("env")
|
||||
.and_then(|env| env.get("ANTHROPIC_AUTH_TOKEN"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some(PROXY_TOKEN_PLACEHOLDER),
|
||||
"Live must not still carry the proxy placeholder"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: when Live is already a proxy placeholder (a corrupted state
|
||||
/// where previous stop failed to restore), backup must NOT overwrite a
|
||||
/// previously-good backup with the proxy config. This prevents the bug
|
||||
/// where stop-then-start cycles permanently corrupt the backup.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn backup_skips_when_live_is_already_proxy_placeholder() {
|
||||
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());
|
||||
|
||||
// Seed a GOOD backup (the "real" original Live)
|
||||
let good_backup = serde_json::to_string(&json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.minimaxi.com/anthropic",
|
||||
"ANTHROPIC_AUTH_TOKEN": "real-token"
|
||||
}
|
||||
}))
|
||||
.expect("serialize good backup");
|
||||
db.save_live_backup("claude", &good_backup)
|
||||
.await
|
||||
.expect("seed good backup");
|
||||
|
||||
// Seed Live with proxy placeholder (the corrupted state)
|
||||
service
|
||||
.write_claude_live(&json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER,
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
|
||||
}
|
||||
}))
|
||||
.expect("seed taken-over live file");
|
||||
|
||||
// Call backup_live_config_strict: must skip
|
||||
service
|
||||
.backup_live_config_strict(&AppType::Claude)
|
||||
.await
|
||||
.expect("backup should succeed (no-op when live is placeholder)");
|
||||
|
||||
// The good backup must still be intact
|
||||
let backup_after = db
|
||||
.get_live_backup("claude")
|
||||
.await
|
||||
.expect("get backup")
|
||||
.expect("backup still exists");
|
||||
assert_eq!(
|
||||
backup_after.original_config, good_backup,
|
||||
"must not overwrite a good backup with a proxy placeholder"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: when ALL apps have Live=proxy-placeholder (worst-case
|
||||
/// corrupted state), the bulk `backup_live_configs` path used by
|
||||
/// `start_with_takeover` must skip every save — instead of overwriting
|
||||
/// good backups with the proxy config.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn bulk_backup_skips_all_when_live_is_proxy_placeholder() {
|
||||
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());
|
||||
|
||||
// Seed good backups for all three apps
|
||||
let good_backup = serde_json::to_string(&json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "real-token"
|
||||
}
|
||||
}))
|
||||
.expect("serialize good backup");
|
||||
db.save_live_backup("claude", &good_backup)
|
||||
.await
|
||||
.expect("seed claude backup");
|
||||
|
||||
let codex_good_backup = serde_json::to_string(&json!({
|
||||
"auth": { "OPENAI_API_KEY": "real-codex-token" }
|
||||
}))
|
||||
.expect("serialize codex good backup");
|
||||
db.save_live_backup("codex", &codex_good_backup)
|
||||
.await
|
||||
.expect("seed codex backup");
|
||||
|
||||
let gemini_good_backup = serde_json::to_string(&json!({
|
||||
"env": { "GEMINI_API_KEY": "real-gemini-key" }
|
||||
}))
|
||||
.expect("serialize gemini good backup");
|
||||
db.save_live_backup("gemini", &gemini_good_backup)
|
||||
.await
|
||||
.expect("seed gemini backup");
|
||||
|
||||
// Seed all three Live files with proxy placeholders
|
||||
service
|
||||
.write_claude_live(&json!({
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": PROXY_TOKEN_PLACEHOLDER,
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721"
|
||||
}
|
||||
}))
|
||||
.expect("seed claude live");
|
||||
let codex_dir = crate::codex_config::get_codex_config_dir();
|
||||
std::fs::create_dir_all(&codex_dir).expect("create codex dir");
|
||||
std::fs::write(
|
||||
crate::codex_config::get_codex_config_path(),
|
||||
r#"model_provider = "custom"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "Custom"
|
||||
base_url = "http://127.0.0.1:15721/v1"
|
||||
wire_api = "chat"
|
||||
experimental_bearer_token = "PROXY_MANAGED"
|
||||
"#,
|
||||
)
|
||||
.expect("seed codex config.toml");
|
||||
std::fs::write(
|
||||
crate::codex_config::get_codex_auth_path(),
|
||||
r#"{"OPENAI_API_KEY":"PROXY_MANAGED"}"#,
|
||||
)
|
||||
.expect("seed codex auth.json");
|
||||
let gemini_env_path = crate::gemini_config::get_gemini_env_path();
|
||||
if let Some(parent) = gemini_env_path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("create gemini dir");
|
||||
}
|
||||
std::fs::write(&gemini_env_path, "GEMINI_API_KEY=PROXY_MANAGED\n")
|
||||
.expect("seed gemini env");
|
||||
|
||||
// Call bulk backup: must skip all three apps
|
||||
service
|
||||
.backup_live_configs()
|
||||
.await
|
||||
.expect("bulk backup should succeed (no-op when all live are placeholders)");
|
||||
|
||||
// All three good backups must still be intact
|
||||
for (app_type, original) in [
|
||||
("claude", good_backup.as_str()),
|
||||
("codex", codex_good_backup.as_str()),
|
||||
("gemini", gemini_good_backup.as_str()),
|
||||
] {
|
||||
let backup_after = db
|
||||
.get_live_backup(app_type)
|
||||
.await
|
||||
.expect("get backup")
|
||||
.expect("backup still exists");
|
||||
assert_eq!(
|
||||
backup_after.original_config, original,
|
||||
"must not overwrite good backup for {app_type} with proxy placeholder"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,926 @@
|
||||
//! S3 HTTP transport layer.
|
||||
//!
|
||||
//! Low-level HTTP primitives for S3 operations (PUT, GET, HEAD).
|
||||
//! Implements AWS Signature Version 4 request signing.
|
||||
//! The sync protocol logic lives in the upcoming `s3_sync` module.
|
||||
|
||||
use reqwest::StatusCode;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::proxy::http_client;
|
||||
use futures::StreamExt;
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
/// Timeout for large file transfers (PUT / GET of db.sql, skills.zip, etc.).
|
||||
const TRANSFER_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
// ─── Credentials ─────────────────────────────────────────────
|
||||
|
||||
/// S3-compatible storage credentials.
|
||||
pub(crate) struct S3Credentials {
|
||||
pub access_key_id: String,
|
||||
pub secret_access_key: String,
|
||||
pub region: String,
|
||||
pub bucket: String,
|
||||
/// Custom endpoint host (e.g. `minio.example.com:9000`).
|
||||
/// Empty string means AWS official endpoint.
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
// ─── URL construction ────────────────────────────────────────
|
||||
|
||||
/// Returns `true` for AWS official endpoints (empty or contains `amazonaws.com`).
|
||||
fn is_aws_endpoint(endpoint: &str) -> bool {
|
||||
endpoint.is_empty() || endpoint.contains("amazonaws.com")
|
||||
}
|
||||
|
||||
/// Split an endpoint into its scheme and host-with-port parts.
|
||||
///
|
||||
/// Preserves the original scheme when one is provided, defaulting to `"https"`
|
||||
/// when the endpoint is bare (no `://` prefix).
|
||||
///
|
||||
/// ```text
|
||||
/// "http://minio:9000" → ("http", "minio:9000")
|
||||
/// "https://storage.example.com" → ("https", "storage.example.com")
|
||||
/// "minio:9000" → ("https", "minio:9000")
|
||||
/// "storage.example.com" → ("https", "storage.example.com")
|
||||
/// ```
|
||||
fn split_scheme_host(endpoint: &str) -> (&str, &str) {
|
||||
if let Some(rest) = endpoint.strip_prefix("http://") {
|
||||
("http", rest.trim_end_matches('/'))
|
||||
} else if let Some(rest) = endpoint.strip_prefix("https://") {
|
||||
("https", rest.trim_end_matches('/'))
|
||||
} else {
|
||||
("https", endpoint.trim_end_matches('/'))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the full URL for an S3 object.
|
||||
///
|
||||
/// - AWS endpoints use virtual-hosted style: `https://{bucket}.s3.{region}.amazonaws.com/{key}`
|
||||
/// - Custom endpoints use path style: `https://{endpoint}/{bucket}/{key}`
|
||||
fn build_object_url(creds: &S3Credentials, key: &str) -> String {
|
||||
let key = key.trim_start_matches('/');
|
||||
if is_aws_endpoint(&creds.endpoint) {
|
||||
format!(
|
||||
"https://{}.s3.{}.amazonaws.com/{}",
|
||||
creds.bucket, creds.region, key
|
||||
)
|
||||
} else {
|
||||
let (scheme, host) = split_scheme_host(&creds.endpoint);
|
||||
format!("{}://{}/{}/{}", scheme, host, creds.bucket, key)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the bucket-level URL (for HEAD bucket / test connection).
|
||||
fn build_bucket_url(creds: &S3Credentials) -> String {
|
||||
if is_aws_endpoint(&creds.endpoint) {
|
||||
format!(
|
||||
"https://{}.s3.{}.amazonaws.com/",
|
||||
creds.bucket, creds.region
|
||||
)
|
||||
} else {
|
||||
let (scheme, host) = split_scheme_host(&creds.endpoint);
|
||||
format!("{}://{}/{}/", scheme, host, creds.bucket)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cryptographic helpers ───────────────────────────────────
|
||||
|
||||
fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
use hmac::{Hmac, Mac};
|
||||
type HmacSha256 = Hmac<sha2::Sha256>;
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
|
||||
mac.update(data);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn sha256_hex(data: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(data))
|
||||
}
|
||||
|
||||
/// Percent-encode following AWS Sig V4 rules (RFC 3986 unreserved characters only).
|
||||
fn uri_encode(input: &str, encode_slash: bool) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
for byte in input.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(byte as char);
|
||||
}
|
||||
b'/' if !encode_slash => out.push('/'),
|
||||
_ => {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(out, "%{:02X}", byte);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ─── AWS Signature V4 signing ────────────────────────────────
|
||||
|
||||
/// Sign an HTTP request using AWS Signature Version 4.
|
||||
///
|
||||
/// Mutates `headers` by adding `host`, `x-amz-date`, `x-amz-content-sha256`,
|
||||
/// and the final `authorization` header.
|
||||
fn sign_request(
|
||||
method: &str,
|
||||
url: &Url,
|
||||
headers: &mut reqwest::header::HeaderMap,
|
||||
body_hash: &str,
|
||||
creds: &S3Credentials,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
) {
|
||||
let timestamp = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let datestamp = now.format("%Y%m%d").to_string();
|
||||
|
||||
// ── Step 1: Add required headers ──
|
||||
let host_value = match url.port() {
|
||||
Some(port) => format!("{}:{}", url.host_str().unwrap_or_default(), port),
|
||||
None => url.host_str().unwrap_or_default().to_string(),
|
||||
};
|
||||
headers.insert("host", host_value.parse().unwrap());
|
||||
headers.insert("x-amz-date", timestamp.parse().unwrap());
|
||||
headers.insert("x-amz-content-sha256", body_hash.parse().unwrap());
|
||||
|
||||
// ── Step 2: Build canonical request ──
|
||||
|
||||
// Canonical URI (already percent-encoded by the url crate).
|
||||
let canonical_uri = url.path();
|
||||
let canonical_uri = if canonical_uri.is_empty() {
|
||||
"/"
|
||||
} else {
|
||||
canonical_uri
|
||||
};
|
||||
|
||||
// Canonical query string — sorted, re-encoded per Sig V4 rules.
|
||||
let mut query_pairs: Vec<(String, String)> = url
|
||||
.query_pairs()
|
||||
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||||
.collect();
|
||||
query_pairs.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
||||
let canonical_query = if query_pairs.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
query_pairs
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", uri_encode(k, true), uri_encode(v, true)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
};
|
||||
|
||||
// Canonical headers — sorted by lowercase name.
|
||||
let mut header_names: Vec<String> = headers.keys().map(|k| k.as_str().to_lowercase()).collect();
|
||||
header_names.sort();
|
||||
header_names.dedup();
|
||||
|
||||
let canonical_headers: String = header_names
|
||||
.iter()
|
||||
.map(|name| {
|
||||
let value = headers
|
||||
.get(name.as_str())
|
||||
.map(|v| v.to_str().unwrap_or("").trim().to_string())
|
||||
.unwrap_or_default();
|
||||
format!("{}:{}\n", name, value)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let signed_headers = header_names.join(";");
|
||||
|
||||
let canonical_request = format!(
|
||||
"{}\n{}\n{}\n{}\n{}\n{}",
|
||||
method, canonical_uri, canonical_query, canonical_headers, signed_headers, body_hash
|
||||
);
|
||||
|
||||
// ── Step 3: Build string to sign ──
|
||||
let scope = format!("{}/{}/s3/aws4_request", datestamp, creds.region);
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||
timestamp,
|
||||
scope,
|
||||
sha256_hex(canonical_request.as_bytes())
|
||||
);
|
||||
|
||||
// ── Step 4: Derive signing key ──
|
||||
// HMAC(HMAC(HMAC(HMAC("AWS4"+secret, date), region), "s3"), "aws4_request")
|
||||
let k_date = hmac_sha256(
|
||||
format!("AWS4{}", creds.secret_access_key).as_bytes(),
|
||||
datestamp.as_bytes(),
|
||||
);
|
||||
let k_region = hmac_sha256(&k_date, creds.region.as_bytes());
|
||||
let k_service = hmac_sha256(&k_region, b"s3");
|
||||
let k_signing = hmac_sha256(&k_service, b"aws4_request");
|
||||
|
||||
// ── Step 5: Compute signature ──
|
||||
let sig_bytes = hmac_sha256(&k_signing, string_to_sign.as_bytes());
|
||||
let signature: String = sig_bytes.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
|
||||
// ── Step 6: Add Authorization header ──
|
||||
let authorization = format!(
|
||||
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
|
||||
creds.access_key_id, scope, signed_headers, signature
|
||||
);
|
||||
headers.insert("authorization", authorization.parse().unwrap());
|
||||
}
|
||||
|
||||
// ─── Error helpers ───────────────────────────────────────────
|
||||
|
||||
/// Redact a URL for safe inclusion in error messages (strips query parameters).
|
||||
fn redact_url(raw: &str) -> String {
|
||||
match Url::parse(raw) {
|
||||
Ok(parsed) => {
|
||||
let mut out = format!("{}://", parsed.scheme());
|
||||
if let Some(host) = parsed.host_str() {
|
||||
out.push_str(host);
|
||||
}
|
||||
if let Some(port) = parsed.port() {
|
||||
out.push(':');
|
||||
out.push_str(&port.to_string());
|
||||
}
|
||||
out.push_str(parsed.path());
|
||||
out
|
||||
}
|
||||
Err(_) => raw.split('?').next().unwrap_or(raw).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn s3_transport_error(
|
||||
key: &'static str,
|
||||
op_zh: &str,
|
||||
op_en: &str,
|
||||
err: &reqwest::Error,
|
||||
) -> AppError {
|
||||
let (zh_reason, en_reason) = if err.is_timeout() {
|
||||
("请求超时", "request timed out")
|
||||
} else if err.is_connect() {
|
||||
("连接失败", "connection failed")
|
||||
} else if err.is_request() {
|
||||
("请求构造失败", "request build failed")
|
||||
} else {
|
||||
("网络请求失败", "network request failed")
|
||||
};
|
||||
|
||||
AppError::localized(
|
||||
key,
|
||||
format!("S3 {op_zh}失败({zh_reason})"),
|
||||
format!("S3 {op_en} failed ({en_reason})"),
|
||||
)
|
||||
}
|
||||
|
||||
fn s3_status_error(op: &str, status: StatusCode, url: &str) -> AppError {
|
||||
let safe_url = redact_url(url);
|
||||
let mut zh = format!("S3 {op} 失败: {status} ({safe_url})");
|
||||
let mut en = format!("S3 {op} failed: {status} ({safe_url})");
|
||||
|
||||
if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) {
|
||||
zh.push_str("。请检查 Access Key ID 和 Secret Access Key。");
|
||||
en.push_str(". Please verify your Access Key ID and Secret Access Key.");
|
||||
} else if status == StatusCode::NOT_FOUND && op == "HEAD bucket" {
|
||||
zh.push_str("。请检查存储桶名称和区域是否正确。");
|
||||
en.push_str(". Please check the bucket name and region.");
|
||||
}
|
||||
|
||||
AppError::localized("s3.http.status", zh, en)
|
||||
}
|
||||
|
||||
fn response_too_large_error(url: &str, max_bytes: usize) -> AppError {
|
||||
let max_mb = max_bytes / 1024 / 1024;
|
||||
AppError::localized(
|
||||
"s3.response_too_large",
|
||||
format!("S3 响应体超过上限({} MB): {}", max_mb, redact_url(url)),
|
||||
format!(
|
||||
"S3 response body exceeds limit ({} MB): {}",
|
||||
max_mb,
|
||||
redact_url(url)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn ensure_content_length_within_limit(
|
||||
headers: &reqwest::header::HeaderMap,
|
||||
max_bytes: usize,
|
||||
url: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let Some(cl) = headers.get(reqwest::header::CONTENT_LENGTH) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Ok(raw) = cl.to_str() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Ok(value) = raw.parse::<u64>() else {
|
||||
return Ok(());
|
||||
};
|
||||
if value > max_bytes as u64 {
|
||||
return Err(response_too_large_error(url, max_bytes));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Transport functions ─────────────────────────────────────
|
||||
|
||||
/// Test S3 connectivity by sending HEAD to the bucket root.
|
||||
pub(crate) async fn test_connection(creds: &S3Credentials) -> Result<(), AppError> {
|
||||
let url_str = build_bucket_url(creds);
|
||||
let url = Url::parse(&url_str).map_err(|e| {
|
||||
AppError::localized(
|
||||
"s3.url.invalid",
|
||||
format!("S3 URL 无效: {e}"),
|
||||
format!("Invalid S3 URL: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let client = http_client::get();
|
||||
let body_hash = sha256_hex(b"");
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
sign_request(
|
||||
"HEAD",
|
||||
&url,
|
||||
&mut headers,
|
||||
&body_hash,
|
||||
creds,
|
||||
chrono::Utc::now(),
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.head(url.as_str())
|
||||
.headers(headers)
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| s3_transport_error("s3.connection_failed", "连接", "connection", &e))?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(s3_status_error("HEAD bucket", resp.status(), &url_str))
|
||||
}
|
||||
|
||||
/// Upload bytes to an S3 object.
|
||||
pub(crate) async fn put_object(
|
||||
creds: &S3Credentials,
|
||||
key: &str,
|
||||
bytes: Vec<u8>,
|
||||
content_type: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let url_str = build_object_url(creds, key);
|
||||
let url = Url::parse(&url_str).map_err(|e| {
|
||||
AppError::localized(
|
||||
"s3.url.invalid",
|
||||
format!("S3 URL 无效: {e}"),
|
||||
format!("Invalid S3 URL: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let client = http_client::get();
|
||||
let body_hash = sha256_hex(&bytes);
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert("content-type", content_type.parse().unwrap());
|
||||
sign_request(
|
||||
"PUT",
|
||||
&url,
|
||||
&mut headers,
|
||||
&body_hash,
|
||||
creds,
|
||||
chrono::Utc::now(),
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.put(url.as_str())
|
||||
.headers(headers)
|
||||
.body(bytes)
|
||||
.timeout(Duration::from_secs(TRANSFER_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| s3_transport_error("s3.put_failed", "PUT 请求", "PUT request", &e))?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(s3_status_error("PUT", resp.status(), &url_str))
|
||||
}
|
||||
|
||||
/// Download an S3 object. Returns `None` if the object does not exist (404).
|
||||
///
|
||||
/// On success returns `(body_bytes, optional_etag)`.
|
||||
pub(crate) async fn get_object(
|
||||
creds: &S3Credentials,
|
||||
key: &str,
|
||||
max_bytes: usize,
|
||||
) -> Result<Option<(Vec<u8>, Option<String>)>, AppError> {
|
||||
let url_str = build_object_url(creds, key);
|
||||
let url = Url::parse(&url_str).map_err(|e| {
|
||||
AppError::localized(
|
||||
"s3.url.invalid",
|
||||
format!("S3 URL 无效: {e}"),
|
||||
format!("Invalid S3 URL: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let client = http_client::get();
|
||||
let body_hash = sha256_hex(b"");
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
sign_request(
|
||||
"GET",
|
||||
&url,
|
||||
&mut headers,
|
||||
&body_hash,
|
||||
creds,
|
||||
chrono::Utc::now(),
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.get(url.as_str())
|
||||
.headers(headers)
|
||||
.timeout(Duration::from_secs(TRANSFER_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| s3_transport_error("s3.get_failed", "GET 请求", "GET request", &e))?;
|
||||
|
||||
if resp.status() == StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
if !resp.status().is_success() {
|
||||
return Err(s3_status_error("GET", resp.status(), &url_str));
|
||||
}
|
||||
ensure_content_length_within_limit(resp.headers(), max_bytes, &url_str)?;
|
||||
|
||||
let etag = resp
|
||||
.headers()
|
||||
.get("etag")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let mut bytes = Vec::new();
|
||||
let mut stream = resp.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| {
|
||||
AppError::localized(
|
||||
"s3.response_read_failed",
|
||||
format!("读取 S3 响应失败: {e}"),
|
||||
format!("Failed to read S3 response: {e}"),
|
||||
)
|
||||
})?;
|
||||
if bytes.len().saturating_add(chunk.len()) > max_bytes {
|
||||
return Err(response_too_large_error(&url_str, max_bytes));
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(Some((bytes, etag)))
|
||||
}
|
||||
|
||||
/// Retrieve the ETag of an S3 object via HEAD. Returns `None` on 404.
|
||||
pub(crate) async fn head_object(
|
||||
creds: &S3Credentials,
|
||||
key: &str,
|
||||
) -> Result<Option<String>, AppError> {
|
||||
let url_str = build_object_url(creds, key);
|
||||
let url = Url::parse(&url_str).map_err(|e| {
|
||||
AppError::localized(
|
||||
"s3.url.invalid",
|
||||
format!("S3 URL 无效: {e}"),
|
||||
format!("Invalid S3 URL: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let client = http_client::get();
|
||||
let body_hash = sha256_hex(b"");
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
sign_request(
|
||||
"HEAD",
|
||||
&url,
|
||||
&mut headers,
|
||||
&body_hash,
|
||||
creds,
|
||||
chrono::Utc::now(),
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.head(url.as_str())
|
||||
.headers(headers)
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| s3_transport_error("s3.head_failed", "HEAD 请求", "HEAD request", &e))?;
|
||||
|
||||
if resp.status() == StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
if !resp.status().is_success() {
|
||||
return Err(s3_status_error("HEAD", resp.status(), &url_str));
|
||||
}
|
||||
Ok(resp
|
||||
.headers()
|
||||
.get("etag")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
// ── Crypto helpers ──
|
||||
|
||||
#[test]
|
||||
fn sha256_hex_empty_body() {
|
||||
assert_eq!(
|
||||
sha256_hex(b""),
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_hex_known_value() {
|
||||
// From the AWS S3 documentation PUT Object example body.
|
||||
assert_eq!(
|
||||
sha256_hex(b"Welcome to Amazon S3."),
|
||||
"44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_sha256_rfc2104_test_vector() {
|
||||
// HMAC-SHA256("key", "The quick brown fox jumps over the lazy dog")
|
||||
let result = hmac_sha256(b"key", b"The quick brown fox jumps over the lazy dog");
|
||||
let hex: String = result.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
assert_eq!(
|
||||
hex,
|
||||
"f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"
|
||||
);
|
||||
}
|
||||
|
||||
// ── URL construction: virtual-hosted vs path-style ──
|
||||
|
||||
#[test]
|
||||
fn build_object_url_virtual_hosted_style_aws() {
|
||||
let creds = test_creds("", "us-east-1", "mybucket");
|
||||
assert_eq!(
|
||||
build_object_url(&creds, "path/to/file.json"),
|
||||
"https://mybucket.s3.us-east-1.amazonaws.com/path/to/file.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_object_url_virtual_hosted_explicit_aws_endpoint() {
|
||||
let creds = test_creds(
|
||||
"s3.ap-northeast-1.amazonaws.com",
|
||||
"ap-northeast-1",
|
||||
"mybucket",
|
||||
);
|
||||
assert_eq!(
|
||||
build_object_url(&creds, "key.txt"),
|
||||
"https://mybucket.s3.ap-northeast-1.amazonaws.com/key.txt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_object_url_path_style_custom_endpoint() {
|
||||
let creds = test_creds("minio.example.com:9000", "us-east-1", "mybucket");
|
||||
assert_eq!(
|
||||
build_object_url(&creds, "path/to/file.json"),
|
||||
"https://minio.example.com:9000/mybucket/path/to/file.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_object_url_strips_leading_slash_from_key() {
|
||||
let creds = test_creds("", "us-east-1", "b");
|
||||
assert_eq!(
|
||||
build_object_url(&creds, "/leading/slash.txt"),
|
||||
"https://b.s3.us-east-1.amazonaws.com/leading/slash.txt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_bucket_url_aws() {
|
||||
let creds = test_creds("", "us-west-2", "testbucket");
|
||||
assert_eq!(
|
||||
build_bucket_url(&creds),
|
||||
"https://testbucket.s3.us-west-2.amazonaws.com/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_bucket_url_custom_endpoint() {
|
||||
let creds = test_creds("storage.example.com", "us-east-1", "data");
|
||||
assert_eq!(
|
||||
build_bucket_url(&creds),
|
||||
"https://storage.example.com/data/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_object_url_endpoint_with_trailing_slash() {
|
||||
let creds = test_creds("minio.local:9000/", "us-east-1", "b");
|
||||
assert_eq!(
|
||||
build_object_url(&creds, "k"),
|
||||
"https://minio.local:9000/b/k"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_object_url_endpoint_with_scheme_prefix() {
|
||||
let creds = test_creds("https://minio.local:9000", "us-east-1", "b");
|
||||
assert_eq!(
|
||||
build_object_url(&creds, "k"),
|
||||
"https://minio.local:9000/b/k"
|
||||
);
|
||||
}
|
||||
|
||||
// ── HTTP scheme preservation (MinIO support) ──
|
||||
|
||||
#[test]
|
||||
fn build_object_url_preserves_http_scheme() {
|
||||
let creds = test_creds("http://minio:9000", "us-east-1", "mybucket");
|
||||
let url = build_object_url(&creds, "path/to/file.json");
|
||||
assert!(
|
||||
url.starts_with("http://"),
|
||||
"expected http:// scheme, got: {url}"
|
||||
);
|
||||
assert_eq!(url, "http://minio:9000/mybucket/path/to/file.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_object_url_preserves_https_scheme() {
|
||||
let creds = test_creds("https://storage.example.com", "us-east-1", "mybucket");
|
||||
let url = build_object_url(&creds, "path/to/file.json");
|
||||
assert!(
|
||||
url.starts_with("https://"),
|
||||
"expected https:// scheme, got: {url}"
|
||||
);
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://storage.example.com/mybucket/path/to/file.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_object_url_bare_endpoint_defaults_to_https() {
|
||||
let creds = test_creds("minio:9000", "us-east-1", "mybucket");
|
||||
let url = build_object_url(&creds, "path/to/file.json");
|
||||
assert!(
|
||||
url.starts_with("https://"),
|
||||
"bare endpoint should default to https://, got: {url}"
|
||||
);
|
||||
assert_eq!(url, "https://minio:9000/mybucket/path/to/file.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_bucket_url_preserves_http_scheme() {
|
||||
let creds = test_creds("http://minio:9000", "us-east-1", "data");
|
||||
let url = build_bucket_url(&creds);
|
||||
assert!(
|
||||
url.starts_with("http://"),
|
||||
"expected http:// scheme, got: {url}"
|
||||
);
|
||||
assert_eq!(url, "http://minio:9000/data/");
|
||||
}
|
||||
|
||||
// ── Endpoint detection ──
|
||||
|
||||
#[test]
|
||||
fn is_aws_endpoint_detection() {
|
||||
assert!(is_aws_endpoint(""), "empty string = AWS");
|
||||
assert!(is_aws_endpoint("s3.us-east-1.amazonaws.com"));
|
||||
assert!(is_aws_endpoint("s3.amazonaws.com"));
|
||||
assert!(!is_aws_endpoint("minio.example.com"));
|
||||
assert!(!is_aws_endpoint("storage.googleapis.com"));
|
||||
assert!(!is_aws_endpoint("r2.cloudflarestorage.com"));
|
||||
}
|
||||
|
||||
// ── URI encoding ──
|
||||
|
||||
#[test]
|
||||
fn uri_encode_preserves_unreserved_chars() {
|
||||
assert_eq!(
|
||||
uri_encode("hello-world_test.txt~v2", true),
|
||||
"hello-world_test.txt~v2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uri_encode_encodes_spaces_and_special_chars() {
|
||||
assert_eq!(uri_encode("hello world", true), "hello%20world");
|
||||
assert_eq!(uri_encode("a+b=c&d", true), "a%2Bb%3Dc%26d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uri_encode_slash_handling() {
|
||||
assert_eq!(uri_encode("path/to/file", false), "path/to/file");
|
||||
assert_eq!(uri_encode("path/to/file", true), "path%2Fto%2Ffile");
|
||||
}
|
||||
|
||||
// ── AWS Signature V4 signing ──
|
||||
|
||||
#[test]
|
||||
fn sig_v4_signing_against_aws_test_vector() {
|
||||
// Based on the AWS documentation example: GET /?lifecycle
|
||||
// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
|
||||
let creds = S3Credentials {
|
||||
access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
|
||||
secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
|
||||
region: "us-east-1".to_string(),
|
||||
bucket: "examplebucket".to_string(),
|
||||
endpoint: String::new(),
|
||||
};
|
||||
|
||||
let now = chrono::Utc.with_ymd_and_hms(2013, 5, 24, 0, 0, 0).unwrap();
|
||||
let url = Url::parse("https://examplebucket.s3.amazonaws.com/?lifecycle").unwrap();
|
||||
let body_hash = sha256_hex(b"");
|
||||
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
sign_request("GET", &url, &mut headers, &body_hash, &creds, now);
|
||||
|
||||
let auth = headers.get("authorization").unwrap().to_str().unwrap();
|
||||
|
||||
// Verify credential scope
|
||||
assert!(
|
||||
auth.starts_with(
|
||||
"AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request"
|
||||
),
|
||||
"unexpected credential scope in: {auth}"
|
||||
);
|
||||
// Verify signed headers
|
||||
assert!(
|
||||
auth.contains("SignedHeaders=host;x-amz-content-sha256;x-amz-date"),
|
||||
"unexpected signed headers in: {auth}"
|
||||
);
|
||||
// Verify exact signature from AWS documentation
|
||||
assert!(
|
||||
auth.contains(
|
||||
"Signature=fea454ca298b7da1c68078a5d1bdbfbbe0d65c699e0f91ac7a200a0136783543"
|
||||
),
|
||||
"signature mismatch in: {auth}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sig_v4_includes_content_type_when_present() {
|
||||
let creds = S3Credentials {
|
||||
access_key_id: "TESTKEY".to_string(),
|
||||
secret_access_key: "TESTSECRET".to_string(),
|
||||
region: "us-east-1".to_string(),
|
||||
bucket: "b".to_string(),
|
||||
endpoint: String::new(),
|
||||
};
|
||||
|
||||
let now = chrono::Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
|
||||
let url = Url::parse("https://b.s3.us-east-1.amazonaws.com/key.json").unwrap();
|
||||
let body_hash = sha256_hex(b"{}");
|
||||
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert("content-type", "application/json".parse().unwrap());
|
||||
sign_request("PUT", &url, &mut headers, &body_hash, &creds, now);
|
||||
|
||||
let auth = headers.get("authorization").unwrap().to_str().unwrap();
|
||||
// content-type must appear in the signed headers
|
||||
assert!(
|
||||
auth.contains("content-type"),
|
||||
"content-type should be in signed headers: {auth}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sig_v4_signing_key_derivation() {
|
||||
// Verify the signing key derivation chain independently.
|
||||
// Using the same AWS example credentials and date.
|
||||
let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
|
||||
let datestamp = "20130524";
|
||||
let region = "us-east-1";
|
||||
|
||||
let k_date = hmac_sha256(format!("AWS4{}", secret).as_bytes(), datestamp.as_bytes());
|
||||
let k_region = hmac_sha256(&k_date, region.as_bytes());
|
||||
let k_service = hmac_sha256(&k_region, b"s3");
|
||||
let k_signing = hmac_sha256(&k_service, b"aws4_request");
|
||||
|
||||
// The signing key should be a 32-byte value (256 bits).
|
||||
assert_eq!(k_signing.len(), 32);
|
||||
|
||||
// Verify it is deterministic — computing again yields the same result.
|
||||
let k_date2 = hmac_sha256(format!("AWS4{}", secret).as_bytes(), datestamp.as_bytes());
|
||||
let k_region2 = hmac_sha256(&k_date2, region.as_bytes());
|
||||
let k_service2 = hmac_sha256(&k_region2, b"s3");
|
||||
let k_signing2 = hmac_sha256(&k_service2, b"aws4_request");
|
||||
assert_eq!(k_signing, k_signing2);
|
||||
}
|
||||
|
||||
// ── Redact URL ──
|
||||
|
||||
#[test]
|
||||
fn redact_url_strips_query_params() {
|
||||
let r = redact_url(
|
||||
"https://mybucket.s3.us-east-1.amazonaws.com/file.txt?X-Amz-Credential=AKID&X-Amz-Signature=abc",
|
||||
);
|
||||
assert!(!r.contains("AKID"));
|
||||
assert!(!r.contains("abc"));
|
||||
assert!(r.contains("mybucket.s3.us-east-1.amazonaws.com"));
|
||||
assert!(r.contains("/file.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_url_preserves_path() {
|
||||
let r = redact_url("https://minio.local:9000/bucket/path/to/file.json");
|
||||
assert_eq!(r, "https://minio.local:9000/bucket/path/to/file.json");
|
||||
}
|
||||
|
||||
// ── Content-length check ──
|
||||
|
||||
#[test]
|
||||
fn ensure_content_length_within_limit_rejects_oversized() {
|
||||
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_LENGTH};
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(CONTENT_LENGTH, HeaderValue::from_static("2048"));
|
||||
assert!(ensure_content_length_within_limit(&h, 1024, "https://example.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_content_length_within_limit_accepts_within_bounds() {
|
||||
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_LENGTH};
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(CONTENT_LENGTH, HeaderValue::from_static("512"));
|
||||
assert!(ensure_content_length_within_limit(&h, 1024, "https://example.com").is_ok());
|
||||
|
||||
let empty = HeaderMap::new();
|
||||
assert!(ensure_content_length_within_limit(&empty, 1024, "https://example.com").is_ok());
|
||||
}
|
||||
|
||||
// ── Helper ──
|
||||
|
||||
fn test_creds(endpoint: &str, region: &str, bucket: &str) -> S3Credentials {
|
||||
S3Credentials {
|
||||
access_key_id: String::new(),
|
||||
secret_access_key: String::new(),
|
||||
region: region.to_string(),
|
||||
bucket: bucket.to_string(),
|
||||
endpoint: endpoint.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Live integration tests (run with --ignored) ─────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod integration_tests {
|
||||
use super::*;
|
||||
|
||||
fn test_creds() -> S3Credentials {
|
||||
S3Credentials {
|
||||
access_key_id: std::env::var("S3_TEST_AK").expect("S3_TEST_AK env required"),
|
||||
secret_access_key: std::env::var("S3_TEST_SK").expect("S3_TEST_SK env required"),
|
||||
region: std::env::var("S3_TEST_REGION").unwrap_or_else(|_| "us-east-1".to_string()),
|
||||
bucket: std::env::var("S3_TEST_BUCKET").expect("S3_TEST_BUCKET env required"),
|
||||
endpoint: std::env::var("S3_TEST_ENDPOINT").unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn live_s3_connection() {
|
||||
crate::proxy::http_client::init(None).ok();
|
||||
let creds = test_creds();
|
||||
let result = test_connection(&creds).await;
|
||||
assert!(result.is_ok(), "Connection failed: {:?}", result.err());
|
||||
println!("PASS: test_connection OK");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn live_s3_put_get_head_roundtrip() {
|
||||
crate::proxy::http_client::init(None).ok();
|
||||
let creds = test_creds();
|
||||
let key = "cc-switch-sync/v2/default/_integration_test.json";
|
||||
let data = br#"{"test":true,"ts":12345}"#;
|
||||
|
||||
// PUT
|
||||
let r = put_object(&creds, key, data.to_vec(), "application/json").await;
|
||||
assert!(r.is_ok(), "PUT failed: {:?}", r.err());
|
||||
println!("PASS: put_object {} bytes", data.len());
|
||||
|
||||
// GET
|
||||
let r = get_object(&creds, key, 1 << 20).await;
|
||||
assert!(r.is_ok(), "GET failed: {:?}", r.err());
|
||||
let (body, etag) = r.unwrap().expect("should exist");
|
||||
assert_eq!(body, data);
|
||||
println!("PASS: get_object {} bytes, etag={:?}", body.len(), etag);
|
||||
|
||||
// HEAD
|
||||
let r = head_object(&creds, key).await;
|
||||
assert!(r.is_ok(), "HEAD failed: {:?}", r.err());
|
||||
assert!(r.unwrap().is_some());
|
||||
println!("PASS: head_object OK");
|
||||
|
||||
// 404
|
||||
let r = get_object(&creds, "cc-switch-sync/_no_such_key", 1024).await;
|
||||
assert!(r.is_ok());
|
||||
assert!(r.unwrap().is_none());
|
||||
println!("PASS: get_object(404) returned None");
|
||||
|
||||
println!("ALL LIVE S3 TESTS PASSED");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::json;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::mpsc::error::TrySendError;
|
||||
use tokio::sync::mpsc::{channel, Receiver, Sender};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::services::s3_sync;
|
||||
use crate::settings::{self, S3SyncSettings};
|
||||
|
||||
const AUTO_SYNC_DEBOUNCE_MS: u64 = 1000;
|
||||
pub(crate) const MAX_AUTO_SYNC_WAIT_MS: u64 = 10_000;
|
||||
|
||||
static DB_CHANGE_TX: OnceLock<Sender<String>> = OnceLock::new();
|
||||
static AUTO_SYNC_SUPPRESS_DEPTH: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
pub(crate) struct AutoSyncSuppressionGuard;
|
||||
|
||||
impl AutoSyncSuppressionGuard {
|
||||
pub fn new() -> Self {
|
||||
AUTO_SYNC_SUPPRESS_DEPTH.fetch_add(1, Ordering::SeqCst);
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AutoSyncSuppressionGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ =
|
||||
AUTO_SYNC_SUPPRESS_DEPTH.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |value| {
|
||||
Some(value.saturating_sub(1))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_auto_sync_suppressed() -> bool {
|
||||
AUTO_SYNC_SUPPRESS_DEPTH.load(Ordering::SeqCst) > 0
|
||||
}
|
||||
|
||||
pub fn should_trigger_for_table(table: &str) -> bool {
|
||||
let normalized = table.trim().to_ascii_lowercase();
|
||||
matches!(
|
||||
normalized.as_str(),
|
||||
"providers"
|
||||
| "provider_endpoints"
|
||||
| "mcp_servers"
|
||||
| "prompts"
|
||||
| "skills"
|
||||
| "skill_repos"
|
||||
| "settings"
|
||||
| "proxy_config"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn enqueue_change_signal(tx: &Sender<String>, table: &str) -> bool {
|
||||
match tx.try_send(table.to_string()) {
|
||||
Ok(()) => true,
|
||||
Err(TrySendError::Full(_)) | Err(TrySendError::Closed(_)) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn auto_sync_wait_duration(started_at: Instant, now: Instant) -> Option<Duration> {
|
||||
let max_wait = Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS);
|
||||
let debounce = Duration::from_millis(AUTO_SYNC_DEBOUNCE_MS);
|
||||
let elapsed = now.saturating_duration_since(started_at);
|
||||
if elapsed >= max_wait {
|
||||
return None;
|
||||
}
|
||||
Some(debounce.min(max_wait - elapsed))
|
||||
}
|
||||
|
||||
fn should_run_auto_sync(settings: Option<&S3SyncSettings>) -> bool {
|
||||
let Some(sync) = settings else {
|
||||
return false;
|
||||
};
|
||||
sync.enabled && sync.auto_sync
|
||||
}
|
||||
|
||||
fn persist_auto_sync_error(settings: &mut S3SyncSettings, error: &AppError) {
|
||||
settings.status.last_error = Some(error.to_string());
|
||||
settings.status.last_error_source = Some("auto".to_string());
|
||||
let _ = settings::update_s3_sync_status(settings.status.clone());
|
||||
}
|
||||
|
||||
fn emit_auto_sync_status_updated(app: &AppHandle, status: &str, error: Option<&str>) {
|
||||
let payload = match error {
|
||||
Some(message) => json!({
|
||||
"source": "auto",
|
||||
"status": status,
|
||||
"error": message,
|
||||
}),
|
||||
None => json!({
|
||||
"source": "auto",
|
||||
"status": status,
|
||||
}),
|
||||
};
|
||||
|
||||
if let Err(err) = app.emit("s3-sync-status-updated", payload) {
|
||||
log::debug!("[S3] failed to emit sync status update event: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_auto_sync_upload(
|
||||
db: &crate::database::Database,
|
||||
app: &AppHandle,
|
||||
) -> Result<(), AppError> {
|
||||
let mut settings = settings::get_s3_sync_settings();
|
||||
if !should_run_auto_sync(settings.as_ref()) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut sync_settings = match settings.take() {
|
||||
Some(value) => value,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let result = s3_sync::run_with_sync_lock(s3_sync::upload(db, &mut sync_settings)).await;
|
||||
match result {
|
||||
Ok(_) => {
|
||||
emit_auto_sync_status_updated(app, "success", None);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
persist_auto_sync_error(&mut sync_settings, &err);
|
||||
emit_auto_sync_status_updated(app, "error", Some(&err.to_string()));
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notify_db_changed(table: &str) {
|
||||
if is_auto_sync_suppressed() {
|
||||
return;
|
||||
}
|
||||
if !should_trigger_for_table(table) {
|
||||
return;
|
||||
}
|
||||
let Some(tx) = DB_CHANGE_TX.get() else {
|
||||
return;
|
||||
};
|
||||
let _ = enqueue_change_signal(tx, table);
|
||||
}
|
||||
|
||||
pub fn start_worker(db: Arc<crate::database::Database>, app: tauri::AppHandle) {
|
||||
if DB_CHANGE_TX.get().is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Buffer size 1 is enough: we only need "dirty" signals, not every event.
|
||||
let (tx, rx) = channel::<String>(1);
|
||||
if DB_CHANGE_TX.set(tx).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_worker_loop(db, rx, app).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_worker_loop(
|
||||
db: Arc<crate::database::Database>,
|
||||
mut rx: Receiver<String>,
|
||||
app: tauri::AppHandle,
|
||||
) {
|
||||
while let Some(first_table) = rx.recv().await {
|
||||
let started_at = Instant::now();
|
||||
let mut merged_count = 1usize;
|
||||
|
||||
while let Some(wait_for) = auto_sync_wait_duration(started_at, Instant::now()) {
|
||||
let timeout = tokio::time::timeout(wait_for, rx.recv()).await;
|
||||
|
||||
match timeout {
|
||||
Ok(Some(_)) => merged_count += 1,
|
||||
Ok(None) => return,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"[S3][AutoSync] Triggered by table={first_table}, merged_changes={merged_count}"
|
||||
);
|
||||
|
||||
if let Err(err) = run_auto_sync_upload(&db, &app).await {
|
||||
log::warn!("[S3][AutoSync] Upload failed: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
auto_sync_wait_duration, enqueue_change_signal, is_auto_sync_suppressed,
|
||||
should_run_auto_sync, should_trigger_for_table, AutoSyncSuppressionGuard,
|
||||
MAX_AUTO_SYNC_WAIT_MS,
|
||||
};
|
||||
use crate::settings::S3SyncSettings;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc::channel;
|
||||
|
||||
#[test]
|
||||
fn should_trigger_sync_for_config_tables_only() {
|
||||
assert!(should_trigger_for_table("providers"));
|
||||
assert!(should_trigger_for_table("settings"));
|
||||
assert!(!should_trigger_for_table("proxy_request_logs"));
|
||||
assert!(!should_trigger_for_table("provider_health"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppression_guard_enables_and_restores_state() {
|
||||
assert!(!is_auto_sync_suppressed());
|
||||
{
|
||||
let _guard = AutoSyncSuppressionGuard::new();
|
||||
assert!(is_auto_sync_suppressed());
|
||||
}
|
||||
assert!(!is_auto_sync_suppressed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_wait_caps_flush_latency_for_continuous_events() {
|
||||
let started = Instant::now();
|
||||
let later = started + Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS + 1);
|
||||
assert!(auto_sync_wait_duration(started, later).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enqueue_change_signal_drops_when_channel_is_full() {
|
||||
let (tx, _rx) = channel::<String>(1);
|
||||
assert!(enqueue_change_signal(&tx, "providers"));
|
||||
assert!(!enqueue_change_signal(&tx, "providers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_run_auto_sync_requires_enabled_and_auto_sync_flag() {
|
||||
assert!(!should_run_auto_sync(None));
|
||||
|
||||
let disabled = S3SyncSettings {
|
||||
enabled: false,
|
||||
auto_sync: true,
|
||||
..S3SyncSettings::default()
|
||||
};
|
||||
assert!(!should_run_auto_sync(Some(&disabled)));
|
||||
|
||||
let auto_sync_off = S3SyncSettings {
|
||||
enabled: true,
|
||||
auto_sync: false,
|
||||
..S3SyncSettings::default()
|
||||
};
|
||||
assert!(!should_run_auto_sync(Some(&auto_sync_off)));
|
||||
|
||||
let enabled = S3SyncSettings {
|
||||
enabled: true,
|
||||
auto_sync: true,
|
||||
..S3SyncSettings::default()
|
||||
};
|
||||
assert!(should_run_auto_sync(Some(&enabled)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_layer_does_not_depend_on_commands_layer() {
|
||||
let source = include_str!("s3_auto_sync.rs");
|
||||
let needle = ["crate", "commands", ""].join("::");
|
||||
assert!(
|
||||
!source.contains(&needle),
|
||||
"services layer should not depend on commands layer"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
//! S3 v2 sync protocol layer.
|
||||
//!
|
||||
//! Implements manifest-based synchronization on top of the S3 transport
|
||||
//! primitives in [`super::s3`]. Artifact set: `db.sql` + `skills.zip`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::future::Future;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use chrono::Utc;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::services::s3::{self, S3Credentials};
|
||||
use crate::settings::{update_s3_sync_status, S3SyncSettings, WebDavSyncStatus};
|
||||
|
||||
use super::sync_protocol::{
|
||||
apply_snapshot, build_local_snapshot, localized, persist_sync_success_best_effort, sha256_hex,
|
||||
validate_artifact_size_limit, validate_manifest_compat, verify_artifact, ArtifactMeta,
|
||||
RemoteLayout, SyncManifest, DB_COMPAT_VERSION, MAX_MANIFEST_BYTES, MAX_SYNC_ARTIFACT_BYTES,
|
||||
PROTOCOL_VERSION, REMOTE_DB_SQL, REMOTE_MANIFEST, REMOTE_SKILLS_ZIP,
|
||||
};
|
||||
|
||||
// ─── Sync lock ───────────────────────────────────────────────
|
||||
|
||||
pub fn sync_mutex() -> &'static tokio::sync::Mutex<()> {
|
||||
static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
|
||||
}
|
||||
|
||||
pub async fn run_with_sync_lock<T, Fut>(operation: Fut) -> Result<T, AppError>
|
||||
where
|
||||
Fut: Future<Output = Result<T, AppError>>,
|
||||
{
|
||||
let _guard = sync_mutex().lock().await;
|
||||
operation.await
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────
|
||||
|
||||
/// Check S3 connectivity by issuing a HEAD request against the bucket.
|
||||
pub async fn check_connection(settings: &S3SyncSettings) -> Result<(), AppError> {
|
||||
settings.validate()?;
|
||||
let creds = creds_for(settings);
|
||||
s3::test_connection(&creds).await
|
||||
}
|
||||
|
||||
/// Upload local snapshot (db + skills) to remote S3.
|
||||
pub async fn upload(
|
||||
db: &crate::database::Database,
|
||||
settings: &mut S3SyncSettings,
|
||||
) -> Result<Value, AppError> {
|
||||
settings.validate()?;
|
||||
let creds = creds_for(settings);
|
||||
|
||||
let snapshot = build_local_snapshot(db)?;
|
||||
|
||||
// Upload order: artifacts first, manifest last (best-effort consistency)
|
||||
let db_key = s3_key(settings, REMOTE_DB_SQL);
|
||||
s3::put_object(&creds, &db_key, snapshot.db_sql, "application/sql").await?;
|
||||
|
||||
let skills_key = s3_key(settings, REMOTE_SKILLS_ZIP);
|
||||
s3::put_object(&creds, &skills_key, snapshot.skills_zip, "application/zip").await?;
|
||||
|
||||
let manifest_key = s3_key(settings, REMOTE_MANIFEST);
|
||||
s3::put_object(
|
||||
&creds,
|
||||
&manifest_key,
|
||||
snapshot.manifest_bytes,
|
||||
"application/json",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Fetch etag (best-effort, don't fail the upload)
|
||||
let etag = match s3::head_object(&creds, &manifest_key).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
log::debug!("[S3] Failed to fetch ETag after upload: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let _persisted = persist_sync_success_best_effort(
|
||||
settings,
|
||||
snapshot.manifest_hash,
|
||||
etag,
|
||||
persist_sync_success,
|
||||
);
|
||||
Ok(serde_json::json!({ "status": "uploaded" }))
|
||||
}
|
||||
|
||||
/// Download remote snapshot and apply to local database + skills.
|
||||
pub async fn download(
|
||||
db: &crate::database::Database,
|
||||
settings: &mut S3SyncSettings,
|
||||
) -> Result<Value, AppError> {
|
||||
settings.validate()?;
|
||||
let creds = creds_for(settings);
|
||||
|
||||
let manifest_key = s3_key(settings, REMOTE_MANIFEST);
|
||||
let (manifest_bytes, etag) = s3::get_object(&creds, &manifest_key, MAX_MANIFEST_BYTES)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
localized(
|
||||
"s3.sync.remote_empty",
|
||||
"远端没有可下载的同步数据",
|
||||
"No downloadable sync data found on the remote.",
|
||||
)
|
||||
})?;
|
||||
|
||||
let manifest: SyncManifest =
|
||||
serde_json::from_slice(&manifest_bytes).map_err(|e| AppError::Json {
|
||||
path: REMOTE_MANIFEST.to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
validate_manifest_compat(&manifest, RemoteLayout::Current)?;
|
||||
|
||||
// Download and verify artifacts
|
||||
let db_sql = download_and_verify(settings, &creds, REMOTE_DB_SQL, &manifest.artifacts).await?;
|
||||
let skills_zip =
|
||||
download_and_verify(settings, &creds, REMOTE_SKILLS_ZIP, &manifest.artifacts).await?;
|
||||
|
||||
// Apply snapshot
|
||||
apply_snapshot(db, &db_sql, &skills_zip)?;
|
||||
|
||||
let manifest_hash = sha256_hex(&manifest_bytes);
|
||||
let _persisted =
|
||||
persist_sync_success_best_effort(settings, manifest_hash, etag, persist_sync_success);
|
||||
Ok(serde_json::json!({ "status": "downloaded" }))
|
||||
}
|
||||
|
||||
/// Fetch remote manifest info without downloading artifacts.
|
||||
pub async fn fetch_remote_info(settings: &S3SyncSettings) -> Result<Option<Value>, AppError> {
|
||||
settings.validate()?;
|
||||
let creds = creds_for(settings);
|
||||
let manifest_key = s3_key(settings, REMOTE_MANIFEST);
|
||||
|
||||
let Some((bytes, _)) = s3::get_object(&creds, &manifest_key, MAX_MANIFEST_BYTES).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let manifest: SyncManifest = serde_json::from_slice(&bytes).map_err(|e| AppError::Json {
|
||||
path: REMOTE_MANIFEST.to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let compatible = validate_manifest_compat(&manifest, RemoteLayout::Current).is_ok();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"deviceName": manifest.device_name,
|
||||
"createdAt": manifest.created_at,
|
||||
"snapshotId": manifest.snapshot_id,
|
||||
"version": manifest.version,
|
||||
"protocolVersion": manifest.version,
|
||||
"dbCompatVersion": manifest.db_compat_version,
|
||||
"compatible": compatible,
|
||||
"artifacts": manifest.artifacts.keys().collect::<Vec<_>>(),
|
||||
"layout": RemoteLayout::Current.as_str(),
|
||||
"remotePath": s3_dir_display(settings),
|
||||
});
|
||||
|
||||
Ok(Some(payload))
|
||||
}
|
||||
|
||||
// ─── Sync status persistence ─────────────────────────────────
|
||||
|
||||
fn persist_sync_success(
|
||||
settings: &mut S3SyncSettings,
|
||||
manifest_hash: String,
|
||||
etag: Option<String>,
|
||||
) -> Result<(), AppError> {
|
||||
let status = WebDavSyncStatus {
|
||||
last_sync_at: Some(Utc::now().timestamp()),
|
||||
last_error: None,
|
||||
last_error_source: None,
|
||||
last_local_manifest_hash: Some(manifest_hash.clone()),
|
||||
last_remote_manifest_hash: Some(manifest_hash),
|
||||
last_remote_etag: etag,
|
||||
};
|
||||
settings.status = status.clone();
|
||||
update_s3_sync_status(status)
|
||||
}
|
||||
|
||||
// ─── Download & verify ───────────────────────────────────────
|
||||
|
||||
async fn download_and_verify(
|
||||
settings: &S3SyncSettings,
|
||||
creds: &S3Credentials,
|
||||
artifact_name: &str,
|
||||
artifacts: &BTreeMap<String, ArtifactMeta>,
|
||||
) -> Result<Vec<u8>, AppError> {
|
||||
let meta = artifacts.get(artifact_name).ok_or_else(|| {
|
||||
localized(
|
||||
"s3.sync.manifest_missing_artifact",
|
||||
format!("manifest 中缺少 artifact: {artifact_name}"),
|
||||
format!("Manifest missing artifact: {artifact_name}"),
|
||||
)
|
||||
})?;
|
||||
validate_artifact_size_limit(artifact_name, meta.size)?;
|
||||
|
||||
let key = s3_key(settings, artifact_name);
|
||||
let (bytes, _) = s3::get_object(creds, &key, MAX_SYNC_ARTIFACT_BYTES as usize)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
localized(
|
||||
"s3.sync.remote_missing_artifact",
|
||||
format!("远端缺少 artifact 文件: {artifact_name}"),
|
||||
format!("Remote artifact file missing: {artifact_name}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
verify_artifact(&bytes, artifact_name, meta)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
// ─── S3 key helpers ──────────────────────────────────────────
|
||||
|
||||
/// Build the S3 object key for a given artifact.
|
||||
///
|
||||
/// Format: `{remote_root}/v{PROTOCOL_VERSION}/db-v{DB_COMPAT_VERSION}/{profile}/{artifact}`
|
||||
/// Example: `cc-switch-sync/v2/db-v6/default/manifest.json`
|
||||
fn s3_key(settings: &S3SyncSettings, artifact: &str) -> String {
|
||||
format!(
|
||||
"{}/v{}/db-v{}/{}/{}",
|
||||
settings.remote_root, PROTOCOL_VERSION, DB_COMPAT_VERSION, settings.profile, artifact
|
||||
)
|
||||
}
|
||||
|
||||
fn s3_dir_display(settings: &S3SyncSettings) -> String {
|
||||
format!(
|
||||
"{}/v{}/db-v{}/{}",
|
||||
settings.remote_root, PROTOCOL_VERSION, DB_COMPAT_VERSION, settings.profile
|
||||
)
|
||||
}
|
||||
|
||||
fn creds_for(settings: &S3SyncSettings) -> S3Credentials {
|
||||
S3Credentials {
|
||||
access_key_id: settings.access_key_id.clone(),
|
||||
secret_access_key: settings.secret_access_key.clone(),
|
||||
region: settings.region.clone(),
|
||||
bucket: settings.bucket.clone(),
|
||||
endpoint: settings.endpoint.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_settings() -> S3SyncSettings {
|
||||
S3SyncSettings {
|
||||
remote_root: "cc-switch-sync".to_string(),
|
||||
profile: "default".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s3_key_uses_v2_and_correct_format() {
|
||||
let settings = test_settings();
|
||||
let key = s3_key(&settings, "manifest.json");
|
||||
assert_eq!(key, "cc-switch-sync/v2/db-v6/default/manifest.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s3_key_with_custom_profile() {
|
||||
let settings = S3SyncSettings {
|
||||
remote_root: "my-root".to_string(),
|
||||
profile: "work".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
};
|
||||
assert_eq!(s3_key(&settings, "db.sql"), "my-root/v2/db-v6/work/db.sql");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s3_key_matches_expected_pattern() {
|
||||
let settings = test_settings();
|
||||
let key = s3_key(&settings, "skills.zip");
|
||||
// Should follow {remote_root}/v{version}/db-v{db}/{profile}/{artifact}
|
||||
let parts: Vec<&str> = key.splitn(5, '/').collect();
|
||||
assert_eq!(parts.len(), 5);
|
||||
assert_eq!(parts[0], "cc-switch-sync");
|
||||
assert_eq!(parts[1], "v2");
|
||||
assert_eq!(parts[2], "db-v6");
|
||||
assert_eq!(parts[3], "default");
|
||||
assert_eq!(parts[4], "skills.zip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_mutex_is_singleton() {
|
||||
let m1 = sync_mutex();
|
||||
let m2 = sync_mutex();
|
||||
assert!(
|
||||
std::ptr::eq(m1, m2),
|
||||
"sync_mutex must return the same instance"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creds_for_maps_all_fields() {
|
||||
let settings = S3SyncSettings {
|
||||
access_key_id: "AKID".to_string(),
|
||||
secret_access_key: "SECRET".to_string(),
|
||||
region: "us-west-2".to_string(),
|
||||
bucket: "my-bucket".to_string(),
|
||||
endpoint: "minio.local:9000".to_string(),
|
||||
..S3SyncSettings::default()
|
||||
};
|
||||
let creds = creds_for(&settings);
|
||||
assert_eq!(creds.access_key_id, "AKID");
|
||||
assert_eq!(creds.secret_access_key, "SECRET");
|
||||
assert_eq!(creds.region, "us-west-2");
|
||||
assert_eq!(creds.bucket, "my-bucket");
|
||||
assert_eq!(creds.endpoint, "minio.local:9000");
|
||||
}
|
||||
}
|
||||
@@ -109,9 +109,15 @@ pub fn sync_claude_session_logs(db: &Database) -> Result<SessionSyncResult, AppE
|
||||
|
||||
/// 收集目录下所有 .jsonl 文件(含子 agent 文件)
|
||||
///
|
||||
/// 扫描三层固定深度,不使用递归,避免死循环:
|
||||
/// projects_dir/项目目录/*.jsonl (主会话)
|
||||
/// projects_dir/项目目录/SESSION_ID/subagents/*.jsonl (子 agent)
|
||||
/// 扫描固定深度,不使用递归,避免死循环:
|
||||
/// projects_dir/项目目录/*.jsonl (主会话)
|
||||
/// projects_dir/项目目录/SESSION_ID/subagents/*.jsonl (Task/Agent 子 agent)
|
||||
/// projects_dir/项目目录/SESSION_ID/subagents/workflows/wf_*/*.jsonl (Workflow 子 agent)
|
||||
///
|
||||
/// 最后一层是 Claude Code Workflow 功能产生的子 agent transcript,比普通子
|
||||
/// agent 多嵌套一层 `workflows/wf_<ID>/`。漏掉这一层会让 Workflow 的 token
|
||||
/// 用量完全不计入统计;`journal.jsonl` 不含 `type=="assistant"` 行,解析时
|
||||
/// 会被 `sync_single_file` 天然跳过,因此这里无需按文件名过滤。
|
||||
fn collect_jsonl_files(projects_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
@@ -136,12 +142,18 @@ fn collect_jsonl_files(projects_dir: &Path) -> Vec<PathBuf> {
|
||||
// 扫描子 agent 目录: 项目/SESSION_ID/subagents/*.jsonl
|
||||
let subagents_dir = sub_path.join("subagents");
|
||||
if subagents_dir.is_dir() {
|
||||
if let Ok(agent_entries) = fs::read_dir(&subagents_dir) {
|
||||
for agent_entry in agent_entries.flatten() {
|
||||
let agent_path = agent_entry.path();
|
||||
if agent_path.extension().and_then(|e| e.to_str()) == Some("jsonl")
|
||||
{
|
||||
files.push(agent_path);
|
||||
push_jsonl_children(&subagents_dir, &mut files);
|
||||
|
||||
// 额外下探 Workflow 子 agent:
|
||||
// 项目/SESSION_ID/subagents/workflows/wf_<ID>/*.jsonl
|
||||
let workflows_dir = subagents_dir.join("workflows");
|
||||
if workflows_dir.is_dir() {
|
||||
if let Ok(wf_entries) = fs::read_dir(&workflows_dir) {
|
||||
for wf_entry in wf_entries.flatten() {
|
||||
let wf_path = wf_entry.path();
|
||||
if wf_path.is_dir() {
|
||||
push_jsonl_children(&wf_path, &mut files);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,6 +166,18 @@ fn collect_jsonl_files(projects_dir: &Path) -> Vec<PathBuf> {
|
||||
files
|
||||
}
|
||||
|
||||
/// 将 `dir` 下直接子层的所有 `.jsonl` 文件追加到 `files`(不递归)。
|
||||
fn push_jsonl_children(dir: &Path, files: &mut Vec<PathBuf>) {
|
||||
if let Ok(entries) = fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步单个 JSONL 文件,返回 (imported, skipped)
|
||||
fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppError> {
|
||||
let file_path_str = file_path.to_string_lossy().to_string();
|
||||
@@ -290,8 +314,23 @@ fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppEr
|
||||
let mut skipped: u32 = 0;
|
||||
|
||||
for msg in messages.values() {
|
||||
// 只导入有 stop_reason 的最终条目(完整的 API 调用)
|
||||
if msg.stop_reason.is_none() {
|
||||
// 只要产生了真实计费 token 就导入,不再强制要求 stop_reason 或 output>0。
|
||||
//
|
||||
// Anthropic 在受理请求时即对 input + cache_read + cache_creation 计费
|
||||
// (这些在请求开始就确定),output 按实际生成量计。Workflow / 子 agent 的
|
||||
// 并行短命请求经常只写了 message_start 快照(output=1、stop_reason=None)
|
||||
// 却没有写最终块,但其 cache/input 成本已被真实计费。旧逻辑用 stop_reason
|
||||
// 非空 + output>0 双重过滤,会把这类请求整条丢弃,实测系统性低估约 4.1%,
|
||||
// 且 92% 集中在 workflow/subagent。这里改为「任一计费维度 > 0 即导入」。
|
||||
//
|
||||
// 去重选择逻辑(上方按 message.id 取 stop_reason 优先 / output 最大者)保持
|
||||
// 不变:它选出的代表行的 input/cache 本就准确;request_id = session:msg_id
|
||||
// 主键 + INSERT OR IGNORE 保证一个 message 仍只落库一次,放宽 gate 不会双算。
|
||||
let has_billable_tokens = msg.input_tokens > 0
|
||||
|| msg.output_tokens > 0
|
||||
|| msg.cache_read_tokens > 0
|
||||
|| msg.cache_creation_tokens > 0;
|
||||
if !has_billable_tokens {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -301,11 +340,6 @@ fn sync_single_file(db: &Database, file_path: &Path) -> Result<(u32, u32), AppEr
|
||||
msg.message_id
|
||||
);
|
||||
|
||||
// 跳过 output_tokens 为 0 的无意义条目
|
||||
if msg.output_tokens == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
match insert_session_log_entry(db, &request_id, msg) {
|
||||
Ok(true) => imported += 1,
|
||||
Ok(false) => skipped += 1,
|
||||
@@ -467,7 +501,7 @@ fn insert_session_log_entry(
|
||||
total_cost,
|
||||
0i64, // latency_ms: 会话日志无此数据
|
||||
Option::<i64>::None, // first_token_ms
|
||||
200i64, // status_code: 有 stop_reason 说明请求成功
|
||||
200i64, // status_code: 会话日志中的请求只要产生计费 token 即视为成功
|
||||
Option::<String>::None, // error_message
|
||||
msg.session_id,
|
||||
Some("session_log"), // provider_type
|
||||
@@ -685,4 +719,80 @@ mod tests {
|
||||
|
||||
fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_jsonl_files_includes_workflow_subagents() {
|
||||
// Claude Code Workflow 把子 agent transcript 嵌在
|
||||
// 项目/SESSION_ID/subagents/workflows/wf_<ID>/ 下,比普通子 agent 深一层。
|
||||
let tmp = std::env::temp_dir().join(format!("cc-switch-test-{}", uuid::Uuid::new_v4()));
|
||||
let project = tmp.join("project");
|
||||
let session_dir = project.join("test-session");
|
||||
let subagents_dir = session_dir.join("subagents");
|
||||
let wf_dir = subagents_dir.join("workflows").join("wf_test123");
|
||||
fs::create_dir_all(&wf_dir).unwrap();
|
||||
|
||||
fs::write(project.join("main.jsonl"), "{}").unwrap();
|
||||
fs::write(subagents_dir.join("agent-plain.jsonl"), "{}").unwrap();
|
||||
fs::write(wf_dir.join("agent-wf.jsonl"), "{}").unwrap();
|
||||
// journal.jsonl 也会被收集,但解析时因无 assistant 行而产出 0 条
|
||||
fs::write(wf_dir.join("journal.jsonl"), "{}").unwrap();
|
||||
|
||||
let files = collect_jsonl_files(&tmp);
|
||||
let paths: Vec<String> = files
|
||||
.iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
// 主会话 + 普通子 agent + Workflow 子 agent(agent-wf + journal) = 4
|
||||
assert_eq!(files.len(), 4);
|
||||
assert!(paths.iter().any(|p| p.contains("main.jsonl")));
|
||||
assert!(paths.iter().any(|p| p.contains("agent-plain.jsonl")));
|
||||
assert!(
|
||||
paths.iter().any(|p| p.contains("agent-wf.jsonl")),
|
||||
"Workflow 子 agent transcript 必须被收集"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_imports_billable_message_without_stop_reason() -> Result<(), AppError> {
|
||||
// 回归:stop_reason 缺失但有真实 cache/input 成本的 message(Workflow /
|
||||
// 子 agent 常见的「只有 message_start 快照、没写最终块」形态)必须被计入,
|
||||
// 不能因缺 stop_reason 或 output==0 而整条丢弃;全 0 token 的占位行仍应跳过。
|
||||
let db = Database::memory()?;
|
||||
let tmp = std::env::temp_dir().join(format!("cc-switch-test-{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&tmp).unwrap();
|
||||
let file = tmp.join("agent-wf.jsonl");
|
||||
|
||||
// 第一行:无 stop_reason、output=1,但 cache_read/cache_creation 很大 → 应导入
|
||||
// 第二行:全部 token 为 0 → 应跳过(无计费意义)
|
||||
let billable = r#"{"type":"assistant","message":{"id":"msg_nostop","model":"claude-opus-4-8","usage":{"input_tokens":2,"output_tokens":1,"cache_read_input_tokens":48719,"cache_creation_input_tokens":2061}},"timestamp":"2026-06-07T13:01:23Z","sessionId":"session-wf"}"#;
|
||||
let empty = r#"{"type":"assistant","message":{"id":"msg_empty","model":"claude-opus-4-8","usage":{"input_tokens":0,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}},"timestamp":"2026-06-07T13:01:24Z","sessionId":"session-wf"}"#;
|
||||
fs::write(&file, format!("{billable}\n{empty}\n")).unwrap();
|
||||
|
||||
let (imported, _skipped) = sync_single_file(&db, &file)?;
|
||||
assert_eq!(
|
||||
imported, 1,
|
||||
"有 cache 成本但无 stop_reason 的 message 必须被导入"
|
||||
);
|
||||
|
||||
let conn = lock_conn!(db.conn);
|
||||
let cache_read: i64 = conn.query_row(
|
||||
"SELECT cache_read_tokens FROM proxy_request_logs WHERE request_id = 'session:msg_nostop'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(cache_read, 48719, "cache_read 必须被完整记录");
|
||||
let empty_exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM proxy_request_logs WHERE request_id = 'session:msg_empty')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert!(!empty_exists, "全 0 token 的 message 应被跳过");
|
||||
drop(conn);
|
||||
|
||||
fs::remove_dir_all(&tmp).ok();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
//! OpenCode 会话日志使用追踪
|
||||
//!
|
||||
//! 从 ~/.local/share/opencode/opencode.db (SQLite) 中提取精确 token 使用数据。
|
||||
//!
|
||||
//! ## 数据流
|
||||
//! ```text
|
||||
//! ~/.local/share/opencode/opencode.db
|
||||
//! → session 表获取所有会话
|
||||
//! → message 表获取 assistant 消息
|
||||
//! → 解析 data JSON 提取 tokens/cost/model
|
||||
//! → proxy_request_logs 表
|
||||
//! ```
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::opencode_config::get_opencode_db_path;
|
||||
use crate::proxy::usage::calculator::CostCalculator;
|
||||
use crate::proxy::usage::parser::TokenUsage;
|
||||
use crate::services::session_usage::{
|
||||
get_sync_state, metadata_modified_nanos, update_sync_state, SessionSyncResult,
|
||||
};
|
||||
use crate::services::usage_stats::{find_model_pricing, should_skip_session_insert, DedupKey};
|
||||
use rust_decimal::Decimal;
|
||||
use std::fs;
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// 从 opencode message.data JSON 中提取的 token 和费用数据
|
||||
struct OpenCodeMessageData {
|
||||
input_tokens: u32,
|
||||
output_tokens: u32,
|
||||
reasoning_tokens: u32,
|
||||
cache_read_tokens: u32,
|
||||
cache_write_tokens: u32,
|
||||
cost: f64,
|
||||
model_id: String,
|
||||
timestamp_ms: i64,
|
||||
}
|
||||
|
||||
struct OpenCodeMessageQueryResult {
|
||||
messages: Vec<(String, OpenCodeMessageData)>,
|
||||
has_incomplete_usage: bool,
|
||||
}
|
||||
|
||||
/// 同步 OpenCode 使用数据
|
||||
pub fn sync_opencode_usage(db: &Database) -> Result<SessionSyncResult, AppError> {
|
||||
let db_path = get_opencode_db_path();
|
||||
|
||||
if !db_path.exists() {
|
||||
return Ok(SessionSyncResult {
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
files_scanned: 0,
|
||||
errors: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
let db_path_str = db_path.to_string_lossy().to_string();
|
||||
|
||||
// 检查文件修改时间。
|
||||
// opencode 的数据库运行在 WAL 模式:新提交先落在 -wal 文件里,
|
||||
// 主库文件只有在 checkpoint 时才更新。因此必须同时考虑 -wal 的
|
||||
// mtime,否则会在 checkpoint 之前漏掉刚写入的会话。
|
||||
let metadata = fs::metadata(&db_path)
|
||||
.map_err(|e| AppError::Config(format!("无法读取 opencode.db 元数据: {e}")))?;
|
||||
let mut file_modified = metadata_modified_nanos(&metadata);
|
||||
|
||||
let wal_path = db_path.with_extension("db-wal");
|
||||
if let Ok(wal_meta) = fs::metadata(&wal_path) {
|
||||
file_modified = file_modified.max(metadata_modified_nanos(&wal_meta));
|
||||
}
|
||||
|
||||
let (last_modified, _last_offset) = get_sync_state(db, &db_path_str)?;
|
||||
|
||||
// 文件未变化则跳过
|
||||
if file_modified <= last_modified {
|
||||
return Ok(SessionSyncResult {
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
files_scanned: 1,
|
||||
errors: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
// 打开 opencode 的 SQLite 数据库(只读)
|
||||
let opencode_conn =
|
||||
rusqlite::Connection::open_with_flags(&db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.map_err(|e| AppError::Database(format!("无法打开 opencode.db: {e}")))?;
|
||||
|
||||
let mut result = SessionSyncResult {
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
files_scanned: 1,
|
||||
errors: vec![],
|
||||
};
|
||||
let mut has_sync_errors = false;
|
||||
|
||||
// 查询所有会话
|
||||
let sessions = query_sessions(&opencode_conn)?;
|
||||
|
||||
for (session_id, time_updated) in &sessions {
|
||||
// 检查会话是否需要重新同步
|
||||
let sync_key = format!("{db_path_str}:{session_id}");
|
||||
let (sess_last_modified, _) = get_sync_state(db, &sync_key)?;
|
||||
if *time_updated <= sess_last_modified {
|
||||
continue; // 会话未更新,跳过
|
||||
}
|
||||
|
||||
let mut session_had_error = false;
|
||||
|
||||
// 查询该会话的所有 assistant 消息
|
||||
let mut session_has_incomplete_usage = false;
|
||||
match query_assistant_messages(&opencode_conn, session_id) {
|
||||
Ok(query_result) => {
|
||||
session_has_incomplete_usage = query_result.has_incomplete_usage;
|
||||
for (message_id, msg_data) in &query_result.messages {
|
||||
let request_id = format!("opencode_session:{session_id}:{message_id}");
|
||||
|
||||
match insert_opencode_message(db, &request_id, msg_data, session_id) {
|
||||
Ok(true) => result.imported += 1,
|
||||
Ok(false) => result.skipped += 1,
|
||||
Err(e) => {
|
||||
let msg = format!("OpenCode 消息插入失败 {request_id}: {e}");
|
||||
log::warn!("[OPENCODE-SYNC] {msg}");
|
||||
result.errors.push(msg);
|
||||
result.skipped += 1;
|
||||
session_had_error = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("OpenCode 会话消息查询失败 {session_id}: {e}");
|
||||
log::warn!("[OPENCODE-SYNC] {msg}");
|
||||
result.errors.push(msg);
|
||||
session_had_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
if session_had_error {
|
||||
has_sync_errors = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if session_has_incomplete_usage {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 更新会话级同步状态。失败时不要推进文件级状态,确保下次可重试。
|
||||
if let Err(e) = update_sync_state(db, &sync_key, *time_updated, 0) {
|
||||
let msg = format!("OpenCode 会话同步状态更新失败 {session_id}: {e}");
|
||||
log::warn!("[OPENCODE-SYNC] {msg}");
|
||||
result.errors.push(msg);
|
||||
has_sync_errors = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 仅在本轮完全成功时推进文件级状态;否则保留下次重试入口。
|
||||
if !has_sync_errors {
|
||||
update_sync_state(db, &db_path_str, file_modified, 0)?;
|
||||
}
|
||||
|
||||
if result.imported > 0 {
|
||||
log::info!(
|
||||
"[OPENCODE-SYNC] 同步完成: 导入 {} 条, 跳过 {} 条, 扫描 {} 个会话",
|
||||
result.imported,
|
||||
result.skipped,
|
||||
sessions.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 查询所有会话的 (id, sync_watermark)
|
||||
fn query_sessions(conn: &rusqlite::Connection) -> Result<Vec<(String, i64)>, AppError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT s.id,
|
||||
MAX(s.time_updated, COALESCE(MAX(m.time_updated), s.time_updated)) AS sync_watermark
|
||||
FROM session s
|
||||
LEFT JOIN message m ON m.session_id = s.id
|
||||
GROUP BY s.id
|
||||
ORDER BY sync_watermark",
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("准备会话查询失败: {e}")))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})
|
||||
.map_err(|e| AppError::Database(format!("查询会话失败: {e}")))?;
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
for row in rows {
|
||||
sessions.push(row.map_err(|e| AppError::Database(format!("读取会话行失败: {e}")))?);
|
||||
}
|
||||
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
/// 查询某会话的已完成 assistant 消息,并标记是否还有未完成 usage 消息。
|
||||
fn query_assistant_messages(
|
||||
conn: &rusqlite::Connection,
|
||||
session_id: &str,
|
||||
) -> Result<OpenCodeMessageQueryResult, AppError> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, data FROM message WHERE session_id = ?1 ORDER BY time_created")
|
||||
.map_err(|e| AppError::Database(format!("准备消息查询失败: {e}")))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([session_id], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map_err(|e| AppError::Database(format!("查询消息失败: {e}")))?;
|
||||
|
||||
let mut messages = Vec::new();
|
||||
let mut has_incomplete_usage = false;
|
||||
for row in rows {
|
||||
let (message_id, data_json) =
|
||||
row.map_err(|e| AppError::Database(format!("读取消息行失败: {e}")))?;
|
||||
|
||||
// 只处理 assistant 消息
|
||||
let value: serde_json::Value = match serde_json::from_str(&data_json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if value.get("role").and_then(|r| r.as_str()) != Some("assistant") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 必须有 tokens 字段
|
||||
if value.get("tokens").is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过未完成的消息:进行中只有半截 token,且因 INSERT OR IGNORE 无法回填
|
||||
if value.get("time").and_then(|t| t.get("completed")).is_none() {
|
||||
has_incomplete_usage = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(msg_data) = parse_message_data(&value) {
|
||||
messages.push((message_id, msg_data));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(OpenCodeMessageQueryResult {
|
||||
messages,
|
||||
has_incomplete_usage,
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析 opencode message.data JSON 为结构化数据
|
||||
fn parse_message_data(value: &serde_json::Value) -> Option<OpenCodeMessageData> {
|
||||
let tokens = value.get("tokens")?;
|
||||
|
||||
let input_tokens = tokens.get("input").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||
let output_tokens = tokens.get("output").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||
let reasoning_tokens = tokens
|
||||
.get("reasoning")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
let cache_obj = tokens.get("cache");
|
||||
let cache_read_tokens = cache_obj
|
||||
.and_then(|c| c.get("read"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
let cache_write_tokens = cache_obj
|
||||
.and_then(|c| c.get("write"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
// 跳过全零 token 的消息
|
||||
if input_tokens == 0
|
||||
&& output_tokens == 0
|
||||
&& reasoning_tokens == 0
|
||||
&& cache_read_tokens == 0
|
||||
&& cache_write_tokens == 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let cost = value.get("cost").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
|
||||
let model_id = value
|
||||
.get("modelID")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let timestamp_ms = value
|
||||
.get("time")
|
||||
.and_then(|t| t.get("created"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
|
||||
Some(OpenCodeMessageData {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
reasoning_tokens,
|
||||
cache_read_tokens,
|
||||
cache_write_tokens,
|
||||
cost,
|
||||
model_id,
|
||||
timestamp_ms,
|
||||
})
|
||||
}
|
||||
|
||||
/// 插入单条 OpenCode 消息记录到 proxy_request_logs
|
||||
fn insert_opencode_message(
|
||||
db: &Database,
|
||||
request_id: &str,
|
||||
msg: &OpenCodeMessageData,
|
||||
session_id: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(db.conn);
|
||||
|
||||
let created_at = if msg.timestamp_ms > 0 {
|
||||
msg.timestamp_ms / 1000
|
||||
} else {
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
// OpenCode 使用 Anthropic 风格:input 是新鲜输入,cache 单独计
|
||||
// output 包含 reasoning tokens(按输出计费)
|
||||
let output_with_reasoning = msg.output_tokens + msg.reasoning_tokens;
|
||||
|
||||
let dedup_key = DedupKey {
|
||||
app_type: "opencode",
|
||||
model: &msg.model_id,
|
||||
input_tokens: msg.input_tokens,
|
||||
output_tokens: output_with_reasoning,
|
||||
cache_read_tokens: msg.cache_read_tokens,
|
||||
cache_creation_tokens: msg.cache_write_tokens,
|
||||
created_at,
|
||||
};
|
||||
if should_skip_session_insert(&conn, request_id, &dedup_key)? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 如果 opencode 已经提供了费用,直接使用;否则从模型定价计算
|
||||
let (input_cost, output_cost, cache_read_cost, cache_creation_cost, total_cost) =
|
||||
if msg.cost > 0.0 {
|
||||
// opencode 已计算费用,直接使用
|
||||
// 简化处理:全部放入 total_cost(opencode 的 cost 是聚合值,无法精确拆分)
|
||||
(
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
msg.cost.to_string(),
|
||||
)
|
||||
} else {
|
||||
// opencode 费用为 0(如免费模型),尝试用 cc-switch 自带的模型定价计算
|
||||
let usage = TokenUsage {
|
||||
input_tokens: msg.input_tokens,
|
||||
output_tokens: output_with_reasoning,
|
||||
cache_read_tokens: msg.cache_read_tokens,
|
||||
cache_creation_tokens: msg.cache_write_tokens,
|
||||
model: Some(msg.model_id.clone()),
|
||||
message_id: None,
|
||||
};
|
||||
|
||||
match find_model_pricing(&conn, &msg.model_id) {
|
||||
Some(pricing) => {
|
||||
let cost = CostCalculator::calculate_for_app(
|
||||
"opencode",
|
||||
&usage,
|
||||
&pricing,
|
||||
Decimal::from(1),
|
||||
);
|
||||
(
|
||||
cost.input_cost.to_string(),
|
||||
cost.output_cost.to_string(),
|
||||
cost.cache_read_cost.to_string(),
|
||||
cost.cache_creation_cost.to_string(),
|
||||
cost.total_cost.to_string(),
|
||||
)
|
||||
}
|
||||
None => (
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
"0".to_string(),
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
let inserted_rows = conn.execute(
|
||||
"INSERT OR IGNORE INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||
latency_ms, first_token_ms, status_code, error_message, session_id,
|
||||
provider_type, is_streaming, cost_multiplier, created_at, data_source
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)",
|
||||
rusqlite::params![
|
||||
request_id,
|
||||
"_opencode_session", // provider_id
|
||||
"opencode", // app_type
|
||||
msg.model_id,
|
||||
msg.model_id, // request_model = model
|
||||
msg.input_tokens,
|
||||
output_with_reasoning,
|
||||
msg.cache_read_tokens,
|
||||
msg.cache_write_tokens,
|
||||
input_cost,
|
||||
output_cost,
|
||||
cache_read_cost,
|
||||
cache_creation_cost,
|
||||
total_cost,
|
||||
0i64, // latency_ms
|
||||
Option::<i64>::None, // first_token_ms
|
||||
200i64, // status_code
|
||||
Option::<String>::None,// error_message
|
||||
Some(session_id.to_string()),
|
||||
Some("opencode_session"), // provider_type
|
||||
1i64, // is_streaming
|
||||
"1.0", // cost_multiplier
|
||||
created_at,
|
||||
"opencode_session", // data_source
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("插入 OpenCode 会话日志失败: {e}")))?;
|
||||
|
||||
Ok(inserted_rows > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_message_data_full() {
|
||||
let json: serde_json::Value = serde_json::json!({
|
||||
"role": "assistant",
|
||||
"cost": 0.0023113,
|
||||
"tokens": {
|
||||
"total": 56554,
|
||||
"input": 3272,
|
||||
"output": 383,
|
||||
"reasoning": 419,
|
||||
"cache": {
|
||||
"write": 0,
|
||||
"read": 52480
|
||||
}
|
||||
},
|
||||
"modelID": "deepseek-v4-pro",
|
||||
"providerID": "deepseek",
|
||||
"time": {
|
||||
"created": 1779755333700i64,
|
||||
"completed": 1779755350639i64
|
||||
}
|
||||
});
|
||||
let data = parse_message_data(&json).unwrap();
|
||||
assert_eq!(data.input_tokens, 3272);
|
||||
assert_eq!(data.output_tokens, 383);
|
||||
assert_eq!(data.reasoning_tokens, 419);
|
||||
assert_eq!(data.cache_read_tokens, 52480);
|
||||
assert_eq!(data.cache_write_tokens, 0);
|
||||
assert!((data.cost - 0.0023113).abs() < 1e-10);
|
||||
assert_eq!(data.model_id, "deepseek-v4-pro");
|
||||
assert_eq!(data.timestamp_ms, 1779755333700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_message_data_missing_cache() {
|
||||
let json: serde_json::Value = serde_json::json!({
|
||||
"role": "assistant",
|
||||
"cost": 0.0,
|
||||
"tokens": {
|
||||
"input": 1000,
|
||||
"output": 200
|
||||
},
|
||||
"modelID": "mimo-v2.5-pro",
|
||||
"time": { "created": 1779755333700i64 }
|
||||
});
|
||||
let data = parse_message_data(&json).unwrap();
|
||||
assert_eq!(data.input_tokens, 1000);
|
||||
assert_eq!(data.output_tokens, 200);
|
||||
assert_eq!(data.reasoning_tokens, 0);
|
||||
assert_eq!(data.cache_read_tokens, 0);
|
||||
assert_eq!(data.cache_write_tokens, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_message_data_skips_zero_tokens() {
|
||||
let json: serde_json::Value = serde_json::json!({
|
||||
"role": "assistant",
|
||||
"tokens": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"reasoning": 0,
|
||||
"cache": { "read": 0, "write": 0 }
|
||||
},
|
||||
"modelID": "test"
|
||||
});
|
||||
assert!(parse_message_data(&json).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_message_data_ignores_role() {
|
||||
// parse_message_data does not filter by role; that's the caller's job
|
||||
let json: serde_json::Value = serde_json::json!({
|
||||
"role": "user",
|
||||
"tokens": { "input": 100, "output": 0 }
|
||||
});
|
||||
let data = parse_message_data(&json).unwrap();
|
||||
assert_eq!(data.input_tokens, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_assistant_messages_skips_incomplete() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE message (id TEXT, session_id TEXT, time_created INTEGER, data TEXT);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let done = serde_json::json!({
|
||||
"role": "assistant",
|
||||
"tokens": { "input": 1000, "output": 200 },
|
||||
"modelID": "m",
|
||||
"time": { "created": 1, "completed": 2 }
|
||||
})
|
||||
.to_string();
|
||||
let in_progress = serde_json::json!({
|
||||
"role": "assistant",
|
||||
"tokens": { "input": 500, "output": 0 },
|
||||
"modelID": "m",
|
||||
"time": { "created": 3 }
|
||||
})
|
||||
.to_string();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO message VALUES ('done', 's1', 1, ?1), ('wip', 's1', 2, ?2)",
|
||||
rusqlite::params![done, in_progress],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = query_assistant_messages(&conn, "s1").unwrap();
|
||||
// 只返回已完成(带 time.completed)的消息,半截的被跳过
|
||||
assert_eq!(result.messages.len(), 1);
|
||||
assert_eq!(result.messages[0].0, "done");
|
||||
assert!(result.has_incomplete_usage);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_sessions_uses_message_update_watermark() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE session (id TEXT, time_updated INTEGER);
|
||||
CREATE TABLE message (
|
||||
id TEXT,
|
||||
session_id TEXT,
|
||||
time_created INTEGER,
|
||||
time_updated INTEGER,
|
||||
data TEXT
|
||||
);
|
||||
INSERT INTO session VALUES ('s1', 100);
|
||||
INSERT INTO message VALUES ('m1', 's1', 90, 200, '{}');",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let sessions = query_sessions(&conn).unwrap();
|
||||
assert_eq!(sessions, vec![("s1".to_string(), 200)]);
|
||||
}
|
||||
}
|
||||
@@ -1964,7 +1964,7 @@ impl SkillService {
|
||||
.strip_prefix(base_dir)
|
||||
.unwrap_or(current_dir)
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
.replace('\\', "/")
|
||||
};
|
||||
|
||||
let doc_path = skill_md
|
||||
|
||||
@@ -293,6 +293,20 @@ impl StreamCheckService {
|
||||
))
|
||||
}
|
||||
|
||||
/// Provider 级自定义 User-Agent(`meta.customUserAgent`),经 `parse_custom_user_agent` 校验。
|
||||
///
|
||||
/// 与 forwarder 转发路径(`RequestForwarder::forward`)、model_fetch 共用单一口径:trim、
|
||||
/// 空串视为未设置、**非法值静默忽略**(返回 `None`,不报错)。Stream Check 必须复用同一个
|
||||
/// UA 去探测,否则会与真实流量用不同的 User-Agent(例如 Kimi Coding Plan 的 UA 白名单),
|
||||
/// 导致"检测失败但代理可用"或反之的分歧——非法 UA 时尤甚(转发静默丢弃、检测却会因
|
||||
/// reqwest 非法头在 `.send()` 报错)。
|
||||
fn custom_user_agent(provider: &Provider) -> Option<reqwest::header::HeaderValue> {
|
||||
provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.custom_user_agent_header().ok().flatten())
|
||||
}
|
||||
|
||||
/// Claude 流式检查
|
||||
///
|
||||
/// 根据供应商的 api_format 选择请求格式:
|
||||
@@ -489,6 +503,14 @@ impl StreamCheckService {
|
||||
}
|
||||
}
|
||||
|
||||
// Provider 级自定义 User-Agent(meta.customUserAgent)覆盖默认 UA,与 forwarder
|
||||
// 转发路径口径一致;Copilot 指纹 UA 不可被覆盖。
|
||||
if !is_github_copilot {
|
||||
if let Some(ua) = Self::custom_user_agent(provider) {
|
||||
request_builder = request_builder.header("user-agent", ua);
|
||||
}
|
||||
}
|
||||
|
||||
let response = request_builder
|
||||
.timeout(timeout)
|
||||
.json(&body)
|
||||
@@ -549,6 +571,15 @@ impl StreamCheckService {
|
||||
let os_name = Self::get_os_name();
|
||||
let arch_name = Self::get_arch_name();
|
||||
|
||||
// Provider 级自定义 User-Agent(meta.customUserAgent)覆盖默认 codex UA,与 forwarder
|
||||
// 转发路径口径一致——否则 Stream Check 会用与真实流量不同的 UA 探测(如 Kimi UA 白名单)。
|
||||
let user_agent = Self::custom_user_agent(provider).unwrap_or_else(|| {
|
||||
reqwest::header::HeaderValue::from_str(&format!(
|
||||
"codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"
|
||||
))
|
||||
.unwrap_or_else(|_| reqwest::header::HeaderValue::from_static("codex_cli_rs/0.80.0"))
|
||||
});
|
||||
|
||||
let mut body = if uses_chat {
|
||||
// Chat Completions 请求体(与 transform_codex_chat::responses_to_chat_completions 对齐)
|
||||
json!({
|
||||
@@ -586,10 +617,7 @@ impl StreamCheckService {
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "text/event-stream")
|
||||
.header("accept-encoding", "identity")
|
||||
.header(
|
||||
"user-agent",
|
||||
format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"),
|
||||
)
|
||||
.header("user-agent", user_agent.clone())
|
||||
.header("originator", "codex_cli_rs")
|
||||
.timeout(timeout)
|
||||
.json(&body)
|
||||
@@ -1630,6 +1658,53 @@ mod tests {
|
||||
assert!(!StreamCheckService::additive_app_uses_auth_header(&p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_user_agent_trims_and_filters_empty() {
|
||||
use crate::provider::ProviderMeta;
|
||||
|
||||
let mut p = make_provider(serde_json::json!({
|
||||
"baseUrl": "https://api.kimi.com/coding",
|
||||
"apiKey": "k",
|
||||
}));
|
||||
|
||||
// 未设置 meta → None
|
||||
assert!(StreamCheckService::custom_user_agent(&p).is_none());
|
||||
|
||||
// 带首尾空格的 UA → 去空格后返回合法 HeaderValue
|
||||
p.meta = Some(ProviderMeta {
|
||||
custom_user_agent: Some(" claude-cli/2.1.161 ".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
StreamCheckService::custom_user_agent(&p)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"claude-cli/2.1.161"
|
||||
);
|
||||
|
||||
// 纯空白 → 视为未设置(与 forwarder 路径口径一致)
|
||||
p.meta = Some(ProviderMeta {
|
||||
custom_user_agent: Some(" ".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(StreamCheckService::custom_user_agent(&p).is_none());
|
||||
|
||||
// 非 ASCII 字符其实合法(UTF-8 字节均 ≥ 0x80,HeaderValue 按字节放行)→ 应返回 Some
|
||||
p.meta = Some(ProviderMeta {
|
||||
custom_user_agent: Some("claude-cli/2.1.161 \u{4e2d}".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(StreamCheckService::custom_user_agent(&p).is_some());
|
||||
|
||||
// 含控制字符(内嵌换行)才非法 → None(静默忽略,与 forwarder 一致,不让 Stream Check 报错)
|
||||
p.meta = Some(ProviderMeta {
|
||||
custom_user_agent: Some("claude-cli/2.1.161\nX".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(StreamCheckService::custom_user_agent(&p).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_opencode_base_url_explicit_wins() {
|
||||
let p = make_provider(serde_json::json!({
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
//! Transport-agnostic sync protocol layer.
|
||||
//!
|
||||
//! Shared by WebDAV, S3, and future transports. Artifact set: `db.sql` + `skills.zip`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tempfile::tempdir;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
// Re-export archive functions for use by transport layers.
|
||||
pub(crate) use super::webdav_sync::archive::{
|
||||
backup_current_skills, restore_skills_from_backup, restore_skills_zip, zip_skills_ssot,
|
||||
};
|
||||
|
||||
// ─── Protocol constants ──────────────────────────────────────
|
||||
|
||||
/// Wire-format identifier stored in remote manifests.
|
||||
/// Retains historic "webdav" naming for backward compatibility with existing remotes.
|
||||
pub(crate) const PROTOCOL_FORMAT: &str = "cc-switch-webdav-sync";
|
||||
pub(crate) const PROTOCOL_VERSION: u32 = 2;
|
||||
pub(crate) const DB_COMPAT_VERSION: u32 = 6;
|
||||
pub(crate) const LEGACY_DB_COMPAT_VERSION: u32 = 5;
|
||||
pub(crate) const REMOTE_DB_SQL: &str = "db.sql";
|
||||
pub(crate) const REMOTE_SKILLS_ZIP: &str = "skills.zip";
|
||||
pub(crate) const REMOTE_MANIFEST: &str = "manifest.json";
|
||||
pub(crate) const MAX_DEVICE_NAME_LEN: usize = 64;
|
||||
pub(crate) const MAX_MANIFEST_BYTES: usize = 1024 * 1024;
|
||||
pub(crate) const MAX_SYNC_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
// ─── Error helpers ───────────────────────────────────────────
|
||||
|
||||
pub(crate) fn localized(
|
||||
key: &'static str,
|
||||
zh: impl Into<String>,
|
||||
en: impl Into<String>,
|
||||
) -> AppError {
|
||||
AppError::localized(key, zh, en)
|
||||
}
|
||||
|
||||
pub(crate) fn io_context_localized(
|
||||
_key: &'static str,
|
||||
zh: impl Into<String>,
|
||||
en: impl Into<String>,
|
||||
source: std::io::Error,
|
||||
) -> AppError {
|
||||
let zh_msg = zh.into();
|
||||
let en_msg = en.into();
|
||||
AppError::IoContext {
|
||||
context: format!("{zh_msg} ({en_msg})"),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SyncManifest {
|
||||
pub format: String,
|
||||
pub version: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub db_compat_version: Option<u32>,
|
||||
pub device_name: String,
|
||||
pub created_at: String,
|
||||
pub artifacts: BTreeMap<String, ArtifactMeta>,
|
||||
pub snapshot_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct ArtifactMeta {
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct LocalSnapshot {
|
||||
pub db_sql: Vec<u8>,
|
||||
pub skills_zip: Vec<u8>,
|
||||
pub manifest_bytes: Vec<u8>,
|
||||
pub manifest_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RemoteLayout {
|
||||
Current,
|
||||
Legacy,
|
||||
}
|
||||
|
||||
impl RemoteLayout {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Current => "current",
|
||||
Self::Legacy => "legacy",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Snapshot building ───────────────────────────────────────
|
||||
|
||||
pub(crate) fn build_local_snapshot(
|
||||
db: &crate::database::Database,
|
||||
) -> Result<LocalSnapshot, AppError> {
|
||||
// Export database to SQL string
|
||||
let sql_string = db.export_sql_string_for_sync()?;
|
||||
let db_sql = sql_string.into_bytes();
|
||||
|
||||
// Pack skills into deterministic ZIP
|
||||
let tmp = tempdir().map_err(|e| {
|
||||
io_context_localized(
|
||||
"sync.snapshot_tmpdir_failed",
|
||||
"创建快照临时目录失败",
|
||||
"Failed to create temporary directory for snapshot",
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
let skills_zip_path = tmp.path().join(REMOTE_SKILLS_ZIP);
|
||||
zip_skills_ssot(&skills_zip_path)?;
|
||||
let skills_zip = fs::read(&skills_zip_path).map_err(|e| AppError::io(&skills_zip_path, e))?;
|
||||
|
||||
// Build artifact map and compute hashes
|
||||
let mut artifacts = BTreeMap::new();
|
||||
artifacts.insert(
|
||||
REMOTE_DB_SQL.to_string(),
|
||||
ArtifactMeta {
|
||||
sha256: sha256_hex(&db_sql),
|
||||
size: db_sql.len() as u64,
|
||||
},
|
||||
);
|
||||
artifacts.insert(
|
||||
REMOTE_SKILLS_ZIP.to_string(),
|
||||
ArtifactMeta {
|
||||
sha256: sha256_hex(&skills_zip),
|
||||
size: skills_zip.len() as u64,
|
||||
},
|
||||
);
|
||||
|
||||
let snapshot_id = compute_snapshot_id(&artifacts);
|
||||
let manifest = SyncManifest {
|
||||
format: PROTOCOL_FORMAT.to_string(),
|
||||
version: PROTOCOL_VERSION,
|
||||
db_compat_version: Some(DB_COMPAT_VERSION),
|
||||
device_name: detect_system_device_name().unwrap_or_else(|| "Unknown Device".to_string()),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
artifacts,
|
||||
snapshot_id,
|
||||
};
|
||||
let manifest_bytes =
|
||||
serde_json::to_vec_pretty(&manifest).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
let manifest_hash = sha256_hex(&manifest_bytes);
|
||||
|
||||
Ok(LocalSnapshot {
|
||||
db_sql,
|
||||
skills_zip,
|
||||
manifest_bytes,
|
||||
manifest_hash,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Manifest handling ───────────────────────────────────────
|
||||
|
||||
/// Compute a deterministic snapshot identity from artifact hashes.
|
||||
///
|
||||
/// BTreeMap iteration order is sorted by key, ensuring stability.
|
||||
pub(crate) fn compute_snapshot_id(artifacts: &BTreeMap<String, ArtifactMeta>) -> String {
|
||||
let parts: Vec<String> = artifacts
|
||||
.iter()
|
||||
.map(|(name, meta)| format!("{}:{}", name, meta.sha256))
|
||||
.collect();
|
||||
sha256_hex(parts.join("|").as_bytes())
|
||||
}
|
||||
|
||||
pub(crate) fn effective_db_compat_version(
|
||||
manifest: &SyncManifest,
|
||||
layout: RemoteLayout,
|
||||
) -> Option<u32> {
|
||||
manifest
|
||||
.db_compat_version
|
||||
.or_else(|| (layout == RemoteLayout::Legacy).then_some(LEGACY_DB_COMPAT_VERSION))
|
||||
}
|
||||
|
||||
pub(crate) fn validate_manifest_compat(
|
||||
manifest: &SyncManifest,
|
||||
layout: RemoteLayout,
|
||||
) -> Result<(), AppError> {
|
||||
if manifest.format != PROTOCOL_FORMAT {
|
||||
return Err(localized(
|
||||
"sync.manifest_format_incompatible",
|
||||
format!("远端 manifest 格式不兼容: {}", manifest.format),
|
||||
format!(
|
||||
"Remote manifest format is incompatible: {}",
|
||||
manifest.format
|
||||
),
|
||||
));
|
||||
}
|
||||
if manifest.version != PROTOCOL_VERSION {
|
||||
return Err(localized(
|
||||
"sync.manifest_version_incompatible",
|
||||
format!(
|
||||
"远端 manifest 协议版本不兼容: v{} (本地 v{PROTOCOL_VERSION})",
|
||||
manifest.version
|
||||
),
|
||||
format!(
|
||||
"Remote manifest protocol version is incompatible: v{} (local v{PROTOCOL_VERSION})",
|
||||
manifest.version
|
||||
),
|
||||
));
|
||||
}
|
||||
let Some(db_compat_version) = effective_db_compat_version(manifest, layout) else {
|
||||
return Err(localized(
|
||||
"sync.manifest_db_version_missing",
|
||||
"远端 manifest 缺少数据库兼容版本",
|
||||
"Remote manifest is missing the database compatibility version.",
|
||||
));
|
||||
};
|
||||
match layout {
|
||||
RemoteLayout::Current if db_compat_version != DB_COMPAT_VERSION => {
|
||||
return Err(localized(
|
||||
"sync.manifest_db_version_incompatible",
|
||||
format!(
|
||||
"远端数据库快照版本不兼容: db-v{db_compat_version} (本地 db-v{DB_COMPAT_VERSION})"
|
||||
),
|
||||
format!(
|
||||
"Remote database snapshot version is incompatible: db-v{db_compat_version} (local db-v{DB_COMPAT_VERSION})"
|
||||
),
|
||||
));
|
||||
}
|
||||
RemoteLayout::Legacy if db_compat_version > DB_COMPAT_VERSION => {
|
||||
return Err(localized(
|
||||
"sync.manifest_db_version_incompatible",
|
||||
format!(
|
||||
"远端数据库快照版本不兼容: db-v{db_compat_version} (本地最高支持 db-v{DB_COMPAT_VERSION})"
|
||||
),
|
||||
format!(
|
||||
"Remote database snapshot version is incompatible: db-v{db_compat_version} (local supports up to db-v{DB_COMPAT_VERSION})"
|
||||
),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Artifact verification ───────────────────────────────────
|
||||
|
||||
pub(crate) fn validate_artifact_size_limit(artifact_name: &str, size: u64) -> Result<(), AppError> {
|
||||
if size > MAX_SYNC_ARTIFACT_BYTES {
|
||||
let max_mb = MAX_SYNC_ARTIFACT_BYTES / 1024 / 1024;
|
||||
return Err(localized(
|
||||
"sync.artifact_too_large",
|
||||
format!("artifact {artifact_name} 超过下载上限({} MB)", max_mb),
|
||||
format!(
|
||||
"Artifact {artifact_name} exceeds download limit ({} MB)",
|
||||
max_mb
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify that downloaded artifact bytes match the expected size and SHA-256 hash.
|
||||
pub(crate) fn verify_artifact(
|
||||
bytes: &[u8],
|
||||
artifact_name: &str,
|
||||
meta: &ArtifactMeta,
|
||||
) -> Result<(), AppError> {
|
||||
// Quick size check before expensive hash
|
||||
if bytes.len() as u64 != meta.size {
|
||||
return Err(localized(
|
||||
"sync.artifact_size_mismatch",
|
||||
format!(
|
||||
"artifact {artifact_name} 大小不匹配 (expected: {}, got: {})",
|
||||
meta.size,
|
||||
bytes.len(),
|
||||
),
|
||||
format!(
|
||||
"Artifact {artifact_name} size mismatch (expected: {}, got: {})",
|
||||
meta.size,
|
||||
bytes.len(),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let actual_hash = sha256_hex(bytes);
|
||||
if actual_hash != meta.sha256 {
|
||||
return Err(localized(
|
||||
"sync.artifact_hash_mismatch",
|
||||
format!(
|
||||
"artifact {artifact_name} SHA256 校验失败 (expected: {}..., got: {}...)",
|
||||
meta.sha256.get(..8).unwrap_or(&meta.sha256),
|
||||
actual_hash.get(..8).unwrap_or(&actual_hash),
|
||||
),
|
||||
format!(
|
||||
"Artifact {artifact_name} SHA256 verification failed (expected: {}..., got: {}...)",
|
||||
meta.sha256.get(..8).unwrap_or(&meta.sha256),
|
||||
actual_hash.get(..8).unwrap_or(&actual_hash),
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Snapshot application ────────────────────────────────────
|
||||
|
||||
pub(crate) fn apply_snapshot(
|
||||
db: &crate::database::Database,
|
||||
db_sql: &[u8],
|
||||
skills_zip: &[u8],
|
||||
) -> Result<(), AppError> {
|
||||
let sql_str = std::str::from_utf8(db_sql).map_err(|e| {
|
||||
localized(
|
||||
"sync.sql_not_utf8",
|
||||
format!("SQL 非 UTF-8: {e}"),
|
||||
format!("SQL is not valid UTF-8: {e}"),
|
||||
)
|
||||
})?;
|
||||
let skills_backup = backup_current_skills()?;
|
||||
|
||||
// Replace skills first, then import database; roll back skills on DB failure.
|
||||
restore_skills_zip(skills_zip)?;
|
||||
|
||||
if let Err(db_err) = db.import_sql_string_for_sync(sql_str) {
|
||||
if let Err(rollback_err) = restore_skills_from_backup(&skills_backup) {
|
||||
return Err(localized(
|
||||
"sync.db_import_and_rollback_failed",
|
||||
format!("导入数据库失败: {db_err}; 同时回滚 Skills 失败: {rollback_err}"),
|
||||
format!(
|
||||
"Database import failed: {db_err}; skills rollback also failed: {rollback_err}"
|
||||
),
|
||||
));
|
||||
}
|
||||
return Err(db_err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Utilities ───────────────────────────────────────────────
|
||||
|
||||
pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
pub(crate) fn detect_system_device_name() -> Option<String> {
|
||||
let env_name = ["CC_SWITCH_DEVICE_NAME", "COMPUTERNAME", "HOSTNAME"]
|
||||
.iter()
|
||||
.filter_map(|key| std::env::var(key).ok())
|
||||
.find_map(|value| normalize_device_name(&value));
|
||||
|
||||
if env_name.is_some() {
|
||||
return env_name;
|
||||
}
|
||||
|
||||
let output = Command::new("hostname").output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let hostname = String::from_utf8(output.stdout).ok()?;
|
||||
normalize_device_name(&hostname)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_device_name(raw: &str) -> Option<String> {
|
||||
let compact = raw
|
||||
.chars()
|
||||
.fold(String::with_capacity(raw.len()), |mut acc, ch| {
|
||||
if ch.is_whitespace() {
|
||||
acc.push(' ');
|
||||
} else if !ch.is_control() {
|
||||
acc.push(ch);
|
||||
}
|
||||
acc
|
||||
});
|
||||
let normalized = compact.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let trimmed = normalized.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let limited = trimmed
|
||||
.chars()
|
||||
.take(MAX_DEVICE_NAME_LEN)
|
||||
.collect::<String>();
|
||||
if limited.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(limited)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sync status persistence ─────────────────────────────────
|
||||
|
||||
pub(crate) fn persist_sync_success_best_effort<S, F>(
|
||||
settings: &mut S,
|
||||
manifest_hash: String,
|
||||
etag: Option<String>,
|
||||
persist_fn: F,
|
||||
) -> bool
|
||||
where
|
||||
F: FnOnce(&mut S, String, Option<String>) -> Result<(), AppError>,
|
||||
{
|
||||
match persist_fn(settings, manifest_hash, etag) {
|
||||
Ok(()) => true,
|
||||
Err(err) => {
|
||||
log::warn!("[Sync] Persist sync status failed, keep operation success: {err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn artifact(sha256: &str, size: u64) -> ArtifactMeta {
|
||||
ArtifactMeta {
|
||||
sha256: sha256.to_string(),
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_id_is_stable() {
|
||||
let mut artifacts = BTreeMap::new();
|
||||
artifacts.insert("db.sql".to_string(), artifact("abc123", 100));
|
||||
artifacts.insert("skills.zip".to_string(), artifact("def456", 200));
|
||||
|
||||
let id1 = compute_snapshot_id(&artifacts);
|
||||
let id2 = compute_snapshot_id(&artifacts);
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_id_changes_with_artifacts() {
|
||||
let mut a1 = BTreeMap::new();
|
||||
a1.insert("db.sql".to_string(), artifact("hash-a", 1));
|
||||
|
||||
let mut a2 = BTreeMap::new();
|
||||
a2.insert("db.sql".to_string(), artifact("hash-b", 1));
|
||||
|
||||
assert_ne!(compute_snapshot_id(&a1), compute_snapshot_id(&a2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_hex_is_correct() {
|
||||
let hash = sha256_hex(b"hello");
|
||||
assert_eq!(
|
||||
hash,
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_best_effort_returns_true_on_success() {
|
||||
let mut dummy = ();
|
||||
let ok = persist_sync_success_best_effort(
|
||||
&mut dummy,
|
||||
"hash".to_string(),
|
||||
Some("etag".to_string()),
|
||||
|_settings, _hash, _etag| Ok(()),
|
||||
);
|
||||
assert!(ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_best_effort_returns_false_on_error() {
|
||||
let mut dummy = ();
|
||||
let ok = persist_sync_success_best_effort(
|
||||
&mut dummy,
|
||||
"hash".to_string(),
|
||||
None,
|
||||
|_settings, _hash, _etag| Err(AppError::Config("boom".to_string())),
|
||||
);
|
||||
assert!(!ok);
|
||||
}
|
||||
|
||||
fn manifest_with(format: &str, version: u32, db_compat_version: Option<u32>) -> SyncManifest {
|
||||
let mut artifacts = BTreeMap::new();
|
||||
artifacts.insert("db.sql".to_string(), artifact("abc", 1));
|
||||
artifacts.insert("skills.zip".to_string(), artifact("def", 2));
|
||||
SyncManifest {
|
||||
format: format.to_string(),
|
||||
version,
|
||||
db_compat_version,
|
||||
device_name: "My MacBook".to_string(),
|
||||
created_at: "2026-02-12T00:00:00Z".to_string(),
|
||||
artifacts,
|
||||
snapshot_id: "snap-1".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_accepts_supported_manifest() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_wrong_format() {
|
||||
let manifest = manifest_with("other-format", PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_wrong_version() {
|
||||
let manifest = manifest_with(
|
||||
PROTOCOL_FORMAT,
|
||||
PROTOCOL_VERSION + 1,
|
||||
Some(DB_COMPAT_VERSION),
|
||||
);
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_accepts_legacy_manifest_without_db_compat() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, None);
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Legacy).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_current_manifest_with_wrong_db_compat() {
|
||||
let manifest = manifest_with(
|
||||
PROTOCOL_FORMAT,
|
||||
PROTOCOL_VERSION,
|
||||
Some(LEGACY_DB_COMPAT_VERSION),
|
||||
);
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_legacy_manifest_from_newer_db_generation() {
|
||||
let manifest = manifest_with(
|
||||
PROTOCOL_FORMAT,
|
||||
PROTOCOL_VERSION,
|
||||
Some(DB_COMPAT_VERSION + 1),
|
||||
);
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Legacy).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_db_compat_version_defaults_legacy_layout_to_v5() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, None);
|
||||
assert_eq!(
|
||||
effective_db_compat_version(&manifest, RemoteLayout::Legacy),
|
||||
Some(LEGACY_DB_COMPAT_VERSION)
|
||||
);
|
||||
assert_eq!(
|
||||
effective_db_compat_version(&manifest, RemoteLayout::Current),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_device_name_returns_none_for_blank_input() {
|
||||
assert_eq!(normalize_device_name(" \n\t "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_device_name_collapses_whitespace_and_drops_control_chars() {
|
||||
assert_eq!(
|
||||
normalize_device_name(" Mac\tBook \n Pro\u{0007} "),
|
||||
Some("Mac Book Pro".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_device_name_truncates_to_max_len() {
|
||||
let long = "a".repeat(80);
|
||||
assert_eq!(normalize_device_name(&long).map(|s| s.len()), Some(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_serialization_uses_device_name_only() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
|
||||
let value = serde_json::to_value(&manifest).expect("serialize manifest");
|
||||
assert!(
|
||||
value.get("deviceName").is_some(),
|
||||
"manifest should contain deviceName"
|
||||
);
|
||||
assert_eq!(
|
||||
value.get("dbCompatVersion").and_then(|v| v.as_u64()),
|
||||
Some(DB_COMPAT_VERSION as u64)
|
||||
);
|
||||
assert!(
|
||||
value.get("deviceId").is_none(),
|
||||
"manifest should not contain deviceId"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_artifact_size_limit_rejects_oversized_artifacts() {
|
||||
let err = validate_artifact_size_limit("skills.zip", MAX_SYNC_ARTIFACT_BYTES + 1)
|
||||
.expect_err("artifact larger than limit should be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("too large") || err.to_string().contains("超过"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_artifact_size_limit_accepts_limit_boundary() {
|
||||
assert!(validate_artifact_size_limit("skills.zip", MAX_SYNC_ARTIFACT_BYTES).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_artifact_rejects_size_mismatch() {
|
||||
let meta = artifact("abc123", 100);
|
||||
let bytes = vec![0u8; 50];
|
||||
let err = verify_artifact(&bytes, "test.bin", &meta)
|
||||
.expect_err("size mismatch should be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("mismatch") || err.to_string().contains("不匹配"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_artifact_rejects_hash_mismatch() {
|
||||
let meta = ArtifactMeta {
|
||||
sha256: "0000000000000000000000000000000000000000000000000000000000000000".to_string(),
|
||||
size: 5,
|
||||
};
|
||||
let bytes = b"hello";
|
||||
let err = verify_artifact(bytes, "test.bin", &meta)
|
||||
.expect_err("hash mismatch should be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("verification failed") || err.to_string().contains("校验失败"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_artifact_accepts_matching_data() {
|
||||
let data = b"hello";
|
||||
let meta = ArtifactMeta {
|
||||
sha256: sha256_hex(data),
|
||||
size: data.len() as u64,
|
||||
};
|
||||
assert!(verify_artifact(data, "test.bin", &meta).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,19 @@ impl UsageCache {
|
||||
w.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalidate_subscription(&self, app_type: &AppType) {
|
||||
if !self
|
||||
.subscription
|
||||
.read()
|
||||
.is_ok_and(|r| r.contains_key(app_type))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Ok(mut w) = self.subscription.write() {
|
||||
w.remove(app_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -149,17 +149,20 @@ pub struct RequestLogDetail {
|
||||
pub created_at: i64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data_source: Option<String>,
|
||||
/// 写入时实际用于计价的模型名。None = v11 前的历史行,"" = 未计价的错误行。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pricing_model: Option<String>,
|
||||
}
|
||||
|
||||
/// 把 24 列的查询结果映射为 `RequestLogDetail`。
|
||||
/// 把 25 列的查询结果映射为 `RequestLogDetail`。
|
||||
///
|
||||
/// 调用方的 SELECT **必须**按以下顺序返回 24 列:
|
||||
/// 调用方的 SELECT **必须**按以下顺序返回 25 列:
|
||||
/// `request_id, provider_id, provider_name, app_type, model, request_model,
|
||||
/// cost_multiplier, input_tokens, output_tokens, cache_read_tokens,
|
||||
/// cache_creation_tokens, input_cost_usd, output_cost_usd, cache_read_cost_usd,
|
||||
/// cache_creation_cost_usd, total_cost_usd, is_streaming, latency_ms,
|
||||
/// first_token_ms, duration_ms, status_code, error_message, created_at,
|
||||
/// data_source`
|
||||
/// data_source, pricing_model`
|
||||
///
|
||||
/// 不需要 provider_name 时(如 backfill)SELECT `NULL AS provider_name` 占位即可。
|
||||
fn row_to_request_log_detail(row: &rusqlite::Row<'_>) -> rusqlite::Result<RequestLogDetail> {
|
||||
@@ -190,6 +193,7 @@ fn row_to_request_log_detail(row: &rusqlite::Row<'_>) -> rusqlite::Result<Reques
|
||||
error_message: row.get(21)?,
|
||||
created_at: row.get(22)?,
|
||||
data_source: row.get(23)?,
|
||||
pricing_model: row.get(24)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -203,6 +207,7 @@ fn provider_name_coalesce(log_alias: &str, provider_alias: &str) -> String {
|
||||
WHEN '_session' THEN 'Claude (Session)' \
|
||||
WHEN '_codex_session' THEN 'Codex (Session)' \
|
||||
WHEN '_gemini_session' THEN 'Gemini (Session)' \
|
||||
WHEN '_opencode_session' THEN 'OpenCode (Session)' \
|
||||
ELSE {log_alias}.provider_id END)"
|
||||
)
|
||||
}
|
||||
@@ -223,7 +228,7 @@ pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String {
|
||||
let proxy_data_source = data_source_expr("proxy_dedup");
|
||||
format!(
|
||||
"NOT (
|
||||
{data_source} IN ('session_log', 'codex_session', 'gemini_session')
|
||||
{data_source} IN ('session_log', 'codex_session', 'gemini_session', 'opencode_session')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM proxy_request_logs proxy_dedup
|
||||
@@ -238,7 +243,7 @@ pub(crate) fn effective_usage_log_filter(log_alias: &str) -> String {
|
||||
proxy_dedup.cache_creation_tokens = {log_alias}.cache_creation_tokens
|
||||
OR (
|
||||
{log_alias}.cache_creation_tokens = 0
|
||||
AND {data_source} IN ('codex_session', 'gemini_session')
|
||||
AND {data_source} IN ('codex_session', 'gemini_session', 'opencode_session')
|
||||
)
|
||||
)
|
||||
AND proxy_dedup.created_at BETWEEN
|
||||
@@ -298,7 +303,7 @@ pub(crate) fn has_matching_proxy_usage_log(
|
||||
key: &DedupKey,
|
||||
) -> Result<bool, AppError> {
|
||||
let allow_missing_cache_creation =
|
||||
matches!(key.app_type, "codex" | "gemini") && key.cache_creation_tokens == 0;
|
||||
matches!(key.app_type, "codex" | "gemini" | "opencode") && key.cache_creation_tokens == 0;
|
||||
|
||||
let l_data_source = data_source_expr("l");
|
||||
let sql = format!(
|
||||
@@ -1153,6 +1158,11 @@ impl Database {
|
||||
};
|
||||
|
||||
// UNION detail logs + rollup data
|
||||
//
|
||||
// 分组键用「有效计价模型」:pricing_model 非空时优先(成本就是按它的
|
||||
// 定价算的,金额与定价表自洽),NULL/'' 回落 model。默认 response 计价
|
||||
// 模式下两者相同,行为不变;request 模式 + 路由接管下,钱挂在实际计价
|
||||
// 基准名下,而不是上游回显/客户端别名名下。
|
||||
let fresh_input_detail = fresh_input_sql("l");
|
||||
let fresh_input_rollup = fresh_input_sql("r");
|
||||
let sql = format!(
|
||||
@@ -1162,21 +1172,21 @@ impl Database {
|
||||
SUM(total_tokens) as total_tokens,
|
||||
SUM(total_cost) as total_cost
|
||||
FROM (
|
||||
SELECT l.model,
|
||||
SELECT COALESCE(NULLIF(l.pricing_model, ''), l.model) as model,
|
||||
COUNT(*) as request_count,
|
||||
COALESCE(SUM({fresh_input_detail} + l.output_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost
|
||||
FROM proxy_request_logs l
|
||||
{detail_where}
|
||||
GROUP BY l.model
|
||||
GROUP BY COALESCE(NULLIF(l.pricing_model, ''), l.model)
|
||||
UNION ALL
|
||||
SELECT r.model,
|
||||
SELECT COALESCE(NULLIF(r.pricing_model, ''), r.model),
|
||||
COALESCE(SUM(r.request_count), 0),
|
||||
COALESCE(SUM({fresh_input_rollup} + r.output_tokens), 0),
|
||||
COALESCE(SUM(CAST(r.total_cost_usd AS REAL)), 0)
|
||||
FROM usage_daily_rollups r
|
||||
{rollup_where}
|
||||
GROUP BY r.model
|
||||
GROUP BY COALESCE(NULLIF(r.pricing_model, ''), r.model)
|
||||
)
|
||||
GROUP BY model
|
||||
ORDER BY total_cost DESC"
|
||||
@@ -1280,7 +1290,7 @@ impl Database {
|
||||
l.input_tokens, l.output_tokens, l.cache_read_tokens, l.cache_creation_tokens,
|
||||
l.input_cost_usd, l.output_cost_usd, l.cache_read_cost_usd, l.cache_creation_cost_usd, l.total_cost_usd,
|
||||
l.is_streaming, l.latency_ms, l.first_token_ms, l.duration_ms,
|
||||
l.status_code, l.error_message, l.created_at, l.data_source
|
||||
l.status_code, l.error_message, l.created_at, l.data_source, l.pricing_model
|
||||
FROM proxy_request_logs l
|
||||
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
|
||||
{where_clause}
|
||||
@@ -1323,7 +1333,7 @@ impl Database {
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||
is_streaming, latency_ms, first_token_ms, duration_ms,
|
||||
status_code, error_message, created_at, l.data_source
|
||||
status_code, error_message, created_at, l.data_source, l.pricing_model
|
||||
FROM proxy_request_logs l
|
||||
LEFT JOIN providers p ON l.provider_id = p.id AND l.app_type = p.app_type
|
||||
WHERE l.request_id = ?"
|
||||
@@ -1468,7 +1478,7 @@ impl Database {
|
||||
Self::backfill_missing_usage_costs_on_conn(&conn, Some(model_id))
|
||||
}
|
||||
|
||||
fn backfill_missing_usage_costs_on_conn(
|
||||
pub(crate) fn backfill_missing_usage_costs_on_conn(
|
||||
conn: &Connection,
|
||||
only_model_id: Option<&str>,
|
||||
) -> Result<u64, AppError> {
|
||||
@@ -1479,7 +1489,7 @@ impl Database {
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd,
|
||||
cache_creation_cost_usd, total_cost_usd, is_streaming, latency_ms,
|
||||
first_token_ms, duration_ms, status_code, error_message, created_at,
|
||||
data_source
|
||||
data_source, pricing_model
|
||||
FROM proxy_request_logs
|
||||
WHERE CAST(total_cost_usd AS REAL) <= 0
|
||||
AND (input_tokens > 0 OR output_tokens > 0
|
||||
@@ -1488,7 +1498,9 @@ impl Database {
|
||||
let mut logs = {
|
||||
match only_model_id {
|
||||
Some(model) => {
|
||||
let sql = format!("{BASE_SQL} AND (model = ?1 OR request_model = ?1)");
|
||||
let sql = format!(
|
||||
"{BASE_SQL} AND (model = ?1 OR request_model = ?1 OR pricing_model = ?1)"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map([model], row_to_request_log_detail)?;
|
||||
rows.collect::<Result<Vec<_>, _>>()?
|
||||
@@ -1646,10 +1658,32 @@ impl Database {
|
||||
cache: &mut HashMap<String, PricingInfo>,
|
||||
log: &RequestLogDetail,
|
||||
) -> Result<Option<PricingInfo>, AppError> {
|
||||
// 写入时的计价基准已落库(v11+):回填只按它重算,找不到就保持 0 成本
|
||||
// 等补价。不能换用 model/request_model 猜——路由接管 + request 计价模式下
|
||||
// 三者可能各不相同(model=上游回显、request_model=客户端别名、
|
||||
// pricing_model=实际出站模型),换基准会按错误价格永久固化。
|
||||
// 占位符("" = 未计价错误行 / "unknown")视同缺失,走历史行逻辑。
|
||||
if let Some(pricing_model) = log
|
||||
.pricing_model
|
||||
.as_deref()
|
||||
.filter(|pm| !is_placeholder_pricing_model(pm))
|
||||
{
|
||||
return Self::get_model_pricing_cached(conn, cache, pricing_model);
|
||||
}
|
||||
|
||||
if let Some(pricing) = Self::get_model_pricing_cached(conn, cache, &log.model)? {
|
||||
return Ok(Some(pricing));
|
||||
}
|
||||
|
||||
// 仅当 model 列是占位符(解析失败留下的 ""/"unknown" 等)时才回退到
|
||||
// request_model 定价。model 是真实模型名但缺定价时必须保持 0 成本等待
|
||||
// 补价:路由接管下 request_model 是客户端别名(如 claude-sonnet-4-6),
|
||||
// 按别名回填会把真实上游模型的 tokens 按错误价格永久固化(行一旦有成本
|
||||
// 就不再进入回填范围)。
|
||||
if !is_placeholder_pricing_model(&log.model) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(request_model) = log.request_model.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -2189,6 +2223,123 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backfill_skips_request_model_fallback_for_real_unpriced_model() -> Result<(), AppError>
|
||||
{
|
||||
let db = Database::memory()?;
|
||||
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
// 路由接管场景:model 是上游回显的真实模型(缺定价),request_model
|
||||
// 是客户端别名(有定价)。回填不得按别名定价,必须保持 0 成本等待补价。
|
||||
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,
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd,
|
||||
total_cost_usd, latency_ms, status_code, created_at, data_source
|
||||
) VALUES (
|
||||
'takeover-unpriced-model', 'provider-1', 'claude',
|
||||
'takeover-real-model-unpriced', 'claude-sonnet-4-6',
|
||||
1000000, 0, 0, 0,
|
||||
'0', '0', '0', '0',
|
||||
'0', 100, 200, 1000, 'proxy'
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
// request_model(claude-sonnet-4-6)有定价,但 model 是真实模型名:不得回退
|
||||
assert_eq!(db.backfill_missing_usage_costs()?, 0);
|
||||
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
let total_cost: String = conn.query_row(
|
||||
"SELECT total_cost_usd
|
||||
FROM proxy_request_logs WHERE request_id = 'takeover-unpriced-model'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(total_cost, "0");
|
||||
|
||||
// 补上真实模型定价后,回填必须按真实模型价格修复(0 成本行未被污染固化)
|
||||
conn.execute(
|
||||
"INSERT INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
|
||||
VALUES ('takeover-real-model-unpriced', 'Takeover Real Model', '0.6', '2.5')",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
assert_eq!(db.backfill_missing_usage_costs()?, 1);
|
||||
|
||||
let conn = lock_conn!(db.conn);
|
||||
let total_cost: String = conn.query_row(
|
||||
"SELECT total_cost_usd
|
||||
FROM proxy_request_logs WHERE request_id = 'takeover-unpriced-model'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(total_cost, "0.600000");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backfill_uses_persisted_pricing_model() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
// request 计价模式 + 接管:写入时锚定出站模型 kimi-k2-novel(当时缺价),
|
||||
// 但上游回显了别名 → model/request_model 都是 claude-sonnet-4-6(有定价)。
|
||||
// 回填必须按落库的 pricing_model 重算,不得换用 model 列的别名价格。
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model, request_model, pricing_model,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd,
|
||||
total_cost_usd, latency_ms, status_code, created_at, data_source
|
||||
) VALUES (
|
||||
'persisted-pricing-model', 'provider-1', 'claude',
|
||||
'claude-sonnet-4-6', 'claude-sonnet-4-6', 'kimi-k2-novel',
|
||||
1000000, 0, 0, 0,
|
||||
'0', '0', '0', '0',
|
||||
'0', 100, 200, 1000, 'proxy'
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
// pricing_model(kimi-k2-novel)缺价:不得回退到 model 列的别名价格
|
||||
assert_eq!(db.backfill_missing_usage_costs()?, 0);
|
||||
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
|
||||
VALUES ('kimi-k2-novel', 'Kimi K2 Novel', '0.6', '2.5')",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
// 按 pricing_model 也能定位到该行(model/request_model 都不是 kimi-k2-novel)
|
||||
assert_eq!(
|
||||
db.backfill_missing_usage_costs_for_model("kimi-k2-novel")?,
|
||||
1
|
||||
);
|
||||
|
||||
let conn = lock_conn!(db.conn);
|
||||
let total_cost: String = conn.query_row(
|
||||
"SELECT total_cost_usd
|
||||
FROM proxy_request_logs WHERE request_id = 'persisted-pricing-model'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(total_cost, "0.600000");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backfill_missing_usage_costs_keeps_claude_fresh_input() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
@@ -2832,6 +2983,37 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_provider_stats_labels_opencode_session_provider() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
insert_usage_log(
|
||||
&conn,
|
||||
"opencode-session",
|
||||
"opencode",
|
||||
"_opencode_session",
|
||||
"opencode-model",
|
||||
"opencode_session",
|
||||
1000,
|
||||
100,
|
||||
50,
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
"0.01",
|
||||
)?;
|
||||
}
|
||||
|
||||
let stats = db.get_provider_stats(None, None, Some("opencode"))?;
|
||||
assert_eq!(stats.len(), 1);
|
||||
assert_eq!(stats[0].provider_id, "_opencode_session");
|
||||
assert_eq!(stats[0].provider_name, "OpenCode (Session)");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_provider_stats_excludes_partial_rollup_boundary_days() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
@@ -4,16 +4,11 @@
|
||||
//! primitives in [`super::webdav`]. Artifact set: `db.sql` + `skills.zip`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::future::Future;
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tempfile::tempdir;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::services::webdav::{
|
||||
@@ -22,23 +17,17 @@ use crate::services::webdav::{
|
||||
};
|
||||
use crate::settings::{update_webdav_sync_status, WebDavSyncSettings, WebDavSyncStatus};
|
||||
|
||||
mod archive;
|
||||
use archive::{
|
||||
backup_current_skills, restore_skills_from_backup, restore_skills_zip, zip_skills_ssot,
|
||||
use super::sync_protocol::{
|
||||
apply_snapshot, build_local_snapshot, effective_db_compat_version, localized,
|
||||
persist_sync_success_best_effort, sha256_hex, validate_artifact_size_limit,
|
||||
validate_manifest_compat, verify_artifact, ArtifactMeta, RemoteLayout, SyncManifest,
|
||||
DB_COMPAT_VERSION, MAX_MANIFEST_BYTES, MAX_SYNC_ARTIFACT_BYTES, PROTOCOL_VERSION,
|
||||
REMOTE_DB_SQL, REMOTE_MANIFEST, REMOTE_SKILLS_ZIP,
|
||||
};
|
||||
|
||||
// ─── Protocol constants ──────────────────────────────────────
|
||||
pub(crate) mod archive;
|
||||
|
||||
const PROTOCOL_FORMAT: &str = "cc-switch-webdav-sync";
|
||||
const PROTOCOL_VERSION: u32 = 2;
|
||||
const DB_COMPAT_VERSION: u32 = 6;
|
||||
const LEGACY_DB_COMPAT_VERSION: u32 = 5;
|
||||
const REMOTE_DB_SQL: &str = "db.sql";
|
||||
const REMOTE_SKILLS_ZIP: &str = "skills.zip";
|
||||
const REMOTE_MANIFEST: &str = "manifest.json";
|
||||
const MAX_DEVICE_NAME_LEN: usize = 64;
|
||||
const MAX_MANIFEST_BYTES: usize = 1024 * 1024;
|
||||
pub(super) const MAX_SYNC_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;
|
||||
// ─── Sync lock ───────────────────────────────────────────────
|
||||
|
||||
pub fn sync_mutex() -> &'static tokio::sync::Mutex<()> {
|
||||
static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
|
||||
@@ -53,74 +42,12 @@ where
|
||||
operation.await
|
||||
}
|
||||
|
||||
fn localized(key: &'static str, zh: impl Into<String>, en: impl Into<String>) -> AppError {
|
||||
AppError::localized(key, zh, en)
|
||||
}
|
||||
|
||||
fn io_context_localized(
|
||||
_key: &'static str,
|
||||
zh: impl Into<String>,
|
||||
en: impl Into<String>,
|
||||
source: std::io::Error,
|
||||
) -> AppError {
|
||||
let zh_msg = zh.into();
|
||||
let en_msg = en.into();
|
||||
AppError::IoContext {
|
||||
context: format!("{zh_msg} ({en_msg})"),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SyncManifest {
|
||||
format: String,
|
||||
version: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
db_compat_version: Option<u32>,
|
||||
device_name: String,
|
||||
created_at: String,
|
||||
artifacts: BTreeMap<String, ArtifactMeta>,
|
||||
snapshot_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ArtifactMeta {
|
||||
sha256: String,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
struct LocalSnapshot {
|
||||
db_sql: Vec<u8>,
|
||||
skills_zip: Vec<u8>,
|
||||
manifest_bytes: Vec<u8>,
|
||||
manifest_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RemoteLayout {
|
||||
Current,
|
||||
Legacy,
|
||||
}
|
||||
|
||||
impl RemoteLayout {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Current => "current",
|
||||
Self::Legacy => "legacy",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteSnapshot {
|
||||
layout: RemoteLayout,
|
||||
manifest: SyncManifest,
|
||||
manifest_bytes: Vec<u8>,
|
||||
manifest_etag: Option<String>,
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────
|
||||
|
||||
/// Check WebDAV connectivity and ensure remote directory structure.
|
||||
@@ -143,7 +70,7 @@ pub async fn upload(
|
||||
let dir_segs = remote_dir_segments(settings, RemoteLayout::Current);
|
||||
ensure_remote_directories(&settings.base_url, &dir_segs, &auth).await?;
|
||||
|
||||
let snapshot = build_local_snapshot(db, settings)?;
|
||||
let snapshot = build_local_snapshot(db)?;
|
||||
|
||||
// Upload order: artifacts first, manifest last (best-effort consistency)
|
||||
let db_url = remote_file_url(settings, RemoteLayout::Current, REMOTE_DB_SQL)?;
|
||||
@@ -259,7 +186,7 @@ pub async fn fetch_remote_info(settings: &WebDavSyncSettings) -> Result<Option<V
|
||||
Ok(Some(payload))
|
||||
}
|
||||
|
||||
// ─── Sync status persistence (I3: deduplicated) ─────────────
|
||||
// ─── Sync status persistence ─────────────────────────────────
|
||||
|
||||
fn persist_sync_success(
|
||||
settings: &mut WebDavSyncSettings,
|
||||
@@ -278,214 +205,6 @@ fn persist_sync_success(
|
||||
update_webdav_sync_status(status)
|
||||
}
|
||||
|
||||
fn persist_sync_success_best_effort<F>(
|
||||
settings: &mut WebDavSyncSettings,
|
||||
manifest_hash: String,
|
||||
etag: Option<String>,
|
||||
persist_fn: F,
|
||||
) -> bool
|
||||
where
|
||||
F: FnOnce(&mut WebDavSyncSettings, String, Option<String>) -> Result<(), AppError>,
|
||||
{
|
||||
match persist_fn(settings, manifest_hash, etag) {
|
||||
Ok(()) => true,
|
||||
Err(err) => {
|
||||
log::warn!("[WebDAV] Persist sync status failed, keep operation success: {err}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Snapshot building ───────────────────────────────────────
|
||||
|
||||
fn build_local_snapshot(
|
||||
db: &crate::database::Database,
|
||||
_settings: &WebDavSyncSettings,
|
||||
) -> Result<LocalSnapshot, AppError> {
|
||||
// Export database to SQL string
|
||||
let sql_string = db.export_sql_string_for_sync()?;
|
||||
let db_sql = sql_string.into_bytes();
|
||||
|
||||
// Pack skills into deterministic ZIP
|
||||
let tmp = tempdir().map_err(|e| {
|
||||
io_context_localized(
|
||||
"webdav.sync.snapshot_tmpdir_failed",
|
||||
"创建 WebDAV 快照临时目录失败",
|
||||
"Failed to create temporary directory for WebDAV snapshot",
|
||||
e,
|
||||
)
|
||||
})?;
|
||||
let skills_zip_path = tmp.path().join(REMOTE_SKILLS_ZIP);
|
||||
zip_skills_ssot(&skills_zip_path)?;
|
||||
let skills_zip = fs::read(&skills_zip_path).map_err(|e| AppError::io(&skills_zip_path, e))?;
|
||||
|
||||
// Build artifact map and compute hashes
|
||||
let mut artifacts = BTreeMap::new();
|
||||
artifacts.insert(
|
||||
REMOTE_DB_SQL.to_string(),
|
||||
ArtifactMeta {
|
||||
sha256: sha256_hex(&db_sql),
|
||||
size: db_sql.len() as u64,
|
||||
},
|
||||
);
|
||||
artifacts.insert(
|
||||
REMOTE_SKILLS_ZIP.to_string(),
|
||||
ArtifactMeta {
|
||||
sha256: sha256_hex(&skills_zip),
|
||||
size: skills_zip.len() as u64,
|
||||
},
|
||||
);
|
||||
|
||||
let snapshot_id = compute_snapshot_id(&artifacts);
|
||||
let manifest = SyncManifest {
|
||||
format: PROTOCOL_FORMAT.to_string(),
|
||||
version: PROTOCOL_VERSION,
|
||||
db_compat_version: Some(DB_COMPAT_VERSION),
|
||||
device_name: detect_system_device_name().unwrap_or_else(|| "Unknown Device".to_string()),
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
artifacts,
|
||||
snapshot_id,
|
||||
};
|
||||
let manifest_bytes =
|
||||
serde_json::to_vec_pretty(&manifest).map_err(|e| AppError::JsonSerialize { source: e })?;
|
||||
let manifest_hash = sha256_hex(&manifest_bytes);
|
||||
|
||||
Ok(LocalSnapshot {
|
||||
db_sql,
|
||||
skills_zip,
|
||||
manifest_bytes,
|
||||
manifest_hash,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute a deterministic snapshot identity from artifact hashes.
|
||||
///
|
||||
/// BTreeMap iteration order is sorted by key, ensuring stability.
|
||||
fn compute_snapshot_id(artifacts: &BTreeMap<String, ArtifactMeta>) -> String {
|
||||
let parts: Vec<String> = artifacts
|
||||
.iter()
|
||||
.map(|(name, meta)| format!("{}:{}", name, meta.sha256))
|
||||
.collect();
|
||||
sha256_hex(parts.join("|").as_bytes())
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
fn detect_system_device_name() -> Option<String> {
|
||||
let env_name = ["CC_SWITCH_DEVICE_NAME", "COMPUTERNAME", "HOSTNAME"]
|
||||
.iter()
|
||||
.filter_map(|key| std::env::var(key).ok())
|
||||
.find_map(|value| normalize_device_name(&value));
|
||||
|
||||
if env_name.is_some() {
|
||||
return env_name;
|
||||
}
|
||||
|
||||
let output = Command::new("hostname").output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let hostname = String::from_utf8(output.stdout).ok()?;
|
||||
normalize_device_name(&hostname)
|
||||
}
|
||||
|
||||
fn normalize_device_name(raw: &str) -> Option<String> {
|
||||
let compact = raw
|
||||
.chars()
|
||||
.fold(String::with_capacity(raw.len()), |mut acc, ch| {
|
||||
if ch.is_whitespace() {
|
||||
acc.push(' ');
|
||||
} else if !ch.is_control() {
|
||||
acc.push(ch);
|
||||
}
|
||||
acc
|
||||
});
|
||||
let normalized = compact.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let trimmed = normalized.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let limited = trimmed
|
||||
.chars()
|
||||
.take(MAX_DEVICE_NAME_LEN)
|
||||
.collect::<String>();
|
||||
if limited.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(limited)
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_db_compat_version(manifest: &SyncManifest, layout: RemoteLayout) -> Option<u32> {
|
||||
manifest
|
||||
.db_compat_version
|
||||
.or_else(|| (layout == RemoteLayout::Legacy).then_some(LEGACY_DB_COMPAT_VERSION))
|
||||
}
|
||||
|
||||
fn validate_manifest_compat(manifest: &SyncManifest, layout: RemoteLayout) -> Result<(), AppError> {
|
||||
if manifest.format != PROTOCOL_FORMAT {
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_format_incompatible",
|
||||
format!("远端 manifest 格式不兼容: {}", manifest.format),
|
||||
format!(
|
||||
"Remote manifest format is incompatible: {}",
|
||||
manifest.format
|
||||
),
|
||||
));
|
||||
}
|
||||
if manifest.version != PROTOCOL_VERSION {
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_version_incompatible",
|
||||
format!(
|
||||
"远端 manifest 协议版本不兼容: v{} (本地 v{PROTOCOL_VERSION})",
|
||||
manifest.version
|
||||
),
|
||||
format!(
|
||||
"Remote manifest protocol version is incompatible: v{} (local v{PROTOCOL_VERSION})",
|
||||
manifest.version
|
||||
),
|
||||
));
|
||||
}
|
||||
let Some(db_compat_version) = effective_db_compat_version(manifest, layout) else {
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_db_version_missing",
|
||||
"远端 manifest 缺少数据库兼容版本",
|
||||
"Remote manifest is missing the database compatibility version.",
|
||||
));
|
||||
};
|
||||
match layout {
|
||||
RemoteLayout::Current if db_compat_version != DB_COMPAT_VERSION => {
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_db_version_incompatible",
|
||||
format!(
|
||||
"远端数据库快照版本不兼容: db-v{db_compat_version} (本地 db-v{DB_COMPAT_VERSION})"
|
||||
),
|
||||
format!(
|
||||
"Remote database snapshot version is incompatible: db-v{db_compat_version} (local db-v{DB_COMPAT_VERSION})"
|
||||
),
|
||||
));
|
||||
}
|
||||
RemoteLayout::Legacy if db_compat_version > DB_COMPAT_VERSION => {
|
||||
return Err(localized(
|
||||
"webdav.sync.manifest_db_version_incompatible",
|
||||
format!(
|
||||
"远端数据库快照版本不兼容: db-v{db_compat_version} (本地最高支持 db-v{DB_COMPAT_VERSION})"
|
||||
),
|
||||
format!(
|
||||
"Remote database snapshot version is incompatible: db-v{db_compat_version} (local supports up to db-v{DB_COMPAT_VERSION})"
|
||||
),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_remote_snapshot(
|
||||
settings: &WebDavSyncSettings,
|
||||
auth: &WebDavAuth,
|
||||
@@ -521,7 +240,6 @@ async fn fetch_remote_snapshot(
|
||||
manifest_etag,
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── Download & verify ───────────────────────────────────────
|
||||
|
||||
async fn download_and_verify(
|
||||
@@ -551,75 +269,10 @@ async fn download_and_verify(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Quick size check before expensive hash
|
||||
if bytes.len() as u64 != meta.size {
|
||||
return Err(localized(
|
||||
"webdav.sync.artifact_size_mismatch",
|
||||
format!(
|
||||
"artifact {artifact_name} 大小不匹配 (expected: {}, got: {})",
|
||||
meta.size,
|
||||
bytes.len(),
|
||||
),
|
||||
format!(
|
||||
"Artifact {artifact_name} size mismatch (expected: {}, got: {})",
|
||||
meta.size,
|
||||
bytes.len(),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let actual_hash = sha256_hex(&bytes);
|
||||
if actual_hash != meta.sha256 {
|
||||
return Err(localized(
|
||||
"webdav.sync.artifact_hash_mismatch",
|
||||
format!(
|
||||
"artifact {artifact_name} SHA256 校验失败 (expected: {}..., got: {}...)",
|
||||
meta.sha256.get(..8).unwrap_or(&meta.sha256),
|
||||
actual_hash.get(..8).unwrap_or(&actual_hash),
|
||||
),
|
||||
format!(
|
||||
"Artifact {artifact_name} SHA256 verification failed (expected: {}..., got: {}...)",
|
||||
meta.sha256.get(..8).unwrap_or(&meta.sha256),
|
||||
actual_hash.get(..8).unwrap_or(&actual_hash),
|
||||
),
|
||||
));
|
||||
}
|
||||
verify_artifact(&bytes, artifact_name, meta)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn apply_snapshot(
|
||||
db: &crate::database::Database,
|
||||
db_sql: &[u8],
|
||||
skills_zip: &[u8],
|
||||
) -> Result<(), AppError> {
|
||||
let sql_str = std::str::from_utf8(db_sql).map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.sql_not_utf8",
|
||||
format!("SQL 非 UTF-8: {e}"),
|
||||
format!("SQL is not valid UTF-8: {e}"),
|
||||
)
|
||||
})?;
|
||||
let skills_backup = backup_current_skills()?;
|
||||
|
||||
// 先替换 skills,再导入数据库;若导入失败则回滚 skills,避免“半恢复”。
|
||||
restore_skills_zip(skills_zip)?;
|
||||
|
||||
if let Err(db_err) = db.import_sql_string_for_sync(sql_str) {
|
||||
if let Err(rollback_err) = restore_skills_from_backup(&skills_backup) {
|
||||
return Err(localized(
|
||||
"webdav.sync.db_import_and_rollback_failed",
|
||||
format!("导入数据库失败: {db_err}; 同时回滚 Skills 失败: {rollback_err}"),
|
||||
format!(
|
||||
"Database import failed: {db_err}; skills rollback also failed: {rollback_err}"
|
||||
),
|
||||
));
|
||||
}
|
||||
return Err(db_err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Remote path helpers ─────────────────────────────────────
|
||||
|
||||
fn remote_dir_segments(settings: &WebDavSyncSettings, layout: RemoteLayout) -> Vec<String> {
|
||||
@@ -652,53 +305,12 @@ fn auth_for(settings: &WebDavSyncSettings) -> WebDavAuth {
|
||||
auth_from_credentials(&settings.username, &settings.password)
|
||||
}
|
||||
|
||||
fn validate_artifact_size_limit(artifact_name: &str, size: u64) -> Result<(), AppError> {
|
||||
if size > MAX_SYNC_ARTIFACT_BYTES {
|
||||
let max_mb = MAX_SYNC_ARTIFACT_BYTES / 1024 / 1024;
|
||||
return Err(localized(
|
||||
"webdav.sync.artifact_too_large",
|
||||
format!("artifact {artifact_name} 超过下载上限({max_mb} MB)"),
|
||||
format!("Artifact {artifact_name} exceeds download limit ({max_mb} MB)"),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn artifact(sha256: &str, size: u64) -> ArtifactMeta {
|
||||
ArtifactMeta {
|
||||
sha256: sha256.to_string(),
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_id_is_stable() {
|
||||
let mut artifacts = BTreeMap::new();
|
||||
artifacts.insert("db.sql".to_string(), artifact("abc123", 100));
|
||||
artifacts.insert("skills.zip".to_string(), artifact("def456", 200));
|
||||
|
||||
let id1 = compute_snapshot_id(&artifacts);
|
||||
let id2 = compute_snapshot_id(&artifacts);
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_id_changes_with_artifacts() {
|
||||
let mut a1 = BTreeMap::new();
|
||||
a1.insert("db.sql".to_string(), artifact("hash-a", 1));
|
||||
|
||||
let mut a2 = BTreeMap::new();
|
||||
a2.insert("db.sql".to_string(), artifact("hash-b", 1));
|
||||
|
||||
assert_ne!(compute_snapshot_id(&a1), compute_snapshot_id(&a2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_dir_segments_uses_current_layout() {
|
||||
let settings = WebDavSyncSettings {
|
||||
@@ -720,165 +332,4 @@ mod tests {
|
||||
let segs = remote_dir_segments(&settings, RemoteLayout::Legacy);
|
||||
assert_eq!(segs, vec!["cc-switch-sync", "v2", "default"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_hex_is_correct() {
|
||||
let hash = sha256_hex(b"hello");
|
||||
assert_eq!(
|
||||
hash,
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_best_effort_returns_true_on_success() {
|
||||
let mut settings = WebDavSyncSettings::default();
|
||||
let ok = persist_sync_success_best_effort(
|
||||
&mut settings,
|
||||
"hash".to_string(),
|
||||
Some("etag".to_string()),
|
||||
|_settings, _hash, _etag| Ok(()),
|
||||
);
|
||||
assert!(ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_best_effort_returns_false_on_error() {
|
||||
let mut settings = WebDavSyncSettings::default();
|
||||
let ok = persist_sync_success_best_effort(
|
||||
&mut settings,
|
||||
"hash".to_string(),
|
||||
None,
|
||||
|_settings, _hash, _etag| Err(AppError::Config("boom".to_string())),
|
||||
);
|
||||
assert!(!ok);
|
||||
}
|
||||
|
||||
fn manifest_with(format: &str, version: u32, db_compat_version: Option<u32>) -> SyncManifest {
|
||||
let mut artifacts = BTreeMap::new();
|
||||
artifacts.insert("db.sql".to_string(), artifact("abc", 1));
|
||||
artifacts.insert("skills.zip".to_string(), artifact("def", 2));
|
||||
SyncManifest {
|
||||
format: format.to_string(),
|
||||
version,
|
||||
db_compat_version,
|
||||
device_name: "My MacBook".to_string(),
|
||||
created_at: "2026-02-12T00:00:00Z".to_string(),
|
||||
artifacts,
|
||||
snapshot_id: "snap-1".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_accepts_supported_manifest() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_wrong_format() {
|
||||
let manifest = manifest_with("other-format", PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_wrong_version() {
|
||||
let manifest = manifest_with(
|
||||
PROTOCOL_FORMAT,
|
||||
PROTOCOL_VERSION + 1,
|
||||
Some(DB_COMPAT_VERSION),
|
||||
);
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_accepts_legacy_manifest_without_db_compat() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, None);
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Legacy).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_current_manifest_with_wrong_db_compat() {
|
||||
let manifest = manifest_with(
|
||||
PROTOCOL_FORMAT,
|
||||
PROTOCOL_VERSION,
|
||||
Some(LEGACY_DB_COMPAT_VERSION),
|
||||
);
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Current).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_compat_rejects_legacy_manifest_from_newer_db_generation() {
|
||||
let manifest = manifest_with(
|
||||
PROTOCOL_FORMAT,
|
||||
PROTOCOL_VERSION,
|
||||
Some(DB_COMPAT_VERSION + 1),
|
||||
);
|
||||
assert!(validate_manifest_compat(&manifest, RemoteLayout::Legacy).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_db_compat_version_defaults_legacy_layout_to_v5() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, None);
|
||||
assert_eq!(
|
||||
effective_db_compat_version(&manifest, RemoteLayout::Legacy),
|
||||
Some(LEGACY_DB_COMPAT_VERSION)
|
||||
);
|
||||
assert_eq!(
|
||||
effective_db_compat_version(&manifest, RemoteLayout::Current),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_device_name_returns_none_for_blank_input() {
|
||||
assert_eq!(normalize_device_name(" \n\t "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_device_name_collapses_whitespace_and_drops_control_chars() {
|
||||
assert_eq!(
|
||||
normalize_device_name(" Mac\tBook \n Pro\u{0007} "),
|
||||
Some("Mac Book Pro".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_device_name_truncates_to_max_len() {
|
||||
let long = "a".repeat(80);
|
||||
assert_eq!(normalize_device_name(&long).map(|s| s.len()), Some(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_serialization_uses_device_name_only() {
|
||||
let manifest = manifest_with(PROTOCOL_FORMAT, PROTOCOL_VERSION, Some(DB_COMPAT_VERSION));
|
||||
let value = serde_json::to_value(&manifest).expect("serialize manifest");
|
||||
assert!(
|
||||
value.get("deviceName").is_some(),
|
||||
"manifest should contain deviceName"
|
||||
);
|
||||
assert_eq!(
|
||||
value.get("dbCompatVersion").and_then(|v| v.as_u64()),
|
||||
Some(DB_COMPAT_VERSION as u64)
|
||||
);
|
||||
assert!(
|
||||
value.get("deviceId").is_none(),
|
||||
"manifest should not contain deviceId"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_artifact_size_limit_rejects_oversized_artifacts() {
|
||||
let err = validate_artifact_size_limit("skills.zip", MAX_SYNC_ARTIFACT_BYTES + 1)
|
||||
.expect_err("artifact larger than limit should be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("too large") || err.to_string().contains("超过"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_artifact_size_limit_accepts_limit_boundary() {
|
||||
assert!(validate_artifact_size_limit("skills.zip", MAX_SYNC_ARTIFACT_BYTES).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,19 +10,21 @@ use zip::DateTime;
|
||||
use crate::error::AppError;
|
||||
use crate::services::skill::SkillService;
|
||||
|
||||
use super::{io_context_localized, localized, MAX_SYNC_ARTIFACT_BYTES, REMOTE_SKILLS_ZIP};
|
||||
use crate::services::sync_protocol::{
|
||||
io_context_localized, localized, MAX_SYNC_ARTIFACT_BYTES, REMOTE_SKILLS_ZIP,
|
||||
};
|
||||
|
||||
/// Maximum number of entries allowed in a zip archive.
|
||||
const MAX_EXTRACT_ENTRIES: usize = 10_000;
|
||||
|
||||
pub(super) struct SkillsBackup {
|
||||
pub(crate) struct SkillsBackup {
|
||||
_tmp: TempDir,
|
||||
backup_dir: PathBuf,
|
||||
ssot_path: PathBuf,
|
||||
existed: bool,
|
||||
}
|
||||
|
||||
pub(super) fn zip_skills_ssot(dest_path: &Path) -> Result<(), AppError> {
|
||||
pub(crate) fn zip_skills_ssot(dest_path: &Path) -> Result<(), AppError> {
|
||||
let source = SkillService::get_ssot_dir().map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.skills_ssot_dir_failed",
|
||||
@@ -63,7 +65,7 @@ pub(super) fn zip_skills_ssot(dest_path: &Path) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn restore_skills_zip(raw: &[u8]) -> Result<(), AppError> {
|
||||
pub(crate) fn restore_skills_zip(raw: &[u8]) -> Result<(), AppError> {
|
||||
let tmp = tempdir().map_err(|e| {
|
||||
io_context_localized(
|
||||
"webdav.sync.skills_extract_tmpdir_failed",
|
||||
@@ -159,7 +161,7 @@ pub(super) fn restore_skills_zip(raw: &[u8]) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn backup_current_skills() -> Result<SkillsBackup, AppError> {
|
||||
pub(crate) fn backup_current_skills() -> Result<SkillsBackup, AppError> {
|
||||
let ssot = SkillService::get_ssot_dir().map_err(|e| {
|
||||
localized(
|
||||
"webdav.sync.skills_ssot_dir_failed",
|
||||
@@ -190,7 +192,7 @@ pub(super) fn backup_current_skills() -> Result<SkillsBackup, AppError> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn restore_skills_from_backup(backup: &SkillsBackup) -> Result<(), AppError> {
|
||||
pub(crate) fn restore_skills_from_backup(backup: &SkillsBackup) -> Result<(), AppError> {
|
||||
if backup.ssot_path.exists() {
|
||||
fs::remove_dir_all(&backup.ssot_path).map_err(|e| AppError::io(&backup.ssot_path, e))?;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ use super::utils::{
|
||||
};
|
||||
|
||||
const PROVIDER_ID: &str = "codex";
|
||||
const VSCODE_CONTEXT_PREFIX: &str = "# Context from my IDE setup:";
|
||||
const CODEX_REQUEST_MARKER: &str = "my request for codex";
|
||||
|
||||
static UUID_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
||||
@@ -186,12 +188,8 @@ fn parse_session(path: &Path) -> Option<SessionMeta> {
|
||||
&& payload.get("role").and_then(Value::as_str) == Some("user")
|
||||
{
|
||||
let text = payload.get("content").map(extract_text).unwrap_or_default();
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty()
|
||||
&& !trimmed.starts_with("# AGENTS.md")
|
||||
&& !trimmed.starts_with("<environment_context>")
|
||||
{
|
||||
first_user_message = Some(trimmed.to_string());
|
||||
if let Some(title) = title_candidate_from_user_message(&text) {
|
||||
first_user_message = Some(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,6 +264,80 @@ fn is_subagent_source(source: Option<&Value>) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn title_candidate_from_user_message(text: &str) -> Option<String> {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty()
|
||||
|| trimmed.starts_with("# AGENTS.md")
|
||||
|| trimmed.starts_with("<environment_context>")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if trimmed.starts_with(VSCODE_CONTEXT_PREFIX) {
|
||||
return extract_codex_prompt_from_ide_context(trimmed);
|
||||
}
|
||||
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn extract_codex_prompt_from_ide_context(text: &str) -> Option<String> {
|
||||
let normalized = text.replace("\r\n", "\n");
|
||||
let lines = normalized.lines().collect::<Vec<_>>();
|
||||
|
||||
// VS Code injects the real prompt as the LAST "## My request for Codex:"
|
||||
// section, so keep the final matching heading. Earlier matches can be
|
||||
// headings that live inside the active selection / open file content.
|
||||
// Trade-off: if the request body itself repeats the heading, the title
|
||||
// truncates to its trailing part (rare; covered by tests below).
|
||||
let mut prompt: Option<String> = None;
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
let Some(inline_prompt) = codex_request_heading_payload(line) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !inline_prompt.is_empty() {
|
||||
prompt = Some(inline_prompt.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
let following_prompt = lines[index + 1..].join("\n").trim().to_string();
|
||||
prompt = (!following_prompt.is_empty()).then_some(following_prompt);
|
||||
}
|
||||
|
||||
prompt
|
||||
}
|
||||
|
||||
fn codex_request_heading_payload(line: &str) -> Option<&str> {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let heading = trimmed.trim_start_matches('#').trim_start();
|
||||
let lowered = heading.to_ascii_lowercase();
|
||||
if !lowered.starts_with(CODEX_REQUEST_MARKER) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let suffix = heading[CODEX_REQUEST_MARKER.len()..].trim_start();
|
||||
if suffix.is_empty() {
|
||||
return Some("");
|
||||
}
|
||||
|
||||
let Some(separator) = suffix.chars().next() else {
|
||||
return Some("");
|
||||
};
|
||||
if !matches!(separator, ':' | ':' | '-' | '—') {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
suffix
|
||||
.trim_start_matches(|c: char| c.is_whitespace() || matches!(c, ':' | ':' | '-' | '—'))
|
||||
.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
fn infer_session_id_from_filename(path: &Path) -> Option<String> {
|
||||
let file_name = path.file_name()?.to_string_lossy();
|
||||
UUID_RE.find(&file_name).map(|mat| mat.as_str().to_string())
|
||||
@@ -426,6 +498,114 @@ mod tests {
|
||||
assert_eq!(meta.title.as_deref(), Some("Fix the login bug"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_extracts_vscode_ide_request_as_title() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# Context from my IDE setup:\\n\\n## Active file: src/main.ts\\n\\n## My request for Codex:\\nFix the session title preview\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("Fix the session title preview"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_extracts_inline_vscode_ide_request_as_title() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# Context from my IDE setup:\\n\\n## My request for Codex: Fix the TOC preview\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("Fix the TOC preview"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_ignores_marker_mentions_before_request_heading() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# Context from my IDE setup:\\n\\n## Active selection:\\nMy request for Codex: not the prompt\\n\\n## My request for Codex:\\nUse the real request heading\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("Use the real request heading"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_uses_last_request_heading_when_selection_has_one() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# Context from my IDE setup:\\n\\n## Active selection: docs/codex-format.md\\n## My request for Codex:\\nselected document content, not the real request\\n\\n## My request for Codex:\\nUse the last request heading\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("Use the last request heading"));
|
||||
}
|
||||
|
||||
// Known limitation: the IDE marker is matched purely by text, so a
|
||||
// "## My request for Codex:" line inside the real request body is treated as
|
||||
// a new boundary and only the trailing part is kept. This pins the
|
||||
// best-effort behavior; fully fixing it needs structured IDE section data
|
||||
// that the Codex VS Code context does not provide.
|
||||
#[test]
|
||||
fn parse_session_keeps_trailing_part_when_request_body_repeats_heading() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# Context from my IDE setup:\\n\\n## Active file: foo.ts\\n\\n## My request for Codex:\\nDocument the format, for example:\\n## My request for Codex:\\nand the rest follows.\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("and the rest follows."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_skips_vscode_ide_context_without_request() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
let path = temp.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"timestamp\":\"2026-03-06T21:50:12Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"test-id\",\"cwd\":\"/tmp/project\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:13Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"# Context from my IDE setup:\\n\\n## Active file: src/main.ts\"}}\n",
|
||||
"{\"timestamp\":\"2026-03-06T21:50:14Z\",\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",\"content\":\"Fix the login bug\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write");
|
||||
|
||||
let meta = parse_session(&path).unwrap();
|
||||
assert_eq!(meta.title.as_deref(), Some("Fix the login bug"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_session_falls_back_to_dir_basename() {
|
||||
let temp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -446,20 +446,17 @@ mod tests {
|
||||
.expect("write session");
|
||||
std::fs::write(
|
||||
sessions_dir.join("sessions.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"agent:main:main": {{
|
||||
serde_json::to_string(&serde_json::json!({
|
||||
"agent:main:main": {
|
||||
"sessionId": "session-123",
|
||||
"sessionFile": "{}"
|
||||
}},
|
||||
"agent:main:other": {{
|
||||
"sessionFile": session_path.to_string_lossy(),
|
||||
},
|
||||
"agent:main:other": {
|
||||
"sessionId": "session-456",
|
||||
"sessionFile": "{}/session-456.jsonl"
|
||||
}}
|
||||
}}"#,
|
||||
session_path.display(),
|
||||
sessions_dir.display()
|
||||
),
|
||||
"sessionFile": sessions_dir.join("session-456.jsonl").to_string_lossy(),
|
||||
},
|
||||
}))
|
||||
.expect("serialize index"),
|
||||
)
|
||||
.expect("write index");
|
||||
|
||||
|
||||
@@ -176,6 +176,106 @@ impl WebDavSyncSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// S3 同步设置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct S3SyncSettings {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub auto_sync: bool,
|
||||
#[serde(default)]
|
||||
pub region: String,
|
||||
#[serde(default)]
|
||||
pub bucket: String,
|
||||
#[serde(default)]
|
||||
pub access_key_id: String,
|
||||
#[serde(default)]
|
||||
pub secret_access_key: String,
|
||||
#[serde(default)]
|
||||
pub endpoint: String,
|
||||
#[serde(default = "default_remote_root")]
|
||||
pub remote_root: String,
|
||||
#[serde(default = "default_profile")]
|
||||
pub profile: String,
|
||||
#[serde(default)]
|
||||
pub status: WebDavSyncStatus,
|
||||
}
|
||||
|
||||
impl Default for S3SyncSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
auto_sync: false,
|
||||
region: String::new(),
|
||||
bucket: String::new(),
|
||||
access_key_id: String::new(),
|
||||
secret_access_key: String::new(),
|
||||
endpoint: String::new(),
|
||||
remote_root: default_remote_root(),
|
||||
profile: default_profile(),
|
||||
status: WebDavSyncStatus::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl S3SyncSettings {
|
||||
pub fn validate(&self) -> Result<(), crate::error::AppError> {
|
||||
if self.bucket.trim().is_empty() {
|
||||
return Err(crate::error::AppError::localized(
|
||||
"s3.bucket.required",
|
||||
"S3 存储桶不能为空",
|
||||
"S3 bucket is required.",
|
||||
));
|
||||
}
|
||||
if self.region.trim().is_empty() {
|
||||
return Err(crate::error::AppError::localized(
|
||||
"s3.region.required",
|
||||
"S3 区域不能为空",
|
||||
"S3 region is required.",
|
||||
));
|
||||
}
|
||||
if self.access_key_id.trim().is_empty() {
|
||||
return Err(crate::error::AppError::localized(
|
||||
"s3.access_key_id.required",
|
||||
"S3 Access Key ID 不能为空",
|
||||
"S3 Access Key ID is required.",
|
||||
));
|
||||
}
|
||||
if self.secret_access_key.trim().is_empty() {
|
||||
return Err(crate::error::AppError::localized(
|
||||
"s3.secret_access_key.required",
|
||||
"S3 Secret Access Key 不能为空",
|
||||
"S3 Secret Access Key is required.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn normalize(&mut self) {
|
||||
self.region = self.region.trim().to_string();
|
||||
self.bucket = self.bucket.trim().to_string();
|
||||
self.access_key_id = self.access_key_id.trim().to_string();
|
||||
self.endpoint = self.endpoint.trim().to_string();
|
||||
self.remote_root = self.remote_root.trim().to_string();
|
||||
self.profile = self.profile.trim().to_string();
|
||||
if self.remote_root.is_empty() {
|
||||
self.remote_root = default_remote_root();
|
||||
}
|
||||
if self.profile.is_empty() {
|
||||
self.profile = default_profile();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if all credential fields are blank (no config to persist).
|
||||
fn is_empty(&self) -> bool {
|
||||
self.bucket.is_empty()
|
||||
&& self.region.is_empty()
|
||||
&& self.access_key_id.is_empty()
|
||||
&& self.secret_access_key.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 本机自动迁移状态。
|
||||
///
|
||||
/// 这里记录的是本机启动时执行过的一次性迁移;标记不随数据库同步。
|
||||
@@ -322,6 +422,10 @@ pub struct AppSettings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub webdav_sync: Option<WebDavSyncSettings>,
|
||||
|
||||
// ===== S3 同步设置 =====
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub s3_sync: Option<S3SyncSettings>,
|
||||
|
||||
// ===== WebDAV 备份设置(旧版,保留向后兼容)=====
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub webdav_backup: Option<serde_json::Value>,
|
||||
@@ -392,6 +496,7 @@ impl Default for AppSettings {
|
||||
skill_sync_method: SyncMethod::default(),
|
||||
skill_storage_location: SkillStorageLocation::default(),
|
||||
webdav_sync: None,
|
||||
s3_sync: None,
|
||||
webdav_backup: None,
|
||||
backup_interval_hours: None,
|
||||
backup_retain_count: None,
|
||||
@@ -467,6 +572,13 @@ impl AppSettings {
|
||||
self.webdav_sync = None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(s3) = &mut self.s3_sync {
|
||||
s3.normalize();
|
||||
if s3.is_empty() {
|
||||
self.s3_sync = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_from_file() -> Self {
|
||||
@@ -570,6 +682,9 @@ pub fn get_settings_for_frontend() -> AppSettings {
|
||||
if let Some(sync) = &mut settings.webdav_sync {
|
||||
sync.password.clear();
|
||||
}
|
||||
if let Some(s3) = &mut settings.s3_sync {
|
||||
s3.secret_access_key.clear();
|
||||
}
|
||||
settings.webdav_backup = None;
|
||||
settings
|
||||
}
|
||||
@@ -882,6 +997,26 @@ pub fn update_webdav_sync_status(status: WebDavSyncStatus) -> Result<(), AppErro
|
||||
})
|
||||
}
|
||||
|
||||
// ===== S3 同步设置管理函数 =====
|
||||
|
||||
pub fn get_s3_sync_settings() -> Option<S3SyncSettings> {
|
||||
settings_store().read().ok()?.s3_sync.clone()
|
||||
}
|
||||
|
||||
pub fn set_s3_sync_settings(settings: Option<S3SyncSettings>) -> Result<(), AppError> {
|
||||
mutate_settings(|current| {
|
||||
current.s3_sync = settings;
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_s3_sync_status(status: WebDavSyncStatus) -> Result<(), AppError> {
|
||||
mutate_settings(|current| {
|
||||
if let Some(s3) = current.s3_sync.as_mut() {
|
||||
s3.status = status;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+157
-84
@@ -11,6 +11,26 @@ use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
use crate::store::AppState;
|
||||
|
||||
const TEMPLATE_TYPE_OFFICIAL_SUBSCRIPTION: &str = "official_subscription";
|
||||
const H_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_FIVE_HOUR];
|
||||
const W_TIER_NAMES: &[&str] = &[
|
||||
crate::services::subscription::TIER_WEEKLY_LIMIT,
|
||||
crate::services::subscription::TIER_SEVEN_DAY,
|
||||
crate::services::subscription::TIER_SEVEN_DAY_OPUS,
|
||||
crate::services::subscription::TIER_SEVEN_DAY_SONNET,
|
||||
];
|
||||
const GEMINI_PRO_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_PRO];
|
||||
const GEMINI_FLASH_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_FLASH];
|
||||
const GEMINI_FLASH_LITE_TIER_NAMES: &[&str] =
|
||||
&[crate::services::subscription::TIER_GEMINI_FLASH_LITE];
|
||||
const TIER_LABEL_GROUPS: &[(&str, &[&str])] = &[
|
||||
("h", H_TIER_NAMES),
|
||||
("w", W_TIER_NAMES),
|
||||
("p", GEMINI_PRO_TIER_NAMES),
|
||||
("f", GEMINI_FLASH_TIER_NAMES),
|
||||
("l", GEMINI_FLASH_LITE_TIER_NAMES),
|
||||
];
|
||||
|
||||
/// 每个 app 分区的子菜单句柄,用于 usage 更新时就地改 label 而非整菜单重建。
|
||||
/// `create_tray_menu` 每次重建都会整表覆盖写入,保证句柄始终指向当前活跃菜单。
|
||||
static TRAY_SECTION_SUBMENUS: Lazy<
|
||||
@@ -121,47 +141,16 @@ fn emoji_for_utilization(pct: f64) -> &'static str {
|
||||
fn format_subscription_summary(
|
||||
quota: &crate::services::subscription::SubscriptionQuota,
|
||||
) -> Option<String> {
|
||||
use crate::services::subscription::{
|
||||
TIER_FIVE_HOUR, TIER_GEMINI_FLASH, TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_SEVEN_DAY,
|
||||
};
|
||||
if !quota.success {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 按 tool 选取主卡槽 tier 并映射到短 label:
|
||||
// Claude / Codex 沿用时间窗口(h=5 小时,w=7 天);
|
||||
// Gemini 用模型维度(p=pro,f=flash,l=flash-lite)——Gemini 后端 tier
|
||||
// 命名是 gemini_pro / gemini_flash / gemini_flash_lite,与时间窗口不同命名空间。
|
||||
// flash_lite 必须纳入:否则 lite 利用率最高时色标偏低,与前端 footer 行为不一致。
|
||||
let parts: Vec<(&'static str, f64)> = match quota.tool.as_str() {
|
||||
"gemini" => {
|
||||
let mut v = Vec::new();
|
||||
if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_GEMINI_PRO) {
|
||||
v.push(("p", t.utilization));
|
||||
}
|
||||
if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_GEMINI_FLASH) {
|
||||
v.push(("f", t.utilization));
|
||||
}
|
||||
if let Some(t) = quota
|
||||
.tiers
|
||||
.iter()
|
||||
.find(|t| t.name == TIER_GEMINI_FLASH_LITE)
|
||||
{
|
||||
v.push(("l", t.utilization));
|
||||
}
|
||||
v
|
||||
}
|
||||
_ => {
|
||||
let mut v = Vec::new();
|
||||
if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_FIVE_HOUR) {
|
||||
v.push(("h", t.utilization));
|
||||
}
|
||||
if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_SEVEN_DAY) {
|
||||
v.push(("w", t.utilization));
|
||||
}
|
||||
v
|
||||
}
|
||||
};
|
||||
let entries: Vec<(&str, f64)> = quota
|
||||
.tiers
|
||||
.iter()
|
||||
.map(|tier| (tier.name.as_str(), tier.utilization))
|
||||
.collect();
|
||||
let parts = labeled_tier_parts(&entries);
|
||||
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
@@ -185,6 +174,22 @@ fn format_subscription_summary(
|
||||
Some(format!("{emoji} {body}"))
|
||||
}
|
||||
|
||||
fn labeled_tier_parts(entries: &[(&str, f64)]) -> Vec<(&'static str, f64)> {
|
||||
let mut parts = Vec::new();
|
||||
for &(label, tier_names) in TIER_LABEL_GROUPS {
|
||||
let max_utilization = entries
|
||||
.iter()
|
||||
.filter(|(name, _)| tier_names.contains(name))
|
||||
.map(|(_, utilization)| *utilization)
|
||||
.filter(|utilization| utilization.is_finite())
|
||||
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
if let Some(utilization) = max_utilization {
|
||||
parts.push((label, utilization));
|
||||
}
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
fn tier_pct(data: &crate::provider::UsageData) -> Option<f64> {
|
||||
match (data.used, data.total) {
|
||||
(Some(used), Some(total)) if total > 0.0 => Some(used / total * 100.0),
|
||||
@@ -193,8 +198,6 @@ fn tier_pct(data: &crate::provider::UsageData) -> Option<f64> {
|
||||
}
|
||||
|
||||
fn format_script_summary(result: &crate::provider::UsageResult) -> Option<String> {
|
||||
use crate::services::subscription::{TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT};
|
||||
|
||||
if !result.success {
|
||||
return None;
|
||||
}
|
||||
@@ -203,23 +206,15 @@ fn format_script_summary(result: &crate::provider::UsageResult) -> Option<String
|
||||
return None;
|
||||
}
|
||||
|
||||
// commands::provider 的 token_plan 分支把 SubscriptionQuota 的每个 tier
|
||||
// 扁平化为一条 UsageData(plan_name 承载 tier 名),所以这里按 plan_name
|
||||
// 识别双桶形态,其余 usage 结果(Copilot / balance / 自定义脚本)走 fallback。
|
||||
const TOKEN_PLAN_LABELS: &[(&str, &str)] = &[(TIER_FIVE_HOUR, "h"), (TIER_WEEKLY_LIMIT, "w")];
|
||||
|
||||
let mut parts: Vec<(&'static str, f64)> = Vec::new();
|
||||
for &(tier_name, label) in TOKEN_PLAN_LABELS {
|
||||
let Some(d) = data
|
||||
.iter()
|
||||
.find(|d| d.plan_name.as_deref() == Some(tier_name))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(u) = tier_pct(d) {
|
||||
parts.push((label, u));
|
||||
}
|
||||
}
|
||||
// commands::provider 的 token_plan / official_subscription 分支都会把
|
||||
// SubscriptionQuota 的每个 tier 扁平化为一条 UsageData(plan_name 承载
|
||||
// tier 名),所以这里按 plan_name 恢复托盘短标签。其余 usage 结果
|
||||
//(Copilot / balance / 自定义脚本)走 fallback。
|
||||
let entries: Vec<(&str, f64)> = data
|
||||
.iter()
|
||||
.filter_map(|d| Some((d.plan_name.as_deref()?, tier_pct(d)?)))
|
||||
.collect();
|
||||
let parts = labeled_tier_parts(&entries);
|
||||
if !parts.is_empty() {
|
||||
let worst = parts
|
||||
.iter()
|
||||
@@ -246,6 +241,18 @@ fn format_script_summary(result: &crate::provider::UsageResult) -> Option<String
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_uses_official_subscription(provider: &crate::provider::Provider) -> bool {
|
||||
provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.usage_script.as_ref())
|
||||
.map(|script| {
|
||||
script.enabled
|
||||
&& script.template_type.as_deref() == Some(TEMPLATE_TYPE_OFFICIAL_SUBSCRIPTION)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn format_usage_suffix(
|
||||
app_state: &AppState,
|
||||
app_type: &AppType,
|
||||
@@ -254,7 +261,10 @@ fn format_usage_suffix(
|
||||
) -> Option<String> {
|
||||
// 当前脚本是否启用:禁用/删除时不再沿用旧 UsageCache 结果,
|
||||
// 并顺手 invalidate,防止后续重建继续命中过期数据。
|
||||
if provider.has_usage_script_enabled() {
|
||||
let is_official_provider = provider.category.as_deref() == Some("official");
|
||||
let can_use_script = provider.has_usage_script_enabled()
|
||||
&& (!is_official_provider || provider_uses_official_subscription(provider));
|
||||
if can_use_script {
|
||||
// 脚本缓存优先(覆盖 Copilot/coding_plan/balance/自定义脚本),借用访问避免克隆整条 UsageResult。
|
||||
if let Some(Some(s)) =
|
||||
app_state
|
||||
@@ -263,19 +273,22 @@ fn format_usage_suffix(
|
||||
{
|
||||
return Some(format!(" · {s}"));
|
||||
}
|
||||
if provider_uses_official_subscription(provider) {
|
||||
if let Some(Some(s)) = app_state
|
||||
.usage_cache
|
||||
.with_subscription(app_type, format_subscription_summary)
|
||||
{
|
||||
return Some(format!(" · {s}"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app_state
|
||||
.usage_cache
|
||||
.invalidate_script(app_type, provider_id);
|
||||
}
|
||||
|
||||
if provider.category.as_deref() == Some("official") {
|
||||
if let Some(Some(s)) = app_state
|
||||
.usage_cache
|
||||
.with_subscription(app_type, format_subscription_summary)
|
||||
{
|
||||
return Some(format!(" · {s}"));
|
||||
}
|
||||
if !provider_uses_official_subscription(provider) {
|
||||
app_state.usage_cache.invalidate_subscription(app_type);
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -768,8 +781,8 @@ pub fn schedule_tray_refresh(app: &tauri::AppHandle) {
|
||||
/// 雪崩请求;互斥锁被毒化时以上次状态为准继续推进,不会永久阻塞。
|
||||
///
|
||||
/// 刷新面与 `format_usage_suffix` 的展示面严格对齐 —— 每次悬停最多发
|
||||
/// `TRAY_SECTIONS.len()` 次外部请求,script 优先(覆盖 coding_plan / balance /
|
||||
/// Copilot / 自定义脚本),否则当前 provider 必须是 `official` 才查订阅。
|
||||
/// `TRAY_SECTIONS.len()` 次外部请求;只有显式启用的用量查询(含官方订阅、
|
||||
/// coding_plan / balance / Copilot / 自定义脚本)才会发请求。
|
||||
pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) {
|
||||
use crate::commands::CopilotAuthState;
|
||||
use futures::future::join_all;
|
||||
@@ -797,7 +810,6 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) {
|
||||
.visible_apps
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut subscription_futures = Vec::new();
|
||||
let mut script_futures = Vec::new();
|
||||
|
||||
for section in TRAY_SECTIONS.iter() {
|
||||
@@ -831,9 +843,11 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) {
|
||||
}
|
||||
};
|
||||
|
||||
// 与 format_usage_suffix 同一优先级:脚本启用 → 查脚本;
|
||||
// 否则当前 provider 是 official → 查订阅;其它情况不发请求。
|
||||
if current.has_usage_script_enabled() {
|
||||
// 与 format_usage_suffix 同一优先级:只有显式启用的用量查询才发请求。
|
||||
let is_official_provider = current.category.as_deref() == Some("official");
|
||||
if current.has_usage_script_enabled()
|
||||
&& (!is_official_provider || provider_uses_official_subscription(¤t))
|
||||
{
|
||||
let app_clone = app.clone();
|
||||
let state = app.state::<AppState>();
|
||||
let copilot_state = app.state::<CopilotAuthState>();
|
||||
@@ -852,22 +866,10 @@ pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) {
|
||||
log::debug!("[Tray] 刷新{log_name}供应商 {provider_id} 用量失败: {e}");
|
||||
}
|
||||
});
|
||||
} else if current.category.as_deref() == Some("official") {
|
||||
let app_clone = app.clone();
|
||||
let state = app.state::<AppState>();
|
||||
let tool = app_type_str.to_string();
|
||||
subscription_futures.push(async move {
|
||||
if let Err(e) =
|
||||
crate::commands::get_subscription_quota(app_clone, state, tool).await
|
||||
{
|
||||
log::debug!("[Tray] 刷新{log_name}订阅用量失败(可能未登录): {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 两组并行启动,整体等待 —— 订阅/脚本互不依赖,没必要串行。
|
||||
futures::future::join(join_all(subscription_futures), join_all(script_futures)).await;
|
||||
join_all(script_futures).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -875,7 +877,9 @@ mod tests {
|
||||
use super::{format_script_summary, format_subscription_summary, TRAY_ID};
|
||||
use crate::provider::{UsageData, UsageResult};
|
||||
use crate::services::subscription::{
|
||||
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_WEEKLY_LIMIT,
|
||||
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_GEMINI_FLASH,
|
||||
TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_SEVEN_DAY, TIER_SEVEN_DAY_OPUS,
|
||||
TIER_SEVEN_DAY_SONNET, TIER_WEEKLY_LIMIT,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -988,6 +992,22 @@ mod tests {
|
||||
assert!(s.starts_with("\u{1F534}"), "expected red emoji in {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscription_summary_week_aliases_use_highest_utilization() {
|
||||
let quota = make_quota(
|
||||
"claude",
|
||||
true,
|
||||
vec![
|
||||
tier(TIER_FIVE_HOUR, 10.0),
|
||||
tier(TIER_SEVEN_DAY_OPUS, 20.0),
|
||||
tier(TIER_SEVEN_DAY_SONNET, 95.0),
|
||||
],
|
||||
);
|
||||
let s = format_subscription_summary("a).unwrap();
|
||||
assert!(s.contains("w95%"), "expected w95% in {s}");
|
||||
assert!(s.starts_with("\u{1F534}"), "expected red emoji in {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_quota_returns_none() {
|
||||
let quota = make_quota("claude", false, vec![tier("five_hour", 50.0)]);
|
||||
@@ -1074,6 +1094,59 @@ mod tests {
|
||||
assert!(s.contains("w50%"), "expected w50% in {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_official_subscription_claude_uses_h_and_w_labels() {
|
||||
let r = usage_result(
|
||||
true,
|
||||
vec![
|
||||
usage_data(Some(TIER_FIVE_HOUR), 12.0),
|
||||
usage_data(Some(TIER_SEVEN_DAY), 80.0),
|
||||
],
|
||||
);
|
||||
let s = format_script_summary(&r).expect("should format");
|
||||
assert!(s.contains("h12%"), "expected h12% in {s}");
|
||||
assert!(s.contains("w80%"), "expected w80% in {s}");
|
||||
assert!(
|
||||
!s.contains(TIER_SEVEN_DAY),
|
||||
"tier machine name should not leak into label: {s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_week_aliases_use_highest_utilization() {
|
||||
let r = usage_result(
|
||||
true,
|
||||
vec![
|
||||
usage_data(Some(TIER_FIVE_HOUR), 10.0),
|
||||
usage_data(Some(TIER_SEVEN_DAY_OPUS), 20.0),
|
||||
usage_data(Some(TIER_SEVEN_DAY_SONNET), 95.0),
|
||||
],
|
||||
);
|
||||
let s = format_script_summary(&r).unwrap();
|
||||
assert!(s.contains("w95%"), "expected w95% in {s}");
|
||||
assert!(s.starts_with("\u{1F534}"), "expected red emoji in {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_official_subscription_gemini_uses_short_labels() {
|
||||
let r = usage_result(
|
||||
true,
|
||||
vec![
|
||||
usage_data(Some(TIER_GEMINI_PRO), 15.0),
|
||||
usage_data(Some(TIER_GEMINI_FLASH), 42.0),
|
||||
usage_data(Some(TIER_GEMINI_FLASH_LITE), 80.0),
|
||||
],
|
||||
);
|
||||
let s = format_script_summary(&r).expect("should format");
|
||||
assert!(s.contains("p15%"), "expected p15% in {s}");
|
||||
assert!(s.contains("f42%"), "expected f42% in {s}");
|
||||
assert!(s.contains("l80%"), "expected l80% in {s}");
|
||||
assert!(
|
||||
!s.contains("gemini_"),
|
||||
"Gemini tier machine names should not leak into label: {s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_summary_single_bucket_fallback_with_plan_name() {
|
||||
let r = usage_result(true, vec![usage_data(Some("Copilot Pro"), 40.0)]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CC Switch",
|
||||
"version": "3.16.1",
|
||||
"version": "3.16.2",
|
||||
"identifier": "com.ccswitch.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -588,3 +588,30 @@ fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() {
|
||||
"current provider should remain empty or be the attempted id on failure, got: {current_id:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_refuses_live_config_under_proxy_takeover() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
ensure_test_home();
|
||||
|
||||
// 接管态 Codex Live:auth 是 PROXY_MANAGED 占位符,不是用户真实配置
|
||||
let auth = json!({"OPENAI_API_KEY": "PROXY_MANAGED"});
|
||||
let config = r#"model = "gpt-5"
|
||||
"#;
|
||||
write_codex_live_atomic(&auth, Some(config)).expect("seed taken-over codex live");
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
import_default_config_test_hook(&state, AppType::Codex)
|
||||
.expect_err("importing a taken-over live config must fail");
|
||||
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers(AppType::Codex.as_str())
|
||||
.expect("get codex providers");
|
||||
assert!(
|
||||
providers.is_empty(),
|
||||
"taken-over live import must not create providers"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1911,3 +1911,62 @@ fn provider_service_delete_current_provider_returns_error() {
|
||||
other => panic!("expected Config/Message error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recover_from_crash_without_backup_cleans_placeholder_instead_of_writing_it_back() {
|
||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||
reset_test_fs();
|
||||
let _home = ensure_test_home();
|
||||
|
||||
// 接管态 Claude Live,且 DB 中无备份(模拟切换 app_config_dir 后新库首启的场景)
|
||||
let taken_over_live = json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721",
|
||||
"ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED"
|
||||
}
|
||||
});
|
||||
let settings_path = get_claude_settings_path();
|
||||
std::fs::create_dir_all(settings_path.parent().expect("settings dir")).expect("create dir");
|
||||
std::fs::write(
|
||||
&settings_path,
|
||||
serde_json::to_string_pretty(&taken_over_live).expect("serialize taken over live"),
|
||||
)
|
||||
.expect("write taken over live");
|
||||
|
||||
let state = create_test_state().expect("create test state");
|
||||
|
||||
// 模拟历史异常:接管态 Live 已被导入成 current provider(SSOT 被污染)
|
||||
let provider = Provider::with_id(
|
||||
"default".to_string(),
|
||||
"default".to_string(),
|
||||
taken_over_live.clone(),
|
||||
None,
|
||||
);
|
||||
state
|
||||
.db
|
||||
.save_provider(AppType::Claude.as_str(), &provider)
|
||||
.expect("save placeholder provider");
|
||||
state
|
||||
.db
|
||||
.set_current_provider(AppType::Claude.as_str(), "default")
|
||||
.expect("set current provider");
|
||||
|
||||
futures::executor::block_on(state.proxy_service.recover_from_crash())
|
||||
.expect("recover from crash");
|
||||
|
||||
let live_after: serde_json::Value =
|
||||
read_json_file(&settings_path).expect("read live settings after recovery");
|
||||
let env = live_after.get("env").cloned().unwrap_or_else(|| json!({}));
|
||||
assert_ne!(
|
||||
env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()),
|
||||
Some("PROXY_MANAGED"),
|
||||
"recovery must not write the placeholder back to live"
|
||||
);
|
||||
assert!(
|
||||
env.get("ANTHROPIC_BASE_URL")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|url| !url.starts_with("http://127.0.0.1"))
|
||||
.unwrap_or(true),
|
||||
"recovery must drop the local proxy base URL"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,7 +110,14 @@
|
||||
<Directory Id="TARGETDIR" Name="SourceDir">
|
||||
<Directory Id="DesktopFolder" Name="Desktop">
|
||||
<Component Id="ApplicationShortcutDesktop" Guid="*">
|
||||
<Shortcut Id="ApplicationDesktopShortcut" Name="{{product_name}}" Description="Runs {{product_name}}" Target="[!Path]" WorkingDirectory="INSTALLDIR" />
|
||||
<Shortcut Id="ApplicationDesktopShortcut"
|
||||
Name="{{product_name}}"
|
||||
Description="Runs {{product_name}}"
|
||||
Target="[!Path]"
|
||||
Icon="ProductIcon"
|
||||
WorkingDirectory="INSTALLDIR">
|
||||
<ShortcutProperty Key="System.AppUserModel.ID" Value="{{bundle_id}}"/>
|
||||
</Shortcut>
|
||||
<RemoveFolder Id="DesktopFolder" On="uninstall" />
|
||||
<RegistryValue Root="HKCU" Key="Software\\{{manufacturer}}\\{{product_name}}" Name="Desktop Shortcut" Type="integer" Value="1" KeyPath="yes" />
|
||||
</Component>
|
||||
|
||||
+18
-2
@@ -107,7 +107,7 @@ type View =
|
||||
| "openclawAgents"
|
||||
| "hermesMemory";
|
||||
|
||||
interface WebDavSyncStatusUpdatedPayload {
|
||||
interface SyncStatusUpdatedPayload {
|
||||
source?: string;
|
||||
status?: string;
|
||||
error?: string;
|
||||
@@ -371,7 +371,7 @@ function App() {
|
||||
}
|
||||
});
|
||||
|
||||
useTauriEvent<WebDavSyncStatusUpdatedPayload | null | undefined>(
|
||||
useTauriEvent<SyncStatusUpdatedPayload | null | undefined>(
|
||||
"webdav-sync-status-updated",
|
||||
async (payload) => {
|
||||
const statusPayload = payload ?? {};
|
||||
@@ -387,6 +387,22 @@ function App() {
|
||||
},
|
||||
);
|
||||
|
||||
useTauriEvent<SyncStatusUpdatedPayload | null | undefined>(
|
||||
"s3-sync-status-updated",
|
||||
async (payload) => {
|
||||
const statusPayload = payload ?? {};
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
if (statusPayload.source !== "auto" || statusPayload.status !== "error") {
|
||||
return;
|
||||
}
|
||||
toast.error(
|
||||
t("settings.s3Sync.autoSyncFailedToast", {
|
||||
error: statusPayload.error || t("common.unknown"),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
useTauriEvent<{ appType: string; providerName: string }>(
|
||||
"proxy-official-warning",
|
||||
(payload) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ interface SubscriptionQuotaFooterProps {
|
||||
appId: AppId;
|
||||
inline?: boolean;
|
||||
isCurrent?: boolean;
|
||||
autoQueryInterval?: number;
|
||||
}
|
||||
|
||||
interface SubscriptionQuotaViewProps {
|
||||
@@ -397,12 +398,18 @@ const SubscriptionQuotaFooter: React.FC<SubscriptionQuotaFooterProps> = ({
|
||||
appId,
|
||||
inline = false,
|
||||
isCurrent = false,
|
||||
autoQueryInterval = 5,
|
||||
}) => {
|
||||
const {
|
||||
data: quota,
|
||||
isFetching: loading,
|
||||
refetch,
|
||||
} = useSubscriptionQuota(appId, isCurrent, isCurrent);
|
||||
} = useSubscriptionQuota(
|
||||
appId,
|
||||
isCurrent,
|
||||
isCurrent && autoQueryInterval > 0,
|
||||
autoQueryInterval,
|
||||
);
|
||||
|
||||
if (!isCurrent) return null;
|
||||
|
||||
|
||||
@@ -110,6 +110,9 @@ const generatePresetTemplates = (
|
||||
|
||||
// 官方余额查询模板不需要脚本,使用专用 Rust 查询
|
||||
[TEMPLATE_TYPES.BALANCE]: "",
|
||||
|
||||
// 官方订阅额度查询不需要脚本,使用 CLI/OAuth 凭据调用官方 API
|
||||
[TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION]: "",
|
||||
});
|
||||
|
||||
// 模板名称国际化键映射
|
||||
@@ -120,6 +123,8 @@ const TEMPLATE_NAME_KEYS: Record<string, string> = {
|
||||
[TEMPLATE_TYPES.GITHUB_COPILOT]: "usageScript.templateCopilot",
|
||||
[TEMPLATE_TYPES.TOKEN_PLAN]: "usageScript.templateTokenPlan",
|
||||
[TEMPLATE_TYPES.BALANCE]: "usageScript.templateBalance",
|
||||
[TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION]:
|
||||
"usageScript.templateOfficialSubscription",
|
||||
};
|
||||
|
||||
/** 官方余额查询供应商检测 */
|
||||
@@ -141,6 +146,45 @@ function detectBalanceProvider(baseUrl: string | undefined): boolean {
|
||||
return BALANCE_PROVIDERS.some((bp) => bp.pattern.test(baseUrl));
|
||||
}
|
||||
|
||||
function isOfficialSubscriptionProvider(provider: Provider, appId: AppId) {
|
||||
if (!["claude", "codex", "gemini"].includes(appId)) return false;
|
||||
if (provider.category === "official") return true;
|
||||
|
||||
const config = provider.settingsConfig as Record<string, any>;
|
||||
if (appId === "claude") {
|
||||
const baseUrl = config?.env?.ANTHROPIC_BASE_URL;
|
||||
return !baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === "");
|
||||
}
|
||||
if (appId === "codex") {
|
||||
const apiKey = config?.auth?.OPENAI_API_KEY;
|
||||
const bearerToken =
|
||||
typeof config?.config === "string"
|
||||
? extractCodexExperimentalBearerToken(config.config)
|
||||
: undefined;
|
||||
return (
|
||||
!bearerToken &&
|
||||
(!apiKey || (typeof apiKey === "string" && apiKey.trim() === ""))
|
||||
);
|
||||
}
|
||||
if (appId === "gemini") {
|
||||
const env = config?.env || {};
|
||||
const apiKey = env.GEMINI_API_KEY;
|
||||
const baseUrl = env.GOOGLE_GEMINI_BASE_URL;
|
||||
return (
|
||||
(!apiKey || (typeof apiKey === "string" && apiKey.trim() === "")) &&
|
||||
(!baseUrl || (typeof baseUrl === "string" && baseUrl.trim() === ""))
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const NATIVE_USAGE_TEMPLATES = new Set<string>([
|
||||
TEMPLATE_TYPES.GITHUB_COPILOT,
|
||||
TEMPLATE_TYPES.TOKEN_PLAN,
|
||||
TEMPLATE_TYPES.BALANCE,
|
||||
TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION,
|
||||
]);
|
||||
|
||||
const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
provider,
|
||||
appId,
|
||||
@@ -238,22 +282,33 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
};
|
||||
|
||||
const providerCredentials = getProviderCredentials();
|
||||
const isOfficialSubscription = isOfficialSubscriptionProvider(
|
||||
provider,
|
||||
appId,
|
||||
);
|
||||
|
||||
const [script, setScript] = useState<UsageScript>(() => {
|
||||
const savedScript = provider.meta?.usage_script;
|
||||
if (savedScript) {
|
||||
const normalizedScript = createUsageScript(savedScript);
|
||||
if (
|
||||
isOfficialSubscription &&
|
||||
normalizedScript.templateType !== TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION
|
||||
) {
|
||||
return createUsageScript();
|
||||
}
|
||||
// 已有配置:如果是 coding_plan 但没有 codingPlanProvider,自动检测填充
|
||||
if (
|
||||
savedScript.templateType === TEMPLATE_TYPES.TOKEN_PLAN &&
|
||||
!savedScript.codingPlanProvider
|
||||
normalizedScript.templateType === TEMPLATE_TYPES.TOKEN_PLAN &&
|
||||
!normalizedScript.codingPlanProvider
|
||||
) {
|
||||
return {
|
||||
...savedScript,
|
||||
...normalizedScript,
|
||||
codingPlanProvider:
|
||||
detectCodingPlanProvider(providerCredentials.baseUrl) || "kimi",
|
||||
};
|
||||
}
|
||||
return savedScript;
|
||||
return normalizedScript;
|
||||
}
|
||||
|
||||
const autoDetected = detectCodingPlanProvider(providerCredentials.baseUrl);
|
||||
@@ -265,6 +320,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
return createUsageScript();
|
||||
}
|
||||
|
||||
if (isOfficialSubscription) {
|
||||
return createUsageScript();
|
||||
}
|
||||
|
||||
return createUsageScript({
|
||||
code: PRESET_TEMPLATES[TEMPLATE_TYPES.GENERAL],
|
||||
});
|
||||
@@ -272,6 +331,14 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
// {{apiKey}}/{{baseUrl}} 实际注入值,镜像后端 resolve_script_credentials 的
|
||||
// 优先级:脚本配置中的显式非空值优先(旧配置可能携带),否则回退供应商凭据。
|
||||
const effectiveScriptCredentials = {
|
||||
apiKey: script.apiKey?.trim() || providerCredentials.apiKey,
|
||||
baseUrl:
|
||||
script.baseUrl?.trim().replace(/\/+$/, "") || providerCredentials.baseUrl,
|
||||
};
|
||||
|
||||
// 🔧 失焦时的验证(严格)- 仅确保有效整数
|
||||
const validateTimeout = (value: string): number => {
|
||||
const num = Number(value);
|
||||
@@ -327,9 +394,17 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
return TEMPLATE_TYPES.GITHUB_COPILOT;
|
||||
}
|
||||
// 优先使用保存的 templateType
|
||||
if (existingScript?.templateType) {
|
||||
if (
|
||||
existingScript?.templateType &&
|
||||
(!isOfficialSubscription ||
|
||||
existingScript.templateType === TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION)
|
||||
) {
|
||||
return existingScript.templateType as string;
|
||||
}
|
||||
// 官方 CLI/OAuth 供应商默认使用官方订阅额度模板,但开关默认关闭
|
||||
if (isOfficialSubscription) {
|
||||
return TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION;
|
||||
}
|
||||
// 向后兼容:根据字段推断模板类型
|
||||
// 检测 NEW_API 模板(有 accessToken 或 userId)
|
||||
if (existingScript?.accessToken || existingScript?.userId) {
|
||||
@@ -378,12 +453,8 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Copilot、Coding Plan、Balance 模板不需要脚本验证
|
||||
if (
|
||||
selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT &&
|
||||
selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN &&
|
||||
selectedTemplate !== TEMPLATE_TYPES.BALANCE
|
||||
) {
|
||||
// 专用模板不需要脚本验证
|
||||
if (!NATIVE_USAGE_TEMPLATES.has(selectedTemplate || "")) {
|
||||
if (script.enabled && !script.code.trim()) {
|
||||
toast.error(t("usageScript.scriptEmpty"));
|
||||
return;
|
||||
@@ -403,6 +474,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
| "github_copilot"
|
||||
| "token_plan"
|
||||
| "balance"
|
||||
| "official_subscription"
|
||||
| undefined,
|
||||
};
|
||||
onSave(scriptWithTemplate);
|
||||
@@ -412,6 +484,28 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
// 官方订阅额度模板使用 CLI/OAuth 凭据和官方 API
|
||||
if (selectedTemplate === TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION) {
|
||||
const { subscriptionApi } = await import("@/lib/api/subscription");
|
||||
const quota = await subscriptionApi.getQuota(appId);
|
||||
if (quota.success && quota.tiers.length > 0) {
|
||||
const summary = quota.tiers
|
||||
.map((tier) => `${tier.name}: ${Math.round(tier.utilization)}%`)
|
||||
.join(", ");
|
||||
toast.success(`${t("usageScript.testSuccess")}${summary}`, {
|
||||
duration: 3000,
|
||||
closeButton: true,
|
||||
});
|
||||
queryClient.setQueryData(["subscription", "quota", appId], quota);
|
||||
} else {
|
||||
toast.error(
|
||||
`${t("usageScript.testFailed")}: ${quota.error || t("endpointTest.noResult")}`,
|
||||
{ duration: 5000 },
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 官方余额查询模板使用专用 API
|
||||
if (selectedTemplate === TEMPLATE_TYPES.BALANCE) {
|
||||
const baseUrl = providerCredentials.baseUrl ?? "";
|
||||
@@ -592,13 +686,12 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
const preset = PRESET_TEMPLATES[presetName];
|
||||
if (preset !== undefined) {
|
||||
if (presetName === TEMPLATE_TYPES.CUSTOM) {
|
||||
// 🔧 自定义模式:用户应该在脚本中直接写完整 URL 和凭证,而不是依赖变量替换
|
||||
// 这样可以避免同源检查导致的问题
|
||||
// 如果用户想使用变量,需要手动在配置中设置 baseUrl/apiKey
|
||||
// 自定义模板没有凭证输入框:清空显式覆盖值后,测试与真实查询
|
||||
// 都会在后端回退到供应商配置(Provider::resolve_usage_credentials),
|
||||
// 与下方“支持的变量”区域展示的 {{apiKey}}/{{baseUrl}} 取值一致。
|
||||
setScript({
|
||||
...script,
|
||||
code: preset,
|
||||
// 清除凭证,用户可选择手动输入或保持空
|
||||
apiKey: undefined,
|
||||
baseUrl: undefined,
|
||||
accessToken: undefined,
|
||||
@@ -654,6 +747,16 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
accessToken: undefined,
|
||||
userId: undefined,
|
||||
});
|
||||
} else if (presetName === TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION) {
|
||||
// 官方订阅额度查询不需要脚本,使用 CLI/OAuth 凭据
|
||||
setScript({
|
||||
...script,
|
||||
code: "",
|
||||
apiKey: undefined,
|
||||
baseUrl: undefined,
|
||||
accessToken: undefined,
|
||||
userId: undefined,
|
||||
});
|
||||
}
|
||||
setSelectedTemplate(presetName);
|
||||
}
|
||||
@@ -681,7 +784,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFormat}
|
||||
disabled={!script.enabled}
|
||||
disabled={
|
||||
!script.enabled ||
|
||||
NATIVE_USAGE_TEMPLATES.has(selectedTemplate || "")
|
||||
}
|
||||
title={t("usageScript.format")}
|
||||
>
|
||||
<Wand2 size={14} className="mr-1" />
|
||||
@@ -742,8 +848,15 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
if (isCopilotProvider) {
|
||||
return name === TEMPLATE_TYPES.GITHUB_COPILOT;
|
||||
}
|
||||
// 官方 CLI/OAuth 供应商只显示官方订阅额度模板
|
||||
if (isOfficialSubscription) {
|
||||
return name === TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION;
|
||||
}
|
||||
// 非 Copilot 供应商不显示 copilot 模板
|
||||
return name !== TEMPLATE_TYPES.GITHUB_COPILOT;
|
||||
return (
|
||||
name !== TEMPLATE_TYPES.GITHUB_COPILOT &&
|
||||
name !== TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION
|
||||
);
|
||||
})
|
||||
.map((name) => {
|
||||
const isSelected = selectedTemplate === name;
|
||||
@@ -780,9 +893,9 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
{"{{baseUrl}}"}
|
||||
</code>
|
||||
<span className="text-muted-foreground/50">=</span>
|
||||
{providerCredentials.baseUrl ? (
|
||||
{effectiveScriptCredentials.baseUrl ? (
|
||||
<code className="text-foreground/70 break-all font-mono">
|
||||
{providerCredentials.baseUrl}
|
||||
{effectiveScriptCredentials.baseUrl}
|
||||
</code>
|
||||
) : (
|
||||
<span className="text-muted-foreground/50 italic">
|
||||
@@ -797,11 +910,11 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
{"{{apiKey}}"}
|
||||
</code>
|
||||
<span className="text-muted-foreground/50">=</span>
|
||||
{providerCredentials.apiKey ? (
|
||||
{effectiveScriptCredentials.apiKey ? (
|
||||
<>
|
||||
{showApiKey ? (
|
||||
<code className="text-foreground/70 break-all font-mono">
|
||||
{providerCredentials.apiKey}
|
||||
{effectiveScriptCredentials.apiKey}
|
||||
</code>
|
||||
) : (
|
||||
<code className="text-foreground/70 font-mono">
|
||||
@@ -865,6 +978,15 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 官方订阅额度模式:自动提示 */}
|
||||
{selectedTemplate === TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION && (
|
||||
<div className="space-y-2 border-t border-white/10 pt-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("usageScript.officialSubscriptionHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coding Plan 模式:供应商选择 */}
|
||||
{selectedTemplate === TEMPLATE_TYPES.TOKEN_PLAN && (
|
||||
<div className="space-y-3 border-t border-white/10 pt-3">
|
||||
@@ -1191,43 +1313,41 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提取器代码 - Copilot 模板不需要 */}
|
||||
{selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT &&
|
||||
selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN && (
|
||||
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base font-medium">
|
||||
{t("usageScript.extractorCode")}
|
||||
</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("usageScript.extractorHint")}
|
||||
</div>
|
||||
{/* 提取器代码 - 专用模板不需要 */}
|
||||
{!NATIVE_USAGE_TEMPLATES.has(selectedTemplate || "") && (
|
||||
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base font-medium">
|
||||
{t("usageScript.extractorCode")}
|
||||
</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("usageScript.extractorHint")}
|
||||
</div>
|
||||
<JsonEditor
|
||||
id="usage-code"
|
||||
value={script.code || ""}
|
||||
onChange={(value) =>
|
||||
setScript((prev) => ({ ...prev, code: value }))
|
||||
}
|
||||
height={480}
|
||||
language="javascript"
|
||||
showMinimap={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<JsonEditor
|
||||
id="usage-code"
|
||||
value={script.code || ""}
|
||||
onChange={(value) =>
|
||||
setScript((prev) => ({ ...prev, code: value }))
|
||||
}
|
||||
height={480}
|
||||
language="javascript"
|
||||
showMinimap={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 帮助信息 - Copilot 模板不需要 */}
|
||||
{selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT &&
|
||||
selectedTemplate !== TEMPLATE_TYPES.TOKEN_PLAN && (
|
||||
<div className="glass rounded-xl border border-white/10 p-6 text-sm text-foreground/90">
|
||||
<h4 className="font-medium mb-2">
|
||||
{t("usageScript.scriptHelp")}
|
||||
</h4>
|
||||
<div className="space-y-3 text-xs">
|
||||
<div>
|
||||
<strong>{t("usageScript.configFormat")}</strong>
|
||||
<pre className="mt-1 p-2 bg-black/20 text-foreground rounded border border-white/10 text-[10px] overflow-x-auto">
|
||||
{`({
|
||||
{/* 帮助信息 - 专用模板不需要 */}
|
||||
{!NATIVE_USAGE_TEMPLATES.has(selectedTemplate || "") && (
|
||||
<div className="glass rounded-xl border border-white/10 p-6 text-sm text-foreground/90">
|
||||
<h4 className="font-medium mb-2">
|
||||
{t("usageScript.scriptHelp")}
|
||||
</h4>
|
||||
<div className="space-y-3 text-xs">
|
||||
<div>
|
||||
<strong>{t("usageScript.configFormat")}</strong>
|
||||
<pre className="mt-1 p-2 bg-black/20 text-foreground rounded border border-white/10 text-[10px] overflow-x-auto">
|
||||
{`({
|
||||
request: {
|
||||
url: "{{baseUrl}}/api/usage",
|
||||
method: "POST",
|
||||
@@ -1244,39 +1364,39 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
||||
};
|
||||
}
|
||||
})`}
|
||||
</pre>
|
||||
</div>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong>{t("usageScript.extractorFormat")}</strong>
|
||||
<ul className="mt-1 space-y-0.5 ml-2">
|
||||
<li>{t("usageScript.fieldIsValid")}</li>
|
||||
<li>{t("usageScript.fieldInvalidMessage")}</li>
|
||||
<li>{t("usageScript.fieldRemaining")}</li>
|
||||
<li>{t("usageScript.fieldUnit")}</li>
|
||||
<li>{t("usageScript.fieldPlanName")}</li>
|
||||
<li>{t("usageScript.fieldTotal")}</li>
|
||||
<li>{t("usageScript.fieldUsed")}</li>
|
||||
<li>{t("usageScript.fieldExtra")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t("usageScript.extractorFormat")}</strong>
|
||||
<ul className="mt-1 space-y-0.5 ml-2">
|
||||
<li>{t("usageScript.fieldIsValid")}</li>
|
||||
<li>{t("usageScript.fieldInvalidMessage")}</li>
|
||||
<li>{t("usageScript.fieldRemaining")}</li>
|
||||
<li>{t("usageScript.fieldUnit")}</li>
|
||||
<li>{t("usageScript.fieldPlanName")}</li>
|
||||
<li>{t("usageScript.fieldTotal")}</li>
|
||||
<li>{t("usageScript.fieldUsed")}</li>
|
||||
<li>{t("usageScript.fieldExtra")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground">
|
||||
<strong>{t("usageScript.tips")}</strong>
|
||||
<ul className="mt-1 space-y-0.5 ml-2">
|
||||
<li>
|
||||
{t("usageScript.tip1", {
|
||||
apiKey: "{{apiKey}}",
|
||||
baseUrl: "{{baseUrl}}",
|
||||
})}
|
||||
</li>
|
||||
<li>{t("usageScript.tip2")}</li>
|
||||
<li>{t("usageScript.tip3")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
<strong>{t("usageScript.tips")}</strong>
|
||||
<ul className="mt-1 space-y-0.5 ml-2">
|
||||
<li>
|
||||
{t("usageScript.tip1", {
|
||||
apiKey: "{{apiKey}}",
|
||||
baseUrl: "{{baseUrl}}",
|
||||
})}
|
||||
</li>
|
||||
<li>{t("usageScript.tip2")}</li>
|
||||
<li>{t("usageScript.tip3")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Minus,
|
||||
Play,
|
||||
Plus,
|
||||
ShieldAlert,
|
||||
Terminal,
|
||||
TestTube2,
|
||||
Trash2,
|
||||
@@ -45,6 +44,18 @@ interface ProviderActionsProps {
|
||||
onSetAsDefault?: () => void;
|
||||
}
|
||||
|
||||
// 主按钮的呈现状态。title 用于 disabled 态向用户解释为何不可点击;
|
||||
// 因 Button 基类带 disabled:pointer-events-none,title 必须挂在外层非禁用
|
||||
// 的 wrapper 上才会在 hover 时显示(见下方 <span> 包裹)。
|
||||
interface MainButtonState {
|
||||
disabled: boolean;
|
||||
variant: "default" | "secondary";
|
||||
className: string;
|
||||
icon: JSX.Element;
|
||||
text: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function ProviderActions({
|
||||
appId,
|
||||
isCurrent,
|
||||
@@ -108,7 +119,7 @@ export function ProviderActions({
|
||||
}
|
||||
};
|
||||
|
||||
const getMainButtonState = () => {
|
||||
const getMainButtonState = (): MainButtonState => {
|
||||
if (isOmo) {
|
||||
if (isCurrent) {
|
||||
return {
|
||||
@@ -174,16 +185,6 @@ export function ProviderActions({
|
||||
};
|
||||
}
|
||||
|
||||
if (isOfficialBlockedByProxy) {
|
||||
return {
|
||||
disabled: true,
|
||||
variant: "secondary" as const,
|
||||
className: "opacity-40 cursor-not-allowed",
|
||||
icon: <ShieldAlert className="h-4 w-4" />,
|
||||
text: t("provider.blockedByProxy", { defaultValue: "已拦截" }),
|
||||
};
|
||||
}
|
||||
|
||||
if (isCurrent) {
|
||||
return {
|
||||
disabled: true,
|
||||
@@ -195,6 +196,17 @@ export function ProviderActions({
|
||||
};
|
||||
}
|
||||
|
||||
if (isOfficialBlockedByProxy) {
|
||||
return {
|
||||
disabled: true,
|
||||
variant: "default" as const,
|
||||
className: "",
|
||||
icon: <Play className="h-4 w-4" />,
|
||||
text: t("provider.enable"),
|
||||
title: t("provider.blockedByProxyHint"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
disabled: false,
|
||||
variant: "default" as const,
|
||||
@@ -247,16 +259,26 @@ export function ProviderActions({
|
||||
);
|
||||
})()}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant={buttonState.variant}
|
||||
onClick={handleMainButtonClick}
|
||||
disabled={buttonState.disabled}
|
||||
className={cn("w-[4.5rem] px-2.5", buttonState.className)}
|
||||
{/* wrapper span 承接 hover:disabled 按钮自身 pointer-events:none,
|
||||
原生 title 与 cursor 都必须挂在未禁用的外层元素上才会生效 */}
|
||||
<span
|
||||
title={buttonState.title}
|
||||
className={cn(
|
||||
"inline-flex",
|
||||
buttonState.disabled && "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{buttonState.icon}
|
||||
{buttonState.text}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={buttonState.variant}
|
||||
onClick={handleMainButtonClick}
|
||||
disabled={buttonState.disabled}
|
||||
className={cn("w-[4.5rem] px-2.5", buttonState.className)}
|
||||
>
|
||||
{buttonState.icon}
|
||||
{buttonState.text}
|
||||
</Button>
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
|
||||
@@ -14,7 +14,7 @@ import UsageFooter from "@/components/UsageFooter";
|
||||
import SubscriptionQuotaFooter from "@/components/SubscriptionQuotaFooter";
|
||||
import CopilotQuotaFooter from "@/components/CopilotQuotaFooter";
|
||||
import CodexOauthQuotaFooter from "@/components/CodexOauthQuotaFooter";
|
||||
import { PROVIDER_TYPES } from "@/config/constants";
|
||||
import { PROVIDER_TYPES, TEMPLATE_TYPES } from "@/config/constants";
|
||||
import { isHermesReadOnlyProvider } from "@/config/hermesProviderPresets";
|
||||
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
||||
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
|
||||
@@ -192,8 +192,23 @@ export function ProviderCard({
|
||||
|
||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||
const isOfficial = isOfficialProvider(provider, appId);
|
||||
const supportsOfficialSubscription =
|
||||
isOfficial && ["claude", "codex", "gemini"].includes(appId);
|
||||
const isOfficialSubscriptionUsage =
|
||||
provider.meta?.usage_script?.templateType ===
|
||||
TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION;
|
||||
const officialSubscriptionEnabled =
|
||||
supportsOfficialSubscription && usageEnabled && isOfficialSubscriptionUsage;
|
||||
// 官方判定只认显式 category === "official"(SSOT),不回退 isOfficial 的空字段启发式。
|
||||
// 理由(此判定曾在「纯 category ↔ category+isOfficial 回退」间反复,结论钉死于此):
|
||||
// 1) 封号保护是高代价决策,不该建立在「base_url/key 缺失」这种脆弱信号上——它无法区分
|
||||
// 「想直连官方」与「自定义但还没填完」,两者都表现为字段为空,必然误伤后者。
|
||||
// 2) 启发式在 UI 多拦的部分,执行层 useProviderActions.ts 也只认 category === "official"、
|
||||
// 并不兑现(绕过 UI 即可切换)→ 属虚保护,却以误伤 category 缺失的自定义供应商为代价。
|
||||
// 3) 预设导入的官方一定带 category="official",category 缺失的「真官方」现实中≈不存在。
|
||||
// 真官方就该有显式 category;手动新建官方应引导标注,而不是靠空字段猜。
|
||||
const isOfficialBlockedByProxy =
|
||||
isProxyTakeover && (provider.category === "official" || isOfficial);
|
||||
isProxyTakeover && provider.category === "official";
|
||||
const isCopilot =
|
||||
provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT ||
|
||||
provider.meta?.usage_script?.templateType === "github_copilot";
|
||||
@@ -231,7 +246,7 @@ export function ProviderCard({
|
||||
: 0;
|
||||
|
||||
const { data: usage } = useUsageQuery(provider.id, appId, {
|
||||
enabled: usageEnabled,
|
||||
enabled: usageEnabled && !isOfficial && !isOfficialSubscriptionUsage,
|
||||
autoQueryInterval,
|
||||
});
|
||||
|
||||
@@ -469,11 +484,16 @@ export function ProviderCard({
|
||||
isCurrent={isCurrent}
|
||||
/>
|
||||
) : isOfficial ? (
|
||||
<SubscriptionQuotaFooter
|
||||
appId={appId}
|
||||
inline={true}
|
||||
isCurrent={isCurrent}
|
||||
/>
|
||||
officialSubscriptionEnabled ? (
|
||||
<SubscriptionQuotaFooter
|
||||
appId={appId}
|
||||
inline={true}
|
||||
isCurrent={isCurrent}
|
||||
autoQueryInterval={
|
||||
provider.meta?.usage_script?.autoQueryInterval ?? 0
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
) : hasMultiplePlans ? (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span className="font-medium">
|
||||
@@ -540,7 +560,9 @@ export function ProviderCard({
|
||||
: undefined
|
||||
}
|
||||
onConfigureUsage={
|
||||
isOfficial || isCopilot || isCodexOauth
|
||||
(isOfficial && !supportsOfficialSubscription) ||
|
||||
isCopilot ||
|
||||
isCodexOauth
|
||||
? undefined
|
||||
: () => onConfigureUsage(provider)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
showFetchModelsError,
|
||||
type FetchedModel,
|
||||
} from "@/lib/api/model-fetch";
|
||||
import { CustomUserAgentField } from "./CustomUserAgentField";
|
||||
import type {
|
||||
ProviderCategory,
|
||||
ClaudeApiFormat,
|
||||
@@ -138,6 +139,10 @@ interface ClaudeFormFieldsProps {
|
||||
// Full URL mode
|
||||
isFullUrl: boolean;
|
||||
onFullUrlChange: (value: boolean) => void;
|
||||
|
||||
// Local proxy User-Agent override
|
||||
customUserAgent: string;
|
||||
onCustomUserAgentChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function ClaudeFormFields({
|
||||
@@ -190,6 +195,8 @@ export function ClaudeFormFields({
|
||||
onApiKeyFieldChange,
|
||||
isFullUrl,
|
||||
onFullUrlChange,
|
||||
customUserAgent,
|
||||
onCustomUserAgentChange,
|
||||
}: ClaudeFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasAnyAdvancedValue = !!(
|
||||
@@ -198,7 +205,8 @@ export function ClaudeFormFields({
|
||||
defaultSonnetModel ||
|
||||
defaultOpusModel ||
|
||||
apiFormat !== "anthropic" ||
|
||||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN"
|
||||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN" ||
|
||||
customUserAgent
|
||||
);
|
||||
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
|
||||
|
||||
@@ -251,7 +259,7 @@ export function ClaudeFormFields({
|
||||
const modelsUrl = matchedPreset?.modelsUrl;
|
||||
|
||||
setIsFetchingModels(true);
|
||||
fetchModelsForConfig(baseUrl, apiKey, isFullUrl, modelsUrl)
|
||||
fetchModelsForConfig(baseUrl, apiKey, isFullUrl, modelsUrl, customUserAgent)
|
||||
.then((models) => {
|
||||
setFetchedModels(models);
|
||||
showModelFetchResult(models.length);
|
||||
@@ -261,7 +269,7 @@ export function ClaudeFormFields({
|
||||
showFetchModelsError(err, t);
|
||||
})
|
||||
.finally(() => setIsFetchingModels(false));
|
||||
}, [baseUrl, apiKey, isFullUrl, showModelFetchResult, t]);
|
||||
}, [baseUrl, apiKey, isFullUrl, customUserAgent, showModelFetchResult, t]);
|
||||
|
||||
const handleFetchCopilotModels = useCallback(() => {
|
||||
if (!isCopilotAuthenticated) {
|
||||
@@ -665,7 +673,6 @@ export function ClaudeFormFields({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 高级选项(API 格式 + 认证字段 + 模型映射) */}
|
||||
{shouldShowModelSelector && (
|
||||
<Collapsible open={advancedExpanded} onOpenChange={setAdvancedExpanded}>
|
||||
<CollapsibleTrigger asChild>
|
||||
@@ -933,6 +940,12 @@ export function ClaudeFormFields({
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CustomUserAgentField
|
||||
id="claude-custom-user-agent"
|
||||
value={customUserAgent}
|
||||
onChange={onCustomUserAgentChange}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
showFetchModelsError,
|
||||
type FetchedModel,
|
||||
} from "@/lib/api/model-fetch";
|
||||
import { CustomUserAgentField } from "./CustomUserAgentField";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
CodexApiFormat,
|
||||
CodexCatalogModel,
|
||||
@@ -72,6 +74,10 @@ interface CodexFormFieldsProps {
|
||||
|
||||
// Speed Test Endpoints
|
||||
speedTestEndpoints: EndpointCandidate[];
|
||||
|
||||
// Local proxy User-Agent override
|
||||
customUserAgent: string;
|
||||
onCustomUserAgentChange: (value: string) => void;
|
||||
}
|
||||
|
||||
type CodexCatalogRow = CodexCatalogModel & { rowId: string };
|
||||
@@ -128,12 +134,13 @@ export function CodexFormFields({
|
||||
catalogModels = [],
|
||||
onCatalogModelsChange,
|
||||
speedTestEndpoints,
|
||||
customUserAgent,
|
||||
onCustomUserAgentChange,
|
||||
}: CodexFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
|
||||
const [isFetchingModels, setIsFetchingModels] = useState(false);
|
||||
const [reasoningExpanded, setReasoningExpanded] = useState(false);
|
||||
const needsLocalRouting = apiFormat === "openai_chat";
|
||||
const canEditCatalog = Boolean(onCatalogModelsChange);
|
||||
const canEditReasoning = Boolean(onCodexChatReasoningChange);
|
||||
@@ -142,6 +149,17 @@ export function CodexFormFields({
|
||||
codexChatReasoning.supportsEffort === true;
|
||||
const supportsEffort = codexChatReasoning.supportsEffort === true;
|
||||
|
||||
// needsLocalRouting 非默认值说明预设/用户动过路由配置,需要让模型映射保持可见
|
||||
const hasAnyAdvancedValue = !!customUserAgent || needsLocalRouting;
|
||||
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
|
||||
|
||||
// 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
|
||||
useEffect(() => {
|
||||
if (hasAnyAdvancedValue) {
|
||||
setAdvancedExpanded(true);
|
||||
}
|
||||
}, [hasAnyAdvancedValue]);
|
||||
|
||||
const [catalogRows, setCatalogRows] = useState<CodexCatalogRow[]>(() =>
|
||||
catalogModels.map((m) => createCatalogRow(m)),
|
||||
);
|
||||
@@ -216,7 +234,13 @@ export function CodexFormFields({
|
||||
return;
|
||||
}
|
||||
setIsFetchingModels(true);
|
||||
fetchModelsForConfig(codexBaseUrl, codexApiKey, isFullUrl)
|
||||
fetchModelsForConfig(
|
||||
codexBaseUrl,
|
||||
codexApiKey,
|
||||
isFullUrl,
|
||||
undefined,
|
||||
customUserAgent,
|
||||
)
|
||||
.then((models) => {
|
||||
setFetchedModels(models);
|
||||
if (models.length === 0) {
|
||||
@@ -232,7 +256,7 @@ export function CodexFormFields({
|
||||
showFetchModelsError(err, t);
|
||||
})
|
||||
.finally(() => setIsFetchingModels(false));
|
||||
}, [codexBaseUrl, codexApiKey, isFullUrl, t]);
|
||||
}, [codexBaseUrl, codexApiKey, isFullUrl, customUserAgent, t]);
|
||||
|
||||
const handleAddCatalogRow = useCallback(() => {
|
||||
if (!onCatalogModelsChange) return;
|
||||
@@ -321,42 +345,11 @@ export function CodexFormFields({
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldShowSpeedTest && (
|
||||
<div className="space-y-3 rounded-lg border border-border-default bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>
|
||||
{t("codexConfig.localRoutingToggle", {
|
||||
defaultValue: "需要本地路由映射",
|
||||
})}
|
||||
</FormLabel>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{needsLocalRouting
|
||||
? t("codexConfig.localRoutingOnHint", {
|
||||
defaultValue:
|
||||
"Codex 目前仅原生支持 OpenAI Responses API 与 GPT 系列模型;如果您的供应商使用 Chat Completions 协议或非 GPT 模型(如 DeepSeek、Kimi),则需要打开本开关,并在使用过程中保持本地路由开启。",
|
||||
})
|
||||
: t("codexConfig.localRoutingOffHint", {
|
||||
defaultValue:
|
||||
"如果您的供应商不是原生 OpenAI Responses API,或者模型名不是 Codex 默认的 GPT 系列,请打开此开关。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={needsLocalRouting}
|
||||
onCheckedChange={handleLocalRoutingChange}
|
||||
aria-label={t("codexConfig.localRoutingToggle", {
|
||||
defaultValue: "需要本地路由映射",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsLocalRouting && canEditReasoning && (
|
||||
{/* 高级选项 —— 本地路由映射/模型映射/思考能力/自定义 UA;预设供应商通常无需展开 */}
|
||||
{category !== "official" && (
|
||||
<Collapsible
|
||||
open={reasoningExpanded}
|
||||
onOpenChange={setReasoningExpanded}
|
||||
open={advancedExpanded}
|
||||
onOpenChange={setAdvancedExpanded}
|
||||
className="rounded-lg border border-border-default p-4"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
@@ -366,205 +359,282 @@ export function CodexFormFields({
|
||||
size="sm"
|
||||
className="h-8 w-full justify-start gap-1.5 px-0 text-sm font-medium text-foreground hover:opacity-70"
|
||||
>
|
||||
{reasoningExpanded ? (
|
||||
{advancedExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
{t("codexConfig.reasoningSectionToggle", {
|
||||
defaultValue: "思考能力(高级·通常自动识别)",
|
||||
{t("providerForm.advancedOptionsToggle", {
|
||||
defaultValue: "高级选项",
|
||||
})}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
{!reasoningExpanded && (
|
||||
{!advancedExpanded && (
|
||||
<p className="mt-1 ml-1 text-xs text-muted-foreground">
|
||||
{t("codexConfig.reasoningSectionHint", {
|
||||
{t("codexConfig.advancedSectionHint", {
|
||||
defaultValue:
|
||||
"预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需展开手动覆盖。",
|
||||
"包含本地路由映射、模型映射、思考能力与自定义 User-Agent。供应商使用 Chat Completions 协议或非 GPT 模型时,需在此开启本地路由映射。",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<CollapsibleContent className="space-y-3 pt-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>
|
||||
{t("codexConfig.reasoningModeToggle", {
|
||||
defaultValue: "支持思考模式",
|
||||
{/* 本地路由映射开关 —— 沿用 shouldShowSpeedTest 门控,cloud_provider 保持不可切换 */}
|
||||
{shouldShowSpeedTest && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>
|
||||
{t("codexConfig.localRoutingToggle", {
|
||||
defaultValue: "需要本地路由映射",
|
||||
})}
|
||||
</FormLabel>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{needsLocalRouting
|
||||
? t("codexConfig.localRoutingOnHint", {
|
||||
defaultValue:
|
||||
"Codex 目前仅原生支持 OpenAI Responses API 与 GPT 系列模型;如果您的供应商使用 Chat Completions 协议或非 GPT 模型(如 DeepSeek、Kimi),则需要打开本开关,并在使用过程中保持本地路由开启。",
|
||||
})
|
||||
: t("codexConfig.localRoutingOffHint", {
|
||||
defaultValue:
|
||||
"如果您的供应商不是原生 OpenAI Responses API,或者模型名不是 Codex 默认的 GPT 系列,请打开此开关。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={needsLocalRouting}
|
||||
onCheckedChange={handleLocalRoutingChange}
|
||||
aria-label={t("codexConfig.localRoutingToggle", {
|
||||
defaultValue: "需要本地路由映射",
|
||||
})}
|
||||
</FormLabel>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.reasoningModeHint", {
|
||||
defaultValue:
|
||||
"上游 Chat Completions 接口支持开启或关闭 thinking 时启用。Kimi、GLM、Qwen 等通常属于这一类。",
|
||||
})}
|
||||
</p>
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={supportsThinking}
|
||||
onCheckedChange={handleReasoningThinkingChange}
|
||||
aria-label={t("codexConfig.reasoningModeToggle", {
|
||||
defaultValue: "支持思考模式",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border-default pt-3">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>
|
||||
{t("codexConfig.reasoningEffortToggle", {
|
||||
defaultValue: "支持思考等级",
|
||||
})}
|
||||
</FormLabel>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.reasoningEffortHint", {
|
||||
defaultValue:
|
||||
"上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={supportsEffort}
|
||||
onCheckedChange={handleReasoningEffortChange}
|
||||
aria-label={t("codexConfig.reasoningEffortToggle", {
|
||||
defaultValue: "支持思考等级",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
{needsLocalRouting && canEditReasoning && (
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-3",
|
||||
shouldShowSpeedTest && "border-t border-border-default pt-3",
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<FormLabel>
|
||||
{t("codexConfig.reasoningGroupTitle", {
|
||||
defaultValue: "思考能力",
|
||||
})}
|
||||
</FormLabel>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.reasoningSectionHint", {
|
||||
defaultValue:
|
||||
"预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需手动覆盖。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Codex 模型映射 —— 仅在本地路由 + 可编辑时显示 */}
|
||||
{needsLocalRouting && canEditCatalog && (
|
||||
<div className="space-y-4 rounded-lg border border-border-default p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FormLabel>
|
||||
{t("codexConfig.modelMappingTitle", {
|
||||
defaultValue: "模型映射",
|
||||
})}
|
||||
</FormLabel>
|
||||
{renderCatalogActionButtons(
|
||||
handleAddCatalogRow,
|
||||
t("codexConfig.addCatalogModel", {
|
||||
defaultValue: "添加模型",
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.modelMappingHint", {
|
||||
defaultValue:
|
||||
"选择模型角色后,CC Switch 会自动生成 Codex 兼容路由;菜单显示名可以填 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{catalogRows.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{/* 列头:md+ 显示 */}
|
||||
<div className="hidden grid-cols-[1fr_1fr_140px_36px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
|
||||
<span>
|
||||
{t("codexConfig.catalogColumnDisplay", {
|
||||
defaultValue: "菜单显示名",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("codexConfig.catalogColumnModel", {
|
||||
defaultValue: "实际请求模型",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("codexConfig.catalogColumnContext", {
|
||||
defaultValue: "上下文窗口",
|
||||
})}
|
||||
</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{catalogRows.map((row, index) => (
|
||||
<div
|
||||
key={row.rowId}
|
||||
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_140px_36px]"
|
||||
>
|
||||
<Input
|
||||
value={row.displayName ?? ""}
|
||||
onChange={(event) =>
|
||||
handleUpdateCatalogRow(index, {
|
||||
displayName: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t(
|
||||
"codexConfig.catalogDisplayNamePlaceholder",
|
||||
{
|
||||
defaultValue: "例如: DeepSeek V4 Flash",
|
||||
},
|
||||
)}
|
||||
aria-label={t("codexConfig.catalogColumnDisplay", {
|
||||
defaultValue: "菜单显示名",
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>
|
||||
{t("codexConfig.reasoningModeToggle", {
|
||||
defaultValue: "支持思考模式",
|
||||
})}
|
||||
</FormLabel>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.reasoningModeHint", {
|
||||
defaultValue:
|
||||
"上游 Chat Completions 接口支持开启或关闭 thinking 时启用。Kimi、GLM、Qwen 等通常属于这一类。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={supportsThinking}
|
||||
onCheckedChange={handleReasoningThinkingChange}
|
||||
aria-label={t("codexConfig.reasoningModeToggle", {
|
||||
defaultValue: "支持思考模式",
|
||||
})}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={row.model}
|
||||
onChange={(event) =>
|
||||
handleUpdateCatalogRow(index, {
|
||||
model: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t("codexConfig.catalogModelPlaceholder", {
|
||||
defaultValue: "例如: deepseek-v4-flash",
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border-default pt-3">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>
|
||||
{t("codexConfig.reasoningEffortToggle", {
|
||||
defaultValue: "支持思考等级",
|
||||
})}
|
||||
aria-label={t("codexConfig.catalogColumnModel", {
|
||||
defaultValue: "实际请求模型",
|
||||
</FormLabel>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.reasoningEffortHint", {
|
||||
defaultValue:
|
||||
"上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。",
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
{fetchedModels.length > 0 && (
|
||||
<ModelDropdown
|
||||
models={fetchedModels}
|
||||
onSelect={(id) =>
|
||||
handleUpdateCatalogRow(index, {
|
||||
model: id,
|
||||
displayName: row.displayName?.trim()
|
||||
? row.displayName
|
||||
: id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={supportsEffort}
|
||||
onCheckedChange={handleReasoningEffortChange}
|
||||
aria-label={t("codexConfig.reasoningEffortToggle", {
|
||||
defaultValue: "支持思考等级",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
(shouldShowSpeedTest ||
|
||||
(needsLocalRouting && canEditReasoning)) &&
|
||||
"border-t border-border-default pt-3",
|
||||
)}
|
||||
>
|
||||
<CustomUserAgentField
|
||||
id="codex-custom-user-agent"
|
||||
value={customUserAgent}
|
||||
onChange={onCustomUserAgentChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 模型映射 —— 仅在本地路由 + 可编辑时显示;上方恒有 UA 字段,分隔线无需条件 */}
|
||||
{needsLocalRouting && canEditCatalog && (
|
||||
<div className="space-y-4 border-t border-border-default pt-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FormLabel>
|
||||
{t("codexConfig.modelMappingTitle", {
|
||||
defaultValue: "模型映射",
|
||||
})}
|
||||
</FormLabel>
|
||||
{renderCatalogActionButtons(
|
||||
handleAddCatalogRow,
|
||||
t("codexConfig.addCatalogModel", {
|
||||
defaultValue: "添加模型",
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
inputMode="numeric"
|
||||
value={row.contextWindow ?? ""}
|
||||
onChange={(event) =>
|
||||
handleUpdateCatalogRow(index, {
|
||||
contextWindow: event.target.value.replace(/[^\d]/g, ""),
|
||||
})
|
||||
}
|
||||
placeholder={t("codexConfig.contextWindowPlaceholder", {
|
||||
defaultValue: "例如: 128000",
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t("codexConfig.modelMappingHint", {
|
||||
defaultValue:
|
||||
"选择模型角色后,CC Switch 会自动生成 Codex 兼容路由;菜单显示名可以填 DeepSeek、Kimi 等品牌模型,实际请求模型按右侧填写内容发送。",
|
||||
})}
|
||||
aria-label={t("codexConfig.catalogColumnContext", {
|
||||
defaultValue: "上下文窗口",
|
||||
})}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemoveCatalogRow(index)}
|
||||
title={t("common.delete", { defaultValue: "删除" })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{catalogRows.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{/* 列头:md+ 显示 */}
|
||||
<div className="hidden grid-cols-[1fr_1fr_140px_36px] gap-2 px-1 text-xs font-medium text-muted-foreground md:grid">
|
||||
<span>
|
||||
{t("codexConfig.catalogColumnDisplay", {
|
||||
defaultValue: "菜单显示名",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("codexConfig.catalogColumnModel", {
|
||||
defaultValue: "实际请求模型",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("codexConfig.catalogColumnContext", {
|
||||
defaultValue: "上下文窗口",
|
||||
})}
|
||||
</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{catalogRows.map((row, index) => (
|
||||
<div
|
||||
key={row.rowId}
|
||||
className="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_140px_36px]"
|
||||
>
|
||||
<Input
|
||||
value={row.displayName ?? ""}
|
||||
onChange={(event) =>
|
||||
handleUpdateCatalogRow(index, {
|
||||
displayName: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t(
|
||||
"codexConfig.catalogDisplayNamePlaceholder",
|
||||
{
|
||||
defaultValue: "例如: DeepSeek V4 Flash",
|
||||
},
|
||||
)}
|
||||
aria-label={t("codexConfig.catalogColumnDisplay", {
|
||||
defaultValue: "菜单显示名",
|
||||
})}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={row.model}
|
||||
onChange={(event) =>
|
||||
handleUpdateCatalogRow(index, {
|
||||
model: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={t(
|
||||
"codexConfig.catalogModelPlaceholder",
|
||||
{
|
||||
defaultValue: "例如: deepseek-v4-flash",
|
||||
},
|
||||
)}
|
||||
aria-label={t("codexConfig.catalogColumnModel", {
|
||||
defaultValue: "实际请求模型",
|
||||
})}
|
||||
className="flex-1"
|
||||
/>
|
||||
{fetchedModels.length > 0 && (
|
||||
<ModelDropdown
|
||||
models={fetchedModels}
|
||||
onSelect={(id) =>
|
||||
handleUpdateCatalogRow(index, {
|
||||
model: id,
|
||||
displayName: row.displayName?.trim()
|
||||
? row.displayName
|
||||
: id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
inputMode="numeric"
|
||||
value={row.contextWindow ?? ""}
|
||||
onChange={(event) =>
|
||||
handleUpdateCatalogRow(index, {
|
||||
contextWindow: event.target.value.replace(
|
||||
/[^\d]/g,
|
||||
"",
|
||||
),
|
||||
})
|
||||
}
|
||||
placeholder={t(
|
||||
"codexConfig.contextWindowPlaceholder",
|
||||
{
|
||||
defaultValue: "例如: 128000",
|
||||
},
|
||||
)}
|
||||
aria-label={t("codexConfig.catalogColumnContext", {
|
||||
defaultValue: "上下文窗口",
|
||||
})}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemoveCatalogRow(index)}
|
||||
title={t("common.delete", { defaultValue: "删除" })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* 端点测速弹窗 - Codex */}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { isValidUserAgentHeader } from "@/lib/userAgent";
|
||||
import { USER_AGENT_PRESETS } from "@/config/userAgentPresets";
|
||||
|
||||
interface CustomUserAgentFieldProps {
|
||||
/** 输入框的 id(用于 label htmlFor);两个表单需传入各自唯一值。 */
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 供应商级自定义 User-Agent 字段(Claude / Codex 表单共用)。
|
||||
*
|
||||
* 含标签 + 输入框 + 右侧预设下拉菜单 + 实时合法性提示。校验口径与后端
|
||||
* `parse_custom_user_agent` 一致(见 `@/lib/userAgent`),非法时给非阻断红字提示
|
||||
* (运行时仍会静默忽略)。
|
||||
*/
|
||||
export function CustomUserAgentField({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: CustomUserAgentFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const valid = isValidUserAgentHeader(value);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor={id}>
|
||||
{t("providerForm.customUserAgent", {
|
||||
defaultValue: "自定义 User-Agent",
|
||||
})}
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Mozilla/5.0 ..."
|
||||
autoComplete="off"
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="outline" className="shrink-0 gap-1">
|
||||
{t("providerForm.customUserAgentPresets", {
|
||||
defaultValue: "预设",
|
||||
})}
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-60" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-h-64 overflow-y-auto z-[200]"
|
||||
>
|
||||
{USER_AGENT_PRESETS.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset}
|
||||
onSelect={() => onChange(preset)}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{preset}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{valid ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.customUserAgentHint", {
|
||||
defaultValue:
|
||||
"仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("providerForm.customUserAgentInvalid", {
|
||||
defaultValue:
|
||||
"User-Agent 不能包含控制字符(如换行符),否则将被忽略。",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -355,6 +355,7 @@ function ProviderFormFull({
|
||||
),
|
||||
});
|
||||
setCodexChatReasoning(initialData?.meta?.codexChatReasoning ?? {});
|
||||
setCustomUserAgent(initialData?.meta?.customUserAgent ?? "");
|
||||
}, [appId, initialData, supportsFullUrl]);
|
||||
|
||||
const defaultValues: ProviderFormData = useMemo(
|
||||
@@ -509,6 +510,9 @@ function ProviderFormFull({
|
||||
useState<CodexChatReasoning>(
|
||||
() => initialData?.meta?.codexChatReasoning ?? {},
|
||||
);
|
||||
const [customUserAgent, setCustomUserAgent] = useState<string>(
|
||||
() => initialData?.meta?.customUserAgent ?? "",
|
||||
);
|
||||
|
||||
const {
|
||||
codexAuth,
|
||||
@@ -1386,6 +1390,10 @@ function ProviderFormFull({
|
||||
localCodexApiFormat === "openai_chat"
|
||||
? normalizeCodexChatReasoningForSave(codexChatReasoning)
|
||||
: undefined,
|
||||
customUserAgent:
|
||||
(appId === "claude" || appId === "codex") && category !== "official"
|
||||
? customUserAgent.trim() || undefined
|
||||
: undefined,
|
||||
testConfig: testConfig.enabled ? testConfig : undefined,
|
||||
costMultiplier: pricingConfig.enabled
|
||||
? pricingConfig.costMultiplier
|
||||
@@ -2009,6 +2017,8 @@ function ProviderFormFull({
|
||||
onApiKeyFieldChange={handleApiKeyFieldChange}
|
||||
isFullUrl={localIsFullUrl}
|
||||
onFullUrlChange={setLocalIsFullUrl}
|
||||
customUserAgent={customUserAgent}
|
||||
onCustomUserAgentChange={setCustomUserAgent}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2041,6 +2051,8 @@ function ProviderFormFull({
|
||||
catalogModels={codexCatalogModels}
|
||||
onCatalogModelsChange={setCodexCatalogModels}
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
customUserAgent={customUserAgent}
|
||||
onCustomUserAgentChange={setCustomUserAgent}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { ClaudeIcon, CodexIcon, GeminiIcon } from "@/components/BrandIcons";
|
||||
import { Zap, Star, Layers, Settings2 } from "lucide-react";
|
||||
import { ArrowUpAZ, Search, Zap, Star, Layers, Settings2 } from "lucide-react";
|
||||
import type { ProviderPreset } from "@/config/claudeProviderPresets";
|
||||
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
|
||||
import type { GeminiProviderPreset } from "@/config/geminiProviderPresets";
|
||||
@@ -16,7 +30,17 @@ import {
|
||||
} from "@/config/universalProviderPresets";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
|
||||
type AnyPreset =
|
||||
type PresetTranslator = (key: string) => unknown;
|
||||
|
||||
export const PresetSortMode = {
|
||||
Original: "original",
|
||||
NameAsc: "nameAsc",
|
||||
} as const;
|
||||
|
||||
export type PresetSortMode =
|
||||
(typeof PresetSortMode)[keyof typeof PresetSortMode];
|
||||
|
||||
export type AnyPreset =
|
||||
| ProviderPreset
|
||||
| CodexProviderPreset
|
||||
| GeminiProviderPreset
|
||||
@@ -25,11 +49,91 @@ type AnyPreset =
|
||||
| OpenClawProviderPreset
|
||||
| HermesProviderPreset;
|
||||
|
||||
type PresetEntry = {
|
||||
export type PresetEntry = {
|
||||
id: string;
|
||||
preset: AnyPreset;
|
||||
};
|
||||
|
||||
export function getPresetDisplayName(
|
||||
preset: AnyPreset,
|
||||
t: PresetTranslator,
|
||||
): string {
|
||||
return preset.nameKey ? String(t(preset.nameKey)) : preset.name;
|
||||
}
|
||||
|
||||
export function getPresetSearchText(
|
||||
entry: PresetEntry,
|
||||
presetCategoryLabels: Record<string, string>,
|
||||
t: PresetTranslator,
|
||||
): string {
|
||||
const presetCategory = entry.preset.category ?? "others";
|
||||
const categoryLabel =
|
||||
presetCategoryLabels[presetCategory] ?? String(t("providerPreset.other"));
|
||||
|
||||
return [
|
||||
getPresetDisplayName(entry.preset, t),
|
||||
entry.preset.name,
|
||||
entry.preset.websiteUrl,
|
||||
categoryLabel,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function filterPresetEntries(
|
||||
entries: PresetEntry[],
|
||||
query: string,
|
||||
presetCategoryLabels: Record<string, string>,
|
||||
t: PresetTranslator,
|
||||
): PresetEntry[] {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
return entries.filter((entry) =>
|
||||
getPresetSearchText(entry, presetCategoryLabels, t).includes(
|
||||
normalizedQuery,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function sortPresetEntries(
|
||||
entries: PresetEntry[],
|
||||
sortMode: PresetSortMode,
|
||||
t: PresetTranslator,
|
||||
): PresetEntry[] {
|
||||
if (sortMode === PresetSortMode.Original) {
|
||||
return [...entries];
|
||||
}
|
||||
|
||||
return [...entries].sort((a, b) =>
|
||||
getPresetDisplayName(a.preset, t).localeCompare(
|
||||
getPresetDisplayName(b.preset, t),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export interface PresetVisibilityOptions {
|
||||
query: string;
|
||||
sortMode: PresetSortMode;
|
||||
presetCategoryLabels: Record<string, string>;
|
||||
t: PresetTranslator;
|
||||
}
|
||||
|
||||
export function getVisiblePresetEntries(
|
||||
entries: PresetEntry[],
|
||||
options: PresetVisibilityOptions,
|
||||
): PresetEntry[] {
|
||||
const { query, sortMode, presetCategoryLabels, t } = options;
|
||||
|
||||
return sortPresetEntries(
|
||||
filterPresetEntries(entries, query, presetCategoryLabels, t),
|
||||
sortMode,
|
||||
t,
|
||||
);
|
||||
}
|
||||
|
||||
interface ProviderPresetSelectorProps {
|
||||
selectedPresetId: string | null;
|
||||
presetEntries: PresetEntry[];
|
||||
@@ -50,8 +154,24 @@ export function ProviderPresetSelector({
|
||||
category,
|
||||
}: ProviderPresetSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortMode, setSortMode] = useState<PresetSortMode>(
|
||||
PresetSortMode.Original,
|
||||
);
|
||||
|
||||
const getCategoryHint = (): React.ReactNode => {
|
||||
const visiblePresetEntries = useMemo(
|
||||
() =>
|
||||
getVisiblePresetEntries(presetEntries, {
|
||||
query: searchQuery,
|
||||
sortMode,
|
||||
presetCategoryLabels,
|
||||
t,
|
||||
}),
|
||||
[presetEntries, presetCategoryLabels, searchQuery, sortMode, t],
|
||||
);
|
||||
|
||||
const getCategoryHint = (): ReactNode => {
|
||||
switch (category) {
|
||||
case "official":
|
||||
return t("providerForm.officialHint", {
|
||||
@@ -85,6 +205,14 @@ export function ProviderPresetSelector({
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSortMode = () => {
|
||||
setSortMode((current) =>
|
||||
current === PresetSortMode.Original
|
||||
? PresetSortMode.NameAsc
|
||||
: PresetSortMode.Original,
|
||||
);
|
||||
};
|
||||
|
||||
const renderPresetIcon = (preset: AnyPreset) => {
|
||||
const iconType = preset.theme?.icon;
|
||||
if (!iconType) return null;
|
||||
@@ -130,7 +258,88 @@ export function ProviderPresetSelector({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<FormLabel>{t("providerPreset.label")}</FormLabel>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FormLabel>{t("providerPreset.label")}</FormLabel>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex items-center gap-1">
|
||||
<Popover open={searchOpen} onOpenChange={setSearchOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("providerPreset.searchAriaLabel", {
|
||||
defaultValue: "Search provider presets",
|
||||
})}
|
||||
className={
|
||||
searchQuery.trim()
|
||||
? "size-8 bg-accent text-foreground"
|
||||
: "size-8"
|
||||
}
|
||||
>
|
||||
<Search className="size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("providerPreset.searchTooltip", {
|
||||
defaultValue: "Search presets",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-72 p-2 border-border-default"
|
||||
>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder={t("providerPreset.searchPlaceholder", {
|
||||
defaultValue: "Search presets...",
|
||||
})}
|
||||
aria-label={t("providerPreset.searchAriaLabel", {
|
||||
defaultValue: "Search provider presets",
|
||||
})}
|
||||
autoFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("providerPreset.sortAriaLabel", {
|
||||
defaultValue: "Toggle preset sorting",
|
||||
})}
|
||||
aria-pressed={sortMode === PresetSortMode.NameAsc}
|
||||
onClick={toggleSortMode}
|
||||
className={
|
||||
sortMode === PresetSortMode.NameAsc
|
||||
? "size-8 bg-accent text-foreground"
|
||||
: "size-8"
|
||||
}
|
||||
>
|
||||
<ArrowUpAZ className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{sortMode === PresetSortMode.NameAsc
|
||||
? t("providerPreset.sortOriginalTooltip", {
|
||||
defaultValue: "Restore original order",
|
||||
})
|
||||
: t("providerPreset.sortNameAscTooltip", {
|
||||
defaultValue: "Sort A-Z",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -144,7 +353,15 @@ export function ProviderPresetSelector({
|
||||
{t("providerPreset.custom")}
|
||||
</button>
|
||||
|
||||
{presetEntries.map((entry) => {
|
||||
{visiblePresetEntries.length === 0 && (
|
||||
<div className="w-full rounded-md border border-dashed border-border-default px-3 py-2 text-xs text-muted-foreground">
|
||||
{t("providerPreset.noSearchResults", {
|
||||
defaultValue: "No matching presets.",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visiblePresetEntries.map((entry) => {
|
||||
const isSelected = selectedPresetId === entry.id;
|
||||
const isPartner = entry.preset.isPartner;
|
||||
const presetCategory = entry.preset.category ?? "others";
|
||||
@@ -161,9 +378,7 @@ export function ProviderPresetSelector({
|
||||
}
|
||||
>
|
||||
{renderPresetIcon(entry.preset)}
|
||||
{entry.preset.nameKey
|
||||
? t(entry.preset.nameKey)
|
||||
: entry.preset.name}
|
||||
{getPresetDisplayName(entry.preset, t)}
|
||||
{isPartner && (
|
||||
<span className="absolute -top-1 -right-1 flex items-center gap-0.5 rounded-full bg-gradient-to-r from-amber-500 to-yellow-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-md">
|
||||
<Star className="h-2.5 w-2.5 fill-current" />
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import type { ProxyStatus } from "@/types/proxy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
|
||||
interface ProxyPanelProps {
|
||||
enableLocalProxy: boolean;
|
||||
@@ -88,8 +89,12 @@ export function ProxyPanel({
|
||||
{ closeButton: true },
|
||||
);
|
||||
} catch (error) {
|
||||
const detail =
|
||||
extractErrorMessage(error) ||
|
||||
t("common.unknown", { defaultValue: "未知错误" });
|
||||
toast.error(
|
||||
t("proxy.takeover.failed", {
|
||||
detail,
|
||||
defaultValue: "切换接管状态失败",
|
||||
}),
|
||||
);
|
||||
@@ -139,6 +144,8 @@ export function ProxyPanel({
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const normalizedAddress =
|
||||
addressTrimmed === "localhost" ? "127.0.0.1" : addressTrimmed;
|
||||
const isValidAddress =
|
||||
addressTrimmed === "localhost" ||
|
||||
addressTrimmed === "0.0.0.0" ||
|
||||
@@ -176,7 +183,7 @@ export function ProxyPanel({
|
||||
try {
|
||||
await updateGlobalConfig.mutateAsync({
|
||||
...globalConfig,
|
||||
listenAddress: addressTrimmed,
|
||||
listenAddress: normalizedAddress,
|
||||
listenPort: port,
|
||||
});
|
||||
toast.success(
|
||||
|
||||
@@ -48,12 +48,15 @@ import { SessionItem } from "./SessionItem";
|
||||
import { SessionMessageItem } from "./SessionMessageItem";
|
||||
import { SessionTocDialog, SessionTocSidebar } from "./SessionToc";
|
||||
import {
|
||||
extractCodexPromptPreview,
|
||||
formatSessionMessagePreview,
|
||||
formatSessionTitle,
|
||||
formatTimestamp,
|
||||
getBaseName,
|
||||
getProviderIconName,
|
||||
getProviderLabel,
|
||||
getSessionKey,
|
||||
shouldHideCodexMessageFromToc,
|
||||
} from "./utils";
|
||||
|
||||
type ProviderFilter =
|
||||
@@ -167,18 +170,28 @@ export function SessionManagerPage({ appId }: { appId: string }) {
|
||||
});
|
||||
}, [sessions]);
|
||||
|
||||
const isCodexSession = selectedSession?.providerId === "codex";
|
||||
|
||||
// 提取用户消息用于目录
|
||||
const userMessagesToc = useMemo(() => {
|
||||
return messages
|
||||
.map((msg, index) => ({ msg, index }))
|
||||
.filter(({ msg }) => msg.role.toLowerCase() === "user")
|
||||
.map(({ msg, index }) => ({
|
||||
index,
|
||||
preview:
|
||||
msg.content.slice(0, 50) + (msg.content.length > 50 ? "..." : ""),
|
||||
ts: msg.ts,
|
||||
}));
|
||||
}, [messages]);
|
||||
.filter(({ msg }) => {
|
||||
if (msg.role.toLowerCase() !== "user") return false;
|
||||
return !(isCodexSession && shouldHideCodexMessageFromToc(msg.content));
|
||||
})
|
||||
.map(({ msg, index }) => {
|
||||
const previewContent = isCodexSession
|
||||
? extractCodexPromptPreview(msg.content)
|
||||
: msg.content;
|
||||
|
||||
return {
|
||||
index,
|
||||
preview: formatSessionMessagePreview(previewContent),
|
||||
ts: msg.ts,
|
||||
};
|
||||
});
|
||||
}, [isCodexSession, messages]);
|
||||
|
||||
const scrollToMessage = (index: number) => {
|
||||
virtualizer.scrollToIndex(index, { align: "center", behavior: "smooth" });
|
||||
|
||||
@@ -2,6 +2,56 @@ import type { ReactNode } from "react";
|
||||
import { createElement } from "react";
|
||||
import { SessionMeta } from "@/types";
|
||||
|
||||
const CODEX_IDE_CONTEXT_PREFIX = "# Context from my IDE setup:";
|
||||
const CODEX_REQUEST_MARKER = "my request for codex";
|
||||
|
||||
const getCodexRequestHeadingPayload = (lineText: string) => {
|
||||
if (!lineText.startsWith("#")) return null;
|
||||
|
||||
const heading = lineText.replace(/^#+\s*/, "");
|
||||
const suffix = heading.toLowerCase().startsWith(CODEX_REQUEST_MARKER)
|
||||
? heading.slice(CODEX_REQUEST_MARKER.length).trimStart()
|
||||
: null;
|
||||
|
||||
if (suffix === null) return null;
|
||||
if (!suffix) return "";
|
||||
if (!/^[::\-—]/.test(suffix)) return null;
|
||||
|
||||
return suffix.replace(/^[::\-—\s]+/, "").trim();
|
||||
};
|
||||
|
||||
const extractCodexPromptFromIdeContext = (content: string) => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed.startsWith(CODEX_IDE_CONTEXT_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// VS Code injects the real prompt as the LAST "## My request for Codex:"
|
||||
// section, so keep the final matching heading. Earlier matches can be
|
||||
// headings that live inside the active selection / open file content.
|
||||
// Trade-off: if the request body itself repeats the heading, the preview
|
||||
// truncates to its trailing part (rare; see sessionUtils.test.ts).
|
||||
const lines = trimmed.replace(/\r\n/g, "\n").split("\n");
|
||||
let prompt: string | null = null;
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const inlinePrompt = getCodexRequestHeadingPayload(line.trim());
|
||||
if (inlinePrompt === null) continue;
|
||||
|
||||
if (inlinePrompt) {
|
||||
prompt = inlinePrompt;
|
||||
continue;
|
||||
}
|
||||
|
||||
const followingPrompt = lines
|
||||
.slice(index + 1)
|
||||
.join("\n")
|
||||
.trim();
|
||||
prompt = followingPrompt || null;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
};
|
||||
|
||||
export const getSessionKey = (session: SessionMeta) =>
|
||||
`${session.providerId}:${session.sessionId}:${session.sourcePath ?? ""}`;
|
||||
|
||||
@@ -81,6 +131,29 @@ export const formatSessionTitle = (session: SessionMeta) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const shouldHideCodexMessageFromToc = (content: string) => {
|
||||
const trimmed = content.trim();
|
||||
return (
|
||||
trimmed.startsWith("# AGENTS.md instructions for ") ||
|
||||
trimmed.startsWith("<environment_context>") ||
|
||||
(trimmed.startsWith(CODEX_IDE_CONTEXT_PREFIX) &&
|
||||
!extractCodexPromptFromIdeContext(trimmed))
|
||||
);
|
||||
};
|
||||
|
||||
export const extractCodexPromptPreview = (content: string) => {
|
||||
return extractCodexPromptFromIdeContext(content) ?? content;
|
||||
};
|
||||
|
||||
export const formatSessionMessagePreview = (
|
||||
content: string,
|
||||
maxLength = 50,
|
||||
) => {
|
||||
return (
|
||||
content.slice(0, maxLength) + (content.length > maxLength ? "..." : "")
|
||||
);
|
||||
};
|
||||
|
||||
export const highlightText = (text: string, query: string): ReactNode => {
|
||||
if (!query) return text;
|
||||
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
@@ -15,6 +15,8 @@ export function RectifierConfigPanel() {
|
||||
enabled: true,
|
||||
requestThinkingSignature: true,
|
||||
requestThinkingBudget: true,
|
||||
requestMediaFallback: true,
|
||||
requestMediaHeuristic: true,
|
||||
});
|
||||
const [optimizerConfig, setOptimizerConfig] = useState<OptimizerConfig>({
|
||||
enabled: false,
|
||||
@@ -111,6 +113,36 @@ export function RectifierConfigPanel() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pl-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t("settings.advanced.rectifier.mediaFallback")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.advanced.rectifier.mediaFallbackDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.requestMediaFallback}
|
||||
disabled={!config.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({ requestMediaFallback: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pl-8">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t("settings.advanced.rectifier.mediaHeuristic")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.advanced.rectifier.mediaHeuristicDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.requestMediaHeuristic}
|
||||
disabled={!config.enabled || !config.requestMediaFallback}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({ requestMediaHeuristic: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6 mt-6">
|
||||
|
||||
@@ -415,6 +415,7 @@ export function SettingsPage({
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<WebdavSyncSection
|
||||
config={settings?.webdavSync}
|
||||
s3Config={settings?.s3Sync}
|
||||
settings={settings}
|
||||
onAutoSave={handleAutoSave}
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,10 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
"flex h-9 w-full rounded-md border border-border-default bg-background text-foreground px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,7 @@ const DATA_SOURCE_ICONS: Record<string, React.ReactNode> = {
|
||||
codex_db: <Database className="h-3.5 w-3.5" />,
|
||||
codex_session: <FileText className="h-3.5 w-3.5" />,
|
||||
gemini_session: <FileText className="h-3.5 w-3.5" />,
|
||||
opencode_session: <FileText className="h-3.5 w-3.5" />,
|
||||
};
|
||||
|
||||
export function DataSourceBar({ refreshIntervalMs }: DataSourceBarProps) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user