feat(api-access): add provider health checks with multi-key status and latency
This commit is contained in:
1 parent
1edf647581
commit
fd5d1f0ba4
9 files changed
+588
-7
No files matched your search
@@ -8623,6 +8623,8 @@ struct ManagementRequest {
|
||||
path: String,
|
||||
query: Option<std::collections::HashMap<String, String>>,
|
||||
body: Option<serde_json::Value>,
|
||||
#[serde(rename = "timeoutMs")]
|
||||
timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -8648,6 +8650,9 @@ async fn management_request(
|
||||
let mut builder = client
|
||||
.request(method, management_endpoint(&config, path)?)
|
||||
.header("Authorization", management_authorization(&config)?);
|
||||
if let Some(timeout_ms) = request.timeout_ms {
|
||||
builder = builder.timeout(Duration::from_millis(timeout_ms.clamp(1_000, 120_000)));
|
||||
}
|
||||
if let Some(query) = request.query {
|
||||
builder = builder.query(&query);
|
||||
}
|
||||
|
||||
@@ -628,6 +628,18 @@ export const jaOverrides = {
|
||||
'apiAccess.status.disabled': '無効',
|
||||
'apiAccess.status.enabled': '有効',
|
||||
'apiAccess.toggleAria': '{remark} 接続の{action}スイッチ',
|
||||
'apiAccess.health.action': 'ヘルスチェック',
|
||||
'apiAccess.health.confirm': 'ヘルスチェックは上流モデルへ最小限の実リクエストを送信するため、少額の料金が発生する場合があります。続行しますか?',
|
||||
'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}',
|
||||
'common.edit': '編集',
|
||||
'apiAccess.error.enterBaseUrl': '先に Base URL を入力してください',
|
||||
'apiAccess.error.noAvailableModels': '利用可能なモデルが見つかりません',
|
||||
|
||||
@@ -631,6 +631,18 @@ export const zhCN = {
|
||||
'apiAccess.status.disabled': '已停用',
|
||||
'apiAccess.status.enabled': '已启用',
|
||||
'apiAccess.toggleAria': '{remark} 接入{action}开关',
|
||||
'apiAccess.health.action': '健康检测',
|
||||
'apiAccess.health.confirm': '健康检测会向上游模型发送一次最小真实请求,并可能产生少量费用。确定继续吗?',
|
||||
'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}',
|
||||
'common.edit': '编辑',
|
||||
'apiAccess.error.enterBaseUrl': '请先填写 Base URL',
|
||||
'apiAccess.error.noAvailableModels': '未发现可用模型',
|
||||
@@ -1523,6 +1535,18 @@ export const en: Record<MessageKey, string> = {
|
||||
'apiAccess.status.disabled': 'Disabled',
|
||||
'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.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}',
|
||||
'common.edit': 'Edit',
|
||||
'apiAccess.error.enterBaseUrl': 'Enter a Base URL first',
|
||||
'apiAccess.error.noAvailableModels': 'No available models found',
|
||||
|
||||
+111
-2
@@ -32,6 +32,11 @@ import {
|
||||
type ModelOption,
|
||||
type ModelProvider,
|
||||
} from '../services/modelService';
|
||||
import {
|
||||
checkProviderHealth,
|
||||
ProviderHealthCheckError,
|
||||
type ProviderHealthResult,
|
||||
} from '../services/providerHealthCheck';
|
||||
import { modelMatchesRule } from '../services/oauthModels';
|
||||
import { getCurrentLocale, translate, useI18n } from '../i18n';
|
||||
|
||||
@@ -71,6 +76,8 @@ type ProviderRow = {
|
||||
remark: string;
|
||||
};
|
||||
|
||||
type ProviderHealthState = { status: 'checking' } | ProviderHealthResult;
|
||||
|
||||
export type ProviderDraft = {
|
||||
name: string;
|
||||
apiKey: string;
|
||||
@@ -192,6 +199,29 @@ const rowFromRecord = (
|
||||
const providerRemarkIdentity = (section: ProviderSection, apiKeys: string[]) =>
|
||||
`${section}\u0000${apiKeys.join('\u0000')}`;
|
||||
|
||||
const providerHealthIdentity = (row: ProviderRow) => [
|
||||
row.section,
|
||||
row.index,
|
||||
row.name,
|
||||
row.baseUrl,
|
||||
row.authIndex,
|
||||
readString(row.record, 'test-model', 'testModel'),
|
||||
row.apiKeys.join('\u0000'),
|
||||
row.models.map((model) => model.name).join('\u0000'),
|
||||
].join('\u0001');
|
||||
|
||||
const providerModelType = (section: ProviderSection): ModelProvider => {
|
||||
if (section === 'gemini-api-key') return 'gemini';
|
||||
if (section === 'claude-api-key') return 'claude';
|
||||
if (section === 'codex-api-key') return 'codex';
|
||||
return 'openai';
|
||||
};
|
||||
|
||||
const providerHeadersFromRecord = (record: Record<string, unknown>) =>
|
||||
isRecord(record.headers)
|
||||
? Object.fromEntries(Object.entries(record.headers).map(([key, value]) => [key, String(value)]))
|
||||
: {};
|
||||
|
||||
export const stripResponseFields = (record: Record<string, unknown>) => {
|
||||
const next = { ...record };
|
||||
delete next['auth-index'];
|
||||
@@ -622,6 +652,7 @@ export function ApiAccessPage() {
|
||||
const [editingRow, setEditingRow] = useState<ProviderRow | null>(null);
|
||||
const [dialogDraft, setDialogDraft] = useState<ProviderDraft>(emptyProviderDraft);
|
||||
const [apiAccessRemarks, setApiAccessRemarks] = useState<Record<string, string>>({});
|
||||
const [providerHealth, setProviderHealth] = useState<Record<string, ProviderHealthState>>({});
|
||||
const activeDefinition = definitionFor(activeCategory);
|
||||
const activeSection = activeDefinition.section;
|
||||
|
||||
@@ -907,6 +938,58 @@ 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) =>
|
||||
@@ -976,11 +1059,26 @@ export function ApiAccessPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="real-provider-list">
|
||||
{rows.map((row) => (
|
||||
{rows.map((row) => {
|
||||
const health = providerHealth[providerHealthIdentity(row)];
|
||||
return (
|
||||
<article className="real-provider-row" key={`${row.section}-${row.index}-${row.authIndex}`}>
|
||||
<div className="provider-row-main">
|
||||
<div className="provider-row-title">
|
||||
<strong title={row.remark || row.name}>{row.remark || row.name}</strong>
|
||||
{health?.status === 'checking' ? (
|
||||
<span className="state-pill provider-health-pill checking">
|
||||
{t('apiAccess.health.checking')}
|
||||
</span>
|
||||
) : health ? (
|
||||
<span
|
||||
className={`state-pill provider-health-pill ${health.status === 'healthy' ? 'success' : health.status === 'partial' ? 'update' : 'error'}`}
|
||||
title={healthResultTitle(health)}
|
||||
>
|
||||
{healthStatusLabel(health.status)} · {health.successCount}/{health.totalCount}
|
||||
{health.latencyMs === undefined ? '' : ` · ${health.latencyMs} ms`}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<code title={definitionFor(row.section).openAi ? t('apiAccess.keys.count', { count: row.apiKeys.length }) : undefined}>
|
||||
{definitionFor(row.section).openAi && row.apiKeys.length > 1
|
||||
@@ -995,6 +1093,16 @@ export function ApiAccessPage() {
|
||||
{row.authIndex ? <span title={row.authIndex}>{t('apiAccess.runtimeCredential', { index: row.authIndex.slice(0, 8) })}</span> : null}
|
||||
</div>
|
||||
<div className="provider-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button provider-health-button"
|
||||
onClick={() => void checkRowHealth(row)}
|
||||
disabled={busy || health?.status === 'checking'}
|
||||
>
|
||||
{health?.status === 'checking'
|
||||
? t('apiAccess.health.checking')
|
||||
: t('apiAccess.health.action')}
|
||||
</button>
|
||||
<label className="provider-enabled-control" title={row.disabled ? t('apiAccess.enable') : t('apiAccess.disable')}>
|
||||
<span>{row.disabled ? t('apiAccess.status.disabled') : t('apiAccess.status.enabled')}</span>
|
||||
<span className="switch-control">
|
||||
@@ -1016,7 +1124,8 @@ export function ApiAccessPage() {
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -6,6 +6,7 @@ export type ManagementJson = Record<string, unknown> | unknown[] | string | numb
|
||||
type ManagementRequestOptions = {
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
body?: ManagementJson;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
const normalizeQuery = (
|
||||
@@ -34,6 +35,7 @@ async function request<T = ManagementJson>(
|
||||
path,
|
||||
query: normalizeQuery(options.query),
|
||||
body: options.body,
|
||||
timeoutMs: options.timeoutMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -41,8 +43,11 @@ async function request<T = ManagementJson>(
|
||||
export const managementApi = {
|
||||
get: <T = ManagementJson>(path: string, query?: ManagementRequestOptions['query']) =>
|
||||
request<T>('GET', path, { query }),
|
||||
post: <T = ManagementJson>(path: string, body?: ManagementJson) =>
|
||||
request<T>('POST', path, { body }),
|
||||
post: <T = ManagementJson>(
|
||||
path: string,
|
||||
body?: ManagementJson,
|
||||
options: Pick<ManagementRequestOptions, 'timeoutMs'> = {},
|
||||
) => request<T>('POST', path, { ...options, body }),
|
||||
put: <T = ManagementJson>(path: string, body?: ManagementJson) =>
|
||||
request<T>('PUT', path, { body }),
|
||||
patch: <T = ManagementJson>(path: string, body?: ManagementJson) =>
|
||||
|
||||
@@ -92,6 +92,7 @@ export async function fetchModels(
|
||||
apiKey: string,
|
||||
authIndex?: string,
|
||||
customHeaders: Record<string, string> = {},
|
||||
timeoutMs?: number,
|
||||
): Promise<ModelOption[]> {
|
||||
const normalized = baseUrl.trim() ? normalizeBaseUrl(baseUrl) : '';
|
||||
const candidates = endpointCandidates(provider, normalized);
|
||||
@@ -132,7 +133,7 @@ export async function fetchModels(
|
||||
method: 'GET',
|
||||
url: pageUrl.toString(),
|
||||
header: Object.keys(headers).length ? headers : undefined,
|
||||
});
|
||||
}, { timeoutMs });
|
||||
const status = Number(response.status_code ?? response.statusCode ?? 0);
|
||||
if (status < 200 || status >= 300) {
|
||||
lastError = apiCallErrorMessage(response);
|
||||
@@ -167,7 +168,7 @@ export async function fetchModels(
|
||||
const response = await managementApi.post<Record<string, unknown>>('/api-call', {
|
||||
method: 'GET',
|
||||
url,
|
||||
});
|
||||
}, { timeoutMs });
|
||||
const status = Number(response.status_code ?? response.statusCode ?? 0);
|
||||
if (status >= 200 && status < 300) {
|
||||
const models = normalizeModelList(response.body ?? response.bodyText);
|
||||
@@ -181,7 +182,7 @@ export async function fetchModels(
|
||||
const response = await managementApi.post<Record<string, unknown>>('/api-call', {
|
||||
method: 'GET',
|
||||
url,
|
||||
});
|
||||
}, { timeoutMs });
|
||||
const status = Number(response.status_code ?? response.statusCode ?? 0);
|
||||
if (status >= 200 && status < 300) {
|
||||
const models = normalizeModelList(response.body ?? response.bodyText);
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { apiCallErrorMessage, managementApi } from './managementApi';
|
||||
import {
|
||||
fetchModels,
|
||||
normalizeBaseUrl,
|
||||
type ModelOption,
|
||||
type ModelProvider,
|
||||
} from './modelService';
|
||||
|
||||
export const PROVIDER_HEALTH_TIMEOUT_MS = 15_000;
|
||||
|
||||
export type ProviderHealthStatus = 'healthy' | 'partial' | 'failed';
|
||||
|
||||
export type ProviderHealthProbe = {
|
||||
url: string;
|
||||
header: Record<string, string>;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export type ProviderHealthProbeResult = {
|
||||
success: boolean;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
timedOut?: boolean;
|
||||
};
|
||||
|
||||
export type ProviderHealthResult = {
|
||||
status: ProviderHealthStatus;
|
||||
model: string;
|
||||
successCount: number;
|
||||
totalCount: number;
|
||||
latencyMs?: number;
|
||||
errors: string[];
|
||||
timedOut: boolean;
|
||||
};
|
||||
|
||||
export type ProviderHealthCheckOptions = {
|
||||
provider: ModelProvider;
|
||||
baseUrl: string;
|
||||
apiKeys: string[];
|
||||
authIndex?: string;
|
||||
models: ModelOption[];
|
||||
testModel?: string;
|
||||
customHeaders?: Record<string, string>;
|
||||
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';
|
||||
return '';
|
||||
};
|
||||
|
||||
const endpointRoot = (provider: ModelProvider, baseUrl: string) => {
|
||||
const normalized = normalizeBaseUrl(baseUrl.trim() || defaultBaseUrl(provider));
|
||||
return normalized.replace(/\/(?:v1beta|v1)$/i, '');
|
||||
};
|
||||
|
||||
const hasHeader = (headers: Record<string, string>, name: string) =>
|
||||
Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
|
||||
|
||||
const headerValue = (headers: Record<string, string>, name: string) =>
|
||||
Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1] ?? '';
|
||||
|
||||
const setHeaderIfMissing = (
|
||||
headers: Record<string, string>,
|
||||
name: string,
|
||||
value: string,
|
||||
) => {
|
||||
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 buildProviderHealthProbe(
|
||||
provider: ModelProvider,
|
||||
baseUrl: string,
|
||||
model: string,
|
||||
apiKey: string,
|
||||
authIndex = '',
|
||||
customHeaders: Record<string, string> = {},
|
||||
): ProviderHealthProbe {
|
||||
const root = endpointRoot(provider, baseUrl);
|
||||
const headers = { ...customHeaders };
|
||||
const key = apiKey.trim();
|
||||
setHeaderIfMissing(headers, 'Content-Type', 'application/json');
|
||||
|
||||
if (provider === 'gemini') {
|
||||
if (key) setHeaderIfMissing(headers, 'x-goog-api-key', key);
|
||||
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`,
|
||||
header: headers,
|
||||
data: JSON.stringify({
|
||||
contents: [{ parts: [{ text: 'hi' }] }],
|
||||
generationConfig: { maxOutputTokens: 1 },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === 'claude') {
|
||||
const bearerToken = headerValue(headers, 'authorization').match(/^Bearer\s+(.+)$/i)?.[1]?.trim() ?? '';
|
||||
if (key) setHeaderIfMissing(headers, 'x-api-key', key);
|
||||
else if (bearerToken) setHeaderIfMissing(headers, 'x-api-key', bearerToken);
|
||||
else if (authIndex) setHeaderIfMissing(headers, 'x-api-key', '$TOKEN$');
|
||||
setHeaderIfMissing(headers, 'anthropic-version', '2023-06-01');
|
||||
return {
|
||||
url: `${root}/v1/messages`,
|
||||
header: headers,
|
||||
data: JSON.stringify({
|
||||
model: model.trim(),
|
||||
max_tokens: 1,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (key) setHeaderIfMissing(headers, 'Authorization', `Bearer ${key}`);
|
||||
else if (authIndex) setHeaderIfMissing(headers, 'Authorization', 'Bearer $TOKEN$');
|
||||
|
||||
if (provider === 'codex') {
|
||||
return {
|
||||
url: `${root}/v1/responses`,
|
||||
header: headers,
|
||||
data: JSON.stringify({
|
||||
model: model.trim(),
|
||||
input: 'hi',
|
||||
max_output_tokens: 1,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url: `${root}/v1/chat/completions`,
|
||||
header: headers,
|
||||
data: JSON.stringify({
|
||||
model: model.trim(),
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const isTimeoutError = (message: string) =>
|
||||
/timed?\s*out|timeout|deadline has elapsed|超时/i.test(message);
|
||||
|
||||
const errorMessage = (error: unknown) => {
|
||||
if (error instanceof Error) return error.message.trim() || error.name;
|
||||
return String(error).replace(/^Error:\s*/i, '').trim();
|
||||
};
|
||||
|
||||
export async function checkProviderHealthProbe(
|
||||
provider: ModelProvider,
|
||||
baseUrl: string,
|
||||
model: string,
|
||||
apiKey: string,
|
||||
authIndex = '',
|
||||
customHeaders: Record<string, string> = {},
|
||||
timeoutMs = PROVIDER_HEALTH_TIMEOUT_MS,
|
||||
): Promise<ProviderHealthProbeResult> {
|
||||
const probe = buildProviderHealthProbe(
|
||||
provider,
|
||||
baseUrl,
|
||||
model,
|
||||
apiKey,
|
||||
authIndex,
|
||||
customHeaders,
|
||||
);
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const response = await managementApi.post<Record<string, unknown>>('/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) {
|
||||
return {
|
||||
success: false,
|
||||
error: apiCallErrorMessage(response),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
latencyMs: Math.max(1, Math.round(performance.now() - startedAt)),
|
||||
};
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
timedOut: isTimeoutError(message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
options: ProviderHealthCheckOptions,
|
||||
): Promise<ProviderHealthResult> {
|
||||
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(
|
||||
options.provider,
|
||||
options.baseUrl,
|
||||
model,
|
||||
apiKey,
|
||||
options.authIndex,
|
||||
customHeaders,
|
||||
timeoutMs,
|
||||
)));
|
||||
return aggregateProviderHealthResults(model, results);
|
||||
}
|
||||
@@ -3407,6 +3407,18 @@ textarea:focus-visible {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.provider-health-pill {
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: var(--font-size-meta);
|
||||
}
|
||||
|
||||
.provider-health-pill.checking {
|
||||
border-color: var(--theme-d5d2cb);
|
||||
background: var(--theme-f4f1ea);
|
||||
color: var(--theme-5f5a53);
|
||||
}
|
||||
|
||||
.provider-row-meta {
|
||||
align-items: flex-end;
|
||||
flex-direction: column;
|
||||
@@ -3421,6 +3433,13 @@ textarea:focus-visible {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.provider-row-actions .provider-health-button {
|
||||
width: auto;
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.provider-enabled-control {
|
||||
display: flex;
|
||||
min-width: 108px;
|
||||
@@ -7516,6 +7535,15 @@ textarea:focus-visible {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.real-provider-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.provider-row-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.thinking-effort-options {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
aggregateProviderHealthResults,
|
||||
buildProviderHealthProbe,
|
||||
selectProviderHealthModel,
|
||||
} from '../src/services/providerHealthCheck';
|
||||
|
||||
describe('API 接入健康检测', () => {
|
||||
it('优先使用 test-model,其次使用第一个已配置模型', () => {
|
||||
const models = [{ name: 'configured-first' }, { name: 'configured-second' }];
|
||||
|
||||
expect(selectProviderHealthModel('health-model', models)).toBe('health-model');
|
||||
expect(selectProviderHealthModel('', models)).toBe('configured-first');
|
||||
expect(selectProviderHealthModel('', [])).toBe('');
|
||||
});
|
||||
|
||||
it('为 OpenAI 兼容接入构造最小 chat completions 请求并保留自定义头', () => {
|
||||
const probe = buildProviderHealthProbe(
|
||||
'openai',
|
||||
'https://openrouter.example/api/v1',
|
||||
'gpt-test',
|
||||
'secret-key',
|
||||
'',
|
||||
{ 'X-Team': 'production' },
|
||||
);
|
||||
|
||||
expect(probe.url).toBe('https://openrouter.example/api/v1/chat/completions');
|
||||
expect(probe.header).toMatchObject({
|
||||
Authorization: 'Bearer secret-key',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Team': 'production',
|
||||
});
|
||||
expect(JSON.parse(probe.data)).toEqual({
|
||||
model: 'gpt-test',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('不会覆盖供应商已有的自定义认证头', () => {
|
||||
const probe = buildProviderHealthProbe(
|
||||
'claude',
|
||||
'',
|
||||
'claude-test',
|
||||
'new-key',
|
||||
'',
|
||||
{ 'X-Api-Key': 'custom-key', 'Anthropic-Version': 'custom-version' },
|
||||
);
|
||||
|
||||
expect(probe.url).toBe('https://api.anthropic.com/v1/messages');
|
||||
expect(probe.header['X-Api-Key']).toBe('custom-key');
|
||||
expect(probe.header['Anthropic-Version']).toBe('custom-version');
|
||||
expect(probe.header['x-api-key']).toBeUndefined();
|
||||
expect(probe.header['anthropic-version']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('使用运行时凭据占位符构造 Codex responses 请求', () => {
|
||||
const probe = buildProviderHealthProbe(
|
||||
'codex',
|
||||
'https://codex.example/v1/responses',
|
||||
'gpt-5-codex',
|
||||
'',
|
||||
'runtime-auth-index',
|
||||
);
|
||||
|
||||
expect(probe.url).toBe('https://codex.example/v1/responses');
|
||||
expect(probe.header.Authorization).toBe('Bearer $TOKEN$');
|
||||
expect(JSON.parse(probe.data)).toEqual({
|
||||
model: 'gpt-5-codex',
|
||||
input: 'hi',
|
||||
max_output_tokens: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('为 Gemini 使用默认地址并移除 models/ 前缀', () => {
|
||||
const probe = buildProviderHealthProbe(
|
||||
'gemini',
|
||||
'',
|
||||
'models/gemini-2.5-flash',
|
||||
'gemini-key',
|
||||
);
|
||||
|
||||
expect(probe.url).toBe(
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent',
|
||||
);
|
||||
expect(probe.header['x-goog-api-key']).toBe('gemini-key');
|
||||
});
|
||||
|
||||
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 },
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'partial',
|
||||
model: 'test-model',
|
||||
successCount: 2,
|
||||
totalCount: 4,
|
||||
latencyMs: 180,
|
||||
errors: ['invalid key'],
|
||||
timedOut: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user