Compare commits

...

1 Commits

Author SHA1 Message Date
YoVinchen 6af2b8671c feat(stream-check): enhance health check with configurable prompt and CLI-compatible requests
- 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
2026-01-13 11:32:24 +08:00
6 changed files with 177 additions and 42 deletions
+151 -39
View File
@@ -36,6 +36,13 @@ pub struct StreamCheckConfig {
pub codex_model: String, pub codex_model: String,
/// Gemini 测试模型 /// Gemini 测试模型
pub gemini_model: String, 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 { impl Default for StreamCheckConfig {
@@ -47,6 +54,7 @@ impl Default for StreamCheckConfig {
claude_model: "claude-haiku-4-5-20251001".to_string(), claude_model: "claude-haiku-4-5-20251001".to_string(),
codex_model: "gpt-5.1-codex@low".to_string(), codex_model: "gpt-5.1-codex@low".to_string(),
gemini_model: "gemini-3-pro-preview".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 { Ok(last_result.unwrap_or_else(|| StreamCheckResult {
status: HealthStatus::Failed, status: HealthStatus::Failed,
success: false, success: false,
message: "检查失败".to_string(), message: "Check failed".to_string(),
response_time_ms: None, response_time_ms: None,
http_status: None, http_status: None,
model_used: String::new(), model_used: String::new(),
@@ -130,17 +138,18 @@ impl StreamCheckService {
let base_url = adapter let base_url = adapter
.extract_base_url(provider) .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 let auth = adapter
.extract_auth(provider) .extract_auth(provider)
.ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?; .ok_or_else(|| AppError::Message("API Key not found".to_string()))?;
// 使用全局 HTTP 客户端(已包含代理配置) // 使用全局 HTTP 客户端(已包含代理配置)
let client = crate::proxy::http_client::get(); let client = crate::proxy::http_client::get();
let request_timeout = std::time::Duration::from_secs(config.timeout_secs); let request_timeout = std::time::Duration::from_secs(config.timeout_secs);
let model_to_test = Self::resolve_test_model(app_type, provider, config); let model_to_test = Self::resolve_test_model(app_type, provider, config);
let test_prompt = &config.test_prompt;
let result = match app_type { let result = match app_type {
AppType::Claude => { AppType::Claude => {
@@ -149,13 +158,21 @@ impl StreamCheckService {
&base_url, &base_url,
&auth, &auth,
&model_to_test, &model_to_test,
test_prompt,
request_timeout, request_timeout,
) )
.await .await
} }
AppType::Codex => { AppType::Codex => {
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test, request_timeout) Self::check_codex_stream(
.await &client,
&base_url,
&auth,
&model_to_test,
test_prompt,
request_timeout,
)
.await
} }
AppType::Gemini => { AppType::Gemini => {
Self::check_gemini_stream( Self::check_gemini_stream(
@@ -163,6 +180,7 @@ impl StreamCheckService {
&base_url, &base_url,
&auth, &auth,
&model_to_test, &model_to_test,
test_prompt,
request_timeout, request_timeout,
) )
.await .await
@@ -179,7 +197,7 @@ impl StreamCheckService {
Ok(StreamCheckResult { Ok(StreamCheckResult {
status: health_status, status: health_status,
success: true, success: true,
message: "检查成功".to_string(), message: "Check succeeded".to_string(),
response_time_ms: Some(response_time), response_time_ms: Some(response_time),
http_status: Some(status_code), http_status: Some(status_code),
model_used: model, model_used: model,
@@ -201,32 +219,68 @@ impl StreamCheckService {
} }
/// Claude 流式检查 /// Claude 流式检查
///
/// 严格按照 Claude CLI 真实请求格式构建请求
async fn check_claude_stream( async fn check_claude_stream(
client: &Client, client: &Client,
base_url: &str, base_url: &str,
auth: &AuthInfo, auth: &AuthInfo,
model: &str, model: &str,
test_prompt: &str,
timeout: std::time::Duration, timeout: std::time::Duration,
) -> Result<(u16, String), AppError> { ) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
// URL 必须包含 ?beta=true 参数(某些中转服务依赖此参数验证请求来源)
let url = if base.ends_with("/v1") { let url = if base.ends_with("/v1") {
format!("{base}/messages") format!("{base}/messages?beta=true")
} else { } else {
format!("{base}/v1/messages") format!("{base}/v1/messages?beta=true")
}; };
let body = json!({ let body = json!({
"model": model, "model": model,
"max_tokens": 1, "max_tokens": 1,
"messages": [{ "role": "user", "content": "hi" }], "messages": [{ "role": "user", "content": test_prompt }],
"stream": true "stream": true
}); });
// 获取本地系统信息
let os_name = Self::get_os_name();
let arch_name = Self::get_arch_name();
// 严格按照 Claude CLI 请求格式设置 headers
let response = client let response = client
.post(&url) .post(&url)
// 认证 headers(双重认证)
.header("authorization", format!("Bearer {}", auth.api_key))
.header("x-api-key", &auth.api_key) .header("x-api-key", &auth.api_key)
// Anthropic 必需 headers
.header("anthropic-version", "2023-06-01") .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) .timeout(timeout)
.json(&body) .json(&body)
.send() .send()
@@ -245,52 +299,63 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await { if let Some(chunk) = stream.next().await {
match chunk { match chunk {
Ok(_) => Ok((status, model.to_string())), Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))), Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
} }
} else { } else {
Err(AppError::Message("未收到响应数据".to_string())) Err(AppError::Message("No response data received".to_string()))
} }
} }
/// Codex 流式检查 /// Codex 流式检查
///
/// 严格按照 Codex CLI 真实请求格式构建请求 (Responses API)
async fn check_codex_stream( async fn check_codex_stream(
client: &Client, client: &Client,
base_url: &str, base_url: &str,
auth: &AuthInfo, auth: &AuthInfo,
model: &str, model: &str,
test_prompt: &str,
timeout: std::time::Duration, timeout: std::time::Duration,
) -> Result<(u16, String), AppError> { ) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
// Codex CLI 使用 /v1/responses 端点 (OpenAI Responses API)
let url = if base.ends_with("/v1") { let url = if base.ends_with("/v1") {
format!("{base}/chat/completions") format!("{base}/responses")
} else { } else {
format!("{base}/v1/chat/completions") format!("{base}/v1/responses")
}; };
// 解析模型名和推理等级 (支持 model@level 或 model#level 格式) // 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model); 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!({ let mut body = json!({
"model": actual_model, "model": actual_model,
"messages": [ "input": [{ "role": "user", "content": test_prompt }],
{ "role": "system", "content": "" },
{ "role": "assistant", "content": "" },
{ "role": "user", "content": "hi" }
],
"max_tokens": 1,
"temperature": 0,
"stream": true "stream": true
}); });
// 如果是推理模型,添加 reasoning_effort // 如果是推理模型,添加 reasoning_effort
if let Some(effort) = reasoning_effort { if let Some(effort) = reasoning_effort {
body["reasoning_effort"] = json!(effort); body["reasoning"] = json!({ "effort": effort });
} }
// 严格按照 Codex CLI 请求格式设置 headers
let response = client let response = client
.post(&url) .post(&url)
.header("Authorization", format!("Bearer {}", auth.api_key)) .header("authorization", format!("Bearer {}", auth.api_key))
.header("Content-Type", "application/json") .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) .timeout(timeout)
.json(&body) .json(&body)
.send() .send()
@@ -308,10 +373,10 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await { if let Some(chunk) = stream.next().await {
match chunk { match chunk {
Ok(_) => Ok((status, model.to_string())), Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))), Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
} }
} else { } else {
Err(AppError::Message("未收到响应数据".to_string())) Err(AppError::Message("No response data received".to_string()))
} }
} }
@@ -321,6 +386,7 @@ impl StreamCheckService {
base_url: &str, base_url: &str,
auth: &AuthInfo, auth: &AuthInfo,
model: &str, model: &str,
test_prompt: &str,
timeout: std::time::Duration, timeout: std::time::Duration,
) -> Result<(u16, String), AppError> { ) -> Result<(u16, String), AppError> {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
@@ -328,7 +394,7 @@ impl StreamCheckService {
let body = json!({ let body = json!({
"model": model, "model": model,
"messages": [{ "role": "user", "content": "hi" }], "messages": [{ "role": "user", "content": test_prompt }],
"max_tokens": 1, "max_tokens": 1,
"temperature": 0, "temperature": 0,
"stream": true "stream": true
@@ -355,10 +421,10 @@ impl StreamCheckService {
if let Some(chunk) = stream.next().await { if let Some(chunk) = stream.next().await {
match chunk { match chunk {
Ok(_) => Ok((status, model.to_string())), Ok(_) => Ok((status, model.to_string())),
Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))), Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))),
} }
} else { } 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 格式) /// 解析模型名和推理等级 (支持 model@level 或 model#level 格式)
/// 返回 (实际模型名, Option<推理等级>) /// 返回 (实际模型名, Option<推理等级>)
fn parse_model_with_effort(model: &str) -> (String, Option<String>) { fn parse_model_with_effort(model: &str) -> (String, Option<String>) {
// 查找 @ 或 # 分隔符
if let Some(pos) = model.find('@').or_else(|| model.find('#')) { if let Some(pos) = model.find('@').or_else(|| model.find('#')) {
let actual_model = model[..pos].to_string(); let actual_model = model[..pos].to_string();
let effort = model[pos + 1..].to_string(); let effort = model[pos + 1..].to_string();
@@ -386,17 +451,14 @@ impl StreamCheckService {
fn should_retry(msg: &str) -> bool { fn should_retry(msg: &str) -> bool {
let lower = msg.to_lowercase(); let lower = msg.to_lowercase();
lower.contains("timeout") lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out")
|| lower.contains("abort")
|| lower.contains("中断")
|| lower.contains("超时")
} }
fn map_request_error(e: reqwest::Error) -> AppError { fn map_request_error(e: reqwest::Error) -> AppError {
if e.is_timeout() { if e.is_timeout() {
AppError::Message("请求超时".to_string()) AppError::Message("Request timeout".to_string())
} else if e.is_connect() { } else if e.is_connect() {
AppError::Message(format!("连接失败: {e}")) AppError::Message(format!("Connection failed: {e}"))
} else { } else {
AppError::Message(e.to_string()) AppError::Message(e.to_string())
} }
@@ -443,6 +505,26 @@ impl StreamCheckService {
.map(|m| m.as_str().trim().to_string()) .map(|m| m.as_str().trim().to_string())
.filter(|value| !value.is_empty()) .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)] #[cfg(test)]
@@ -467,9 +549,10 @@ mod tests {
#[test] #[test]
fn test_should_retry() { fn test_should_retry() {
assert!(StreamCheckService::should_retry("请求超时")); assert!(StreamCheckService::should_retry("Request timeout"));
assert!(StreamCheckService::should_retry("request timeout")); assert!(StreamCheckService::should_retry("request timed out"));
assert!(!StreamCheckService::should_retry("API Key 无效")); assert!(StreamCheckService::should_retry("connection abort"));
assert!(!StreamCheckService::should_retry("API Key invalid"));
} }
#[test] #[test]
@@ -497,4 +580,33 @@ mod tests {
assert_eq!(model, "gpt-4o-mini"); assert_eq!(model, "gpt-4o-mini");
assert_eq!(effort, None); 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");
}
} }
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Save, Loader2 } from "lucide-react"; import { Save, Loader2 } from "lucide-react";
@@ -25,6 +26,7 @@ export function ModelTestConfigPanel() {
claudeModel: "claude-haiku-4-5-20251001", claudeModel: "claude-haiku-4-5-20251001",
codexModel: "gpt-5.1-codex@low", codexModel: "gpt-5.1-codex@low",
geminiModel: "gemini-3-pro-preview", geminiModel: "gemini-3-pro-preview",
testPrompt: "Who are you?",
}); });
useEffect(() => { useEffect(() => {
@@ -43,6 +45,7 @@ export function ModelTestConfigPanel() {
claudeModel: data.claudeModel, claudeModel: data.claudeModel,
codexModel: data.codexModel, codexModel: data.codexModel,
geminiModel: data.geminiModel, geminiModel: data.geminiModel,
testPrompt: data.testPrompt || "Who are you?",
}); });
} catch (e) { } catch (e) {
setError(String(e)); setError(String(e));
@@ -66,6 +69,7 @@ export function ModelTestConfigPanel() {
claudeModel: config.claudeModel, claudeModel: config.claudeModel,
codexModel: config.codexModel, codexModel: config.codexModel,
geminiModel: config.geminiModel, geminiModel: config.geminiModel,
testPrompt: config.testPrompt || "Who are you?",
}; };
await saveStreamCheckConfig(parsed); await saveStreamCheckConfig(parsed);
toast.success(t("streamCheck.configSaved"), { toast.success(t("streamCheck.configSaved"), {
@@ -189,6 +193,21 @@ export function ModelTestConfigPanel() {
/> />
</div> </div>
</div> </div>
{/* 检查提示词配置 */}
<div className="space-y-2">
<Label htmlFor="testPrompt">{t("streamCheck.testPrompt")}</Label>
<Textarea
id="testPrompt"
value={config.testPrompt}
onChange={(e) =>
setConfig({ ...config, testPrompt: e.target.value })
}
placeholder="Who are you?"
rows={2}
className="min-h-[60px]"
/>
</div>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
+2 -1
View File
@@ -1192,7 +1192,8 @@
"checkParams": "Check Parameters", "checkParams": "Check Parameters",
"timeout": "Timeout (seconds)", "timeout": "Timeout (seconds)",
"maxRetries": "Max Retries", "maxRetries": "Max Retries",
"degradedThreshold": "Degraded Threshold (ms)" "degradedThreshold": "Degraded Threshold (ms)",
"testPrompt": "Test Prompt"
}, },
"proxyConfig": { "proxyConfig": {
"proxyEnabled": "Proxy Enabled", "proxyEnabled": "Proxy Enabled",
+2 -1
View File
@@ -1186,7 +1186,8 @@
"checkParams": "チェックパラメーター", "checkParams": "チェックパラメーター",
"timeout": "タイムアウト(秒)", "timeout": "タイムアウト(秒)",
"maxRetries": "最大リトライ回数", "maxRetries": "最大リトライ回数",
"degradedThreshold": "劣化しきい値(ミリ秒)" "degradedThreshold": "劣化しきい値(ミリ秒)",
"testPrompt": "テストプロンプト"
}, },
"proxyConfig": { "proxyConfig": {
"proxyEnabled": "プロキシ有効", "proxyEnabled": "プロキシ有効",
+2 -1
View File
@@ -1192,7 +1192,8 @@
"checkParams": "检查参数", "checkParams": "检查参数",
"timeout": "超时时间(秒)", "timeout": "超时时间(秒)",
"maxRetries": "最大重试次数", "maxRetries": "最大重试次数",
"degradedThreshold": "降级阈值(毫秒)" "degradedThreshold": "降级阈值(毫秒)",
"testPrompt": "检查提示词"
}, },
"proxyConfig": { "proxyConfig": {
"proxyEnabled": "代理总开关", "proxyEnabled": "代理总开关",
+1
View File
@@ -12,6 +12,7 @@ export interface StreamCheckConfig {
claudeModel: string; claudeModel: string;
codexModel: string; codexModel: string;
geminiModel: string; geminiModel: string;
testPrompt: string;
} }
export interface StreamCheckResult { export interface StreamCheckResult {