forked from boos/BoosAPI
Merge pull request #50 from cita-777/codex/model-refresh-health
Codex/model refresh health
This commit is contained in:
@@ -79,7 +79,11 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
expect(result).toMatchObject({
|
||||
accountId: account.id,
|
||||
refreshed: true,
|
||||
status: 'success',
|
||||
errorCode: null,
|
||||
errorMessage: '',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['claude-sonnet-4-5-20250929', 'claude-opus-4-6'],
|
||||
tokenScanned: 0,
|
||||
discoveredByCredential: true,
|
||||
});
|
||||
@@ -95,4 +99,121 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
const tokenRows = await db.select().from(schema.tokenModelAvailability).all();
|
||||
expect(tokenRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('marks runtime health unhealthy when model discovery fails', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockRejectedValue(new Error('HTTP 401: invalid token'));
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'site-fail',
|
||||
url: 'https://site-fail.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'fail-user',
|
||||
accessToken: '',
|
||||
apiToken: 'sk-invalid',
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
|
||||
}).returning().get();
|
||||
|
||||
const result = await refreshModelsForAccount(account.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accountId: account.id,
|
||||
refreshed: true,
|
||||
modelCount: 0,
|
||||
modelsPreview: [],
|
||||
tokenScanned: 0,
|
||||
status: 'failed',
|
||||
errorCode: 'unauthorized',
|
||||
});
|
||||
|
||||
const latest = await db.select().from(schema.accounts)
|
||||
.where(eq(schema.accounts.id, account.id))
|
||||
.get();
|
||||
const parsed = JSON.parse(latest!.extraConfig || '{}');
|
||||
expect(parsed.runtimeHealth?.state).toBe('unhealthy');
|
||||
expect(parsed.runtimeHealth?.source).toBe('model-discovery');
|
||||
expect(parsed.runtimeHealth?.reason).toBe('模型获取失败,API Key 已无效');
|
||||
expect(parsed.runtimeHealth?.checkedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it('returns structured result when account missing', async () => {
|
||||
const result = await refreshModelsForAccount(9999);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accountId: 9999,
|
||||
refreshed: false,
|
||||
status: 'failed',
|
||||
errorCode: 'account_not_found',
|
||||
errorMessage: '账号不存在',
|
||||
modelCount: 0,
|
||||
modelsPreview: [],
|
||||
reason: 'account_not_found',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns structured result when site disabled', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'site-disabled',
|
||||
url: 'https://site-disabled.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'disabled',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'disabled-user',
|
||||
accessToken: 'session-token',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const result = await refreshModelsForAccount(account.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accountId: account.id,
|
||||
refreshed: false,
|
||||
status: 'skipped',
|
||||
errorCode: 'site_disabled',
|
||||
errorMessage: '站点已禁用',
|
||||
modelCount: 0,
|
||||
modelsPreview: [],
|
||||
reason: 'site_disabled',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns structured result when account inactive', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'site-inactive',
|
||||
url: 'https://site-inactive.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'inactive-user',
|
||||
accessToken: 'session-token',
|
||||
apiToken: null,
|
||||
status: 'disabled',
|
||||
}).returning().get();
|
||||
|
||||
const result = await refreshModelsForAccount(account.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accountId: account.id,
|
||||
refreshed: false,
|
||||
status: 'skipped',
|
||||
errorCode: 'adapter_or_status',
|
||||
errorMessage: '平台不可用或账号未激活',
|
||||
modelCount: 0,
|
||||
modelsPreview: [],
|
||||
reason: 'adapter_or_status',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,24 @@ const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
|
||||
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
||||
const MODEL_REFRESH_BATCH_SIZE = 3;
|
||||
|
||||
type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown';
|
||||
|
||||
function classifyModelDiscoveryError(message: string): ModelRefreshErrorCode {
|
||||
const lowered = message.toLowerCase();
|
||||
if (lowered.includes('timeout') || lowered.includes('timed out') || lowered.includes('请求超时')) return 'timeout';
|
||||
if (lowered.includes('http 401') || lowered.includes('http 403')
|
||||
|| lowered.includes('unauthorized') || lowered.includes('invalid')
|
||||
|| lowered.includes('无权') || lowered.includes('未提供令牌')) return 'unauthorized';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function buildModelFailureMessage(code: ModelRefreshErrorCode, fallback?: string) {
|
||||
if (code === 'timeout') return '模型获取失败(请求超时)';
|
||||
if (code === 'unauthorized') return '模型获取失败,API Key 已无效';
|
||||
if (code === 'empty_models') return '模型获取失败:未获取到可用模型';
|
||||
return fallback || '模型获取失败';
|
||||
}
|
||||
|
||||
function isSiteDisabled(status?: string | null): boolean {
|
||||
return (status || 'active') === 'disabled';
|
||||
}
|
||||
@@ -53,7 +71,16 @@ export async function refreshModelsForAccount(accountId: number) {
|
||||
.get();
|
||||
|
||||
if (!row) {
|
||||
return { accountId, refreshed: false, modelCount: 0, reason: 'account_not_found' };
|
||||
return {
|
||||
accountId,
|
||||
refreshed: false,
|
||||
status: 'failed',
|
||||
errorCode: 'account_not_found',
|
||||
errorMessage: '账号不存在',
|
||||
modelCount: 0,
|
||||
modelsPreview: [],
|
||||
reason: 'account_not_found',
|
||||
};
|
||||
}
|
||||
|
||||
const account = row.accounts;
|
||||
@@ -76,11 +103,29 @@ export async function refreshModelsForAccount(accountId: number) {
|
||||
}
|
||||
|
||||
if (isSiteDisabled(site.status)) {
|
||||
return { accountId, refreshed: false, modelCount: 0, reason: 'site_disabled' };
|
||||
return {
|
||||
accountId,
|
||||
refreshed: false,
|
||||
status: 'skipped',
|
||||
errorCode: 'site_disabled',
|
||||
errorMessage: '站点已禁用',
|
||||
modelCount: 0,
|
||||
modelsPreview: [],
|
||||
reason: 'site_disabled',
|
||||
};
|
||||
}
|
||||
|
||||
if (!adapter || account.status !== 'active') {
|
||||
return { accountId, refreshed: false, modelCount: 0, reason: 'adapter_or_status' };
|
||||
return {
|
||||
accountId,
|
||||
refreshed: false,
|
||||
status: 'skipped',
|
||||
errorCode: 'adapter_or_status',
|
||||
errorMessage: '平台不可用或账号未激活',
|
||||
modelCount: 0,
|
||||
modelsPreview: [],
|
||||
reason: 'adapter_or_status',
|
||||
};
|
||||
}
|
||||
|
||||
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
|
||||
@@ -125,6 +170,11 @@ export async function refreshModelsForAccount(accountId: number) {
|
||||
let scannedTokenCount = 0;
|
||||
let discoveredByCredential = false;
|
||||
const attemptedCredentials = new Set<string>();
|
||||
const failureMessages: string[] = [];
|
||||
const recordFailure = (err: unknown) => {
|
||||
const message = (err as { message?: string })?.message || String(err || '');
|
||||
if (message) failureMessages.push(message);
|
||||
};
|
||||
|
||||
const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => {
|
||||
for (const modelName of models) {
|
||||
@@ -155,7 +205,8 @@ export async function refreshModelsForAccount(accountId: number) {
|
||||
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
recordFailure(err);
|
||||
models = [];
|
||||
}
|
||||
if (models.length === 0) return;
|
||||
@@ -181,7 +232,8 @@ export async function refreshModelsForAccount(accountId: number) {
|
||||
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
recordFailure(err);
|
||||
models = [];
|
||||
}
|
||||
|
||||
@@ -204,7 +256,30 @@ export async function refreshModelsForAccount(accountId: number) {
|
||||
mergeDiscoveredModels(models, latencyMs);
|
||||
}
|
||||
|
||||
if (accountModels.size > 0) {
|
||||
if (accountModels.size === 0) {
|
||||
const firstMessage = failureMessages[0] || '';
|
||||
const errorCode = firstMessage ? classifyModelDiscoveryError(firstMessage) : 'empty_models';
|
||||
const errorMessage = buildModelFailureMessage(errorCode, firstMessage);
|
||||
await setAccountRuntimeHealth(account.id, {
|
||||
state: 'unhealthy',
|
||||
reason: errorMessage,
|
||||
source: 'model-discovery',
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
return {
|
||||
accountId,
|
||||
refreshed: true,
|
||||
status: 'failed',
|
||||
errorCode,
|
||||
errorMessage,
|
||||
modelCount: 0,
|
||||
modelsPreview: [],
|
||||
tokenScanned: scannedTokenCount,
|
||||
discoveredByCredential,
|
||||
discoveredApiToken: !!discoveredApiToken,
|
||||
};
|
||||
}
|
||||
|
||||
const checkedAt = new Date().toISOString();
|
||||
await db.insert(schema.modelAvailability).values(
|
||||
Array.from(accountModels).map((modelName) => ({
|
||||
@@ -216,20 +291,22 @@ export async function refreshModelsForAccount(accountId: number) {
|
||||
})),
|
||||
).run();
|
||||
|
||||
if (isApiKeyConnection(account)) {
|
||||
await setAccountRuntimeHealth(account.id, {
|
||||
state: 'healthy',
|
||||
reason: '模型探测成功',
|
||||
source: 'model-discovery',
|
||||
checkedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const modelsPreview = Array.from(accountModels).slice(0, 10);
|
||||
return {
|
||||
accountId,
|
||||
refreshed: true,
|
||||
status: 'success',
|
||||
errorCode: null,
|
||||
errorMessage: '',
|
||||
modelCount: accountModels.size,
|
||||
modelsPreview,
|
||||
tokenScanned: scannedTokenCount,
|
||||
discoveredByCredential,
|
||||
discoveredApiToken: !!discoveredApiToken,
|
||||
|
||||
@@ -352,6 +352,42 @@ export default function Accounts() {
|
||||
}
|
||||
};
|
||||
|
||||
const formatModelSuccess = (refresh: any) => {
|
||||
const models = Array.isArray(refresh?.modelsPreview) ? refresh.modelsPreview : [];
|
||||
const count = Number.isFinite(refresh?.modelCount) ? refresh.modelCount : models.length;
|
||||
if (models.length === 0) return `已获取到模型(共 ${count} 个)`;
|
||||
const preview = models.slice(0, 6).join('、');
|
||||
const suffix = `(共 ${count} 个)`;
|
||||
return `已获取到模型:${preview}${suffix}`;
|
||||
};
|
||||
|
||||
const formatModelFailure = (refresh: any, messageFallback?: string) => {
|
||||
const code = refresh?.errorCode;
|
||||
if (code === 'timeout') return '模型获取失败(请求超时)';
|
||||
if (code === 'unauthorized') return '模型获取失败,API Key 已无效';
|
||||
if (code === 'empty_models') return '模型获取失败:未获取到可用模型';
|
||||
return messageFallback || refresh?.errorMessage || '模型获取失败';
|
||||
};
|
||||
|
||||
const handleCheckModels = async (accountId: number) => {
|
||||
const key = `models-${accountId}`;
|
||||
setActionLoading(s => ({ ...s, [key]: true }));
|
||||
try {
|
||||
const result = await api.checkModels(accountId);
|
||||
const refresh = result?.refresh;
|
||||
if (!refresh || refresh.status !== 'success') {
|
||||
toast.error(formatModelFailure(refresh, result?.message));
|
||||
} else {
|
||||
toast.success(formatModelSuccess(refresh));
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '模型获取失败');
|
||||
} finally {
|
||||
setActionLoading(s => ({ ...s, [key]: false }));
|
||||
void load();
|
||||
}
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none',
|
||||
@@ -1685,7 +1721,7 @@ export default function Accounts() {
|
||||
{actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => withLoading(`models-${a.id}`, () => api.checkModels(a.id), '模型已更新')} disabled={actionLoading[`models-${a.id}`]} className="btn btn-link btn-link-info">
|
||||
<button onClick={() => handleCheckModels(a.id)} disabled={actionLoading[`models-${a.id}`]} className="btn btn-link btn-link-info">
|
||||
{actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
|
||||
</button>
|
||||
{capabilities.canCheckin && (
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Accounts from './Accounts.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
const { apiMock, toastMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
updateAccount: vi.fn(),
|
||||
refreshAccountHealth: vi.fn(),
|
||||
checkModels: vi.fn(),
|
||||
},
|
||||
toastMock: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -17,6 +25,11 @@ vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Toast.js', () => ({
|
||||
ToastProvider: ({ children }: { children: ReactNode }) => children,
|
||||
useToast: () => toastMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
@@ -89,4 +102,47 @@ describe('Accounts edit panel', () => {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows model refresh toast on success', async () => {
|
||||
apiMock.checkModels.mockResolvedValue({
|
||||
refresh: {
|
||||
status: 'success',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['gpt-4', 'gpt-3.5'],
|
||||
},
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const modelButtons = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('btn-link-info')
|
||||
&& collectText(node).trim() === '模型'
|
||||
));
|
||||
expect(modelButtons.length).toBeGreaterThan(0);
|
||||
|
||||
await act(async () => {
|
||||
await modelButtons[0]!.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining('已获取到模型'));
|
||||
expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining('(共 2 个)'));
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user