diff --git a/src-tauri/src/commands/stream_check.rs b/src-tauri/src/commands/stream_check.rs index be0de3f12..9fc605775 100644 --- a/src-tauri/src/commands/stream_check.rs +++ b/src-tauri/src/commands/stream_check.rs @@ -133,6 +133,7 @@ pub async fn stream_check_all_providers( model_used: String::new(), tested_at: chrono::Utc::now().timestamp(), retry_count: 0, + error_category: None, } }); diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 3e4fcc983..086f944e6 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -21,7 +21,8 @@ use super::{ }, response_processor::{ create_logged_passthrough_stream, process_response, read_decoded_body, - strip_entity_headers_for_rebuilt_body, SseUsageCollector, + strip_entity_headers_for_rebuilt_body, strip_hop_by_hop_response_headers, + SseUsageCollector, }, server::ProxyState, types::*, @@ -227,10 +228,6 @@ async fn handle_claude_transform( "Cache-Control", axum::http::HeaderValue::from_static("no-cache"), ); - headers.insert( - "Connection", - axum::http::HeaderValue::from_static("keep-alive"), - ); let body = axum::body::Body::from_stream(logged_stream); return Ok((headers, body).into_response()); @@ -306,6 +303,7 @@ async fn handle_claude_transform( // 构建响应 let mut builder = axum::response::Response::builder().status(status); strip_entity_headers_for_rebuilt_body(&mut response_headers); + strip_hop_by_hop_response_headers(&mut response_headers); for (key, value) in response_headers.iter() { builder = builder.header(key, value); diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index 647ba02e6..0b9f19f82 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -11,7 +11,7 @@ use super::{ usage::parser::TokenUsage, ProxyError, }; -use axum::http::header::HeaderMap; +use axum::http::{header::HeaderMap, HeaderName}; use axum::response::{IntoResponse, Response}; use bytes::Bytes; use futures::stream::{Stream, StreamExt}; @@ -68,6 +68,41 @@ fn get_content_encoding(headers: &HeaderMap) -> Option { .filter(|s| !s.is_empty() && s != "identity") } +/// RFC 2616 / RFC 7230 中定义的不应被代理继续转发的响应头。 +const HOP_BY_HOP_RESPONSE_HEADERS: &[&str] = &[ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "trailers", + "transfer-encoding", + "upgrade", +]; + +/// 移除响应侧 hop-by-hop 头,以及 `Connection` 中点名的扩展头。 +pub(crate) fn strip_hop_by_hop_response_headers(headers: &mut HeaderMap) { + let connection_listed_headers: Vec = headers + .get_all(axum::http::header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(str::trim) + .filter(|name| !name.is_empty()) + .filter_map(|name| HeaderName::from_bytes(name.as_bytes()).ok()) + .collect(); + + for name in HOP_BY_HOP_RESPONSE_HEADERS { + headers.remove(*name); + } + + for name in connection_listed_headers { + headers.remove(name); + } +} + /// 移除在重建响应体后会失真的实体头。 pub(crate) fn strip_entity_headers_for_rebuilt_body(headers: &mut HeaderMap) { headers.remove(axum::http::header::CONTENT_ENCODING); @@ -163,10 +198,13 @@ pub async fn handle_streaming( ); } + let mut response_headers = response.headers().clone(); + strip_hop_by_hop_response_headers(&mut response_headers); + let mut builder = axum::response::Response::builder().status(status); // 复制响应头 - for (key, value) in response.headers() { + for (key, value) in &response_headers { builder = builder.header(key, value); } @@ -207,8 +245,9 @@ pub async fn handle_non_streaming( } else { Duration::ZERO }; - let (response_headers, status, body_bytes) = + let (mut response_headers, status, body_bytes) = read_decoded_body(response, ctx.tag, body_timeout).await?; + strip_hop_by_hop_response_headers(&mut response_headers); log::debug!( "[{}] 上游响应体内容: {}", @@ -713,6 +752,90 @@ mod tests { assert_eq!(super::strip_sse_field("id:1", "data"), None); } + #[test] + fn test_strip_hop_by_hop_response_headers_removes_standard_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::CONNECTION, + axum::http::HeaderValue::from_static("keep-alive"), + ); + headers.insert( + axum::http::header::HeaderName::from_static("keep-alive"), + axum::http::HeaderValue::from_static("timeout=5"), + ); + headers.insert( + axum::http::header::TRANSFER_ENCODING, + axum::http::HeaderValue::from_static("chunked"), + ); + headers.insert( + axum::http::header::HeaderName::from_static("proxy-connection"), + axum::http::HeaderValue::from_static("keep-alive"), + ); + headers.insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json"), + ); + headers.insert( + axum::http::header::CONTENT_LENGTH, + axum::http::HeaderValue::from_static("12"), + ); + + strip_hop_by_hop_response_headers(&mut headers); + + assert!(!headers.contains_key(axum::http::header::CONNECTION)); + assert!(!headers.contains_key("keep-alive")); + assert!(!headers.contains_key(axum::http::header::TRANSFER_ENCODING)); + assert!(!headers.contains_key("proxy-connection")); + assert_eq!( + headers.get(axum::http::header::CONTENT_TYPE), + Some(&axum::http::HeaderValue::from_static("application/json")) + ); + assert_eq!( + headers.get(axum::http::header::CONTENT_LENGTH), + Some(&axum::http::HeaderValue::from_static("12")) + ); + } + + #[test] + fn test_strip_hop_by_hop_response_headers_removes_connection_listed_extensions() { + let mut headers = HeaderMap::new(); + headers.append( + axum::http::header::CONNECTION, + axum::http::HeaderValue::from_static("x-trace-hop, x-debug-hop"), + ); + headers.append( + axum::http::header::CONNECTION, + axum::http::HeaderValue::from_static("upgrade"), + ); + headers.insert( + axum::http::header::HeaderName::from_static("x-trace-hop"), + axum::http::HeaderValue::from_static("trace"), + ); + headers.insert( + axum::http::header::HeaderName::from_static("x-debug-hop"), + axum::http::HeaderValue::from_static("debug"), + ); + headers.insert( + axum::http::header::UPGRADE, + axum::http::HeaderValue::from_static("websocket"), + ); + headers.insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("text/event-stream"), + ); + + strip_hop_by_hop_response_headers(&mut headers); + + assert!(!headers.contains_key(axum::http::header::CONNECTION)); + assert!(!headers.contains_key("x-trace-hop")); + assert!(!headers.contains_key("x-debug-hop")); + assert!(!headers.contains_key(axum::http::header::UPGRADE)); + assert_eq!( + headers.get(axum::http::header::CONTENT_TYPE), + Some(&axum::http::HeaderValue::from_static("text/event-stream")) + ); + } + fn build_state(db: Arc) -> ProxyState { ProxyState { db: db.clone(), diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 7b289e80b..2d32f6477 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -57,8 +57,8 @@ impl Default for StreamCheckConfig { max_retries: 2, degraded_threshold_ms: 6000, claude_model: "claude-haiku-4-5-20251001".to_string(), - codex_model: "gpt-5.1-codex@low".to_string(), - gemini_model: "gemini-3-pro-preview".to_string(), + codex_model: "gpt-5.4@low".to_string(), + gemini_model: "gemini-3-flash-preview".to_string(), test_prompt: default_test_prompt(), } } @@ -76,6 +76,9 @@ pub struct StreamCheckResult { pub model_used: String, pub tested_at: i64, pub retry_count: u32, + /// 细粒度错误分类(如 "modelNotFound"),前端据此渲染专门的文案 + #[serde(skip_serializing_if = "Option::is_none")] + pub error_category: Option, } /// 流式健康检查服务 @@ -145,6 +148,7 @@ impl StreamCheckService { model_used: String::new(), tested_at: chrono::Utc::now().timestamp(), retry_count: effective_config.max_retries, + error_category: None, })) } @@ -277,6 +281,7 @@ impl StreamCheckService { result, response_time, config.degraded_threshold_ms, + &model_to_test, )) } @@ -458,8 +463,7 @@ impl StreamCheckService { .header("x-stainless-retry-count", "0") .header("x-stainless-timeout", "600") // Other headers - .header("sec-fetch-mode", "cors") - .header("connection", "keep-alive"); + .header("sec-fetch-mode", "cors"); } // 供应商自定义 headers 最后追加,允许覆盖内置默认值(例如 user-agent) @@ -701,16 +705,21 @@ impl StreamCheckService { result, response_time, config.degraded_threshold_ms, + &model_to_test, )) } /// 将 check_*_stream 的原始结果包装成 StreamCheckResult /// /// 抽取自 check_once 的末尾逻辑,以便 OpenCode/OpenClaw 的独立分支复用。 + /// + /// `model_tested` 是本次探测使用的模型名,用于在失败场景下仍能把模型信息透传给前端, + /// 方便针对"模型不存在 / 已下架"这类错误渲染专门的提示。 fn build_stream_check_result( result: Result<(u16, String), AppError>, response_time: u64, degraded_threshold_ms: u64, + model_tested: &str, ) -> StreamCheckResult { let tested_at = chrono::Utc::now().timestamp(); match result { @@ -723,14 +732,19 @@ impl StreamCheckService { model_used: model, tested_at, retry_count: 0, + error_category: None, }, Err(e) => { - let (http_status, message) = match &e { - AppError::HttpStatus { status, .. } => ( - Some(*status), - Self::classify_http_status(*status).to_string(), - ), - _ => (None, e.to_string()), + let (http_status, message, error_category) = match &e { + AppError::HttpStatus { status, body } => { + let category = Self::detect_error_category(*status, body); + ( + Some(*status), + Self::classify_http_status(*status).to_string(), + category.map(|s| s.to_string()), + ) + } + _ => (None, e.to_string(), None), }; StreamCheckResult { status: HealthStatus::Failed, @@ -738,14 +752,47 @@ impl StreamCheckService { message, response_time_ms: Some(response_time), http_status, - model_used: String::new(), + model_used: model_tested.to_string(), tested_at, retry_count: 0, + error_category, } } } } + /// 基于 HTTP 状态码和响应体识别细粒度错误分类。 + /// + /// 目前仅识别"模型不存在 / 已下架":各厂商该类错误通常返回 4xx,body 中会包含 + /// 如 `model_not_found`(OpenAI)、`does not exist`、`invalid model`、`not_found_error` + /// + `model` 字样(Anthropic)等标记。 + pub(crate) fn detect_error_category(status: u16, body: &str) -> Option<&'static str> { + // 只检查 4xx;5xx 的错误信息里可能巧合出现"model"之类的词,容易误判 + if !(400..500).contains(&status) { + return None; + } + let lower = body.to_lowercase(); + // 必须提到 "model",避免通用 404 / 400 被误判 + if !lower.contains("model") { + return None; + } + let indicators = [ + "model_not_found", + "model not found", + "does not exist", + "invalid_model", + "invalid model", + "unknown_model", + "unknown model", + "is not a valid model", + "not_found_error", // Anthropic 的 type 字段 + ]; + if indicators.iter().any(|s| lower.contains(s)) { + return Some("modelNotFound"); + } + None + } + /// OpenClaw 流式检查分发器 /// /// 根据 `settings_config.api` 字段分发到对应协议的检查器。 @@ -1511,6 +1558,51 @@ mod tests { assert_eq!(effort, None); } + #[test] + fn test_detect_model_not_found() { + // OpenAI 典型响应:404 + model_not_found 错误码 + let openai_404 = r#"{"error":{"message":"The model `gpt-5.1-codex` does not exist or you do not have access to it","type":"invalid_request_error","param":null,"code":"model_not_found"}}"#; + assert_eq!( + StreamCheckService::detect_error_category(404, openai_404), + Some("modelNotFound") + ); + + // Anthropic 典型响应:404 + not_found_error + 提到 model + let anthropic_404 = r#"{"type":"error","error":{"type":"not_found_error","message":"model: claude-deprecated"}}"#; + assert_eq!( + StreamCheckService::detect_error_category(404, anthropic_404), + Some("modelNotFound") + ); + + // 400 + invalid model 也算 + let bad_req = r#"{"error":{"message":"invalid model specified"}}"#; + assert_eq!( + StreamCheckService::detect_error_category(400, bad_req), + Some("modelNotFound") + ); + + // 通用 404(比如 Base URL 错误),body 里没有 model 字样 → 不应误判 + let generic_404 = r#"{"error":"Not Found"}"#; + assert_eq!( + StreamCheckService::detect_error_category(404, generic_404), + None + ); + + // 5xx 就算 body 里有 "model does not exist" 也不分类(避免误判) + let server_error = r#"{"error":"model does not exist"}"#; + assert_eq!( + StreamCheckService::detect_error_category(500, server_error), + None + ); + + // 401 鉴权错误(body 里没有 model 字样) + let auth_err = r#"{"error":"Invalid API key"}"#; + assert_eq!( + StreamCheckService::detect_error_category(401, auth_err), + None + ); + } + #[test] fn test_get_os_name() { let os_name = StreamCheckService::get_os_name(); diff --git a/src/components/usage/ModelTestConfigPanel.tsx b/src/components/usage/ModelTestConfigPanel.tsx index cb6e9feff..65dba22e9 100644 --- a/src/components/usage/ModelTestConfigPanel.tsx +++ b/src/components/usage/ModelTestConfigPanel.tsx @@ -25,7 +25,7 @@ export function ModelTestConfigPanel() { degradedThresholdMs: "6000", claudeModel: "claude-haiku-4-5-20251001", codexModel: "gpt-5.4@low", - geminiModel: "gemini-3-pro-preview", + geminiModel: "gemini-3-flash-preview", testPrompt: "Who are you?", }); diff --git a/src/hooks/useStreamCheck.ts b/src/hooks/useStreamCheck.ts index c30667d76..196e65e9f 100644 --- a/src/hooks/useStreamCheck.ts +++ b/src/hooks/useStreamCheck.ts @@ -46,6 +46,22 @@ export function useStreamCheck(appId: AppId) { // 降级状态也重置熔断器,因为至少能通信 resetCircuitBreaker.mutate({ providerId, appType: appId }); + } else if (result.errorCategory === "modelNotFound") { + // 专门处理"模型不存在/已下架":指向配置入口,比通用 404 文案更有指导性 + toast.error( + t("streamCheck.modelNotFound", { + providerName: providerName, + model: result.modelUsed, + defaultValue: `${providerName} 测试模型 ${result.modelUsed} 不存在或已下架`, + }), + { + description: t("streamCheck.modelNotFoundHint", { + defaultValue: "", + }), + duration: 10000, + closeButton: true, + }, + ); } else { const httpStatus = result.httpStatus; const hintKey = httpStatus diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index dada3987c..1fc9f7722 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2134,6 +2134,8 @@ "failed": "{{providerName}} check failed: {{message}}", "rejected": "{{providerName}} check rejected: {{message}}", "error": "{{providerName}} check error: {{error}}", + "modelNotFound": "{{providerName}} test model {{model}} does not exist or has been deprecated", + "modelNotFoundHint": "This model may have been retired by the provider. Update the default test model in \"Model Test Config\".", "httpHint": { "400": "Provider rejected request format. Health check probe may differ from actual usage.", "401": "API key may be invalid, or provider uses OAuth auth. Check failure doesn't mean it's unusable.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e0793b98c..59c797c28 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2134,6 +2134,8 @@ "failed": "{{providerName}} のチェックに失敗しました: {{message}}", "rejected": "{{providerName}} のチェックが拒否されました: {{message}}", "error": "{{providerName}} のチェックでエラーが発生しました: {{error}}", + "modelNotFound": "{{providerName}} のテストモデル {{model}} は存在しないか廃止されています", + "modelNotFoundHint": "このモデルはプロバイダーにより廃止された可能性があります。「モデルテスト設定」でデフォルトのテストモデルを更新してください。", "httpHint": { "400": "リクエスト形式が拒否されました。ヘルスチェックの形式は実際の使用と異なる場合があります。", "401": "APIキーが無効か、OAuthなどの認証方式を使用しています。チェック失敗は実際に使えないことを意味しません。", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7554f1449..0af0bf59b 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2135,6 +2135,8 @@ "failed": "{{providerName}} 检查失败: {{message}}", "rejected": "{{providerName}} 检查被拒: {{message}}", "error": "{{providerName}} 检查出错: {{error}}", + "modelNotFound": "{{providerName}} 测试模型 {{model}} 不存在或已下架", + "modelNotFoundHint": "该模型可能已被供应商弃用。请在\"模型测试配置\"中更新默认测试模型。", "httpHint": { "400": "供应商拒绝了请求格式。健康检查的探测格式可能与实际使用不同。", "401": "API Key 可能无效,或供应商使用 OAuth 等认证方式。检查失败不代表实际不可用。", diff --git a/src/lib/api/model-test.ts b/src/lib/api/model-test.ts index 4fc1c5936..7f69d2088 100644 --- a/src/lib/api/model-test.ts +++ b/src/lib/api/model-test.ts @@ -24,6 +24,8 @@ export interface StreamCheckResult { modelUsed: string; testedAt: number; retryCount: number; + /** 细粒度错误分类,如 "modelNotFound" */ + errorCategory?: string; } // ===== 流式健康检查 API =====