import type { Api, Model, ModelSpec, RemoteCompactionConfig } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { getVariantAliasSources, resolveVariantSelector } from "@oh-my-pi/pi-catalog/compat/collapse"; import { getBundledModelReferenceIndex, inheritReferenceThinking, resolveModelReference, } from "@oh-my-pi/pi-catalog/identity"; import { runnerApiKind } from "@oh-my-pi/pi-catalog/types"; import { logger } from "@oh-my-pi/pi-utils"; import { type ConfigHeaderResolver, type ConfigHeaderSource, createConfigHeaderResolver } from "./resolve-config-value"; import { type ModelPatch, mergeCompat, mergeRemoteCompactionConfig } from "./model-patch"; import { parseModelString } from "@oh-my-pi/pi-tui/overlays/model-selector"; import type { ModelOverride, ProviderAuthMode } from "./models-config-schema"; export interface CustomModelDefinitionLike extends ModelPatch { id: string; api?: Api; baseUrl?: string; cost?: Model["cost"]; } export interface CustomModelBuildOptions { useDefaults: boolean; } export interface CustomModelOverlay extends ModelPatch { id: string; provider: string; api: Api; baseUrl: string; cost?: Model["cost"]; isOAuth?: boolean; } function mergeCustomModelHeaders( providerHeaders: Record | undefined, modelHeaders: Record | undefined, authHeader: boolean | undefined, apiKeyConfig: string | undefined, ): ConfigHeaderResolver | undefined { return createConfigHeaderResolver([providerHeaders, modelHeaders], { authHeader, apiKeyConfig }); } export function mergeAuthHeaderSources( sources: readonly ConfigHeaderSource[], authHeader: boolean | undefined, apiKeyConfig: string | undefined, ): ConfigHeaderResolver | undefined { return createConfigHeaderResolver(sources, { authHeader, apiKeyConfig }); } /** * Decide whether a custom-yaml model should force OAuth-style request shaping. * - Explicit `auth: oauth` → force on. * endpoints are typically Claude-Code-style proxies (e.g. CLIProxyAPI) that expect * the cloaked request shape regardless of how the proxy itself is authenticated. * - Otherwise → unset. */ function resolveCustomModelIsOAuth(api: Api, providerAuth: ProviderAuthMode | undefined): boolean | undefined { if (providerAuth === "oauth") return true; if (providerAuth !== undefined) return undefined; if (api === "anthropic-messages") return true; return undefined; } export function buildCustomModelOverlay( providerName: string, providerBaseUrl: string, providerApi: Api | undefined, providerHeaders: Record | undefined, providerApiKey: string | undefined, authHeader: boolean | undefined, providerCompat: ModelSpec["compat"] | undefined, providerAuth: ProviderAuthMode | undefined, providerRemoteCompaction: RemoteCompactionConfig | undefined, modelDef: CustomModelDefinitionLike, ): CustomModelOverlay | undefined { const api = modelDef.api ?? providerApi; if (!api) return undefined; return { id: modelDef.id, provider: providerName, api, baseUrl: modelDef.baseUrl ?? providerBaseUrl, name: modelDef.name, kind: modelDef.kind ?? runnerApiKind(api), reasoning: modelDef.reasoning, thinking: modelDef.thinking, input: modelDef.input, imageInputDecoder: modelDef.imageInputDecoder, imageCountBudget: modelDef.imageCountBudget, tokenizer: modelDef.tokenizer, supportsTools: modelDef.supportsTools, cost: modelDef.cost, promptCache: modelDef.promptCache, contextWindow: modelDef.contextWindow, maxContextWindow: modelDef.maxContextWindow, maxTokens: modelDef.maxTokens, omitMaxOutputTokens: modelDef.omitMaxOutputTokens, preferWebsockets: modelDef.preferWebsockets, resolveHeaders: mergeCustomModelHeaders(providerHeaders, modelDef.headers, authHeader, providerApiKey), compat: mergeCompat(providerCompat, modelDef.compat), contextPromotionTarget: modelDef.contextPromotionTarget, compactionModel: modelDef.compactionModel, remoteCompaction: mergeRemoteCompactionConfig(providerRemoteCompaction, modelDef.remoteCompaction), premiumMultiplier: modelDef.premiumMultiplier, isOAuth: resolveCustomModelIsOAuth(api, providerAuth), }; } export function finalizeCustomModel(resolvedModel: CustomModelOverlay, options: CustomModelBuildOptions): Model { const reference = options.useDefaults ? resolveModelReference(resolvedModel.id, getBundledModelReferenceIndex()) : undefined; const cost = resolvedModel.cost ?? reference?.cost ?? (options.useDefaults ? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } : undefined); const input = resolvedModel.input ?? reference?.input ?? (options.useDefaults ? ["text"] : undefined); const supportsTools = resolvedModel.supportsTools ?? reference?.supportsTools; const built = buildModel({ id: resolvedModel.id, name: resolvedModel.name ?? (options.useDefaults ? resolvedModel.id : undefined), api: resolvedModel.api, kindConfig: resolvedModel.kind, provider: resolvedModel.provider, baseUrl: resolvedModel.baseUrl, reasoning: resolvedModel.reasoning ?? reference?.reasoning ?? (options.useDefaults ? false : undefined), thinking: inheritReferenceThinking(resolvedModel.thinking, reference, resolvedModel.provider), input: input as ("text" | "image")[], imageInputDecoder: resolvedModel.imageInputDecoder, imageCountBudget: resolvedModel.imageCountBudget, ...(supportsTools !== undefined ? { supportsTools } : {}), cost, promptCache: resolvedModel.promptCache, promptCacheConfig: resolvedModel.promptCache, contextWindow: resolvedModel.contextWindow ?? reference?.contextWindow ?? (options.useDefaults ? 128000 : null), maxTokens: resolvedModel.maxTokens ?? reference?.maxTokens ?? (options.useDefaults ? 16384 : null), headers: resolvedModel.headers, resolveHeaders: resolvedModel.resolveHeaders, omitMaxOutputTokens: resolvedModel.omitMaxOutputTokens ?? reference?.omitMaxOutputTokens, preferWebsockets: resolvedModel.preferWebsockets, compat: mergeCompat(reference?.compatConfig, resolvedModel.compat), tokenizer: resolvedModel.tokenizer, contextPromotionTarget: resolvedModel.contextPromotionTarget, compactionModel: resolvedModel.compactionModel, remoteCompaction: resolvedModel.remoteCompaction, premiumMultiplier: resolvedModel.premiumMultiplier, isOAuth: resolvedModel.isOAuth, } as ModelSpec); if (resolvedModel.cost) { // Explicit custom prices outrank catalog corrections and schedules, just // as they do for same-id model patches. built.cost = { ...resolvedModel.cost }; delete built.cost.timeBased; } // Explicit custom limits outrank the catalog `limits-patch` buildModel applied. if (resolvedModel.contextWindow !== undefined) built.contextWindow = resolvedModel.contextWindow; if (resolvedModel.maxTokens !== undefined) built.maxTokens = resolvedModel.maxTokens; return built; } export function normalizeSuppressedSelector( selector: string, hasLiveModel?: (provider: string, id: string) => boolean, ): string { const trimmed = selector.trim(); if (!trimmed) return trimmed; const parsed = parseModelString(trimmed, { allowMaxSuffix: true, allowAutoAlias: true, isLiteralModelId: (provider, id) => hasLiveModel?.(provider, id) === true, }); if (!parsed) return trimmed; // Retired effort-tier variant ids normalize to their collapsed logical id // so persisted suppressions keyed by raw member ids still bind. const aliasId = resolveVariantSelector(parsed.provider, parsed.id); return `${parsed.provider}/${aliasId ?? parsed.id}`; } /** * Look up a model's override, falling back to entries keyed by retired * effort-tier variant ids (models.yml authored before collapsing). A raw key * only re-binds when no live model holds that id. */ export function resolveModelOverrideWithAliases( overrides: Map, model: Model, hasLiveModel: (provider: string, id: string) => boolean, ): ModelOverride | undefined { const direct = overrides.get(model.id); if (direct) return direct; for (const rawId of getVariantAliasSources(model.provider, model.id)) { if (hasLiveModel(model.provider, rawId)) continue; const remapped = overrides.get(rawId); if (remapped) { logger.debug("model override re-keyed through variant alias", { provider: model.provider, from: rawId, to: model.id, }); return remapped; } } return undefined; }