1
0
forked from boos/BoosAPI
This commit is contained in:
cita
2026-03-10 16:59:43 +08:00
4 changed files with 367 additions and 77 deletions
@@ -79,7 +79,11 @@ describe('refreshModelsForAccount credential discovery', () => {
expect(result).toMatchObject({ expect(result).toMatchObject({
accountId: account.id, accountId: account.id,
refreshed: true, refreshed: true,
status: 'success',
errorCode: null,
errorMessage: '',
modelCount: 2, modelCount: 2,
modelsPreview: ['claude-sonnet-4-5-20250929', 'claude-opus-4-6'],
tokenScanned: 0, tokenScanned: 0,
discoveredByCredential: true, discoveredByCredential: true,
}); });
@@ -95,4 +99,121 @@ describe('refreshModelsForAccount credential discovery', () => {
const tokenRows = await db.select().from(schema.tokenModelAvailability).all(); const tokenRows = await db.select().from(schema.tokenModelAvailability).all();
expect(tokenRows).toHaveLength(0); 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',
});
});
}); });
+102 -25
View File
@@ -11,6 +11,24 @@ const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000; const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
const MODEL_REFRESH_BATCH_SIZE = 3; 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 { function isSiteDisabled(status?: string | null): boolean {
return (status || 'active') === 'disabled'; return (status || 'active') === 'disabled';
} }
@@ -53,7 +71,16 @@ export async function refreshModelsForAccount(accountId: number) {
.get(); .get();
if (!row) { 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; const account = row.accounts;
@@ -76,11 +103,29 @@ export async function refreshModelsForAccount(accountId: number) {
} }
if (isSiteDisabled(site.status)) { 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') { 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); const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
@@ -125,6 +170,11 @@ export async function refreshModelsForAccount(accountId: number) {
let scannedTokenCount = 0; let scannedTokenCount = 0;
let discoveredByCredential = false; let discoveredByCredential = false;
const attemptedCredentials = new Set<string>(); 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) => { const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => {
for (const modelName of models) { 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)`, `model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
), ),
); );
} catch { } catch (err) {
recordFailure(err);
models = []; models = [];
} }
if (models.length === 0) return; 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)`, `model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
), ),
); );
} catch { } catch (err) {
recordFailure(err);
models = []; models = [];
} }
@@ -204,32 +256,57 @@ export async function refreshModelsForAccount(accountId: number) {
mergeDiscoveredModels(models, latencyMs); mergeDiscoveredModels(models, latencyMs);
} }
if (accountModels.size > 0) { if (accountModels.size === 0) {
const checkedAt = new Date().toISOString(); const firstMessage = failureMessages[0] || '';
await db.insert(schema.modelAvailability).values( const errorCode = firstMessage ? classifyModelDiscoveryError(firstMessage) : 'empty_models';
Array.from(accountModels).map((modelName) => ({ const errorMessage = buildModelFailureMessage(errorCode, firstMessage);
accountId: account.id, await setAccountRuntimeHealth(account.id, {
modelName, state: 'unhealthy',
available: true, reason: errorMessage,
latencyMs: modelLatency.get(modelName) ?? null, source: 'model-discovery',
checkedAt, checkedAt: new Date().toISOString(),
})), });
).run(); return {
accountId,
if (isApiKeyConnection(account)) { refreshed: true,
await setAccountRuntimeHealth(account.id, { status: 'failed',
state: 'healthy', errorCode,
reason: '模型探测成功', errorMessage,
source: 'model-discovery', modelCount: 0,
checkedAt, modelsPreview: [],
}); tokenScanned: scannedTokenCount,
} discoveredByCredential,
discoveredApiToken: !!discoveredApiToken,
};
} }
const checkedAt = new Date().toISOString();
await db.insert(schema.modelAvailability).values(
Array.from(accountModels).map((modelName) => ({
accountId: account.id,
modelName,
available: true,
latencyMs: modelLatency.get(modelName) ?? null,
checkedAt,
})),
).run();
await setAccountRuntimeHealth(account.id, {
state: 'healthy',
reason: '模型探测成功',
source: 'model-discovery',
checkedAt,
});
const modelsPreview = Array.from(accountModels).slice(0, 10);
return { return {
accountId, accountId,
refreshed: true, refreshed: true,
status: 'success',
errorCode: null,
errorMessage: '',
modelCount: accountModels.size, modelCount: accountModels.size,
modelsPreview,
tokenScanned: scannedTokenCount, tokenScanned: scannedTokenCount,
discoveredByCredential, discoveredByCredential,
discoveredApiToken: !!discoveredApiToken, discoveredApiToken: !!discoveredApiToken,
+37 -1
View File
@@ -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 = { const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)', width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none', 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" /> : '刷新'} {actionLoading[`refresh-${a.id}`] ? <span className="spinner spinner-sm" /> : '刷新'}
</button> </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" /> : '模型'} {actionLoading[`models-${a.id}`] ? <span className="spinner spinner-sm" /> : '模型'}
</button> </button>
{capabilities.canCheckin && ( {capabilities.canCheckin && (
+57 -1
View File
@@ -1,15 +1,23 @@
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer'; import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js'; import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js'; import Accounts from './Accounts.js';
const { apiMock } = vi.hoisted(() => ({ const { apiMock, toastMock } = vi.hoisted(() => ({
apiMock: { apiMock: {
getAccounts: vi.fn(), getAccounts: vi.fn(),
getSites: vi.fn(), getSites: vi.fn(),
updateAccount: vi.fn(), updateAccount: vi.fn(),
refreshAccountHealth: 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, api: apiMock,
})); }));
vi.mock('../components/Toast.js', () => ({
ToastProvider: ({ children }: { children: ReactNode }) => children,
useToast: () => toastMock,
}));
function collectText(node: ReactTestInstance): string { function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => { return (node.children || []).map((child) => {
if (typeof child === 'string') return child; if (typeof child === 'string') return child;
@@ -89,4 +102,47 @@ describe('Accounts edit panel', () => {
root?.unmount(); 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();
}
});
}); });