Compare commits

...

9 Commits

Author SHA1 Message Date
SaladDay 492245dcb9 feat(codex-oauth): show per-account usage in Auth Center (#4887)
* feat(codex-oauth): show per-account usage in Auth Center

Each ChatGPT (Codex OAuth) account under Settings → 认证 now displays its
own subscription usage — reset countdowns and per-window progress bars —
directly in the account list, instead of usage only being visible on the
active provider card.

- Add useCodexOauthQuotaByAccountId(accountId) and refactor
  useCodexOauthQuota to delegate to it (shared query key → cache reuse)
- Add CodexOauthAccountQuota, a thin per-account wrapper that reuses the
  existing SubscriptionQuotaView expanded layout (same look and 5-state
  handling as provider cards), with a light spinner on first load
- Render it under each account row in CodexOAuthSection; fetch once when
  the Auth Center opens, manual refresh available (no polling)

Copilot is intentionally left out — same as before, this is Codex-only.

* refactor(codex-oauth): stable async loading placeholder for account usage

The account header (login + badges + actions) already renders independently
of the usage query — the quota is fetched async via Tauri invoke + React
Query, so the account never waits on it. Make that visually obvious and
jump-free: while the usage loads, show a spinner inside a placeholder shaped
like the final quota card (same rounded-xl / border / bg-card), so the card
morphs smoothly into the data instead of popping in from an empty gap.

* fix(codex-oauth): scope account quota to auth center

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-08-03 21:47:49 +08:00
Jason 0e604b75bd feat(sponsors): surface Kimi first top-up bonus in README and presets
Kimi is running an API top-up promotion (distinct from the Kimi Code
subscription): new users who complete their first successful top-up get 10%
of the amount back as API credit, capped at CNY 1,000.

In the four READMEs, add a dedicated bonus paragraph between the K3 intro and
the Kimi Code subscription line, so the API and subscription paths each keep
their own call to action. All platform links in the Kimi header block now
carry the campaign track_id: README_ZH uses the mainland platform.kimi.com
tracker, while the en/ja/de READMEs use the global platform.kimi.ai one. The
kimi.com/code subscription links are unchanged.

In the app, give the six Kimi presets (claude, claudeDesktop, codex, opencode,
openclaw, hermes) a partnerPromotionKey so the offer shows under the API key
link, and add the matching string in the zh/en/ja/zh-TW locales. The Kimi For
Coding presets are deliberately left alone — the promotion does not apply to
the subscription. Promotion display is decoupled from isPartner, so this adds
no gold partner star to Kimi.

The English and Japanese strings say "CNY ¥1,000" rather than "¥1,000", since
a bare yen sign reads as JPY in those locales and would understate the offer
by roughly 20x.
2026-08-03 21:34:28 +08:00
Jason 4d3e2c3524 chore(sponsors): update RunAPI in-app promo to first top-up discount
The in-app partnerPromotion.runapi string still advertised the retired
"register and contact support for CNY 14 free credit" offer, while all four
READMEs had already moved to the first-top-up discount. Align the zh, zh-TW,
en and ja strings with the README wording: 9 折 in Chinese, 10% off in
English and Japanese.
2026-08-03 21:34:28 +08:00
Jason 996d512f6e chore(sponsors): drop NekoCode partner from README, presets and i18n
Remove the NekoCode sponsor row from the four README files (en, zh, ja, de)
and the provider preset across all seven preset files that carried it
(claude, claudeDesktop, codex, opencode, openclaw, hermes, grokBuild; there
was never a gemini preset). Also drop the matching partnerPromotion.nekocode
string in the zh/en/ja/zh-TW locales.

The nekocode icon stays registered in src/icons/extracted, since icon names
are persisted on existing provider records and removing it would blank out
the icon for users who already imported the preset. Historical CHANGELOG and
release-notes entries are left untouched.
2026-08-03 21:34:28 +08:00
Jason 9db9c56fda fix(proxy): report dropped Chat tool calls instead of faking completion
When a third-party gateway returns tool calls without a function name,
the Chat -> Responses transform silently discarded them and still marked
the turn as `completed`. Codex then saw a successful turn with nothing
left to do and ended its agent loop without any error, turning a
diagnosable upstream failure into a silent stall.

- Emit `response.failed` (streaming) or a transform error (non-streaming)
  when every tool call in a turn was dropped and none remains usable.
  Gated on `status == "completed"` so that `finish_reason: length`
  truncation keeps its own `incomplete` semantics, matching the existing
  Anthropic streaming path.
- Log all three drop sites with structured, content-free fields (call_id
  presence, argument byte counts, finish reason) so the upstream defect
  can finally be diagnosed from real traffic.
- Treat whitespace-only function names as missing, and resolve tool keys
  conservatively when upstream omits the required `index` field.

Turns that still contain a valid tool call, text-only turns and truncated
turns are unaffected. Adds 13 tests.

Refs #4341
2026-08-03 21:34:28 +08:00
makoMakoGo eb356e15bd fix(skills): resolve source dir by SKILL.md anchor instead of name (#4153)
* fix(skills): resolve source dir by SKILL.md anchor instead of name

resolve_skill_source_dir previously guessed the source dir via root.join(name).is_dir() without verifying SKILL.md, misjudging same-name non-skill dirs (e.g. the ast-grep plugin wrapper dir in ast-grep/agent-skill) and causing install failure #4141.

Now anchors on SKILL.md: direct + SKILL.md check -> root manifest explicit skills[] -> fallback by name -> root fallback. Adds 5 layout tests.

Closes #4141

* fix(skills): drop speculative manifest resolver path

resolve_via_manifest (parsing root .claude-plugin/marketplace.json &
plugin.json explicit skills[]) is inert for the actual #4141 case: the
real ast-grep/agent-skill marketplace.json declares no skills[] array,
so the manifest branch never produces a candidate. The #4141 fix is
delivered entirely by resolve_skill_source_dir step 1's SKILL.md anchor
plus the pre-existing find_skill_dir_by_name DFS.

Keeping the manifest path would pull npx-skills package-parity semantics
(pluginRoot / source / remote-object source / skills[] / "./"-validation
/ ...) into a bug hotfix, with no real manifest proving it is not dead
code. Drop it to keep this PR a focused #4141 hotfix.

- remove SkillMarketplaceMetadata / SkillManifestPlugin /
  SkillMarketplaceManifest, resolve_via_manifest, sanitize_manifest_path
- narrow resolve_skill_source_dir to 3 steps
  (direct+SKILL.md -> by-name DFS+SKILL.md -> root+SKILL.md -> None)
- replace the two synthetic manifest tests with a negative case:
  same-name wrapper dir without SKILL.md and no inner skill -> None

cargo test --lib resolve_skill_source_dir: 7 passed
cargo clippy --lib: clean
2026-08-03 19:05:51 +08:00
mhy1227 f38722a440 feat(pricing): seed Qwen3.8 Max built-in model pricing (#6053)
* feat(pricing): seed Qwen3.8 Max built-in model pricing

Add insert-if-absent row for qwen3.8-max at 2/6 USD per Mtok input/output with 0.20 cache read.

* fix(pricing): set qwen3.8-max cache write to 2.50

Align cache_write with official explicit context-cache rate (125 percent of input). cache_read stays 0.20 (10 percent hit).

* fix(pricing): correct qwen3.8-max cache read price

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-08-03 17:57:24 +08:00
Xu Lei 13ea497ab0 fix(proxy): improve GitHub Copilot compatibility with modern Claude Code (#5832)
* 修复 Copilot 与新版 Claude Code 的兼容问题

* docs(proxy): correct Copilot placeholder rationale to the real mechanism

Claude Code (verified on 2.1.220) does not format-validate ANTHROPIC_API_KEY
against sk-ant-*: in headless mode the placeholder is sent upstream as-is.
The actual failure mode is the interactive custom-API-key approval prompt,
which defaults to "No (recommended)" — following the default ignores the
key and lands users in "Not logged in". Also drop the #3289 citation,
which describes a missing-placeholder scenario, not key validation.

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-08-03 10:26:22 +08:00
mao qinghui 8383076791 fix(hermes): use SOUL.md instead of AGENTS.md for Hermes prompt filename (#5779)
* fix(hermes): use SOUL.md instead of AGENTS.md for Hermes prompt filename

* test(hermes): add regression test for SOUL.md prompt filename

---------

Co-authored-by: mmm-05610 <maoqh@users.noreply.github.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-08-02 22:02:06 +08:00
29 changed files with 864 additions and 259 deletions
+4 -7
View File
@@ -25,9 +25,11 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Deutsch](README_
<details open>
<summary>Click to collapse</summary>
[![Kimi K2.7 Code](https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png)](https://platform.kimi.ai?aff=cc-switch)
[![Kimi K2.7 Code](https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png)](https://platform.kimi.ai?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch)
Kimi K3 is Moonshot AI's most capable model and the world's first open 3T-class model. With 2.8 trillion parameters, native vision, and a 1-million-token context window, K3 delivers frontier performance across long-horizon coding, knowledge work, and reasoning. CC Switch makes it easy to configure and switch to Kimi across agentic tools. **[Click here to start using Kimi](https://platform.kimi.ai?aff=cc-switch)**
Kimi K3 is Moonshot AI's most capable model and the world's first open 3T-class model. With 2.8 trillion parameters, native vision, and a 1-million-token context window, K3 delivers frontier performance across long-horizon coding, knowledge work, and reasoning. CC Switch makes it easy to configure and switch to Kimi across agentic tools. **[Click here to start using Kimi](https://platform.kimi.ai?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch)**
**New user top-up bonus**: register via [this link](https://platform.kimi.ai?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch) and complete your first top-up to receive 10% of the amount as bonus API credit, up to CNY ¥1,000.
Doing mostly coding work? Try the **[Kimi Code subscription](https://www.kimi.com/code/?aff=cc-switch)**.
@@ -134,11 +136,6 @@ TeamoRouter also offers enterprise features including centralized billing, team
<td>Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via <a href="https://cloud.siliconflow.cn/i/YflgU2Ve">this link</a> and complete real-name verification to receive ¥16 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free.</td>
</tr>
<tr>
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
<td>Thanks to <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> for sponsoring this project! NekoCode provides developers with a stable, efficient, and reliable API relay service for Claude, Codex, and other AI models. With transparent pricing and flexible pay-as-you-go billing, it offers a simple and cost-effective way to access AI models. CC Switch users can enjoy an exclusive 10% discount: register via <a href="https://nekocode.ai?aff=CCSWITCH">this link</a> and enter promo code <code>cc-switch</code> during recharge to receive 10% off your top-up!</td>
</tr>
<tr>
<td width="180"><a href="https://a6api.com/register?aff=AqNr"><img src="assets/partners/logos/a6-banner-en.jpg" alt="A6API" width="150"></a></td>
<td>Thanks to <a href="https://a6api.com/register?aff=AqNr">A6API</a> for sponsoring this project! A6API is a one-stop AI model API aggregation platform covering Claude, GPT, Gemini, Codex, and other mainstream models. Multiple vendors can list their supply on the platform, so the same model can be quoted competitively by several upstream providers. Smart routing automatically picks the more stable, lower-priced route available and fails over automatically, helping you reduce failed requests, cut costs, and improve stability. Whether you are an individual developer, an AI product team, or a studio, you can integrate quickly through a unified interface — compatible with all formats, with low migration cost. New users who register via <a href="https://a6api.com/register?aff=AqNr">this link</a> receive free trial credits: try it first, then use it at a low price.</td>
+4 -7
View File
@@ -25,9 +25,11 @@
<details open>
<summary>Zum Einklappen klicken</summary>
[![Kimi K2.7 Code](https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png)](https://platform.kimi.ai?aff=cc-switch)
[![Kimi K2.7 Code](https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png)](https://platform.kimi.ai?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch)
Kimi K3 ist das bislang leistungsstärkste Modell von Moonshot AI und das weltweit erste offene Modell der 3T-Klasse. Mit 2,8 Billionen Parametern, nativen visuellen Fähigkeiten und einem Kontextfenster von 1 Million Token liefert K3 Spitzenleistung bei langfristigen Programmieraufgaben, Wissensarbeit und Reasoning. Mit CC Switch lässt sich Kimi in den verschiedensten Agenten-Tools bequem konfigurieren und umschalten. **[Hier klicken, um Kimi zu nutzen](https://platform.kimi.ai?aff=cc-switch)**
Kimi K3 ist das bislang leistungsstärkste Modell von Moonshot AI und das weltweit erste offene Modell der 3T-Klasse. Mit 2,8 Billionen Parametern, nativen visuellen Fähigkeiten und einem Kontextfenster von 1 Million Token liefert K3 Spitzenleistung bei langfristigen Programmieraufgaben, Wissensarbeit und Reasoning. Mit CC Switch lässt sich Kimi in den verschiedensten Agenten-Tools bequem konfigurieren und umschalten. **[Hier klicken, um Kimi zu nutzen](https://platform.kimi.ai?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch)**
**Bonus für die erste Aufladung neuer Nutzer**: Registrieren Sie sich über [diesen Link](https://platform.kimi.ai?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch) und schließen Sie Ihre erste Aufladung ab, um 10 % des Betrags als Bonus-API-Guthaben zu erhalten bis zu CNY ¥1.000.
Hauptsächlich mit Programmierung beschäftigt? Probieren Sie das **[Kimi-Code-Abo](https://www.kimi.com/code/?aff=cc-switch)** aus!
@@ -134,11 +136,6 @@ TeamoRouter bietet außerdem Enterprise-Funktionen wie zentrale Abrechnung, Team
<td>Danke an SiliconFlow für die Unterstützung dieses Projekts! SiliconFlow ist eine leistungsstarke KI-Infrastruktur- und Modell-API-Plattform, die schnellen und zuverlässigen Zugriff auf Sprach-, Audio-, Bild- und Videomodelle an einem Ort bietet. Mit nutzungsbasierter Abrechnung, breiter Unterstützung multimodaler Modelle, Hochgeschwindigkeitsinferenz und unternehmensgerechter Stabilität hilft SiliconFlow Entwicklern und Teams, KI-Anwendungen effizienter zu erstellen und zu skalieren. Registrieren Sie sich über <a href="https://cloud.siliconflow.cn/i/YflgU2Ve">diesen Link</a> und schließen Sie die Identitätsverifizierung ab, um ein Bonusguthaben von ¥16 zu erhalten, das für alle Modelle der Plattform nutzbar ist. SiliconFlow ist zudem nun mit OpenClaw kompatibel, sodass Nutzer einen SiliconFlow-API-Schlüssel verbinden und große KI-Modelle kostenlos aufrufen können.</td>
</tr>
<tr>
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
<td>Vielen Dank an <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> für die Unterstützung dieses Projekts! NekoCode bietet Entwicklern einen stabilen, effizienten und zuverlässigen API-Relay-Dienst für Claude, Codex und weitere KI-Modelle. Mit transparenter Preisgestaltung und flexibler nutzungsbasierter Abrechnung bietet es einen einfachen und kostengünstigen Zugang zu KI-Modellen. CC-Switch-Nutzer erhalten einen exklusiven Rabatt von 10 %: Registrieren Sie sich über <a href="https://nekocode.ai?aff=CCSWITCH">diesen Link</a> und geben Sie beim Aufladen den Gutscheincode <code>cc-switch</code> ein, um 10 % Rabatt auf Ihre Aufladung zu erhalten!</td>
</tr>
<tr>
<td width="180"><a href="https://a6api.com/register?aff=AqNr"><img src="assets/partners/logos/a6-banner-en.jpg" alt="A6API" width="150"></a></td>
<td>Vielen Dank an <a href="https://a6api.com/register?aff=AqNr">A6API</a> für die Unterstützung dieses Projekts! A6API ist eine All-in-one-Aggregationsplattform für KI-Modell-APIs und deckt Claude, GPT, Gemini, Codex und weitere gängige Modelle ab. Mehrere Anbieter können ihr Angebot einstellen, sodass dasselbe Modell von verschiedenen Upstream-Anbietern im Preiswettbewerb bereitgestellt wird. Intelligentes Routing wählt automatisch die stabilere und günstigere verfügbare Route und schaltet bei Fehlern automatisch um das reduziert fehlgeschlagene Anfragen, senkt die Kosten und erhöht die Stabilität. Ob einzelne Entwickler, KI-Produktteams oder Studios: Die Anbindung erfolgt schnell über eine einheitliche Schnittstelle, kompatibel mit allen Formaten und mit geringem Migrationsaufwand. Neue Nutzer erhalten bei der Registrierung über <a href="https://a6api.com/register?aff=AqNr">diesen Link</a> kostenloses Testguthaben erst testen, dann günstig loslegen.</td>
+4 -7
View File
@@ -25,9 +25,11 @@
<details open>
<summary>クリックで折りたたむ</summary>
[![Kimi K2.7 Code](https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png)](https://platform.kimi.ai?aff=cc-switch)
[![Kimi K2.7 Code](https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png)](https://platform.kimi.ai?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch)
Kimi K3 は Moonshot AI がこれまでに開発した中で最も高性能なモデルであり、世界初のオープンソース 3T クラスモデルです。2.8 兆パラメータ、ネイティブな視覚能力、100 万トークンのコンテキストウィンドウを備え、長期にわたるコーディング、ナレッジワーク、推論タスクにおいてフロンティア級の性能を発揮します。CC Switch を使えば、さまざまなエージェントツールで Kimi を手軽に設定・切り替えできます。**[ここをクリックして Kimi を使い始める](https://platform.kimi.ai?aff=cc-switch)**
Kimi K3 は Moonshot AI がこれまでに開発した中で最も高性能なモデルであり、世界初のオープンソース 3T クラスモデルです。2.8 兆パラメータ、ネイティブな視覚能力、100 万トークンのコンテキストウィンドウを備え、長期にわたるコーディング、ナレッジワーク、推論タスクにおいてフロンティア級の性能を発揮します。CC Switch を使えば、さまざまなエージェントツールで Kimi を手軽に設定・切り替えできます。**[ここをクリックして Kimi を使い始める](https://platform.kimi.ai?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch)**
**新規ユーザー初回チャージ特典**[こちらのリンク](https://platform.kimi.ai?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch)から登録し、初回チャージに成功すると、チャージ金額の 10%(最大 CNY ¥1,000)が API クレジットとして進呈されます。
コーディング作業がメインですか?**[Kimi Code サブスクリプション](https://www.kimi.com/code/?aff=cc-switch)** をぜひお試しください!
@@ -134,11 +136,6 @@ TeamoRouter は、集中請求、チーム管理、BYOK、スマートルーテ
<td>SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。<a href="https://cloud.siliconflow.cn/i/YflgU2Ve">このリンク</a>から登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥16 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。</td>
</tr>
<tr>
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
<td>本プロジェクトをご支援いただいている <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> に感謝します!NekoCode は、Claude や Codex などの AI モデルに対応した、安定性・効率性・信頼性に優れた API 中継サービスを提供しています。料金体系は明瞭で、柔軟な従量課金にも対応しています。CC Switch ユーザー限定の 10%オフ特典:<a href="https://nekocode.ai?aff=CCSWITCH">こちらのリンク</a> から登録し、チャージ時にクーポンコード <code>cc-switch</code> を入力すると、チャージが 10%オフになります!</td>
</tr>
<tr>
<td width="180"><a href="https://a6api.com/register?aff=AqNr"><img src="assets/partners/logos/a6-banner-en.jpg" alt="A6API" width="150"></a></td>
<td>本プロジェクトをご支援いただいている <a href="https://a6api.com/register?aff=AqNr">A6API</a> に感謝します!A6API は、Claude、GPT、Gemini、Codex などの主要モデルを網羅するワンストップの AI モデル API アグリゲーションプラットフォームです。複数のベンダーが出品でき、同じモデルを複数の上流プロバイダーが競争価格で提供します。スマートルーティングにより、より安定して安価な利用可能ルートを自動で選択し、失敗時には自動で切り替えるため、リクエストの失敗を減らし、コストを抑え、安定性を高められます。個人開発者でも、AI プロダクトチームでも、スタジオでも、統一されたインターフェースからすぐに接続でき、あらゆるフォーマットに対応、移行コストも低く抑えられます。<a href="https://a6api.com/register?aff=AqNr">こちらのリンク</a> から新規登録すると無料の体験クレジットがもらえます。まず試してから、低価格で使い始められます。</td>
+4 -7
View File
@@ -25,9 +25,11 @@
<details open>
<summary>点击折叠</summary>
[![Kimi K2.7 Code](https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-zh.png)](https://platform.kimi.com?aff=cc-switch)
[![Kimi K2.7 Code](https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-zh.png)](https://platform.kimi.com?track_id=track-6840233b42274ab4bcfd283e2bdd2aee&aff=cc-switch)
Kimi K3 是 Moonshot AI 迄今能力最强的模型,也是全球首个开源 3T 级模型。K3 拥有 2.8T 参数、原生视觉能力与 100 万 Token 上下文,在长程编码、知识工作和推理任务中展现前沿性能。使用 CC Switch,可以在各类 Agent 工具中便捷配置和切换 Kimi。**[点击此处开始使用 Kimi](https://platform.kimi.com?aff=cc-switch)**
Kimi K3 是 Moonshot AI 迄今能力最强的模型,也是全球首个开源 3T 级模型。K3 拥有 2.8T 参数、原生视觉能力与 100 万 Token 上下文,在长程编码、知识工作和推理任务中展现前沿性能。使用 CC Switch,可以在各类 Agent 工具中便捷配置和切换 Kimi。**[点击此处开始使用 Kimi](https://platform.kimi.com?track_id=track-6840233b42274ab4bcfd283e2bdd2aee&aff=cc-switch)**
**新用户首充福利**:通过[此链接](https://platform.kimi.com?track_id=track-6840233b42274ab4bcfd283e2bdd2aee&aff=cc-switch)注册并首次成功充值,即可获赠充值金额 10% 的 API 额度,最高赠送 ¥1000。
主要进行编程工作?可以试试 **[Kimi Code 订阅](https://www.kimi.com/code/?aff=cc-switch)**。
@@ -134,11 +136,6 @@ TeamoRouter 还提供企业级功能,包括集中账单、团队管理、BYOK
<td>感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过<a href="https://cloud.siliconflow.cn/i/YflgU2Ve">此链接</a>注册并完成实名认证,即可获得 ¥16 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。</td>
</tr>
<tr>
<td width="180"><a href="https://nekocode.ai?aff=CCSWITCH"><img src="assets/partners/logos/nekocode-banner.png" alt="NekoCode" width="150"></a></td>
<td>感谢 <a href="https://nekocode.ai?aff=CCSWITCH">NekoCode</a> 赞助本项目!NekoCode 为开发者提供稳定、高效、可靠的 Claude、Codex 等 AI 模型 API 中转服务,价格透明,接入便捷,支持灵活的按量计费。CC Switch 用户专享 9 折福利:通过 <a href="https://nekocode.ai?aff=CCSWITCH">此链接</a> 注册,并在充值时输入优惠码 <code>cc-switch</code>,即可享受充值 9 折优惠!</td>
</tr>
<tr>
<td width="180"><a href="https://a6api.com/register?aff=AqNr"><img src="assets/partners/logos/a6-banner-zh.jpg" alt="A6API" width="150"></a></td>
<td>感谢 <a href="https://a6api.com/register?aff=AqNr">A6API</a> 赞助本项目!A6API 是一站式 AI 模型 API 聚合平台,覆盖 Claude、GPT、Gemini、Codex 等主流模型,支持多商家入驻供货,同一个模型可由多个上游商家竞争报价。平台通过智能路由自动优选更稳定、更低价的可用线路,并支持失败自动切换,帮助用户减少请求失败、降低调用成本、提升使用稳定性。无论你是开发者、AI 产品团队还是工作室,都可以通过统一接口快速接入,兼容所有格式,迁移成本低,使用更省心。新用户通过 <a href="https://a6api.com/register?aff=AqNr">此链接</a> 注册即可获得免费体验额度,先试再用,低价开用。</td>
+1
View File
@@ -2237,6 +2237,7 @@ impl Database {
"0",
),
// Qwen 系列 (阿里巴巴)
("qwen3.8-max", "Qwen3.8 Max", "2", "6", "0.25", "2.50"),
("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"),
(
+17 -1
View File
@@ -33,13 +33,29 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
AppType::Claude => "CLAUDE.md",
AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md",
AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw => "AGENTS.md",
AppType::Hermes => "SOUL.md",
AppType::ClaudeDesktop => unreachable!("handled above"),
};
Ok(base_dir.join(filename))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hermes_prompt_file_uses_soul_md() {
let path = prompt_file_path(&AppType::Hermes).expect("Hermes prompt path");
assert_eq!(
path.file_name().and_then(|name| name.to_str()),
Some("SOUL.md")
);
}
}
fn get_base_dir_with_fallback(
primary_path: PathBuf,
fallback_dir: &str,
+11
View File
@@ -87,6 +87,17 @@ impl Provider {
|| self.claude_base_url_contains("chatgpt.com/backend-api/codex")
}
/// Whether the provider form's "auth field" was explicitly set to
/// ANTHROPIC_API_KEY. The form only persists `meta.apiKeyField` for the
/// non-default choice, so `None` means the default ANTHROPIC_AUTH_TOKEN.
pub fn claude_uses_api_key_field(&self) -> bool {
self.meta
.as_ref()
.and_then(|m| m.api_key_field.as_deref())
.map(|field| field.eq_ignore_ascii_case("ANTHROPIC_API_KEY"))
.unwrap_or(false)
}
fn provider_type(&self) -> Option<&str> {
self.meta.as_ref().and_then(|m| m.provider_type.as_deref())
}
+9
View File
@@ -1183,6 +1183,15 @@ impl RequestForwarder {
super::providers::copilot_model_map::apply_copilot_model_normalization(mapped_body);
self.apply_copilot_live_model_resolution(provider, &mut mapped_body)
.await;
// Strip the [1M] context marker after Copilot normalization/resolve.
// A user's mapped value (e.g. "gpt-5.6-sol[1M]") carries [1M] as a
// Claude Code context-capability declaration that upstream APIs reject
// as part of the model name. The preceding normalization step already
// rewrites claude-xxx[1M] into the "-1m" dash form Copilot accepts, and
// the strip helper only touches the "[1m]" bracket form, so "-1m"
// variants pass through unchanged.
mapped_body =
super::model_mapper::strip_one_m_suffix_for_upstream_from_body(mapped_body);
} else if !codex_responses_to_anthropic {
// Skip on the Codex→Anthropic path: stripping [1m] here would break both the
// model-catalog match (apply_codex_upstream_model) and the transform's own
@@ -80,6 +80,8 @@ struct ChatToResponsesState {
latest_usage: Option<Value>,
finish_reason: Option<String>,
tool_context: CodexToolContext,
/// 本回合因缺少合法函数名而被丢弃的工具调用数(见 `finalize_tools`)。
dropped_tool_calls: usize,
}
impl Default for ChatToResponsesState {
@@ -100,6 +102,7 @@ impl Default for ChatToResponsesState {
latest_usage: None,
finish_reason: None,
tool_context: CodexToolContext::default(),
dropped_tool_calls: 0,
}
}
}
@@ -332,8 +335,43 @@ impl ChatToResponsesState {
(!self.reasoning.text.trim().is_empty()).then(|| self.reasoning.text.trim().to_string())
}
/// 上游未下发 `index` 时的 key 解析。
///
/// `index` 在 OpenAI Chat Completions 协议里是必填字段,但部分第三方网关会省略。
/// 缺了它就无法从帧结构上区分「同一调用的 arguments 续帧」和「一个新调用」,
/// 所以这里只在**能确证是新调用**时才分配新 key:delta 带非空 `id`,且该 id 与
/// 所有已知调用都不同。其余情况一律归入最后一个已知 key(空 map 时为 0),保持
/// 既有行为——宁可两个并行调用坍缩成一个,也不能把一个调用的续帧炸成多个 item。
fn resolve_tool_key_without_index(&self, tool_call: &Value) -> usize {
let last_key = self.tools.keys().next_back().copied();
let Some(id) = tool_call
.get("id")
.and_then(|v| v.as_str())
.filter(|id| !id.is_empty())
else {
return last_key.unwrap_or(0);
};
if let Some((key, _)) = self.tools.iter().find(|(_, state)| state.call_id == id) {
return *key;
}
// 上游可以先发一个显式 `index: usize::MAX` 再发无 index 的新 id。这段代码
// 存在的理由就是应付畸形上游,所以不能用裸 `+1`debug 下 panic、release 下
// 回绕到 0 覆盖已有调用)。溢出时退回并入最后一个已知调用,与本函数
// "宁可坍缩也不炸开" 的取向一致。
match last_key {
Some(key) => key.checked_add(1).unwrap_or(key),
None => 0,
}
}
fn push_tool_call_delta(&mut self, tool_call: &Value, reasoning: Option<&str>) -> Vec<Bytes> {
let chat_index = tool_call.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let chat_index = match tool_call.get("index").and_then(|v| v.as_u64()) {
Some(index) => index as usize,
None => self.resolve_tool_key_without_index(tool_call),
};
let id_delta = tool_call
.get("id")
.and_then(|v| v.as_str())
@@ -483,6 +521,16 @@ impl ChatToResponsesState {
})
}
/// 本回合最终产出里是否至少有一个可被 Codex 识别的工具调用 item。
fn has_emitted_tool_call(&self) -> bool {
self.output_items.iter().any(|(_, item)| {
matches!(
item.get("type").and_then(|v| v.as_str()),
Some("function_call" | "custom_tool_call" | "tool_search_call")
)
})
}
fn finalize(&mut self) -> Vec<Bytes> {
if self.completed {
return Vec::new();
@@ -495,6 +543,27 @@ impl ChatToResponsesState {
events.extend(self.finalize_tools());
let status = response_status_from_finish_reason(self.finish_reason.as_deref());
// 丢弃过工具调用、且最终一个工具调用都没剩下时,Codex 会收到一个
// "status=completed 但 output 里没有任何工具调用" 的回合,agent loop 必然
// 静默收尾——这正是 #4341「答一句就停、零报错」的形态。此时如实报错,
// 而不是谎报成功。只要还剩下任何一个合法工具调用,Codex 本来就会继续,
// 判据不成立,行为保持不变。
//
// 🔴 只对本应 `completed` 的回合生效:`finish_reason=length`(含流提前断开后
// 合成的 length)有自己正当的终止解释,工具调用没拿到 name 是截断的后果而非
// 上游发了畸形数据——报成 tool_call_dropped 会给出错误的归因,而本修复的全部
// 意义就在于诊断信息的准确性。
if status == "completed" && self.dropped_tool_calls > 0 && !self.has_emitted_tool_call() {
let dropped = self.dropped_tool_calls;
let message = format!(
"Upstream returned {dropped} tool call(s) without a function name, \
leaving no usable tool call in this turn"
);
events.push(self.failed_event(message, Some("upstream_tool_call_dropped".to_string())));
return events;
}
let mut response = self.base_response(status, self.completed_output_items());
if status == "incomplete" {
response["incomplete_details"] = json!({ "reason": "max_output_tokens" });
@@ -545,16 +614,35 @@ impl ChatToResponsesState {
// Skip tool calls with missing names (defensive: some models generate
// tool call deltas without providing a valid function name)
// 纯空白名同样对应不到任何已发布工具,必须与空名同等对待——否则它会
// 伪装成"本回合还有工具调用",绕过下面 finalize 里的失败判据。
let has_bad_name = self
.tools
.get(&key)
.map(|state| state.name.is_empty())
.map(|state| state.name.trim().is_empty())
.unwrap_or(true);
if has_bad_name {
let (call_id_empty, args_bytes) = self
.tools
.get(&key)
.map(|state| (state.call_id.is_empty(), state.arguments.len()))
.unwrap_or((true, 0));
if let Some(state) = self.tools.get_mut(&key) {
state.done = true;
}
log::warn!("[Codex] Skipping streaming tool call with missing name");
self.dropped_tool_calls += 1;
// 只记结构信息:arguments 内容可能包含用户代码,且前端日志出口是
// allowlist 脱敏,新字段不进白名单就不会被处理,因此只输出字节数。
log::warn!(
"[Codex] dropped streaming tool call: model={} chat_index={} \
call_id_empty={} args_bytes={} finish_reason={} tools_total={}",
self.model,
key,
call_id_empty,
args_bytes,
self.finish_reason.as_deref().unwrap_or("<none>"),
self.tools.len()
);
continue;
}
@@ -1033,6 +1121,138 @@ mod tests {
assert!(!output.contains("call_missing"));
}
/// #4341:上游只给出畸形工具调用时,丢弃后本回合一个工具调用都不剩,
/// Codex 会把它当成正常完成而静默收尾。此时必须如实报错。
#[tokio::test]
async fn dropped_only_tool_call_emits_failed_without_completed() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_drop\",\"model\":\"kimi-k3\",\"choices\":[{\"delta\":{\"content\":\"让我继续处理这个文件\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_drop\",\"model\":\"kimi-k3\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_bad\",\"type\":\"function\",\"function\":{\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
assert!(output.contains("event: response.failed"));
assert!(output.contains("upstream_tool_call_dropped"));
assert!(!output.contains("event: response.completed"));
// 已经推给客户端的文本增量不受影响,用户仍能看到模型说了什么。
assert!(output.contains("让我继续处理这个文件"));
}
/// `finish_reason=length`token 截断)时工具调用往往只到一半就没了 name。
/// 这不是"上游发了畸形数据",而是截断——归因必须是 incomplete,不能报成
/// tool_call_dropped,否则诊断信息本身就是错的。
#[tokio::test]
async fn truncated_turn_stays_incomplete_instead_of_failed() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_trunc\",\"model\":\"kimi-k3\",\"choices\":[{\"delta\":{\"content\":\"我来看看\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_trunc\",\"model\":\"kimi-k3\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_cut\",\"type\":\"function\",\"function\":{\"arguments\":\"{\\\"pa\"}}]},\"finish_reason\":\"length\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
assert!(output.contains("event: response.completed"));
assert!(output.contains("\"status\":\"incomplete\""));
assert!(!output.contains("event: response.failed"));
}
/// 纯空白函数名对应不到任何已发布工具,必须与空名同等对待,
/// 否则它会伪装成"本回合还有工具调用"而绕过判据。
#[tokio::test]
async fn whitespace_only_tool_name_is_dropped() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_ws\",\"model\":\"kimi-k3\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_ws\",\"type\":\"function\",\"function\":{\"name\":\" \",\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
assert!(output.contains("event: response.failed"));
assert!(output.contains("upstream_tool_call_dropped"));
assert!(!output.contains("event: response.completed"));
}
/// 纯文本回合(从未出现过工具调用增量)不受判据影响。
#[tokio::test]
async fn text_only_turn_still_completes() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_text\",\"model\":\"kimi-k3\",\"choices\":[{\"delta\":{\"content\":\"完成了\"},\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
assert!(output.contains("event: response.completed"));
assert!(!output.contains("event: response.failed"));
}
/// 上游省略 `index` 时,两个 id 不同的调用不得坍缩成一个。
#[tokio::test]
async fn missing_index_with_distinct_ids_keeps_calls_separate() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_noidx\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"a.txt\\\"}\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_noidx\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_b\",\"type\":\"function\",\"function\":{\"name\":\"exec_command\",\"arguments\":\"{\\\"cmd\\\":\\\"ls\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
let events = parse_sse_events(&output);
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.unwrap();
let items = completed["response"]["output"].as_array().unwrap();
assert_eq!(items.len(), 2);
assert_eq!(items[0]["call_id"], "call_a");
assert_eq!(items[0]["name"], "read_file");
assert_eq!(items[0]["arguments"], r#"{"path":"a.txt"}"#);
assert_eq!(items[1]["call_id"], "call_b");
assert_eq!(items[1]["name"], "exec_command");
assert_eq!(items[1]["arguments"], r#"{"cmd":"ls"}"#);
}
/// 上游省略 `index` 时,不带 id 的 arguments 续帧必须归入同一个调用,
/// 不能被当成新调用炸成多个 item。
#[tokio::test]
async fn missing_index_argument_fragments_stay_in_one_call() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_frag\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_frag\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"type\":\"function\",\"function\":{\"arguments\":\"\\\"a.txt\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
let events = parse_sse_events(&output);
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.unwrap();
let items = completed["response"]["output"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["call_id"], "call_a");
assert_eq!(items[0]["arguments"], r#"{"path":"a.txt"}"#);
}
/// 上游省略 `index` 且重复下发同一个 id(部分网关每帧重复整个头部)时,
/// 不得被判成新调用。
#[tokio::test]
async fn missing_index_repeated_same_id_stays_in_one_call() {
let output = collect(vec![
"data: {\"id\":\"chatcmpl_rep\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_rep\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"\\\"a.txt\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n",
])
.await;
let events = parse_sse_events(&output);
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.unwrap();
let items = completed["response"]["output"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["call_id"], "call_a");
assert_eq!(items[0]["arguments"], r#"{"path":"a.txt"}"#);
}
#[tokio::test]
async fn finalization_keeps_non_contiguous_tool_index() {
let output = collect(vec![
@@ -1409,11 +1409,28 @@ pub(crate) fn chat_completion_to_response_with_context(
if let Some(message_item) = chat_message_to_response_output_item(message, &response_id) {
output.push(message_item);
}
output.extend(chat_tool_calls_to_response_output_items(
message,
reasoning.as_deref(),
tool_context,
));
let tool_calls =
chat_tool_calls_to_response_output_items(message, reasoning.as_deref(), tool_context);
// 丢弃过工具调用、且最终一个工具调用都没剩下时,Codex 会收到一个
// "status=completed 但 output 里没有任何工具调用" 的回合,agent loop 必然静默
// 收尾(#4341)。此时如实报错,而不是谎报成功。只要还剩下任何一个合法工具
// 调用,Codex 本来就会继续,判据不成立,行为保持不变。
//
// 🔴 与流式分支一致,只对本应 `completed` 的回合生效:`finish_reason=length`
// 是截断,工具调用缺 name 是截断的后果而非上游发了畸形数据,报成
// tool_call_dropped 会给出错误的归因。
if response_status_from_finish_reason(finish_reason) == "completed"
&& tool_calls.dropped > 0
&& tool_calls.items.is_empty()
{
return Err(ProxyError::TransformError(format!(
"Upstream returned {} tool call(s) without a function name, \
leaving no usable tool call in this turn",
tool_calls.dropped
)));
}
output.extend(tool_calls.items);
let mut response = json!({
"id": response_id,
@@ -1533,12 +1550,20 @@ fn chat_message_to_response_output_item(message: &Value, response_id: &str) -> O
}))
}
/// 非流式工具调用转换结果。`dropped` 记录因缺少合法函数名而被丢弃的条数,
/// 供调用方判断本回合是否已经不可能让 Codex 继续(见 #4341)。
struct ChatToolCallItems {
items: Vec<Value>,
dropped: usize,
}
fn chat_tool_calls_to_response_output_items(
message: &Value,
reasoning: Option<&str>,
tool_context: &CodexToolContext,
) -> Vec<Value> {
) -> ChatToolCallItems {
let mut output = Vec::new();
let mut dropped = 0usize;
if let Some(tool_calls) = message.get("tool_calls").and_then(|v| v.as_array()) {
for (index, tool_call) in tool_calls.iter().enumerate() {
@@ -1546,8 +1571,24 @@ fn chat_tool_calls_to_response_output_items(
// may generate tool calls without providing a valid name)
let function = tool_call.get("function").unwrap_or(&Value::Null);
let name = function.get("name").and_then(|v| v.as_str()).unwrap_or("");
if name.is_empty() {
log::warn!("[Codex] Skipping tool call with missing name");
// 纯空白名同样对应不到任何已发布工具,与空名同等对待。
if name.trim().is_empty() {
dropped += 1;
// 只记结构信息,不记 arguments 内容(可能包含用户代码)。
let call_id_empty = tool_call
.get("id")
.and_then(|v| v.as_str())
.is_none_or(str::is_empty);
let args_bytes = function
.get("arguments")
.and_then(|v| v.as_str())
.map(str::len)
.unwrap_or(0);
log::warn!(
"[Codex] dropped tool call: index={index} call_id_empty={call_id_empty} \
args_bytes={args_bytes} tools_total={}",
tool_calls.len()
);
continue;
}
output.push(chat_tool_call_to_response_item(
@@ -1558,14 +1599,16 @@ fn chat_tool_calls_to_response_output_items(
));
}
} else if let Some(function_call) = message.get("function_call") {
if let Some(item) =
chat_legacy_function_call_to_response_item(function_call, reasoning, tool_context)
{
output.push(item);
match chat_legacy_function_call_to_response_item(function_call, reasoning, tool_context) {
Some(item) => output.push(item),
None => dropped += 1,
}
}
output
ChatToolCallItems {
items: output,
dropped,
}
}
fn chat_tool_call_to_response_item(
@@ -1612,9 +1655,18 @@ fn chat_legacy_function_call_to_response_item(
.unwrap_or("");
// Skip legacy function calls with missing names (defensive: some models
// may generate function_call without providing a valid name)
if name.is_empty() {
log::warn!("[Codex] Skipping legacy function_call with missing name");
// may generate function_call without providing a valid name)
// 纯空白名同样对应不到任何已发布工具,与空名同等对待。
if name.trim().is_empty() {
// 只记结构信息,不记 arguments 内容(可能包含用户代码)。
let args_bytes = function_call
.get("arguments")
.and_then(|v| v.as_str())
.map(str::len)
.unwrap_or(0);
log::warn!(
"[Codex] dropped legacy function_call: call_id={call_id} args_bytes={args_bytes}"
);
return None;
}
@@ -3952,6 +4004,165 @@ mod tests {
);
}
/// #4341(非流式路径):丢弃后一个工具调用都不剩时,必须如实报错,
/// 而不是返回一个 Codex 会当成正常完成的空壳回合。
#[test]
fn chat_response_with_only_unnamed_tool_call_is_an_error() {
let chat = json!({
"id": "chatcmpl_drop",
"object": "chat.completion",
"created": 123,
"model": "kimi-k3",
"choices": [{
"message": {
"role": "assistant",
"content": "让我继续处理这个文件",
"tool_calls": [{
"id": "call_bad",
"type": "function",
"function": {"arguments": "{}"}
}]
},
"finish_reason": "tool_calls"
}]
});
let err = chat_completion_to_response_with_context(chat, &CodexToolContext::default())
.unwrap_err();
assert!(matches!(err, ProxyError::TransformError(_)));
assert!(err.to_string().contains("without a function name"));
}
/// 只要还剩下一个合法工具调用,Codex 本来就会继续,行为保持不变。
#[test]
fn chat_response_keeps_valid_tool_call_beside_unnamed_one() {
let chat = json!({
"id": "chatcmpl_mixed",
"object": "chat.completion",
"created": 123,
"model": "kimi-k3",
"choices": [{
"message": {
"role": "assistant",
"tool_calls": [
{"id": "call_bad", "type": "function", "function": {"arguments": "{}"}},
{
"id": "call_good",
"type": "function",
"function": {"name": "exec_command", "arguments": "{\"cmd\":\"ls\"}"}
}
]
},
"finish_reason": "tool_calls"
}]
});
let result =
chat_completion_to_response_with_context(chat, &CodexToolContext::default()).unwrap();
let output = result["output"].as_array().unwrap();
assert_eq!(output.len(), 1);
assert_eq!(output[0]["name"], "exec_command");
assert_eq!(output[0]["call_id"], "call_good");
assert_eq!(result["status"], "completed");
}
/// legacy `function_call` 形态同样受判据保护。
#[test]
fn chat_response_with_unnamed_legacy_function_call_is_an_error() {
let chat = json!({
"id": "chatcmpl_legacy",
"object": "chat.completion",
"created": 123,
"model": "kimi-k3",
"choices": [{
"message": {
"role": "assistant",
"function_call": {"id": "call_legacy", "arguments": "{}"}
},
"finish_reason": "function_call"
}]
});
let err = chat_completion_to_response_with_context(chat, &CodexToolContext::default())
.unwrap_err();
assert!(matches!(err, ProxyError::TransformError(_)));
}
/// `finish_reason=length` 是截断,不是"上游发了畸形数据"——归因必须保持
/// incomplete,不能报成 tool_call_dropped。
#[test]
fn chat_response_truncated_stays_incomplete_instead_of_error() {
let chat = json!({
"id": "chatcmpl_trunc",
"object": "chat.completion",
"created": 123,
"model": "kimi-k3",
"choices": [{
"message": {
"role": "assistant",
"content": "我来看看",
"tool_calls": [{
"id": "call_cut",
"type": "function",
"function": {"arguments": "{\"pa"}
}]
},
"finish_reason": "length"
}]
});
let result =
chat_completion_to_response_with_context(chat, &CodexToolContext::default()).unwrap();
assert_eq!(result["status"], "incomplete");
assert_eq!(result["incomplete_details"]["reason"], "max_output_tokens");
}
/// 纯空白函数名必须与空名同等对待,否则会伪装成"本回合还有工具调用"。
#[test]
fn chat_response_whitespace_only_tool_name_is_an_error() {
let chat = json!({
"id": "chatcmpl_ws",
"object": "chat.completion",
"created": 123,
"model": "kimi-k3",
"choices": [{
"message": {
"role": "assistant",
"tool_calls": [{
"id": "call_ws",
"type": "function",
"function": {"name": " ", "arguments": "{}"}
}]
},
"finish_reason": "tool_calls"
}]
});
let err = chat_completion_to_response_with_context(chat, &CodexToolContext::default())
.unwrap_err();
assert!(matches!(err, ProxyError::TransformError(_)));
}
/// 纯文本回合(从未出现工具调用)不受判据影响。
#[test]
fn chat_response_text_only_still_completes() {
let chat = json!({
"id": "chatcmpl_text",
"object": "chat.completion",
"created": 123,
"model": "kimi-k3",
"choices": [{
"message": {"role": "assistant", "content": "完成了"},
"finish_reason": "stop"
}]
});
let result =
chat_completion_to_response_with_context(chat, &CodexToolContext::default()).unwrap();
assert_eq!(result["status"], "completed");
}
#[test]
fn chat_response_to_responses_canonicalizes_json_string_tool_arguments() {
let input = json!({
+64 -10
View File
@@ -95,9 +95,16 @@ impl ProxyService {
let auth_policy = if provider.uses_managed_account_auth() {
// Codex 系(含仅凭 base_url 识别、无 provider_type meta 的)必须保留
// ANTHROPIC_AUTH_TOKEN 占位符:Claude Code 缺该键会弹登录提示(#3784)。
// Copilot 维持仅 API_KEY 占位,避免与 /login 管理的 key 冲突(#1049)。
// Copilot 默认同样注入 AUTH_TOKEN 占位符:Claude Code(实测 2.1.220
// 对 ANTHROPIC_API_KEY 会弹"是否使用该自定义 key"确认框且默认
// "No (recommended)",按默认走后占位符被忽略、落入 Not logged in
// (并非 sk-ant-* 格式校验——headless 下占位符原样出站);AUTH_TOKEN
// 作为网关 Bearer 被直接信任,零弹窗。仅当供应商表单显式选择了
// ANTHROPIC_API_KEYmeta.apiKeyField)时才保留 API_KEY 占位,以规避
// 与 /login 管理的 key 冲突(#1049)。
ClaudeTakeoverAuthPolicy::ManagedAccount {
keep_auth_token: !provider.is_github_copilot(),
keep_auth_token: !provider.is_github_copilot()
|| !provider.claude_uses_api_key_field(),
}
} else {
ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken
@@ -197,7 +204,10 @@ impl ProxyService {
// - Codex 系保留 AUTH_TOKEN:缺该键 Claude Code 会弹登录提示(#3784)。
// 无条件注入而非"已存在才保留":热切换路径传入的是 provider
// settings(预设不含该键),且旧版接管已把存量用户 live 中的键删光。
// - Copilot 仅 API_KEY:避免与 /login 管理的 key 冲突(#1049)。
// - Copilot 默认 AUTH_TOKENAPI_KEY 占位符会触发 Claude Code 的
// 自定义 key 确认框(默认 "No (recommended)"),按默认走即
// Not logged in;仅当表单显式选择了 ANTHROPIC_API_KEY 时才用
// API_KEY 占位以规避 /login key 冲突(#1049)。
if keep_auth_token {
env.insert(
"ANTHROPIC_AUTH_TOKEN".to_string(),
@@ -3304,7 +3314,7 @@ mod tests {
}
#[test]
fn managed_account_claude_takeover_uses_api_key_placeholder() {
fn managed_account_claude_takeover_uses_auth_token_placeholder() {
let mut provider = Provider::with_id(
"copilot".to_string(),
"GitHub Copilot".to_string(),
@@ -3333,13 +3343,13 @@ mod tests {
.and_then(|value| value.as_object())
.expect("env should exist");
assert_eq!(
env.get("ANTHROPIC_API_KEY")
env.get("ANTHROPIC_AUTH_TOKEN")
.and_then(|value| value.as_str()),
Some(PROXY_TOKEN_PLACEHOLDER)
);
assert!(
env.get("ANTHROPIC_AUTH_TOKEN").is_none(),
"managed OAuth providers should avoid Claude Auth Token login semantics"
env.get("ANTHROPIC_API_KEY").is_none(),
"API_KEY placeholders trigger Claude Code's custom-key approval prompt (defaults to No), landing users in Not logged in"
);
}
@@ -3421,8 +3431,8 @@ mod tests {
"CLAUDE_CODE_SUBAGENT_MODEL",
Some("claude-sonnet-4.6[1M]"),
);
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None);
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_API_KEY", None);
}
#[test]
@@ -3675,7 +3685,7 @@ mod tests {
}
#[test]
fn managed_account_claude_takeover_copilot_removes_stale_auth_token() {
fn managed_account_claude_takeover_copilot_defaults_to_auth_token() {
let mut provider = Provider::with_id(
"copilot".to_string(),
"GitHub Copilot".to_string(),
@@ -3691,6 +3701,48 @@ mod tests {
..Default::default()
});
let mut live_config = json!({
"env": {
"ANTHROPIC_BASE_URL": "https://stale.example.com",
"ANTHROPIC_AUTH_TOKEN": "stale-token",
"ANTHROPIC_API_KEY": "stale-key"
}
});
ProxyService::apply_claude_takeover_fields_for_provider(
&mut live_config,
"http://127.0.0.1:15721",
&provider,
);
let env = live_config
.get("env")
.and_then(|value| value.as_object())
.expect("env should exist");
// Default Copilot takeover injects AUTH_TOKEN: the API_KEY placeholder
// triggers Claude Code's custom-key approval prompt (defaults to
// "No (recommended)"), which lands users in "Not logged in".
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_API_KEY", None);
}
#[test]
fn managed_account_claude_takeover_copilot_honors_api_key_field_choice() {
let mut provider = Provider::with_id(
"copilot".to_string(),
"GitHub Copilot".to_string(),
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
}
}),
None,
);
provider.meta = Some(ProviderMeta {
provider_type: Some("github_copilot".to_string()),
api_key_field: Some("ANTHROPIC_API_KEY".to_string()),
..Default::default()
});
let mut live_config = json!({
"env": {
"ANTHROPIC_BASE_URL": "https://stale.example.com",
@@ -3707,6 +3759,8 @@ mod tests {
.get("env")
.and_then(|value| value.as_object())
.expect("env should exist");
// Explicit API-key-field choice keeps the API_KEY placeholder to avoid
// conflicting with the /login-managed key (#1049).
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None);
}
+87 -10
View File
@@ -2374,31 +2374,38 @@ impl SkillService {
/// 将 discoverable skill 的目录信息重新解析为解压目录中的真实源目录。
///
/// 兼容三种情况
/// 1. `skills/foo` 这类直接相对路径
/// 2. 仅持有安装名 `foo`,需要在仓库中递归查找真实目录;
/// 3. 仓库根目录本身就是 skill,此时回退到解压根目录
/// **核心原则:返回的目录必定含 `SKILL.md`**(以 SKILL.md 为锚点)。解析顺序
/// 1. 直接相对路径命中(如 `skills/foo`),校验含 `SKILL.md`——明确路径优先
/// 2. 按安装名递归查找名字匹配 **且** 含 `SKILL.md` 的目录;
/// 3. 兜底:仓库根本身含 `SKILL.md`
fn resolve_skill_source_dir(root: &Path, raw_directory: &str) -> Option<PathBuf> {
let source_rel = Self::sanitize_skill_source_path(raw_directory)?;
let install_name = source_rel
.file_name()
.map(|n| n.to_string_lossy().to_string())?;
// 1. 直接相对路径命中(明确路径优先)——必须校验 SKILL.md,否则同名空壳目录
// (如 ast-grep/agent-skill 根下的 plugin 包目录 ast-grep/)会被误判为源目录。
let direct = root.join(&source_rel);
if direct.is_dir() {
if direct.is_dir() && direct.join("SKILL.md").is_file() {
return Some(direct);
}
let target_name = source_rel.file_name()?.to_string_lossy().to_string();
if let Some(found) = Self::find_skill_dir_by_name(root, &target_name) {
// 2. 按名字递归查找(find_skill_dir_by_name 已校验 SKILL.md
if let Some(found) = Self::find_skill_dir_by_name(root, &install_name) {
log::info!(
"Skill directory '{}' not found at direct path, using fallback: {}",
target_name,
install_name,
found.display()
);
return Some(found);
}
if root.is_dir() && root.join("SKILL.md").exists() {
// 3. 兜底:仓库根本身是 skill
if root.join("SKILL.md").is_file() {
log::info!(
"Skill directory '{}' not found, but SKILL.md exists at root, using repo root",
target_name,
install_name,
);
return Some(root.to_path_buf());
}
@@ -4452,4 +4459,74 @@ mod tests {
"existing destination skill should be preserved"
);
}
#[test]
fn resolve_skill_source_dir_rejects_same_name_wrapper_without_skill_md() {
// 复刻 issue #4141ast-grep/agent-skill 结构。仓库根下有同名目录 ast-grep/
// plugin 包,无 SKILL.md),真正的 skill 在 ast-grep/skills/ast-grep/SKILL.md。
let temp = tempdir().expect("tempdir");
let wrapper = temp.path().join("ast-grep");
fs::create_dir_all(wrapper.join(".claude-plugin")).expect("create wrapper plugin dir");
fs::write(
wrapper.join(".claude-plugin").join("plugin.json"),
"{\"name\":\"ast-grep\"}",
)
.expect("write plugin.json");
let real_skill = wrapper.join("skills").join("ast-grep");
write_skill(&real_skill, "ast-grep");
// directory 只给了 skill 名 "ast-grep"skills.sh API 的语义),不能命中空壳 wrapper。
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "ast-grep")
.expect("should resolve to the inner skill dir, not the same-name wrapper");
assert_eq!(resolved, real_skill);
assert!(resolved.join("SKILL.md").is_file());
}
#[test]
fn resolve_skill_source_dir_finds_two_level_catalog_skill() {
// catalog layoutskills/category/foo/SKILL.mddepth 3find_skill_dir_by_name 可达)。
let temp = tempdir().expect("tempdir");
let catalog_skill = temp.path().join("skills").join("category").join("foo");
write_skill(&catalog_skill, "Foo Skill");
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "foo")
.expect("should resolve the two-level catalog skill by name");
assert_eq!(resolved, catalog_skill);
}
#[test]
fn resolve_skill_source_dir_returns_none_for_wrapper_without_inner_skill() {
// 同名 wrapper 存在、无 SKILL.md,且无 inner skill / root SKILL.md 可兜底时,
// 必须返回 None——守住 #4141 这个 bug class 的负例(不能把空壳目录当源目录)。
let temp = tempdir().expect("tempdir");
let wrapper = temp.path().join("ast-grep");
fs::create_dir_all(wrapper.join(".claude-plugin")).expect("create wrapper plugin dir");
fs::write(
wrapper.join(".claude-plugin").join("plugin.json"),
"{\"name\":\"ast-grep\"}",
)
.expect("write plugin.json");
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "ast-grep");
assert!(
resolved.is_none(),
"wrapper dir without SKILL.md and no inner skill must resolve to None, got {:?}",
resolved
);
}
#[test]
fn resolve_skill_source_dir_returns_none_when_no_skill_md_anywhere() {
let temp = tempdir().expect("tempdir");
fs::create_dir_all(temp.path().join("skills").join("foo")).expect("create empty skill dir");
fs::write(temp.path().join("README.md"), "no skills here").expect("write README");
let resolved = SkillService::resolve_skill_source_dir(temp.path(), "foo");
assert!(
resolved.is_none(),
"no SKILL.md anywhere must resolve to None"
);
}
}
+55
View File
@@ -0,0 +1,55 @@
import React from "react";
import { Loader2 } from "lucide-react";
import { useCodexOauthQuotaByAccountId } from "@/lib/query/subscription";
import { SubscriptionQuotaView } from "@/components/SubscriptionQuotaFooter";
interface CodexOauthAccountQuotaProps {
/** cc-switch 自管的 ChatGPT 账号 ID */
accountId: string;
}
/**
* ChatGPT (Codex OAuth)
*
* accountId cc-switch OAuth token
* `SubscriptionQuotaView` + +
*
*
*
*/
const CodexOauthAccountQuota: React.FC<CodexOauthAccountQuotaProps> = ({
accountId,
}) => {
const {
data: quota,
isFetching: loading,
refetch,
} = useCodexOauthQuotaByAccountId(accountId, {
enabled: true,
autoQuery: false,
});
// 首次加载占位:账号头部由父组件独立渲染,这里只负责用量区。
// 用量请求是异步的(Tauri invoke + React Query),加载期间给一个
// 与最终额度卡片同形状(rounded-xl / border / bg-card)的转圈占位,
// 这样账号会立刻显示、用量数据到达后原地平滑替换,不产生跳版。
if (loading && !quota) {
return (
<div className="mt-3 flex items-center justify-center rounded-xl border border-border-default bg-card py-5 shadow-sm">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
);
}
return (
<SubscriptionQuotaView
quota={quota}
loading={loading}
refetch={refetch}
appIdForExpiredHint="codex_oauth"
inline={false}
/>
);
};
export default CodexOauthAccountQuota;
+1 -1
View File
@@ -32,7 +32,7 @@ const PromptFormPanel: React.FC<PromptFormPanelProps> = ({
grokbuild: "AGENTS.md",
opencode: "AGENTS.md",
openclaw: "AGENTS.md",
hermes: "AGENTS.md",
hermes: "SOUL.md",
};
const filename = filenameMap[appId];
const [name, setName] = useState("");
@@ -24,9 +24,12 @@ import {
} from "lucide-react";
import { useCodexOauth } from "./hooks/useCodexOauth";
import { copyText } from "@/lib/clipboard";
import CodexOauthAccountQuota from "@/components/CodexOauthAccountQuota";
interface CodexOAuthSectionProps {
className?: string;
/** 是否展示每个账号的订阅额度 */
showAccountQuota?: boolean;
/** 当前选中的 ChatGPT 账号 ID */
selectedAccountId?: string | null;
/** 账号选择回调 */
@@ -45,6 +48,7 @@ interface CodexOAuthSectionProps {
*/
export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
className,
showAccountQuota = false,
selectedAccountId,
onAccountSelect,
fastModeEnabled = false,
@@ -178,47 +182,52 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
{accounts.map((account) => (
<div
key={account.id}
className="flex items-center justify-between p-2 rounded-md border bg-muted/30"
className="space-y-2 p-2 rounded-md border bg-muted/30"
>
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{account.login}</span>
{defaultAccountId === account.id && (
<Badge variant="secondary" className="text-xs">
{t("codexOauth.defaultAccount", "默认")}
</Badge>
)}
{selectedAccountId === account.id && (
<Badge variant="outline" className="text-xs">
{t("codexOauth.selected", "已选中")}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
{defaultAccountId !== account.id && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{account.login}</span>
{defaultAccountId === account.id && (
<Badge variant="secondary" className="text-xs">
{t("codexOauth.defaultAccount", "默认")}
</Badge>
)}
{selectedAccountId === account.id && (
<Badge variant="outline" className="text-xs">
{t("codexOauth.selected", "已选中")}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
{defaultAccountId !== account.id && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => setDefaultAccount(account.id)}
disabled={isSettingDefaultAccount}
>
{t("codexOauth.setAsDefault", "设为默认")}
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => setDefaultAccount(account.id)}
disabled={isSettingDefaultAccount}
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-500"
onClick={(e) => handleRemoveAccount(account.id, e)}
disabled={isRemovingAccount}
title={t("codexOauth.removeAccount", "移除账号")}
>
{t("codexOauth.setAsDefault", "设为默认")}
<X className="h-4 w-4" />
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-500"
onClick={(e) => handleRemoveAccount(account.id, e)}
disabled={isRemovingAccount}
title={t("codexOauth.removeAccount", "移除账号")}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{showAccountQuota && (
<CodexOauthAccountQuota accountId={account.id} />
)}
</div>
))}
</div>
+1 -1
View File
@@ -69,7 +69,7 @@ export function AuthCenterPanel() {
</div>
</div>
<CodexOAuthSection />
<CodexOAuthSection showAccountQuota />
</section>
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
+1 -13
View File
@@ -167,6 +167,7 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
"kimi-k2.7-code",
"kimi-k2.7-code",
),
partnerPromotionKey: "kimi",
icon: "kimi",
iconColor: "#6366F1",
},
@@ -507,19 +508,6 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
icon: "siliconflow",
iconColor: "#000000",
},
{
name: "NekoCode",
websiteUrl: "https://nekocode.ai",
apiKeyUrl: "https://nekocode.ai?aff=CCSWITCH",
category: "aggregator",
baseUrl: "https://nekocode.ai",
mode: "direct",
apiFormat: "anthropic",
modelRoutes: passthroughRoutes(),
isPartner: true,
partnerPromotionKey: "nekocode",
icon: "nekocode",
},
{
name: "A6API",
websiteUrl: "https://www.a6api.com",
+1 -15
View File
@@ -106,6 +106,7 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
partnerPromotionKey: "kimi",
icon: "kimi",
iconColor: "#6366F1",
},
@@ -507,21 +508,6 @@ export const providerPresets: ProviderPreset[] = [
icon: "siliconflow",
iconColor: "#000000",
},
{
name: "NekoCode",
websiteUrl: "https://nekocode.ai",
apiKeyUrl: "https://nekocode.ai?aff=CCSWITCH",
settingsConfig: {
env: {
ANTHROPIC_BASE_URL: "https://nekocode.ai",
ANTHROPIC_AUTH_TOKEN: "",
},
},
category: "aggregator",
isPartner: true,
partnerPromotionKey: "nekocode",
icon: "nekocode",
},
{
name: "A6API",
websiteUrl: "https://www.a6api.com",
+1 -16
View File
@@ -159,6 +159,7 @@ export const codexProviderPresets: CodexProviderPreset[] = [
outputFormat: "reasoning_content",
},
category: "cn_official",
partnerPromotionKey: "kimi",
icon: "kimi",
iconColor: "#6366F1",
},
@@ -601,22 +602,6 @@ requires_openai_auth = true`,
icon: "siliconflow",
iconColor: "#000000",
},
{
name: "NekoCode",
websiteUrl: "https://nekocode.ai",
apiKeyUrl: "https://nekocode.ai?aff=CCSWITCH",
category: "aggregator",
auth: generateThirdPartyAuth(""),
config: generateThirdPartyConfig(
"nekocode",
"https://nekocode.ai/v1",
"gpt-5.6-sol",
),
endpointCandidates: ["https://nekocode.ai/v1"],
isPartner: true,
partnerPromotionKey: "nekocode",
icon: "nekocode",
},
{
name: "A6API",
websiteUrl: "https://www.a6api.com",
-12
View File
@@ -266,18 +266,6 @@ export const grokBuildProviderPresets: GrokBuildProviderPreset[] = [
partnerPromotionKey: "claudecn",
icon: "claudecn",
},
{
name: "NekoCode",
websiteUrl: "https://nekocode.ai",
apiKeyUrl: "https://nekocode.ai?aff=CCSWITCH",
auth: grokAuth(),
config: grokPresetConfig("NekoCode", "https://nekocode.ai/v1"),
endpointCandidates: ["https://nekocode.ai/v1"],
category: "aggregator",
isPartner: true,
partnerPromotionKey: "nekocode",
icon: "nekocode",
},
{
name: "A6API",
websiteUrl: "https://www.a6api.com",
+1 -19
View File
@@ -145,6 +145,7 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
],
},
category: "cn_official",
partnerPromotionKey: "kimi",
icon: "kimi",
iconColor: "#6366F1",
suggestedDefaults: {
@@ -647,25 +648,6 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
},
},
},
{
name: "NekoCode",
websiteUrl: "https://nekocode.ai",
apiKeyUrl: "https://nekocode.ai?aff=CCSWITCH",
settingsConfig: {
name: "nekocode",
base_url: "https://nekocode.ai/v1",
api_key: "",
api_mode: "chat_completions",
models: [{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" }],
},
category: "aggregator",
isPartner: true,
partnerPromotionKey: "nekocode",
icon: "nekocode",
suggestedDefaults: {
model: { default: "gpt-5.6-sol", provider: "nekocode" },
},
},
{
name: "A6API",
websiteUrl: "https://www.a6api.com",
+1 -36
View File
@@ -125,6 +125,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
],
},
category: "cn_official",
partnerPromotionKey: "kimi",
icon: "kimi",
iconColor: "#6366F1",
templateValues: {
@@ -957,42 +958,6 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
},
},
},
{
name: "NekoCode",
websiteUrl: "https://nekocode.ai",
apiKeyUrl: "https://nekocode.ai?aff=CCSWITCH",
settingsConfig: {
baseUrl: "https://nekocode.ai/v1",
apiKey: "",
api: "openai-completions",
models: [
{
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
contextWindow: 400000,
},
],
},
category: "aggregator",
isPartner: true,
partnerPromotionKey: "nekocode",
icon: "nekocode",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
suggestedDefaults: {
model: {
primary: "nekocode/gpt-5.6-sol",
},
modelCatalog: {
"nekocode/gpt-5.6-sol": { alias: "GPT-5.6 Sol" },
},
},
},
{
name: "A6API",
websiteUrl: "https://www.a6api.com",
+1 -28
View File
@@ -306,6 +306,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
category: "cn_official",
partnerPromotionKey: "kimi",
icon: "kimi",
iconColor: "#6366F1",
templateValues: {
@@ -865,34 +866,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
},
{
name: "NekoCode",
websiteUrl: "https://nekocode.ai",
apiKeyUrl: "https://nekocode.ai?aff=CCSWITCH",
settingsConfig: {
npm: "@ai-sdk/openai-compatible",
name: "NekoCode",
options: {
baseURL: "https://nekocode.ai/v1",
apiKey: "",
setCacheKey: true,
},
models: {
"gpt-5.6-sol": { name: "GPT-5.6 Sol" },
},
},
category: "aggregator",
isPartner: true,
partnerPromotionKey: "nekocode",
icon: "nekocode",
templateValues: {
apiKey: {
label: "API Key",
placeholder: "",
editorValue: "",
},
},
},
{
name: "A6API",
websiteUrl: "https://www.a6api.com",
+2 -2
View File
@@ -1022,10 +1022,10 @@
"providerKeyStatusLoading": "Provider identifier status is still loading. Please try again shortly.",
"getApiKey": "Get API Key",
"partnerPromotion": {
"kimi": "New Kimi users get 10% of their first successful top-up back as bonus API credit, up to CNY ¥1,000.",
"a6api": "A6API is a token aggregation platform with a built-in real-time price leaderboard that automatically picks the lowest price on the market. Smooth and stable, simple to operate, and no more tedious price comparisons. Register now to claim free trial credits!",
"sudocode": "With one SudoCode key, Claude Code and Claude Desktop use Claude Opus 5, while Codex uses GPT-5.6. Sign up, join QQ group 726213516, and contact the group owner to claim CNY ¥10 in trial credit.",
"code0": "code0.ai is an AI coding service platform for developers, supporting Claude Code, Codex, and Gemini. Exclusive for CC Switch users: contact support via the official website to claim free trial credits!",
"nekocode": "NekoCode gives developers a stable, efficient, and reliable API relay for Claude, Codex, and other AI models, with transparent pay-as-you-go pricing. Exclusive 10% off for CC Switch users: register via the link above and enter promo code cc-switch at recharge to save 10%!",
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off",
"apikeyfun": "APIKEY.FUN offers a special deal for CC Switch users. Register through the exclusive link to enjoy up to permanent 5% off top-ups.",
"apinebula": "APINEBULA offers CC Switch users a special discount: register using the link and enter the \"ccswitch\" promo code during your first top-up to get 10% off.",
@@ -1033,7 +1033,7 @@
"patewayai": "PatewayAI offers special benefits for CC Switch users. Register via this link to receive $3 credit.",
"claudeapi": "ClaudeAPI offers special benefits for CC Switch users. Register via this link to claim test credits.",
"claudecn": "ClaudeCN is an enterprise-grade AI gateway operated by a registered company, supporting enterprise procurement processes with corporate payments, contracts, and compliance guarantees.",
"runapi": "RunAPI offers special benefits for CC Switch users. Register via this link and contact customer support to claim ¥14 free credit.",
"runapi": "RunAPI offers special benefits for CC Switch users. Register via this link and enjoy 10% off your first top-up.",
"minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9",
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)",
"opencode_go": "Subscribe to OpenCode Go via this link — only $5 for your first month, plus an extra $5 in credit!",
+2 -2
View File
@@ -1022,10 +1022,10 @@
"providerKeyStatusLoading": "プロバイダー識別子の状態を読み込んでいます。しばらくしてからもう一度お試しください",
"getApiKey": "API Key を取得",
"partnerPromotion": {
"kimi": "Kimi の新規ユーザーは、初回チャージに成功するとチャージ金額の 10%(最大 CNY ¥1,000)が API クレジットとして進呈されます。",
"a6api": "A6API はトークンアグリゲーションサイトです。リアルタイム価格ランキングを内蔵し、全ネット最安値を自動で選択。動作は滑らかで安定し、面倒な価格比較も不要。登録するだけで体験クレジットがもらえます!",
"sudocode": "SudoCode の1つのキーで、Claude Code と Claude Desktop では Claude Opus 5、Codex では GPT-5.6 を利用できます。登録後 QQ グループ 726213516 に参加し、管理者へ連絡すると CNY ¥10 のトライアルクレジットを受け取れます。",
"code0": "code0.ai は開発者向けの AI コーディングサービスプラットフォームで、Claude Code、Codex、Gemini に対応。CC Switch ユーザー限定特典:公式サイトからサポートに連絡してテストクレジットを受け取れます!",
"nekocode": "NekoCode は Claude や Codex などの AI モデルに対応した、安定・高効率で信頼性の高い API 中継サービスを提供します。明瞭な従量課金制。CC Switch ユーザー限定 10%オフ:上のリンクから登録し、チャージ時にクーポンコード cc-switch を入力すると 10%オフ!",
"packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ",
"apikeyfun": "APIKEY.FUN は CC Switch ユーザー向けに特別優待を提供しています。専用リンクから登録すると、最大でチャージ永久 5% オフを受けられます。",
"apinebula": "APINEBULA は CC Switch ユーザー向けに特別割引を提供しています。専用リンクから登録し、チャージ時にプロモコード「ccswitch」を入力すると、さらに 10% OFF の割引が適用されます。",
@@ -1033,7 +1033,7 @@
"patewayai": "PatewayAI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録すると $3 のクレジットがもらえます。",
"claudeapi": "ClaudeAPI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録するとテストクレジットを受け取ることができます。",
"claudecn": "ClaudeCN は登録企業が運営するエンタープライズグレードの AI ゲートウェイプラットフォームで、企業調達プロセスをサポートし、法人支払い、契約、コンプライアンス保証を提供します。",
"runapi": "RunAPI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録しカスタマーサポートにご連絡いただくと、¥14 の無料クレジットを受け取ることができます。",
"runapi": "RunAPI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録すると、初回チャージが 10% オフになります。",
"minimax_cn": "MiniMax Coding Plan 特別価格、Starter ¥9.9 から",
"minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $280% OFF",
"opencode_go": "このリンクから OpenCode Go を購読すると、初月はわずか $5、さらに $5 分のクレジットがもらえます!",
+2 -2
View File
@@ -1023,10 +1023,10 @@
"providerKeyStatusLoading": "正在載入供應商識別碼狀態,請稍後再試",
"getApiKey": "取得 API Key",
"partnerPromotion": {
"kimi": "Kimi 新使用者首次成功儲值,即可獲贈儲值金額 10% 的 API 額度,最高贈送 ¥1000。",
"a6api": "A6API 是一家 Token 聚合站,內建即時價格排行自動篩選全網最低價,滑順穩定不卡頓,簡易操作省去比價繁瑣,註冊獲得體驗金!",
"sudocode": "SudoCode 讓 Claude Code 與 Claude Desktop 使用 Claude Opus 5Codex 使用 GPT-5.6,一個 Key 統一管理。CC Switch 使用者註冊並加入 QQ 群 726213516,聯絡群主領取人民幣 ¥10 試用額度。",
"code0": "code0.ai 是面向開發者的 AI 程式設計服務平台,支援 Claude Code、Codex、Gemini。CC Switch 使用者專屬福利:透過官網聯繫客服即可領取測試額度!",
"nekocode": "NekoCode 為開發者提供穩定、高效、可靠的 Claude、Codex 等 AI 模型 API 中轉服務,價格透明、按量計費。CC Switch 使用者專享 9 折:透過上方連結註冊,儲值時輸入優惠碼 cc-switch 即享 9 折優惠!",
"packycode": "PackyCode 是 CC Switch 的官方合作夥伴,使用此連結註冊並在儲值時填寫「cc-switch」優惠碼,可以享受 9 折優惠",
"apikeyfun": "APIKEY.FUN 為 CC Switch 的使用者提供了特別優惠,透過專屬連結註冊,可享受最高儲值永久 95 折優惠。",
"apinebula": "APINEBULA 為 CC Switch 使用者提供特別優惠:使用專屬連結註冊並在儲值時填寫「ccswitch」優惠碼,可享 9 折優惠。",
@@ -1034,7 +1034,7 @@
"patewayai": "PatewayAI 為 CC Switch 的使用者提供了特別福利,透過此連結註冊可以獲得 3 美元額度。",
"claudeapi": "ClaudeAPI 為 CC Switch 的使用者提供了特別福利,透過此連結註冊可以領取測試額度。",
"claudecn": "ClaudeCN 是一家實體企業營運的企業級 AI 中繼平台,支援企業採購流程,可對公打款、簽約,服務合規有保障。",
"runapi": "RunAPI 為 CC Switch 的使用者提供了特別福利,透過此連結註冊並聯繫客服可領取 ¥14 免費額度。",
"runapi": "RunAPI 為 CC Switch 的使用者提供了特別福利,透過此連結註冊,首次儲值即可享受 9 折優惠。",
"minimax_cn": "MiniMax Coding Plan 特惠,Starter 方案 9.9 元起",
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 方案現僅 $2 / 月(2 折優惠!)",
"opencode_go": "使用此連結訂閱 OpenCode Go,首月僅需 $5,並可獲得額外的 $5 額度!",
+2 -2
View File
@@ -1022,10 +1022,10 @@
"providerKeyStatusLoading": "正在加载供应商标识状态,请稍后再试",
"getApiKey": "获取 API Key",
"partnerPromotion": {
"kimi": "Kimi 新用户首次成功充值,即可获赠充值金额 10% 的 API 额度,最高赠送 ¥1000。",
"a6api": "A6API 是一家 Token 聚合站,内置实时价格排行自动筛选全网最低价,丝滑稳定不卡顿,简易操作省去比价繁琐,注册获得体验金!",
"sudocode": "SudoCode 让 Claude Code 与 Claude Desktop 接入 Claude Opus 5Codex 接入 GPT-5.6,一个 Key 统一使用。CC Switch 用户注册并加入 QQ 群 726213516,联系群主领取 ¥10 试用额度。",
"code0": "code0.ai 是面向开发者的 AI 编程服务平台,支持 Claude Code、Codex、Gemini。CC Switch 用户专属福利:通过官网联系客服即可领取测试额度!",
"nekocode": "NekoCode 为开发者提供稳定、高效、可靠的 Claude、Codex 等 AI 模型 API 中转服务,价格透明、按量计费。CC Switch 用户专享 9 折:通过上方链接注册,充值时输入优惠码 cc-switch 即享 9 折优惠!",
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠",
"apikeyfun": "APIKEY.FUN 为 CC Switch 的用户提供了特别优惠,通过专属链接注册,可享受最高充值永久 95 折优惠。",
"apinebula": "APINEBULA 为 CC Switch 用户提供特别优惠:使用专属链接注册并在充值时填写 \"ccswitch\" 优惠码,可享九折优惠。",
@@ -1033,7 +1033,7 @@
"patewayai": "PatewayAI 为 CC Switch 的用户提供了特别福利,通过此链接注册可以获得3美元额度。",
"claudeapi": "ClaudeAPI 为 CC Switch 的用户提供了特别福利,通过此链接注册可以领取测试额度。",
"claudecn": "ClaudeCN 是一家实体企业运营的企业级AI中转平台,支持企业采购流程,可对公打款、签约,服务合规有保障。",
"runapi": "RunAPI 为 CC Switch 的用户提供了特别福利,通过此链接注册并联系客服可领取 ¥14 免费额度。",
"runapi": "RunAPI 为 CC Switch 的用户提供了特别福利,通过此链接注册,首次充值即可享受 9 折优惠。",
"minimax_cn": "MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起",
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)",
"opencode_go": "使用此链接订阅 OpenCode Go,首月只需 $5,并可获得额外的 $5 额度!",
+21 -8
View File
@@ -112,20 +112,18 @@ export interface UseCodexOauthQuotaOptions {
}
/**
* Codex OAuth (ChatGPT Plus/Pro ) hook
* Codex OAuth hook ID
*
* `useSubscriptionQuota` cc-switch OAuth token
* Codex CLI ~/.codex/auth.json
*
* Query key accountId
* cc-switch ChatGPT ID
* Query key `useCodexOauthQuota`
*
* accountId null 使 "default" fallback
*/
export function useCodexOauthQuota(
meta: ProviderMeta | undefined,
export function useCodexOauthQuotaByAccountId(
accountId: string | null,
options: UseCodexOauthQuotaOptions = {},
) {
const { enabled = true, autoQuery = false } = options;
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH);
const query = useQuery({
queryKey: ["codex_oauth", "quota", accountId ?? "default"],
queryFn: () => subscriptionApi.getCodexOauthQuota(accountId),
@@ -140,6 +138,21 @@ export function useCodexOauthQuota(
return useQuotaKeepLastGood(query, accountId ?? "default");
}
/**
* Codex OAuth (ChatGPT Plus/Pro ) hook
*
* `useSubscriptionQuota` cc-switch OAuth token
* Codex CLI ~/.codex/auth.json ID meta
* authBinding `useCodexOauthQuotaByAccountId`
*/
export function useCodexOauthQuota(
meta: ProviderMeta | undefined,
options: UseCodexOauthQuotaOptions = {},
) {
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH);
return useCodexOauthQuotaByAccountId(accountId, options);
}
/**
* xAI OAuth (SuperGrok ) hook
*
@@ -0,0 +1,74 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection";
import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel";
const mocks = vi.hoisted(() => ({
useCodexOauth: vi.fn(),
renderAccountQuota: vi.fn(),
}));
vi.mock("@/components/providers/forms/hooks/useCodexOauth", () => ({
useCodexOauth: mocks.useCodexOauth,
}));
vi.mock("@/components/CodexOauthAccountQuota", () => ({
default: ({ accountId }: { accountId: string }) => {
mocks.renderAccountQuota(accountId);
return <div data-testid="account-quota">{accountId}</div>;
},
}));
vi.mock("@/components/providers/forms/CopilotAuthSection", () => ({
CopilotAuthSection: () => <div />,
}));
vi.mock("@/components/providers/forms/XaiOAuthSection", () => ({
XaiOAuthSection: () => <div />,
}));
describe("CodexOAuthSection", () => {
beforeEach(() => {
mocks.useCodexOauth.mockReturnValue({
accounts: [
{
id: "account-1",
provider: "codex_oauth",
login: "user@example.com",
avatar_url: null,
authenticated_at: 0,
is_default: true,
github_domain: "",
},
],
defaultAccountId: "account-1",
hasAnyAccount: true,
pollingState: "idle",
deviceCode: null,
error: null,
isPolling: false,
isAddingAccount: false,
isRemovingAccount: false,
isSettingDefaultAccount: false,
addAccount: vi.fn(),
removeAccount: vi.fn(),
setDefaultAccount: vi.fn(),
cancelAuth: vi.fn(),
logout: vi.fn(),
});
});
it("does not render account quota by default", () => {
render(<CodexOAuthSection />);
expect(mocks.renderAccountQuota).not.toHaveBeenCalled();
expect(screen.queryByTestId("account-quota")).not.toBeInTheDocument();
});
it("renders account quota in Auth Center", () => {
render(<AuthCenterPanel />);
expect(mocks.renderAccountQuota).toHaveBeenCalledWith("account-1");
expect(screen.getByTestId("account-quota")).toHaveTextContent("account-1");
});
});