feat: add API compatibility check feature

- Add check_api_compatibility Tauri command
- Add API compatibility check UI in Settings page
- Test all configured models with health check
- Show last successful check time
- Display warnings for 401/403/400 errors
- Fix clippy format string warnings
This commit is contained in:
coso
2025-12-14 11:13:42 +08:00
parent 172d50318a
commit 865d0c7b7e
5 changed files with 458 additions and 71 deletions
+164
View File
@@ -763,6 +763,168 @@ struct ModelInfo {
owned_by: String,
}
// ============ API Compatibility Check ============
#[derive(serde::Serialize)]
struct ApiCheckResult {
model: String,
available: bool,
status: u16,
error_type: Option<String>,
error_message: Option<String>,
time_ms: u64,
}
#[derive(serde::Serialize)]
struct ApiCompatibilityResult {
provider: String,
overall_status: String,
checked_at: String,
results: Vec<ApiCheckResult>,
warnings: Vec<String>,
}
#[tauri::command]
async fn check_api_compatibility(
state: tauri::State<'_, AppState>,
logs: tauri::State<'_, LogState>,
provider: String,
) -> Result<ApiCompatibilityResult, String> {
logs.write().await.add(
"info",
&format!("[API检测] 开始检测 {provider} API 兼容性..."),
);
let s = state.read().await;
let mut results: Vec<ApiCheckResult> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let models_to_check = match provider.as_str() {
"kiro" => vec!["claude-sonnet-4-5", "claude-3-7-sonnet-20250219"],
"gemini" => vec!["gemini-2.5-flash", "gemini-2.5-pro"],
"qwen" => vec!["qwen3-coder-plus", "qwen3-coder-flash"],
_ => vec![],
};
for model in models_to_check {
let start = std::time::Instant::now();
// 构建简单的测试请求
let test_request = crate::models::openai::ChatCompletionRequest {
model: model.to_string(),
messages: vec![crate::models::openai::ChatMessage {
role: "user".to_string(),
content: Some(crate::models::openai::MessageContent::Text(
"Hi".to_string(),
)),
tool_calls: None,
tool_call_id: None,
}],
temperature: None,
max_tokens: Some(10),
stream: false,
tools: None,
tool_choice: None,
};
let result = match provider.as_str() {
"kiro" => s.kiro_provider.call_api(&test_request).await,
_ => Err("Provider not supported for direct API check".into()),
};
let time_ms = start.elapsed().as_millis() as u64;
match result {
Ok(resp) => {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
let (available, error_type, error_message) = if (200..300).contains(&status) {
(true, None, None)
} else {
let err_type = match status {
401 => {
warnings.push(format!("模型 {model} 返回 401: Token 可能已过期或无效"));
Some("AUTH_ERROR".to_string())
}
403 => {
warnings.push(format!(
"模型 {model} 返回 403: 无权访问,可能需要刷新 Token"
));
Some("FORBIDDEN".to_string())
}
400 => {
warnings.push(format!("模型 {model} 返回 400: 请求格式可能已变更"));
Some("BAD_REQUEST".to_string())
}
404 => {
warnings.push(format!("模型 {model} 返回 404: 模型或接口可能已下线"));
Some("NOT_FOUND".to_string())
}
429 => {
warnings.push(format!("模型 {model} 返回 429: 请求过于频繁"));
Some("RATE_LIMITED".to_string())
}
500..=599 => {
warnings.push(format!("模型 {model} 返回 {status}: 服务端错误"));
Some("SERVER_ERROR".to_string())
}
_ => Some("UNKNOWN_ERROR".to_string()),
};
(
false,
err_type,
Some(body[..body.len().min(200)].to_string()),
)
};
results.push(ApiCheckResult {
model: model.to_string(),
available,
status,
error_type,
error_message,
time_ms,
});
}
Err(e) => {
warnings.push(format!("模型 {model} 请求失败: {e}"));
results.push(ApiCheckResult {
model: model.to_string(),
available: false,
status: 0,
error_type: Some("REQUEST_FAILED".to_string()),
error_message: Some(e.to_string()),
time_ms,
});
}
}
}
let overall_status = if results.iter().all(|r| r.available) {
"healthy".to_string()
} else if results.iter().any(|r| r.available) {
"partial".to_string()
} else {
"error".to_string()
};
let checked_at = chrono::Utc::now().to_rfc3339();
logs.write().await.add(
"info",
&format!("[API检测] {provider} 检测完成: {overall_status}"),
);
Ok(ApiCompatibilityResult {
provider,
overall_status,
checked_at,
results,
warnings,
})
}
#[tauri::command]
async fn get_available_models() -> Result<Vec<ModelInfo>, String> {
Ok(vec![
@@ -957,6 +1119,8 @@ pub fn run() {
clear_logs,
test_api,
get_available_models,
// API Compatibility
check_api_compatibility,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+1 -1
View File
@@ -343,7 +343,7 @@ export function Dashboard() {
</h3>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
自动监测中 (5s)
自动监测中
</div>
</div>
<div className="grid grid-cols-4 gap-4 text-sm">
+108 -68
View File
@@ -122,7 +122,7 @@ export function Providers() {
const [qwenLastSync, setQwenLastSync] = useState<Date | null>(null);
// Last check time (used for display)
const [_lastCheckTime, setLastCheckTime] = useState<Date | null>(null);
const [lastCheckTime, setLastCheckTime] = useState<Date | null>(null);
// OpenAI Custom state
const [openaiStatus, setOpenaiStatus] = useState<OpenAICustomStatus | null>(
@@ -166,80 +166,102 @@ export function Providers() {
await loadQwenStatus();
await loadOpenAICustomStatus();
await loadClaudeCustomStatus();
// Get initial hashes
try {
kiroHashRef.current = await getTokenFileHash();
geminiHashRef.current = await getGeminiTokenFileHash();
qwenHashRef.current = await getQwenTokenFileHash();
const kiroHash = await getTokenFileHash();
const geminiHash = await getGeminiTokenFileHash();
const qwenHash = await getQwenTokenFileHash();
kiroHashRef.current = kiroHash;
geminiHashRef.current = geminiHash;
qwenHashRef.current = qwenHash;
console.log("[Init] Kiro hash:", kiroHash);
console.log("[Init] Gemini hash:", geminiHash);
console.log("[Init] Qwen hash:", qwenHash);
} catch (e) {
console.error("Failed to get initial hash:", e);
}
};
init();
const interval = setInterval(checkFileChanges, 5000);
// Define checkFileChanges inside useEffect to avoid stale closure
const checkFiles = async () => {
const now = new Date();
setLastCheckTime(now);
console.log("[Check] Running file check at", now.toLocaleTimeString());
// Check Kiro
try {
console.log("[Check] Kiro current hash:", kiroHashRef.current);
const kiroResult = await checkAndReloadCredentials(kiroHashRef.current);
console.log("[Check] Kiro result:", kiroResult);
if (kiroResult.new_hash !== kiroHashRef.current) {
console.log(
"[Check] Kiro hash changed:",
kiroHashRef.current,
"->",
kiroResult.new_hash,
);
}
kiroHashRef.current = kiroResult.new_hash;
if (kiroResult.changed && kiroResult.reloaded) {
await loadKiroStatus();
setKiroLastSync(new Date());
setMessage({
type: "success",
text: "[Kiro] 检测到凭证文件变化,已自动重新加载",
});
setTimeout(() => setMessage(null), 5000);
}
} catch (e) {
console.error("Kiro check error:", e);
}
// Check Gemini
try {
const geminiResult = await checkAndReloadGeminiCredentials(
geminiHashRef.current,
);
geminiHashRef.current = geminiResult.new_hash;
if (geminiResult.changed && geminiResult.reloaded) {
await loadGeminiStatus();
setGeminiLastSync(new Date());
setMessage({
type: "success",
text: "[Gemini] 检测到凭证文件变化,已自动重新加载",
});
setTimeout(() => setMessage(null), 5000);
}
} catch (e) {
console.error("Gemini check error:", e);
}
// Check Qwen
try {
const qwenResult = await checkAndReloadQwenCredentials(
qwenHashRef.current,
);
qwenHashRef.current = qwenResult.new_hash;
if (qwenResult.changed && qwenResult.reloaded) {
await loadQwenStatus();
setQwenLastSync(new Date());
setMessage({
type: "success",
text: "[Qwen] 检测到凭证文件变化,已自动重新加载",
});
setTimeout(() => setMessage(null), 5000);
}
} catch (e) {
console.error("Qwen check error:", e);
}
};
const interval = setInterval(checkFiles, 5000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const checkFileChanges = async () => {
setLastCheckTime(new Date());
// Check Kiro
try {
const kiroResult = await checkAndReloadCredentials(kiroHashRef.current);
kiroHashRef.current = kiroResult.new_hash;
if (kiroResult.changed && kiroResult.reloaded) {
await loadKiroStatus();
setKiroLastSync(new Date());
setMessage({
type: "success",
text: "[Kiro] 检测到凭证文件变化,已自动重新加载",
});
setTimeout(() => setMessage(null), 5000);
}
} catch (e) {
console.error("Kiro check error:", e);
}
// Check Gemini
try {
const geminiResult = await checkAndReloadGeminiCredentials(
geminiHashRef.current,
);
geminiHashRef.current = geminiResult.new_hash;
if (geminiResult.changed && geminiResult.reloaded) {
await loadGeminiStatus();
setGeminiLastSync(new Date());
setMessage({
type: "success",
text: "[Gemini] 检测到凭证文件变化,已自动重新加载",
});
setTimeout(() => setMessage(null), 5000);
}
} catch (e) {
console.error("Gemini check error:", e);
}
// Check Qwen
try {
const qwenResult = await checkAndReloadQwenCredentials(
qwenHashRef.current,
);
qwenHashRef.current = qwenResult.new_hash;
if (qwenResult.changed && qwenResult.reloaded) {
await loadQwenStatus();
setQwenLastSync(new Date());
setMessage({
type: "success",
text: "[Qwen] 检测到凭证文件变化,已自动重新加载",
});
setTimeout(() => setMessage(null), 5000);
}
} catch (e) {
console.error("Qwen check error:", e);
}
};
const loadKiroStatus = async () => {
try {
const status = await getKiroCredentials();
@@ -557,9 +579,15 @@ export function Providers() {
{formatTime(kiroLastSync)}
</span>
</span>
<span>
最后检测:{" "}
<span className="text-foreground">
{formatTime(lastCheckTime)}
</span>
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
监测中 (5s)
监测中
</span>
</div>
</div>
@@ -634,9 +662,15 @@ export function Providers() {
{formatTime(geminiLastSync)}
</span>
</span>
<span>
最后检测:{" "}
<span className="text-foreground">
{formatTime(lastCheckTime)}
</span>
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
监测中 (5s)
监测中
</span>
</div>
</div>
@@ -714,9 +748,15 @@ export function Providers() {
{formatTime(qwenLastSync)}
</span>
</span>
<span>
最后检测:{" "}
<span className="text-foreground">
{formatTime(lastCheckTime)}
</span>
</span>
<span className="flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
监测中 (5s)
监测中
</span>
</div>
</div>
+160 -2
View File
@@ -1,6 +1,22 @@
import { useState, useEffect } from "react";
import { Eye, EyeOff, Copy, Check } from "lucide-react";
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
import {
Eye,
EyeOff,
Copy,
Check,
Shield,
AlertTriangle,
CheckCircle2,
XCircle,
Loader2,
} from "lucide-react";
import {
getConfig,
saveConfig,
Config,
checkApiCompatibility,
ApiCompatibilityResult,
} from "@/hooks/useTauri";
export function Settings() {
const [config, setConfig] = useState<Config | null>(null);
@@ -9,6 +25,13 @@ export function Settings() {
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<string | null>(null);
// API Compatibility Check
const [checking, setChecking] = useState(false);
const [checkResult, setCheckResult] = useState<ApiCompatibilityResult | null>(
null,
);
const [lastCheckTime, setLastCheckTime] = useState<Date | null>(null);
useEffect(() => {
loadConfig();
}, []);
@@ -44,6 +67,45 @@ export function Settings() {
}
};
const handleCheckApiCompatibility = async (provider: string) => {
setChecking(true);
setCheckResult(null);
try {
const result = await checkApiCompatibility(provider);
setCheckResult(result);
setLastCheckTime(new Date());
} catch (e) {
setMessage(`API 检测失败: ${e}`);
}
setChecking(false);
};
const getStatusIcon = (status: string) => {
switch (status) {
case "healthy":
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
case "partial":
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
case "error":
return <XCircle className="h-5 w-5 text-red-500" />;
default:
return null;
}
};
const getStatusText = (status: string) => {
switch (status) {
case "healthy":
return "所有模型可用";
case "partial":
return "部分模型可用";
case "error":
return "API 不可用";
default:
return "未知";
}
};
if (!config) {
return <div>加载中...</div>;
}
@@ -150,6 +212,102 @@ export function Settings() {
{saving ? "保存中..." : "保存设置"}
</button>
</div>
{/* API 兼容性检测 */}
<div className="max-w-2xl space-y-4 rounded-lg border bg-card p-6">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5" />
<h3 className="font-semibold">API 兼容性检测</h3>
</div>
<p className="text-sm text-muted-foreground">
检测当前配置的模型是否可用,识别 API 变更或认证问题
</p>
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleCheckApiCompatibility("kiro")}
disabled={checking}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
>
{checking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Shield className="h-4 w-4" />
)}
检测 Kiro API
</button>
</div>
{lastCheckTime && (
<p className="text-xs text-muted-foreground">
最后检测时间: {lastCheckTime.toLocaleString()}
</p>
)}
{checkResult && (
<div className="space-y-3 rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{getStatusIcon(checkResult.overall_status)}
<span className="font-medium">
{checkResult.provider.toUpperCase()} -{" "}
{getStatusText(checkResult.overall_status)}
</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(checkResult.checked_at).toLocaleString()}
</span>
</div>
{/* 模型检测结果 */}
<div className="space-y-2">
<p className="text-sm font-medium">模型状态:</p>
{checkResult.results.map((r) => (
<div
key={r.model}
className={`flex items-center justify-between rounded p-2 text-sm ${
r.available ? "bg-green-50" : "bg-red-50"
}`}
>
<div className="flex items-center gap-2">
{r.available ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-red-500" />
)}
<span>{r.model}</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{r.status > 0 && <span>HTTP {r.status}</span>}
<span>{r.time_ms}ms</span>
{r.error_type && (
<span className="rounded bg-red-100 px-1 text-red-600">
{r.error_type}
</span>
)}
</div>
</div>
))}
</div>
{/* 警告信息 */}
{checkResult.warnings.length > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium text-yellow-600">警告:</p>
{checkResult.warnings.map((w, i) => (
<div
key={i}
className="flex items-start gap-2 rounded bg-yellow-50 p-2 text-sm text-yellow-700"
>
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>{w}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
+25
View File
@@ -289,3 +289,28 @@ export interface ModelInfo {
export async function getAvailableModels(): Promise<ModelInfo[]> {
return invoke("get_available_models");
}
// ============ API Compatibility Check ============
export interface ApiCheckResult {
model: string;
available: boolean;
status: number;
error_type: string | null;
error_message: string | null;
time_ms: number;
}
export interface ApiCompatibilityResult {
provider: string;
overall_status: string;
checked_at: string;
results: ApiCheckResult[];
warnings: string[];
}
export async function checkApiCompatibility(
provider: string,
): Promise<ApiCompatibilityResult> {
return invoke("check_api_compatibility", { provider });
}