/** * pi-airpx — pure catalog mapping logic. * * This module is intentionally I/O-free and pi-import-free (plain TS only) so it * can be unit-tested in isolation. All fetching, caching, and provider * registration lives in extensions/index.ts. * * It maps rows from the proxy's `GET /v1/models` response into pi * ProviderModelConfig entries, skipping alias rows and pseudo (provider-group) * rows. */ // ── proxy /v1/models row shape (only the fields we consume) ───────────────── export interface ProxyPricing { input?: number; output?: number; cache_read?: number; cache_write?: number; } export interface ProxyModel { id: string; display_name?: string; reasoning?: boolean; supports_vision?: boolean; context_window?: number; max_output_tokens?: number; supported_parameters?: string[]; /** Upstream providers that can serve the model; absent on group rows. */ providers?: string[]; /** Owning upstream vendor; empty string on group rows. */ owned_by?: string; /** Preferred Pi API surface exposed by the proxy, e.g. openai-responses. */ recommended_api?: string; /** Back-compat/source field; mapped through apiFromCatalog(). */ api_format?: string; is_free?: boolean; /** Present ONLY on alias rows. */ alias_of?: string; /** * Server-tagged: exactly one row per underlying model carries true. This is * the proxy's own "show each model once" decision — see isAlias() for why we * must not re-derive it. Optional for backend-version skew. */ is_primary?: boolean; pricing?: ProxyPricing; effective_pricing?: ProxyPricing; /** * Effort ladder this specific model accepts, ordered minimal→max, e.g. * `["minimal","low","medium","high","xhigh"]`. Server-validated. Absent on * older backends. */ reasoning_levels?: string[]; /** * False when the server is serving its conservative fallback because nobody * annotated the model yet — NOT a real declaration. We must not turn a * fallback into a thinkingLevelMap, or an un-annotated model would silently * gain or lose levels. Absent on older backends. */ reasoning_levels_declared?: boolean; /** * Server's "offer this model by default" decision, mirroring GitHub Copilot's * `model_picker_enabled`. Absent on older backends. */ picker_enabled?: boolean; /** Server policy, mirroring Copilot's `policy.state`. Absent on older backends. */ policy?: { state?: string }; } // ── picker policy (server-decided, client-filtered) ─────────────────────────── // The proxy decides which models a client offers by default; we filter on the fly // and persist NOTHING. Publishing a model is then the only step needed to surface // it, and deprecating one removes it without touching any client config. // // FAIL-OPEN, and not as a matter of taste: when GitHub started returning // `model_picker_enabled: false` for every model, opencode's Copilot provider // surfaced ZERO models (opencode#42083). A picker must never hinge on one positive // flag — missing or unrecognised means usable. // // We also deliberately do NOT keep a client-side exclusion list. github/copilot-cli // hid `gemini-2.5-pro` from its picker via a hardcoded id set regardless of what the // API returned (copilot-cli#3633); the server is the only source of this decision. const POLICY_HIDDEN_STATES = new Set(["disabled", "deprecated"]); /** True when the server says this model should not be offered by default. */ export function isPickerHidden(m: ProxyModel): boolean { const state = String(m.policy?.state ?? "").trim().toLowerCase(); if (POLICY_HIDDEN_STATES.has(state)) return true; return m.picker_enabled === false; } // ── pi ProviderModelConfig (subset we emit) ───────────────────────────────── // thinkingLevelMap is emitted ONLY for models whose ladder the server explicitly // declares (`reasoning_levels_declared === true`). // // The original rule here was "emit none at all", because inventing levels the // upstream does not advertise silently sends unsupported effort values. That // reasoning was correct and is preserved — what changed is that the server now // states the ladder per model, so a declared level is no longer an invention. // // Everything else keeps the old behaviour byte-for-byte: an un-annotated model, // or any response from an older backend, emits no map and therefore keeps pi's // default off..high ladder. // // The cost of getting this wrong is concrete: probing grok-4.6 live showed // minimal/low/medium/high/xhigh all return 200 while `max` returns // 400 "Invalid reasoning effort". Offering a level the model rejects turns a // working model into an error the moment the user selects it. const PI_STANDARD_LEVELS = ["minimal", "low", "medium", "high"] as const; const PI_EXTENDED_LEVELS = ["xhigh", "max"] as const; /** pi thinking levels in ladder order; `off` is always implicit. */ export const PI_THINKING_LEVELS = [ ...PI_STANDARD_LEVELS, ...PI_EXTENDED_LEVELS, ] as const; /** * Build pi's tristate thinkingLevelMap from the server's declared ladder. * * Tristate per pi's docs: string = supported (that value is sent upstream), * `null` = explicitly unsupported (hidden/clamped), omitted = default. * * Returns undefined when no map must be emitted: not a reasoning model, no * server declaration, an older backend, or a ladder that adds nothing over * pi's default — a redundant map is noise that can only drift. */ export function toThinkingLevelMap( m: ProxyModel, ): Record | undefined { if (!m.reasoning) return undefined; if (m.reasoning_levels_declared !== true) return undefined; const declared = new Set( (m.reasoning_levels ?? []).map((l) => String(l).trim().toLowerCase()), ); if (declared.size === 0) return undefined; // Emit only when it differs from pi's default (standard levels on, extended off). const addsExtended = PI_EXTENDED_LEVELS.some((l) => declared.has(l)); const hidesStandard = PI_STANDARD_LEVELS.some((l) => !declared.has(l)); if (!addsExtended && !hidesStandard) return undefined; const map: Record = {}; for (const level of PI_THINKING_LEVELS) { // The proxy speaks OpenAI `reasoning_effort`, so pi's level name IS the // upstream value — no translation table that could drift out of sync. map[level] = declared.has(level) ? level : null; } return map; } export interface PiModel { id: string; name: string; reasoning: boolean; input: ("text" | "image")[]; contextWindow: number; maxTokens: number; cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; /** Per-model API override (e.g. openai-responses). */ api?: string; /** * Tristate effort ladder (string = supported, null = hidden). Emitted only * for models whose ladder the server declares; see toThinkingLevelMap(). */ thinkingLevelMap?: Record; } // ── overrides ─────────────────────────────────────────────────────────────── // // Only ONE override table remains, and it is empty. Client-side id renaming and // relabelling were both removed as DRY violations against the proxy: // // ID_OVERRIDES {claude-opus-5 → opus-5} — `opus-5` ALREADY EXISTS upstream as // an alias row (alias_of: claude-opus-5). The plugin dropped that row in // isAlias() and then re-created the same name by hand, so the alias registry // lived in two places and pi disagreed with the airpx landing page (which // shows the canonical `claude-opus-5`). The proxy owns alias policy. // // DISPLAY_NAME_OVERRIDES — `claude-opus-5` duplicated the upstream // display_name verbatim ("Claude Opus 5"), and `kimi-for-coding` is not in // the public catalog at all (dead entry). No public row ships without a // display_name, so toPiModel()'s `display_name || id` fallback is enough. // // Re-add a table here only for a value the proxy genuinely cannot express. /** * Last-resort API pinning for models the proxy does not yet describe. * Empty on purpose: `/v1/models` now ships `recommended_api`, so client-side * model-name policy would just be a second source of truth. */ export const API_OVERRIDES: Record = {}; /** * Map proxy catalog hints to Pi API names. * * Hint-driven only — the proxy owns this policy, because it knows which upstream * surface preserves signed/encrypted reasoning. `undefined` means "use the * provider default" (openai-completions). */ export function apiFromCatalog(model: ProxyModel): string | undefined { const hinted = model.recommended_api || model.api_format || API_OVERRIDES[model.id]; if (hinted === "openai-responses" || hinted === "openai-codex-responses") return "openai-responses"; return undefined; } /** * True when the row must NOT be loaded because another row already represents * the same model. * * The proxy owns this decision and ships it as `is_primary` (exactly one row per * model). We honour that flag rather than re-deriving the rule, because the * derived version drifted from the airpx landing page's copy: dropping every * row with `alias_of` loses an ORPHAN alias — an alias whose canonical row is * filtered out of the response by the public set / per-key allowlist. The site * showed such a model while pi silently lost it. * * Fallback when the field is absent (older proxy): keep the previous behaviour * and treat any `alias_of` row as a duplicate. */ export function isAlias(row: ProxyModel): boolean { if (typeof row.is_primary === "boolean") return !row.is_primary; return typeof row.alias_of === "string" && row.alias_of.length > 0; } /** * True when the row is a pseudo provider-group row rather than a real model. * * DATA-DRIVEN, not a hardcoded name list. The previous implementation kept a * copy of the server's provider registry (26 ids) and was both stale and * incomplete: 7 names no longer existed upstream while 3 live groups * (github_copilot, perplexity, xiaomi_mimo) were missing and leaked into pi as * "models". Its documented "no pricing AND no supported_parameters" fallback * was also inert — every group row ships 6 supported_parameters, so it caught * 0 of 19 groups and the hardcoded list was doing all the work. * * A group row is a placeholder for a whole upstream provider, so it carries * NONE of the identity/commercial metadata a real model must have. Requiring * all five to be absent is what keeps this safe: verified against the live * catalog it matches 22/22 group rows with 0 false positives across 219 real * models (a priced/labelled model such as the ctx:0 `legacy-model` survives). * * NOTE on the default anonymous fetch: the proxy REDACTS `providers`, * `top_provider` and `owned_by` for non-admin callers (they are trade secrets), * so on the public list those two signals are always absent and detection rests * on pricing/display_name/context_window. That is still correct in both * directions — verified on the live public list: every canonical row has all * three, and group rows have none. The provider fields are not redundant * though: they carry the keyed `AIRPX_ALL_MODELS=1` catalog, where a row may * legitimately lack pricing. */ export function isPseudoRow(row: ProxyModel): boolean { const hasPricing = Boolean(row.pricing || row.effective_pricing); const hasLabel = Boolean(row.display_name); const hasProviders = Array.isArray(row.providers) && row.providers.length > 0; const hasOwner = Boolean(row.owned_by); const hasContext = Boolean(row.context_window); return !hasPricing && !hasLabel && !hasProviders && !hasOwner && !hasContext; } /** Map one proxy model row → pi model config (pure; no override applied). */ export function toPiModel(m: ProxyModel): PiModel { const price = m.effective_pricing || m.pricing || {}; const model: PiModel = { id: m.id, name: m.display_name || m.id, reasoning: Boolean(m.reasoning), input: m.supports_vision ? ["text", "image"] : ["text"], // 0/undefined means "unknown" (server may default) — fall back. contextWindow: m.context_window || 128000, maxTokens: m.max_output_tokens || 16384, cost: { input: price.input ?? 0, output: price.output ?? 0, cacheRead: price.cache_read ?? 0, cacheWrite: price.cache_write ?? 0, }, }; const api = apiFromCatalog(m); if (api) model.api = api; const levelMap = toThinkingLevelMap(m); if (levelMap) model.thinkingLevelMap = levelMap; return model; } /** * Map a full catalog: skip alias rows (this also covers profile rows such as * `opus-5(400k)`, which always carry `alias_of`), skip pseudo provider-group * rows, then map the rest. * * Pure and stateless: the result is exactly the input catalog, so a model * removed upstream disappears instead of lingering. Ids and labels are the * server's verbatim — pi shows the same names as the airpx landing page. */ /** * Ids the server currently wants offered by default, as `airpx/` patterns. * * Used to keep `enabledModels` (pi's Ctrl+P scope) in sync with server policy, so * a newly published model appears without anyone editing settings, and a * deprecated one disappears everywhere. Registration is NOT filtered by this — * hidden models stay fully addressable, they are just not offered by default. */ export function pickerPatterns(rows: ProxyModel[], provider = "airpx"): string[] { const out: string[] = []; for (const row of rows) { if (!row || typeof row.id !== "string" || row.id.length === 0) continue; if (isAlias(row) || isPseudoRow(row)) continue; if (isPickerHidden(row)) continue; out.push(`${provider}/${row.id}`); } return out; } export function mapCatalog(rows: ProxyModel[]): PiModel[] { const out: PiModel[] = []; for (const row of rows) { if (!row || typeof row.id !== "string" || row.id.length === 0) continue; if (isAlias(row)) continue; if (isPseudoRow(row)) continue; out.push(toPiModel(row)); } return out; }