import type { ParsedModelEntry, ProviderModelConfig } from './types'; import { buildModelConfigs } from './fetcher'; export type LoadReason = 'boot' | 'session-retry' | 'manual-reload'; export type LoadUiStatus = | 'loaded' | 'empty' | 'failed_empty' | 'failed_preserved' | 'empty_preserved' | 'session_retry_skipped'; export interface LoadAttemptResult { /** Catalog that should be considered current after this attempt. */ acceptedModels: ProviderModelConfig[]; /** True when a new non-empty fetch replaced the catalog. */ didReplace: boolean; /** Whether caller should re-register provider models from acceptedModels. */ shouldRegister: boolean; ui: LoadUiStatus; /** User-facing detail (no secrets). */ message: string; sessionAutoRetryConsumed: boolean; } export type FetchEntriesFn = (timeoutMs: number) => Promise; export interface ModelLoadCoordinator { load(opts: { timeoutMs: number; reason: LoadReason }): Promise; /** Whether a non-empty catalog is currently accepted. */ hasNonEmptyCatalog(): boolean; /** Whether session_start should attempt an automatic retry. */ shouldSessionAutoRetry(): boolean; /** Snapshot of accepted models (may be empty). */ getAcceptedModels(): ProviderModelConfig[]; /** Whether a user-facing success for N models was already reported. */ consumePendingSuccessNotify(): number | null; markSuccessNotified(): void; } /** * Create the model load coordinator — primary test seam for discovery rules. */ export function createModelLoadCoordinator(fetchEntries: FetchEntriesFn): ModelLoadCoordinator { let acceptedModels: ProviderModelConfig[] = []; let sessionAutoRetryConsumed = false; let inFlight: Promise | null = null; let successNotified = false; let pendingSuccessCount: number | null = null; async function runLoad(timeoutMs: number, reason: LoadReason): Promise { if (reason === 'session-retry') { if (sessionAutoRetryConsumed) { return { acceptedModels: [...acceptedModels], didReplace: false, shouldRegister: false, ui: 'session_retry_skipped', message: '会话自动补拉已用尽,请执行 /cliproxy-reload', sessionAutoRetryConsumed, }; } sessionAutoRetryConsumed = true; } try { const entries = await fetchEntries(timeoutMs); const built = buildModelConfigs(entries); if (built.length > 0) { acceptedModels = built; pendingSuccessCount = built.length; return { acceptedModels: [...acceptedModels], didReplace: true, shouldRegister: true, ui: 'loaded', message: `已加载 ${built.length} 个模型`, sessionAutoRetryConsumed, }; } // Empty list: preserve non-empty catalog if (acceptedModels.length > 0) { return { acceptedModels: [...acceptedModels], didReplace: false, shouldRegister: false, ui: 'empty_preserved', message: 'CLIProxyAPI 返回 0 个模型,已保留现有模型列表', sessionAutoRetryConsumed, }; } acceptedModels = []; return { acceptedModels: [], didReplace: false, shouldRegister: true, // ensure empty shell is registered on first empty ui: 'empty', message: '已连接 CLIProxyAPI,但模型列表为空', sessionAutoRetryConsumed, }; } catch (err) { const detail = err instanceof Error ? err.message : String(err); if (acceptedModels.length > 0) { return { acceptedModels: [...acceptedModels], didReplace: false, shouldRegister: false, ui: 'failed_preserved', message: `${detail}(已保留现有模型列表)`, sessionAutoRetryConsumed, }; } acceptedModels = []; return { acceptedModels: [], didReplace: false, shouldRegister: true, ui: 'failed_empty', message: detail, sessionAutoRetryConsumed, }; } } return { load({ timeoutMs, reason }) { // Session-retry: short-circuit if budget already consumed if (reason === 'session-retry' && sessionAutoRetryConsumed) { return Promise.resolve({ acceptedModels: [...acceptedModels], didReplace: false, shouldRegister: false, ui: 'session_retry_skipped', message: '会话自动补拉已用尽,请执行 /cliproxy-reload', sessionAutoRetryConsumed, }); } if (inFlight) { // Session-retry joins in-flight — consume budget now so a later // session call cannot start a real second fetch. if (reason === 'session-retry') { sessionAutoRetryConsumed = true; } return inFlight; } inFlight = runLoad(timeoutMs, reason).finally(() => { inFlight = null; }); return inFlight; }, hasNonEmptyCatalog() { return acceptedModels.length > 0; }, shouldSessionAutoRetry() { return acceptedModels.length === 0 && !sessionAutoRetryConsumed; }, getAcceptedModels() { return [...acceptedModels]; }, consumePendingSuccessNotify() { if (successNotified || pendingSuccessCount === null) return null; const n = pendingSuccessCount; pendingSuccessCount = null; successNotified = true; return n; }, markSuccessNotified() { successNotified = true; pendingSuccessCount = null; }, }; }