diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 929e3a3..e2c428d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8667,6 +8667,214 @@ async fn management_request( read_management_value(response).await } +const MAX_PROVIDER_HEALTH_STREAM_BYTES: usize = 256 * 1024; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProviderHealthProbeRequest { + url: String, + header: std::collections::HashMap, + data: String, + protocol: String, + timeout_ms: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProviderHealthProbeResponse { + first_token_latency_ms: u64, +} + +fn provider_health_value_has_text(value: Option<&serde_json::Value>) -> bool { + match value { + Some(serde_json::Value::String(text)) => !text.trim().is_empty(), + Some(serde_json::Value::Array(items)) => items + .iter() + .any(|item| provider_health_value_has_text(Some(item))), + Some(serde_json::Value::Object(object)) => ["text", "content"] + .iter() + .any(|key| provider_health_value_has_text(object.get(*key))), + _ => false, + } +} + +fn provider_health_json_has_text(protocol: &str, value: &serde_json::Value) -> bool { + match protocol { + "openai-chat" => value + .get("choices") + .and_then(serde_json::Value::as_array) + .is_some_and(|choices| { + choices.iter().any(|choice| { + provider_health_value_has_text(choice.pointer("/delta/content")) + || provider_health_value_has_text( + choice.pointer("/delta/reasoning_content"), + ) + || provider_health_value_has_text(choice.pointer("/delta/reasoning")) + || provider_health_value_has_text(choice.pointer("/delta/thinking")) + || provider_health_value_has_text(choice.pointer("/message/content")) + }) + }), + "openai-responses" => { + (matches!( + value.get("type").and_then(serde_json::Value::as_str), + Some( + "response.output_text.delta" + | "response.reasoning_text.delta" + | "response.reasoning_summary_text.delta" + ) + ) && provider_health_value_has_text(value.get("delta"))) + || value + .get("output") + .and_then(serde_json::Value::as_array) + .is_some_and(|output| { + output.iter().any(|item| { + item.get("content") + .and_then(serde_json::Value::as_array) + .is_some_and(|content| { + content.iter().any(|part| { + provider_health_value_has_text(part.get("text")) + }) + }) + }) + }) + } + "claude" => { + provider_health_value_has_text(value.pointer("/delta/text")) + || provider_health_value_has_text(value.pointer("/delta/thinking")) + || value + .get("content") + .and_then(serde_json::Value::as_array) + .is_some_and(|content| { + content + .iter() + .any(|part| provider_health_value_has_text(part.get("text"))) + }) + } + "gemini" => value + .get("candidates") + .and_then(serde_json::Value::as_array) + .is_some_and(|candidates| { + candidates.iter().any(|candidate| { + candidate + .pointer("/content/parts") + .and_then(serde_json::Value::as_array) + .is_some_and(|parts| { + parts + .iter() + .any(|part| provider_health_value_has_text(part.get("text"))) + }) + }) + }), + _ => false, + } +} + +fn provider_health_stream_has_text(protocol: &str, bytes: &[u8]) -> bool { + let text = String::from_utf8_lossy(bytes); + text.lines().any(|line| { + let line = line.trim(); + let data = line.strip_prefix("data:").map(str::trim).unwrap_or(line); + if data.is_empty() || data == "[DONE]" { + return false; + } + serde_json::from_str::(data) + .ok() + .is_some_and(|value| provider_health_json_has_text(protocol, &value)) + }) +} + +fn provider_health_content_type_is_streaming(content_type: &str) -> bool { + let content_type = content_type.to_ascii_lowercase(); + content_type.contains("text/event-stream") + || content_type.contains("application/x-ndjson") + || content_type.contains("application/json-seq") +} + +#[tauri::command] +async fn provider_health_probe( + request: ProviderHealthProbeRequest, +) -> Result { + let url = reqwest::Url::parse(request.url.trim()) + .map_err(|error| format!("健康检测地址无效: {error}"))?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err("健康检测仅支持 HTTP 或 HTTPS 地址".to_string()); + } + if !matches!( + request.protocol.as_str(), + "openai-chat" | "openai-responses" | "claude" | "gemini" + ) { + return Err("不支持的健康检测协议".to_string()); + } + if request.data.len() > 64 * 1024 { + return Err("健康检测请求体过大".to_string()); + } + + let timeout = Duration::from_millis(request.timeout_ms.unwrap_or(15_000).clamp(1_000, 120_000)); + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(timeout) + .build() + .map_err(|error| format!("创建健康检测客户端失败: {error}"))?; + let mut headers = reqwest::header::HeaderMap::new(); + for (name, value) in request.header { + let name = reqwest::header::HeaderName::from_bytes(name.as_bytes()) + .map_err(|error| format!("健康检测请求头名称无效: {error}"))?; + let value = reqwest::header::HeaderValue::from_str(&value) + .map_err(|error| format!("健康检测请求头值无效: {error}"))?; + headers.insert(name, value); + } + if !headers.contains_key(reqwest::header::USER_AGENT) { + headers.insert( + reqwest::header::USER_AGENT, + reqwest::header::HeaderValue::from_static(APP_USER_AGENT), + ); + } + + let started_at = Instant::now(); + let response = client + .post(url) + .headers(headers) + .body(request.data) + .send() + .await + .map_err(|error| format!("健康检测请求失败: {error}"))?; + let status = response.status(); + if !status.is_success() { + let detail = response.text().await.unwrap_or_default(); + let detail = detail.trim(); + return Err(if detail.is_empty() { + format!("上游返回 HTTP {}", status.as_u16()) + } else { + format!("上游返回 HTTP {}: {}", status.as_u16(), detail) + }); + } + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_ascii_lowercase(); + if !provider_health_content_type_is_streaming(&content_type) { + return Err("上游未返回流式响应,无法测量首字延迟".to_string()); + } + + let mut stream = response.bytes_stream(); + let mut received = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| format!("读取健康检测流失败: {error}"))?; + if received.len().saturating_add(chunk.len()) > MAX_PROVIDER_HEALTH_STREAM_BYTES { + return Err("健康检测在限制范围内未收到模型首字".to_string()); + } + received.extend_from_slice(&chunk); + if provider_health_stream_has_text(&request.protocol, &received) { + return Ok(ProviderHealthProbeResponse { + first_token_latency_ms: started_at.elapsed().as_millis().max(1) as u64, + }); + } + } + Err("健康检测未收到模型首字".to_string()) +} + #[tauri::command] async fn upload_auth_file( gui_config_state: tauri::State<'_, GuiConfigState>, @@ -16617,6 +16825,7 @@ fn main() { set_core_management_secret_key, clear_core_management_secret_key, management_request, + provider_health_probe, upload_auth_file, open_auth_files_directory, set_core_plugins_enabled, @@ -16685,6 +16894,71 @@ fn main() { mod tests { use super::*; + #[test] + fn provider_health_stream_detects_only_real_model_text() { + assert!(provider_health_content_type_is_streaming( + "text/event-stream; charset=utf-8" + )); + assert!(provider_health_content_type_is_streaming( + "application/x-ndjson" + )); + assert!(!provider_health_content_type_is_streaming( + "application/json" + )); + + let openai_metadata = br#"data: {"choices":[{"delta":{"role":"assistant","content":""}}]} + +"#; + let openai_text = br#"data: {"choices":[{"delta":{"content":"H"}}]} + +"#; + assert!(!provider_health_stream_has_text( + "openai-chat", + openai_metadata + )); + assert!(provider_health_stream_has_text("openai-chat", openai_text)); + let openai_reasoning = br#"data: {"choices":[{"delta":{"reasoning_content":"R"}}]} + +"#; + assert!(provider_health_stream_has_text( + "openai-chat", + openai_reasoning + )); + + let responses_metadata = br#"event: response.created +data: {"type":"response.created"} + +"#; + let responses_text = br#"event: response.output_text.delta +data: {"type":"response.output_text.delta","delta":"H"} + +"#; + assert!(!provider_health_stream_has_text( + "openai-responses", + responses_metadata + )); + assert!(provider_health_stream_has_text( + "openai-responses", + responses_text + )); + + let claude_metadata = br#"event: message_start +data: {"type":"message_start"} + +"#; + let claude_text = br#"event: content_block_delta +data: {"delta":{"type":"text_delta","text":"H"}} + +"#; + assert!(!provider_health_stream_has_text("claude", claude_metadata)); + assert!(provider_health_stream_has_text("claude", claude_text)); + + let gemini_text = br#"data: {"candidates":[{"content":{"parts":[{"text":"H"}]}}]} + +"#; + assert!(provider_health_stream_has_text("gemini", gemini_text)); + } + #[test] fn agent_status_cache_requires_matching_port_and_api_key() { let cache = AgentConfigStatusCache::default(); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 1985821..a0a9188 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -629,17 +629,27 @@ export const jaOverrides = { 'apiAccess.status.enabled': '有効', 'apiAccess.toggleAria': '{remark} 接続の{action}スイッチ', 'apiAccess.health.action': 'ヘルスチェック', - 'apiAccess.health.confirm': 'ヘルスチェックは上流モデルへ最小限の実リクエストを送信するため、少額の料金が発生する場合があります。続行しますか?', + 'apiAccess.health.title': 'モデルヘルスチェック', + 'apiAccess.health.description': '{provider} · 各モデルのエンドポイントを検証', + 'apiAccess.health.warning': '各モデルへ最小限のストリーミングリクエストを 1 回送信し、最初の出力文字を受信した時点で停止します。少額の料金が発生する場合があります。', + 'apiAccess.health.search': 'モデル名またはエイリアスを検索', + 'apiAccess.health.loadingModels': 'モデル一覧を読み込んでいます…', + 'apiAccess.health.modelLoadFailed': 'モデル一覧を更新できませんでした:{error}', + 'apiAccess.health.noMatch': '一致するモデルがありません', + 'apiAccess.health.notChecked': '未確認', + 'apiAccess.health.checkOne': '確認', + 'apiAccess.health.retryOne': '再確認', + 'apiAccess.health.checkAll': 'すべてのモデルを確認', + 'apiAccess.health.checkingProgress': '確認中 {checked}/{total}', + 'apiAccess.health.summary': '確認済み {checked}/{total} · 正常 {healthy} · 失敗 {failed}', 'apiAccess.health.checking': '確認中…', 'apiAccess.health.healthy': '正常', - 'apiAccess.health.partial': '一部成功', 'apiAccess.health.failed': '失敗', 'apiAccess.health.noModel': 'ヘルスチェックに使用できるモデルがありません', 'apiAccess.health.timeout': 'リクエストがタイムアウトしました', - 'apiAccess.health.modelResult': '確認モデル:{model}', - 'apiAccess.health.keysResult': 'キー結果:{success}/{total}', - 'apiAccess.health.latencyResult': '最小遅延:{latency} ms', - 'apiAccess.health.errorsResult': 'エラー:{errors}', + 'apiAccess.health.missingDirectKey': '初字遅延を測定するには、直接使用できる平文の API キーが必要です', + 'apiAccess.health.firstTokenInline': '初字 {latency} ms', + 'apiAccess.health.firstTokenLatencyResult': '初字遅延:{latency} ms', 'common.edit': '編集', 'apiAccess.error.enterBaseUrl': '先に Base URL を入力してください', 'apiAccess.error.noAvailableModels': '利用可能なモデルが見つかりません', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 627c1e6..9d184a9 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -632,17 +632,27 @@ export const zhCN = { 'apiAccess.status.enabled': '已启用', 'apiAccess.toggleAria': '{remark} 接入{action}开关', 'apiAccess.health.action': '健康检测', - 'apiAccess.health.confirm': '健康检测会向上游模型发送一次最小真实请求,并可能产生少量费用。确定继续吗?', + 'apiAccess.health.title': '模型健康检测', + 'apiAccess.health.description': '{provider} · 逐个验证模型接口', + 'apiAccess.health.warning': '检测会对每个模型发送一次最小流式请求,收到模型首字后立即停止,并可能产生少量费用。', + 'apiAccess.health.search': '搜索模型名称或别名', + 'apiAccess.health.loadingModels': '正在获取模型列表…', + 'apiAccess.health.modelLoadFailed': '模型列表刷新失败:{error}', + 'apiAccess.health.noMatch': '没有匹配的模型', + 'apiAccess.health.notChecked': '未检测', + 'apiAccess.health.checkOne': '检测', + 'apiAccess.health.retryOne': '重新检测', + 'apiAccess.health.checkAll': '检测全部模型', + 'apiAccess.health.checkingProgress': '检测中 {checked}/{total}', + 'apiAccess.health.summary': '已检测 {checked}/{total} · 健康 {healthy} · 失败 {failed}', 'apiAccess.health.checking': '检测中…', 'apiAccess.health.healthy': '健康', - 'apiAccess.health.partial': '部分通过', 'apiAccess.health.failed': '失败', 'apiAccess.health.noModel': '没有可用于健康检测的模型', 'apiAccess.health.timeout': '请求已超时', - 'apiAccess.health.modelResult': '检测模型:{model}', - 'apiAccess.health.keysResult': '密钥结果:{success}/{total}', - 'apiAccess.health.latencyResult': '最低延迟:{latency} ms', - 'apiAccess.health.errorsResult': '错误:{errors}', + 'apiAccess.health.missingDirectKey': '缺少可直接使用的明文 API 密钥,无法测量首字延迟', + 'apiAccess.health.firstTokenInline': '首字 {latency} ms', + 'apiAccess.health.firstTokenLatencyResult': '首字延迟:{latency} ms', 'common.edit': '编辑', 'apiAccess.error.enterBaseUrl': '请先填写 Base URL', 'apiAccess.error.noAvailableModels': '未发现可用模型', @@ -1536,17 +1546,27 @@ export const en: Record = { 'apiAccess.status.enabled': 'Enabled', 'apiAccess.toggleAria': '{remark} connection {action} switch', 'apiAccess.health.action': 'Health Check', - 'apiAccess.health.confirm': 'The health check sends a minimal real request to the upstream model and may incur a small charge. Continue?', + 'apiAccess.health.title': 'Model Health Check', + 'apiAccess.health.description': '{provider} · Validate each model endpoint', + 'apiAccess.health.warning': 'The check sends one minimal streaming request to each model, stops at the first output token, and may incur a small charge.', + 'apiAccess.health.search': 'Search model names or aliases', + 'apiAccess.health.loadingModels': 'Loading the model list…', + 'apiAccess.health.modelLoadFailed': 'Could not refresh the model list: {error}', + 'apiAccess.health.noMatch': 'No matching models', + 'apiAccess.health.notChecked': 'Not checked', + 'apiAccess.health.checkOne': 'Check', + 'apiAccess.health.retryOne': 'Check Again', + 'apiAccess.health.checkAll': 'Check All Models', + 'apiAccess.health.checkingProgress': 'Checking {checked}/{total}', + 'apiAccess.health.summary': 'Checked {checked}/{total} · Healthy {healthy} · Failed {failed}', 'apiAccess.health.checking': 'Checking…', 'apiAccess.health.healthy': 'Healthy', - 'apiAccess.health.partial': 'Partial', 'apiAccess.health.failed': 'Failed', 'apiAccess.health.noModel': 'No model is available for a health check', 'apiAccess.health.timeout': 'The request timed out', - 'apiAccess.health.modelResult': 'Model: {model}', - 'apiAccess.health.keysResult': 'Key results: {success}/{total}', - 'apiAccess.health.latencyResult': 'Lowest latency: {latency} ms', - 'apiAccess.health.errorsResult': 'Errors: {errors}', + 'apiAccess.health.missingDirectKey': 'A directly usable plaintext API key is required to measure first-token latency', + 'apiAccess.health.firstTokenInline': 'TTFT {latency} ms', + 'apiAccess.health.firstTokenLatencyResult': 'Time to first token: {latency} ms', 'common.edit': 'Edit', 'apiAccess.error.enterBaseUrl': 'Enter a Base URL first', 'apiAccess.error.noAvailableModels': 'No available models found', diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index 7fccc2b..9a13893 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -33,9 +33,12 @@ import { type ModelProvider, } from '../services/modelService'; import { - checkProviderHealth, - ProviderHealthCheckError, - type ProviderHealthResult, + checkProviderModelHealth, + checkProviderModelsHealth, + mergeProviderHealthModels, + PROVIDER_HEALTH_TIMEOUT_MS, + type ProviderHealthCheckOptions, + type ProviderModelHealthResult, } from '../services/providerHealthCheck'; import { modelMatchesRule } from '../services/oauthModels'; import { getCurrentLocale, translate, useI18n } from '../i18n'; @@ -76,8 +79,6 @@ type ProviderRow = { remark: string; }; -type ProviderHealthState = { status: 'checking' } | ProviderHealthResult; - export type ProviderDraft = { name: string; apiKey: string; @@ -652,7 +653,7 @@ export function ApiAccessPage() { const [editingRow, setEditingRow] = useState(null); const [dialogDraft, setDialogDraft] = useState(emptyProviderDraft); const [apiAccessRemarks, setApiAccessRemarks] = useState>({}); - const [providerHealth, setProviderHealth] = useState>({}); + const [healthDialogRow, setHealthDialogRow] = useState(null); const activeDefinition = definitionFor(activeCategory); const activeSection = activeDefinition.section; @@ -938,58 +939,6 @@ export function ApiAccessPage() { } }; - const checkRowHealth = async (row: ProviderRow) => { - if (!window.confirm(t('apiAccess.health.confirm'))) return; - const identity = providerHealthIdentity(row); - setProviderHealth((current) => ({ ...current, [identity]: { status: 'checking' } })); - try { - const result = await checkProviderHealth({ - provider: providerModelType(row.section), - baseUrl: row.baseUrl, - apiKeys: row.apiKeys, - authIndex: row.authIndex, - models: row.models, - testModel: readString(row.record, 'test-model', 'testModel'), - customHeaders: providerHeadersFromRecord(row.record), - }); - setProviderHealth((current) => ({ ...current, [identity]: result })); - } catch (requestError) { - const message = requestError instanceof ProviderHealthCheckError - ? t('apiAccess.health.noModel') - : String(requestError).replace(/^Error:\s*/i, ''); - const timedOut = /timed?\s*out|timeout|deadline has elapsed|超时/i.test(message); - setProviderHealth((current) => ({ - ...current, - [identity]: { - status: 'failed', - model: readString(row.record, 'test-model', 'testModel') || row.models[0]?.name || '', - successCount: 0, - totalCount: Math.max(1, new Set(row.apiKeys.filter(Boolean)).size), - errors: [message], - timedOut, - }, - })); - } - }; - - const healthStatusLabel = (status: ProviderHealthResult['status']) => { - if (status === 'healthy') return t('apiAccess.health.healthy'); - if (status === 'partial') return t('apiAccess.health.partial'); - return t('apiAccess.health.failed'); - }; - - const healthResultTitle = (result: ProviderHealthResult) => [ - result.model ? t('apiAccess.health.modelResult', { model: result.model }) : '', - t('apiAccess.health.keysResult', { success: result.successCount, total: result.totalCount }), - result.latencyMs === undefined - ? '' - : t('apiAccess.health.latencyResult', { latency: result.latencyMs }), - result.timedOut ? t('apiAccess.health.timeout') : '', - result.errors.length > 0 - ? t('apiAccess.health.errorsResult', { errors: result.errors.join('; ') }) - : '', - ].filter(Boolean).join('\n'); - const totalCount = Object.values(records).reduce((sum, items) => sum + items.length, 0); const countForDefinition = (definition: ProviderDefinition) => @@ -1059,26 +1008,11 @@ export function ApiAccessPage() { ) : (
- {rows.map((row) => { - const health = providerHealth[providerHealthIdentity(row)]; - return ( + {rows.map((row) => (
{row.remark || row.name} - {health?.status === 'checking' ? ( - - {t('apiAccess.health.checking')} - - ) : health ? ( - - {healthStatusLabel(health.status)} · {health.successCount}/{health.totalCount} - {health.latencyMs === undefined ? '' : ` · ${health.latencyMs} ms`} - - ) : null}
{definitionFor(row.section).openAi && row.apiKeys.length > 1 @@ -1096,12 +1030,10 @@ export function ApiAccessPage() {
- ); - })} + ))}
)} @@ -1141,10 +1072,217 @@ export function ApiAccessPage() { onSave={saveProvider} /> ) : null} + {healthDialogRow ? ( + setHealthDialogRow(null)} + /> + ) : null} ); } +type ProviderHealthDialogProps = { + row: ProviderRow; + onClose: () => void; +}; + +type ProviderModelHealthState = { status: 'checking' } | ProviderModelHealthResult; + +function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) { + const { t } = useI18n(); + const testModel = readString(row.record, 'test-model', 'testModel'); + const configuredModels = useMemo( + () => mergeProviderHealthModels([], row.models, testModel), + [row.models, testModel], + ); + const [models, setModels] = useState(configuredModels); + const [modelLoading, setModelLoading] = useState(true); + const [modelError, setModelError] = useState(''); + const [search, setSearch] = useState(''); + const [results, setResults] = useState>({}); + const [checkingAll, setCheckingAll] = useState(false); + + const healthOptions = useMemo(() => ({ + provider: providerModelType(row.section), + baseUrl: row.baseUrl, + apiKeys: row.apiKeys, + authIndex: row.authIndex, + customHeaders: providerHeadersFromRecord(row.record), + timeoutMs: PROVIDER_HEALTH_TIMEOUT_MS, + }), [row]); + + useEffect(() => { + let disposed = false; + setModelLoading(true); + setModelError(''); + void fetchModels( + healthOptions.provider, + healthOptions.baseUrl, + row.apiKeys.find((key) => key.trim()) ?? '', + healthOptions.authIndex, + healthOptions.customHeaders, + healthOptions.timeoutMs, + ).then((discovered) => { + if (!disposed) setModels(mergeProviderHealthModels(discovered, row.models, testModel)); + }).catch((requestError) => { + if (!disposed) setModelError(String(requestError).replace(/^Error:\s*/i, '')); + }).finally(() => { + if (!disposed) setModelLoading(false); + }); + return () => { + disposed = true; + }; + }, [healthOptions, row.apiKeys, row.models, testModel]); + + const visibleModels = useMemo(() => { + const query = search.trim().toLowerCase(); + if (!query) return models; + return models.filter((model) => + `${model.name} ${model.alias ?? ''}`.toLowerCase().includes(query), + ); + }, [models, search]); + + const resultValues = Object.values(results); + const checkedCount = resultValues.filter((result) => result.status !== 'checking').length; + const healthyCount = resultValues.filter((result) => result.status === 'healthy').length; + const failedCount = resultValues.filter((result) => result.status === 'failed').length; + const hasCheckingModel = resultValues.some((result) => result.status === 'checking'); + + const saveResult = (result: ProviderModelHealthResult) => { + setResults((current) => ({ + ...current, + [result.model.toLowerCase()]: result, + })); + }; + + const checkOneModel = async (model: ModelOption) => { + const key = model.name.toLowerCase(); + setResults((current) => ({ ...current, [key]: { status: 'checking' } })); + saveResult(await checkProviderModelHealth(healthOptions, model.name)); + }; + + const checkAllModels = async () => { + if (models.length === 0) return; + setCheckingAll(true); + setResults(Object.fromEntries(models.map((model) => [ + model.name.toLowerCase(), + { status: 'checking' } satisfies ProviderModelHealthState, + ]))); + try { + await checkProviderModelsHealth(healthOptions, models, saveResult); + } finally { + setCheckingAll(false); + } + }; + + const statusLabel = (state: ProviderModelHealthState | undefined) => { + if (!state) return t('apiAccess.health.notChecked'); + if (state.status === 'checking') return t('apiAccess.health.checking'); + return state.status === 'healthy' + ? t('apiAccess.health.healthy') + : t('apiAccess.health.failed'); + }; + + return ( +
event.currentTarget === event.target && onClose()}> +
+
+
+

{t('apiAccess.health.title')}

+ {t('apiAccess.health.description', { provider: row.remark || row.name })} +
+ +
+ +
+
+ +
+

{t('apiAccess.health.warning')}

+ {t('apiAccess.health.summary', { + checked: checkedCount, + total: models.length, + healthy: healthyCount, + failed: failedCount, + })} + {modelError ? ( + {t('apiAccess.health.modelLoadFailed', { error: modelError })} + ) : modelLoading ? ( + {t('apiAccess.health.loadingModels')} + ) : null} +
+ +
+ {modelLoading && models.length === 0 ? ( +
{t('apiAccess.health.loadingModels')}
+ ) : visibleModels.length === 0 ? ( +
{models.length ? t('apiAccess.health.noMatch') : t('apiAccess.health.noModel')}
+ ) : visibleModels.map((model) => { + const state = results[model.name.toLowerCase()]; + const checked = state && state.status !== 'checking' ? state : null; + const error = checked?.status === 'failed' + ? checked.errorCode === 'missing-direct-key' + ? t('apiAccess.health.missingDirectKey') + : checked.timedOut + ? t('apiAccess.health.timeout') + : checked.error || t('apiAccess.health.failed') + : ''; + return ( +
+
+ {model.name} + {error + ? {error} + : model.alias + ? {model.alias} + : null} +
+ + {statusLabel(state)} + {checked?.firstTokenLatencyMs === undefined + ? '' + : ` · ${t('apiAccess.health.firstTokenInline', { latency: checked.firstTokenLatencyMs })}`} + + +
+ ); + })} +
+ +
+ + +
+
+
+ ); +} + type ApiProviderDialogProps = { activeCategory: ProviderCategory; editingRow: ProviderRow | null; diff --git a/src/services/providerHealthCheck.ts b/src/services/providerHealthCheck.ts index a90afeb..341db67 100644 --- a/src/services/providerHealthCheck.ts +++ b/src/services/providerHealthCheck.ts @@ -1,36 +1,27 @@ -import { apiCallErrorMessage, managementApi } from './managementApi'; -import { - fetchModels, - normalizeBaseUrl, - type ModelOption, - type ModelProvider, -} from './modelService'; +import { invoke } from '@tauri-apps/api/core'; +import { normalizeBaseUrl, type ModelOption, type ModelProvider } from './modelService'; export const PROVIDER_HEALTH_TIMEOUT_MS = 15_000; - -export type ProviderHealthStatus = 'healthy' | 'partial' | 'failed'; +export const PROVIDER_HEALTH_CONCURRENCY = 4; export type ProviderHealthProbe = { url: string; header: Record; data: string; + protocol: 'openai-chat' | 'openai-responses' | 'claude' | 'gemini'; }; export type ProviderHealthProbeResult = { success: boolean; - latencyMs?: number; + firstTokenLatencyMs?: number; error?: string; timedOut?: boolean; + errorCode?: 'missing-direct-key'; }; -export type ProviderHealthResult = { - status: ProviderHealthStatus; +export type ProviderModelHealthResult = ProviderHealthProbeResult & { model: string; - successCount: number; - totalCount: number; - latencyMs?: number; - errors: string[]; - timedOut: boolean; + status: 'healthy' | 'failed'; }; export type ProviderHealthCheckOptions = { @@ -38,22 +29,10 @@ export type ProviderHealthCheckOptions = { baseUrl: string; apiKeys: string[]; authIndex?: string; - models: ModelOption[]; - testModel?: string; customHeaders?: Record; timeoutMs?: number; }; -export class ProviderHealthCheckError extends Error { - readonly code: 'no-model'; - - constructor(code: 'no-model') { - super(code); - this.name = 'ProviderHealthCheckError'; - this.code = code; - } -} - const defaultBaseUrl = (provider: ModelProvider) => { if (provider === 'claude') return 'https://api.anthropic.com'; if (provider === 'gemini') return 'https://generativelanguage.googleapis.com'; @@ -79,11 +58,29 @@ const setHeaderIfMissing = ( if (!hasHeader(headers, name)) headers[name] = value; }; -export function selectProviderHealthModel( - testModel: string | undefined, - models: ModelOption[], -): string { - return testModel?.trim() || models.find((model) => model.name.trim())?.name.trim() || ''; +export function primaryProviderHealthCredential(apiKeys: string[]): string { + return apiKeys.map((key) => key.trim()).find(Boolean) ?? ''; +} + +export function mergeProviderHealthModels( + discoveredModels: ModelOption[], + configuredModels: ModelOption[], + testModel = '', +): ModelOption[] { + const models = new Map(); + [...discoveredModels, ...configuredModels].forEach((model) => { + const name = model.name.trim(); + if (!name) return; + const key = name.toLowerCase(); + models.set(key, { ...models.get(key), ...model, name }); + }); + const normalizedTestModel = testModel.trim(); + if (normalizedTestModel && !models.has(normalizedTestModel.toLowerCase())) { + models.set(normalizedTestModel.toLowerCase(), { name: normalizedTestModel }); + } + return Array.from(models.values()).sort((left, right) => + left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }), + ); } export function buildProviderHealthProbe( @@ -104,11 +101,12 @@ export function buildProviderHealthProbe( else if (authIndex) setHeaderIfMissing(headers, 'x-goog-api-key', '$TOKEN$'); const normalizedModel = model.trim().replace(/^models\//i, ''); return { - url: `${root}/v1beta/models/${encodeURIComponent(normalizedModel)}:generateContent`, + url: `${root}/v1beta/models/${encodeURIComponent(normalizedModel)}:generateContent?alt=sse`, header: headers, + protocol: 'gemini', data: JSON.stringify({ contents: [{ parts: [{ text: 'hi' }] }], - generationConfig: { maxOutputTokens: 1 }, + generationConfig: { maxOutputTokens: 16 }, }), }; } @@ -122,9 +120,11 @@ export function buildProviderHealthProbe( return { url: `${root}/v1/messages`, header: headers, + protocol: 'claude', data: JSON.stringify({ model: model.trim(), - max_tokens: 1, + max_tokens: 16, + stream: true, messages: [{ role: 'user', content: 'hi' }], }), }; @@ -137,10 +137,11 @@ export function buildProviderHealthProbe( return { url: `${root}/v1/responses`, header: headers, + protocol: 'openai-responses', data: JSON.stringify({ model: model.trim(), input: 'hi', - max_output_tokens: 1, + stream: true, }), }; } @@ -148,11 +149,11 @@ export function buildProviderHealthProbe( return { url: `${root}/v1/chat/completions`, header: headers, + protocol: 'openai-chat', data: JSON.stringify({ model: model.trim(), messages: [{ role: 'user', content: 'hi' }], - max_tokens: 1, - stream: false, + stream: true, }), }; } @@ -174,33 +175,33 @@ export async function checkProviderHealthProbe( customHeaders: Record = {}, timeoutMs = PROVIDER_HEALTH_TIMEOUT_MS, ): Promise { - const probe = buildProviderHealthProbe( - provider, - baseUrl, - model, - apiKey, - authIndex, - customHeaders, - ); - const startedAt = performance.now(); try { - const response = await managementApi.post>('/api-call', { - authIndex: authIndex.trim() || undefined, - method: 'POST', - url: probe.url, - header: probe.header, - data: probe.data, - }, { timeoutMs }); - const status = Number(response.status_code ?? response.statusCode ?? 0); - if (status < 200 || status >= 300) { + const probe = buildProviderHealthProbe( + provider, + baseUrl, + model, + apiKey, + authIndex, + customHeaders, + ); + if (Object.values(probe.header).some((value) => value.includes('$TOKEN$'))) { return { success: false, - error: apiCallErrorMessage(response), + errorCode: 'missing-direct-key', }; } + const response = await invoke<{ firstTokenLatencyMs: number }>('provider_health_probe', { + request: { + protocol: probe.protocol, + timeoutMs, + data: probe.data, + header: probe.header, + url: probe.url, + }, + }); return { success: true, - latencyMs: Math.max(1, Math.round(performance.now() - startedAt)), + firstTokenLatencyMs: Math.max(1, Math.round(response.firstTokenLatencyMs)), }; } catch (error) { const message = errorMessage(error); @@ -212,78 +213,62 @@ export async function checkProviderHealthProbe( } } -export function aggregateProviderHealthResults( - model: string, - results: ProviderHealthProbeResult[], -): ProviderHealthResult { - const successful = results.filter((result) => result.success); - const status: ProviderHealthStatus = successful.length === results.length - ? 'healthy' - : successful.length > 0 - ? 'partial' - : 'failed'; - const latencies = successful - .map((result) => result.latencyMs) - .filter((latency): latency is number => latency !== undefined); - const errors = results - .map((result) => result.error?.trim() ?? '') - .filter((message, index, messages) => message && messages.indexOf(message) === index); - return { - status, - model, - successCount: successful.length, - totalCount: results.length, - ...(latencies.length > 0 ? { latencyMs: Math.min(...latencies) } : {}), - errors, - timedOut: results.some((result) => result.timedOut), - }; -} - -const healthCredentials = (apiKeys: string[]) => { - const keys = apiKeys - .map((key) => key.trim()) - .filter((key, index, values) => key && values.indexOf(key) === index); - return keys.length > 0 ? keys : ['']; -}; - -export async function checkProviderHealth( +export async function checkProviderModelHealth( options: ProviderHealthCheckOptions, -): Promise { - const timeoutMs = options.timeoutMs ?? PROVIDER_HEALTH_TIMEOUT_MS; - const credentials = healthCredentials(options.apiKeys); - const customHeaders = options.customHeaders ?? {}; - let model = selectProviderHealthModel(options.testModel, options.models); - - if (!model) { - let discoveryError: unknown; - for (const apiKey of credentials) { - try { - const discovered = await fetchModels( - options.provider, - options.baseUrl, - apiKey, - options.authIndex, - customHeaders, - timeoutMs, - ); - model = selectProviderHealthModel(undefined, discovered); - if (model) break; - } catch (error) { - discoveryError = error; - } - } - if (!model && discoveryError) throw discoveryError; - if (!model) throw new ProviderHealthCheckError('no-model'); - } - - const results = await Promise.all(credentials.map((apiKey) => checkProviderHealthProbe( + model: string, +): Promise { + const result = await checkProviderHealthProbe( options.provider, options.baseUrl, model, - apiKey, + primaryProviderHealthCredential(options.apiKeys), options.authIndex, - customHeaders, - timeoutMs, - ))); - return aggregateProviderHealthResults(model, results); + options.customHeaders, + options.timeoutMs, + ); + return { + ...result, + model, + status: result.success ? 'healthy' : 'failed', + }; +} + +export async function runProviderModelHealthChecks( + models: ModelOption[], + checkModel: (model: ModelOption, index: number) => Promise, + onModelChecked?: (result: ProviderModelHealthResult, index: number) => void, + concurrency = PROVIDER_HEALTH_CONCURRENCY, +): Promise { + const results: ProviderModelHealthResult[] = new Array(models.length); + let nextIndex = 0; + const workerCount = Math.min(models.length, Math.max(1, Math.floor(concurrency))); + + const runWorker = async () => { + while (nextIndex < models.length) { + const index = nextIndex; + nextIndex += 1; + const model = models[index]; + if (!model) continue; + const result = await checkModel(model, index); + results[index] = result; + onModelChecked?.(result, index); + } + }; + + await Promise.all(Array.from({ length: workerCount }, runWorker)); + return results; +} + +export async function checkProviderModelsHealth( + options: ProviderHealthCheckOptions, + models: ModelOption[], + onModelChecked?: (result: ProviderModelHealthResult, index: number) => void, + concurrency = PROVIDER_HEALTH_CONCURRENCY, +): Promise { + return runProviderModelHealthChecks( + models, + (model) => checkProviderModelHealth(options, model.name), + onModelChecked, + concurrency, + ); } diff --git a/src/styles.css b/src/styles.css index 15380ce..d38fc13 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3009,6 +3009,126 @@ textarea:focus-visible { min-width: 112px; } +.provider-health-dialog { + width: min(780px, 100%); + height: min(660px, calc(100vh - 32px)); +} + +.provider-health-overview { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 5px 14px; + min-width: 0; + padding: 9px 11px; + border: 1px solid var(--theme-dfbf91); + border-radius: 7px; + background: var(--theme-fff7e9); +} + +.provider-health-overview p, +.provider-health-overview span, +.provider-health-overview small { + margin: 0; + font-size: var(--font-size-label); + line-height: var(--line-height-body); +} + +.provider-health-overview p { + min-width: 0; + color: var(--theme-8b5b21); +} + +.provider-health-overview span { + color: var(--theme-5f5a53); + font-variant-numeric: tabular-nums; + font-weight: 700; + white-space: nowrap; +} + +.provider-health-overview small { + grid-column: 1 / -1; + min-width: 0; + overflow: hidden; + color: var(--theme-7a746c); + text-overflow: ellipsis; + white-space: nowrap; +} + +.provider-health-overview small.error { + color: var(--theme-9f2d20); +} + +.provider-health-model-list { + min-height: 0; + border: 1px solid var(--theme-e2ddd3); + border-radius: 7px; + background: var(--theme-ffffff); +} + +.provider-health-model-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto 88px; + align-items: center; + gap: 10px; + min-height: 54px; + padding: 7px 10px; + border-bottom: 1px solid var(--theme-eeece6); +} + +.provider-health-model-row:last-child { + border-bottom: 0; +} + +.provider-health-model-row.failed { + background: var(--theme-fff1ee); +} + +.provider-health-model-name { + display: grid; + gap: 3px; + min-width: 0; +} + +.provider-health-model-name strong, +.provider-health-model-name small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.provider-health-model-name strong { + color: var(--theme-2d2a26); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: var(--font-size-label); +} + +.provider-health-model-name small { + color: var(--theme-8a847c); + font-size: var(--font-size-meta); +} + +.provider-health-model-name small.error { + color: var(--theme-9f2d20); +} + +.provider-health-model-row .provider-health-pill { + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; +} + +.provider-health-single-button { + width: 88px; + min-width: 88px; + margin-top: 0; +} + +.provider-health-actions .primary-button { + min-width: 150px; +} + .management-card-actions button, .management-heading-actions button { gap: 7px; @@ -7715,6 +7835,25 @@ textarea:focus-visible { flex: 1; min-width: 0; } + + .provider-health-overview { + grid-template-columns: 1fr; + } + + .provider-health-overview span, + .provider-health-overview small { + grid-column: auto; + white-space: normal; + } + + .provider-health-model-row { + grid-template-columns: minmax(0, 1fr) auto; + } + + .provider-health-single-button { + grid-column: 1 / -1; + width: 100%; + } } @media (prefers-reduced-motion: reduce) { diff --git a/tests/providerHealthCheck.test.ts b/tests/providerHealthCheck.test.ts index 8a02c64..0ebf0d0 100644 --- a/tests/providerHealthCheck.test.ts +++ b/tests/providerHealthCheck.test.ts @@ -1,17 +1,30 @@ import { describe, expect, it } from 'bun:test'; import { - aggregateProviderHealthResults, buildProviderHealthProbe, - selectProviderHealthModel, + mergeProviderHealthModels, + primaryProviderHealthCredential, + runProviderModelHealthChecks, } from '../src/services/providerHealthCheck'; describe('API 接入健康检测', () => { - it('优先使用 test-model,其次使用第一个已配置模型', () => { - const models = [{ name: 'configured-first' }, { name: 'configured-second' }]; + it('合并发现模型、已配置模型和 test-model,并按名称去重', () => { + const models = mergeProviderHealthModels( + [{ name: 'model-b' }, { name: 'model-a' }], + [{ name: 'MODEL-A', alias: 'model-a-alias' }, { name: 'model-c' }], + 'health-model', + ); - expect(selectProviderHealthModel('health-model', models)).toBe('health-model'); - expect(selectProviderHealthModel('', models)).toBe('configured-first'); - expect(selectProviderHealthModel('', [])).toBe(''); + expect(models).toEqual([ + { name: 'health-model' }, + { name: 'MODEL-A', alias: 'model-a-alias' }, + { name: 'model-b' }, + { name: 'model-c' }, + ]); + }); + + it('逐模型检测只使用当前接入的首个有效密钥', () => { + expect(primaryProviderHealthCredential(['', ' first-key ', 'second-key'])).toBe('first-key'); + expect(primaryProviderHealthCredential([])).toBe(''); }); it('为 OpenAI 兼容接入构造最小 chat completions 请求并保留自定义头', () => { @@ -33,9 +46,9 @@ describe('API 接入健康检测', () => { expect(JSON.parse(probe.data)).toEqual({ model: 'gpt-test', messages: [{ role: 'user', content: 'hi' }], - max_tokens: 1, - stream: false, + stream: true, }); + expect(probe.protocol).toBe('openai-chat'); }); it('不会覆盖供应商已有的自定义认证头', () => { @@ -69,8 +82,9 @@ describe('API 接入健康检测', () => { expect(JSON.parse(probe.data)).toEqual({ model: 'gpt-5-codex', input: 'hi', - max_output_tokens: 1, + stream: true, }); + expect(probe.protocol).toBe('openai-responses'); }); it('为 Gemini 使用默认地址并移除 models/ 前缀', () => { @@ -82,27 +96,30 @@ describe('API 接入健康检测', () => { ); expect(probe.url).toBe( - 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent', + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?alt=sse', ); expect(probe.header['x-goog-api-key']).toBe('gemini-key'); + expect(probe.protocol).toBe('gemini'); }); - it('多密钥检测部分成功时汇总最低延迟、去重错误和超时状态', () => { - const result = aggregateProviderHealthResults('test-model', [ - { success: true, latencyMs: 240 }, - { success: false, error: 'invalid key' }, - { success: true, latencyMs: 180 }, - { success: false, error: 'invalid key', timedOut: true }, - ]); + it('检测全部时每个模型只请求一次并保持结果顺序', async () => { + const requested: string[] = []; + const results = await runProviderModelHealthChecks([ + { name: 'model-a' }, + { name: 'model-b' }, + { name: 'model-c' }, + ], async (model) => { + requested.push(model.name); + return { + model: model.name, + status: 'healthy', + success: true, + firstTokenLatencyMs: 120, + }; + }, undefined, 2); - expect(result).toEqual({ - status: 'partial', - model: 'test-model', - successCount: 2, - totalCount: 4, - latencyMs: 180, - errors: ['invalid key'], - timedOut: true, - }); + expect(requested.sort()).toEqual(['model-a', 'model-b', 'model-c']); + expect(results.map((result) => result.model)).toEqual(['model-a', 'model-b', 'model-c']); }); + });