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,