import type { Api, Model, ModelSpec, RemoteCompactionConfig, ThinkingConfig } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { isVertexExpressOpenAIUrl } from "@oh-my-pi/pi-catalog/hosts"; import { providerEntry } from "@oh-my-pi/pi-catalog/compat/providers"; import { PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-catalog/provider-models"; import { openrouterDecisionsBaseUrl } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; import { toModelSpec } from "@oh-my-pi/pi-catalog/provider-models/bundled-references"; import { apiServesKind, modelKind, type ModelKind, runnerApiKind } from "@oh-my-pi/pi-catalog/types"; import { isRecord } from "@oh-my-pi/pi-utils"; import { createConfigHeaderResolver } from "./resolve-config-value"; import { SPECIAL_MODEL_MANAGER_PROVIDER_IDS } from "./model-provider-discovery"; import type { ModelOverride } from "./models-config-schema"; /** Provider override config (baseUrl, headers, apiKey, compat, transport). */ export interface ProviderOverride { baseUrl?: string; /** APIs covered by the provider `baseUrl`: effective APIs of custom models * inheriting that URL, plus a provider-level `api` for override-only * configs. `undefined` preserves the historical provider-wide override. */ baseUrlApis?: readonly Api[]; headers?: Record; apiKey?: string; authHeader?: boolean; compat?: ModelSpec["compat"]; remoteCompaction?: RemoteCompactionConfig; transport?: Model["transport"]; guardrailIdentifier?: Model["guardrailIdentifier"]; guardrailVersion?: Model["guardrailVersion"]; guardrailTrace?: Model["guardrailTrace"]; requestMetadata?: Model["requestMetadata"]; } /** * Single decision point for provider `baseUrl` application, shared by every * composition path (built-in load, cached load, discovery merge, runtime * overrides). `undefined` `baseUrlApis` keeps the historical provider-wide * override; an explicit scope applies only to models whose API it covers. * `transport: "pi-native"` is provider-wide by documented contract * (docs/models.md): every model under the provider rides the auth-gateway, * so the gateway `baseUrl` follows the transport regardless of the model's * own API — a model must never end up pi-native on a catalog upstream host * (#2555). An unscoped override names the OpenRouter `/api/v1` chat root, so * Decisions models move to its `/api/alpha` sibling on the same host. */ export function resolveProviderBaseUrl( modelApi: TApi, modelBaseUrl: string | undefined, override: Pick | undefined, ): string | undefined { if (override?.baseUrl === undefined) return modelBaseUrl; if (override.transport === "pi-native") return override.baseUrl; if (override.baseUrlApis !== undefined && !override.baseUrlApis.includes(modelApi)) return modelBaseUrl; if (override.baseUrlApis === undefined && modelApi === "openrouter-decisions") { return openrouterDecisionsBaseUrl(override.baseUrl); } return override.baseUrl; } /** * Merge a freshly discovered model with the matching bundled/configured entry * (or a runtime provider override when no bundled entry exists). * * `baseUrl` resolution priority: * 1. User-set `providerOverride.baseUrl` (explicit override in models.json) * 2. Discovered baseUrl (xiaomi `tp-` token-plan keys resolve to * `token-plan-sgp.xiaomimimo.com` at discovery time) * 3. Existing bundled baseUrl (the host baked into `models.json`) * * `transport` resolution priority: * 1. `providerOverride.transport` (e.g. `pi-native` for auth-gateway users) * 2. `existing.transport` (carried over from boot-time override application) * 3. `model.transport` (rarely set — discovery defaults omit it) * * Without (1), the user's override would lose to discovery; without (2) * preferred over (3), the bundled `api.xiaomimimo.com` would shadow the * tp- token-plan host and produce 401s on the first stream call. * Without explicit transport propagation, an openrouter (or any) entry * marked `transport: pi-native` in models.yml silently reverts to the * default openai-completions transport after the background catalog * refresh — so the first `/model` switch after boot hits the raw OpenAI * chat-completions URL instead of the gateway's `/v1/pi/stream` (#2555). * * Merged config headers are retained behind an async request-boundary * resolver so discovery and catalog composition never execute `!command` * values. `authHeader` is resolved through the same hook, allowing a 401 * invalidation to re-mint both the API key and header credentials before the * retry without exposing command-evaluating property access. */ export function mergeDiscoveredModel( model: Model, existing: Model | undefined, providerOverride?: Pick< ProviderOverride, "baseUrl" | "baseUrlApis" | "compat" | "headers" | "remoteCompaction" | "transport" | "authHeader" | "apiKey" >, ): Model { if (existing) { const supportsTools = model.supportsTools ?? existing.supportsTools; const merged = buildModel({ ...toModelSpec(model), baseUrl: resolveProviderBaseUrl(model.api, model.baseUrl ?? existing.baseUrl, providerOverride), headers: undefined, resolveHeaders: createConfigHeaderResolver( [ existing.resolveHeaders ?? existing.headers, model.resolveHeaders ?? model.headers, providerOverride?.headers, ], { authHeader: providerOverride?.authHeader, apiKeyConfig: providerOverride?.apiKey, }, ), transport: providerOverride?.transport ?? existing.transport ?? model.transport, remoteCompaction: mergeProviderRemoteCompactionConfig( mergeRemoteCompactionConfig(existing.remoteCompaction, model.remoteCompaction), providerOverride?.remoteCompaction, ), ...(supportsTools !== undefined ? { supportsTools } : {}), compat: mergeCompat(model.compatConfig, providerOverride?.compat), } as ModelSpec); return Bun.deepEquals(merged, model) ? model : merged; } if (providerOverride) { const merged = buildModel({ ...toModelSpec(model), baseUrl: resolveProviderBaseUrl(model.api, model.baseUrl, providerOverride), headers: undefined, resolveHeaders: createConfigHeaderResolver([model.resolveHeaders ?? model.headers, providerOverride.headers], { authHeader: providerOverride.authHeader, apiKeyConfig: providerOverride.apiKey, }), ...(providerOverride.transport !== undefined ? { transport: providerOverride.transport } : {}), remoteCompaction: mergeProviderRemoteCompactionConfig( model.remoteCompaction, providerOverride.remoteCompaction, ), compat: mergeCompat(model.compatConfig, providerOverride.compat), } as ModelSpec); return Bun.deepEquals(merged, model) ? model : merged; } return model; } /** Built-in providers whose KDL entry makes a successful runtime roster replace bundled chat models. */ export const AUTHORITATIVE_RUNTIME_CATALOG_PROVIDERS = new Set( [...PROVIDER_DESCRIPTORS.map(descriptor => descriptor.providerId), ...SPECIAL_MODEL_MANAGER_PROVIDER_IDS].filter( providerId => providerEntry(providerId)?.dynamicModelsAuthoritative === true, ), ); function isAuthoritativeProjectCatalogModel(model: Model): boolean { return ( model.provider === "google-vertex" && model.api === "openai-completions" && isVertexExpressOpenAIUrl(model.baseUrl) ); } export function providersWithAuthoritativeProjectCatalog(models: readonly Model[]): Set { const providers = new Set(); for (const model of models) { if (isAuthoritativeProjectCatalogModel(model)) { providers.add(model.provider); } } return providers; } /** Removes a registry layer's provider models, optionally limited to one catalog kind. */ export function dropProviderModels( models: readonly Model[], providers: ReadonlySet, options?: { kind?: ModelKind }, ): Model[] { return models.filter( model => !providers.has(model.provider) || (options?.kind !== undefined && modelKind(model) !== options.kind), ); } /** * Merge `incoming` entries into a copy of `base`, keyed by `provider`+`id`. * Matches are replaced with `combine(existing, entry)`; new entries are * appended as `combine(undefined, entry)`. */ export function mergeByModelKey( base: readonly Model[], incoming: readonly T[], combine: (existing: Model | undefined, entry: T) => Model, ): Model[] { const merged = [...base]; const indexByKey = new Map(); for (let i = 0; i < merged.length; i += 1) { indexByKey.set(`${merged[i].provider}\u0000${merged[i].id}`, i); } for (const entry of incoming) { const key = `${entry.provider}\u0000${entry.id}`; const existingIndex = indexByKey.get(key); if (existingIndex !== undefined) { merged[existingIndex] = combine(merged[existingIndex], entry); } else { merged.push(combine(undefined, entry)); indexByKey.set(key, merged.length - 1); } } return merged; } export function mergeCompat( baseCompat: TBase | null | undefined, overrideCompat: TOverride | null | undefined, ): (TBase & TOverride) | TBase | TOverride | undefined { if (!baseCompat) return overrideCompat ?? undefined; if (!overrideCompat) return baseCompat; const merged: Record = { ...(baseCompat as Record) }; for (const [key, overrideValue] of Object.entries(overrideCompat)) { const baseValue = (baseCompat as Record)[key]; merged[key] = isRecord(baseValue) && isRecord(overrideValue) ? mergeCompat(baseValue, overrideValue) : overrideValue; } return merged as TBase & TOverride; } export function mergeRemoteCompactionConfig( baseConfig: RemoteCompactionConfig | undefined, overrideConfig: RemoteCompactionConfig | undefined, ): RemoteCompactionConfig | undefined { if (!baseConfig) return overrideConfig; if (!overrideConfig) return baseConfig; return { ...baseConfig, ...overrideConfig }; } export function mergeProviderRemoteCompactionConfig( modelConfig: RemoteCompactionConfig | undefined, providerConfig: RemoteCompactionConfig | undefined, ): RemoteCompactionConfig | undefined { return mergeRemoteCompactionConfig(providerConfig, modelConfig); } /** * The patchable subset of `Model` fields shared by `modelOverrides` entries, * custom model definitions, and parsed custom-model overlays. `undefined` * always means "leave the base value alone". */ export interface ModelPatch { name?: string; api?: Api; kind?: ModelKind; reasoning?: boolean; thinking?: ThinkingConfig; input?: ("text" | "image")[]; imageInputDecoder?: Model["imageInputDecoder"]; imageCountBudget?: number; tokenizer?: Model["tokenizer"]; supportsTools?: boolean; cost?: Partial["cost"]>; promptCache?: Model["promptCache"]; contextWindow?: number; /** Registry-only window preference; never patches the provider-advertised maximum. */ maxContextWindow?: number; maxTokens?: number; omitMaxOutputTokens?: boolean; /** WebSocket preference within catalog policy; explicit false disables transport selection. */ preferWebsockets?: boolean; headers?: Record; resolveHeaders?: Model["resolveHeaders"]; compat?: ModelSpec["compat"]; contextPromotionTarget?: string; compactionModel?: string; remoteCompaction?: RemoteCompactionConfig; premiumMultiplier?: number; } /** * How a patch treats the base model's transport metadata (headers/compat): * - `merge`: fold the patch into the base's (modelOverrides semantics). * - `replace`: the patch owns transport wholesale — same-id custom definitions * already folded provider-level headers/compat in during parsing, so bundled * transport metadata must not be re-merged (see `#mergeCustomModels`). */ type ModelTransportPolicy = "merge" | "replace"; export function applyModelPatch(base: Model, patch: ModelPatch, transport: ModelTransportPolicy): Model { const result = { ...base }; if (patch.name !== undefined) result.name = patch.name; if (patch.api !== undefined) result.api = patch.api; if (patch.kind !== undefined) { result.kind = patch.kind; result.kindConfig = patch.kind; } if (patch.reasoning !== undefined) { result.reasoning = patch.reasoning; // A configured capability replaces the catalog's unverified guess. if (result.thinking?.unverified) delete result.thinking; } if (patch.thinking !== undefined) result.thinking = patch.thinking; if (patch.input !== undefined) result.input = patch.input; if (patch.tokenizer !== undefined) result.tokenizer = patch.tokenizer; if (patch.imageInputDecoder !== undefined) result.imageInputDecoder = patch.imageInputDecoder; if (patch.imageCountBudget !== undefined) result.imageCountBudget = patch.imageCountBudget; if (patch.supportsTools !== undefined) result.supportsTools = patch.supportsTools; if (patch.promptCache !== undefined) { result.promptCache = patch.promptCache; result.promptCacheConfig = patch.promptCache; } if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow; if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens; if (patch.omitMaxOutputTokens !== undefined) result.omitMaxOutputTokens = patch.omitMaxOutputTokens; if (patch.preferWebsockets !== undefined) result.preferWebsockets = patch.preferWebsockets; if (patch.contextPromotionTarget !== undefined) result.contextPromotionTarget = patch.contextPromotionTarget; if (patch.compactionModel !== undefined) result.compactionModel = patch.compactionModel; if (patch.remoteCompaction !== undefined) { result.remoteCompaction = mergeRemoteCompactionConfig(base.remoteCompaction, patch.remoteCompaction); } if (patch.premiumMultiplier !== undefined) result.premiumMultiplier = patch.premiumMultiplier; if (patch.cost) { const longContext = patch.cost.longContext ?? base.cost.longContext; result.cost = { input: patch.cost.input ?? base.cost.input, output: patch.cost.output ?? base.cost.output, cacheRead: patch.cost.cacheRead ?? base.cost.cacheRead, cacheWrite: patch.cost.cacheWrite ?? base.cost.cacheWrite, ...(longContext ? { longContext } : {}), }; } let compat: ModelSpec["compat"]; if (transport === "merge") { if (patch.headers || patch.resolveHeaders) { result.headers = undefined; result.resolveHeaders = createConfigHeaderResolver([ base.resolveHeaders ?? base.headers, patch.resolveHeaders ?? patch.headers, ]); } compat = mergeCompat(base.compatConfig, patch.compat); } else { result.headers = patch.headers; result.resolveHeaders = patch.resolveHeaders; compat = patch.compat; // A same-id definition that omits a lifetime or kind must not carry the // previous route's configuration; buildModel reapplies policy for the new route. if (patch.promptCache === undefined) { delete result.promptCache; delete result.promptCacheConfig; } if (patch.kind === undefined) { delete result.kindConfig; // The base row's kind came with its old api; keep it only if the new api serves it. const kind = kindOnApi(result, result.api); if (kind !== undefined) { result.kind = kind; result.kindConfig = kind; } } } const built = buildModel({ ...toModelSpec(result), compat } as ModelSpec); if (patch.thinking !== undefined && built.thinking !== undefined) { // Config-authored capability metadata owns the explicit surface; build // first so non-reasoning and wire-disabled models still suppress it. // Prefix binding is a lineage safety invariant, not surface: keep the // rule-derived flag unless the patch sets it. const { prefixBinding } = built.thinking; built.thinking = prefixBinding ? { prefixBinding, ...patch.thinking } : patch.thinking; } // Capacity already includes registry policy and runtime metadata; rebuilding // compat must not replace it with the catalog's baseline limits. built.contextWindow = result.contextWindow; built.maxTokens = result.maxTokens; // Explicit input and cost patches outrank catalog corrections. if (patch.input !== undefined) built.input = patch.input; // Patches never change model identity. Preserve already-resolved pricing, // including earlier custom prices and the deliberate absence of a schedule. built.cost = result.cost; return built; } /** * The explicit `kind` of a `modelOverrides` entry that the api `model` ends up * on does not serve. Config validation only sees apis named in models.yml; * built-in, discovered, and `kind-apis` rows are checked here, against their * resolved api, and the override's kind is ignored. */ export function unservedOverrideKind(model: Model, override: ModelOverride): ModelKind | undefined { if (override.kind === undefined) return undefined; return apiServesKind(override.api ?? model.api, override.kind) ? undefined : override.kind; } /** * The kind `model` takes on `api`: a runner api's kind, else `undefined` (keep the * current kind) when `api` serves it, else `chat`. */ function kindOnApi(model: Model, api: Api): ModelKind | undefined { const runnerKind = runnerApiKind(api); if (runnerKind !== undefined) return runnerKind; return apiServesKind(api, modelKind(model)) ? undefined : "chat"; } /** * The kind a `modelOverrides` entry gives `model`; `undefined` leaves it alone. * An explicit kind applies when the api the model ends up on serves it. * Otherwise an `api` change takes a runner api's kind, keeps a kind the new api * still serves, and falls back to `chat`. */ function overrideKind(model: Model, override: ModelOverride): ModelKind | undefined { if (override.kind !== undefined && unservedOverrideKind(model, override) === undefined) return override.kind; return override.api === undefined ? undefined : kindOnApi(model, override.api); } export function applyModelOverride(model: Model, override: ModelOverride): Model { return applyModelPatch(model, { ...(override as ModelPatch), kind: overrideKind(model, override) }, "merge"); }