import { defaultModelConfig, findCuratedModel, REASONING_EFFORT_MAP, } from "./models.js"; import type { InceptronModel } from "./types.js"; /** Raw model entry as returned by Inceptron's `GET /v1/models`. */ interface RawModel { id?: string; name?: string; quantization?: string | null; context_length?: number; max_output_length?: number; input_modalities?: string[]; supported_features?: string[]; supported_sampling_parameters?: string[]; pricing?: { /** Per-token input/prompt price (decimal string). */ prompt?: string; /** Per-token output/completion price (decimal string). */ completion?: string; input_cache_reads?: string; input_cache_writes?: string; }; } interface ModelsListResponse { data?: RawModel[]; } const DISCOVERY_TIMEOUT_MS = 5_000; /** * Convert a per-token price string (e.g. "0.0000014") to USD per **million** * tokens, which is the unit pi's `cost` fields expect. Missing / non-positive * values become 0. Rounded to 4 decimals to avoid float noise. */ function perMillion(price: string | undefined): number { const n = Number.parseFloat(price ?? ""); if (!Number.isFinite(n) || n <= 0) return 0; return Math.round(n * 1e6 * 1e4) / 1e4; } /** Restrict arbitrary modality strings to the ones pi understands. */ function mapInput(modalities: string[] | undefined): ("text" | "image")[] { const allowed = (modalities ?? []).filter( (m): m is "text" | "image" => m === "text" || m === "image", ); return allowed.length > 0 ? allowed : ["text"]; } /** Append quantization (e.g. "FP8") to the display name when present. */ function displayName(raw: RawModel): string { const base = raw.name?.trim() || raw.id?.split("/").pop() || raw.id || "unknown"; const quant = raw.quantization?.trim(); return quant ? `${base} (${quant.toUpperCase()})` : base; } /** * Map a raw API model to a pi model config. Live API data is authoritative; any * field the endpoint omits is back-filled from the curated catalog entry for the * same id (if one exists), then from the generic {@link defaultModelConfig}. This * lets the curated catalog enrich discovered ids — e.g. supplying pricing when * `/models` reports none. Returns `null` for entries without an id. */ export function mapApiModel(raw: RawModel): InceptronModel | null { if (!raw.id) return null; const features = raw.supported_features ?? []; const samplingParams = raw.supported_sampling_parameters ?? []; const reasoning = features.includes("reasoning"); // Only advertise thinking levels when the model accepts the `reasoning_effort` // parameter — otherwise pi would send a field the model rejects (e.g. Kimi // reports `reasoning` but not `reasoning_effort`). const supportsEffort = samplingParams.includes("reasoning_effort"); // Enrichment chain: curated catalog entry first, generic defaults last. const curated = findCuratedModel(raw.id); const fallback = curated ?? defaultModelConfig(raw.id); // A missing/zero API price means "not reported" — fall back to curated value. const cost = ( apiPrice: string | undefined, curatedValue: number, ): number => perMillion(apiPrice) || curatedValue; return { id: raw.id, name: displayName(raw), reasoning, thinkingLevelMap: reasoning && supportsEffort ? REASONING_EFFORT_MAP : undefined, input: mapInput(raw.input_modalities), cost: { input: cost(raw.pricing?.prompt, fallback.cost.input), output: cost(raw.pricing?.completion, fallback.cost.output), cacheRead: cost(raw.pricing?.input_cache_reads, fallback.cost.cacheRead), cacheWrite: cost(raw.pricing?.input_cache_writes, fallback.cost.cacheWrite), }, contextWindow: raw.context_length ?? fallback.contextWindow, maxTokens: raw.max_output_length ?? fallback.maxTokens, }; } /** * Fetch the live model list from Inceptron's `GET {baseUrl}/models` and map each * entry to a fully-populated pi model config (cost, limits, modalities, * reasoning) via {@link mapApiModel}. * * Returns `null` on any failure (timeout, network error, non-2xx, or empty * `data`) so the caller can fall back to the curated catalog. Never throws — * startup must not be blocked by a bad endpoint. */ export async function fetchInceptronModels( baseUrl: string, apiKey: string, ): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), DISCOVERY_TIMEOUT_MS); try { const res = await fetch(`${baseUrl}/models`, { method: "GET", headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json", }, signal: controller.signal, }); if (!res.ok) return null; const payload = (await res.json()) as ModelsListResponse; const models = (payload.data ?? []) .map(mapApiModel) .filter((m): m is InceptronModel => m !== null); return models.length > 0 ? models : null; } catch { // Timeout / network / parse error — degrade to the curated fallback. return null; } finally { clearTimeout(timeout); } }