From aeab1e3ff8576ab1f52777f67beb200823171b83 Mon Sep 17 00:00:00 2001 From: SsuJo_ <1049731887@qq.com> Date: Mon, 2 Nov 2026 13:19:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E9=9D=A2=E5=88=A4=E9=94=99=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=97=B6=E5=9B=9E=E8=90=BD=E5=88=B0=20HTTP=20=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZCode 活动套餐通道用 HTTP 200 + application/json 返回 `{"code":1005,"msg":"exceed quota limit"}`。网关把这类 2xx 交给插件判定, 但原实现一旦 RPC 失败就直接返回错误,整份请求中断;而默认实现又会把 200 当成成功流交给 Anthropic 翻译层,最终只剩 `response.failed`、 `output` 为空。 修复: - `ProviderAdapter` 为异步判错钩子补上说明,内置家仍转调同步版; - `RpcAdapter` 问插件失败时回落到 HTTP 状态码判定,避免控制面超时 误罚账号; - 补两条回归测试:200 + application/json 必须判错误,200 + text/event-stream 仍判成功。 验证: - `cargo check -p agent2api-server` 通过; - `cargo test -p agent2api-server plugin_host::tests` 13 passed。 注:上游 beta 的 `provider_loop/tests.rs` 与 `api/pipeline.rs` 测试代码 存在既有编译错误(`send_with_retry` 参数、`RequestQuery` 字段不齐), 本次仅本地临时补齐以运行测试,未纳入提交。 --- .../server/src/server/core/plugin_host/rpc.rs | 25 ++++-- .../src/server/core/plugin_host/tests.rs | 77 +++++++++++++++++++ .../src/server/core/providers/adapter.rs | 6 ++ 3 files changed, 101 insertions(+), 7 deletions(-) diff --git a/desktop-tauri/src-tauri/server/src/server/core/plugin_host/rpc.rs b/desktop-tauri/src-tauri/server/src/server/core/plugin_host/rpc.rs index 1cdcc766d..de90ab1a6 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/plugin_host/rpc.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/plugin_host/rpc.rs @@ -417,24 +417,35 @@ impl ProviderAdapter for RpcAdapter { }) } + /// 上游响应是否「其实是错误」(HTTP 200 但正文是业务失败)。 + /// + /// ── 为什么这一位必须问插件 ────────────────────────────────── + /// 这条判据是 provider 知识:ZCode 的活动套餐通道用 HTTP 200 + + /// `application/json` 返回 `{"code":1005,"msg":"exceed quota limit"}`, + /// 智谱清言用 200 + `-1001 剩余积分不足`。网关自己只看得到状态码与头, + /// 不知道哪家的 2xx 其实是失败。默认实现(`!(200..300).contains`)在 + /// 这里会把它们当成功,直接交给 SSE 翻译层 —— 结果是 `output` 为空的 + /// 一次「成功」响应,账号也不会进冷却。 + /// + /// ── 插件问不到时为什么回落到默认 ──────────────────────────── + /// RPC 失败说明插件挂了;此时按 HTTP 状态码判成功至少与内置家同一口径, + /// 不会因为一次控制面超时把所有 2xx 响应都当成错误而误罚账号。 fn is_error_response_async<'a>( &'a self, status: u16, headers: &'a HeaderMap, ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { - if !(200..300).contains(&status) { - return Ok(true); - } + let default = !(200..300).contains(&status); let mut params = self.base(); params.insert("status".into(), Value::from(status)); params.insert("headers".into(), headers_json(headers)); + // 控制面问不到时回落到 HTTP 状态码:一次 RPC 超时不该把所有 2xx + // 响应都判成错误,从而误罚账号。 let value = self.control( proto::methods::gateway::IS_ERROR_RESPONSE, Value::Object(params), - ).await?; - value.as_bool().ok_or_else(|| GatewayError::with_status( - 502, "插件返回的响应类型判定不是布尔值", - )) + ).await; + Ok(value.ok().and_then(|value| value.as_bool()).unwrap_or(default)) }) } diff --git a/desktop-tauri/src-tauri/server/src/server/core/plugin_host/tests.rs b/desktop-tauri/src-tauri/server/src/server/core/plugin_host/tests.rs index e9275b5e1..2916caea6 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/plugin_host/tests.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/plugin_host/tests.rs @@ -249,6 +249,83 @@ async fn classify_error_goes_through_rpc() { host.shutdown().await; } +#[tokio::test(flavor = "multi_thread")] +async fn is_error_response_goes_through_rpc() { + let Some((host, providers)) = boot().await else { + eprintln!("跳过:插件二进制不存在"); + return; + }; + let demo = providers.iter().find(|p| p.id.0 == "demo").expect("demo 存在"); + let adapter = RpcAdapter::new( + "demo".into(), + crate::server::core::providers::ProviderKind::WorkBuddy, + host.clone(), + demo.clone(), + ); + + // 普通 200 + SSE 是成功流,不能因为走了 RPC 就被误判成错误。 + let mut sse = axum::http::HeaderMap::new(); + sse.insert( + axum::http::header::CONTENT_TYPE, + "text/event-stream".parse().expect("合法头值"), + ); + assert!( + !adapter.is_error_response_async(200, &sse).await.expect("RPC 判定失败"), + "200 + text/event-stream 应当判成功" + ); + + // 非 2xx 无论插件怎么答都应当是错误(默认口径不能被 RPC 覆盖掉)。 + assert!( + adapter.is_error_response_async(429, &sse).await.expect("RPC 判定失败"), + "429 应当判错误" + ); + + host.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn plugin_200_json_is_an_error_response() { + let Some((host, providers)) = boot().await else { + eprintln!("跳过:插件二进制不存在"); + return; + }; + // 用 chatglm:插件侧对「200 + application/json」有明确判据(与 zcode + // 同一口径)。这条测试钉的是网关有没有把这一位问出去 —— 没接 RPC 时 + // 默认实现会把它当成功,正好复现 testwhy0out 的 0 输出。 + let chatglm = providers + .iter() + .find(|p| p.id.0 == "chatglm") + .expect("chatglm 存在"); + let adapter = RpcAdapter::new( + "chatglm".into(), + crate::server::core::providers::ProviderKind::WorkBuddy, + host.clone(), + chatglm.clone(), + ); + + let mut json = axum::http::HeaderMap::new(); + json.insert( + axum::http::header::CONTENT_TYPE, + "application/json; charset=utf-8".parse().expect("合法头值"), + ); + assert!( + adapter.is_error_response_async(200, &json).await.expect("RPC 判定失败"), + "200 + application/json 必须判成错误,否则业务错误会被当成空回答" + ); + + let mut sse = axum::http::HeaderMap::new(); + sse.insert( + axum::http::header::CONTENT_TYPE, + "text/event-stream".parse().expect("合法头值"), + ); + assert!( + !adapter.is_error_response_async(200, &sse).await.expect("RPC 判定失败"), + "200 + text/event-stream 仍然应当判成功" + ); + + host.shutdown().await; +} + #[tokio::test(flavor = "multi_thread")] async fn reasoning_patch_goes_through_rpc() { let Some((host, providers)) = boot().await else { diff --git a/desktop-tauri/src-tauri/server/src/server/core/providers/adapter.rs b/desktop-tauri/src-tauri/server/src/server/core/providers/adapter.rs index ba504b295..0644af052 100644 --- a/desktop-tauri/src-tauri/server/src/server/core/providers/adapter.rs +++ b/desktop-tauri/src-tauri/server/src/server/core/providers/adapter.rs @@ -634,6 +634,12 @@ pub trait ProviderAdapter: Send + Sync { !(200..300).contains(&status) } + /// [`Self::is_error_response`] 的异步形态。 + /// + /// ── 为什么这一位需要异步 ──────────────────────────────────── + /// 插件家的判据在子进程里(协议方法 `is_error_response`),而 trait 的 + /// 同步签名没法 await 一次 RPC;转发循环本身就在 async 上下文里,所以 + /// 把这一位做成异步钩子,默认转调同步版。内置家逐字不变。 fn is_error_response_async<'a>( &'a self, status: u16,