mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-09 04:31:24 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07d00e726e | |||
| 579df5ad99 | |||
| e349012abc | |||
| 594e0d52f7 | |||
| 11404d4d96 | |||
| 0fcb1b01e2 | |||
| ea20b0aec2 |
@@ -15,16 +15,18 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
|
||||
|
||||
## ❤️Sponsor
|
||||
|
||||
[](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||
[](https://bit.ly/3Nue8mA)
|
||||
|
||||
This project is sponsored by Z.ai, supporting us with their GLM CODING PLAN.GLM CODING PLAN is a subscription service designed for AI coding, starting at just $3/month. It provides access to their flagship GLM-4.6 model across 10+ popular AI coding tools (Claude Code, Cline, Roo Code, etc.), offering developers top-tier, fast, and stable coding experiences.Get 10% OFF the GLM CODING PLAN with [this link](https://z.ai/subscribe?ic=8JVLJQFSKB)!
|
||||
MiniMax M2.1 is an open-source, SOTA model built for real-world development and agentic workflows. It delivers top-tier performance on major coding benchmarks such as SWE, VIBE, and Multi-SWE. Powered by a 10B active / 230B total MoE architecture, M2.1 enables faster inference, easier deployment, and even local execution. It excels at coding, navigating digital environments, and handling long, multi-step tasks at scale.
|
||||
|
||||
[Click](https://bit.ly/3Nue8mA) to get an exclusive 12% off the MiniMax Coding Plan!
|
||||
|
||||
---
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during recharge to get 10% off.</td>
|
||||
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during first recharge to get 10% off.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -48,6 +50,11 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
<td>Thanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! <a href="https://www.dmxapi.cn/register?aff=bUHu">Register here</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>Thank you to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini. It features a highly cost-effective Codex monthly subscription plan and <strong>supports quota rollovers—unused quota from one day can be carried over and used the next day.</strong> Invoices are available upon top-up. Enterprise and team users can receive dedicated one-on-one support. Right Code also offers an exclusive discount for CC Switch users: register via <a href="https://www.right.codes/register?aff=CCSWITCH">this link</a>, and with every top-up you will receive pay-as-you-go credit equivalent to 25% of the amount paid.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td>Thanks to AICoding.sh for sponsoring this project! AICoding.sh — Global AI Model API Relay Service at Unbeatable Prices! Claude Code at 19% of original price, GPT at just 1%! Trusted by hundreds of enterprises for cost-effective AI services. Supports Claude Code, GPT, Gemini and major domestic models, with enterprise-grade high concurrency, fast invoicing, and 24/7 dedicated technical support. CC Switch users who register via <a href="https://aicoding.sh/i/CCSWITCH">this link</a> get 10% off their first top-up!</td>
|
||||
|
||||
+9
-2
@@ -15,9 +15,11 @@
|
||||
|
||||
## ❤️スポンサー
|
||||
|
||||
[](https://z.ai/subscribe?ic=8JVLJQFSKB)
|
||||
[](https://bit.ly/3Nue8mA)
|
||||
|
||||
本プロジェクトは Z.ai の GLM CODING PLAN による支援を受けています。GLM CODING PLAN は AI コーディング向けのサブスクリプションで、月額わずか 3 ドルから。Claude Code、Cline、Roo Code など 10 以上の人気 AI コーディングツールでフラッグシップモデル GLM-4.6 を利用でき、速く安定した開発体験を提供します。[このリンク](https://z.ai/subscribe?ic=8JVLJQFSKB) から申し込むと 10% オフになります!
|
||||
MiniMax M2.1 は、実務開発とエージェントワークフロー向けに構築されたオープンソースの最先端モデルです。100 億のアクティブパラメータ / 2,300 億の総パラメータを持つ MoE アーキテクチャにより、高速な推論、簡単なデプロイ、ローカル実行にも対応します。SWE、VIBE、Multi-SWE などの主要コーディングベンチマークでトップクラスの性能を発揮し、コーディング、デジタル環境のナビゲーション、大規模な多段階タスクの処理に優れています。
|
||||
|
||||
[こちら](https://bit.ly/3Nue8mA)から MiniMax Coding Plan の限定 12% オフを入手!
|
||||
|
||||
---
|
||||
|
||||
@@ -48,6 +50,11 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
<td>DMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!<a href="https://www.dmxapi.cn/register?aff=bUHu">登録はこちら</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデルに対応した中継(プロキシ)サービスを安定して提供しています。特に高いコストパフォーマンスを誇る Codex の月額プランを主力としており、<strong>未使用分の利用枠を翌日に繰り越して利用できる(繰越対応)</strong>点が特長です。チャージ(入金)後に請求書の発行が可能で、企業・チーム向けには専任担当による個別対応も行っています。さらに CC Switch ユーザー向けの特別優待として、<a href="https://www.right.codes/register?aff=CCSWITCH">こちらのリンク</a>からご登録いただくと、チャージのたびに実支払額の 25% 相当の従量課金クレジットが付与されます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td>AICoding.sh のご支援に感謝します!AICoding.sh —— グローバル AI モデル API 超お得な中継サービス!Claude Code 81% オフ、GPT 99% オフ!数百社の企業に高コストパフォーマンスの AI サービスを提供。Claude Code、GPT、Gemini および国内主要モデルに対応、エンタープライズ級の高同時接続、迅速な請求書発行、24 時間年中無休の専属テクニカルサポート。<a href="https://aicoding.sh/i/CCSWITCH">こちらのリンク</a>から登録した CC Switch ユーザーは、初回チャージ 10% オフ!</td>
|
||||
|
||||
+10
-3
@@ -15,16 +15,18 @@
|
||||
|
||||
## ❤️赞助商
|
||||
|
||||
[](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)
|
||||
[](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)
|
||||
|
||||
感谢智谱AI的 GLM CODING PLAN 赞助了本项目!GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元,即可在十余款主流AI编码工具如 Claude Code、Cline 中畅享智谱旗舰模型 GLM-4.6,为开发者提供顶尖、高速、稳定的编码体验。CC Switch 已经预设了智谱GLM,只需要填写 key 即可一键导入编程工具。智谱AI为本软件的用户提供了特别优惠,使用[此链接](https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII)购买可以享受九折优惠。
|
||||
MiniMax M2.x 系列模型是面向实际开发与智能体工作流打造的编码模型,M2.1 基于 100 亿激活 / 2300 亿总参的混合专家架构打造,推理更快、部署更便捷且支持本地运行,在 SWE、VIBE、Multi-SWE 等主流代码评测基准中均表现顶尖,擅长代码开发、数字环境适配及规模化处理长链路多步骤任务。
|
||||
|
||||
[点击](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)即可领取 MiniMax Coding Plan 专属 88 折优惠!
|
||||
|
||||
---
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.packyapi.com/register?aff=cc-switch"><img src="assets/partners/logos/packycode.png" alt="PackyCode" width="150"></a></td>
|
||||
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,可以享受9折优惠!</td>
|
||||
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,首次充值可以享受9折优惠!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -49,6 +51,11 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
为200多家企业用户提供全球大模型API服务。· 充值即开票 ·当天开票 ·并发不限制 ·1元起充 · 7x24 在线技术辅导,GPT/Claude/Gemini全部6.8折,国内模型5~8折,Claude Code 专属模型3.4折进行中!<a href="https://www.dmxapi.cn/register?aff=bUHu">点击这里注册</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.right.codes/register?aff=CCSWITCH"><img src="assets/partners/logos/rightcode.jpg" alt="RightCode" width="150"></a></td>
|
||||
<td>感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务。主打<strong>极高性价比</strong>的Codex包月套餐,<strong>提供额度转结,套餐当天用不完的额度,第二天还能接着用!</strong>充值即可开票,企业、团队用户一对一对接。同时为 CC Switch 的用户提供了特别优惠:通过<a href="https://www.right.codes/register?aff=CCSWITCH">此链接</a>注册,每次充值均可获得实付金额25%的按量额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aicoding.sh/i/CCSWITCH"><img src="assets/partners/logos/aicoding.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td>感谢 AICoding.sh 赞助了本项目!AICoding.sh —— 全球大模型 API 超值中转服务!Claude Code 1.9 折,GPT 0.1 折,已为数百家企业提供高性价比 AI 服务。支持 Claude Code、GPT、Gemini 及国内主流模型,企业级高并发、极速开票、7×24 专属技术支持,通过<a href="https://aicoding.sh/i/CCSWITCH">此链接</a> 注册的 CC Switch 用户,首充可享受九折优惠!</td>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
@@ -323,7 +323,7 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
let mut search_paths: Vec<std::path::PathBuf> = vec![
|
||||
home.join(".local/bin"), // Native install (official recommended)
|
||||
home.join(".npm-global/bin"),
|
||||
home.join("n/bin"), // n version manager
|
||||
home.join("n/bin"), // n version manager
|
||||
home.join(".volta/bin"), // Volta package manager
|
||||
];
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ impl Database {
|
||||
|
||||
/// 获取整流器配置
|
||||
///
|
||||
/// 返回整流器配置,如果不存在则返回默认值(全部启用)
|
||||
/// 返回整流器配置,如果不存在则返回默认值(全部开启)
|
||||
pub fn get_rectifier_config(&self) -> Result<crate::proxy::types::RectifierConfig, AppError> {
|
||||
match self.get_setting("rectifier_config")? {
|
||||
Some(json) => serde_json::from_str(&json)
|
||||
|
||||
@@ -8,7 +8,10 @@ use super::{
|
||||
failover_switch::FailoverSwitchManager,
|
||||
provider_router::ProviderRouter,
|
||||
providers::{get_adapter, ProviderAdapter, ProviderType},
|
||||
thinking_rectifier::{rectify_anthropic_request, should_rectify_thinking_signature},
|
||||
thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget},
|
||||
thinking_rectifier::{
|
||||
normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,
|
||||
},
|
||||
types::{ProxyStatus, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
@@ -157,6 +160,7 @@ impl RequestForwarder {
|
||||
|
||||
// 整流器重试标记:确保整流最多触发一次
|
||||
let mut rectifier_retried = false;
|
||||
let mut budget_rectifier_retried = false;
|
||||
|
||||
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
||||
let bypass_circuit_breaker = providers.len() == 1;
|
||||
@@ -258,6 +262,7 @@ impl RequestForwarder {
|
||||
provider_type,
|
||||
ProviderType::Claude | ProviderType::ClaudeAuth
|
||||
);
|
||||
let mut signature_rectifier_non_retryable_client_error = false;
|
||||
|
||||
if is_anthropic_provider {
|
||||
let error_message = extract_error_message(&e);
|
||||
@@ -293,12 +298,185 @@ impl RequestForwarder {
|
||||
// 首次触发:整流请求体
|
||||
let rectified = rectify_anthropic_request(&mut body);
|
||||
|
||||
// 整流未生效:直接返回错误(不可重试客户端错误)
|
||||
// 整流未生效:继续尝试 budget 整流路径,避免误判后短路
|
||||
if !rectified.applied {
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-006] 整流器触发但无可整流内容,不做无意义重试"
|
||||
"[{app_type_str}] [RECT-006] thinking 签名整流器触发但无可整流内容,继续检查 budget;若 budget 也未命中则按客户端错误返回"
|
||||
);
|
||||
signature_rectifier_non_retryable_client_error = true;
|
||||
} else {
|
||||
log::info!(
|
||||
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
|
||||
app_type_str,
|
||||
rectified.removed_thinking_blocks,
|
||||
rectified.removed_redacted_thinking_blocks,
|
||||
rectified.removed_signature_fields
|
||||
);
|
||||
|
||||
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
|
||||
let _ = std::mem::replace(&mut rectifier_retried, true);
|
||||
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||
// 记录成功
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 更新当前应用类型使用的 provider
|
||||
{
|
||||
let mut current_providers =
|
||||
self.current_providers.write().await;
|
||||
current_providers.insert(
|
||||
app_type_str.to_string(),
|
||||
(provider.id.clone(), provider.name.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
// 更新成功统计
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.success_requests += 1;
|
||||
status.last_error = None;
|
||||
let should_switch =
|
||||
self.current_provider_id_at_start.as_str()
|
||||
!= provider.id.as_str();
|
||||
if should_switch {
|
||||
status.failover_count += 1;
|
||||
|
||||
// 异步触发供应商切换,更新 UI/托盘
|
||||
let fm = self.failover_manager.clone();
|
||||
let ah = self.app_handle.clone();
|
||||
let pid = provider.id.clone();
|
||||
let pname = provider.name.clone();
|
||||
let at = app_type_str.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = fm
|
||||
.try_switch(ah.as_ref(), &at, &pid, &pname)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests
|
||||
as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(ForwardResult {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
});
|
||||
}
|
||||
Err(retry_err) => {
|
||||
// 整流重试仍失败:区分错误类型决定是否记录熔断器
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
|
||||
);
|
||||
|
||||
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
|
||||
let is_provider_error = match &retry_err {
|
||||
ProxyError::Timeout(_)
|
||||
| ProxyError::ForwardFailed(_) => true,
|
||||
ProxyError::UpstreamError { status, .. } => {
|
||||
*status >= 500
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if is_provider_error {
|
||||
// Provider 问题:记录失败到熔断器
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
false,
|
||||
Some(retry_err.to_string()),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
// 客户端问题:仅释放 permit,不记录熔断器
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(retry_err.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: retry_err,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检测是否需要触发 budget 整流器(仅 Claude/ClaudeAuth 供应商)
|
||||
if is_anthropic_provider {
|
||||
let error_message = extract_error_message(&e);
|
||||
if should_rectify_thinking_budget(
|
||||
error_message.as_deref(),
|
||||
&self.rectifier_config,
|
||||
) {
|
||||
// 已经重试过:直接返回错误(不可重试客户端错误)
|
||||
if budget_rectifier_retried {
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-013] budget 整流器已触发过,不再重试"
|
||||
);
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(e.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: e,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
let budget_rectified = rectify_thinking_budget(&mut body);
|
||||
if !budget_rectified.applied {
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-014] budget 整流器触发但无可整流内容,不做无意义重试"
|
||||
);
|
||||
// 释放 HalfOpen permit(不记录熔断器,这是客户端兼容性问题)
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
@@ -321,15 +499,13 @@ impl RequestForwarder {
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[{}] [RECT-001] thinking 签名整流器触发, 移除 {} thinking blocks, {} redacted_thinking blocks, {} signature fields",
|
||||
"[{}] [RECT-010] thinking budget 整流器触发, before={:?}, after={:?}",
|
||||
app_type_str,
|
||||
rectified.removed_thinking_blocks,
|
||||
rectified.removed_redacted_thinking_blocks,
|
||||
rectified.removed_signature_fields
|
||||
budget_rectified.before,
|
||||
budget_rectified.after
|
||||
);
|
||||
|
||||
// 标记已重试(当前逻辑下重试后必定 return,保留标记以备将来扩展)
|
||||
let _ = std::mem::replace(&mut rectifier_retried, true);
|
||||
let _ = std::mem::replace(&mut budget_rectifier_retried, true);
|
||||
|
||||
// 使用同一供应商重试(不计入熔断器)
|
||||
match self
|
||||
@@ -337,8 +513,7 @@ impl RequestForwarder {
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||
// 记录成功
|
||||
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
@@ -350,7 +525,6 @@ impl RequestForwarder {
|
||||
)
|
||||
.await;
|
||||
|
||||
// 更新当前应用类型使用的 provider
|
||||
{
|
||||
let mut current_providers =
|
||||
self.current_providers.write().await;
|
||||
@@ -360,7 +534,6 @@ impl RequestForwarder {
|
||||
);
|
||||
}
|
||||
|
||||
// 更新成功统计
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
status.success_requests += 1;
|
||||
@@ -370,14 +543,11 @@ impl RequestForwarder {
|
||||
!= provider.id.as_str();
|
||||
if should_switch {
|
||||
status.failover_count += 1;
|
||||
|
||||
// 异步触发供应商切换,更新 UI/托盘
|
||||
let fm = self.failover_manager.clone();
|
||||
let ah = self.app_handle.clone();
|
||||
let pid = provider.id.clone();
|
||||
let pname = provider.name.clone();
|
||||
let at = app_type_str.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = fm
|
||||
.try_switch(ah.as_ref(), &at, &pid, &pname)
|
||||
@@ -397,12 +567,10 @@ impl RequestForwarder {
|
||||
});
|
||||
}
|
||||
Err(retry_err) => {
|
||||
// 整流重试仍失败:区分错误类型决定是否记录熔断器
|
||||
log::warn!(
|
||||
"[{app_type_str}] [RECT-003] 整流重试仍失败: {retry_err}"
|
||||
"[{app_type_str}] [RECT-012] budget 整流重试仍失败: {retry_err}"
|
||||
);
|
||||
|
||||
// 区分错误类型:Provider 问题记录失败,客户端问题仅释放 permit
|
||||
let is_provider_error = match &retry_err {
|
||||
ProxyError::Timeout(_) | ProxyError::ForwardFailed(_) => {
|
||||
true
|
||||
@@ -412,7 +580,6 @@ impl RequestForwarder {
|
||||
};
|
||||
|
||||
if is_provider_error {
|
||||
// Provider 问题:记录失败到熔断器
|
||||
let _ = self
|
||||
.router
|
||||
.record_result(
|
||||
@@ -424,7 +591,6 @@ impl RequestForwarder {
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
// 客户端问题:仅释放 permit,不记录熔断器
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
@@ -451,6 +617,28 @@ impl RequestForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
if signature_rectifier_non_retryable_client_error {
|
||||
self.router
|
||||
.release_permit_neutral(
|
||||
&provider.id,
|
||||
app_type_str,
|
||||
used_half_open_permit,
|
||||
)
|
||||
.await;
|
||||
let mut status = self.status.write().await;
|
||||
status.failed_requests += 1;
|
||||
status.last_error = Some(e.to_string());
|
||||
if status.total_requests > 0 {
|
||||
status.success_rate = (status.success_requests as f32
|
||||
/ status.total_requests as f32)
|
||||
* 100.0;
|
||||
}
|
||||
return Err(ForwardError {
|
||||
error: e,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
// 失败:记录失败并更新熔断器
|
||||
let _ = self
|
||||
.router
|
||||
@@ -575,6 +763,9 @@ impl RequestForwarder {
|
||||
let (mapped_body, _original_model, _mapped_model) =
|
||||
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||
|
||||
// 与 CCH 对齐:请求前不做 thinking 主动改写(仅保留兼容入口)
|
||||
let mapped_body = normalize_thinking_type(mapped_body);
|
||||
|
||||
// 转换请求体(如果需要)
|
||||
let request_body = if needs_transform {
|
||||
adapter.transform_request(mapped_body, provider)?
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod response_handler;
|
||||
pub mod response_processor;
|
||||
pub(crate) mod server;
|
||||
pub mod session;
|
||||
pub mod thinking_budget_rectifier;
|
||||
pub mod thinking_rectifier;
|
||||
pub(crate) mod types;
|
||||
pub mod usage;
|
||||
|
||||
@@ -97,11 +97,21 @@ impl ModelMapping {
|
||||
|
||||
/// 检测请求是否启用了 thinking 模式
|
||||
pub fn has_thinking_enabled(body: &Value) -> bool {
|
||||
body.get("thinking")
|
||||
match body
|
||||
.get("thinking")
|
||||
.and_then(|v| v.as_object())
|
||||
.and_then(|o| o.get("type"))
|
||||
.and_then(|t| t.as_str())
|
||||
== Some("enabled")
|
||||
{
|
||||
Some("enabled") | Some("adaptive") => true,
|
||||
Some("disabled") | None => false,
|
||||
Some(other) => {
|
||||
log::warn!(
|
||||
"[ModelMapper] 未知 thinking.type='{other}',按 disabled 处理以避免误路由 reasoning 模型"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 对请求体应用模型映射
|
||||
@@ -300,6 +310,30 @@ mod tests {
|
||||
assert!(mapped.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_adaptive() {
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"thinking": {"type": "adaptive"}
|
||||
});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "reasoning-model");
|
||||
assert_eq!(mapped, Some("reasoning-model".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_unknown_type() {
|
||||
let provider = create_provider_with_mapping();
|
||||
let body = json!({
|
||||
"model": "claude-sonnet-4-5",
|
||||
"thinking": {"type": "some_future_type"}
|
||||
});
|
||||
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||
assert_eq!(result["model"], "sonnet-mapped");
|
||||
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_insensitive() {
|
||||
let provider = create_provider_with_mapping();
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
//! Thinking Budget 整流器
|
||||
//!
|
||||
//! 用于自动修复 Anthropic API 中因 thinking budget 约束导致的请求错误。
|
||||
//! 当上游 API 返回 budget_tokens 相关错误时,系统会自动调整 budget 参数并重试。
|
||||
|
||||
use super::types::RectifierConfig;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 最大 thinking budget tokens
|
||||
const MAX_THINKING_BUDGET: u64 = 32000;
|
||||
|
||||
/// 最大 max_tokens 值
|
||||
const MAX_TOKENS_VALUE: u64 = 64000;
|
||||
|
||||
/// max_tokens 必须大于 budget_tokens
|
||||
const MIN_MAX_TOKENS_FOR_BUDGET: u64 = MAX_THINKING_BUDGET + 1;
|
||||
|
||||
/// Budget 整流结果
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct BudgetRectifySnapshot {
|
||||
/// max_tokens
|
||||
pub max_tokens: Option<u64>,
|
||||
/// thinking.type
|
||||
pub thinking_type: Option<String>,
|
||||
/// thinking.budget_tokens
|
||||
pub thinking_budget_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
/// Budget 整流结果
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct BudgetRectifyResult {
|
||||
/// 是否应用了整流
|
||||
pub applied: bool,
|
||||
/// 整流前快照
|
||||
pub before: BudgetRectifySnapshot,
|
||||
/// 整流后快照
|
||||
pub after: BudgetRectifySnapshot,
|
||||
}
|
||||
|
||||
/// 检测是否需要触发 thinking budget 整流器
|
||||
///
|
||||
/// 检测条件:error message 同时包含 `budget_tokens` + `thinking` 相关约束
|
||||
pub fn should_rectify_thinking_budget(
|
||||
error_message: Option<&str>,
|
||||
config: &RectifierConfig,
|
||||
) -> bool {
|
||||
// 检查总开关
|
||||
if !config.enabled {
|
||||
return false;
|
||||
}
|
||||
// 检查子开关
|
||||
if !config.request_thinking_budget {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(msg) = error_message else {
|
||||
return false;
|
||||
};
|
||||
let lower = msg.to_lowercase();
|
||||
|
||||
// 与 CCH 对齐:仅在包含 budget_tokens + thinking + 1024 约束时触发
|
||||
let has_budget_tokens_reference =
|
||||
lower.contains("budget_tokens") || lower.contains("budget tokens");
|
||||
let has_thinking_reference = lower.contains("thinking");
|
||||
let has_1024_constraint = lower.contains("greater than or equal to 1024")
|
||||
|| lower.contains(">= 1024")
|
||||
|| (lower.contains("1024") && lower.contains("input should be"));
|
||||
if has_budget_tokens_reference && has_thinking_reference && has_1024_constraint {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 对请求体执行 budget 整流
|
||||
///
|
||||
/// 整流动作:
|
||||
/// - `thinking.type = "enabled"`
|
||||
/// - `thinking.budget_tokens = 32000`
|
||||
/// - 如果 `max_tokens < 32001`,设为 `64000`
|
||||
pub fn rectify_thinking_budget(body: &mut Value) -> BudgetRectifyResult {
|
||||
let before = snapshot_budget(body);
|
||||
|
||||
// 与 CCH 对齐:adaptive 请求不改写
|
||||
if before.thinking_type.as_deref() == Some("adaptive") {
|
||||
return BudgetRectifyResult {
|
||||
applied: false,
|
||||
before: before.clone(),
|
||||
after: before,
|
||||
};
|
||||
}
|
||||
|
||||
// 与 CCH 对齐:缺少/非法 thinking 时自动创建后再整流
|
||||
if !body.get("thinking").is_some_and(Value::is_object) {
|
||||
body["thinking"] = Value::Object(serde_json::Map::new());
|
||||
}
|
||||
|
||||
let Some(thinking) = body.get_mut("thinking").and_then(|t| t.as_object_mut()) else {
|
||||
return BudgetRectifyResult {
|
||||
applied: false,
|
||||
before: before.clone(),
|
||||
after: before,
|
||||
};
|
||||
};
|
||||
|
||||
thinking.insert("type".to_string(), Value::String("enabled".to_string()));
|
||||
thinking.insert(
|
||||
"budget_tokens".to_string(),
|
||||
Value::Number(MAX_THINKING_BUDGET.into()),
|
||||
);
|
||||
|
||||
if before.max_tokens.is_none() || before.max_tokens < Some(MIN_MAX_TOKENS_FOR_BUDGET) {
|
||||
body["max_tokens"] = Value::Number(MAX_TOKENS_VALUE.into());
|
||||
}
|
||||
|
||||
let after = snapshot_budget(body);
|
||||
BudgetRectifyResult {
|
||||
applied: before != after,
|
||||
before,
|
||||
after,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_budget(body: &Value) -> BudgetRectifySnapshot {
|
||||
let max_tokens = body.get("max_tokens").and_then(|v| v.as_u64());
|
||||
let thinking = body.get("thinking").and_then(|t| t.as_object());
|
||||
let thinking_type = thinking
|
||||
.and_then(|t| t.get("type"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToString::to_string);
|
||||
let thinking_budget_tokens = thinking
|
||||
.and_then(|t| t.get("budget_tokens"))
|
||||
.and_then(|v| v.as_u64());
|
||||
BudgetRectifySnapshot {
|
||||
max_tokens,
|
||||
thinking_type,
|
||||
thinking_budget_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn enabled_config() -> RectifierConfig {
|
||||
RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn budget_disabled_config() -> RectifierConfig {
|
||||
RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn master_disabled_config() -> RectifierConfig {
|
||||
RectifierConfig {
|
||||
enabled: false,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== should_rectify_thinking_budget 测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_detect_budget_tokens_thinking_error() {
|
||||
assert!(should_rectify_thinking_budget(
|
||||
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_budget_tokens_max_tokens_error() {
|
||||
assert!(!should_rectify_thinking_budget(
|
||||
Some("budget_tokens must be less than max_tokens"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_budget_tokens_1024_error() {
|
||||
assert!(!should_rectify_thinking_budget(
|
||||
Some("budget_tokens: value must be at least 1024"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_budget_tokens_with_thinking_and_1024_error() {
|
||||
assert!(should_rectify_thinking_budget(
|
||||
Some("thinking budget_tokens must be >= 1024"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_trigger_for_unrelated_error() {
|
||||
assert!(!should_rectify_thinking_budget(
|
||||
Some("Request timeout"),
|
||||
&enabled_config()
|
||||
));
|
||||
assert!(!should_rectify_thinking_budget(None, &enabled_config()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_budget_config() {
|
||||
assert!(!should_rectify_thinking_budget(
|
||||
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
|
||||
&budget_disabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_master_disabled() {
|
||||
assert!(!should_rectify_thinking_budget(
|
||||
Some("thinking.budget_tokens: Input should be greater than or equal to 1024"),
|
||||
&master_disabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== rectify_thinking_budget 测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_basic() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 512 },
|
||||
"max_tokens": 1024
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.before.thinking_type.as_deref(), Some("enabled"));
|
||||
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
|
||||
assert_eq!(result.before.thinking_budget_tokens, Some(512));
|
||||
assert_eq!(
|
||||
result.after.thinking_budget_tokens,
|
||||
Some(MAX_THINKING_BUDGET)
|
||||
);
|
||||
assert_eq!(result.before.max_tokens, Some(1024));
|
||||
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
|
||||
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_skips_adaptive() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive", "budget_tokens": 512 },
|
||||
"max_tokens": 1024
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(result.before, result.after);
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 512);
|
||||
assert_eq!(body["max_tokens"], 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_preserves_large_max_tokens() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 512 },
|
||||
"max_tokens": 100000
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.before.max_tokens, Some(100000));
|
||||
assert_eq!(result.after.max_tokens, Some(100000));
|
||||
assert_eq!(body["max_tokens"], 100000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_creates_thinking_object_when_missing() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"max_tokens": 1024
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.before.thinking_type, None);
|
||||
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
|
||||
assert_eq!(
|
||||
result.after.thinking_budget_tokens,
|
||||
Some(MAX_THINKING_BUDGET)
|
||||
);
|
||||
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
|
||||
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_no_max_tokens() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 512 }
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.before.max_tokens, None);
|
||||
assert_eq!(result.after.max_tokens, Some(MAX_TOKENS_VALUE));
|
||||
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_normalizes_non_enabled_type() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "disabled", "budget_tokens": 512 },
|
||||
"max_tokens": 1024
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.before.thinking_type.as_deref(), Some("disabled"));
|
||||
assert_eq!(result.after.thinking_type.as_deref(), Some("enabled"));
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], MAX_THINKING_BUDGET);
|
||||
assert_eq!(body["max_tokens"], MAX_TOKENS_VALUE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_budget_no_change_when_already_valid() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 32000 },
|
||||
"max_tokens": 64001
|
||||
});
|
||||
|
||||
let result = rectify_thinking_budget(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(result.before, result.after);
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 32000);
|
||||
assert_eq!(body["max_tokens"], 64001);
|
||||
}
|
||||
}
|
||||
@@ -59,10 +59,12 @@ pub fn should_rectify_thinking_signature(
|
||||
}
|
||||
|
||||
// 场景3: expected thinking or redacted_thinking, found tool_use
|
||||
// 与 CCH 对齐:要求明确包含 tool_use,避免过宽匹配。
|
||||
// 错误示例: "Expected `thinking` or `redacted_thinking`, but found `tool_use`"
|
||||
if lower.contains("expected")
|
||||
&& (lower.contains("thinking") || lower.contains("redacted_thinking"))
|
||||
&& lower.contains("found")
|
||||
&& lower.contains("tool_use")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -73,6 +75,28 @@ pub fn should_rectify_thinking_signature(
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景5: signature 字段不被接受(第三方渠道)
|
||||
// 错误示例: "xxx.signature: Extra inputs are not permitted"
|
||||
if lower.contains("signature") && lower.contains("extra inputs are not permitted") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景6: thinking/redacted_thinking 块被修改
|
||||
// 错误示例: "thinking or redacted_thinking blocks ... cannot be modified"
|
||||
if (lower.contains("thinking") || lower.contains("redacted_thinking"))
|
||||
&& lower.contains("cannot be modified")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 场景7: 非法请求(与 CCH 对齐,按 invalid request 统一兜底)
|
||||
if lower.contains("非法请求")
|
||||
|| lower.contains("illegal request")
|
||||
|| lower.contains("invalid request")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
@@ -159,11 +183,13 @@ pub fn rectify_anthropic_request(body: &mut Value) -> RectifyResult {
|
||||
/// 判断是否需要删除顶层 thinking 字段
|
||||
fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
|
||||
// 检查 thinking 是否启用
|
||||
let thinking_enabled = body
|
||||
let thinking_type = body
|
||||
.get("thinking")
|
||||
.and_then(|t| t.get("type"))
|
||||
.and_then(|t| t.as_str())
|
||||
== Some("enabled");
|
||||
.and_then(|t| t.as_str());
|
||||
|
||||
// 与 CCH 对齐:仅 type=enabled 视为开启
|
||||
let thinking_enabled = thinking_type == Some("enabled");
|
||||
|
||||
if !thinking_enabled {
|
||||
return false;
|
||||
@@ -202,6 +228,11 @@ fn should_remove_top_level_thinking(body: &Value, messages: &[Value]) -> bool {
|
||||
.any(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
|
||||
}
|
||||
|
||||
/// 与 CCH 对齐:请求前不做 thinking type 主动改写。
|
||||
pub fn normalize_thinking_type(body: Value) -> Value {
|
||||
body
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -211,6 +242,7 @@ mod tests {
|
||||
RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +250,7 @@ mod tests {
|
||||
RectifierConfig {
|
||||
enabled: true,
|
||||
request_thinking_signature: false,
|
||||
request_thinking_budget: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +258,7 @@ mod tests {
|
||||
RectifierConfig {
|
||||
enabled: false,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +298,14 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_detect_thinking_expected_without_tool_use() {
|
||||
assert!(!should_rectify_thinking_signature(
|
||||
Some("messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `text`."),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_must_start_with_thinking() {
|
||||
assert!(should_rectify_thinking_signature(
|
||||
@@ -418,4 +460,230 @@ mod tests {
|
||||
// 此时会触发删除顶层 thinking 的逻辑
|
||||
// 这是预期行为:整流后如果仍然不符合要求,就删除顶层 thinking
|
||||
}
|
||||
|
||||
// ==================== 新增错误场景检测测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_detect_signature_extra_inputs() {
|
||||
// 场景5: signature 字段不被接受
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("xxx.signature: Extra inputs are not permitted"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_thinking_cannot_be_modified() {
|
||||
// 场景6: thinking blocks cannot be modified
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("thinking or redacted_thinking blocks in the response cannot be modified"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_invalid_request() {
|
||||
// 场景7: 非法请求(与 CCH 对齐,统一触发)
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("非法请求:thinking signature 不合法"),
|
||||
&enabled_config()
|
||||
));
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("illegal request: tool_use block mismatch"),
|
||||
&enabled_config()
|
||||
));
|
||||
assert!(should_rectify_thinking_signature(
|
||||
Some("invalid request: malformed JSON"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_do_not_detect_thinking_type_tag_mismatch() {
|
||||
// 与 CCH 对齐:adaptive tag mismatch 不触发签名整流器
|
||||
assert!(!should_rectify_thinking_signature(
|
||||
Some("Input tag 'adaptive' found using 'type' does not match expected tags"),
|
||||
&enabled_config()
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== adaptive thinking type 测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_rectify_keeps_adaptive_when_no_legacy_blocks() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive" },
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{ "type": "text", "text": "hello" }]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
assert!(body["thinking"].get("budget_tokens").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_adaptive_preserves_existing_budget_tokens() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive", "budget_tokens": 5000 },
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{ "type": "text", "text": "hello" }]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_does_not_change_enabled_type() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 1024 },
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{ "type": "text", "text": "hello" }]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_removes_top_level_thinking_adaptive() {
|
||||
// 顶层 thinking 仅在 type=enabled 且 tool_use 场景才会删除,adaptive 不删除
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive" },
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "tool_use", "id": "toolu_1", "name": "WebSearch", "input": {} }
|
||||
]
|
||||
}, {
|
||||
"role": "user",
|
||||
"content": [{ "type": "tool_result", "tool_use_id": "toolu_1", "content": "ok" }]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(!result.applied);
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectify_adaptive_still_cleans_legacy_signature_blocks() {
|
||||
let mut body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive" },
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "thinking", "thinking": "t", "signature": "sig_thinking" },
|
||||
{ "type": "text", "text": "hello", "signature": "sig_text" }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
let result = rectify_anthropic_request(&mut body);
|
||||
|
||||
assert!(result.applied);
|
||||
assert_eq!(result.removed_thinking_blocks, 1);
|
||||
let content = body["messages"][0]["content"].as_array().unwrap();
|
||||
assert_eq!(content.len(), 1);
|
||||
assert_eq!(content[0]["type"], "text");
|
||||
assert!(content[0].get("signature").is_none());
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
}
|
||||
|
||||
// ==================== normalize_thinking_type 测试 ====================
|
||||
|
||||
#[test]
|
||||
fn test_normalize_thinking_type_adaptive_unchanged() {
|
||||
let body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive" }
|
||||
});
|
||||
|
||||
let result = normalize_thinking_type(body);
|
||||
|
||||
assert_eq!(result["thinking"]["type"], "adaptive");
|
||||
assert!(result["thinking"].get("budget_tokens").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_thinking_type_enabled_unchanged() {
|
||||
let body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "enabled", "budget_tokens": 2048 }
|
||||
});
|
||||
|
||||
let result = normalize_thinking_type(body);
|
||||
|
||||
assert_eq!(result["thinking"]["type"], "enabled");
|
||||
assert_eq!(result["thinking"]["budget_tokens"], 2048);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_thinking_type_disabled_unchanged() {
|
||||
let body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "disabled" }
|
||||
});
|
||||
|
||||
let result = normalize_thinking_type(body);
|
||||
|
||||
assert_eq!(result["thinking"]["type"], "disabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_thinking_type_preserves_budget() {
|
||||
let body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "adaptive", "budget_tokens": 5000 }
|
||||
});
|
||||
|
||||
let result = normalize_thinking_type(body);
|
||||
|
||||
assert_eq!(result["thinking"]["type"], "adaptive");
|
||||
assert_eq!(result["thinking"]["budget_tokens"], 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_thinking_type_no_thinking() {
|
||||
let body = json!({
|
||||
"model": "claude-test"
|
||||
});
|
||||
|
||||
let result = normalize_thinking_type(body);
|
||||
|
||||
assert!(result.get("thinking").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_thinking_type_unknown_unchanged() {
|
||||
let body = json!({
|
||||
"model": "claude-test",
|
||||
"thinking": { "type": "unexpected", "budget_tokens": 100 }
|
||||
});
|
||||
|
||||
let result = normalize_thinking_type(body);
|
||||
|
||||
assert_eq!(result["thinking"]["type"], "unexpected");
|
||||
assert_eq!(result["thinking"]["budget_tokens"], 100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,17 +195,22 @@ pub struct AppProxyConfig {
|
||||
/// 整流器配置
|
||||
///
|
||||
/// 存储在 settings 表中
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RectifierConfig {
|
||||
/// 总开关:是否启用整流器
|
||||
#[serde(default)]
|
||||
/// 总开关:是否启用整流器(默认开启)
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// 请求整流:启用 thinking 签名整流器
|
||||
/// 请求整流:启用 thinking 签名整流器(默认开启)
|
||||
///
|
||||
/// 处理错误:Invalid 'signature' in 'thinking' block
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_true")]
|
||||
pub request_thinking_signature: bool,
|
||||
/// 请求整流:启用 thinking budget 整流器(默认开启)
|
||||
///
|
||||
/// 处理错误:budget_tokens + thinking 相关约束
|
||||
#[serde(default = "default_true")]
|
||||
pub request_thinking_budget: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -216,6 +221,16 @@ fn default_log_level() -> String {
|
||||
"info".to_string()
|
||||
}
|
||||
|
||||
impl Default for RectifierConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
request_thinking_signature: true,
|
||||
request_thinking_budget: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志配置
|
||||
///
|
||||
/// 存储在 settings 表的 log_config 字段中(JSON 格式)
|
||||
@@ -261,32 +276,49 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_default_disabled() {
|
||||
// 验证 RectifierConfig::default() 返回全禁用状态
|
||||
fn test_rectifier_config_default_enabled() {
|
||||
// 验证 RectifierConfig::default() 返回全开启状态
|
||||
let config = RectifierConfig::default();
|
||||
assert!(!config.enabled, "整流器总开关默认应为 false");
|
||||
assert!(config.enabled, "整流器总开关默认应为 true");
|
||||
assert!(
|
||||
!config.request_thinking_signature,
|
||||
"thinking 签名整流器默认应为 false"
|
||||
config.request_thinking_signature,
|
||||
"thinking 签名整流器默认应为 true"
|
||||
);
|
||||
assert!(
|
||||
config.request_thinking_budget,
|
||||
"thinking budget 整流器默认应为 true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_default() {
|
||||
// 验证反序列化缺字段时使用默认值 false
|
||||
// 验证反序列化缺字段时使用默认值 true
|
||||
let json = "{}";
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(!config.enabled);
|
||||
assert!(!config.request_thinking_signature);
|
||||
assert!(config.enabled);
|
||||
assert!(config.request_thinking_signature);
|
||||
assert!(config.request_thinking_budget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_explicit_true() {
|
||||
// 验证显式设置 true 时正确反序列化
|
||||
let json = r#"{"enabled": true, "requestThinkingSignature": true}"#;
|
||||
let json =
|
||||
r#"{"enabled": true, "requestThinkingSignature": true, "requestThinkingBudget": true}"#;
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(config.enabled);
|
||||
assert!(config.request_thinking_signature);
|
||||
assert!(config.request_thinking_budget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rectifier_config_serde_partial_fields() {
|
||||
// 验证只设置部分字段时,缺失字段使用默认值 true
|
||||
let json = r#"{"enabled": true, "requestThinkingSignature": false}"#;
|
||||
let config: RectifierConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(config.enabled);
|
||||
assert!(!config.request_thinking_signature);
|
||||
assert!(config.request_thinking_budget);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+104
-29
@@ -174,6 +174,29 @@ impl SkillService {
|
||||
Self
|
||||
}
|
||||
|
||||
/// 构建 Skill 文档 URL(指向仓库中的 SKILL.md 文件)
|
||||
fn build_skill_doc_url(owner: &str, repo: &str, branch: &str, doc_path: &str) -> String {
|
||||
format!("https://github.com/{owner}/{repo}/blob/{branch}/{doc_path}")
|
||||
}
|
||||
|
||||
/// 从旧 readme_url 中提取仓库内文档路径,兼容 `blob`/`tree` 两种格式
|
||||
fn extract_doc_path_from_url(url: &str) -> Option<String> {
|
||||
let marker = if url.contains("/blob/") {
|
||||
"/blob/"
|
||||
} else if url.contains("/tree/") {
|
||||
"/tree/"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let (_, tail) = url.split_once(marker)?;
|
||||
let (_, path) = tail.split_once('/')?;
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(path.to_string())
|
||||
}
|
||||
|
||||
// ========== 路径管理 ==========
|
||||
|
||||
/// 获取 SSOT 目录(~/.cc-switch/skills/)
|
||||
@@ -298,6 +321,8 @@ impl SkillService {
|
||||
|
||||
let dest = ssot_dir.join(&install_name);
|
||||
|
||||
let mut repo_branch = skill.repo_branch.clone();
|
||||
|
||||
// 如果已存在则跳过下载
|
||||
if !dest.exists() {
|
||||
let repo = SkillRepo {
|
||||
@@ -308,7 +333,7 @@ impl SkillService {
|
||||
};
|
||||
|
||||
// 下载仓库
|
||||
let temp_dir = timeout(
|
||||
let (temp_dir, used_branch) = timeout(
|
||||
std::time::Duration::from_secs(60),
|
||||
self.download_repo(&repo),
|
||||
)
|
||||
@@ -324,6 +349,7 @@ impl SkillService {
|
||||
Some("checkNetwork"),
|
||||
))
|
||||
})??;
|
||||
repo_branch = used_branch;
|
||||
|
||||
// 复制到 SSOT
|
||||
let source = temp_dir.join(&skill.directory);
|
||||
@@ -338,8 +364,39 @@ impl SkillService {
|
||||
|
||||
Self::copy_dir_recursive(&source, &dest)?;
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
// 使用实际下载成功的分支,避免 readme_url / repo_branch 与真实分支不一致。
|
||||
if repo_branch != skill.repo_branch {
|
||||
log::info!(
|
||||
"Skill {}/{} 分支自动回退: {} -> {}",
|
||||
skill.repo_owner,
|
||||
skill.repo_name,
|
||||
skill.repo_branch,
|
||||
repo_branch
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let doc_path = skill
|
||||
.readme_url
|
||||
.as_deref()
|
||||
.and_then(Self::extract_doc_path_from_url)
|
||||
.map(|path| {
|
||||
if path.ends_with("/SKILL.md") || path == "SKILL.md" {
|
||||
path
|
||||
} else {
|
||||
format!("{}/SKILL.md", path.trim_end_matches('/'))
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| format!("{}/SKILL.md", skill.directory.trim_end_matches('/')));
|
||||
|
||||
let readme_url = Some(Self::build_skill_doc_url(
|
||||
&skill.repo_owner,
|
||||
&skill.repo_name,
|
||||
&repo_branch,
|
||||
&doc_path,
|
||||
));
|
||||
|
||||
// 创建 InstalledSkill 记录
|
||||
let installed_skill = InstalledSkill {
|
||||
id: skill.key.clone(),
|
||||
@@ -352,8 +409,8 @@ impl SkillService {
|
||||
directory: install_name.clone(),
|
||||
repo_owner: Some(skill.repo_owner.clone()),
|
||||
repo_name: Some(skill.repo_name.clone()),
|
||||
repo_branch: Some(skill.repo_branch.clone()),
|
||||
readme_url: skill.readme_url.clone(),
|
||||
repo_branch: Some(repo_branch),
|
||||
readme_url,
|
||||
apps: SkillApps::only(current_app),
|
||||
installed_at: chrono::Utc::now().timestamp(),
|
||||
};
|
||||
@@ -862,24 +919,26 @@ impl SkillService {
|
||||
|
||||
/// 从仓库获取技能列表
|
||||
async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result<Vec<DiscoverableSkill>> {
|
||||
let temp_dir = timeout(std::time::Duration::from_secs(60), self.download_repo(repo))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow!(format_skill_error(
|
||||
"DOWNLOAD_TIMEOUT",
|
||||
&[
|
||||
("owner", &repo.owner),
|
||||
("name", &repo.name),
|
||||
("timeout", "60")
|
||||
],
|
||||
Some("checkNetwork"),
|
||||
))
|
||||
})??;
|
||||
let (temp_dir, resolved_branch) =
|
||||
timeout(std::time::Duration::from_secs(60), self.download_repo(repo))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow!(format_skill_error(
|
||||
"DOWNLOAD_TIMEOUT",
|
||||
&[
|
||||
("owner", &repo.owner),
|
||||
("name", &repo.name),
|
||||
("timeout", "60")
|
||||
],
|
||||
Some("checkNetwork"),
|
||||
))
|
||||
})??;
|
||||
|
||||
let mut skills = Vec::new();
|
||||
let scan_dir = temp_dir.clone();
|
||||
|
||||
self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?;
|
||||
let mut resolved_repo = repo.clone();
|
||||
resolved_repo.branch = resolved_branch;
|
||||
self.scan_dir_recursive(&scan_dir, &scan_dir, &resolved_repo, &mut skills)?;
|
||||
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
@@ -907,7 +966,15 @@ impl SkillService {
|
||||
.to_string()
|
||||
};
|
||||
|
||||
if let Ok(skill) = self.build_skill_from_metadata(&skill_md, &directory, repo) {
|
||||
let doc_path = skill_md
|
||||
.strip_prefix(base_dir)
|
||||
.unwrap_or(skill_md.as_path())
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
|
||||
if let Ok(skill) =
|
||||
self.build_skill_from_metadata(&skill_md, &directory, &doc_path, repo)
|
||||
{
|
||||
skills.push(skill);
|
||||
}
|
||||
|
||||
@@ -931,6 +998,7 @@ impl SkillService {
|
||||
&self,
|
||||
skill_md: &Path,
|
||||
directory: &str,
|
||||
doc_path: &str,
|
||||
repo: &SkillRepo,
|
||||
) -> Result<DiscoverableSkill> {
|
||||
let meta = self.parse_skill_metadata(skill_md)?;
|
||||
@@ -940,9 +1008,11 @@ impl SkillService {
|
||||
name: meta.name.unwrap_or_else(|| directory.to_string()),
|
||||
description: meta.description.unwrap_or_default(),
|
||||
directory: directory.to_string(),
|
||||
readme_url: Some(format!(
|
||||
"https://github.com/{}/{}/tree/{}/{}",
|
||||
repo.owner, repo.name, repo.branch, directory
|
||||
readme_url: Some(Self::build_skill_doc_url(
|
||||
&repo.owner,
|
||||
&repo.name,
|
||||
&repo.branch,
|
||||
doc_path,
|
||||
)),
|
||||
repo_owner: repo.owner.clone(),
|
||||
repo_name: repo.name.clone(),
|
||||
@@ -994,16 +1064,21 @@ impl SkillService {
|
||||
}
|
||||
|
||||
/// 下载仓库
|
||||
async fn download_repo(&self, repo: &SkillRepo) -> Result<PathBuf> {
|
||||
async fn download_repo(&self, repo: &SkillRepo) -> Result<(PathBuf, String)> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let temp_path = temp_dir.path().to_path_buf();
|
||||
let _ = temp_dir.keep();
|
||||
|
||||
let branches = if repo.branch.is_empty() {
|
||||
vec!["main", "master"]
|
||||
} else {
|
||||
vec![repo.branch.as_str(), "main", "master"]
|
||||
};
|
||||
let mut branches = Vec::new();
|
||||
if !repo.branch.is_empty() {
|
||||
branches.push(repo.branch.as_str());
|
||||
}
|
||||
if !branches.contains(&"main") {
|
||||
branches.push("main");
|
||||
}
|
||||
if !branches.contains(&"master") {
|
||||
branches.push("master");
|
||||
}
|
||||
|
||||
let mut last_error = None;
|
||||
for branch in branches {
|
||||
@@ -1014,7 +1089,7 @@ impl SkillService {
|
||||
|
||||
match self.download_and_extract(&url, &temp_path).await {
|
||||
Ok(_) => {
|
||||
return Ok(temp_path);
|
||||
return Ok((temp_path, branch.to_string()));
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(e);
|
||||
|
||||
+20
-2
@@ -139,6 +139,17 @@ function App() {
|
||||
}
|
||||
}, [visibleApps, activeApp]);
|
||||
|
||||
// Fallback from sessions view when switching to an app without session support
|
||||
useEffect(() => {
|
||||
if (
|
||||
currentView === "sessions" &&
|
||||
activeApp !== "claude" &&
|
||||
activeApp !== "codex"
|
||||
) {
|
||||
setCurrentView("providers");
|
||||
}
|
||||
}, [activeApp, currentView]);
|
||||
|
||||
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
|
||||
const [usageProvider, setUsageProvider] = useState<Provider | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<{
|
||||
@@ -177,6 +188,7 @@ function App() {
|
||||
const providers = useMemo(() => data?.providers ?? {}, [data]);
|
||||
const currentProviderId = data?.currentProviderId ?? "";
|
||||
const hasSkillsSupport = true;
|
||||
const hasSessionSupport = activeApp === "claude" || activeApp === "codex";
|
||||
|
||||
const {
|
||||
addProvider,
|
||||
@@ -958,10 +970,16 @@ function App() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("sessions")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5",
|
||||
"transition-all duration-200 ease-in-out overflow-hidden",
|
||||
hasSessionSupport
|
||||
? "opacity-100 w-8 scale-100 px-2"
|
||||
: "opacity-0 w-0 scale-75 pointer-events-none px-0 -ml-1",
|
||||
)}
|
||||
title={t("sessionManager.title")}
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
<History className="flex-shrink-0 w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -10,6 +10,7 @@ export function RectifierConfigPanel() {
|
||||
const [config, setConfig] = useState<RectifierConfig>({
|
||||
enabled: true,
|
||||
requestThinkingSignature: true,
|
||||
requestThinkingBudget: true,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -69,6 +70,21 @@ export function RectifierConfigPanel() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pl-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t("settings.advanced.rectifier.thinkingBudget")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.advanced.rectifier.thinkingBudgetDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.requestThinkingBudget}
|
||||
disabled={!config.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange({ requestThinkingBudget: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -214,7 +214,9 @@
|
||||
"requestGroup": "Request Rectification",
|
||||
"responseGroup": "Response Rectification",
|
||||
"thinkingSignature": "Thinking Signature Rectification",
|
||||
"thinkingSignatureDescription": "Automatically fix Claude API errors caused by thinking signature validation failures"
|
||||
"thinkingSignatureDescription": "When an Anthropic-type provider returns thinking signature incompatibility or illegal request errors, automatically removes incompatible thinking-related blocks and retries once with the same provider",
|
||||
"thinkingBudget": "Thinking Budget Rectification",
|
||||
"thinkingBudgetDescription": "When an Anthropic-type provider returns budget_tokens constraint errors (such as at least 1024), automatically normalizes thinking to enabled, sets thinking budget to 32000, and raises max_tokens to 64000 if needed, then retries once"
|
||||
},
|
||||
"logConfig": {
|
||||
"title": "Log Management",
|
||||
|
||||
@@ -214,7 +214,9 @@
|
||||
"requestGroup": "リクエスト整流",
|
||||
"responseGroup": "レスポンス整流",
|
||||
"thinkingSignature": "Thinking 署名整流",
|
||||
"thinkingSignatureDescription": "Claude API の thinking 署名検証エラーを自動修正"
|
||||
"thinkingSignatureDescription": "Anthropic タイプのプロバイダーが thinking 署名の非互換性や不正なリクエストエラーを返した場合、互換性のない thinking 関連ブロックを自動削除し、同じプロバイダーで 1 回リトライします",
|
||||
"thinkingBudget": "Thinking Budget 整流",
|
||||
"thinkingBudgetDescription": "Anthropic タイプのプロバイダーが budget_tokens 制約エラー(例: 1024 以上)を返した場合、thinking を enabled に正規化し、thinking 予算を 32000 に設定し、必要に応じて max_tokens を 64000 に引き上げて 1 回リトライします"
|
||||
},
|
||||
"logConfig": {
|
||||
"title": "ログ管理",
|
||||
|
||||
@@ -214,7 +214,9 @@
|
||||
"requestGroup": "请求整流",
|
||||
"responseGroup": "响应整流",
|
||||
"thinkingSignature": "Thinking 签名整流",
|
||||
"thinkingSignatureDescription": "自动修复 Claude API 中因 thinking 签名校验失败导致的请求错误"
|
||||
"thinkingSignatureDescription": "当 Anthropic 类型供应商返回 thinking 签名不兼容或非法请求等错误时,自动移除不兼容的 thinking 相关块并对同一供应商重试一次",
|
||||
"thinkingBudget": "Thinking Budget 整流",
|
||||
"thinkingBudgetDescription": "当 Anthropic 类型供应商返回 budget_tokens 约束错误(如至少 1024)时,自动将 thinking 规范为 enabled 并将 budget 设为 32000,同时在需要时将 max_tokens 设为 64000,然后重试一次"
|
||||
},
|
||||
"logConfig": {
|
||||
"title": "日志管理",
|
||||
|
||||
@@ -155,6 +155,7 @@ export const settingsApi = {
|
||||
export interface RectifierConfig {
|
||||
enabled: boolean;
|
||||
requestThinkingSignature: boolean;
|
||||
requestThinkingBudget: boolean;
|
||||
}
|
||||
|
||||
export interface LogConfig {
|
||||
|
||||
Reference in New Issue
Block a user