mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 14:35:22 +08:00
Compare commits
26 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 |
+1
-1
@@ -8,7 +8,7 @@ release/
|
||||
*.tsbuildinfo
|
||||
.npmrc
|
||||
CLAUDE.md
|
||||
# AGENTS.md
|
||||
AGENTS.md
|
||||
GEMINI.md
|
||||
/.claude
|
||||
/.codex
|
||||
|
||||
@@ -151,6 +151,11 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
|
||||
<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>
|
||||
|
||||
@@ -151,6 +151,11 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
|
||||
<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>
|
||||
|
||||
@@ -150,6 +150,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
<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>
|
||||
|
||||
@@ -151,6 +151,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
<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>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 KiB 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 工具调用问题的用户。很多补丁都来自这些真实使用场景里的复现线索。
|
||||
|
||||
|
||||
@@ -261,32 +261,32 @@ By enabling these features, users accept the related risks. CC Switch is not res
|
||||
|
||||
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](https://github.com/keithyt06).
|
||||
- [#3215](https://github.com/farion1231/cc-switch/pull/3215): add OpenCode session usage sync, thanks [@nothingness0db](https://github.com/nothingness0db).
|
||||
- [#2709](https://github.com/farion1231/cc-switch/pull/2709): add the ZenMux Token Plan provider, thanks [@Eter365](https://github.com/Eter365).
|
||||
- [#3643](https://github.com/farion1231/cc-switch/pull/3643): add the CherryIN preset provider, thanks [@zhibisora](https://github.com/zhibisora).
|
||||
- [#3818](https://github.com/farion1231/cc-switch/pull/3818): add the Codex CLI reachability `GET /v1/models` endpoint, thanks [@CSberlin](https://github.com/CSberlin).
|
||||
- [#3426](https://github.com/farion1231/cc-switch/pull/3426): Usage Dashboard hero redesign, thanks [@allenxu09](https://github.com/allenxu09).
|
||||
- [#3640](https://github.com/farion1231/cc-switch/pull/3640): drop `tool_choice` when tools is empty, thanks [@Postroggy](https://github.com/Postroggy).
|
||||
- [#3644](https://github.com/farion1231/cc-switch/pull/3644): preserve Codex custom tool metadata in chat routing, thanks [@LanternCX](https://github.com/LanternCX).
|
||||
- [#3514](https://github.com/farion1231/cc-switch/pull/3514): always include `reasoning_tokens` in Chat→Responses, thanks [@yeeyzy](https://github.com/yeeyzy).
|
||||
- [#3689](https://github.com/farion1231/cc-switch/pull/3689): skip backup / restore when live is already a proxy placeholder, thanks [@YongmaoLuo](https://github.com/YongmaoLuo).
|
||||
- [#3016](https://github.com/farion1231/cc-switch/pull/3016): normalize the localhost listen address, thanks [@Alexlangl](https://github.com/Alexlangl).
|
||||
- [#3775](https://github.com/farion1231/cc-switch/pull/3775): normalize Anthropic `system` messages, thanks [@Dearli666](https://github.com/Dearli666).
|
||||
- [#3656](https://github.com/farion1231/cc-switch/pull/3656): improve error message display in the proxy panel, thanks [@lzcndm](https://github.com/lzcndm).
|
||||
- [#2647](https://github.com/farion1231/cc-switch/pull/2647): raise the infinite-whitespace threshold 20 → 500, thanks [@NiuBlibing](https://github.com/NiuBlibing).
|
||||
- [#3702](https://github.com/farion1231/cc-switch/pull/3702): route the Zhipu quota query to the configured base URL, thanks [@YongmaoLuo](https://github.com/YongmaoLuo).
|
||||
- [#3518](https://github.com/farion1231/cc-switch/pull/3518): adapt to the MiniMax new balance API and default pricing, thanks [@LaoYueHanNi](https://github.com/LaoYueHanNi).
|
||||
- [#3524](https://github.com/farion1231/cc-switch/pull/3524): fix the Zhipu Coding Plan presets and model probing for versioned endpoints, thanks [@makoMakoGo](https://github.com/makoMakoGo).
|
||||
- [#3614](https://github.com/farion1231/cc-switch/pull/3614): use a relative filename for the model catalog, thanks [@steponeerror](https://github.com/steponeerror).
|
||||
- [#3797](https://github.com/farion1231/cc-switch/pull/3797): fix the Windows tray icon residue after exit, thanks [@iAJue](https://github.com/iAJue).
|
||||
- [#3457](https://github.com/farion1231/cc-switch/pull/3457): fix the Windows taskbar icon, thanks [@ZhangNanNan1018](https://github.com/ZhangNanNan1018).
|
||||
- [#3430](https://github.com/farion1231/cc-switch/pull/3430): normalize Windows path separators to match subdirectory skill updates, thanks [@Ninthless](https://github.com/Ninthless).
|
||||
- [#3626](https://github.com/farion1231/cc-switch/pull/3626): disable macOS input auto-capitalization, thanks [@ZHLHZHU](https://github.com/ZHLHZHU).
|
||||
- [#3593](https://github.com/farion1231/cc-switch/pull/3593): fix Codex VS Code session previews, thanks [@xwil1](https://github.com/xwil1).
|
||||
- [#3228](https://github.com/farion1231/cc-switch/pull/3228): align the VS Code wording in the Chinese UI, thanks [@Games55k](https://github.com/Games55k).
|
||||
- [#3411](https://github.com/farion1231/cc-switch/pull/3411): refresh the user manual to reflect current app support, thanks [@makoMakoGo](https://github.com/makoMakoGo).
|
||||
- [#3772](https://github.com/farion1231/cc-switch/pull/3772): fix README release-note links and sponsor markup, thanks [@null-easy](https://github.com/null-easy).
|
||||
- [#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.
|
||||
|
||||
|
||||
@@ -261,32 +261,32 @@ Codex は起動時に `model_catalog_json` を読み込みます。本リリー
|
||||
|
||||
v3.16.2 で機能と修正を届けてくださった以下のコントリビューターに感謝します:
|
||||
|
||||
- [#1351](https://github.com/farion1231/cc-switch/pull/1351): S3 互換クラウドストレージ同期を追加、[@keithyt06](https://github.com/keithyt06) に感謝。
|
||||
- [#3215](https://github.com/farion1231/cc-switch/pull/3215): OpenCode セッション用量同期を追加、[@nothingness0db](https://github.com/nothingness0db) に感謝。
|
||||
- [#2709](https://github.com/farion1231/cc-switch/pull/2709): ZenMux Token Plan プロバイダーを追加、[@Eter365](https://github.com/Eter365) に感謝。
|
||||
- [#3643](https://github.com/farion1231/cc-switch/pull/3643): CherryIN プリセットプロバイダーを追加、[@zhibisora](https://github.com/zhibisora) に感謝。
|
||||
- [#3818](https://github.com/farion1231/cc-switch/pull/3818): Codex CLI 到達性確認用の `GET /v1/models` エンドポイントを追加、[@CSberlin](https://github.com/CSberlin) に感謝。
|
||||
- [#3426](https://github.com/farion1231/cc-switch/pull/3426): 用量ダッシュボードのヒーロー再設計、[@allenxu09](https://github.com/allenxu09) に感謝。
|
||||
- [#3640](https://github.com/farion1231/cc-switch/pull/3640): tools が空のとき `tool_choice` を破棄、[@Postroggy](https://github.com/Postroggy) に感謝。
|
||||
- [#3644](https://github.com/farion1231/cc-switch/pull/3644): Chat ルーティングで Codex カスタムツールメタデータを保持、[@LanternCX](https://github.com/LanternCX) に感謝。
|
||||
- [#3514](https://github.com/farion1231/cc-switch/pull/3514): Chat→Responses で常に `reasoning_tokens` を含める、[@yeeyzy](https://github.com/yeeyzy) に感謝。
|
||||
- [#3689](https://github.com/farion1231/cc-switch/pull/3689): live がすでにプロキシプレースホルダーのときバックアップ / 復元をスキップ、[@YongmaoLuo](https://github.com/YongmaoLuo) に感謝。
|
||||
- [#3016](https://github.com/farion1231/cc-switch/pull/3016): localhost リッスンアドレスを正規化、[@Alexlangl](https://github.com/Alexlangl) に感謝。
|
||||
- [#3775](https://github.com/farion1231/cc-switch/pull/3775): Anthropic `system` メッセージを正規化、[@Dearli666](https://github.com/Dearli666) に感謝。
|
||||
- [#3656](https://github.com/farion1231/cc-switch/pull/3656): プロキシパネルのエラー表示を改善、[@lzcndm](https://github.com/lzcndm) に感謝。
|
||||
- [#2647](https://github.com/farion1231/cc-switch/pull/2647): 無限空白検出のしきい値を 20 → 500 へ引き上げ、[@NiuBlibing](https://github.com/NiuBlibing) に感謝。
|
||||
- [#3702](https://github.com/farion1231/cc-switch/pull/3702): 智譜の残量照会を構成済み base URL へルーティング、[@YongmaoLuo](https://github.com/YongmaoLuo) に感謝。
|
||||
- [#3518](https://github.com/farion1231/cc-switch/pull/3518): MiniMax の新残量 API と既定価格に対応、[@LaoYueHanNi](https://github.com/LaoYueHanNi) に感謝。
|
||||
- [#3524](https://github.com/farion1231/cc-switch/pull/3524): 智譜 Coding Plan プリセットとバージョン付き endpoint のモデル探索を修正、[@makoMakoGo](https://github.com/makoMakoGo) に感謝。
|
||||
- [#3614](https://github.com/farion1231/cc-switch/pull/3614): モデルカタログを相対ファイル名に変更、[@steponeerror](https://github.com/steponeerror) に感謝。
|
||||
- [#3797](https://github.com/farion1231/cc-switch/pull/3797): Windows 終了後のトレイアイコン残留を修正、[@iAJue](https://github.com/iAJue) に感謝。
|
||||
- [#3457](https://github.com/farion1231/cc-switch/pull/3457): Windows タスクバーアイコンを修正、[@ZhangNanNan1018](https://github.com/ZhangNanNan1018) に感謝。
|
||||
- [#3430](https://github.com/farion1231/cc-switch/pull/3430): Windows のパス区切りを正規化してサブディレクトリスキルの更新に対応、[@Ninthless](https://github.com/Ninthless) に感謝。
|
||||
- [#3626](https://github.com/farion1231/cc-switch/pull/3626): macOS の入力自動大文字化を無効化、[@ZHLHZHU](https://github.com/ZHLHZHU) に感謝。
|
||||
- [#3593](https://github.com/farion1231/cc-switch/pull/3593): Codex VS Code セッションプレビューを修正、[@xwil1](https://github.com/xwil1) に感謝。
|
||||
- [#3228](https://github.com/farion1231/cc-switch/pull/3228): 中国語 UI の VS Code 表記を揃える、[@Games55k](https://github.com/Games55k) に感謝。
|
||||
- [#3411](https://github.com/farion1231/cc-switch/pull/3411): 現行のアプリ対応を反映してユーザーマニュアルを刷新、[@makoMakoGo](https://github.com/makoMakoGo) に感謝。
|
||||
- [#3772](https://github.com/farion1231/cc-switch/pull/3772): README のリリースノートリンクとスポンサー表記を修正、[@null-easy](https://github.com/null-easy) に感謝。
|
||||
- [#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 ルーティング、ローカルプロキシのテイクオーバー、用量統計、プラットフォーム互換性の問題を報告してくださったすべてのユーザーにも感謝します。今回の多くの修正は、実際の利用シーンから得られた再現情報に基づいています。
|
||||
|
||||
|
||||
@@ -261,32 +261,32 @@ Codex 在启动时读取 `model_catalog_json`。即使本版已把模型目录
|
||||
|
||||
感谢以下贡献者在 v3.16.2 中提交的功能与修复:
|
||||
|
||||
- [#1351](https://github.com/farion1231/cc-switch/pull/1351):新增 S3 兼容云存储同步,感谢 [@keithyt06](https://github.com/keithyt06)。
|
||||
- [#3215](https://github.com/farion1231/cc-switch/pull/3215):新增 OpenCode 会话用量同步,感谢 [@nothingness0db](https://github.com/nothingness0db)。
|
||||
- [#2709](https://github.com/farion1231/cc-switch/pull/2709):新增 ZenMux Token Plan 供应商,感谢 [@Eter365](https://github.com/Eter365)。
|
||||
- [#3643](https://github.com/farion1231/cc-switch/pull/3643):新增 CherryIN 预设供应商,感谢 [@zhibisora](https://github.com/zhibisora)。
|
||||
- [#3818](https://github.com/farion1231/cc-switch/pull/3818):新增 Codex CLI 探活用的 `GET /v1/models` 端点,感谢 [@CSberlin](https://github.com/CSberlin)。
|
||||
- [#3426](https://github.com/farion1231/cc-switch/pull/3426):用量看板 Hero 重新设计,感谢 [@allenxu09](https://github.com/allenxu09)。
|
||||
- [#3640](https://github.com/farion1231/cc-switch/pull/3640):空 tools 时丢弃 `tool_choice`,感谢 [@Postroggy](https://github.com/Postroggy)。
|
||||
- [#3644](https://github.com/farion1231/cc-switch/pull/3644):Chat 路由保留 Codex 自定义工具元数据,感谢 [@LanternCX](https://github.com/LanternCX)。
|
||||
- [#3514](https://github.com/farion1231/cc-switch/pull/3514):Chat→Responses 始终包含 `reasoning_tokens`,感谢 [@yeeyzy](https://github.com/yeeyzy)。
|
||||
- [#3689](https://github.com/farion1231/cc-switch/pull/3689):live 已是代理占位符时跳过备份 / 恢复,感谢 [@YongmaoLuo](https://github.com/YongmaoLuo)。
|
||||
- [#3016](https://github.com/farion1231/cc-switch/pull/3016):归一化 localhost 监听地址,感谢 [@Alexlangl](https://github.com/Alexlangl)。
|
||||
- [#3775](https://github.com/farion1231/cc-switch/pull/3775):规范化 Anthropic `system` 消息,感谢 [@Dearli666](https://github.com/Dearli666)。
|
||||
- [#3656](https://github.com/farion1231/cc-switch/pull/3656):改进代理面板错误信息展示,感谢 [@lzcndm](https://github.com/lzcndm)。
|
||||
- [#2647](https://github.com/farion1231/cc-switch/pull/2647):调高无限空白检测阈值 20 → 500,感谢 [@NiuBlibing](https://github.com/NiuBlibing)。
|
||||
- [#3702](https://github.com/farion1231/cc-switch/pull/3702):智谱配额查询按所配 base URL 路由,感谢 [@YongmaoLuo](https://github.com/YongmaoLuo)。
|
||||
- [#3518](https://github.com/farion1231/cc-switch/pull/3518):适配 MiniMax 余额查询新接口与默认定价,感谢 [@LaoYueHanNi](https://github.com/LaoYueHanNi)。
|
||||
- [#3524](https://github.com/farion1231/cc-switch/pull/3524):修复智谱 Coding Plan 预设与带版本号端点的模型探测,感谢 [@makoMakoGo](https://github.com/makoMakoGo)。
|
||||
- [#3614](https://github.com/farion1231/cc-switch/pull/3614):模型目录改用相对文件名,感谢 [@steponeerror](https://github.com/steponeerror)。
|
||||
- [#3797](https://github.com/farion1231/cc-switch/pull/3797):修复 Windows 退出后托盘图标残留,感谢 [@iAJue](https://github.com/iAJue)。
|
||||
- [#3457](https://github.com/farion1231/cc-switch/pull/3457):修复 Windows 任务栏图标,感谢 [@ZhangNanNan1018](https://github.com/ZhangNanNan1018)。
|
||||
- [#3430](https://github.com/farion1231/cc-switch/pull/3430):归一化 Windows 路径分隔符以匹配子目录技能更新,感谢 [@Ninthless](https://github.com/Ninthless)。
|
||||
- [#3626](https://github.com/farion1231/cc-switch/pull/3626):关闭 macOS 输入框自动大写,感谢 [@ZHLHZHU](https://github.com/ZHLHZHU)。
|
||||
- [#3593](https://github.com/farion1231/cc-switch/pull/3593):修复 Codex VS Code 会话预览,感谢 [@xwil1](https://github.com/xwil1)。
|
||||
- [#3228](https://github.com/farion1231/cc-switch/pull/3228):对齐中文界面 VS Code 文案,感谢 [@Games55k](https://github.com/Games55k)。
|
||||
- [#3411](https://github.com/farion1231/cc-switch/pull/3411):刷新用户手册以反映当前应用支持,感谢 [@makoMakoGo](https://github.com/makoMakoGo)。
|
||||
- [#3772](https://github.com/farion1231/cc-switch/pull/3772):修复 README release note 链接与赞助商标记,感谢 [@null-easy](https://github.com/null-easy)。
|
||||
- [#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 路由、本地代理接管、用量统计和平台兼容性问题的用户,很多补丁都来自这些真实使用场景里的复现线索。
|
||||
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -1254,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"}), "")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -79,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)
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 = "\
|
||||
|
||||
+91
-8
@@ -1443,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:?}),开始清理...");
|
||||
@@ -1891,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,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 在应用主动退出前显式持久化窗口状态
|
||||
// ============================================================
|
||||
@@ -1908,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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
@@ -159,7 +164,7 @@ impl RequestForwarder {
|
||||
provider_body: &Value,
|
||||
error: &ProxyError,
|
||||
) -> bool {
|
||||
adapter_name == "Claude"
|
||||
matches!(adapter_name, "Claude" | "Codex")
|
||||
&& self.rectifier_config.enabled
|
||||
&& self.rectifier_config.request_media_fallback
|
||||
&& !already_retried
|
||||
@@ -463,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)
|
||||
@@ -511,6 +516,7 @@ impl RequestForwarder {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
outbound_model,
|
||||
connection_guard: None,
|
||||
});
|
||||
}
|
||||
@@ -561,7 +567,7 @@ impl RequestForwarder {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((response, claude_api_format)) => {
|
||||
Ok((response, claude_api_format, outbound_model)) => {
|
||||
log::info!(
|
||||
"[{app_type_str}] [Media] Unsupported-image retry succeeded"
|
||||
);
|
||||
@@ -613,6 +619,7 @@ impl RequestForwarder {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
outbound_model,
|
||||
connection_guard: None,
|
||||
});
|
||||
}
|
||||
@@ -706,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,
|
||||
@@ -761,6 +768,7 @@ impl RequestForwarder {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
outbound_model,
|
||||
connection_guard: None,
|
||||
});
|
||||
}
|
||||
@@ -871,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,
|
||||
@@ -920,6 +928,7 @@ impl RequestForwarder {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
claude_api_format,
|
||||
outbound_model,
|
||||
connection_guard: None,
|
||||
});
|
||||
}
|
||||
@@ -1077,6 +1086,9 @@ impl RequestForwarder {
|
||||
}
|
||||
|
||||
/// 转发单个请求(使用适配器)
|
||||
///
|
||||
/// 成功时返回 `(response, claude_api_format, outbound_model)`,其中
|
||||
/// `outbound_model` 是最终发往上游的模型名(所有映射/改写之后)。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn forward(
|
||||
&self,
|
||||
@@ -1088,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)?;
|
||||
|
||||
@@ -1320,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
|
||||
@@ -1359,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,
|
||||
@@ -1504,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
|
||||
@@ -1598,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;
|
||||
|
||||
@@ -1678,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 {
|
||||
@@ -1727,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 {
|
||||
@@ -1874,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();
|
||||
@@ -3298,6 +3363,18 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -3385,6 +3462,21 @@ mod tests {
|
||||
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:上游报图片错误也不触发兜底重试。
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1144
-32
File diff suppressed because it is too large
Load Diff
@@ -41,14 +41,7 @@ pub fn replace_images_for_text_only_model(
|
||||
}
|
||||
|
||||
pub fn contains_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)
|
||||
})
|
||||
messages_have_image_blocks(body) || responses_input_has_image_blocks(body.get("input"))
|
||||
}
|
||||
|
||||
pub fn replace_image_blocks_with_marker(body: &mut Value) -> usize {
|
||||
@@ -95,6 +88,7 @@ pub fn is_unsupported_image_error(error: &ProxyError) -> bool {
|
||||
"text-only",
|
||||
"invalid content type",
|
||||
"invalid message content",
|
||||
"unknown variant",
|
||||
"unknown content type",
|
||||
"unrecognized content type",
|
||||
"cannot process",
|
||||
@@ -113,51 +107,124 @@ fn content_has_image_blocks(content: &Value) -> bool {
|
||||
};
|
||||
|
||||
blocks.iter().any(|block| {
|
||||
block.get("type").and_then(Value::as_str) == Some("image")
|
||||
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 Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
|
||||
return 0;
|
||||
};
|
||||
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);
|
||||
|
||||
messages
|
||||
.iter_mut()
|
||||
.filter_map(|message| message.get_mut("content"))
|
||||
.map(replace_images_in_content)
|
||||
.sum()
|
||||
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 block.get("type").and_then(Value::as_str) == Some("image") {
|
||||
let cache_control = block.get("cache_control").cloned();
|
||||
*block = json!({
|
||||
"type": "text",
|
||||
"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);
|
||||
}
|
||||
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(nested_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;
|
||||
[
|
||||
@@ -369,6 +436,54 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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!({
|
||||
@@ -654,6 +769,19 @@ mod tests {
|
||||
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:内置列表不再预测性剥图,避免误判多模态模型时静默丢图。
|
||||
|
||||
@@ -409,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(
|
||||
@@ -1617,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(
|
||||
|
||||
@@ -100,15 +100,23 @@ struct ToolBlockState {
|
||||
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!(
|
||||
|
||||
@@ -225,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:
|
||||
@@ -617,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())
|
||||
@@ -631,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!({
|
||||
@@ -1287,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);
|
||||
}
|
||||
@@ -1311,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!({
|
||||
|
||||
@@ -336,21 +336,8 @@ pub fn responses_to_chat_completions_with_reasoning(
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 类型
|
||||
@@ -185,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)
|
||||
@@ -522,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!({
|
||||
|
||||
@@ -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,29 +246,38 @@ 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
|
||||
}
|
||||
|
||||
/// Resolve the Zhipu quota endpoint from the user's configured `base_url`.
|
||||
@@ -779,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,若上游意外增加第三条应被丢弃,避免命名空缺。
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1632,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}"))?;
|
||||
@@ -1650,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}"))?;
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1154,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!(
|
||||
@@ -1163,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"
|
||||
@@ -1281,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}
|
||||
@@ -1324,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 = ?"
|
||||
@@ -1469,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> {
|
||||
@@ -1480,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
|
||||
@@ -1489,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<_>, _>>()?
|
||||
@@ -1647,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);
|
||||
};
|
||||
@@ -2190,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()?;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -331,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);
|
||||
@@ -678,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,
|
||||
@@ -886,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">
|
||||
@@ -903,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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -111,6 +111,28 @@ export function RequestDetailPanel({
|
||||
{t("usage.model", "模型")}
|
||||
</dt>
|
||||
<dd className="font-mono">{request.model}</dd>
|
||||
{request.requestModel &&
|
||||
request.requestModel !== request.model && (
|
||||
<>
|
||||
<dt className="mt-1 text-muted-foreground">
|
||||
{t("usage.requestModel", "请求模型")}
|
||||
</dt>
|
||||
<dd className="font-mono text-xs">
|
||||
{request.requestModel}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
{request.pricingModel &&
|
||||
request.pricingModel !== request.model && (
|
||||
<>
|
||||
<dt className="mt-1 text-muted-foreground">
|
||||
{t("usage.pricingModel", "计价模型")}
|
||||
</dt>
|
||||
<dd className="font-mono text-xs">
|
||||
{request.pricingModel}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { cloneElement, isValidElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useUsageSummaryByApp } from "@/lib/query/usage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { APP_ICON_MAP } from "@/config/appConfig";
|
||||
import type { AppId } from "@/lib/api/types";
|
||||
import {
|
||||
Activity,
|
||||
ArrowDownToLine,
|
||||
@@ -46,9 +49,16 @@ const TITLE_THEMES: Record<AppType | "all", TitleTheme> = {
|
||||
accent: "text-amber-600 dark:text-amber-400",
|
||||
iconBg: "bg-amber-500/10",
|
||||
},
|
||||
"claude-desktop": {
|
||||
// 与 Claude Code 同属 Anthropic 品牌,用更深的 orange 区分
|
||||
accent: "text-orange-600 dark:text-orange-400",
|
||||
iconBg: "bg-orange-500/10",
|
||||
},
|
||||
codex: {
|
||||
accent: "text-emerald-600 dark:text-emerald-400",
|
||||
iconBg: "bg-emerald-500/10",
|
||||
// OpenAI/Codex 走黑白单色调;中性灰在深浅模式都能透出方块底色,
|
||||
// 不像纯黑 bg-black/10 在深色背景下会糊掉。
|
||||
accent: "text-neutral-700 dark:text-neutral-300",
|
||||
iconBg: "bg-neutral-500/10",
|
||||
},
|
||||
gemini: {
|
||||
accent: "text-sky-600 dark:text-sky-400",
|
||||
@@ -130,6 +140,27 @@ function deriveCacheWriteState(appTypes: string[]): CacheWriteState {
|
||||
return "partial";
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero 标题图标:选中具体应用时显示该应用的品牌图标,"全部"时回退到通用闪电。
|
||||
* 复用 APP_ICON_MAP(与侧边栏 / 应用切换器同一套图标),用 cloneElement 放大到
|
||||
* 与原闪电一致的 20px;品牌图标自带配色,外层方块仍按 titleTheme 主题色着色。
|
||||
*/
|
||||
function AppGlyph({
|
||||
appType,
|
||||
accentClass,
|
||||
}: {
|
||||
appType?: string;
|
||||
accentClass: string;
|
||||
}) {
|
||||
if (appType && appType in APP_ICON_MAP) {
|
||||
const base = APP_ICON_MAP[appType as AppId].icon;
|
||||
if (isValidElement<{ size?: number }>(base)) {
|
||||
return cloneElement(base, { size: 20 });
|
||||
}
|
||||
}
|
||||
return <Zap className={cn("h-5 w-5", accentClass)} />;
|
||||
}
|
||||
|
||||
export function UsageHero({
|
||||
range,
|
||||
appType,
|
||||
@@ -217,7 +248,7 @@ export function UsageHero({
|
||||
titleTheme.iconBg,
|
||||
)}
|
||||
>
|
||||
<Zap className={cn("h-5 w-5", titleTheme.accent)} />
|
||||
<AppGlyph appType={appType} accentClass={titleTheme.accent} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground flex items-center gap-1.5 mb-0.5">
|
||||
|
||||
@@ -253,6 +253,19 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
|
||||
partnerPromotionKey: "ccsub",
|
||||
icon: "ccsub",
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
apiKeyUrl: "https://unity2.ai/register?source=ccs",
|
||||
category: "aggregator",
|
||||
baseUrl: "https://api.unity2.ai",
|
||||
mode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
modelRoutes: passthroughRoutes(true),
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "unity2",
|
||||
icon: "unity2",
|
||||
},
|
||||
{
|
||||
name: "Gemini Native",
|
||||
websiteUrl: "https://ai.google.dev/gemini-api",
|
||||
|
||||
@@ -208,6 +208,21 @@ export const providerPresets: ProviderPreset[] = [
|
||||
partnerPromotionKey: "ccsub",
|
||||
icon: "ccsub",
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
apiKeyUrl: "https://unity2.ai/register?source=ccs",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.unity2.ai",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "unity2",
|
||||
icon: "unity2",
|
||||
},
|
||||
{
|
||||
name: "Gemini Native",
|
||||
websiteUrl: "https://ai.google.dev/gemini-api",
|
||||
|
||||
@@ -231,6 +231,22 @@ export const codexProviderPresets: CodexProviderPreset[] = [
|
||||
partnerPromotionKey: "ccsub",
|
||||
icon: "ccsub",
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
apiKeyUrl: "https://unity2.ai/register?source=ccs",
|
||||
category: "aggregator",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
config: generateThirdPartyConfig(
|
||||
"unity2",
|
||||
"https://api.unity2.ai",
|
||||
"gpt-5.5",
|
||||
),
|
||||
endpointCandidates: ["https://api.unity2.ai"],
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "unity2",
|
||||
icon: "unity2",
|
||||
},
|
||||
{
|
||||
name: "Azure OpenAI",
|
||||
websiteUrl:
|
||||
|
||||
@@ -69,6 +69,24 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [
|
||||
partnerPromotionKey: "shengsuanyun",
|
||||
icon: "shengsuanyun",
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
apiKeyUrl: "https://unity2.ai/register?source=ccs",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
GOOGLE_GEMINI_BASE_URL: "https://api.unity2.ai",
|
||||
GEMINI_MODEL: "gemini-3.1-pro",
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.unity2.ai",
|
||||
model: "gemini-3.1-pro",
|
||||
description: "Unity2.ai",
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "unity2",
|
||||
icon: "unity2",
|
||||
},
|
||||
{
|
||||
name: "PackyCode",
|
||||
websiteUrl: "https://www.packyapi.com",
|
||||
|
||||
@@ -248,36 +248,11 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
context_length: 1000000,
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
context_length: 1000000,
|
||||
},
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
context_length: 400000,
|
||||
},
|
||||
{
|
||||
id: "o3",
|
||||
name: "o3",
|
||||
context_length: 200000,
|
||||
},
|
||||
{
|
||||
id: "gemini-3.1-pro",
|
||||
name: "Gemini 3.1 Pro",
|
||||
context_length: 1000000,
|
||||
},
|
||||
{
|
||||
id: "deepseek-v4-flash",
|
||||
name: "DeepSeek V4 Flash",
|
||||
context_length: 1000000,
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -285,7 +260,32 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
|
||||
partnerPromotionKey: "ccsub",
|
||||
icon: "ccsub",
|
||||
suggestedDefaults: {
|
||||
model: { default: "claude-opus-4-8", provider: "ccsub" },
|
||||
model: { default: "gpt-5.5", provider: "ccsub" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
apiKeyUrl: "https://unity2.ai/register?source=ccs",
|
||||
settingsConfig: {
|
||||
name: "unity2",
|
||||
base_url: "https://api.unity2.ai/v1",
|
||||
api_key: "",
|
||||
api_mode: "chat_completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
context_length: 400000,
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "unity2",
|
||||
icon: "unity2",
|
||||
suggestedDefaults: {
|
||||
model: { default: "gpt-5.5", provider: "unity2" },
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -265,42 +265,12 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "claude-opus-4-8",
|
||||
name: "Claude Opus 4.8",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 5, output: 25 },
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
contextWindow: 400000,
|
||||
cost: { input: 5, output: 15 },
|
||||
},
|
||||
{
|
||||
id: "o3",
|
||||
name: "o3",
|
||||
contextWindow: 200000,
|
||||
cost: { input: 10, output: 40 },
|
||||
},
|
||||
{
|
||||
id: "gemini-3.1-pro",
|
||||
name: "Gemini 3.1 Pro",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 1.25, output: 10 },
|
||||
},
|
||||
{
|
||||
id: "deepseek-v4-flash",
|
||||
name: "DeepSeek V4 Flash",
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 0.14, output: 0.28 },
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -316,12 +286,47 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "ccsub/claude-opus-4-8",
|
||||
fallbacks: ["ccsub/claude-sonnet-4-6"],
|
||||
primary: "ccsub/gpt-5.5",
|
||||
},
|
||||
modelCatalog: {
|
||||
"ccsub/claude-opus-4-8": { alias: "Opus" },
|
||||
"ccsub/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"ccsub/gpt-5.5": { alias: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
apiKeyUrl: "https://unity2.ai/register?source=ccs",
|
||||
settingsConfig: {
|
||||
baseUrl: "https://api.unity2.ai/v1",
|
||||
apiKey: "",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
contextWindow: 400000,
|
||||
cost: { input: 5, output: 15 },
|
||||
},
|
||||
],
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "unity2",
|
||||
icon: "unity2",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
suggestedDefaults: {
|
||||
model: {
|
||||
primary: "unity2/gpt-5.5",
|
||||
},
|
||||
modelCatalog: {
|
||||
"unity2/gpt-5.5": { alias: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -420,12 +420,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"claude-opus-4-8": { name: "Claude Opus 4.8" },
|
||||
"claude-sonnet-4-6": { name: "Claude Sonnet 4.6" },
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
o3: { name: "o3" },
|
||||
"gemini-3.1-pro": { name: "Gemini 3.1 Pro" },
|
||||
"deepseek-v4-flash": { name: "DeepSeek V4 Flash" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
@@ -440,6 +435,34 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Unity2.ai",
|
||||
websiteUrl: "https://unity2.ai",
|
||||
apiKeyUrl: "https://unity2.ai/register?source=ccs",
|
||||
settingsConfig: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "Unity2.ai",
|
||||
options: {
|
||||
baseURL: "https://api.unity2.ai/v1",
|
||||
apiKey: "",
|
||||
setCacheKey: true,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.5": { name: "GPT-5.5" },
|
||||
},
|
||||
},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
partnerPromotionKey: "unity2",
|
||||
icon: "unity2",
|
||||
templateValues: {
|
||||
apiKey: {
|
||||
label: "API Key",
|
||||
placeholder: "",
|
||||
editorValue: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "DeepSeek",
|
||||
websiteUrl: "https://platform.deepseek.com",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 自定义 User-Agent 预设。
|
||||
*
|
||||
* 取值来自 PR #3671 对 Kimi Coding Plan(api.kimi.com/coding)UA 白名单的 curl 实测:
|
||||
* `claude-cli/*`、`claude-code/*`、`Kilo-Code/*` 可通过;`codex-cli`、`kimi-cli` 会被 403。
|
||||
* 白名单只校验 UA 名称前缀、不看版本号,因此用静态值即可,版本不会因 Claude Code 升级而失效。
|
||||
*
|
||||
* 第一条是官方 Claude Code CLI 实际发送的完整格式(参见 `stream_check.rs` 里检测用的
|
||||
* `claude-cli/2.1.2 (external, cli)`),最贴近真实客户端、最稳过严格的 UA 校验;其余为简短变体。
|
||||
*
|
||||
* 这些预设主要用于"非白名单 Coding Agent(Codex/Gemini/Hermes/OpenClaw 等)想接入受 UA
|
||||
* 限制的上游"的场景——把转发请求伪装成已在白名单内的客户端。是否使用由用户显式选择。
|
||||
*/
|
||||
export const USER_AGENT_PRESETS: readonly string[] = [
|
||||
"claude-cli/2.1.161 (external, cli)",
|
||||
"claude-cli/2.1.161",
|
||||
"claude-code/1.0.0",
|
||||
"claude-code/0.1.0",
|
||||
"Kilo-Code/1.0",
|
||||
];
|
||||
@@ -1002,7 +1002,8 @@
|
||||
"doubaoseed": "DouBao offers exclusive benefits for CC Switch users: register via this link to get 500K tokens of inference credits for all DouBao text models and 5M tokens of free Seedance 2.0 credits.",
|
||||
"volcengine_agentplan": "Volcengine Ark Agent Plan offers exclusive benefits for CC Switch users: subscribe via this link, starting from ¥40/month for new customers.",
|
||||
"byteplus": "Register via this link to get 500,000 tokens of free inference quota per model.",
|
||||
"ccsub": "CCSub is a stable, affordable AI API relay — a drop-in replacement for Claude.ai & OpenAI subscriptions, with one key for all models at ~30% of direct API cost."
|
||||
"ccsub": "CCSub is a stable, affordable AI API relay — a drop-in replacement for Claude.ai & OpenAI subscriptions, with one key for all models at ~30% of direct API cost.",
|
||||
"unity2": "Unity2.ai offers exclusive benefits for CC Switch users: register via this link to get $2 in credits, plus another $10 for joining the official group — up to $12 in free credits!"
|
||||
},
|
||||
"presets": {
|
||||
"ucloud": "Compshare",
|
||||
@@ -1043,6 +1044,10 @@
|
||||
"anthropicSmallFastModel": "Fast Model",
|
||||
"apiFormat": "API Format",
|
||||
"apiFormatHint": "Select the input format for the provider's API",
|
||||
"customUserAgent": "Custom User-Agent",
|
||||
"customUserAgentHint": "Only takes effect when local routing/proxy takeover is enabled; replaces the User-Agent in requests forwarded to the provider API.",
|
||||
"customUserAgentInvalid": "User-Agent must not contain control characters (e.g. line breaks); otherwise it will be ignored.",
|
||||
"customUserAgentPresets": "Presets",
|
||||
"fullUrlLabel": "Full URL",
|
||||
"fullUrlEnabled": "Full URL Mode",
|
||||
"fullUrlDisabled": "Mark as Full URL",
|
||||
@@ -1257,8 +1262,9 @@
|
||||
"reasoningModeHint": "Enable when the upstream Chat Completions API supports toggling thinking on/off. Providers like Kimi, GLM and Qwen usually fall into this category.",
|
||||
"reasoningEffortToggle": "Supports Reasoning Effort",
|
||||
"reasoningEffortHint": "Enable when the upstream supports thinking-depth control such as low/high/max. Enabling this also turns on thinking mode and converts Codex's reasoning.effort into the upstream Chat parameter.",
|
||||
"reasoningSectionToggle": "Reasoning Capability (Advanced · usually auto-detected)",
|
||||
"reasoningSectionHint": "Preset providers are configured automatically; custom providers are inferred from name/URL. Expand to override manually only when auto-detection is wrong."
|
||||
"reasoningGroupTitle": "Reasoning Capability",
|
||||
"reasoningSectionHint": "Preset providers are configured automatically; custom providers are inferred from name/URL. Override manually only when auto-detection is wrong.",
|
||||
"advancedSectionHint": "Includes local routing, model mapping, reasoning overrides and custom User-Agent. Enable local routing here when your provider uses the Chat Completions protocol or non-GPT models."
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "Environment Variables (.env)",
|
||||
@@ -1321,7 +1327,14 @@
|
||||
"label": "Provider Preset",
|
||||
"custom": "Custom Configuration",
|
||||
"other": "Other",
|
||||
"hint": "You can continue to adjust the fields below after selecting a preset."
|
||||
"hint": "You can continue to adjust the fields below after selecting a preset.",
|
||||
"searchTooltip": "Search presets",
|
||||
"searchAriaLabel": "Search provider presets",
|
||||
"searchPlaceholder": "Search name, website, or category...",
|
||||
"sortAriaLabel": "Toggle preset sorting",
|
||||
"sortNameAscTooltip": "Sort by name A-Z",
|
||||
"sortOriginalTooltip": "Restore original order",
|
||||
"noSearchResults": "No provider presets match your search."
|
||||
},
|
||||
"usage": {
|
||||
"title": "Usage Statistics",
|
||||
@@ -1373,6 +1386,7 @@
|
||||
"appFilter": {
|
||||
"all": "All",
|
||||
"claude": "Claude Code",
|
||||
"claude-desktop": "Claude Desktop",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
@@ -1403,6 +1417,7 @@
|
||||
"modelPricingDesc": "Configure token costs for each model",
|
||||
"noPricingData": "No pricing data. Click \"Add\" to add model pricing configuration.",
|
||||
"model": "Model",
|
||||
"pricingModel": "Pricing Model",
|
||||
"displayName": "Display Name",
|
||||
"inputCost": "Input Cost",
|
||||
"outputCost": "Output Cost",
|
||||
|
||||
@@ -1002,7 +1002,8 @@
|
||||
"doubaoseed": "豆包大モデルは CC Switch ユーザーに専属特典を提供しています:このリンクから登録すると、豆包全テキストモデル50万トークンの推論クレジットおよびSeedance2.0の500万トークン無料クレジットがもらえます。",
|
||||
"volcengine_agentplan": "火山方舟 Agent Plan は CC Switch ユーザーに専属特典を提供しています:このリンクから方舟AgentPlanを購読すると、新規のお客様は初月40元から利用できます。",
|
||||
"byteplus": "このリンクから登録すると、各モデルごとに50万トークンの無料推論クォータがもらえます。",
|
||||
"ccsub": "CCSub は安定・低価格の AI API リレーサービスです。Claude Code の公式サブスクリプションの代替として、1つのキーで全モデルを公式比約1/3のコストで利用できます。"
|
||||
"ccsub": "CCSub は安定・低価格の AI API リレーサービスです。Claude Code の公式サブスクリプションの代替として、1つのキーで全モデルを公式比約1/3のコストで利用できます。",
|
||||
"unity2": "Unity2.ai は CC Switch ユーザーに専属特典を提供しています:このリンクから登録すると $2 分のクレジット、公式グループ参加でさらに $10、最大 $12 の無料クレジットがもらえます!"
|
||||
},
|
||||
"presets": {
|
||||
"ucloud": "Compshare",
|
||||
@@ -1043,6 +1044,10 @@
|
||||
"anthropicSmallFastModel": "高速モデル",
|
||||
"apiFormat": "API フォーマット",
|
||||
"apiFormatHint": "プロバイダー API の入力フォーマットを選択",
|
||||
"customUserAgent": "カスタム User-Agent",
|
||||
"customUserAgentHint": "ローカルルーティング/プロキシ引き継ぎが有効な場合にのみ適用され、プロバイダー API へ転送するリクエストの User-Agent を置き換えます。",
|
||||
"customUserAgentInvalid": "User-Agent に制御文字(改行など)を含めることはできません。含まれている場合は無視されます。",
|
||||
"customUserAgentPresets": "プリセット",
|
||||
"fullUrlLabel": "フル URL",
|
||||
"fullUrlEnabled": "フル URL モード",
|
||||
"fullUrlDisabled": "フル URL として設定",
|
||||
@@ -1257,8 +1262,9 @@
|
||||
"reasoningModeHint": "上流の Chat Completions API が思考(thinking)のオン/オフ切り替えに対応している場合に有効化します。Kimi、GLM、Qwen などが通常このタイプに該当します。",
|
||||
"reasoningEffortToggle": "思考レベルに対応",
|
||||
"reasoningEffortHint": "上流が low/high/max などの思考の深さの制御に対応している場合に有効化します。有効にすると思考モードも自動的にオンになり、Codex の reasoning.effort を上流の Chat パラメータに変換します。",
|
||||
"reasoningSectionToggle": "思考能力(詳細・通常は自動識別)",
|
||||
"reasoningSectionHint": "プリセットの供給元は自動的に設定され、カスタム供給元は名前/URL から自動推論されます。自動識別が正しくない場合のみ展開して手動で上書きしてください。"
|
||||
"reasoningGroupTitle": "思考能力",
|
||||
"reasoningSectionHint": "プリセットの供給元は自動的に設定され、カスタム供給元は名前/URL から自動推論されます。自動識別が正しくない場合のみ手動で上書きしてください。",
|
||||
"advancedSectionHint": "ローカルルーティング、モデルマッピング、思考能力、カスタム User-Agent の設定を含みます。供給元が Chat Completions プロトコルまたは GPT 以外のモデルを使用する場合は、ここでローカルルーティングを有効にしてください。"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "環境変数 (.env)",
|
||||
@@ -1321,7 +1327,14 @@
|
||||
"label": "プロバイダータイプ",
|
||||
"custom": "カスタム設定",
|
||||
"other": "その他",
|
||||
"hint": "プリセットを選んだ後でも、下のフィールドで調整できます。"
|
||||
"hint": "プリセットを選んだ後でも、下のフィールドで調整できます。",
|
||||
"searchTooltip": "プリセットを検索",
|
||||
"searchAriaLabel": "プロバイダープリセットを検索",
|
||||
"searchPlaceholder": "名前、サイト、カテゴリで検索...",
|
||||
"sortAriaLabel": "プリセットの並び順を切り替え",
|
||||
"sortNameAscTooltip": "名前順 A-Z で並び替え",
|
||||
"sortOriginalTooltip": "元の順序に戻す",
|
||||
"noSearchResults": "一致するプロバイダープリセットがありません。"
|
||||
},
|
||||
"usage": {
|
||||
"title": "利用統計",
|
||||
@@ -1373,6 +1386,7 @@
|
||||
"appFilter": {
|
||||
"all": "すべて",
|
||||
"claude": "Claude Code",
|
||||
"claude-desktop": "Claude Desktop",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
@@ -1403,6 +1417,7 @@
|
||||
"modelPricingDesc": "各モデルのトークンコストを設定",
|
||||
"noPricingData": "料金データがありません。「追加」をクリックしてモデル料金を設定してください。",
|
||||
"model": "モデル",
|
||||
"pricingModel": "課金モデル",
|
||||
"displayName": "表示名",
|
||||
"inputCost": "入力コスト",
|
||||
"outputCost": "出力コスト",
|
||||
|
||||
@@ -973,7 +973,9 @@
|
||||
"lemondata": "Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。",
|
||||
"doubaoseed": "豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度",
|
||||
"volcengine_agentplan": "火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。",
|
||||
"byteplus": "透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。"
|
||||
"byteplus": "透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。",
|
||||
"ccsub": "CCSub 是穩定、實惠的 AI API 中轉平台,Claude Code 官方訂閱的超強平替,一個 Key 覆蓋全部模型,價格約為官方 1/3。",
|
||||
"unity2": "Unity2.ai 為 CC Switch 使用者提供專屬福利:透過此連結註冊可領取 $2 餘額,加入官方群再送 $10,最高可領 $12 免費額度!"
|
||||
},
|
||||
"presets": {
|
||||
"ucloud": "優雲智算",
|
||||
@@ -1014,6 +1016,10 @@
|
||||
"anthropicSmallFastModel": "快速模型",
|
||||
"apiFormat": "API 格式",
|
||||
"apiFormatHint": "選擇供應商 API 的輸入格式",
|
||||
"customUserAgent": "自訂 User-Agent",
|
||||
"customUserAgentHint": "僅在開啟本地路由/代理接管後生效,會取代轉發至供應商 API 請求中的 User-Agent。",
|
||||
"customUserAgentInvalid": "User-Agent 不能包含控制字元(如換行字元),否則將被忽略。",
|
||||
"customUserAgentPresets": "預設",
|
||||
"fullUrlLabel": "完整 URL",
|
||||
"fullUrlEnabled": "完整 URL 模式",
|
||||
"fullUrlDisabled": "標記為完整 URL",
|
||||
@@ -1203,7 +1209,34 @@
|
||||
"modelNamePlaceholder": "例如: gpt-5-codex",
|
||||
"contextWindow1M": "1M 上下文視窗",
|
||||
"autoCompactLimit": "壓縮閾值",
|
||||
"autoCompactLimitHint": "上下文 token 數達到此閾值時自動壓縮歷史"
|
||||
"autoCompactLimitHint": "上下文 token 數達到此閾值時自動壓縮歷史",
|
||||
"autoCompactLimitPlaceholder": "例如: 90000",
|
||||
"localRoutingToggle": "需要本地路由映射",
|
||||
"localRoutingOnHint": "Codex 目前僅原生支援 OpenAI Responses API 與 GPT 系列模型;如果您的供應商使用 Chat Completions 協定或非 GPT 模型(如 DeepSeek、Kimi),則需要打開本開關,並在使用過程中保持本地路由開啟。",
|
||||
"localRoutingOffHint": "如果您的供應商不是原生 OpenAI Responses API,或者模型名不是 Codex 預設的 GPT 系列,請打開此開關。",
|
||||
"modelMappingTitle": "模型映射",
|
||||
"modelMappingHint": "產生 Codex model_catalog_json,讓 /model 指令顯示這些第三方模型名;表中條目按填寫內容原樣儲存。修改後需要重新啟動 Codex 才能重新整理模型清單。",
|
||||
"addCatalogModel": "新增模型",
|
||||
"catalogDisplayName": "顯示名稱",
|
||||
"catalogModelId": "模型 ID",
|
||||
"catalogColumnDisplay": "選單顯示名",
|
||||
"catalogColumnModel": "實際請求模型",
|
||||
"catalogColumnContext": "上下文視窗",
|
||||
"catalogDisplayNamePlaceholder": "例如: DeepSeek V4 Flash",
|
||||
"catalogModelPlaceholder": "例如: deepseek-v4-flash",
|
||||
"contextWindow": "上下文視窗",
|
||||
"contextWindowPlaceholder": "例如: 128000",
|
||||
"upstreamModelName": "上游模型名稱",
|
||||
"upstreamModelNameHint": "Chat 格式下這裡填寫真實上游模型;路由會把 Codex 的 Responses 請求轉換為 Chat Completions 並保持該模型。",
|
||||
"modelMetadataAdvanced": "模型中繼資料進階選項",
|
||||
"modelMetadataAdvancedHint": "用於未知模型的 Codex 中繼資料覆寫;清空欄位會從 config.toml 刪除對應設定。",
|
||||
"reasoningModeToggle": "支援思考模式",
|
||||
"reasoningModeHint": "上游 Chat Completions 介面支援開啟或關閉 thinking 時啟用。Kimi、GLM、Qwen 等通常屬於這一類。",
|
||||
"reasoningEffortToggle": "支援思考等級",
|
||||
"reasoningEffortHint": "上游支援 low/high/max 等思考深度控制時啟用。啟用後會自動啟用思考模式,並把 Codex 的 reasoning.effort 轉成上游 Chat 參數。",
|
||||
"reasoningGroupTitle": "思考能力",
|
||||
"reasoningSectionHint": "預設供應商已自動設定;自訂供應商會按名稱/位址自動推斷。僅當自動識別不準時才需手動覆寫。",
|
||||
"advancedSectionHint": "包含本地路由映射、模型映射、思考能力與自訂 User-Agent。供應商使用 Chat Completions 協定或非 GPT 模型時,需在此開啟本地路由映射。"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "環境變數 (.env)",
|
||||
@@ -1266,7 +1299,14 @@
|
||||
"label": "預設供應商",
|
||||
"custom": "自訂設定",
|
||||
"other": "其他",
|
||||
"hint": "選擇預設後可繼續調整下方欄位。"
|
||||
"hint": "選擇預設後可繼續調整下方欄位。",
|
||||
"searchTooltip": "搜尋預設",
|
||||
"searchAriaLabel": "搜尋預設供應商",
|
||||
"searchPlaceholder": "搜尋名稱、官網或分類...",
|
||||
"sortAriaLabel": "切換預設排序",
|
||||
"sortNameAscTooltip": "按名稱 A-Z 排序",
|
||||
"sortOriginalTooltip": "恢復原始順序",
|
||||
"noSearchResults": "沒有相符的預設供應商。"
|
||||
},
|
||||
"usage": {
|
||||
"title": "使用統計",
|
||||
@@ -1318,6 +1358,7 @@
|
||||
"appFilter": {
|
||||
"all": "全部",
|
||||
"claude": "Claude Code",
|
||||
"claude-desktop": "Claude Desktop",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
@@ -1348,6 +1389,7 @@
|
||||
"modelPricingDesc": "設定各模型的 Token 成本",
|
||||
"noPricingData": "暫無定價資料。點擊「新增」加入模型定價設定。",
|
||||
"model": "模型",
|
||||
"pricingModel": "計價模型",
|
||||
"displayName": "顯示名稱",
|
||||
"inputCost": "輸入成本",
|
||||
"outputCost": "輸出成本",
|
||||
|
||||
@@ -1002,7 +1002,8 @@
|
||||
"doubaoseed": "豆包大模型为 CC Switch 的用户提供了专属福利:通过此链接注册即可获取豆包全系列文本模型 50万tokens推理额度以及Seedance2.0的500万tokens免费额度",
|
||||
"volcengine_agentplan": "火山方舟 Agent Plan 为 CC Switch 的用户提供了专属福利:通过此链接订阅方舟AgentPlan,新客户首月40元起。",
|
||||
"byteplus": "通过此链接注册即可获取每个模型50万tokens的免费推理额度。",
|
||||
"ccsub": "CCSub 是稳定、实惠的 AI API 中转平台,Claude Code 官方订阅的超强平替,一个 Key 覆盖全部模型,价格约为官方 1/3。"
|
||||
"ccsub": "CCSub 是稳定、实惠的 AI API 中转平台,Claude Code 官方订阅的超强平替,一个 Key 覆盖全部模型,价格约为官方 1/3。",
|
||||
"unity2": "Unity2.ai 为 CC Switch 用户提供专属福利:通过此链接注册可领取 $2 余额,加入官方群再送 $10,最高可领 $12 免费额度!"
|
||||
},
|
||||
"presets": {
|
||||
"ucloud": "优云智算",
|
||||
@@ -1043,6 +1044,10 @@
|
||||
"anthropicSmallFastModel": "快速模型",
|
||||
"apiFormat": "API 格式",
|
||||
"apiFormatHint": "选择供应商 API 的输入格式",
|
||||
"customUserAgent": "自定义 User-Agent",
|
||||
"customUserAgentHint": "仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。",
|
||||
"customUserAgentInvalid": "User-Agent 不能包含控制字符(如换行符),否则将被忽略。",
|
||||
"customUserAgentPresets": "预设",
|
||||
"fullUrlLabel": "完整 URL",
|
||||
"fullUrlEnabled": "完整 URL 模式",
|
||||
"fullUrlDisabled": "标记为完整 URL",
|
||||
@@ -1257,8 +1262,9 @@
|
||||
"reasoningModeHint": "上游 Chat Completions 接口支持开启或关闭 thinking 时启用。Kimi、GLM、Qwen 等通常属于这一类。",
|
||||
"reasoningEffortToggle": "支持思考等级",
|
||||
"reasoningEffortHint": "上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。",
|
||||
"reasoningSectionToggle": "思考能力(高级·通常自动识别)",
|
||||
"reasoningSectionHint": "预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需展开手动覆盖。"
|
||||
"reasoningGroupTitle": "思考能力",
|
||||
"reasoningSectionHint": "预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需手动覆盖。",
|
||||
"advancedSectionHint": "包含本地路由映射、模型映射、思考能力与自定义 User-Agent。供应商使用 Chat Completions 协议或非 GPT 模型时,需在此开启本地路由映射。"
|
||||
},
|
||||
"geminiConfig": {
|
||||
"envFile": "环境变量 (.env)",
|
||||
@@ -1321,7 +1327,14 @@
|
||||
"label": "预设供应商",
|
||||
"custom": "自定义配置",
|
||||
"other": "其他",
|
||||
"hint": "选择预设后可继续调整下方字段。"
|
||||
"hint": "选择预设后可继续调整下方字段。",
|
||||
"searchTooltip": "搜索预设",
|
||||
"searchAriaLabel": "搜索预设供应商",
|
||||
"searchPlaceholder": "搜索名称、官网或分类...",
|
||||
"sortAriaLabel": "切换预设排序",
|
||||
"sortNameAscTooltip": "按名称 A-Z 排序",
|
||||
"sortOriginalTooltip": "恢复原始顺序",
|
||||
"noSearchResults": "没有匹配的预设供应商。"
|
||||
},
|
||||
"usage": {
|
||||
"title": "使用统计",
|
||||
@@ -1373,6 +1386,7 @@
|
||||
"appFilter": {
|
||||
"all": "全部",
|
||||
"claude": "Claude Code",
|
||||
"claude-desktop": "Claude Desktop",
|
||||
"codex": "Codex",
|
||||
"gemini": "Gemini",
|
||||
"opencode": "OpenCode"
|
||||
@@ -1403,6 +1417,7 @@
|
||||
"modelPricingDesc": "配置各模型的 Token 成本",
|
||||
"noPricingData": "暂无定价数据。点击\"新增\"添加模型定价配置。",
|
||||
"model": "模型",
|
||||
"pricingModel": "计价模型",
|
||||
"displayName": "显示名称",
|
||||
"inputCost": "输入成本",
|
||||
"outputCost": "输出成本",
|
||||
|
||||
@@ -19,6 +19,7 @@ import _relaxcode from "./relaxcode.png";
|
||||
import _runapi from "./runapi.jpg";
|
||||
import _shengsuanyun from "./shengsuanyun.svg?url";
|
||||
import _sudocode from "./sudocode.png";
|
||||
import _unity2 from "./unity2.png";
|
||||
|
||||
export const icons: Record<string, string> = {
|
||||
aicodemirror: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" style="flex:none;line-height:1" viewBox="0 0 1017.97 1056.47"><title>AICodeMirror</title><path fill="#E4906E" fill-rule="nonzero" d="M944.92 1014.53c-17.29,-9.23 -33.98,-19.28 -50.08,-30.16 -5.39,-3.65 -14.99,-11.7 -28.81,-24.16 -6.06,-5.47 -14.07,-13.51 -24.03,-24.13 -15.15,-16.17 -29.61,-29.9 -41.69,-40.4 -3.98,-3.46 -14.2,-11.02 -30.68,-22.69 -6.24,-4.42 -12.88,-12.15 -18.63,-19.09 -23.98,-29.04 -49.53,-58.44 -76.66,-88.19 -11.93,-13.1 -25.64,-26.11 -35.61,-36.5 -28.72,-29.92 -51.92,-51.96 -78.23,-79.66 -11.24,-11.83 -20.52,-21.3 -27.85,-28.41 -0.56,-0.53 -1.3,-0.84 -2.08,-0.84 -0.51,0 -1.02,0.14 -1.47,0.39 -9.76,5.54 -17.53,14.33 -24.91,23.31 -10.71,13.01 -21.86,26.65 -33.44,40.91 -4.37,5.38 -7.9,9.46 -10.56,12.24 -5.43,5.67 -9.83,10.88 -15.08,15.39 -9.57,8.23 -19.57,16.01 -29.98,23.31 -14.73,10.32 -29.07,20.29 -43.04,29.9 -5.22,3.61 -13.68,9.81 -20.14,15.41 -25.71,22.32 -53.59,46.12 -83.65,71.41 -9.46,7.95 -19.65,16.88 -34.02,29.22 -25.66,22.03 -52.94,40.06 -81.67,55.65 -7.71,4.19 -14.15,8.2 -19.32,12.01 -19.3,14.23 -33.84,25.65 -43.62,34.28 -25.99,22.91 -43.04,37.82 -51.16,44.73 -9.19,7.8 -18.6,17.05 -28.42,25.54 -2.71,2.35 -5.7,3.03 -8.96,2.01 -0.78,-0.24 -1.25,-1.02 -1.1,-1.82 0.36,-2 1.19,-4.1 2.47,-6.32 6.86,-11.81 14.46,-23.09 19.95,-36.03 3.48,-8.23 7.87,-16.52 13.18,-24.89 2.03,-3.19 4.73,-8.77 8.11,-16.74 2.98,-7.02 7.34,-15.05 13.07,-24.12 5.79,-9.14 16,-23.36 30.63,-42.67 7.66,-10.11 19.49,-23.6 35.49,-40.47 4.9,-5.16 12.21,-11.87 21.92,-20.14 12.12,-10.31 23.53,-21.19 34.23,-32.65 11.73,-12.54 16.99,-22.33 27.39,-40.8 2.37,-4.19 6.49,-9.43 12.37,-15.71 8.27,-8.82 17,-17.23 26.21,-25.23 30.11,-26.18 55.17,-47.43 75.17,-63.76 8.66,-7.08 26.42,-21.39 39.65,-30.77 17.11,-12.13 28.62,-20.44 34.53,-24.91 4.5,-3.4 8.93,-6.6 13.3,-10.56 26.03,-23.54 51.66,-45.71 77.28,-70.7 0.42,-0.41 0.51,-1.06 0.21,-1.58 -6.8,-11.78 -12.84,-21.8 -18.11,-30.06 -10.22,-15.99 -22.07,-29.65 -35.57,-40.99 -7.56,-6.36 -18.41,-13.85 -28.65,-20.43 -15.08,-9.66 -30.62,-21.97 -46.63,-36.9 -35.08,-32.73 -67.65,-71.22 -85.32,-115.42 -5.53,-13.85 -10.8,-29.31 -15.8,-46.37 -5.89,-20.13 -12.37,-35.63 -23.22,-51.27 -8.93,-12.9 -15.77,-21.94 -19.58,-35.93 -1.27,-4.67 -2.93,-12.75 -4.99,-24.23 -2.07,-11.54 -6.54,-22.62 -13.41,-33.25 -7.54,-11.68 -13.66,-21.04 -18.33,-28.08 -3.68,-5.53 -7.02,-12.39 -9.63,-18.53 -3.9,-9.18 -8.14,-15.7 -13.6,-23.37 -3.94,-5.53 -5.07,-12.75 0,-18.32 4.14,-4.57 17.49,-3.02 21.56,-1.13 3.86,1.81 8.1,5.13 12.71,9.94 16.16,16.88 26.41,27.77 30.74,32.66 4.69,5.31 11.21,13.79 16.69,19.94 20.19,22.63 36.17,39.74 47.36,59.71 10.46,18.66 16.41,30.42 29.84,44.67 9.32,9.92 17.94,19.33 25.85,28.23 9.01,10.15 19.25,22.95 30.72,38.39 7.54,10.17 13.89,20.11 19.05,29.84 6.39,12.05 10.8,30.19 15.13,41.41 4.88,12.67 12.52,23.25 22.92,31.75 0.58,0.47 6.79,5.44 18.62,14.89 13.54,10.82 23.74,23.47 30.61,37.96 4.55,9.58 7.82,16.16 9.8,19.74 6.62,11.85 14.64,22.05 24.07,30.59 8.99,8.14 17.47,13.2 31.06,22.64 4.28,2.96 6.68,5.98 10.65,2.54 7.08,-6.11 13.73,-10.71 17.96,-14.53 6.12,-5.54 11.71,-11.84 16.79,-18.92 3.5,-4.88 8.77,-10.16 15.19,-14.42 22.77,-15.02 38.17,-31.11 63.32,-55.15 22.13,-21.17 46.22,-47.56 69.25,-66.8 32.17,-26.89 54.99,-45.9 68.46,-57.01 17.15,-14.15 35.82,-30.97 56.02,-50.46 16.06,-15.5 29.25,-27.72 39.57,-36.66 9.78,-8.47 17.55,-14.8 23.31,-18.98 8.52,-6.2 18.55,-10.61 30.56,-15.03 12.34,-4.55 23.44,-11.06 35.67,-17.61 9.07,-4.85 19.76,-9.89 30.05,-8.84 0.5,0.06 0.97,0.32 1.3,0.72 0.85,1.07 1.01,2.48 0.48,4.23 -2.1,6.99 -5.15,13.55 -9.66,20.87 -6.42,10.42 -11.51,19.46 -15.29,27.11 -6.09,12.35 -9.66,19.49 -10.69,21.43 -7.78,14.65 -17.56,27.97 -29.34,39.97 -4.8,4.89 -12.93,12.92 -24.37,24.07 -14.23,13.87 -25.02,30.77 -37.12,50.78 -15.21,25.13 -29.56,48.47 -43.06,70.01 -5.21,8.29 -13.68,15.13 -21.58,21.47 -25.71,20.7 -46.75,41.48 -70.98,64.67 -1.97,1.88 -4.98,4.47 -9.03,7.76 -22.62,18.36 -45.88,35.93 -69.78,52.74 -5.96,4.2 -13.77,11.24 -23.42,21.11 -17.12,17.5 -25.93,26.56 -26.42,27.19 -1.22,1.54 -1.08,3.09 0.41,4.67 14.11,14.81 34.25,37.65 60.42,68.51 11.89,14.01 24.87,27.08 36.03,39.46 8.75,9.7 16.81,22.11 31.82,42.59 2.69,3.68 13.53,16.07 32.5,37.17 5.17,5.76 11.64,14.47 19.4,26.12 16.37,24.6 36.28,56.2 59.73,94.79 4.2,6.92 7.74,12.33 10.62,16.21 5.41,7.29 10.37,13.74 14.92,20.97 6.26,9.94 11.3,19.92 15.11,29.92 4.29,11.27 7.73,19.49 10.32,24.69 7.21,14.5 14.81,28.41 22.8,41.73 3.44,5.75 6.78,13.03 6.11,20.05 -0.07,0.76 -0.71,1.34 -1.48,1.34 -0.25,0 -0.49,-0.06 -0.71,-0.18l0 0.01z"/></svg>`,
|
||||
@@ -112,6 +113,7 @@ export const iconUrls: Record<string, string> = {
|
||||
runapi: _runapi,
|
||||
shengsuanyun: _shengsuanyun,
|
||||
sudocode: _sudocode,
|
||||
unity2: _unity2,
|
||||
};
|
||||
|
||||
export const iconList = [
|
||||
|
||||
@@ -439,6 +439,13 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
keywords: ["hunyuan"],
|
||||
defaultColor: "#00A4FF",
|
||||
},
|
||||
unity2: {
|
||||
name: "unity2",
|
||||
displayName: "Unity2.ai",
|
||||
category: "ai-provider",
|
||||
keywords: ["unity2", "aggregator", "relay", "claude", "codex", "gateway"],
|
||||
defaultColor: "#000000",
|
||||
},
|
||||
vercel: {
|
||||
name: "vercel",
|
||||
displayName: "vercel",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -18,12 +18,14 @@ export async function fetchModelsForConfig(
|
||||
apiKey: string,
|
||||
isFullUrl?: boolean,
|
||||
modelsUrl?: string,
|
||||
customUserAgent?: string,
|
||||
): Promise<FetchedModel[]> {
|
||||
return invoke("fetch_models_for_config", {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
isFullUrl,
|
||||
modelsUrl,
|
||||
customUserAgent,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 自定义 User-Agent 合法性校验。
|
||||
*
|
||||
* 与后端 `parse_custom_user_agent`(基于 `http::HeaderValue::from_str`)口径严格一致:
|
||||
* HeaderValue 按**字节**判定合法性,规则为 `b >= 32 && b != 127 || b == '\t'`。也就是说:
|
||||
* - 制表符(\t)、可见 ASCII(0x20–0x7E)、以及任意非 ASCII 字符(UTF-8 字节均 ≥ 0x80)都合法;
|
||||
* - 仅控制字符非法:除 \t 外的 0x00–0x1F(含换行)与 0x7F(DEL)。
|
||||
*
|
||||
* 空串(trim 后为空)视为"未设置",合法。
|
||||
*/
|
||||
export function isValidUserAgentHeader(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return true;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return !/[\x00-\x08\x0a-\x1f\x7f]/.test(trimmed);
|
||||
}
|
||||
@@ -219,6 +219,8 @@ export interface ProviderMeta {
|
||||
codexFastMode?: boolean;
|
||||
// Codex Responses -> Chat Completions reasoning capability metadata
|
||||
codexChatReasoning?: CodexChatReasoning;
|
||||
// Custom User-Agent for local proxy routing. Only applied by the local proxy.
|
||||
customUserAgent?: string;
|
||||
// 供应商类型(用于识别 Copilot 等特殊供应商)
|
||||
providerType?: string;
|
||||
// GitHub Copilot 关联账号 ID(旧字段,保留兼容读取)
|
||||
|
||||
+16
-5
@@ -14,6 +14,8 @@ export interface RequestLog {
|
||||
appType: string;
|
||||
model: string;
|
||||
requestModel?: string;
|
||||
/** 写入时实际用于计价的模型名;路由接管 + request 计价模式下可能与 model 不同 */
|
||||
pricingModel?: string;
|
||||
costMultiplier: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
@@ -141,17 +143,26 @@ export interface UsageRangeSelection {
|
||||
/**
|
||||
* App types whose token usage is reliably collected by the proxy.
|
||||
*
|
||||
* The proxy has handlers for `claude-desktop` too, but in practice those
|
||||
* requests overwhelmingly fail (500/503) and contribute near-zero tokens, so
|
||||
* it is hidden from the Dashboard. `opencode` / `openclaw` / `hermes` have
|
||||
* no proxy handler at all — they appear only as managed apps elsewhere.
|
||||
* `claude-desktop` was previously hidden because its rows looked like pure
|
||||
* failure noise — that was an accounting bug: streaming/transform usage of
|
||||
* the Desktop gateway was logged under app_type "claude", leaving only
|
||||
* edge-case rows under "claude-desktop". The backend now attributes all
|
||||
* Desktop traffic to "claude-desktop", so it is a first-class filter option.
|
||||
* `opencode` / `openclaw` / `hermes` have no proxy handler at all — they
|
||||
* appear only as managed apps elsewhere.
|
||||
*/
|
||||
export type AppType = "claude" | "codex" | "gemini" | "opencode";
|
||||
export type AppType =
|
||||
| "claude"
|
||||
| "claude-desktop"
|
||||
| "codex"
|
||||
| "gemini"
|
||||
| "opencode";
|
||||
|
||||
export type AppTypeFilter = "all" | AppType;
|
||||
|
||||
export const KNOWN_APP_TYPES: ReadonlyArray<AppType> = [
|
||||
"claude",
|
||||
"claude-desktop",
|
||||
"codex",
|
||||
"gemini",
|
||||
"opencode",
|
||||
|
||||
@@ -91,6 +91,8 @@ const renderCopilotForm = (overrides: Partial<ClaudeFormFieldsProps> = {}) => {
|
||||
onApiKeyFieldChange: vi.fn(),
|
||||
isFullUrl: false,
|
||||
onFullUrlChange: vi.fn(),
|
||||
customUserAgent: "",
|
||||
onCustomUserAgentChange: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,71 +1,323 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { ProviderPresetSelector } from "@/components/providers/forms/ProviderPresetSelector";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
import {
|
||||
ProviderPresetSelector,
|
||||
filterPresetEntries,
|
||||
getPresetDisplayName,
|
||||
getPresetSearchText,
|
||||
getVisiblePresetEntries,
|
||||
sortPresetEntries,
|
||||
type PresetSortMode,
|
||||
} from "@/components/providers/forms/ProviderPresetSelector";
|
||||
|
||||
describe("ProviderPresetSelector", () => {
|
||||
it("按传入的预设数组顺序渲染,不按分类重新排序", () => {
|
||||
const Wrapper = () => {
|
||||
const form = useForm();
|
||||
const presetCategoryLabels = {
|
||||
official: "官方",
|
||||
cn_official: "国产官方",
|
||||
aggregator: "聚合服务",
|
||||
third_party: "第三方",
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<ProviderPresetSelector
|
||||
selectedPresetId="custom"
|
||||
presetEntries={[
|
||||
{
|
||||
id: "preset-0",
|
||||
preset: {
|
||||
name: "First",
|
||||
websiteUrl: "https://first.example.com",
|
||||
settingsConfig: {},
|
||||
category: "third_party",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "preset-1",
|
||||
preset: {
|
||||
name: "Second",
|
||||
websiteUrl: "https://second.example.com",
|
||||
settingsConfig: {},
|
||||
category: "official",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "preset-2",
|
||||
preset: {
|
||||
name: "Third",
|
||||
websiteUrl: "https://third.example.com",
|
||||
settingsConfig: {},
|
||||
category: "aggregator",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "preset-3",
|
||||
preset: {
|
||||
name: "Fourth",
|
||||
websiteUrl: "https://fourth.example.com",
|
||||
settingsConfig: {},
|
||||
category: "official",
|
||||
},
|
||||
},
|
||||
]}
|
||||
presetCategoryLabels={{
|
||||
official: "官方",
|
||||
aggregator: "聚合服务",
|
||||
third_party: "第三方",
|
||||
}}
|
||||
onPresetChange={vi.fn()}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
const translations: Record<string, string> = {
|
||||
"preset.alpha": "Alpha 本地名",
|
||||
"preset.gamma": "Gamma 本地名",
|
||||
};
|
||||
|
||||
render(<Wrapper />);
|
||||
const t = ((key: string) => translations[key] ?? key) as TFunction;
|
||||
|
||||
type TestPresetEntry = {
|
||||
id: string;
|
||||
preset: {
|
||||
name: string;
|
||||
nameKey?: string;
|
||||
websiteUrl: string;
|
||||
settingsConfig: Record<string, never>;
|
||||
category: ProviderCategory;
|
||||
};
|
||||
};
|
||||
|
||||
const presetEntries: TestPresetEntry[] = [
|
||||
{
|
||||
id: "gamma",
|
||||
preset: {
|
||||
name: "Gamma Raw",
|
||||
nameKey: "preset.gamma",
|
||||
websiteUrl: "https://gamma.example.com",
|
||||
settingsConfig: {},
|
||||
category: "aggregator",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "alpha",
|
||||
preset: {
|
||||
name: "Alpha Raw",
|
||||
nameKey: "preset.alpha",
|
||||
websiteUrl: "https://alpha.example.com/v1",
|
||||
settingsConfig: {},
|
||||
category: "official",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "beta",
|
||||
preset: {
|
||||
name: "Beta Gateway",
|
||||
websiteUrl: "https://CN-Gateway.example.com",
|
||||
settingsConfig: {},
|
||||
category: "cn_official",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "delta",
|
||||
preset: {
|
||||
name: "Delta Mirror",
|
||||
websiteUrl: "https://delta.example.com",
|
||||
settingsConfig: {},
|
||||
category: "third_party",
|
||||
},
|
||||
},
|
||||
] satisfies TestPresetEntry[];
|
||||
|
||||
function getIds(entries: ReadonlyArray<{ id: string }>) {
|
||||
return entries.map((entry) => entry.id);
|
||||
}
|
||||
|
||||
function renderSelector({
|
||||
entries = presetEntries,
|
||||
onPresetChange = vi.fn(),
|
||||
}: {
|
||||
entries?: TestPresetEntry[];
|
||||
onPresetChange?: (value: string) => void;
|
||||
} = {}) {
|
||||
const Wrapper = () => {
|
||||
const form = useForm();
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<ProviderPresetSelector
|
||||
selectedPresetId="custom"
|
||||
presetEntries={entries}
|
||||
presetCategoryLabels={presetCategoryLabels}
|
||||
onPresetChange={onPresetChange}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
return render(<Wrapper />);
|
||||
}
|
||||
|
||||
function getPresetButtonTexts() {
|
||||
const knownNames = new Set([
|
||||
"providerPreset.custom",
|
||||
...presetEntries.flatMap((entry) => [
|
||||
entry.preset.name,
|
||||
entry.preset.nameKey ?? entry.preset.name,
|
||||
]),
|
||||
]);
|
||||
|
||||
return screen
|
||||
.getAllByRole("button")
|
||||
.map((button) => button.textContent?.trim() ?? "")
|
||||
.filter((text) => knownNames.has(text));
|
||||
}
|
||||
|
||||
function getSearchButton() {
|
||||
return screen.getByRole("button", {
|
||||
name: /providerPreset\.(search|searchAriaLabel|openSearch)|搜索|search/i,
|
||||
});
|
||||
}
|
||||
|
||||
function getSortButton() {
|
||||
return screen.getByRole("button", {
|
||||
name: /providerPreset\.(sort|sortByName|restoreOriginalOrder)|按名称排序|恢复原顺序|sort/i,
|
||||
});
|
||||
}
|
||||
|
||||
function getSearchInput() {
|
||||
return screen.getByRole("textbox", {
|
||||
name: /providerPreset\.(searchInput|searchPlaceholder)|搜索预设|search/i,
|
||||
});
|
||||
}
|
||||
|
||||
describe("ProviderPresetSelector pure helpers", () => {
|
||||
it("优先使用 nameKey 翻译作为显示名,否则使用原始 name", () => {
|
||||
expect(getPresetDisplayName(presetEntries[1].preset, t)).toBe(
|
||||
"Alpha 本地名",
|
||||
);
|
||||
expect(getPresetDisplayName(presetEntries[2].preset, t)).toBe(
|
||||
"Beta Gateway",
|
||||
);
|
||||
});
|
||||
|
||||
it("拼接显示名、原始名称、URL、分类 label,并统一 lower-case", () => {
|
||||
const searchText = getPresetSearchText(
|
||||
presetEntries[1],
|
||||
presetCategoryLabels,
|
||||
t,
|
||||
);
|
||||
|
||||
expect(searchText).toContain("alpha 本地名");
|
||||
expect(searchText).toContain("alpha raw");
|
||||
expect(searchText).toContain("https://alpha.example.com/v1");
|
||||
expect(searchText).toContain("官方");
|
||||
expect(searchText).toBe(searchText.toLowerCase());
|
||||
});
|
||||
|
||||
it("空 query 返回原数组,非空 query 大小写不敏感匹配", () => {
|
||||
expect(
|
||||
filterPresetEntries(presetEntries, " ", presetCategoryLabels, t),
|
||||
).toBe(presetEntries);
|
||||
expect(
|
||||
getIds(
|
||||
filterPresetEntries(
|
||||
presetEntries,
|
||||
"ALPHA 本地名",
|
||||
presetCategoryLabels,
|
||||
t,
|
||||
),
|
||||
),
|
||||
).toEqual(["alpha"]);
|
||||
});
|
||||
|
||||
it("支持通过 URL 和分类 label 搜索", () => {
|
||||
expect(
|
||||
getIds(
|
||||
filterPresetEntries(
|
||||
presetEntries,
|
||||
"cn-gateway.example.com",
|
||||
presetCategoryLabels,
|
||||
t,
|
||||
),
|
||||
),
|
||||
).toEqual(["beta"]);
|
||||
expect(
|
||||
getIds(
|
||||
filterPresetEntries(presetEntries, "聚合", presetCategoryLabels, t),
|
||||
),
|
||||
).toEqual(["gamma"]);
|
||||
});
|
||||
|
||||
it("支持 A-Z 排序、original 副本恢复原顺序,并且 getVisible 先 filter 再 sort", () => {
|
||||
const originalMode: PresetSortMode = "original";
|
||||
const nameAscMode: PresetSortMode = "nameAsc";
|
||||
|
||||
const original = sortPresetEntries(presetEntries, originalMode, t);
|
||||
expect(original).not.toBe(presetEntries);
|
||||
expect(getIds(original)).toEqual(["gamma", "alpha", "beta", "delta"]);
|
||||
|
||||
expect(getIds(sortPresetEntries(presetEntries, nameAscMode, t))).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
"delta",
|
||||
"gamma",
|
||||
]);
|
||||
expect(getIds(presetEntries)).toEqual(["gamma", "alpha", "beta", "delta"]);
|
||||
|
||||
expect(
|
||||
screen.getAllByRole("button").map((button) => button.textContent),
|
||||
).toEqual(["providerPreset.custom", "First", "Second", "Third", "Fourth"]);
|
||||
getIds(
|
||||
getVisiblePresetEntries(presetEntries, {
|
||||
query: "a",
|
||||
sortMode: nameAscMode,
|
||||
presetCategoryLabels,
|
||||
t,
|
||||
}),
|
||||
),
|
||||
).toEqual(["alpha", "beta", "delta", "gamma"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderPresetSelector", () => {
|
||||
it("默认按传入的预设数组顺序渲染,不按分类或名称重新排序", () => {
|
||||
renderSelector();
|
||||
|
||||
expect(getPresetButtonTexts()).toEqual([
|
||||
"providerPreset.custom",
|
||||
"preset.gamma",
|
||||
"preset.alpha",
|
||||
"Beta Gateway",
|
||||
"Delta Mirror",
|
||||
]);
|
||||
});
|
||||
|
||||
it("点击排序按钮后普通 preset A-Z,再点恢复原顺序", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSelector();
|
||||
|
||||
await user.click(getSortButton());
|
||||
|
||||
expect(getPresetButtonTexts()).toEqual([
|
||||
"providerPreset.custom",
|
||||
"Beta Gateway",
|
||||
"Delta Mirror",
|
||||
"preset.alpha",
|
||||
"preset.gamma",
|
||||
]);
|
||||
|
||||
await user.click(getSortButton());
|
||||
|
||||
expect(getPresetButtonTexts()).toEqual([
|
||||
"providerPreset.custom",
|
||||
"preset.gamma",
|
||||
"preset.alpha",
|
||||
"Beta Gateway",
|
||||
"Delta Mirror",
|
||||
]);
|
||||
});
|
||||
|
||||
it("搜索只过滤普通 preset,自定义配置始终保留", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSelector();
|
||||
|
||||
await user.click(getSearchButton());
|
||||
await user.type(getSearchInput(), "gateway");
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "providerPreset.custom" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Beta Gateway" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "preset.gamma" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "preset.alpha" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Delta Mirror" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("搜索无普通 preset 结果时保留自定义配置并显示空状态", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSelector();
|
||||
|
||||
await user.click(getSearchButton());
|
||||
await user.type(getSearchInput(), "not-found");
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "providerPreset.custom" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "preset.gamma" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "preset.alpha" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Beta Gateway" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Delta Mirror" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/providerPreset\.(empty|noResults)|没有匹配|无结果|no matching presets/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isValidUserAgentHeader } from "@/lib/userAgent";
|
||||
|
||||
// 与后端 parse_custom_user_agent / http::HeaderValue::from_str 的字节级规则对齐:
|
||||
// 合法 = b>=32 && b!=127 || b=='\t'。即仅控制字符(除 \t 外的 0x00–0x1F 与 0x7F)非法,
|
||||
// 可见 ASCII 与非 ASCII 都合法。控制字符用 String.fromCharCode 构造,避免源码内嵌生字节。
|
||||
const NUL = String.fromCharCode(0);
|
||||
const DEL = String.fromCharCode(0x7f);
|
||||
|
||||
describe("isValidUserAgentHeader", () => {
|
||||
it("treats empty / whitespace-only as valid (unset)", () => {
|
||||
expect(isValidUserAgentHeader("")).toBe(true);
|
||||
expect(isValidUserAgentHeader(" ")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts visible ASCII (trimmed)", () => {
|
||||
expect(isValidUserAgentHeader("claude-cli/2.1.161")).toBe(true);
|
||||
expect(isValidUserAgentHeader(" claude-cli/2.1.161 ")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts non-ASCII — matches backend HeaderValue byte rule", () => {
|
||||
expect(isValidUserAgentHeader("claude-cli/1.0 中文")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts internal tab", () => {
|
||||
expect(isValidUserAgentHeader("claude\tcli")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects control characters (newline / null / DEL)", () => {
|
||||
expect(isValidUserAgentHeader("claude\ncli")).toBe(false);
|
||||
expect(isValidUserAgentHeader(`claude${NUL}cli`)).toBe(false);
|
||||
expect(isValidUserAgentHeader(`claude${DEL}cli`)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user