diff --git a/src/server/services/modelService.discovery.test.ts b/src/server/services/modelService.discovery.test.ts index 72fff1e..1c41ae9 100644 --- a/src/server/services/modelService.discovery.test.ts +++ b/src/server/services/modelService.discovery.test.ts @@ -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', + }); + }); }); diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 1c25190..a40571b 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -7,10 +7,28 @@ import { invalidateTokenRouterCache } from './tokenRouter.js'; import { setAccountRuntimeHealth } from './accountHealthService.js'; import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js'; -const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000; -const MODEL_DISCOVERY_TIMEOUT_MS = 12_000; -const MODEL_REFRESH_BATCH_SIZE = 3; - +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'; } @@ -52,9 +70,18 @@ export async function refreshModelsForAccount(accountId: number) { .where(eq(schema.accounts.id, accountId)) .get(); - if (!row) { - return { accountId, refreshed: false, modelCount: 0, reason: 'account_not_found' }; - } + if (!row) { + return { + accountId, + refreshed: false, + status: 'failed', + errorCode: 'account_not_found', + errorMessage: '账号不存在', + modelCount: 0, + modelsPreview: [], + reason: 'account_not_found', + }; + } const account = row.accounts; const site = row.sites; @@ -75,13 +102,31 @@ export async function refreshModelsForAccount(accountId: number) { .run(); } - if (isSiteDisabled(site.status)) { - return { accountId, refreshed: false, modelCount: 0, reason: 'site_disabled' }; - } - - if (!adapter || account.status !== 'active') { - return { accountId, refreshed: false, modelCount: 0, reason: 'adapter_or_status' }; - } + if (isSiteDisabled(site.status)) { + 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, + status: 'skipped', + errorCode: 'adapter_or_status', + errorMessage: '平台不可用或账号未激活', + modelCount: 0, + modelsPreview: [], + reason: 'adapter_or_status', + }; + } const platformUserId = resolvePlatformUserId(account.extraConfig, account.username); let discoveredApiToken: string | null = null; @@ -120,11 +165,16 @@ export async function refreshModelsForAccount(accountId: number) { } } - const accountModels = new Set(); - const modelLatency = new Map(); - let scannedTokenCount = 0; - let discoveredByCredential = false; - const attemptedCredentials = new Set(); + const accountModels = new Set(); + const modelLatency = new Map(); + let scannedTokenCount = 0; + let discoveredByCredential = false; + const attemptedCredentials = new Set(); + 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) { @@ -146,19 +196,20 @@ export async function refreshModelsForAccount(accountId: number) { attemptedCredentials.add(credential); const startedAt = Date.now(); - let models: string[] = []; - try { - models = normalizeModels( - await withTimeout( - () => adapter.getModels(site.url, credential, platformUserId), - MODEL_DISCOVERY_TIMEOUT_MS, - `model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, - ), - ); - } catch { - models = []; - } - if (models.length === 0) return; + let models: string[] = []; + try { + models = normalizeModels( + await withTimeout( + () => adapter.getModels(site.url, credential, platformUserId), + MODEL_DISCOVERY_TIMEOUT_MS, + `model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, + ), + ); + } catch (err) { + recordFailure(err); + models = []; + } + if (models.length === 0) return; discoveredByCredential = true; const latencyMs = Date.now() - startedAt; mergeDiscoveredModels(models, latencyMs); @@ -171,19 +222,20 @@ export async function refreshModelsForAccount(accountId: number) { for (const token of enabledTokens) { const startedAt = Date.now(); - let models: string[] = []; - - try { - models = normalizeModels( - await withTimeout( - () => adapter.getModels(site.url, token.token, platformUserId), - MODEL_DISCOVERY_TIMEOUT_MS, - `model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, - ), - ); - } catch { - models = []; - } + let models: string[] = []; + + try { + models = normalizeModels( + await withTimeout( + () => adapter.getModels(site.url, token.token, platformUserId), + MODEL_DISCOVERY_TIMEOUT_MS, + `model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, + ), + ); + } catch (err) { + recordFailure(err); + models = []; + } if (models.length === 0) continue; @@ -204,37 +256,62 @@ export async function refreshModelsForAccount(accountId: number) { mergeDiscoveredModels(models, latencyMs); } - if (accountModels.size > 0) { - 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(); - - if (isApiKeyConnection(account)) { - await setAccountRuntimeHealth(account.id, { - state: 'healthy', - reason: '模型探测成功', - source: 'model-discovery', - checkedAt, - }); - } + 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, + }; } - - return { - accountId, - refreshed: true, - modelCount: accountModels.size, - 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 { + accountId, + refreshed: true, + status: 'success', + errorCode: null, + errorMessage: '', + modelCount: accountModels.size, + modelsPreview, + tokenScanned: scannedTokenCount, + discoveredByCredential, + discoveredApiToken: !!discoveredApiToken, + }; +} async function refreshModelsForAllActiveAccounts() { const accounts = await db.select({ id: schema.accounts.id }).from(schema.accounts) diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index 314b471..b28584d 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -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}`] ? : '刷新'} )} - {capabilities.canCheckin && ( diff --git a/src/web/pages/accounts.edit-panel.test.tsx b/src/web/pages/accounts.edit-panel.test.tsx index a8f6304..106172a 100644 --- a/src/web/pages/accounts.edit-panel.test.tsx +++ b/src/web/pages/accounts.edit-panel.test.tsx @@ -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 | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + 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(); + } + }); });