From f9d80b8dc317b834ef0dfa4f0339be48a8905f71 Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Tue, 13 Jan 2026 11:36:19 +0800 Subject: [PATCH] feat(stream-check): enhance health check with configurable prompt and CLI-compatible requests (#623) - Add configurable test prompt field to StreamCheckConfig - Implement Claude CLI-compatible request format with proper headers: - Authorization + x-api-key dual auth - anthropic-beta, anthropic-version headers - x-stainless-* SDK headers with dynamic OS/arch detection - URL with ?beta=true parameter - Implement Codex CLI-compatible Responses API format: - /v1/responses endpoint - input array format with reasoning effort support - codex_cli_rs user-agent and originator headers - Add dynamic OS name and CPU architecture detection - Internationalize error messages (Chinese -> English) - Add test prompt Textarea UI component with i18n support - Remove obsolete testPromptDesc translation key --- src-tauri/src/services/stream_check.rs | 190 ++++++++++++++---- src/components/usage/ModelTestConfigPanel.tsx | 19 ++ src/i18n/locales/en.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/zh.json | 3 +- src/lib/api/model-test.ts | 1 + 6 files changed, 177 insertions(+), 42 deletions(-) diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index f7521f7c..900b4569 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -36,6 +36,13 @@ pub struct StreamCheckConfig { pub codex_model: String, /// Gemini 测试模型 pub gemini_model: String, + /// 检查提示词 + #[serde(default = "default_test_prompt")] + pub test_prompt: String, +} + +fn default_test_prompt() -> String { + "Who are you?".to_string() } impl Default for StreamCheckConfig { @@ -47,6 +54,7 @@ impl Default for StreamCheckConfig { 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(), + test_prompt: default_test_prompt(), } } } @@ -110,7 +118,7 @@ impl StreamCheckService { Ok(last_result.unwrap_or_else(|| StreamCheckResult { status: HealthStatus::Failed, success: false, - message: "检查失败".to_string(), + message: "Check failed".to_string(), response_time_ms: None, http_status: None, model_used: String::new(), @@ -130,17 +138,18 @@ impl StreamCheckService { let base_url = adapter .extract_base_url(provider) - .map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?; + .map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?; let auth = adapter .extract_auth(provider) - .ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?; + .ok_or_else(|| AppError::Message("API Key not found".to_string()))?; // 使用全局 HTTP 客户端(已包含代理配置) let client = crate::proxy::http_client::get(); let request_timeout = std::time::Duration::from_secs(config.timeout_secs); let model_to_test = Self::resolve_test_model(app_type, provider, config); + let test_prompt = &config.test_prompt; let result = match app_type { AppType::Claude => { @@ -149,13 +158,21 @@ impl StreamCheckService { &base_url, &auth, &model_to_test, + test_prompt, request_timeout, ) .await } AppType::Codex => { - Self::check_codex_stream(&client, &base_url, &auth, &model_to_test, request_timeout) - .await + Self::check_codex_stream( + &client, + &base_url, + &auth, + &model_to_test, + test_prompt, + request_timeout, + ) + .await } AppType::Gemini => { Self::check_gemini_stream( @@ -163,6 +180,7 @@ impl StreamCheckService { &base_url, &auth, &model_to_test, + test_prompt, request_timeout, ) .await @@ -179,7 +197,7 @@ impl StreamCheckService { Ok(StreamCheckResult { status: health_status, success: true, - message: "检查成功".to_string(), + message: "Check succeeded".to_string(), response_time_ms: Some(response_time), http_status: Some(status_code), model_used: model, @@ -201,32 +219,68 @@ impl StreamCheckService { } /// Claude 流式检查 + /// + /// 严格按照 Claude CLI 真实请求格式构建请求 async fn check_claude_stream( client: &Client, base_url: &str, auth: &AuthInfo, model: &str, + test_prompt: &str, timeout: std::time::Duration, ) -> Result<(u16, String), AppError> { let base = base_url.trim_end_matches('/'); + // URL 必须包含 ?beta=true 参数(某些中转服务依赖此参数验证请求来源) let url = if base.ends_with("/v1") { - format!("{base}/messages") + format!("{base}/messages?beta=true") } else { - format!("{base}/v1/messages") + format!("{base}/v1/messages?beta=true") }; let body = json!({ "model": model, "max_tokens": 1, - "messages": [{ "role": "user", "content": "hi" }], + "messages": [{ "role": "user", "content": test_prompt }], "stream": true }); + // 获取本地系统信息 + let os_name = Self::get_os_name(); + let arch_name = Self::get_arch_name(); + + // 严格按照 Claude CLI 请求格式设置 headers let response = client .post(&url) + // 认证 headers(双重认证) + .header("authorization", format!("Bearer {}", auth.api_key)) .header("x-api-key", &auth.api_key) + // Anthropic 必需 headers .header("anthropic-version", "2023-06-01") - .header("Content-Type", "application/json") + .header( + "anthropic-beta", + "claude-code-20250219,interleaved-thinking-2025-05-14", + ) + .header("anthropic-dangerous-direct-browser-access", "true") + // 内容类型 headers + .header("content-type", "application/json") + .header("accept", "application/json") + .header("accept-encoding", "identity") + .header("accept-language", "*") + // 客户端标识 headers + .header("user-agent", "claude-cli/2.1.2 (external, cli)") + .header("x-app", "cli") + // x-stainless SDK headers(动态获取本地系统信息) + .header("x-stainless-lang", "js") + .header("x-stainless-package-version", "0.70.0") + .header("x-stainless-os", os_name) + .header("x-stainless-arch", arch_name) + .header("x-stainless-runtime", "node") + .header("x-stainless-runtime-version", "v22.20.0") + .header("x-stainless-retry-count", "0") + .header("x-stainless-timeout", "600") + // 其他 headers + .header("sec-fetch-mode", "cors") + .header("connection", "keep-alive") .timeout(timeout) .json(&body) .send() @@ -245,52 +299,63 @@ impl StreamCheckService { if let Some(chunk) = stream.next().await { match chunk { Ok(_) => Ok((status, model.to_string())), - Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))), + Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))), } } else { - Err(AppError::Message("未收到响应数据".to_string())) + Err(AppError::Message("No response data received".to_string())) } } /// Codex 流式检查 + /// + /// 严格按照 Codex CLI 真实请求格式构建请求 (Responses API) async fn check_codex_stream( client: &Client, base_url: &str, auth: &AuthInfo, model: &str, + test_prompt: &str, timeout: std::time::Duration, ) -> Result<(u16, String), AppError> { let base = base_url.trim_end_matches('/'); + // Codex CLI 使用 /v1/responses 端点 (OpenAI Responses API) let url = if base.ends_with("/v1") { - format!("{base}/chat/completions") + format!("{base}/responses") } else { - format!("{base}/v1/chat/completions") + format!("{base}/v1/responses") }; // 解析模型名和推理等级 (支持 model@level 或 model#level 格式) let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model); + // 获取本地系统信息 + let os_name = Self::get_os_name(); + let arch_name = Self::get_arch_name(); + + // Responses API 请求体格式 (input 必须是数组) let mut body = json!({ "model": actual_model, - "messages": [ - { "role": "system", "content": "" }, - { "role": "assistant", "content": "" }, - { "role": "user", "content": "hi" } - ], - "max_tokens": 1, - "temperature": 0, + "input": [{ "role": "user", "content": test_prompt }], "stream": true }); // 如果是推理模型,添加 reasoning_effort if let Some(effort) = reasoning_effort { - body["reasoning_effort"] = json!(effort); + body["reasoning"] = json!({ "effort": effort }); } + // 严格按照 Codex CLI 请求格式设置 headers let response = client .post(&url) - .header("Authorization", format!("Bearer {}", auth.api_key)) - .header("Content-Type", "application/json") + .header("authorization", format!("Bearer {}", auth.api_key)) + .header("content-type", "application/json") + .header("accept", "text/event-stream") + .header("accept-encoding", "identity") + .header( + "user-agent", + format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"), + ) + .header("originator", "codex_cli_rs") .timeout(timeout) .json(&body) .send() @@ -308,10 +373,10 @@ impl StreamCheckService { if let Some(chunk) = stream.next().await { match chunk { Ok(_) => Ok((status, model.to_string())), - Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))), + Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))), } } else { - Err(AppError::Message("未收到响应数据".to_string())) + Err(AppError::Message("No response data received".to_string())) } } @@ -321,6 +386,7 @@ impl StreamCheckService { base_url: &str, auth: &AuthInfo, model: &str, + test_prompt: &str, timeout: std::time::Duration, ) -> Result<(u16, String), AppError> { let base = base_url.trim_end_matches('/'); @@ -328,7 +394,7 @@ impl StreamCheckService { let body = json!({ "model": model, - "messages": [{ "role": "user", "content": "hi" }], + "messages": [{ "role": "user", "content": test_prompt }], "max_tokens": 1, "temperature": 0, "stream": true @@ -355,10 +421,10 @@ impl StreamCheckService { if let Some(chunk) = stream.next().await { match chunk { Ok(_) => Ok((status, model.to_string())), - Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))), + Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))), } } else { - Err(AppError::Message("未收到响应数据".to_string())) + Err(AppError::Message("No response data received".to_string())) } } @@ -373,7 +439,6 @@ impl StreamCheckService { /// 解析模型名和推理等级 (支持 model@level 或 model#level 格式) /// 返回 (实际模型名, Option<推理等级>) fn parse_model_with_effort(model: &str) -> (String, Option) { - // 查找 @ 或 # 分隔符 if let Some(pos) = model.find('@').or_else(|| model.find('#')) { let actual_model = model[..pos].to_string(); let effort = model[pos + 1..].to_string(); @@ -386,17 +451,14 @@ impl StreamCheckService { fn should_retry(msg: &str) -> bool { let lower = msg.to_lowercase(); - lower.contains("timeout") - || lower.contains("abort") - || lower.contains("中断") - || lower.contains("超时") + lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out") } fn map_request_error(e: reqwest::Error) -> AppError { if e.is_timeout() { - AppError::Message("请求超时".to_string()) + AppError::Message("Request timeout".to_string()) } else if e.is_connect() { - AppError::Message(format!("连接失败: {e}")) + AppError::Message(format!("Connection failed: {e}")) } else { AppError::Message(e.to_string()) } @@ -443,6 +505,26 @@ impl StreamCheckService { .map(|m| m.as_str().trim().to_string()) .filter(|value| !value.is_empty()) } + + /// 获取操作系统名称(映射为 Claude CLI 使用的格式) + fn get_os_name() -> &'static str { + match std::env::consts::OS { + "macos" => "MacOS", + "linux" => "Linux", + "windows" => "Windows", + other => other, + } + } + + /// 获取 CPU 架构名称(映射为 Claude CLI 使用的格式) + fn get_arch_name() -> &'static str { + match std::env::consts::ARCH { + "aarch64" => "arm64", + "x86_64" => "x86_64", + "x86" => "x86", + other => other, + } + } } #[cfg(test)] @@ -467,9 +549,10 @@ mod tests { #[test] fn test_should_retry() { - assert!(StreamCheckService::should_retry("请求超时")); - assert!(StreamCheckService::should_retry("request timeout")); - assert!(!StreamCheckService::should_retry("API Key 无效")); + assert!(StreamCheckService::should_retry("Request timeout")); + assert!(StreamCheckService::should_retry("request timed out")); + assert!(StreamCheckService::should_retry("connection abort")); + assert!(!StreamCheckService::should_retry("API Key invalid")); } #[test] @@ -497,4 +580,33 @@ mod tests { assert_eq!(model, "gpt-4o-mini"); assert_eq!(effort, None); } + + #[test] + fn test_get_os_name() { + let os_name = StreamCheckService::get_os_name(); + // 确保返回非空字符串 + assert!(!os_name.is_empty()); + // 在 macOS 上应该返回 "MacOS" + #[cfg(target_os = "macos")] + assert_eq!(os_name, "MacOS"); + // 在 Linux 上应该返回 "Linux" + #[cfg(target_os = "linux")] + assert_eq!(os_name, "Linux"); + // 在 Windows 上应该返回 "Windows" + #[cfg(target_os = "windows")] + assert_eq!(os_name, "Windows"); + } + + #[test] + fn test_get_arch_name() { + let arch_name = StreamCheckService::get_arch_name(); + // 确保返回非空字符串 + assert!(!arch_name.is_empty()); + // 在 ARM64 上应该返回 "arm64" + #[cfg(target_arch = "aarch64")] + assert_eq!(arch_name, "arm64"); + // 在 x86_64 上应该返回 "x86_64" + #[cfg(target_arch = "x86_64")] + assert_eq!(arch_name, "x86_64"); + } } diff --git a/src/components/usage/ModelTestConfigPanel.tsx b/src/components/usage/ModelTestConfigPanel.tsx index 95a7e889..699facd2 100644 --- a/src/components/usage/ModelTestConfigPanel.tsx +++ b/src/components/usage/ModelTestConfigPanel.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Save, Loader2 } from "lucide-react"; @@ -25,6 +26,7 @@ export function ModelTestConfigPanel() { claudeModel: "claude-haiku-4-5-20251001", codexModel: "gpt-5.1-codex@low", geminiModel: "gemini-3-pro-preview", + testPrompt: "Who are you?", }); useEffect(() => { @@ -43,6 +45,7 @@ export function ModelTestConfigPanel() { claudeModel: data.claudeModel, codexModel: data.codexModel, geminiModel: data.geminiModel, + testPrompt: data.testPrompt || "Who are you?", }); } catch (e) { setError(String(e)); @@ -66,6 +69,7 @@ export function ModelTestConfigPanel() { claudeModel: config.claudeModel, codexModel: config.codexModel, geminiModel: config.geminiModel, + testPrompt: config.testPrompt || "Who are you?", }; await saveStreamCheckConfig(parsed); toast.success(t("streamCheck.configSaved"), { @@ -189,6 +193,21 @@ export function ModelTestConfigPanel() { /> + + {/* 检查提示词配置 */} +
+ +