/** * Kilo Provider Extension * * Access 300+ models via the Kilo Gateway (OpenRouter-compatible) at api.kilo.ai. * Device-code flow for browser-based login, or set KILO_API_KEY. * * This module is bundled by bermudis-pi-goodies. Use /login kilo or * KILO_API_KEY after installing the goodies bundle; do not install this file * separately alongside the bundle. * * Design notes (pi 0.99.x; the refresh adapter also accepts Pi 0.80–0.84): * - Reads auth via the public ModelRegistry API (getApiKeyForProvider / * getProviderAuthStatus). The older `authStorage` map was removed upstream. * - Dynamic model catalog uses the modern ProviderConfig.refreshModels(context) * hook. A static free router keeps extension loading network-free; authenticated * catalogs are persisted through pi's model store and revalidated at most every * four hours (or when pi explicitly forces a refresh). */ import type { Api, Model, OAuthCredentials, OAuthLoginCallbacks, } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ProviderModelConfig, } from "@earendil-works/pi-coding-agent"; import { reportFailure } from "./goodies-log.ts"; import { describeError, timeoutSignal } from "./json-file.ts"; // Pi 0.99 discriminates ProviderModelConfig into chat/image/classifier entries // (union indexed access no longer yields compat/thinkingLevelMap). The Kilo // gateway serves chat models only — fetchKiloModels filters image generators — // so every config kilo produces or restores is the chat variant. type KiloModelConfig = Extract; // ============================================================================= // Constants // ============================================================================= const KILO_API_BASE = process.env.KILO_API_URL || "https://api.kilo.ai"; const KILO_GATEWAY_BASE = `${KILO_API_BASE}/api/gateway`; // Distinct endpoint for models Kilo routes through the OpenAI Responses API // (opencode.ai_sdk_provider === "openai" / current gpt-5 & o-series). Using // chat completions for these is rejected by the gateway ("please use responses"). const KILO_OPENROUTER_BASE = `${KILO_API_BASE}/api/openrouter`; const KILO_DEVICE_AUTH_ENDPOINT = `${KILO_API_BASE}/api/device-auth/codes`; const POLL_INTERVAL_MS = 3000; // Transient poll failures (network blip, 5xx, momentary DNS) must not kill a // login the user may have just approved in the browser. The poll loop retries // them on the normal cadence; only this many failures IN A ROW means the // network is genuinely down. Exported so tests assert against the real bound. export const MAX_CONSECUTIVE_POLL_ERRORS = 5; const MODELS_FETCH_TIMEOUT_MS = 10_000; // Match pi's built-in remote catalogs: model pickers should normally consume a // fresh local snapshot, not turn every open into a network round trip. const MODELS_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000; const LOGIN_REQUEST_TIMEOUT_MS = 15_000; // Kilo device-auth tokens are long-lived; treat as effectively non-expiring so // pi does not force a re-login every session. const TOKEN_EXPIRATION_MS = 365 * 24 * 60 * 60 * 1000; // 1 year const KILO_PROVIDER_ID = "kilo"; /** * The refresh-models persistence API changed in Pi 0.84: * * old: context.store.read()/write() * new: context.stored + context.publish() * * Keep the adapter structural so one bundled extension can survive both host * versions. The new shape is deliberately preferred when both are present. */ interface StoredModelCatalog { models: readonly Model[]; checkedAt?: number; } interface LegacyModelStore { read(): Promise; write(entry: StoredModelCatalog): Promise; } interface CompatibleRefreshContext { stored?: Readonly; store?: LegacyModelStore; publish?: (publication: { persist?: StoredModelCatalog | null; }) => Promise; } function compatibleRefreshContext(context: unknown): CompatibleRefreshContext { return context as CompatibleRefreshContext; } /** * The structural adapter above is intentionally untyped, which also means a * future host rename would fail silently: no restore, no persist, no error. * When the context matches NO known shape, say so (once per process) so the * next pi migration announces itself in the log instead of hiding. */ let warnedUnrecognizedPersistenceShape = false; function warnUnrecognizedPersistenceShape(operation: string): void { if (warnedUnrecognizedPersistenceShape) return; warnedUnrecognizedPersistenceShape = true; reportFailure( "kilo_warning", `[kilo] refresh-models context exposes neither publish nor store (${operation}); model catalog persistence is disabled. pi's refresh API likely changed — kilo.ts needs a compat update.`, ); } async function readStoredCatalog( context: unknown, ): Promise { const compatible = compatibleRefreshContext(context); if (compatible.stored) return compatible.stored; if (compatible.store) return compatible.store.read(); // A context with publish but no snapshot is the modern shape with nothing // persisted yet — normal, not drift. if (typeof compatible.publish !== "function") { warnUnrecognizedPersistenceShape("restore"); } return undefined; } async function publishStoredCatalog( context: unknown, entry: StoredModelCatalog, ): Promise { const compatible = compatibleRefreshContext(context); if (typeof compatible.publish === "function") { return compatible.publish({ persist: entry }); } if (compatible.store) { await compatible.store.write(entry); return true; } warnUnrecognizedPersistenceShape("persist"); // The in-memory catalog still updates; only persistence is lost. return true; } // ============================================================================= // Device authorization flow // ============================================================================= interface DeviceAuthResponse { code: string; verificationUrl: string; expiresIn: number; } interface DeviceAuthPollResponse { status: "pending" | "approved" | "denied" | "expired"; token?: string; } export function abortableSleep( ms: number, signal?: AbortSignal, ): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) return reject(new Error("Login cancelled")); // Remove the abort listener on normal completion; otherwise a login that // polls for >~10 iterations would accumulate listeners on the persistent // login signal (MaxListenersExceededWarning) and retain timer closures. let timeout: ReturnType; const onAbort = () => { clearTimeout(timeout); reject(new Error("Login cancelled")); }; timeout = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms); signal?.addEventListener("abort", onAbort, { once: true }); }); } /** Combine the login callback signal with a per-request timeout ceiling. */ function loginAbortSignal(signal?: AbortSignal): AbortSignal { return timeoutSignal(LOGIN_REQUEST_TIMEOUT_MS, signal); } async function initiateDeviceAuth( signal?: AbortSignal, ): Promise { const response = await fetch(KILO_DEVICE_AUTH_ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json" }, signal: loginAbortSignal(signal), }); if (!response.ok) { if (response.status === 429) { throw new Error( "Too many pending authorization requests. Please try again later.", ); } throw new Error( `Failed to initiate device authorization: ${response.status}`, ); } return (await response.json()) as DeviceAuthResponse; } async function pollDeviceAuth( code: string, signal?: AbortSignal, ): Promise { const response = await fetch(`${KILO_DEVICE_AUTH_ENDPOINT}/${code}`, { signal: loginAbortSignal(signal), }); if (response.status === 202) return { status: "pending" }; if (response.status === 403) return { status: "denied" }; if (response.status === 410) return { status: "expired" }; if (!response.ok) { throw new Error(`Failed to poll device authorization: ${response.status}`); } return (await response.json()) as DeviceAuthPollResponse; } async function loginKilo( callbacks: OAuthLoginCallbacks, ): Promise { callbacks.onProgress?.("Initiating device authorization..."); const { code, verificationUrl, expiresIn } = await initiateDeviceAuth( callbacks.signal, ); callbacks.onAuth({ url: verificationUrl, instructions: `Enter code: ${code}`, }); callbacks.onProgress?.("Waiting for browser authorization..."); const deadline = Date.now() + expiresIn * 1000; let consecutivePollErrors = 0; while (Date.now() < deadline) { if (callbacks.signal?.aborted) throw new Error("Login cancelled"); await abortableSleep(pollIntervalMs, callbacks.signal); let result: DeviceAuthPollResponse; try { result = await pollDeviceAuth(code, callbacks.signal); } catch (error) { // The user may have already clicked "authorize": one failed poll (5xx, // dropped connection, request timeout) must not orphan the login. Retry // on the normal cadence until MAX_CONSECUTIVE_POLL_ERRORS consecutive // failures say the network is actually down. An aborted signal still // cancels immediately instead of being retried. if (callbacks.signal?.aborted) throw error; consecutivePollErrors++; reportFailure( "kilo_warning", `[kilo] device-login poll failed (${consecutivePollErrors}/${MAX_CONSECUTIVE_POLL_ERRORS}): ${describeError(error)}`, ); callbacks.onProgress?.( `Poll failed; retrying (${consecutivePollErrors}/${MAX_CONSECUTIVE_POLL_ERRORS})...`, ); if (consecutivePollErrors >= MAX_CONSECUTIVE_POLL_ERRORS) throw error; continue; } consecutivePollErrors = 0; if (result.status === "approved") { if (!result.token) { throw new Error("Authorization approved but no token received"); } callbacks.onProgress?.("Login successful!"); return { refresh: result.token, access: result.token, expires: Date.now() + TOKEN_EXPIRATION_MS, }; } if (result.status === "denied") { throw new Error("Authorization denied by user."); } if (result.status === "expired") { throw new Error("Authorization code expired. Please try again."); } const remaining = Math.ceil((deadline - Date.now()) / 1000); callbacks.onProgress?.( `Waiting for browser authorization... (${remaining}s remaining)`, ); } throw new Error("Authentication timed out. Please try again."); } async function refreshKiloToken( credentials: OAuthCredentials, signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); // Kilo device-auth tokens are long-lived and not refreshable; if one has // expired past our 1-year horizon the user must re-run /login kilo. if (credentials.expires > Date.now()) return credentials; throw new Error( "Kilo token expired. Please run /login kilo to re-authenticate.", ); } // ============================================================================= // Model catalog (OpenRouter-compatible) // ============================================================================= export interface OpenRouterModel { id: string; name: string; context_length: number; max_completion_tokens?: number | null; pricing?: { prompt?: string | null; completion?: string | null; input_cache_write?: string | null; input_cache_read?: string | null; }; architecture?: { input_modalities?: string[] | null; output_modalities?: string[] | null; }; top_provider?: { max_completion_tokens?: number | null }; supported_parameters?: string[]; opencode?: { family?: string; prompt?: string; /** AI-SDK provider tag Kilo uses to select OpenAI (Responses) vs OpenRouter routing. */ ai_sdk_provider?: string; /** Per-variant reasoning metadata; drives the thinkingLevelMap. */ variants?: Record< string, { reasoning?: { enabled?: boolean; effort?: string }; verbosity?: string; } >; }; } export function parsePrice(price: string | null | undefined): number { if (!price) return 0; const parsed = parseFloat(price); if (isNaN(parsed)) return 0; // Kilo/OpenRouter use negative sentinels ("-1") for router models with // variable, pay-per-result pricing. That means unknown, not a credit — // surfaced 2026-05 by the kilo-smoke check. if (parsed <= 0) return 0; // OpenRouter prices are per-token; pi expects per-million-token. return parsed * 1_000_000; } export function isFreeModel(m: OpenRouterModel): boolean { const prompt = parseFloat(m.pricing?.prompt ?? "1"); const completion = parseFloat(m.pricing?.completion ?? "1"); if (prompt !== 0 || completion !== 0) return false; // Zero pricing alone is unreliable (some models report "0" but need auth). // Trust the :free suffix, Kilo-native ids (no vendor prefix), the // kilo-auto/free router, and the kilo/openrouter router families. if (m.id === "kilo-auto/free") return true; if (m.id.includes(":free")) return true; if (!m.id.includes("/")) return true; if (m.id.startsWith("kilo/") || m.id.startsWith("openrouter/")) return true; return false; } /** Whether Kilo serves this model via the OpenAI Responses API. */ export function shouldUseResponsesApi(m: OpenRouterModel): boolean { // Kilo's gateway uses opencode.ai_sdk_provider to pick the SDK path; "openai" // means the OpenAI SDK (Responses). Matches the maintained Kilo provider. if (m.opencode?.ai_sdk_provider === "openai") return true; // Metadata can lag the catalog, so also match current OpenAI reasoning / // frontier ids directly. (gpt-5* on chat completions yields "please use responses".) const shortId = m.id.includes("/") ? (m.id.split("/").pop() ?? m.id) : m.id; const s = shortId.toLowerCase(); return ( s === "gpt-5" || s.startsWith("gpt-5.") || s.startsWith("gpt-5-") || s.startsWith("o1") || s.startsWith("o3") || s.startsWith("o4") ); } /** Per-model compat for Kilo's gateway. */ export function getKiloModelCompat( m: OpenRouterModel, api: Api | undefined, ): NonNullable { // Responses-API models take OpenAIResponsesCompat, which has no thinkingFormat. // sessionAffinityFormat "openai-nosession" suppresses the underscore `session_id` // header, which Kilo's strict OpenAI-compatible gateway rejects (the canonical // provider set the pre-0.80.7 `sendSessionIdHeader:false` for exactly this; Pi // migrated that field to `sessionAffinityFormat` in 0.80.7 and the canonical // missed it). x-client-request-id and prompt_cache_key are still sent. if (api === "openai-responses") { return { sessionAffinityFormat: "openai-nosession", supportsLongCacheRetention: false, } as NonNullable; } // Chat-completions models: Kilo's gateway is OpenRouter-compatible but lives at // api.kilo.ai, so pi's URL auto-detection does NOT classify it as OpenRouter. // Without this, pi defaults to OpenAI reasoning_effort and skips cache markers. // - thinkingFormat "openrouter" -> reasoning: { effort } // - supportsStore false -> Kilo has no OpenAI "store" conversations // - cacheControlFormat "anthropic" for anthropic/* models // - requiresReasoningContentOnAssistantMessages for deepseek-v4 (replays need it) return { thinkingFormat: "openrouter", supportsStore: false, ...(m.id.startsWith("anthropic/") ? { cacheControlFormat: "anthropic" } : {}), ...(m.id === "deepseek/deepseek-v4-flash" || m.id === "deepseek/deepseek-v4-pro" ? { requiresReasoningContentOnAssistantMessages: true } : {}), } as NonNullable; } // Pi's selectable thinking levels. Kilo/OpenCode may use variant names such // as "thinking" instead of Pi level names; see thinkingLevelMapFromVariants. type PiThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; const PI_THINKING_LEVELS = [ "minimal", "low", "medium", "high", "xhigh", "max", ] as const; /** Map a Kilo/OpenCode variant to its provider reasoning effort string. */ function mapVariantEffort( variants: NonNullable["variants"], key: string, ): string | undefined { const variant = variants?.[key]; if (!variant) return undefined; const reasoning = variant.reasoning; if (!reasoning) return key; if (reasoning.enabled === false || reasoning.effort === "none") return "none"; return reasoning.effort ?? key; } /** Derive a Pi thinkingLevelMap from Kilo/OpenCode per-variant reasoning metadata. */ export function thinkingLevelMapFromVariants( variants: NonNullable["variants"], ): KiloModelConfig["thinkingLevelMap"] | undefined { if (!variants || Object.keys(variants).length === 0) return undefined; const map: Partial> = {}; const off = mapVariantEffort(variants, "none") ?? mapVariantEffort(variants, "instant"); if (off !== undefined) map.off = off; for (const level of PI_THINKING_LEVELS) { const effort = mapVariantEffort(variants, level); map[level] = effort === undefined ? null : effort; } // Kilo/OpenCode also uses descriptive variant names such as "thinking". // Use the declared effort to place those variants at the corresponding Pi // level (qwen3.7-flash, for example, declares thinking -> effort: high). for (const variantName of Object.keys(variants)) { const effort = mapVariantEffort(variants, variantName); if ( !effort || !PI_THINKING_LEVELS.includes( effort as (typeof PI_THINKING_LEVELS)[number], ) ) { continue; } const level = effort as (typeof PI_THINKING_LEVELS)[number]; if (map[level] === null) map[level] = effort; } return map as KiloModelConfig["thinkingLevelMap"]; } /** Resolve a Pi thinkingLevelMap: variant metadata first, then known fallbacks. */ export function getKiloThinkingLevelMap( m: OpenRouterModel, ): KiloModelConfig["thinkingLevelMap"] | undefined { const fromVariants = thinkingLevelMapFromVariants(m.opencode?.variants); if (fromVariants) return fromVariants; if (m.id === "deepseek/deepseek-v4-pro") { return { minimal: null, low: null, medium: null, high: "high", xhigh: null, max: "max", }; } // Safety net for the current frontier OpenAI model while Kilo/OpenRouter // model metadata is catching up. if (m.id.includes("gpt-5.5")) { return { off: "none", minimal: null, low: "low", medium: "medium", high: "high", xhigh: "xhigh", }; } return undefined; } export function modelSupportsReasoning(m: OpenRouterModel): boolean { if (m.supported_parameters?.includes("reasoning")) return true; // Kilo's catalog sometimes describes reasoning through OpenCode variants // without also listing "reasoning" in supported_parameters. The variants // are still authoritative: an enabled effort variant means Pi must expose // the model's thinking controls. return Object.values(m.opencode?.variants ?? {}).some( (variant) => variant.reasoning?.enabled === true && variant.reasoning.effort !== "none", ); } /** Map a Kilo/OpenRouter catalog entry to a Pi provider model config. Exported for the live smoke check (scripts/kilo-smoke.ts), which runs the production mapper against the real gateway. */ export function mapOpenRouterModel(m: OpenRouterModel): KiloModelConfig { const inputModalities = m.architecture?.input_modalities ?? ["text"]; const supportsImages = inputModalities.includes("image"); const supportsReasoning = modelSupportsReasoning(m); const maxTokens = m.top_provider?.max_completion_tokens ?? m.max_completion_tokens ?? Math.ceil(m.context_length * 0.2); // Responses-API models get a per-model api + baseUrl override to Kilo's // /api/openrouter endpoint; everything else stays on the provider default // (openai-completions against /api/gateway). const api = shouldUseResponsesApi(m) ? ("openai-responses" as const) : undefined; return { id: m.id, name: m.name, ...(api ? { api, baseUrl: KILO_OPENROUTER_BASE } : {}), reasoning: supportsReasoning, input: supportsImages ? ["text", "image"] : ["text"], cost: { input: parsePrice(m.pricing?.prompt), output: parsePrice(m.pricing?.completion), cacheRead: parsePrice(m.pricing?.input_cache_read), cacheWrite: parsePrice(m.pricing?.input_cache_write), }, contextWindow: m.context_length, maxTokens, thinkingLevelMap: getKiloThinkingLevelMap(m), compat: getKiloModelCompat(m, api), }; } // Keep extension loading entirely local. Previously the async extension factory // downloaded all 346 models just to discover the free subset, adding about a // second to every pi startup before the TUI could appear. The stable Kilo free // router is enough as a bootstrap; authenticated catalogs are restored from // pi's model store and refreshed in the background. const KILO_FREE_MODELS: ProviderModelConfig[] = [ mapOpenRouterModel({ id: "kilo-auto/free", name: "Auto Free", context_length: 256_000, top_provider: { max_completion_tokens: 10_000 }, pricing: { prompt: "0", completion: "0", input_cache_read: "0", input_cache_write: "0", }, architecture: { input_modalities: ["text"], output_modalities: ["text"], }, supported_parameters: ["reasoning"], }), ]; // --- Catalog health snapshot (footer badge) --------------------------------- // // refreshModels degrades silently by design: on failure it serves the last // good (or bootstrap) catalog so pickers keep working. That silence hides // gateway drift until a user notices missing models. provider-balance's // footer polls this snapshot and shows a badge while we are degraded, so // "the picker looks sparse" becomes an explicit on-screen signal. // Display-only: the refresh logic never reads it back. export interface KiloCatalogStatus { /** Models in the catalog currently served (1 = bootstrap free router only). */ modelCount: number; /** When the served catalog was last verified (epoch ms); 0 = bootstrap only. */ checkedAt: number; /** Most recent refresh attempt failed; a fallback catalog is being served. */ degraded: boolean; } const kiloCatalogStatus: KiloCatalogStatus = { modelCount: KILO_FREE_MODELS.length, checkedAt: 0, degraded: false, }; /** Snapshot for display surfaces (the provider-balance footer badge). */ export function getKiloCatalogStatus(): Readonly { return kiloCatalogStatus; } // Real poll cadence lives in POLL_INTERVAL_MS; tests shrink it so login retry // loops (which sleep between polls) run instantly. let pollIntervalMs = POLL_INTERVAL_MS; /** Tests only: shrink the poll cadence; restored by resetKiloStateForTesting. */ export function setKiloPollIntervalForTesting(ms: number): void { pollIntervalMs = ms; } /** Tests only: reset module-level warning/display state between cases. */ export function resetKiloStateForTesting(): void { warnedUnrecognizedPersistenceShape = false; pollIntervalMs = POLL_INTERVAL_MS; kiloCatalogStatus.modelCount = KILO_FREE_MODELS.length; kiloCatalogStatus.checkedAt = 0; kiloCatalogStatus.degraded = false; } function modelConfigToStoredModel(model: KiloModelConfig): Model { return { ...model, provider: KILO_PROVIDER_ID, api: model.api ?? "openai-completions", baseUrl: model.baseUrl ?? KILO_GATEWAY_BASE, }; } function storedModelToConfig(model: Model): KiloModelConfig | null { if (model.provider !== KILO_PROVIDER_ID) return null; return { id: model.id, name: model.name, ...(model.api !== "openai-completions" ? { api: model.api } : {}), ...(model.baseUrl !== KILO_GATEWAY_BASE ? { baseUrl: model.baseUrl } : {}), reasoning: model.reasoning, thinkingLevelMap: model.thinkingLevelMap, input: model.input, // Persisted catalogs outlive the mapper that wrote them: a snapshot written // before the negative-sentinel clamp (parsePrice, 0.17.0) still holds // -1e6/Mtok for Kilo's "-1" router prices. Re-clamp on restore so pi never // bills a request with a negative rate. cost: { ...model.cost, input: Math.max(0, model.cost?.input ?? 0), output: Math.max(0, model.cost?.output ?? 0), cacheRead: Math.max(0, model.cost?.cacheRead ?? 0), cacheWrite: Math.max(0, model.cost?.cacheWrite ?? 0), }, contextWindow: model.contextWindow, maxTokens: model.maxTokens, compat: model.compat, }; } /** Combine the fetch ceiling timeout with an optional caller signal. */ function modelsAbortSignal(signal?: AbortSignal): AbortSignal { return timeoutSignal(MODELS_FETCH_TIMEOUT_MS, signal); } async function fetchKiloModels(options?: { token?: string; freeOnly?: boolean; signal?: AbortSignal; }): Promise { const headers: Record = { "Content-Type": "application/json", "User-Agent": "pi-kilo-provider", }; if (options?.token) headers.Authorization = `Bearer ${options.token}`; const response = await fetch(`${KILO_GATEWAY_BASE}/models`, { headers, signal: modelsAbortSignal(options?.signal), }); if (!response.ok) { throw new Error( `Failed to fetch models: ${response.status} ${response.statusText}`, ); } const json = (await response.json()) as { data?: OpenRouterModel[] }; if (!json.data || !Array.isArray(json.data)) { throw new Error("Invalid models response: missing data array"); } const models = json.data .filter((m) => { const outputMods = m.architecture?.output_modalities ?? []; if (outputMods.includes("image")) return false; // skip image-generation if (options?.freeOnly && !isFreeModel(m)) return false; return true; }) .map(mapOpenRouterModel); // A 200 with no usable entries is a gateway failure, not a catalog. Treating // it as success would replace last-good (or the free bootstrap) with nothing // and — because the freshness window then looks satisfied — hold an empty // picker for four hours. Kilo's own gateway treats an empty model list as a // schema error for its transcription catalog. if (models.length === 0) { throw new Error("Kilo returned an empty model catalog"); } return models; } // ============================================================================= // Provider config // ============================================================================= const KILO_PROVIDER_CONFIG = { baseUrl: KILO_GATEWAY_BASE, // "$VAR" is pi's env-interpolation syntax; a bare "KILO_API_KEY" would be // treated as a literal key. When unset, this resolves to undefined and the // oauth flow / anonymous free-tier access take over. apiKey: "$KILO_API_KEY", api: "openai-completions" as const, headers: { "X-KILOCODE-EDITORNAME": "Pi", "User-Agent": "pi-kilo-provider", }, }; // ============================================================================= // Extension entry point // ============================================================================= export default function kilo(pi: ExtensionAPI): void { // The in-memory copy makes repeated refreshes (the /models flow performs two) // effectively synchronous. The persisted copy makes extension startup local // and preserves the full authenticated catalog across pi processes. let lastFullCatalog: ProviderModelConfig[] | null = null; let lastFullCatalogCheckedAt = 0; pi.registerProvider(KILO_PROVIDER_ID, { ...KILO_PROVIDER_CONFIG, models: KILO_FREE_MODELS, oauth: { name: "Kilo", login: loginKilo, refreshToken: refreshKiloToken, getApiKey: (cred: OAuthCredentials) => cred.access, }, // Called by the framework during cache-only startup, background startup // refresh, model-picker refreshes, and login. Ordinary picker opens honor a // four-hour freshness window; explicit forced refreshes bypass it. refreshModels: async (context) => { const credential = context.credential; const token = credential?.type === "oauth" ? credential.access : credential?.type === "api_key" ? (credential.key ?? null) : null; if (!token) { // Anonymous use serves the free bootstrap by design — that is not // degradation, so make sure a stale degraded flag cannot outlive a // logout. kiloCatalogStatus.degraded = false; return KILO_FREE_MODELS; } if (!lastFullCatalog) { try { const stored = await readStoredCatalog(context); const restored = (stored?.models ?? []).flatMap((model) => { const config = storedModelToConfig(model); return config ? [config] : []; }); if (restored.length > 0) { lastFullCatalog = restored; lastFullCatalogCheckedAt = stored?.checkedAt ?? 0; kiloCatalogStatus.modelCount = restored.length; kiloCatalogStatus.checkedAt = lastFullCatalogCheckedAt; kiloCatalogStatus.degraded = false; } } catch (error) { reportFailure( "kilo_warning", `[kilo] Failed to restore cached models: ${describeError(error)}`, ); } } const fallback = lastFullCatalog ?? KILO_FREE_MODELS; if (!context.allowNetwork || context.signal?.aborted) return fallback; if ( !context.force && lastFullCatalog && Date.now() - lastFullCatalogCheckedAt < MODELS_REFRESH_INTERVAL_MS ) { return lastFullCatalog; } try { const models = await fetchKiloModels({ token, signal: context.signal, }); const checkedAt = Date.now(); try { const published = await publishStoredCatalog(context, { models: models.map(modelConfigToStoredModel), checkedAt, }); if (!published) { return lastFullCatalog ?? KILO_FREE_MODELS; } } catch (error) { reportFailure( "kilo_warning", `[kilo] Failed to persist refreshed models: ${describeError( error, )}`, ); } lastFullCatalog = models; lastFullCatalogCheckedAt = checkedAt; kiloCatalogStatus.modelCount = models.length; kiloCatalogStatus.checkedAt = checkedAt; kiloCatalogStatus.degraded = false; return models; } catch (error) { // Closing a picker or starting a newer refresh aborts the old request. // Each generation owns its request so a successor never inherits an // aborted promise from the generation it superseded. if (!context.signal?.aborted) { kiloCatalogStatus.degraded = true; reportFailure( "kilo_warning", `[kilo] refreshModels fetch failed: ${describeError(error)}`, ); } return lastFullCatalog ?? KILO_FREE_MODELS; } }, }); }