import type { CLIPProxyModelsResponse, ParsedModelEntry, ProviderModelConfig, } from './types'; import { CONSERVATIVE_DEFAULTS } from './types'; import { guessContextWindow, guessInput, guessMaxTokens, guessReasoning, } from './heuristics'; export type FetchModelsErrorKind = 'timeout' | 'http' | 'invalid_payload' | 'network'; export class FetchModelsError extends Error { readonly kind: FetchModelsErrorKind; readonly status?: number; constructor(kind: FetchModelsErrorKind, message: string, status?: number) { super(message); this.name = 'FetchModelsError'; this.kind = kind; this.status = status; } } function isTimeoutError(err: unknown): boolean { if (!err || typeof err !== 'object') return false; const e = err as { name?: string; code?: string }; return e.name === 'AbortError' || e.name === 'TimeoutError' || e.code === 'ABORT_ERR'; } function positiveNumber(value: unknown): number | undefined { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { return undefined; } return value; } /** * Extract optional context/max fields from a models entry. */ export function extractCapabilityFields(entry: Record): { contextWindow?: number; maxTokens?: number; } { const contextWindow = positiveNumber(entry.context_window) ?? positiveNumber(entry.contextWindow); const maxTokens = positiveNumber(entry.max_tokens) ?? positiveNumber(entry.maxTokens) ?? positiveNumber(entry.max_output_tokens); return { contextWindow, maxTokens }; } /** * 向 CLIProxyAPI 拉取可用模型列表。 * modelsUrl 应为完整的 models 端点(由 config.buildModelsUrl 生成)。 */ export async function fetchModelEntries( modelsUrl: string, apiKey: string, timeoutMs: number, fetchImpl: typeof fetch = fetch, ): Promise { let response: Response; try { response = await fetchImpl(modelsUrl, { headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, signal: AbortSignal.timeout(timeoutMs), }); } catch (err) { if (isTimeoutError(err)) { throw new FetchModelsError( 'timeout', `连接 CLIProxyAPI 超时(${timeoutMs}ms),请确认服务已启动`, ); } const message = err instanceof Error ? err.message : String(err); throw new FetchModelsError('network', `无法连接 CLIProxyAPI: ${message}`); } if (!response.ok) { throw new FetchModelsError( 'http', `CLIProxyAPI 返回 HTTP ${response.status}: ${response.statusText}`, response.status, ); } let payload: CLIPProxyModelsResponse; try { payload = (await response.json()) as CLIPProxyModelsResponse; } catch { throw new FetchModelsError('invalid_payload', 'CLIProxyAPI 返回的模型列表不是合法 JSON'); } if (!payload.data || !Array.isArray(payload.data)) { throw new FetchModelsError('invalid_payload', 'CLIProxyAPI 返回的模型列表格式异常'); } const entries: ParsedModelEntry[] = []; for (const raw of payload.data) { if (!raw || typeof raw !== 'object') continue; const id = (raw as { id?: unknown }).id; if (typeof id !== 'string' || !id) continue; const caps = extractCapabilityFields(raw as unknown as Record); entries.push({ id, contextWindow: caps.contextWindow, maxTokens: caps.maxTokens, }); } return entries; } /** * 将解析后的模型条目转换为 Pi ProviderModelConfig 数组。 * API 正数字段优先,否则回退启发式。 */ export function buildModelConfigs(entries: ParsedModelEntry[]): ProviderModelConfig[] { return entries.map((entry) => ({ id: entry.id, reasoning: guessReasoning(entry.id), input: guessInput(entry.id), cost: { ...CONSERVATIVE_DEFAULTS.cost }, contextWindow: entry.contextWindow ?? guessContextWindow(entry.id), maxTokens: entry.maxTokens ?? guessMaxTokens(entry.id), })); }