Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] 9c7c1ebb56 chore(deps): bump actions/labeler from 5 to 7
Bumps [actions/labeler](https://github.com/actions/labeler) from 5 to 7.
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](https://github.com/actions/labeler/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/labeler
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 18:25:54 +00:00
30 changed files with 260 additions and 865 deletions
+1 -1
View File
@@ -12,6 +12,6 @@ jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5
- uses: actions/labeler@v7
with:
sync-labels: true
+7 -4
View File
@@ -25,11 +25,9 @@ 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?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch)
[![Kimi K2.7 Code](https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png)](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.
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)**
Doing mostly coding work? Try the **[Kimi Code subscription](https://www.kimi.com/code/?aff=cc-switch)**.
@@ -136,6 +134,11 @@ 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>
+7 -4
View File
@@ -25,11 +25,9 @@
<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?track_id=track-20d65732f0aa45dcb1df9691a15610af&aff=cc-switch)
[![Kimi K2.7 Code](https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png)](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.
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)**
Hauptsächlich mit Programmierung beschäftigt? Probieren Sie das **[Kimi-Code-Abo](https://www.kimi.com/code/?aff=cc-switch)** aus!
@@ -136,6 +134,11 @@ 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>
+7 -4
View File
@@ -25,11 +25,9 @@
<details open>
<summary>クリックで折りたたむ</summary>
[![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 K2.7 Code](https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-en.png)](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 K3 は Moonshot AI がこれまでに開発した中で最も高性能なモデルであり、世界初のオープンソース 3T クラスモデルです。2.8 兆パラメータ、ネイティブな視覚能力、100 万トークンのコンテキストウィンドウを備え、長期にわたるコーディング、ナレッジワーク、推論タスクにおいてフロンティア級の性能を発揮します。CC Switch を使えば、さまざまなエージェントツールで Kimi を手軽に設定・切り替えできます。**[ここをクリックして Kimi を使い始める](https://platform.kimi.ai?aff=cc-switch)**
コーディング作業がメインですか?**[Kimi Code サブスクリプション](https://www.kimi.com/code/?aff=cc-switch)** をぜひお試しください!
@@ -136,6 +134,11 @@ 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>
+7 -4
View File
@@ -25,11 +25,9 @@
<details open>
<summary>点击折叠</summary>
[![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 K2.7 Code](https://gcdn.moonshot.cn/growth-cdn/sponsor/kimi-zh.png)](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 K3 是 Moonshot AI 迄今能力最强的模型,也是全球首个开源 3T 级模型。K3 拥有 2.8T 参数、原生视觉能力与 100 万 Token 上下文,在长程编码、知识工作和推理任务中展现前沿性能。使用 CC Switch,可以在各类 Agent 工具中便捷配置和切换 Kimi。**[点击此处开始使用 Kimi](https://platform.kimi.com?aff=cc-switch)**
主要进行编程工作?可以试试 **[Kimi Code 订阅](https://www.kimi.com/code/?aff=cc-switch)**。
@@ -136,6 +134,11 @@ 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,7 +2237,6 @@ 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"),
(
+1 -17
View File
@@ -33,29 +33,13 @@ 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 => "AGENTS.md",
AppType::Hermes => "SOUL.md",
AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.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,17 +87,6 @@ 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,15 +1183,6 @@ 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,8 +80,6 @@ struct ChatToResponsesState {
latest_usage: Option<Value>,
finish_reason: Option<String>,
tool_context: CodexToolContext,
/// 本回合因缺少合法函数名而被丢弃的工具调用数(见 `finalize_tools`)。
dropped_tool_calls: usize,
}
impl Default for ChatToResponsesState {
@@ -102,7 +100,6 @@ impl Default for ChatToResponsesState {
latest_usage: None,
finish_reason: None,
tool_context: CodexToolContext::default(),
dropped_tool_calls: 0,
}
}
}
@@ -335,43 +332,8 @@ 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 = 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 chat_index = tool_call.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let id_delta = tool_call
.get("id")
.and_then(|v| v.as_str())
@@ -521,16 +483,6 @@ 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();
@@ -543,27 +495,6 @@ 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" });
@@ -614,35 +545,16 @@ 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.trim().is_empty())
.map(|state| state.name.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;
}
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()
);
log::warn!("[Codex] Skipping streaming tool call with missing name");
continue;
}
@@ -1121,138 +1033,6 @@ 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,28 +1409,11 @@ 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);
}
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);
output.extend(chat_tool_calls_to_response_output_items(
message,
reasoning.as_deref(),
tool_context,
));
let mut response = json!({
"id": response_id,
@@ -1550,20 +1533,12 @@ 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,
) -> ChatToolCallItems {
) -> Vec<Value> {
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() {
@@ -1571,24 +1546,8 @@ 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.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()
);
if name.is_empty() {
log::warn!("[Codex] Skipping tool call with missing name");
continue;
}
output.push(chat_tool_call_to_response_item(
@@ -1599,16 +1558,14 @@ fn chat_tool_calls_to_response_output_items(
));
}
} else if let Some(function_call) = message.get("function_call") {
match chat_legacy_function_call_to_response_item(function_call, reasoning, tool_context) {
Some(item) => output.push(item),
None => dropped += 1,
if let Some(item) =
chat_legacy_function_call_to_response_item(function_call, reasoning, tool_context)
{
output.push(item);
}
}
ChatToolCallItems {
items: output,
dropped,
}
output
}
fn chat_tool_call_to_response_item(
@@ -1655,18 +1612,9 @@ 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.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}"
);
// may generate function_call without providing a valid name)
if name.is_empty() {
log::warn!("[Codex] Skipping legacy function_call with missing name");
return None;
}
@@ -4004,165 +3952,6 @@ 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!({
+10 -64
View File
@@ -95,16 +95,9 @@ impl ProxyService {
let auth_policy = if provider.uses_managed_account_auth() {
// Codex 系(含仅凭 base_url 识别、无 provider_type meta 的)必须保留
// ANTHROPIC_AUTH_TOKEN 占位符:Claude Code 缺该键会弹登录提示(#3784)。
// 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)。
// Copilot 维持仅 API_KEY 占位,避免与 /login 管理的 key 冲突(#1049)。
ClaudeTakeoverAuthPolicy::ManagedAccount {
keep_auth_token: !provider.is_github_copilot()
|| !provider.claude_uses_api_key_field(),
keep_auth_token: !provider.is_github_copilot(),
}
} else {
ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken
@@ -204,10 +197,7 @@ impl ProxyService {
// - Codex 系保留 AUTH_TOKEN:缺该键 Claude Code 会弹登录提示(#3784)。
// 无条件注入而非"已存在才保留":热切换路径传入的是 provider
// settings(预设不含该键),且旧版接管已把存量用户 live 中的键删光。
// - Copilot 默认 AUTH_TOKENAPI_KEY 占位符会触发 Claude Code 的
// 自定义 key 确认框(默认 "No (recommended)"),按默认走即
// Not logged in;仅当表单显式选择了 ANTHROPIC_API_KEY 时才用
// API_KEY 占位以规避 /login key 冲突(#1049)。
// - Copilot 仅 API_KEY:避免与 /login 管理的 key 冲突(#1049)。
if keep_auth_token {
env.insert(
"ANTHROPIC_AUTH_TOKEN".to_string(),
@@ -3314,7 +3304,7 @@ mod tests {
}
#[test]
fn managed_account_claude_takeover_uses_auth_token_placeholder() {
fn managed_account_claude_takeover_uses_api_key_placeholder() {
let mut provider = Provider::with_id(
"copilot".to_string(),
"GitHub Copilot".to_string(),
@@ -3343,13 +3333,13 @@ mod tests {
.and_then(|value| value.as_object())
.expect("env should exist");
assert_eq!(
env.get("ANTHROPIC_AUTH_TOKEN")
env.get("ANTHROPIC_API_KEY")
.and_then(|value| value.as_str()),
Some(PROXY_TOKEN_PLACEHOLDER)
);
assert!(
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"
env.get("ANTHROPIC_AUTH_TOKEN").is_none(),
"managed OAuth providers should avoid Claude Auth Token login semantics"
);
}
@@ -3431,8 +3421,8 @@ mod tests {
"CLAUDE_CODE_SUBAGENT_MODEL",
Some("claude-sonnet-4.6[1M]"),
);
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_API_KEY", None);
assert_env_str(env, "ANTHROPIC_API_KEY", Some(PROXY_TOKEN_PLACEHOLDER));
assert_env_str(env, "ANTHROPIC_AUTH_TOKEN", None);
}
#[test]
@@ -3685,7 +3675,7 @@ mod tests {
}
#[test]
fn managed_account_claude_takeover_copilot_defaults_to_auth_token() {
fn managed_account_claude_takeover_copilot_removes_stale_auth_token() {
let mut provider = Provider::with_id(
"copilot".to_string(),
"GitHub Copilot".to_string(),
@@ -3701,48 +3691,6 @@ 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",
@@ -3759,8 +3707,6 @@ 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);
}
+10 -87
View File
@@ -2374,38 +2374,31 @@ impl SkillService {
/// 将 discoverable skill 的目录信息重新解析为解压目录中的真实源目录。
///
/// **核心原则:返回的目录必定含 `SKILL.md`**(以 SKILL.md 为锚点)。解析顺序
/// 1. 直接相对路径命中(如 `skills/foo`),校验含 `SKILL.md`——明确路径优先
/// 2. 按安装名递归查找名字匹配 **且** 含 `SKILL.md` 的目录;
/// 3. 兜底:仓库根本身含 `SKILL.md`
/// 兼容三种情况
/// 1. `skills/foo` 这类直接相对路径
/// 2. 仅持有安装名 `foo`,需要在仓库中递归查找真实目录;
/// 3. 仓库根目录本身就是 skill,此时回退到解压根目录
fn resolve_skill_source_dir(root: &Path, raw_directory: &str) -> Option<PathBuf> {
let source_rel = Self::sanitize_skill_source_path(raw_directory)?;
let 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() && direct.join("SKILL.md").is_file() {
if direct.is_dir() {
return Some(direct);
}
// 2. 按名字递归查找(find_skill_dir_by_name 已校验 SKILL.md
if let Some(found) = Self::find_skill_dir_by_name(root, &install_name) {
let target_name = source_rel.file_name()?.to_string_lossy().to_string();
if let Some(found) = Self::find_skill_dir_by_name(root, &target_name) {
log::info!(
"Skill directory '{}' not found at direct path, using fallback: {}",
install_name,
target_name,
found.display()
);
return Some(found);
}
// 3. 兜底:仓库根本身是 skill
if root.join("SKILL.md").is_file() {
if root.is_dir() && root.join("SKILL.md").exists() {
log::info!(
"Skill directory '{}' not found, but SKILL.md exists at root, using repo root",
install_name,
target_name,
);
return Some(root.to_path_buf());
}
@@ -4459,74 +4452,4 @@ 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
@@ -1,55 +0,0 @@
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: "SOUL.md",
hermes: "AGENTS.md",
};
const filename = filenameMap[appId];
const [name, setName] = useState("");
@@ -24,12 +24,9 @@ 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;
/** 账号选择回调 */
@@ -48,7 +45,6 @@ interface CodexOAuthSectionProps {
*/
export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
className,
showAccountQuota = false,
selectedAccountId,
onAccountSelect,
fastModeEnabled = false,
@@ -182,52 +178,47 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
{accounts.map((account) => (
<div
key={account.id}
className="space-y-2 p-2 rounded-md border bg-muted/30"
className="flex items-center justify-between p-2 rounded-md border bg-muted/30"
>
<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>
)}
<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="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", "移除账号")}
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => setDefaultAccount(account.id)}
disabled={isSettingDefaultAccount}
>
<X className="h-4 w-4" />
{t("codexOauth.setAsDefault", "设为默认")}
</Button>
</div>
)}
<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>
{showAccountQuota && (
<CodexOauthAccountQuota accountId={account.id} />
)}
</div>
))}
</div>
+1 -1
View File
@@ -69,7 +69,7 @@ export function AuthCenterPanel() {
</div>
</div>
<CodexOAuthSection showAccountQuota />
<CodexOAuthSection />
</section>
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
+13 -1
View File
@@ -167,7 +167,6 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
"kimi-k2.7-code",
"kimi-k2.7-code",
),
partnerPromotionKey: "kimi",
icon: "kimi",
iconColor: "#6366F1",
},
@@ -508,6 +507,19 @@ 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",
+15 -1
View File
@@ -106,7 +106,6 @@ export const providerPresets: ProviderPreset[] = [
},
},
category: "cn_official",
partnerPromotionKey: "kimi",
icon: "kimi",
iconColor: "#6366F1",
},
@@ -508,6 +507,21 @@ 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",
+16 -1
View File
@@ -159,7 +159,6 @@ export const codexProviderPresets: CodexProviderPreset[] = [
outputFormat: "reasoning_content",
},
category: "cn_official",
partnerPromotionKey: "kimi",
icon: "kimi",
iconColor: "#6366F1",
},
@@ -602,6 +601,22 @@ 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,6 +266,18 @@ 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",
+19 -1
View File
@@ -145,7 +145,6 @@ export const hermesProviderPresets: HermesProviderPreset[] = [
],
},
category: "cn_official",
partnerPromotionKey: "kimi",
icon: "kimi",
iconColor: "#6366F1",
suggestedDefaults: {
@@ -648,6 +647,25 @@ 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",
+36 -1
View File
@@ -125,7 +125,6 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
],
},
category: "cn_official",
partnerPromotionKey: "kimi",
icon: "kimi",
iconColor: "#6366F1",
templateValues: {
@@ -958,6 +957,42 @@ 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",
+28 -1
View File
@@ -306,7 +306,6 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [
},
},
category: "cn_official",
partnerPromotionKey: "kimi",
icon: "kimi",
iconColor: "#6366F1",
templateValues: {
@@ -866,6 +865,34 @@ 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 enjoy 10% off your first top-up.",
"runapi": "RunAPI offers special benefits for CC Switch users. Register via this link and contact customer support to claim ¥14 free credit.",
"minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9",
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)",
"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 ユーザーに特別な特典を提供しています。このリンクから登録すると、初回チャージが 10% オフになります。",
"runapi": "RunAPI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録しカスタマーサポートにご連絡いただくと、¥14 の無料クレジットを受け取ることができます。",
"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 的使用者提供了特別福利,透過此連結註冊,首次儲值即可享受 9 折優惠。",
"runapi": "RunAPI 為 CC Switch 的使用者提供了特別福利,透過此連結註冊並聯繫客服可領取 ¥14 免費額度。",
"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 的用户提供了特别福利,通过此链接注册,首次充值即可享受 9 折优惠。",
"runapi": "RunAPI 为 CC Switch 的用户提供了特别福利,通过此链接注册并联系客服可领取 ¥14 免费额度。",
"minimax_cn": "MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起",
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)",
"opencode_go": "使用此链接订阅 OpenCode Go,首月只需 $5,并可获得额外的 $5 额度!",
+8 -21
View File
@@ -112,18 +112,20 @@ export interface UseCodexOauthQuotaOptions {
}
/**
* Codex OAuth hook ID
* Codex OAuth (ChatGPT Plus/Pro ) hook
*
* cc-switch ChatGPT ID
* Query key `useCodexOauthQuota`
*
* `useSubscriptionQuota` cc-switch OAuth token
* Codex CLI ~/.codex/auth.json
*
* Query key accountId
* accountId null 使 "default" fallback
*/
export function useCodexOauthQuotaByAccountId(
accountId: string | null,
export function useCodexOauthQuota(
meta: ProviderMeta | undefined,
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),
@@ -138,21 +140,6 @@ export function useCodexOauthQuotaByAccountId(
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
*
@@ -1,74 +0,0 @@
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");
});
});