diff --git a/packages/coding-agent/src/config/custom-models.ts b/packages/coding-agent/src/config/custom-models.ts new file mode 100644 index 000000000..d692dc8a8 --- /dev/null +++ b/packages/coding-agent/src/config/custom-models.ts @@ -0,0 +1,188 @@ +import type { Api, Model, ModelSpec, RemoteCompactionConfig } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { + getBundledModelReferenceIndex, + inheritReferenceThinking, + resolveModelReference, +} from "@oh-my-pi/pi-catalog/identity"; +import { getVariantAliasSources, resolveVariantAlias } from "@oh-my-pi/pi-catalog/variant-collapse"; +import { logger } from "@oh-my-pi/pi-utils"; +import { createLiveConfigHeaders, type HeaderSource } from "./model-config-values"; +import { type ModelPatch, mergeCompat, mergeRemoteCompactionConfig } from "./model-patch"; +import { parseModelString } from "./model-resolver"; +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, +): Record | undefined { + return createLiveConfigHeaders([providerHeaders, modelHeaders], { authHeader, apiKeyConfig }); +} + +export function mergeAuthHeaderSources( + sources: readonly HeaderSource[], + authHeader: boolean | undefined, + apiKeyConfig: string | undefined, +): Record | undefined { + return createLiveConfigHeaders(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, + reasoning: modelDef.reasoning, + thinking: modelDef.thinking, + input: modelDef.input, + supportsTools: modelDef.supportsTools, + cost: modelDef.cost, + contextWindow: modelDef.contextWindow, + maxTokens: modelDef.maxTokens, + omitMaxOutputTokens: modelDef.omitMaxOutputTokens, + headers: 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), + }; +} + +function applyStandaloneCustomModelPolicies(model: CustomModelOverlay): CustomModelOverlay { + if (model.id !== "gpt-5.4" || model.provider === "github-copilot" || model.contextWindow !== undefined) { + return model; + } + return { ...model, contextWindow: 1_000_000 }; +} + +export function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuildOptions): Model { + const resolvedModel = options.useDefaults ? applyStandaloneCustomModelPolicies(model) : 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; + return buildModel({ + id: resolvedModel.id, + name: resolvedModel.name ?? (options.useDefaults ? resolvedModel.id : undefined), + api: resolvedModel.api, + 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")[], + ...(supportsTools !== undefined ? { supportsTools } : {}), + cost, + contextWindow: resolvedModel.contextWindow ?? reference?.contextWindow ?? (options.useDefaults ? 128000 : null), + maxTokens: resolvedModel.maxTokens ?? reference?.maxTokens ?? (options.useDefaults ? 16384 : null), + headers: resolvedModel.headers, + omitMaxOutputTokens: resolvedModel.omitMaxOutputTokens ?? reference?.omitMaxOutputTokens, + compat: mergeCompat(reference?.compatConfig, resolvedModel.compat), + contextPromotionTarget: resolvedModel.contextPromotionTarget, + compactionModel: resolvedModel.compactionModel, + remoteCompaction: resolvedModel.remoteCompaction, + premiumMultiplier: resolvedModel.premiumMultiplier, + isOAuth: resolvedModel.isOAuth, + } as ModelSpec); +} + +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 = resolveVariantAlias(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; +} diff --git a/packages/coding-agent/src/config/model-config-values.ts b/packages/coding-agent/src/config/model-config-values.ts new file mode 100644 index 000000000..13b3f3825 --- /dev/null +++ b/packages/coding-agent/src/config/model-config-values.ts @@ -0,0 +1,128 @@ +import { execSync } from "node:child_process"; +import { $envExact } from "@oh-my-pi/pi-utils"; + +const commandValueCache = new Map(); +// Failed `!command` resolutions (non-zero exit, empty stdout) are negative-cached +// with a TTL instead of forever: a transient failure (locked password manager, +// network hiccup) must not disable the key until process restart, but re-running +// the command on every resolution would restore the execSync storm this cache +// exists to prevent. One probe per TTL window bounds both. +const COMMAND_FAILURE_RETRY_MS = 30_000; +const commandFailureRetryAt = new Map(); + +export function isCommandConfigValue(valueConfig: string | undefined): valueConfig is string { + return valueConfig?.startsWith("!") === true; +} + +function resolveCommandConfig(command: string): string | undefined { + const cached = commandValueCache.get(command); + if (cached !== undefined) return cached; + const retryAt = commandFailureRetryAt.get(command); + if (retryAt !== undefined && Date.now() < retryAt) return undefined; + try { + const stdout = execSync(command, { encoding: "utf8", timeout: 10_000, windowsHide: true }); + const trimmed = stdout.trim(); + if (trimmed.length === 0) { + commandFailureRetryAt.set(command, Date.now() + COMMAND_FAILURE_RETRY_MS); + return undefined; + } + commandFailureRetryAt.delete(command); + commandValueCache.set(command, trimmed); + return trimmed; + } catch { + commandFailureRetryAt.set(command, Date.now() + COMMAND_FAILURE_RETRY_MS); + return undefined; + } +} + +export interface CommandApiKeyResolution { + configured: boolean; + value?: string; +} +/** + * Resolve a models.yml/models.yaml secret/config value to an actual value. + * `!cmd` runs a shell command and returns trimmed stdout, otherwise env vars are + * checked first and the input falls back to a literal value. + */ +export function resolveConfigValue(valueConfig: string): string | undefined { + if (valueConfig.startsWith("!")) return resolveCommandConfig(valueConfig.slice(1).trim()); + const envValue = $envExact(valueConfig); + if (envValue) return envValue; + return valueConfig; +} + +export type HeaderSource = Record | undefined; + +interface HeaderResolutionOptions { + authHeader?: boolean; + apiKeyConfig?: string; +} + +function materializeConfigHeaderSources( + sources: readonly HeaderSource[], + options?: HeaderResolutionOptions, +): Record | undefined { + const resolved: Record = {}; + for (const source of sources) { + if (!source) continue; + for (const [key, value] of Object.entries(source)) { + const next = resolveConfigValue(value); + if (next) resolved[key] = next; + } + } + if (options?.authHeader && options.apiKeyConfig) { + const resolvedKey = resolveConfigValue(options.apiKeyConfig); + if (resolvedKey) resolved.Authorization = `Bearer ${resolvedKey}`; + } + return Object.keys(resolved).length > 0 ? resolved : undefined; +} + +export function createLiveConfigHeaders( + sources: readonly HeaderSource[], + options?: HeaderResolutionOptions, +): Record | undefined { + const liveSources = sources.filter((source): source is Record => source !== undefined); + if (liveSources.length === 0 && (!options?.authHeader || !options.apiKeyConfig)) return undefined; + + const localHeaders: Record = {}; + const allSources = [...liveSources, localHeaders]; + const current = () => materializeConfigHeaderSources(allSources, options) ?? {}; + return new Proxy(localHeaders, { + get(target, property, receiver) { + if (typeof property !== "string") return Reflect.get(target, property, receiver); + return current()[property]; + }, + set(target, property, value) { + if (typeof property !== "string" || typeof value !== "string") return false; + target[property] = value; + return true; + }, + deleteProperty(target, property) { + if (typeof property !== "string") return false; + delete target[property]; + return true; + }, + has(_target, property) { + if (typeof property !== "string") return false; + return Object.hasOwn(current(), property); + }, + ownKeys() { + return Reflect.ownKeys(current()); + }, + getOwnPropertyDescriptor(_target, property) { + if (typeof property !== "string") return undefined; + const headers = current(); + if (!Object.hasOwn(headers, property)) return undefined; + return { + configurable: true, + enumerable: true, + value: headers[property], + writable: true, + }; + }, + }); +} + +export function resolveConfigHeaders(headers: Record | undefined): Record | undefined { + return materializeConfigHeaderSources([headers]); +} diff --git a/packages/coding-agent/src/config/model-patch.ts b/packages/coding-agent/src/config/model-patch.ts new file mode 100644 index 000000000..5cc8d2415 --- /dev/null +++ b/packages/coding-agent/src/config/model-patch.ts @@ -0,0 +1,252 @@ +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 { PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-catalog/provider-models"; +import { isRecord } from "@oh-my-pi/pi-utils"; +import type { ModelOverride } from "./models-config-schema"; +/** Provider override config (baseUrl, headers, apiKey, compat, transport) without custom models */ +export interface ProviderOverride { + baseUrl?: string; + headers?: Record; + apiKey?: string; + authHeader?: boolean; + compat?: ModelSpec["compat"]; + remoteCompaction?: RemoteCompactionConfig; + transport?: Model["transport"]; +} + +/** + * 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). + * See `xiaomi-tp-discovery-merge.test.ts` and the `refresh()` baseUrl-override + * regression in `model-registry.test.ts`. + */ +export function mergeDiscoveredModel( + model: Model, + existing: Model | undefined, + providerOverride?: Pick, +): Model { + if (existing) { + const supportsTools = model.supportsTools ?? existing.supportsTools; + return buildModel({ + ...model, + baseUrl: providerOverride?.baseUrl ?? model.baseUrl ?? existing.baseUrl, + headers: existing.headers ? { ...existing.headers, ...model.headers } : model.headers, + 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); + } + if (providerOverride) { + return buildModel({ + ...model, + baseUrl: providerOverride.baseUrl ?? model.baseUrl, + headers: providerOverride.headers ? { ...model.headers, ...providerOverride.headers } : model.headers, + ...(providerOverride.transport !== undefined ? { transport: providerOverride.transport } : {}), + remoteCompaction: mergeProviderRemoteCompactionConfig( + model.remoteCompaction, + providerOverride.remoteCompaction, + ), + compat: mergeCompat(model.compatConfig, providerOverride.compat), + } as ModelSpec); + } + return model; +} + +export const AUTHORITATIVE_RUNTIME_CATALOG_PROVIDERS = new Set( + PROVIDER_DESCRIPTORS.filter(descriptor => descriptor.dynamicModelsAuthoritative).map( + descriptor => descriptor.providerId, + ), +); + +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; +} + +export function dropProviderModels(models: readonly Model[], providers: ReadonlySet): Model[] { + return models.filter(model => !providers.has(model.provider)); +} + +/** + * 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); +} + +/** + * Project a built model back to spec shape for the model-manager/cache + * boundary: sparse compat comes from `compatConfig`, never from the resolved + * record. + */ +export function toModelSpec(model: Model): ModelSpec { + return { ...model, compat: model.compatConfig } as ModelSpec; +} + +/** + * 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; + reasoning?: boolean; + thinking?: ThinkingConfig; + input?: ("text" | "image")[]; + supportsTools?: boolean; + cost?: Partial["cost"]>; + contextWindow?: number; + maxTokens?: number; + omitMaxOutputTokens?: boolean; + headers?: Record; + 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.reasoning !== undefined) result.reasoning = patch.reasoning; + if (patch.thinking !== undefined) result.thinking = patch.thinking; + if (patch.input !== undefined) result.input = patch.input; + if (patch.supportsTools !== undefined) result.supportsTools = patch.supportsTools; + 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.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) { + 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, + }; + } + let compat: ModelSpec["compat"]; + if (transport === "merge") { + if (patch.headers) { + result.headers = { ...base.headers, ...patch.headers }; + } + compat = mergeCompat(base.compatConfig, patch.compat); + } else { + result.headers = patch.headers; + compat = patch.compat; + } + const built = buildModel({ ...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. + built.thinking = patch.thinking; + } + return built; +} + +export function applyModelOverride(model: Model, override: ModelOverride): Model { + return applyModelPatch(model, override as ModelPatch, "merge"); +} diff --git a/packages/coding-agent/src/config/model-provider-discovery.ts b/packages/coding-agent/src/config/model-provider-discovery.ts new file mode 100644 index 000000000..86b391ce2 --- /dev/null +++ b/packages/coding-agent/src/config/model-provider-discovery.ts @@ -0,0 +1,132 @@ +import type { Api, Model } from "@oh-my-pi/pi-ai/types"; +import { type OpenAICodexAccount, PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-catalog/provider-models"; +import type { AuthStorage, OAuthCredential } from "../session/auth-storage"; + +const SPECIAL_MODEL_MANAGER_PROVIDER_IDS: readonly string[] = [ + "google-antigravity", + "google-gemini-cli", + "openai-codex", +]; + +export const STARTUP_MODEL_CACHE_PROVIDER_IDS: readonly string[] = [ + ...PROVIDER_DESCRIPTORS.map(descriptor => descriptor.providerId), + ...SPECIAL_MODEL_MANAGER_PROVIDER_IDS, +]; + +// Sentinels for local-only OAuth tokens — declared inline to avoid loading +// provider modules at startup. Must match packages/ai/src/registry/llama-cpp.ts, +// packages/ai/src/registry/lm-studio.ts, and packages/ai/src/registry/vllm.ts. +const LOCAL_PROVIDER_PLACEHOLDERS = new Set(["llama-cpp-local", "lm-studio-local", "vllm-local"]); + +/** + * Hard bound for extension-provided fetchDynamicModels to prevent indefinite hangs + * during runtime provider discovery. Uses a cancellable manual timer (not AbortSignal.timeout) + * so a successful fast path does not leave an armed timeout signal for concurrent GC. + */ +export const RUNTIME_DYNAMIC_MODEL_FETCH_TIMEOUT_MS = 15_000; +// Built-in discovery preflight mirror of the catalog model-manager's private +// cache timings (model-manager.ts: DEFAULT_CACHE_TTL_MS / NON_AUTHORITATIVE_RETRY_MS). +// Built-in descriptors never override cacheTtlMs, so agreeing with these values +// makes the OAuth-refresh preflight fire exactly when the manager will fetch. +export const BUILT_IN_DISCOVERY_CACHE_TTL_MS = 2 * 60 * 60 * 1000; +export const BUILT_IN_DISCOVERY_NON_AUTHORITATIVE_RETRY_MS = 5 * 60 * 1000; +export const kNoAuth = "N/A"; + +export function isAuthenticated(apiKey: string | undefined | null): apiKey is string { + return Boolean(apiKey) && apiKey !== kNoAuth; +} + +export function isDiscoveryBearerApiKey(apiKey: string | undefined | null): apiKey is string { + return isAuthenticated(apiKey) && !LOCAL_PROVIDER_PLACEHOLDERS.has(apiKey); +} + +/** + * Wraps an extension-provided fetchDynamicModels call with a hard timeout. + * Uses a cancellable manual timer (not AbortSignal.timeout) so that a fast + * successful path does not leave an armed timeout signal for concurrent GC. + * The inner fetcher does not receive a signal (extension contract has none). + */ +export async function withRuntimeDynamicModelsTimeout(timeoutMs: number, run: () => Promise): Promise { + const { promise: timeoutPromise, reject: timeoutReject } = Promise.withResolvers(); + const timer = setTimeout(() => { + timeoutReject(new Error(`fetchDynamicModels timed out after ${timeoutMs}ms`)); + }, timeoutMs); + try { + return await Promise.race([run(), timeoutPromise]); + } finally { + clearTimeout(timer); + } +} + +export interface BuiltInDiscoveryResult { + models: Model[]; + authoritativeProviders: Set; +} + +export type ProviderDiscoveryStatus = "idle" | "ok" | "empty" | "cached" | "unavailable" | "unauthenticated"; + +export interface ProviderDiscoveryState { + provider: string; + status: ProviderDiscoveryStatus; + optional: boolean; + stale: boolean; + fetchedAt?: number; + models: string[]; + error?: string; +} +export function extractGoogleOAuthToken(value: string | undefined): string | undefined { + if (!isAuthenticated(value)) return undefined; + try { + const parsed = JSON.parse(value) as { token?: unknown }; + if (Object.hasOwn(parsed, "token")) { + if (typeof parsed.token !== "string") { + return undefined; + } + const token = parsed.token.trim(); + return token.length > 0 ? token : undefined; + } + } catch { + // OAuth values for Google providers are expected to be JSON, but custom setups may already provide raw token. + } + return value; +} + +export function getOAuthCredentialsForProvider(authStorage: AuthStorage, provider: string): OAuthCredential[] { + const providerEntry = authStorage.getAll()[provider]; + if (!providerEntry) { + return []; + } + const entries = Array.isArray(providerEntry) ? providerEntry : [providerEntry]; + return entries.filter((entry): entry is OAuthCredential => entry.type === "oauth"); +} + +/** + * Resolve every configured Codex OAuth account for catalog discovery, refreshing + * each credential exactly once. Codex `/models` is account-scoped, so discovery + * must fetch per account and union the results; resolving a single access token + * (as before) hid models available only through a sibling account (#6265). + * + * Returns `null` when any stored account fails to resolve (e.g. a transient + * refresh failure): the Codex manager is authoritative, so unioning only the + * accounts that resolved would cache a partial catalog and hide the failed + * account's models for the cache TTL. Aborting keeps the previous/bundled + * catalog instead. + */ +export async function resolveCodexDiscoveryAccounts( + authStorage: AuthStorage, + resolvedAccessToken: string, +): Promise { + const accesses = await authStorage.getOAuthAccesses("openai-codex"); + const accounts: OpenAICodexAccount[] = []; + for (const access of accesses) { + if (!access.ok) return null; + accounts.push({ accessToken: access.accessToken, accountId: access.accountId }); + } + if (!accounts.some(account => account.accessToken === resolvedAccessToken)) { + const matchingCredential = getOAuthCredentialsForProvider(authStorage, "openai-codex").find( + credential => credential.access === resolvedAccessToken, + ); + accounts.push({ accessToken: resolvedAccessToken, accountId: matchingCredential?.accountId }); + } + return accounts; +} diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 5e23a21ca..aa75a514e 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -1,6 +1,10 @@ -import { execSync } from "node:child_process"; import * as path from "node:path"; +import type { ApiKeyResolver, FetchImpl } from "@oh-my-pi/pi-ai"; import { registerCustomApi, unregisterCustomApis } from "@oh-my-pi/pi-ai/api-registry"; +import { registerOAuthProvider, unregisterOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; +import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types"; +import { setCodexAttestationProvider } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; +import { getProviderDefinition } from "@oh-my-pi/pi-ai/registry"; import type { Api, Context, @@ -12,7 +16,6 @@ import type { } from "@oh-my-pi/pi-ai/types"; import type { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; -import { isVertexExpressOpenAIUrl } from "@oh-my-pi/pi-catalog/hosts"; import { readModelCache } from "@oh-my-pi/pi-catalog/model-cache"; import { createModelManager, @@ -23,63 +26,34 @@ import { getBundledModels, getBundledProviders } from "@oh-my-pi/pi-catalog/mode import { googleAntigravityModelManagerOptions, googleGeminiCliModelManagerOptions, - type OpenAICodexAccount, openaiCodexModelManagerOptions, PROVIDER_DESCRIPTORS, resolveModelCacheProviderId, resolveOllamaModelCacheProviderId, } from "@oh-my-pi/pi-catalog/provider-models"; -import { - collapseBuiltModelVariants, - getVariantAliasSources, - resolveVariantAlias, -} from "@oh-my-pi/pi-catalog/variant-collapse"; - -const SPECIAL_MODEL_MANAGER_PROVIDER_IDS: readonly string[] = [ - "google-antigravity", - "google-gemini-cli", - "openai-codex", -]; - -const STARTUP_MODEL_CACHE_PROVIDER_IDS: readonly string[] = [ - ...PROVIDER_DESCRIPTORS.map(descriptor => descriptor.providerId), - ...SPECIAL_MODEL_MANAGER_PROVIDER_IDS, -]; - -// Sentinels for local-only OAuth tokens — declared inline to avoid loading -// provider modules at startup. Must match packages/ai/src/registry/llama-cpp.ts, -// packages/ai/src/registry/lm-studio.ts, and packages/ai/src/registry/vllm.ts. -const LOCAL_PROVIDER_PLACEHOLDERS = new Set(["llama-cpp-local", "lm-studio-local", "vllm-local"]); - -/** - * Hard bound for extension-provided fetchDynamicModels to prevent indefinite hangs - * during runtime provider discovery. Uses a cancellable manual timer (not AbortSignal.timeout) - * so a successful fast path does not leave an armed timeout signal for concurrent GC. - */ -const RUNTIME_DYNAMIC_MODEL_FETCH_TIMEOUT_MS = 15_000; -// Built-in discovery preflight mirror of the catalog model-manager's private -// cache timings (model-manager.ts: DEFAULT_CACHE_TTL_MS / NON_AUTHORITATIVE_RETRY_MS). -// Built-in descriptors never override cacheTtlMs, so agreeing with these values -// makes the OAuth-refresh preflight fire exactly when the manager will fetch. -const BUILT_IN_DISCOVERY_CACHE_TTL_MS = 2 * 60 * 60 * 1000; -const BUILT_IN_DISCOVERY_NON_AUTHORITATIVE_RETRY_MS = 5 * 60 * 1000; - -import type { ApiKeyResolver, FetchImpl } from "@oh-my-pi/pi-ai"; -import { registerOAuthProvider, unregisterOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; -import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types"; -import { setCodexAttestationProvider } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; -import { getProviderDefinition } from "@oh-my-pi/pi-ai/registry"; -import { - getBundledModelReferenceIndex, - inheritReferenceThinking, - resolveModelReference, -} from "@oh-my-pi/pi-catalog/identity"; -import { $envExact, isBunTestRuntime, isRecord, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils"; -import { parseModelString, resolveProviderModelReference } from "../config/model-resolver"; +import { collapseBuiltModelVariants } from "@oh-my-pi/pi-catalog/variant-collapse"; +import { isBunTestRuntime, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils"; +import { resolveProviderModelReference } from "../config/model-resolver"; import { generateCodexAttestation } from "../live/attestation"; -import type { AuthStorage, OAuthCredential } from "../session/auth-storage"; +import type { AuthStorage } from "../session/auth-storage"; import { type ApiKeyResolverModel, type ApiKeyResolverOptions, createApiKeyResolver } from "./api-key-resolver"; import type { ConfigError, ConfigFile } from "./config-file"; +import { + buildCustomModelOverlay, + type CustomModelDefinitionLike, + type CustomModelOverlay, + finalizeCustomModel, + mergeAuthHeaderSources, + normalizeSuppressedSelector, + resolveModelOverrideWithAliases, +} from "./custom-models"; +import { + type CommandApiKeyResolution, + createLiveConfigHeaders, + isCommandConfigValue, + resolveConfigHeaders, + resolveConfigValue, +} from "./model-config-values"; import { applyLlamaCppQwenThinking, DISCOVERY_DEFAULT_MAX_TOKENS, @@ -91,6 +65,45 @@ import { getOllamaContextLengthOverride, normalizeLiteLLMDiscoveryBaseUrl, } from "./model-discovery"; +import { + AUTHORITATIVE_RUNTIME_CATALOG_PROVIDERS, + applyModelOverride, + applyModelPatch, + dropProviderModels, + type ModelPatch, + mergeByModelKey, + mergeCompat, + mergeDiscoveredModel, + mergeProviderRemoteCompactionConfig, + mergeRemoteCompactionConfig, + type ProviderOverride, + providersWithAuthoritativeProjectCatalog, + toModelSpec, +} from "./model-patch"; +import { + BUILT_IN_DISCOVERY_CACHE_TTL_MS, + BUILT_IN_DISCOVERY_NON_AUTHORITATIVE_RETRY_MS, + type BuiltInDiscoveryResult, + extractGoogleOAuthToken, + getOAuthCredentialsForProvider, + isAuthenticated, + isDiscoveryBearerApiKey, + kNoAuth, + type ProviderDiscoveryState, + RUNTIME_DYNAMIC_MODEL_FETCH_TIMEOUT_MS, + resolveCodexDiscoveryAccounts, + STARTUP_MODEL_CACHE_PROVIDER_IDS, + withRuntimeDynamicModelsTimeout, +} from "./model-provider-discovery"; + +export { mergeDiscoveredModel } from "./model-patch"; +export { + isAuthenticated, + kNoAuth, + type ProviderDiscoveryState, + type ProviderDiscoveryStatus, +} from "./model-provider-discovery"; + import { ModelsConfigFile, type ProviderValidationModel, validateProviderConfiguration } from "./models-config"; import type { ModelOverride, ModelsConfig, ProviderAuthMode } from "./models-config-schema"; import { settings } from "./settings"; @@ -99,180 +112,6 @@ import { settings } from "./settings"; // requests; the pi-ai provider resolves it just-in-time per request. setCodexAttestationProvider(generateCodexAttestation); -export const kNoAuth = "N/A"; - -export function isAuthenticated(apiKey: string | undefined | null): apiKey is string { - return Boolean(apiKey) && apiKey !== kNoAuth; -} - -function isDiscoveryBearerApiKey(apiKey: string | undefined | null): apiKey is string { - return isAuthenticated(apiKey) && !LOCAL_PROVIDER_PLACEHOLDERS.has(apiKey); -} - -/** - * Wraps an extension-provided fetchDynamicModels call with a hard timeout. - * Uses a cancellable manual timer (not AbortSignal.timeout) so that a fast - * successful path does not leave an armed timeout signal for concurrent GC. - * The inner fetcher does not receive a signal (extension contract has none). - */ -async function withRuntimeDynamicModelsTimeout(timeoutMs: number, run: () => Promise): Promise { - const { promise: timeoutPromise, reject: timeoutReject } = Promise.withResolvers(); - const timer = setTimeout(() => { - timeoutReject(new Error(`fetchDynamicModels timed out after ${timeoutMs}ms`)); - }, timeoutMs); - try { - return await Promise.race([run(), timeoutPromise]); - } finally { - clearTimeout(timer); - } -} - -/** Provider override config (baseUrl, headers, apiKey, compat, transport) without custom models */ -interface ProviderOverride { - baseUrl?: string; - headers?: Record; - apiKey?: string; - authHeader?: boolean; - compat?: ModelSpec["compat"]; - remoteCompaction?: RemoteCompactionConfig; - transport?: Model["transport"]; -} - -/** - * 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). - * See `xiaomi-tp-discovery-merge.test.ts` and the `refresh()` baseUrl-override - * regression in `model-registry.test.ts`. - */ -export function mergeDiscoveredModel( - model: Model, - existing: Model | undefined, - providerOverride?: Pick, -): Model { - if (existing) { - const supportsTools = model.supportsTools ?? existing.supportsTools; - return buildModel({ - ...model, - baseUrl: providerOverride?.baseUrl ?? model.baseUrl ?? existing.baseUrl, - headers: existing.headers ? { ...existing.headers, ...model.headers } : model.headers, - 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); - } - if (providerOverride) { - return buildModel({ - ...model, - baseUrl: providerOverride.baseUrl ?? model.baseUrl, - headers: providerOverride.headers ? { ...model.headers, ...providerOverride.headers } : model.headers, - ...(providerOverride.transport !== undefined ? { transport: providerOverride.transport } : {}), - remoteCompaction: mergeProviderRemoteCompactionConfig( - model.remoteCompaction, - providerOverride.remoteCompaction, - ), - compat: mergeCompat(model.compatConfig, providerOverride.compat), - } as ModelSpec); - } - return model; -} - -const AUTHORITATIVE_RUNTIME_CATALOG_PROVIDERS = new Set( - PROVIDER_DESCRIPTORS.filter(descriptor => descriptor.dynamicModelsAuthoritative).map( - descriptor => descriptor.providerId, - ), -); - -function isAuthoritativeProjectCatalogModel(model: Model): boolean { - return ( - model.provider === "google-vertex" && - model.api === "openai-completions" && - isVertexExpressOpenAIUrl(model.baseUrl) - ); -} - -function providersWithAuthoritativeProjectCatalog(models: readonly Model[]): Set { - const providers = new Set(); - for (const model of models) { - if (isAuthoritativeProjectCatalogModel(model)) { - providers.add(model.provider); - } - } - return providers; -} - -function dropProviderModels(models: readonly Model[], providers: ReadonlySet): Model[] { - return models.filter(model => !providers.has(model.provider)); -} - -/** - * 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)`. - */ -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; -} - -interface BuiltInDiscoveryResult { - models: Model[]; - authoritativeProviders: Set; -} - -export type ProviderDiscoveryStatus = "idle" | "ok" | "empty" | "cached" | "unavailable" | "unauthenticated"; - -export interface ProviderDiscoveryState { - provider: string; - status: ProviderDiscoveryStatus; - optional: boolean; - stale: boolean; - fetchedAt?: number; - models: string[]; - error?: string; -} - /** Result of loading custom models config. */ interface CustomModelsResult { models?: CustomModelOverlay[]; @@ -285,491 +124,12 @@ interface CustomModelsResult { found: boolean; } -const commandValueCache = new Map(); -// Failed `!command` resolutions (non-zero exit, empty stdout) are negative-cached -// with a TTL instead of forever: a transient failure (locked password manager, -// network hiccup) must not disable the key until process restart, but re-running -// the command on every resolution would restore the execSync storm this cache -// exists to prevent. One probe per TTL window bounds both. -const COMMAND_FAILURE_RETRY_MS = 30_000; -const commandFailureRetryAt = new Map(); - -function isCommandConfigValue(valueConfig: string | undefined): valueConfig is string { - return valueConfig?.startsWith("!") === true; -} - -function resolveCommandConfig(command: string): string | undefined { - const cached = commandValueCache.get(command); - if (cached !== undefined) return cached; - const retryAt = commandFailureRetryAt.get(command); - if (retryAt !== undefined && Date.now() < retryAt) return undefined; - try { - const stdout = execSync(command, { encoding: "utf8", timeout: 10_000, windowsHide: true }); - const trimmed = stdout.trim(); - if (trimmed.length === 0) { - commandFailureRetryAt.set(command, Date.now() + COMMAND_FAILURE_RETRY_MS); - return undefined; - } - commandFailureRetryAt.delete(command); - commandValueCache.set(command, trimmed); - return trimmed; - } catch { - commandFailureRetryAt.set(command, Date.now() + COMMAND_FAILURE_RETRY_MS); - return undefined; - } -} - -interface CommandApiKeyResolution { - configured: boolean; - value?: string; -} -/** - * Resolve a models.yml/models.yaml secret/config value to an actual value. - * `!cmd` runs a shell command and returns trimmed stdout, otherwise env vars are - * checked first and the input falls back to a literal value. - */ -function resolveConfigValue(valueConfig: string): string | undefined { - if (valueConfig.startsWith("!")) return resolveCommandConfig(valueConfig.slice(1).trim()); - const envValue = $envExact(valueConfig); - if (envValue) return envValue; - return valueConfig; -} - -type HeaderSource = Record | undefined; - -interface HeaderResolutionOptions { - authHeader?: boolean; - apiKeyConfig?: string; -} - -function materializeConfigHeaderSources( - sources: readonly HeaderSource[], - options?: HeaderResolutionOptions, -): Record | undefined { - const resolved: Record = {}; - for (const source of sources) { - if (!source) continue; - for (const [key, value] of Object.entries(source)) { - const next = resolveConfigValue(value); - if (next) resolved[key] = next; - } - } - if (options?.authHeader && options.apiKeyConfig) { - const resolvedKey = resolveConfigValue(options.apiKeyConfig); - if (resolvedKey) resolved.Authorization = `Bearer ${resolvedKey}`; - } - return Object.keys(resolved).length > 0 ? resolved : undefined; -} - -function createLiveConfigHeaders( - sources: readonly HeaderSource[], - options?: HeaderResolutionOptions, -): Record | undefined { - const liveSources = sources.filter((source): source is Record => source !== undefined); - if (liveSources.length === 0 && (!options?.authHeader || !options.apiKeyConfig)) return undefined; - - const localHeaders: Record = {}; - const allSources = [...liveSources, localHeaders]; - const current = () => materializeConfigHeaderSources(allSources, options) ?? {}; - return new Proxy(localHeaders, { - get(target, property, receiver) { - if (typeof property !== "string") return Reflect.get(target, property, receiver); - return current()[property]; - }, - set(target, property, value) { - if (typeof property !== "string" || typeof value !== "string") return false; - target[property] = value; - return true; - }, - deleteProperty(target, property) { - if (typeof property !== "string") return false; - delete target[property]; - return true; - }, - has(_target, property) { - if (typeof property !== "string") return false; - return Object.hasOwn(current(), property); - }, - ownKeys() { - return Reflect.ownKeys(current()); - }, - getOwnPropertyDescriptor(_target, property) { - if (typeof property !== "string") return undefined; - const headers = current(); - if (!Object.hasOwn(headers, property)) return undefined; - return { - configurable: true, - enumerable: true, - value: headers[property], - writable: true, - }; - }, - }); -} - -function resolveConfigHeaders(headers: Record | undefined): Record | undefined { - return materializeConfigHeaderSources([headers]); -} - -function extractGoogleOAuthToken(value: string | undefined): string | undefined { - if (!isAuthenticated(value)) return undefined; - try { - const parsed = JSON.parse(value) as { token?: unknown }; - if (Object.hasOwn(parsed, "token")) { - if (typeof parsed.token !== "string") { - return undefined; - } - const token = parsed.token.trim(); - return token.length > 0 ? token : undefined; - } - } catch { - // OAuth values for Google providers are expected to be JSON, but custom setups may already provide raw token. - } - return value; -} - -function getOAuthCredentialsForProvider(authStorage: AuthStorage, provider: string): OAuthCredential[] { - const providerEntry = authStorage.getAll()[provider]; - if (!providerEntry) { - return []; - } - const entries = Array.isArray(providerEntry) ? providerEntry : [providerEntry]; - return entries.filter((entry): entry is OAuthCredential => entry.type === "oauth"); -} - -/** - * Resolve every configured Codex OAuth account for catalog discovery, refreshing - * each credential exactly once. Codex `/models` is account-scoped, so discovery - * must fetch per account and union the results; resolving a single access token - * (as before) hid models available only through a sibling account (#6265). - * - * Returns `null` when any stored account fails to resolve (e.g. a transient - * refresh failure): the Codex manager is authoritative, so unioning only the - * accounts that resolved would cache a partial catalog and hide the failed - * account's models for the cache TTL. Aborting keeps the previous/bundled - * catalog instead. - */ -async function resolveCodexDiscoveryAccounts( - authStorage: AuthStorage, - resolvedAccessToken: string, -): Promise { - const accesses = await authStorage.getOAuthAccesses("openai-codex"); - const accounts: OpenAICodexAccount[] = []; - for (const access of accesses) { - if (!access.ok) return null; - accounts.push({ accessToken: access.accessToken, accountId: access.accountId }); - } - if (!accounts.some(account => account.accessToken === resolvedAccessToken)) { - const matchingCredential = getOAuthCredentialsForProvider(authStorage, "openai-codex").find( - credential => credential.access === resolvedAccessToken, - ); - accounts.push({ accessToken: resolvedAccessToken, accountId: matchingCredential?.accountId }); - } - return accounts; -} - -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; -} - -function mergeRemoteCompactionConfig( - baseConfig: RemoteCompactionConfig | undefined, - overrideConfig: RemoteCompactionConfig | undefined, -): RemoteCompactionConfig | undefined { - if (!baseConfig) return overrideConfig; - if (!overrideConfig) return baseConfig; - return { ...baseConfig, ...overrideConfig }; -} - -function mergeProviderRemoteCompactionConfig( - modelConfig: RemoteCompactionConfig | undefined, - providerConfig: RemoteCompactionConfig | undefined, -): RemoteCompactionConfig | undefined { - return mergeRemoteCompactionConfig(providerConfig, modelConfig); -} - -/** - * Project a built model back to spec shape for the model-manager/cache - * boundary: sparse compat comes from `compatConfig`, never from the resolved - * record. - */ -function toModelSpec(model: Model): ModelSpec { - return { ...model, compat: model.compatConfig } as ModelSpec; -} - -/** - * 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". - */ -interface ModelPatch { - name?: string; - reasoning?: boolean; - thinking?: ThinkingConfig; - input?: ("text" | "image")[]; - supportsTools?: boolean; - cost?: Partial["cost"]>; - contextWindow?: number; - maxTokens?: number; - omitMaxOutputTokens?: boolean; - headers?: Record; - 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"; - /** * Credential-aware model projection supplied by an extension provider. Receives * the fully composed catalog and returns the list the host should serve. */ type ModifyModelsHook = (models: Model[], credentials: OAuthCredentials) => Model[]; -function applyModelPatch(base: Model, patch: ModelPatch, transport: ModelTransportPolicy): Model { - const result = { ...base }; - if (patch.name !== undefined) result.name = patch.name; - if (patch.reasoning !== undefined) result.reasoning = patch.reasoning; - if (patch.thinking !== undefined) result.thinking = patch.thinking; - if (patch.input !== undefined) result.input = patch.input; - if (patch.supportsTools !== undefined) result.supportsTools = patch.supportsTools; - 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.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) { - 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, - }; - } - let compat: ModelSpec["compat"]; - if (transport === "merge") { - if (patch.headers) { - result.headers = { ...base.headers, ...patch.headers }; - } - compat = mergeCompat(base.compatConfig, patch.compat); - } else { - result.headers = patch.headers; - compat = patch.compat; - } - const built = buildModel({ ...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. - built.thinking = patch.thinking; - } - return built; -} - -function applyModelOverride(model: Model, override: ModelOverride): Model { - return applyModelPatch(model, override as ModelPatch, "merge"); -} - -interface CustomModelDefinitionLike extends ModelPatch { - id: string; - api?: Api; - baseUrl?: string; - cost?: Model["cost"]; -} - -interface CustomModelBuildOptions { - useDefaults: boolean; -} - -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, -): Record | undefined { - return createLiveConfigHeaders([providerHeaders, modelHeaders], { authHeader, apiKeyConfig }); -} - -function mergeAuthHeaderSources( - sources: readonly HeaderSource[], - authHeader: boolean | undefined, - apiKeyConfig: string | undefined, -): Record | undefined { - return createLiveConfigHeaders(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; -} - -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, - reasoning: modelDef.reasoning, - thinking: modelDef.thinking, - input: modelDef.input, - supportsTools: modelDef.supportsTools, - cost: modelDef.cost, - contextWindow: modelDef.contextWindow, - maxTokens: modelDef.maxTokens, - omitMaxOutputTokens: modelDef.omitMaxOutputTokens, - headers: 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), - }; -} - -function applyStandaloneCustomModelPolicies(model: CustomModelOverlay): CustomModelOverlay { - if (model.id !== "gpt-5.4" || model.provider === "github-copilot" || model.contextWindow !== undefined) { - return model; - } - return { ...model, contextWindow: 1_000_000 }; -} - -function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuildOptions): Model { - const resolvedModel = options.useDefaults ? applyStandaloneCustomModelPolicies(model) : 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; - return buildModel({ - id: resolvedModel.id, - name: resolvedModel.name ?? (options.useDefaults ? resolvedModel.id : undefined), - api: resolvedModel.api, - 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")[], - ...(supportsTools !== undefined ? { supportsTools } : {}), - cost, - contextWindow: resolvedModel.contextWindow ?? reference?.contextWindow ?? (options.useDefaults ? 128000 : null), - maxTokens: resolvedModel.maxTokens ?? reference?.maxTokens ?? (options.useDefaults ? 16384 : null), - headers: resolvedModel.headers, - omitMaxOutputTokens: resolvedModel.omitMaxOutputTokens ?? reference?.omitMaxOutputTokens, - compat: mergeCompat(reference?.compatConfig, resolvedModel.compat), - contextPromotionTarget: resolvedModel.contextPromotionTarget, - compactionModel: resolvedModel.compactionModel, - remoteCompaction: resolvedModel.remoteCompaction, - premiumMultiplier: resolvedModel.premiumMultiplier, - isOAuth: resolvedModel.isOAuth, - } as ModelSpec); -} - -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 = resolveVariantAlias(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. - */ -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; -} - function getDisabledProviderIdsFromSettings(): Set { try { return new Set(settings.get("disabledProviders"));