/** * Live model-catalog refresh for OpenCode Zen/Go. * * pi's provider config supports an optional `refreshModels(context)` hook: * when it resolves to a model array, pi replaces the provider's model list * and persists it to its own model-store cache (models-store.json — throttled, * restored on startup, so offline sessions keep the last catalog). Resolving * to null keeps the static catalog baseline. * * The live /models endpoints only return OpenAI-style `{id}` entries, so the * refreshed catalog merges over the static baseline: * - every static model stays (full metadata + pinned protocol routing), * - live ids missing from the static catalog are synthesized through the * same getProtocolForModel pattern fallback used for passthrough ids. */ import { GO_MODELS, ZEN_MODELS, buildProviderModels, getProtocolForModel, } from "./catalog.ts" import { GO_MODELS_URL, ZEN_MODELS_URL } from "../../config.ts" import { filterModels } from "../../visibility.ts" import type { OpenCodeModel, ProviderModelConfig } from "../../types.ts" const DEFAULT_CONTEXT_WINDOW = 200_000 const DEFAULT_MAX_TOKENS = 128_000 const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } const TIER_MODELS_URL: Record<"zen" | "go", string> = { zen: ZEN_MODELS_URL, go: GO_MODELS_URL, } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } /** Parse {"object":"list","data":[{id,...}]} (or a bare array) into model ids. */ function parseModelIds(payload: unknown): string[] | null { const data = isRecord(payload) ? payload.data : payload if (!Array.isArray(data)) return null const ids: string[] = [] for (const entry of data) { if (isRecord(entry) && typeof entry.id === "string" && entry.id.length > 0) { ids.push(entry.id) } } return ids.length > 0 ? ids : null } /** * Fetch the live model-id list for a tier. Returns null on any failure * (network error, non-OK status, abort, malformed payload) so callers fall * back to the static baseline. */ export async function fetchOpenCodeModelIds( tier: "zen" | "go", signal?: AbortSignal, ): Promise { try { const response = await fetch(TIER_MODELS_URL[tier], { signal }) if (!response.ok) return null return parseModelIds(await response.json()) } catch { return null } } /** Synthesize a catalog entry for a live id the static catalog does not know. */ function synthesizeModel(tier: "zen" | "go", id: string): OpenCodeModel { return { id, name: id, api: getProtocolForModel(tier, id), reasoning: true, input: ["text"], cost: ZERO_COST, contextWindow: DEFAULT_CONTEXT_WINDOW, maxTokens: DEFAULT_MAX_TOKENS, } } /** * Build the refreshed provider model list: static baseline ∪ live ids. * Returns null when the live fetch fails so the caller keeps the baseline. */ export async function buildRefreshedModels( tier: "zen" | "go", providerId: string, signal?: AbortSignal, ): Promise { const liveIds = await fetchOpenCodeModelIds(tier, signal) if (!liveIds) return null const baseline = tier === "zen" ? ZEN_MODELS : GO_MODELS const baselineIds = new Set(baseline.map((model) => model.id)) const merged: OpenCodeModel[] = [...baseline] for (const id of liveIds) { if (!baselineIds.has(id)) merged.push(synthesizeModel(tier, id)) } return buildProviderModels(tier, providerId, merged) } /** * Minimal structural type for pi's refreshModels context. pi's * RefreshModelsContext is not exported from pi-ai; only the fields we use are * declared (allowNetwork, signal, stored, publish), so the hook stays * compatible without importing an internal type. * * pi calls the hook in two phases per refresh: * 1. allowNetwork=false — restore the previously persisted catalog from * `context.stored` (offline/startup path). * 2. allowNetwork=true — fetch fresh; to PERSIST the result into pi's * model-store cache the hook must call `context.publish({ persist })` * itself (pi does not persist the return value for extension providers). */ export interface RefreshModelsContextLike { allowNetwork?: boolean signal?: AbortSignal stored?: { models?: unknown } | undefined publish?: (publication: { persist?: unknown update?: () => void }) => Promise | boolean } /** * Create a refreshModels hook for a tier. Never throws: returns null on any * failure so pi keeps the static baseline (pi's runtime treats a null result * as "no update"). * * Behavior: * - offline/cache phase (allowNetwork=false): return the persisted catalog * from context.stored so the last live list survives restarts; * - network phase (allowNetwork=true): fetch the live /models catalog, merge * with the static baseline, persist via context.publish({ persist }) into * pi's model-store cache, and return it for the in-memory update. */ export function createRefreshModels( tier: "zen" | "go", providerId: string, ): (context: RefreshModelsContextLike) => Promise { return async (context) => { if (context.signal?.aborted) return null // Phase 1 — restore the persisted catalog (offline/startup). Apply the // visibility filter here too, so a config change takes effect even before // the next live fetch. if (!context.allowNetwork) { const cached = context.stored?.models if (!Array.isArray(cached) || cached.length === 0) return null return filterModels(providerId, cached as ProviderModelConfig[]) } // Phase 2 — fetch fresh and persist into pi's model-store cache. The // visibility filter is applied BEFORE publish, so the persisted cache and // the returned list both already respect showOnly/hide. try { const merged = await buildRefreshedModels(tier, providerId, context.signal) if (!merged) return null const visible = filterModels(providerId, merged) if (context.publish) { await context.publish({ persist: { models: visible, checkedAt: Date.now() }, }) } return visible } catch { return null } } }