import { USER_AGENT, getInstallId } from "@oh-my-pi-zen/pi-utils"; import * as logger from "@oh-my-pi-zen/pi-utils/logger"; import { toClinePassPublicModelId } from "../cline-pass-model-id"; import { apiRouteExactModelIds, apiRouteFor, isBareIdReferenceProvider, isExcludedDiscoveryMode, isExcludedModel, isLikelyOpenAIResponsesId, modelLimitsFor, } from "../compat/behavior"; import { resolveAnthropicApiVersion } from "../compat/anthropic"; import { xaiResponsesReasoningEffortMap } from "../compat/openai"; import { hasModelScopedEffortLadder, resolveModelPolicy } from "../compat/resolve"; import { compareRevision, parseRevision, revisionAtLeast } from "../compat/revision"; import { providerEntries, seedModels } from "../compat/providers"; import { billingVariantPlain, classifyModel, discoveryVocabulary } from "../compat/taxonomy"; import { DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, fetchOpenAICompatibleModels, type OpenAICompatibleModelMapperContext, type OpenAICompatibleModelRecord, withOpenAICompatibleDiscoveryTimeout, } from "../discovery/openai-compatible"; import { Effort, THINKING_EFFORTS } from "../effort"; import { FIREWORKS_FAST_SUFFIX, toFireworksPublicModelId } from "../fireworks-model-id"; import { getBundledModelReferenceIndex } from "../identity/bundled"; import { bareModelId } from "../identity/id"; import { resolveModelReference } from "../identity/reference"; import type { ModelManagerOptions, ModelsDevFallback } from "../model-manager"; import { type GeneratedProvider, getBundledModels } from "../models"; import { KIND_API_KINDS, MODEL_KINDS, type Api, type FetchImpl, type Model, type ModelKind, type ModelSpec, type OpenAICompat, type Provider, type ThinkingConfig, type TokenCost, runnerApiKind, } from "../types"; import { discoveryFetch, isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils"; import { ALIBABA_TOKEN_PLAN_BASE_URL, parseAlibabaTokenPlanCredential } from "../wire/alibaba-token-plan"; import { normalizeCharmHyperBaseUrl } from "../wire/charm-hyper"; import { CLINEPASS_API_BASE_URL, clinePassClientHeaders } from "../wire/cline-pass"; import { CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL } from "../wire/cloudflare-ai-gateway"; import { coreWeaveProjectHeaders } from "../wire/coreweave"; import { COPILOT_API_HEADERS, COPILOT_DISCOVERY_HEADERS, discoverGitHubCopilotApiEndpoint, getGitHubCopilotBaseUrl, isPersonalGitHubCopilotBaseUrl, mergeCopilotApiHeaders, parseGitHubCopilotApiKey, } from "../wire/github-copilot"; import { SINGULARITYAPI_DEV_API_BASE_URL, SINGULARITYAPI_TECH_API_BASE_URL, normalizeSingularityApiBaseUrl, } from "../wire/singularityapi"; import { isXiaomiTokenPlanKey } from "../wire/xiaomi-token-plan"; import { createBundledReferenceMap, createReferenceResolver, toModelSpec } from "./bundled-references"; import { getDefaultModelDiscoveryBaseUrl, resolveModelCacheProviderId } from "./cache-provider-id"; import { getClinePassModelMetadata } from "./cline-pass"; import type { ModelManagerConfig } from "./descriptor-types"; import { filterModelsDevCatalogRows } from "./models-dev-policies"; function isGlmReasoningIdentity(provider: string, modelId: string, floor: string): boolean { const identity = classifyModel(provider, modelId, { lenient: true }); if (identity.class !== "glm" || !revisionAtLeast(identity.revision, floor)) return false; if (identity.family === "flash") return revisionAtLeast(identity.revision, "5.3"); return identity.family === undefined || identity.family === "air" || identity.family === "turbo"; } const MODELS_DEV_URL = "https://catalog.stencil.so/models.json.zstd"; /** Little-endian magic number opening every zstd frame (RFC 8878). */ const ZSTD_MAGIC = 0xfd2fb528; const ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1"; const ANTHROPIC_OAUTH_BETA = "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,extended-cache-ttl-2025-04-11"; export interface ModelsDevModel { id?: string; name?: string; kind?: string; tool_call?: boolean; reasoning?: boolean; reasoning_options?: Array<{ type?: string; values?: string[]; min?: number; max?: number }>; limit?: { context?: number; output?: number; }; cost?: { input?: number; output?: number; cache_read?: number; cache_write?: number; }; modalities?: { input?: string[]; output?: string[]; }; status?: string; provider?: { npm?: string }; int?: number; tps?: number; } function toModelName(value: unknown, fallback: string): string { if (typeof value !== "string") { return fallback; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : fallback; } function toInputCapabilities(value: unknown): ("text" | "image")[] { if (!Array.isArray(value)) { return ["text"]; } const supportsImage = value.some(item => item === "image"); return supportsImage ? ["text", "image"] : ["text"]; } /** * Catalog sessions are scoped to the fetch implementation that owns their * network and authentication context. Callers sharing one fetch reuse the same * conditional request state; isolated registries cannot observe each other's * payloads, ETags, or in-flight requests. */ interface CatalogSession { inflight: Promise | null; payload: unknown; etag: string | null; hasPayload: boolean; } const defaultCatalogSession: CatalogSession = { inflight: null, payload: undefined, etag: null, hasPayload: false }; const catalogSessionsByFetch = new WeakMap(); function getCatalogSession(fetchImpl: FetchImpl | undefined): CatalogSession { if (!fetchImpl) return defaultCatalogSession; const existing = catalogSessionsByFetch.get(fetchImpl); if (existing) return existing; const created: CatalogSession = { inflight: null, payload: undefined, etag: null, hasPayload: false }; catalogSessionsByFetch.set(fetchImpl, created); return created; } function waitForCatalogRequest(request: Promise, signal?: AbortSignal): Promise { if (!signal) return request; if (signal.aborted) return Promise.reject(signal.reason); const aborted = Promise.withResolvers(); const rejectAborted = () => aborted.reject(signal.reason); signal.addEventListener("abort", rejectAborted, { once: true }); return Promise.race([request, aborted.promise]).finally(() => { signal.removeEventListener("abort", rejectAborted); }); } const CATALOG_USER_AGENT = USER_AGENT; /** * Fetches the models.dev catalog via catalog.stencil.so, which serves a * field-pruned copy precompressed as a zstd blob (~93 KB vs ~3.3 MB raw). * The frame magic is sniffed rather than trusting content-type so plain-JSON * responses (test stubs, fallback mirrors) parse identically. * * Fetched fully once per fetch context: concurrent callers sharing a fetch * implementation reuse one transport request, while repeat callers send a * conditional GET that the server answers with `304`. Each subscriber may stop * waiting independently; the shared transport retains its own hard deadline. * Transient failures reuse the last in-memory payload for callers that only * need best-effort metadata. */ export function fetchWellKnownModels(fetchImpl?: FetchImpl, signal?: AbortSignal): Promise { const session = getCatalogSession(fetchImpl); return fetchRevalidatedWellKnownModels(fetchImpl, signal).catch(error => { if (session.hasPayload) return session.payload; throw error; }); } function fetchRevalidatedWellKnownModels(fetchImpl?: FetchImpl, signal?: AbortSignal): Promise { const session = getCatalogSession(fetchImpl); if (!session.inflight) { session.inflight = withOpenAICompatibleDiscoveryTimeout( DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, transportSignal => fetchCatalogPayload(fetchImpl ?? discoveryFetch(), session, transportSignal), ).finally(() => { session.inflight = null; }); } return waitForCatalogRequest(session.inflight, signal); } function fetchRevalidatedWellKnownModelsWithTimeout( fetchImpl?: FetchImpl, timeoutMs = DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, ): Promise { return withOpenAICompatibleDiscoveryTimeout(timeoutMs, signal => fetchRevalidatedWellKnownModels(fetchImpl, signal)); } async function fetchCatalogPayload( fetchImpl: FetchImpl, session: CatalogSession, signal?: AbortSignal, ): Promise { const headers: Record = { Accept: "application/zstd, application/json", "User-Agent": CATALOG_USER_AGENT, }; if (session.hasPayload && session.etag) { headers["If-None-Match"] = session.etag; } const response = await fetchImpl(MODELS_DEV_URL, { method: "GET", headers, signal }); if (response.status === 304 && session.hasPayload) { return session.payload; } if (!response.ok) { throw new Error(`models catalog fetch failed: ${response.status}`); } const bytes = new Uint8Array(await response.arrayBuffer()); const isZstd = bytes.length >= 4 && new DataView(bytes.buffer, bytes.byteOffset).getUint32(0, true) === ZSTD_MAGIC; const text = new TextDecoder().decode(isZstd ? await Bun.zstdDecompress(bytes) : bytes); const payload: unknown = JSON.parse(text); session.payload = payload; session.etag = response.headers.get("etag"); session.hasPayload = true; return payload; } /** * The wire effort tiers the catalog publishes for a model, in canonical order. * Undefined when the row has no effort-addressed thinking, or names no tier * omp knows. */ function publishedEffortLadder(model: ModelsDevModel): Effort[] | undefined { const values = model.reasoning_options?.find(option => option?.type === "effort")?.values; if (!Array.isArray(values)) return undefined; const ladder = THINKING_EFFORTS.filter(effort => values.includes(effort)); return ladder.length > 0 ? ladder : undefined; } /** * Published ladders, addressable both by the host that published them and by * bare id. * * `byHost` (keyed `provider\0id`) is authoritative: a ladder is only valid for * the deployment that published it, so the endpoint's own catalog identity is * consulted first. `byId` answers when the host is unknown — a gateway id with * no catalog provider of its own — and only while every host publishing that * id agrees that it takes an effort dial and on which tiers; an id whose hosts * disagree, or that any host publishes with no dial at all, is dropped from * it, so the ladder stays unknown instead of borrowing an arbitrary host's. * * `withoutLadder` carries the same `provider\0id` key for a row the catalog * does publish but with no effort dial on it. The host serving an id outranks * every other host on the question of what that deployment accepts, so its * silence blocks the bare-id fallback for that id rather than letting a * foreign ladder answer in its place. When the serving host is unknown that * per-host veto cannot fire, which is why a dialless row also disqualifies the * bare id outright. */ interface PublishedEffortLadders { byHost: ReadonlyMap; byId: ReadonlyMap; withoutLadder: ReadonlySet; } const EMPTY_PUBLISHED_EFFORT_LADDERS: PublishedEffortLadders = { byHost: new Map(), byId: new Map(), withoutLadder: new Set(), }; function indexPublishedEffortLadders(payload: unknown): PublishedEffortLadders { const byHost = new Map(); const byId = new Map(); const withoutLadder = new Set(); const index: PublishedEffortLadders = { byHost, byId, withoutLadder }; const unshareable = new Set(); if (!isRecord(payload)) return index; for (const [providerKey, provider] of Object.entries(payload)) { if (!isRecord(provider) || !isRecord(provider.models)) continue; for (const [modelId, rawModel] of Object.entries(provider.models)) { if (!isRecord(rawModel)) continue; const key = `${providerKey}\u0000${modelId}`; const ladder = publishedEffortLadder(rawModel as ModelsDevModel); if (!ladder) { withoutLadder.add(key); // Some deployment of this id rejects an effort dial; without // knowing which host serves a bare id, none may claim one. byId.delete(modelId); unshareable.add(modelId); continue; } byHost.set(key, ladder); if (unshareable.has(modelId)) continue; const shared = byId.get(modelId); if (shared === undefined) { byId.set(modelId, ladder); } else if (shared.length !== ladder.length || shared.some((effort, index) => effort !== ladder[index])) { byId.delete(modelId); unshareable.add(modelId); } } } return index; } /** * The index is tagged onto the catalog payload it was built from, so each * catalog version is indexed once. {@link fetchWellKnownModels} already scopes * payloads by fetch context, coalesces concurrent requests, answers a `304` * with the same object, and hands back the last good payload when a refresh * fails, so the tag inherits all of that lifetime behaviour for free. */ const kPublishedEffortLadders = Symbol("catalog.publishedEffortLadders"); interface IndexedCatalogPayload { [kPublishedEffortLadders]?: PublishedEffortLadders; } async function loadPublishedEffortLadders(fetchImpl?: FetchImpl): Promise { const payload = await fetchWellKnownModels(fetchImpl); if (!isRecord(payload)) return EMPTY_PUBLISHED_EFFORT_LADDERS; const tagged = payload as IndexedCatalogPayload; return (tagged[kPublishedEffortLadders] ??= indexPublishedEffortLadders(payload)); } /** * The catalog provider keys this endpoint publishes under. A provider whose * catalog identity differs from its omp id (`moonshot` → `moonshotai`) is * resolved through its descriptors; the omp id stays as a candidate for * providers the descriptors do not cover. */ function catalogProviderKeys(providerId: string): readonly string[] { const keys = new Set(); for (const descriptor of MODELS_DEV_DESCRIPTORS_BY_PROVIDER[providerId] ?? []) keys.add(descriptor.modelsDevKey); keys.add(providerId); return [...keys]; } /** * The ladder published for a discovered id, preferring the host that serves it. * * Gateway prefixes are peeled (`deepseek/deepseek-v4` → `deepseek-v4`), and * each peeled segment joins the host candidates ahead of the endpoint's own * keys: on an aggregator the prefix names the real upstream. All host-scoped * candidates are checked before accepting any shared-id fallback. * A bare id is only accepted from {@link PublishedEffortLadders.byId}, which * holds it only while every publishing host agrees that it takes an effort * dial and on which tiers. A host serving the id that published it without a * dial is this deployment's own answer and outranks any other host's ladder, * so it vetoes the candidate here; a dialless host omp cannot recognize as the * server already kept the id out of `byId` when the index was built. */ function lookupPublishedEffortLadder( ladders: PublishedEffortLadders, providerKeys: readonly string[], modelId: string, ): readonly Effort[] | undefined { const hosts = [...providerKeys]; let shared: readonly Effort[] | undefined; for (let candidate = modelId; ;) { let dialless = false; for (const host of hosts) { const key = `${host}\u0000${candidate}`; const scoped = ladders.byHost.get(key); if (scoped) return scoped; dialless ||= ladders.withoutLadder.has(key); } if (dialless) return undefined; shared ??= ladders.byId.get(candidate); const slash = candidate.indexOf("/"); if (slash < 0) return shared; hosts.unshift(candidate.slice(0, slash)); candidate = candidate.slice(slash + 1); } } /** * Fill the effort ladder of discovered reasoning models whose tiers omp would * otherwise guess from the neutral wire or provider-wide unknown-class default. * * Source precedence is unchanged: a provider that reports its own thinking * surface, and any model whose ladder reviewed rules declare, are left exactly * as they are. Only the guess is corrected, and only for ids the catalog * actually publishes for this endpoint (or publishes unambiguously), so no * request is made when every discovered model is already covered. */ async function applyPublishedEffortLadders( models: readonly ModelSpec[] | null, providerId: string, fetchImpl?: FetchImpl, ): Promise[] | null> { if (models === null) return null; const guessed = new Set( models .filter( model => model.reasoning === true && model.thinking === undefined && !hasModelScopedEffortLadder(model), ) .map(model => model.id), ); if (guessed.size === 0) return models; // An unreachable catalog with no prior payload is not a discovery failure: // the endpoint's own listing stands and the guess stays in place. const ladders = await loadPublishedEffortLadders(fetchImpl).catch(() => EMPTY_PUBLISHED_EFFORT_LADDERS); if (ladders.byHost.size === 0) return models; const providerKeys = catalogProviderKeys(providerId); return models.map(model => { if (!guessed.has(model.id)) return model; const efforts = lookupPublishedEffortLadder(ladders, providerKeys, model.id); return efforts ? { ...model, thinking: { mode: "effort" as const, efforts } } : model; }); } function mapAnthropicModelsDev(payload: unknown, baseUrl: string): ModelSpec<"anthropic-messages">[] { if (!isRecord(payload)) { return []; } const anthropicPayload = payload.anthropic; if (!isRecord(anthropicPayload)) { return []; } const modelsValue = anthropicPayload.models; if (!isRecord(modelsValue)) { return []; } const models: ModelSpec<"anthropic-messages">[] = []; for (const [modelId, rawModel] of Object.entries(modelsValue)) { if (!isRecord(rawModel)) { continue; } const model = rawModel as ModelsDevModel; if (model.tool_call !== true) { continue; } models.push({ id: modelId, name: toModelName(model.name, modelId), api: "anthropic-messages", provider: "anthropic", baseUrl, reasoning: model.reasoning === true, input: toInputCapabilities(model.modalities?.input), cost: { input: toNumber(model.cost?.input) ?? 0, output: toNumber(model.cost?.output) ?? 0, cacheRead: toNumber(model.cost?.cache_read) ?? 0, cacheWrite: toNumber(model.cost?.cache_write) ?? 0, }, contextWindow: toPositiveNumber(model.limit?.context, null), maxTokens: toPositiveNumber(model.limit?.output, null), }); } models.sort((left, right) => left.id.localeCompare(right.id)); return models; } function buildAnthropicDiscoveryHeaders(apiKey: string, baseUrl: string): Record { const oauthToken = isAnthropicOAuthToken(apiKey); const headers: Record = { "anthropic-version": resolveAnthropicApiVersion(baseUrl), "anthropic-dangerous-direct-browser-access": "true", "anthropic-beta": ANTHROPIC_OAUTH_BETA, }; if (oauthToken) { headers.Authorization = `Bearer ${apiKey}`; } else { headers["x-api-key"] = apiKey; } return headers; } function buildAnthropicReferenceMap( modelsDevModels: readonly ModelSpec<"anthropic-messages">[], ): Map> { const merged = new Map>(); for (const model of modelsDevModels) { merged.set(model.id, model); } // Anthropic /v1/models does not carry token limits, so bundled metadata stays canonical // for known models while models.dev only fills gaps for newly discovered ids. const bundledModels = getBundledModels("anthropic").filter( (model): model is Model<"anthropic-messages"> => model.api === "anthropic-messages", ); for (const model of bundledModels) { merged.set(model.id, toModelSpec(model)); } return merged; } function mapWithBundledReference( entry: OpenAICompatibleModelRecord, defaults: ModelSpec, reference: ModelSpec | undefined, ): ModelSpec { const name = toModelName(entry.name, reference?.name ?? defaults.name); if (!reference) { return { ...defaults, name, contextWindow: toPositiveNumber(entry.context_length, defaults.contextWindow), maxTokens: toPositiveNumber(entry.max_completion_tokens, defaults.maxTokens), }; } // Generic `/models` rows describe the chat roster. Do not make a bundled // runner kind look endpoint-authored merely because its metadata is reused. const { kind: _inheritedKind, ...chatReference } = reference; return { ...chatReference, id: defaults.id, name, api: defaults.api, provider: defaults.provider, baseUrl: defaults.baseUrl, contextWindow: toPositiveNumber(entry.context_length, reference.contextWindow), maxTokens: toPositiveNumber(entry.max_completion_tokens, reference.maxTokens), }; } /** * Rates from an OpenRouter-shaped `pricing` object (USD per token, as decimal * strings). Missing, zero, or negative legs (`-1` marks router-priced rows) * map to 0, which the model manager fills from its reference sources. */ function perTokenPricingCost(pricing: unknown): TokenCost { const rates = isRecord(pricing) ? pricing : {}; return { input: toPositiveNumber(rates.prompt, 0) * 1_000_000, output: toPositiveNumber(rates.completion, 0) * 1_000_000, cacheRead: toPositiveNumber(rates.input_cache_read ?? rates.input_cache_reads, 0) * 1_000_000, cacheWrite: toPositiveNumber(rates.input_cache_write ?? rates.input_cache_writes, 0) * 1_000_000, }; } /** * Rates from NanoGPT's detailed roster: prompt and completion are USD per * million tokens, the cache legs per thousand. Legs the row does not price * keep `fallback`. */ function nanoGptPricingCost(pricing: unknown, fallback: TokenCost): TokenCost { if (!isRecord(pricing) || pricing.unit !== "per_million_tokens") return fallback; const perThousand = (value: unknown, fallbackRate: number): number => { const rate = toPositiveNumber(value, 0); return rate > 0 ? rate * 1_000 : fallbackRate; }; return { input: toPositiveNumber(pricing.prompt, fallback.input), output: toPositiveNumber(pricing.completion, fallback.output), cacheRead: perThousand(pricing.cacheReadInputPer1kTokens, fallback.cacheRead), cacheWrite: perThousand(pricing.cacheWriteInputPer1kTokens, fallback.cacheWrite), }; } function normalizeAnthropicBaseUrl(baseUrl: string | undefined, fallback: string): string { const value = baseUrl?.trim(); if (!value) { return fallback; } return value.endsWith("/") ? value.slice(0, -1) : value; } function toAnthropicDiscoveryBaseUrl(baseUrl: string): string { return baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; } function normalizeOllamaBaseUrl(baseUrl?: string): string { const value = baseUrl?.trim(); if (!value) { return "http://127.0.0.1:11434/v1"; } const trimmed = value.endsWith("/") ? value.slice(0, -1) : value; return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`; } function toOllamaNativeBaseUrl(baseUrl: string): string { return baseUrl.endsWith("/v1") ? baseUrl.slice(0, -3) : baseUrl; } async function fetchOllamaNativeModels( baseUrl: string, resolveMetadata: (modelId: string) => Promise, fetchImpl: FetchImpl = discoveryFetch(), ): Promise[] | null> { const nativeBaseUrl = toOllamaNativeBaseUrl(baseUrl); let response: Response; try { response = await fetchImpl(`${nativeBaseUrl}/api/tags`, { method: "GET", headers: { Accept: "application/json" }, }); } catch { return null; } if (!response.ok) { return null; } const payload = (await response.json()) as { models?: Array<{ name?: string; model?: string }> }; const entries = payload.models ?? []; const resolved = await Promise.all( entries.map(async (entry): Promise | null> => { const id = entry.model ?? entry.name; if (!id) return null; const metadata = await resolveMetadata(id); return { id, name: entry.name ?? id, api: "openai-responses", provider: "ollama", baseUrl, reasoning: metadata.reasoning ?? false, input: metadata.input ?? ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: metadata.contextWindow, maxTokens: metadata.maxTokens, }; }), ); const models: ModelSpec<"openai-responses">[] = resolved.filter( (m): m is ModelSpec<"openai-responses"> => m !== null, ); return models.sort((left, right) => left.id.localeCompare(right.id)); } /** * Fallback context window for Ollama models when `/api/show` is unavailable * or omits a `model_info..context_length` field. Matches the size * Ollama's cloud catalog reports for stock models. */ const OLLAMA_FALLBACK_CONTEXT_WINDOW = 128_000; /** Cap max output tokens at a value that matches OMP's other openai-responses defaults. */ const OLLAMA_DEFAULT_MAX_TOKENS = 8192; interface OllamaResolvedMetadata { contextWindow: number; maxTokens: number; capabilities?: string[]; reasoning?: boolean; input?: ("text" | "image")[]; } interface OllamaShowMetadata { contextWindow?: number; maxTokens?: number; capabilities?: string[]; reasoning?: boolean; input?: ("text" | "image")[]; } function getOllamaContextWindow(modelInfo: Record | undefined): number | undefined { if (!modelInfo) { return undefined; } for (const [key, value] of Object.entries(modelInfo)) { if (typeof value !== "number" || value <= 0) { continue; } if (key.endsWith(".context_length") || key.endsWith(".num_ctx") || key.endsWith(".context_window")) { return value; } } } function getOllamaCapabilities(value: unknown): string[] | undefined { if (!Array.isArray(value)) { return undefined; } return value.filter((item): item is string => typeof item === "string"); } /** * Query Ollama's `/api/show` endpoint for a single model and pull native * context and capability metadata from the response. Returns `undefined` when * the endpoint is unavailable so callers can layer their own fallback. */ async function fetchOllamaShowMetadata( nativeBaseUrl: string, modelId: string, fetchImpl: FetchImpl = discoveryFetch(), ): Promise { try { const response = await fetchImpl(`${nativeBaseUrl}/api/show`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ model: modelId }), }); if (!response.ok) { return undefined; } const payload = (await response.json()) as { capabilities?: unknown; model_info?: Record }; const capabilities = getOllamaCapabilities(payload.capabilities); const contextWindow = getOllamaContextWindow(payload.model_info); return { contextWindow, maxTokens: contextWindow ? OLLAMA_DEFAULT_MAX_TOKENS : undefined, capabilities, reasoning: capabilities ? capabilities.includes("thinking") : undefined, input: capabilities ? capabilities.includes("vision") ? (["text", "image"] as Array<"text" | "image">) : (["text"] as Array<"text">) : undefined, }; } catch { // fall through; caller decides on the fallback } return undefined; } /** * Build a resolver that fetches `/api/show` metadata per model id and caches * the result in-memory for the lifetime of the manager. Successful lookups are * cached so repeated `fetchDynamicModels` calls do not refetch; failed * lookups stay uncached so a later refresh can recover. */ function createOllamaMetadataResolver( nativeBaseUrl: string, fetchImpl?: FetchImpl, ): (modelId: string) => Promise { const cache = new Map>(); return modelId => { const cached = cache.get(modelId); if (cached) return cached; const pending = (async () => { const metadata = await fetchOllamaShowMetadata(nativeBaseUrl, modelId, fetchImpl); if (!metadata) { cache.delete(modelId); return { contextWindow: OLLAMA_FALLBACK_CONTEXT_WINDOW, maxTokens: OLLAMA_DEFAULT_MAX_TOKENS }; } return { ...metadata, contextWindow: metadata.contextWindow ?? OLLAMA_FALLBACK_CONTEXT_WINDOW, maxTokens: metadata.maxTokens ?? OLLAMA_DEFAULT_MAX_TOKENS, }; })(); cache.set(modelId, pending); void pending.catch(() => cache.delete(modelId)); return pending; }; } function isLikelyOpenAIResponsesModelId( id: string, references?: ReadonlyMap>, ): boolean { const trimmed = id.trim(); if (!trimmed) { return false; } if (references?.has(trimmed)) { return true; } return isLikelyOpenAIResponsesId(trimmed); } /** Regex matching NanoGPT `:thinking` suffixed model IDs (with or without a level). */ export const NANO_GPT_THINKING_SUFFIX_RE = /:thinking(:[^:]+)?$/; function isLikelyNanoGptTextModelId(id: string): boolean { const normalized = id.trim().toLowerCase(); if (!normalized) { return false; } if (NANO_GPT_THINKING_SUFFIX_RE.test(normalized)) { return false; } return !isExcludedModel("nanogpt", normalized); } type SimpleProviderDiscoveryHeaders = Record | (() => Record | undefined); type SimpleProviderConfig = { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; headers?: SimpleProviderDiscoveryHeaders; }; function resolveSimpleProviderHeaders( headers: SimpleProviderDiscoveryHeaders | undefined, ): Record | undefined { return typeof headers === "function" ? headers() : headers; } type OpenAICompatibleModelManagerBuilderOptions = { api: TApi; providerId: GeneratedProvider; defaultBaseUrl: string; config?: SimpleProviderConfig; headers?: SimpleProviderDiscoveryHeaders; dynamicModelsAuthoritative?: true; requireApiKey?: true; dropCachedModelIdsOnStaticMismatch?: readonly string[]; cacheProviderId?: string; filterModel?: ( entry: OpenAICompatibleModelRecord, model: ModelSpec, references: Map>, ) => boolean; mapModel: ( entry: OpenAICompatibleModelRecord, defaults: ModelSpec, reference: ModelSpec | undefined, references: Map>, ) => ModelSpec | null; }; function createOpenAICompatibleModelManagerOptions( options: OpenAICompatibleModelManagerBuilderOptions, ): ModelManagerOptions { const apiKey = options.config?.apiKey; const baseUrl = options.config?.baseUrl ?? options.defaultBaseUrl; const references = createBundledReferenceMap(options.providerId); const filterModel = options.filterModel; return { providerId: options.providerId, ...(options.cacheProviderId && { cacheProviderId: options.cacheProviderId }), ...(options.dynamicModelsAuthoritative && { dynamicModelsAuthoritative: true }), ...(options.dropCachedModelIdsOnStaticMismatch && { dropCachedModelIdsOnStaticMismatch: options.dropCachedModelIdsOnStaticMismatch, }), ...((!options.requireApiKey || apiKey) && { fetchDynamicModels: async () => applyPublishedEffortLadders( await fetchOpenAICompatibleModels({ api: options.api, provider: options.providerId, baseUrl, apiKey, ...(options.headers && { headers: resolveSimpleProviderHeaders(options.headers) }), ...(filterModel && { filterModel: (entry, model) => filterModel(entry, model, references), }), mapModel: (entry, defaults) => options.mapModel(entry, defaults, references.get(defaults.id), references), fetch: options.config?.fetch, }), options.providerId, options.config?.fetch, ), }), }; } export function createSimpleOpenAICompletionsOptions( providerId: Parameters[0], defaultBaseUrl: string, config?: SimpleProviderConfig, ): ModelManagerOptions<"openai-completions"> { return createOpenAICompatibleModelManagerOptions({ api: "openai-completions", providerId, defaultBaseUrl, config, headers: config?.headers, requireApiKey: true, mapModel: mapWithBundledReference, }); } function createSimpleAnthropicProviderOptions( providerId: Parameters[0], defaultBaseUrlFallback: string, config?: SimpleProviderConfig, ): ModelManagerOptions<"anthropic-messages"> { const apiKey = config?.apiKey; const baseUrl = normalizeAnthropicBaseUrl(config?.baseUrl, defaultBaseUrlFallback); const discoveryBaseUrl = toAnthropicDiscoveryBaseUrl(baseUrl); const references = createBundledReferenceMap<"anthropic-messages">(providerId); return { providerId, ...(apiKey && { fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "anthropic-messages", provider: providerId, baseUrl: discoveryBaseUrl, headers: buildAnthropicDiscoveryHeaders(apiKey, discoveryBaseUrl), mapModel: (entry, defaults) => { const reference = references.get(defaults.id); const model = mapWithBundledReference(entry, defaults, reference); return { ...model, name: toModelName(entry.display_name, model.name), baseUrl, }; }, fetch: config?.fetch, }), }), }; } // --------------------------------------------------------------------------- // Umans AI Coding Plan // --------------------------------------------------------------------------- const UMANS_BASE_URL = "https://api.code.umans.ai"; const UMANS_MODELS_INFO_PATH = "/models/info"; const UMANS_REASONING_EFFORT_BY_LEVEL: Record = { minimal: Effort.Minimal, low: Effort.Low, medium: Effort.Medium, high: Effort.High, xhigh: Effort.XHigh, max: Effort.Max, }; const UMANS_DEFAULT_REASONING_EFFORTS = [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh] as const; const UMANS_VIA_HANDOFF_MODEL_IDS = ["umans-glm-5.1", "umans-glm-5.2"] as const; export interface UmansModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } interface UmansModelInfo { name?: unknown; display_name?: unknown; capabilities?: unknown; } function normalizeUmansBaseUrl(baseUrl: string | undefined): string { const normalized = normalizeAnthropicBaseUrl(baseUrl, UMANS_BASE_URL); return normalized.endsWith("/v1") ? normalized.slice(0, -3) : normalized; } /** * Umans `models/info` reports `supports_vision: true` for natively * vision-capable models and a non-empty string sentinel (e.g. * `"via-handoff"`) for models that route image inputs through a vision * handoff pre-analysis step instead of accepting raw image blocks. Only * `true` means the model accepts image content directly; sentinel values * MUST map to text-only so the agent's vision-handoff path runs instead * of triggering an upstream HTTP 400 (`This model does not support image * inputs`). */ function umansSupportsVision(value: unknown): boolean { return value === true; } function umansReasoningSupported(value: unknown): boolean { return isRecord(value) ? value.supported === true : value === true; } function mapUmansReasoningEfforts(value: unknown): readonly Effort[] { if (!isRecord(value) || !Array.isArray(value.levels)) { return UMANS_DEFAULT_REASONING_EFFORTS; } const efforts: Effort[] = []; for (const level of value.levels) { if (typeof level !== "string") continue; const effort = UMANS_REASONING_EFFORT_BY_LEVEL[level]; if (effort !== undefined && !efforts.includes(effort)) { efforts.push(effort); } } return efforts.length > 0 ? efforts : UMANS_DEFAULT_REASONING_EFFORTS; } function umansHasMaxReasoningLevel(value: unknown): boolean { return isRecord(value) && Array.isArray(value.levels) && value.levels.includes("max"); } function mapUmansThinkingConfig(value: unknown): ThinkingConfig | undefined { if (!umansReasoningSupported(value)) return undefined; const efforts = mapUmansReasoningEfforts(value); const thinking: ThinkingConfig = { mode: umansHasMaxReasoningLevel(value) ? "anthropic-budget-effort" : "budget", efforts, }; if (isRecord(value)) { if (value.can_disable === false) { thinking.requiresEffort = true; } if (typeof value.default_level === "string") { const defaultLevel = UMANS_REASONING_EFFORT_BY_LEVEL[value.default_level]; if (defaultLevel !== undefined && efforts.includes(defaultLevel)) { thinking.defaultLevel = defaultLevel; } } } return thinking; } function mapUmansModelInfo( modelId: string, raw: UmansModelInfo, baseUrl: string, reference: ModelSpec<"anthropic-messages"> | undefined, ): ModelSpec<"anthropic-messages"> | null { if (!modelId) return null; const capabilities = isRecord(raw.capabilities) ? raw.capabilities : {}; const supportsTools = capabilities.supports_tools; const thinking = mapUmansThinkingConfig(capabilities.reasoning); return { ...reference, id: modelId, name: toModelName(raw.display_name, toModelName(raw.name, modelId)), api: "anthropic-messages", provider: "umans", baseUrl, compat: { ...reference?.compat, escapeBuiltinToolNames: true }, reasoning: thinking !== undefined, ...(thinking ? { thinking } : {}), input: umansSupportsVision(capabilities.supports_vision) ? ["text", "image"] : ["text"], ...(supportsTools === false ? { supportsTools: false } : {}), cost: reference?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: toPositiveNumber(capabilities.context_window, reference?.contextWindow ?? null), maxTokens: toPositiveNumber( capabilities.recommended_max_tokens, toPositiveNumber(capabilities.max_completion_tokens, reference?.maxTokens ?? null), ), }; } async function fetchUmansModelsInfo(options: { baseUrl: string; apiKey?: string; fetch?: FetchImpl; references: Map>; }): Promise[] | null> { const discoveryBaseUrl = toAnthropicDiscoveryBaseUrl(options.baseUrl); const requestHeaders: Record = { Accept: "application/json" }; if (options.apiKey) { requestHeaders["x-api-key"] = options.apiKey; } const fetchImpl = discoveryFetch(options.fetch); let payload: unknown; try { const response = await fetchImpl(`${discoveryBaseUrl}${UMANS_MODELS_INFO_PATH}`, { method: "GET", headers: requestHeaders, }); if (!response.ok) { return null; } payload = await response.json(); } catch (error) { throw new Error("Failed to fetch Umans models info", { cause: error }); } if (!isRecord(payload)) { return null; } const models: ModelSpec<"anthropic-messages">[] = []; for (const [modelId, value] of Object.entries(payload)) { if (!isRecord(value)) continue; const mapped = mapUmansModelInfo(modelId, value, options.baseUrl, options.references.get(modelId)); if (mapped) { models.push(mapped); } } return models.sort((left, right) => left.id.localeCompare(right.id)); } export function umansModelManagerOptions(config?: UmansModelManagerConfig): ModelManagerOptions<"anthropic-messages"> { const apiKey = config?.apiKey; const baseUrl = normalizeUmansBaseUrl(config?.baseUrl); const references = createBundledReferenceMap<"anthropic-messages">("umans"); return { providerId: "umans", dynamicModelsAuthoritative: true, dropCachedModelIdsOnStaticMismatch: UMANS_VIA_HANDOFF_MODEL_IDS, fetchDynamicModels: () => fetchUmansModelsInfo({ baseUrl, apiKey, fetch: config?.fetch, references }), }; } // --------------------------------------------------------------------------- // 1. OpenAI // --------------------------------------------------------------------------- const OPENAI_API_BASE_URL = "https://api.openai.com/v1"; export interface OpenAIModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function openaiModelManagerOptions(config?: OpenAIModelManagerConfig): ModelManagerOptions { const options = createOpenAICompatibleModelManagerOptions({ api: "openai-responses", providerId: "openai", defaultBaseUrl: OPENAI_API_BASE_URL, config, requireApiKey: true, filterModel: (_entry, model, references) => isLikelyOpenAIResponsesModelId(model.id, references), mapModel: mapWithBundledReference, }); const fetchDiscovered = options.fetchDynamicModels; if (!fetchDiscovered) return options; return { ...options, fetchDynamicModels: async () => appendUnbundledSeedModels("openai", await fetchDiscovered()), }; } /** * Append the authored seed rows that neither discovery nor the bundled catalog * carries yet: a seed added since the last models.json regeneration (the * Decisions judge row) reaches runtime through a successful discovery. Seeds * already bundled keep resolving through the bundled slice, and the static * catalog (and with it the cache fingerprint) stays the bundled one. */ function appendUnbundledSeedModels( providerId: GeneratedProvider, discovered: readonly ModelSpec[] | null, ): readonly ModelSpec[] | null { if (!discovered) return null; const known = new Set(getBundledModels(providerId).map(model => model.id)); for (const model of discovered) known.add(model.id); const seeds = seedModels(providerId).filter(seed => !known.has(seed.id)); return seeds.length === 0 ? discovered : [...discovered, ...seeds.map(seed => ({ ...seed }))]; } /** * A row this generator pass owns: one of the derived `gpt-5.6-*-pro` alias ids * on a swept provider that carries the generated `reasoningMode` marker. * A real upstream model occupying the same id has no `reasoningMode` and is * never touched. * The reviewed base-id and sweep tables live in the compiled discovery * vocabulary (`pro-reasoning-alias` / `pro-reasoning-sweep`). */ function isGeneratedOpenAIProReasoningAlias(model: ModelSpec): boolean { const { proReasoningAliases, proReasoningSweep } = discoveryVocabulary(); return ( proReasoningSweep.includes(model.provider) && model.reasoningMode !== undefined && model.id.endsWith("-pro") && Object.values(proReasoningAliases).some(bases => bases.includes(model.id.slice(0, -"-pro".length))) ); } /** * Re-derive the generated pro-reasoning aliases (`gpt-5.6-*-pro`) for the * first-party `openai` gpt-5.6 rows. Each alias inherits the base row's * metadata, requests the base wire id via `requestModelId`, and sets * `reasoningMode: "pro"` so Responses-family request builders emit * `reasoning: { mode: "pro" }`. Called by the models.json generator after all * sources merge: stale copies of the owned aliases (previous snapshot, * including retired `openai-codex` rows) are dropped and re-projected from the * current base rows so alias metadata always tracks the base, while a real * upstream model that occupies an alias id wins and suppresses the projection. */ export function projectOpenAIProReasoningAliases(models: readonly ModelSpec[]): ModelSpec[] { const kept = models.filter(model => !isGeneratedOpenAIProReasoningAlias(model)); const ids = new Set(kept.map(model => `${model.provider}/${model.id}`)); const out = [...kept]; for (const model of kept) { const bases = discoveryVocabulary().proReasoningAliases[model.provider]; if (!bases?.includes(model.id)) continue; const aliasId = `${model.id}-pro`; const aliasKey = `${model.provider}/${aliasId}`; if (ids.has(aliasKey)) continue; ids.add(aliasKey); out.push({ ...model, id: aliasId, name: `${model.name} Pro`, requestModelId: model.id, reasoningMode: "pro", }); } return out; } // --------------------------------------------------------------------------- // 1b. GMI Cloud // --------------------------------------------------------------------------- const GMI_CLOUD_BASE_URL = "https://api.gmi-serving.com/v1"; export interface GmiCloudModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } /** * Map a discovered GMI Cloud model to a full spec. * * GMI's `/v1/models` returns only bare `{id}` rows, so discovery defaults carry * no limits, reasoning, or thinking metadata. When a gmi-cloud bundled * reference exists (the seeded default) it supplies GMI's published tariff and * limits directly. Every other id is an open-weight model GMI resells under its * canonical id (`deepseek-ai/…`, `moonshotai/…`, `zai-org/…`, `Qwen/…`), so its * intrinsic capabilities — context window, output limit, reasoning, thinking * ladder — are recovered from any bundled upstream entry via the canonical * reference index. Pricing is deliberately never borrowed across providers: * GMI's per-model tariff is unknown for these ids, so cost stays zeroed rather * than inheriting another provider's rate. */ function mapGmiCloudModel( entry: OpenAICompatibleModelRecord, defaults: ModelSpec<"openai-completions">, reference: ModelSpec<"openai-completions"> | undefined, ): ModelSpec<"openai-completions"> { if (reference) { return mapWithBundledReference(entry, defaults, reference); } const canonical = resolveModelReference(defaults.id, getBundledModelReferenceIndex()) as | ModelSpec<"openai-completions"> | undefined; if (!canonical) { return { ...defaults, name: toModelName(entry.name, defaults.name) }; } const contextWindow = canonical.contextWindow ?? defaults.contextWindow; const maxTokens = canonical.maxTokens != null && contextWindow != null ? Math.min(canonical.maxTokens, contextWindow) : (canonical.maxTokens ?? defaults.maxTokens); return { ...defaults, name: toModelName(entry.name, canonical.name ?? defaults.name), reasoning: canonical.reasoning, input: canonical.input, ...(canonical.thinking && { thinking: canonical.thinking }), contextWindow, maxTokens, }; } export function gmiCloudModelManagerOptions( config?: GmiCloudModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createOpenAICompatibleModelManagerOptions({ api: "openai-completions", providerId: "gmi-cloud", defaultBaseUrl: GMI_CLOUD_BASE_URL, cacheProviderId: resolveModelCacheProviderId("gmi-cloud"), config, requireApiKey: true, mapModel: mapGmiCloudModel, }); } // --------------------------------------------------------------------------- // 2. Groq // --------------------------------------------------------------------------- export interface GroqModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function groqModelManagerOptions(config?: GroqModelManagerConfig): ModelManagerOptions<"openai-completions"> { return createSimpleOpenAICompletionsOptions("groq", "https://api.groq.com/openai/v1", config); } // --------------------------------------------------------------------------- // 3. Cerebras // --------------------------------------------------------------------------- export interface CerebrasModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function cerebrasModelManagerOptions( config?: CerebrasModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createOpenAICompatibleModelManagerOptions({ api: "openai-completions", providerId: "cerebras", defaultBaseUrl: "https://api.cerebras.ai/v1", config, requireApiKey: true, mapModel: (entry, defaults, reference) => mapWithBundledReference(entry, defaults, reference), }); } // --------------------------------------------------------------------------- // 3.5 SambaNova // --------------------------------------------------------------------------- /** SambaNova discovery inputs: `apiKey` enables the live roster; `baseUrl` defaults to SambaNova Cloud. */ export interface SambaNovaModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } /** * One SambaNova per-token USD rate (a decimal string) as a per-million rate. * `absent` is the value for a missing field; a present field that is not a * nonnegative number yields `undefined`. */ function sambanovaRate(value: unknown, absent: number | undefined): number | undefined { if (value === undefined || value === null) return absent; const parsed = toNumber(value); return parsed !== undefined && parsed >= 0 ? Math.round(parsed * 1e12) / 1e6 : undefined; } /** * Map SambaNova's `pricing` block. Prompt and completion rates are required; * cache rates default to 0 when absent. An unreadable card stays unpriced * (`unknown`) instead of reading as free, and an all-zero card is a declared * free tier. */ function sambanovaPricing( pricing: unknown, fallback: TokenCost, ): Pick, "cost" | "pricingStatus"> { const rates = isRecord(pricing) ? pricing : {}; const input = sambanovaRate(rates.prompt, undefined); const output = sambanovaRate(rates.completion, undefined); const cacheRead = sambanovaRate(rates.input_cache_read, 0); const cacheWrite = sambanovaRate(rates.input_cache_write, 0); if (input === undefined || output === undefined || cacheRead === undefined || cacheWrite === undefined) { return { cost: fallback, pricingStatus: "unknown" }; } const cost = { input, output, cacheRead, cacheWrite }; return input + output + cacheRead + cacheWrite === 0 ? { cost, pricingStatus: "free" } : { cost }; } /** * SambaNova Cloud's model manager. `/v1/models` requires a key, so a keyless * manager serves only the bundled seed; with a key the live roster replaces * it. Rows carry pricing and limits but no capability metadata: reasoning, * thinking wire format, and image input come from `providers/sambanova.kdl`. */ export function sambanovaModelManagerOptions( config?: SambaNovaModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { const apiKey = config?.apiKey; const baseUrl = config?.baseUrl ?? "https://api.sambanova.ai/v1"; return { providerId: "sambanova", dynamicModelsAuthoritative: true, // The live `pricing` block is the tariff: an explicit `0` must not revert // to the bundled seed's rate. dynamicCostAuthoritative: true, ...(apiKey && { fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-completions", provider: "sambanova", baseUrl, apiKey, filterModel: (_entry, model) => !isExcludedModel("sambanova", model.id), mapModel: (entry, defaults) => ({ ...defaults, ...sambanovaPricing(entry.pricing, defaults.cost), contextWindow: toPositiveNumber(entry.context_length, defaults.contextWindow), maxTokens: toPositiveNumber(entry.max_completion_tokens, defaults.maxTokens), }), fetch: config?.fetch, }), }), }; } // --------------------------------------------------------------------------- // 4. Hugging Face // --------------------------------------------------------------------------- export interface HuggingfaceModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function huggingfaceModelManagerOptions( config?: HuggingfaceModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createSimpleOpenAICompletionsOptions("huggingface", "https://router.huggingface.co/v1", config); } // --------------------------------------------------------------------------- // 4.5 Helmcode // --------------------------------------------------------------------------- export interface HelmcodeModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } /** * First-party hosts of the models Helmcode resells (helmcode.com/docs/models, * "Frontier models"). Resold ids resolve only against these rows: the global * bare-id index picks whichever gateway row wins a context/output tie, which * can carry a zero or marked-up price instead of the vendor list price. */ const HELMCODE_RESOLD_VENDORS = ["anthropic", "openai", "google"] as const satisfies readonly GeneratedProvider[]; function createHelmcodeVendorReferenceMap(): Map> { const references = new Map>(); for (const vendor of HELMCODE_RESOLD_VENDORS) { for (const [id, reference] of createBundledReferenceMap<"openai-completions">(vendor)) { if (!references.has(id)) references.set(id, reference); } } return references; } /** * Helmcode model manager: OpenAI-compatible chat completions at * `api.helmcode.com/v1`. `/v1/models` also lists embedding, rerank, TTS, and * STT models; the exclusion policy lives in `runtime/behavior.kdl` * (`exclude-models provider="helmcode"`). * * `/v1/models` carries no capability data. Resold frontier ids (Claude, GPT, * Gemini) take only capability facts from the first-party vendor's bundled * row: reasoning, modalities, context window, output cap, and list price. The * rest of that row (thinking shape, compat, native web search, tool dialects, * cache semantics) describes the vendor's own API, not this chat-completions * proxy; the host's `reasoning_effort` ladders and cache-write pricing live in * `providers/helmcode.kdl`. Ids with no Helmcode or vendor row (e.g. a new * open-weight model) inherit nothing from other gateways. */ export function helmcodeModelManagerOptions( config?: HelmcodeModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { let vendorReferences: Map> | undefined; const resolveVendorReference = (id: string) => (vendorReferences ??= createHelmcodeVendorReferenceMap()).get(id); return createOpenAICompatibleModelManagerOptions({ api: "openai-completions", providerId: "helmcode", defaultBaseUrl: "https://api.helmcode.com/v1", config, requireApiKey: true, filterModel: (_entry, model) => !isExcludedModel("helmcode", model.id), mapModel: (entry, defaults, helmcodeReference) => { if (helmcodeReference) return mapWithBundledReference(entry, defaults, helmcodeReference); const vendor = resolveVendorReference(defaults.id); if (!vendor) return mapWithBundledReference(entry, defaults, undefined); return { ...defaults, name: toModelName(entry.name, vendor.name), reasoning: vendor.reasoning, input: vendor.input, cost: vendor.cost, contextWindow: toPositiveNumber(entry.context_length, vendor.contextWindow), maxTokens: toPositiveNumber(entry.max_completion_tokens, vendor.maxTokens), }; }, // Must live on the manager options, not only the KDL descriptor: // `createModelManager()` prunes the bundled slice from this flag. dynamicModelsAuthoritative: true, }); } // --------------------------------------------------------------------------- // 5. NVIDIA // --------------------------------------------------------------------------- export interface NvidiaModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function nvidiaModelManagerOptions( config?: NvidiaModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createSimpleOpenAICompletionsOptions("nvidia", "https://integrate.api.nvidia.com/v1", config); } // --------------------------------------------------------------------------- // 5.5 Novita // --------------------------------------------------------------------------- /** Novita OpenAI-compatible discovery configuration. */ export interface NovitaModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } function novitaArrayIncludes(value: unknown, expected: string): boolean { return Array.isArray(value) && value.some(item => item === expected); } function isPublicNovitaModelId(id: string): boolean { return !id.toLowerCase().startsWith("ai_infer_test"); } // Novita reports token prices in 1/10,000 USD per million tokens. function toNovitaCostPerMillion(value: unknown): number { return toPositiveNumber(value, 0) / 10_000; } function getNovitaCacheReadPricePerMillion(entry: OpenAICompatibleModelRecord): number { const pricing = entry.pricing; if (!isRecord(pricing)) { return 0; } const cacheRead = pricing.input_cache_read; if (!isRecord(cacheRead)) { return 0; } return toNovitaCostPerMillion(cacheRead.price_per_m); } function mapNovitaModel( entry: OpenAICompatibleModelRecord, defaults: ModelSpec<"openai-completions">, reference: ModelSpec<"openai-completions"> | undefined, ): ModelSpec<"openai-completions"> { const model = mapWithBundledReference( { ...entry, name: entry.display_name ?? entry.title ?? entry.name, }, defaults, reference, ); return { ...model, reasoning: novitaArrayIncludes(entry.features, "reasoning"), supportsTools: novitaArrayIncludes(entry.features, "function-calling"), input: toInputCapabilities(entry.input_modalities), cost: { input: toNovitaCostPerMillion(entry.input_token_price_per_m), output: toNovitaCostPerMillion(entry.output_token_price_per_m), cacheRead: getNovitaCacheReadPricePerMillion(entry), cacheWrite: 0, }, contextWindow: toPositiveNumber(entry.context_size, model.contextWindow), maxTokens: toPositiveNumber(entry.max_output_tokens, model.maxTokens), }; } /** Builds Novita's public model-discovery manager. */ export function novitaModelManagerOptions( config?: NovitaModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createOpenAICompatibleModelManagerOptions({ api: "openai-completions", providerId: "novita", defaultBaseUrl: "https://api.novita.ai/openai/v1", config, dynamicModelsAuthoritative: true, filterModel: (entry, model) => { const active = typeof entry.status !== "number" || entry.status === 1; return ( active && isPublicNovitaModelId(model.id) && novitaArrayIncludes(entry.endpoints, "chat/completions") && toPositiveNumber(entry.max_output_tokens, 0) > 0 ); }, mapModel: mapNovitaModel, }); } // --------------------------------------------------------------------------- // 5.6 DeepInfra // --------------------------------------------------------------------------- export const DEEPINFRA_BASE_URL = "https://api.deepinfra.com/v1/openai"; /** * `filter=with_meta` attaches per-model `metadata` (limits, pricing, tags); * `sort_by=omp` asks DeepInfra to return models in omp-priority order * (earlier = better). The mapper does not stamp `priority` yet — see * `mapDeepinfraModel` — but the params are sent so discovery picks the * ordering up as soon as the server honors it. */ const DEEPINFRA_MODELS_QUERY = "?filter=with_meta&sort_by=omp"; const DEEPINFRA_EFFORTS = [Effort.Low, Effort.Medium, Effort.High] as const; /** DeepInfra OpenAI-compatible discovery configuration. */ export interface DeepinfraModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } interface DeepinfraModelEntry { id?: unknown; metadata?: unknown; } function deepinfraTags(metadata: Record): readonly string[] { const tags = metadata.tags; return Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === "string") : []; } /** * Map one DeepInfra catalog entry to a chat model spec. Non-`chat` entries * (`tts`, `stt`, `embed`, `image-gen`, `video-gen`) are dropped — those * surfaces are served by dedicated tool backends, not the chat catalog. * DeepInfra reports token prices in USD per 1M tokens — omp's `ModelCost` * unit, used verbatim. A bundled reference (when the generated catalog has * one) is spread first so compat/tooling metadata can contribute, but the * live metadata always wins for limits, pricing, and modalities. */ function mapDeepinfraModel( entry: DeepinfraModelEntry, baseUrl: string, reference: ModelSpec<"openai-completions"> | undefined, ): ModelSpec<"openai-completions"> | null { const id = typeof entry.id === "string" ? entry.id.trim() : ""; if (!id) { return null; } const metadata = isRecord(entry.metadata) ? entry.metadata : {}; const tags = deepinfraTags(metadata); if (!tags.includes("chat")) { return null; } const pricing = isRecord(metadata.pricing) ? metadata.pricing : {}; // `metadata.discount` is a promotional fraction in [0, 1): DeepInfra bills // `pricing * (1 - discount)` (verified against the site — GLM-5.2 lists // input 0.75 with discount 0.35 and charges 0.4875), while `pricing.*` // stays at list price. Fold it into the rate card so cost reporting matches // what the user is actually billed. Values outside (0, 1) are ignored. const discount = toNumber(metadata.discount); const discountMultiplier = discount !== undefined && discount > 0 && discount < 1 ? 1 - discount : 1; // `reasoning_effort` marks models whose effort dial is advertised. The // parameter itself is validated and accepted platform-wide on DeepInfra // (verified: 200 on effort-tagged, reasoning-only, and plain-chat models; // 422 only for out-of-enum values), so a bundled reference's effort ladder // surviving the `...reference` spread after a tag disappears is harmless — // the host ignores the dial rather than rejecting the request. const thinking: ThinkingConfig | undefined = tags.includes("reasoning_effort") ? { mode: "effort", efforts: [...DEEPINFRA_EFFORTS] } : undefined; const contextWindow = toPositiveNumber(metadata.context_length, reference?.contextWindow ?? null); // `metadata.max_tokens` mirrors `context_length` on every live row today — // it is the total token ceiling, not an output cap (the catalog exposes no // output-specific field). Stamping it into `maxTokens` would advertise // `output === context` for every model and default oversized `max_tokens` // onto models with smaller real output limits. Trust it only when it is // strictly below the context window (i.e. the API starts publishing a real // output cap); otherwise keep the bundled reference's cap (stencil.so fill // during generation) or leave the limit unknown so requests defer to the // server-side cap. const liveMaxTokens = toPositiveNumber(metadata.max_tokens, 0); const hasLiveOutputCap = contextWindow !== null && liveMaxTokens > 0 && liveMaxTokens < contextWindow; // A same-id reference carries the canonical deployment's output cap, which // can exceed the (smaller) window DeepInfra serves — e.g. a 500K cap against // a 256K context. An output cap above the context ceiling is meaningless, so // keep the fallback inside the window. const referenceMaxTokens = reference?.maxTokens ?? null; const maxTokens = hasLiveOutputCap ? liveMaxTokens : referenceMaxTokens !== null && contextWindow !== null ? Math.min(referenceMaxTokens, contextWindow) : referenceMaxTokens; // This endpoint is filtered to `chat`; a same-id runner reference may lend // metadata, but its kind is not evidence that chat discovery advertised it. const { kind: _inheritedKind, ...chatReference } = reference ?? {}; return { ...chatReference, id, name: reference?.name ?? id, api: "openai-completions", provider: "deepinfra", baseUrl, reasoning: tags.includes("reasoning") || tags.includes("reasoning_effort"), ...(thinking ? { thinking } : {}), input: tags.includes("vision") || tags.includes("vlm") ? ["text", "image"] : ["text"], cost: { input: toPositiveNumber(pricing.input_tokens, 0) * discountMultiplier, output: toPositiveNumber(pricing.output_tokens, 0) * discountMultiplier, cacheRead: toPositiveNumber(pricing.cache_read_tokens, 0) * discountMultiplier, cacheWrite: 0, }, contextWindow, maxTokens, }; } /** * Bespoke fetch instead of `fetchOpenAICompatibleModels`: the shared helper * cannot carry the `filter`/`sort_by` query params and re-sorts results by id, * which would destroy DeepInfra's priority ordering once the server honors * `sort_by=omp`. Response order is preserved (dedupe keeps the first, i.e. * highest-priority, occurrence). */ async function fetchDeepinfraModels(options: { baseUrl: string; apiKey?: string; fetch?: FetchImpl; references: Map>; }): Promise[] | null> { const headers: Record = { Accept: "application/json" }; if (options.apiKey) { headers.Authorization = `Bearer ${options.apiKey}`; } const fetchImpl = discoveryFetch(options.fetch); let payload: unknown; try { const response = await withOpenAICompatibleDiscoveryTimeout( DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, signal => fetchImpl(`${options.baseUrl}/models${DEEPINFRA_MODELS_QUERY}`, { method: "GET", headers, signal }), ); if (!response.ok) { return null; } payload = await response.json(); } catch { return null; } if (!isRecord(payload) || !Array.isArray(payload.data)) { return null; } const models: ModelSpec<"openai-completions">[] = []; const seen = new Set(); for (const entry of payload.data) { if (!isRecord(entry)) { continue; } const reference = typeof entry.id === "string" ? options.references.get(entry.id) : undefined; const mapped = mapDeepinfraModel(entry as DeepinfraModelEntry, options.baseUrl, reference); if (mapped && !seen.has(mapped.id)) { seen.add(mapped.id); models.push(mapped); } } return models; } /** * Builds DeepInfra's model-discovery manager. The catalog endpoint is public, * so discovery (and keyless `gen:models` generation) works without an API key; * model availability at runtime is still gated on credentials by the registry. */ export function deepinfraModelManagerOptions( config?: DeepinfraModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { const apiKey = config?.apiKey; const baseUrl = (config?.baseUrl ?? DEEPINFRA_BASE_URL).replace(/\/$/, ""); const references = createBundledReferenceMap<"openai-completions">("deepinfra"); return { providerId: "deepinfra", dynamicModelsAuthoritative: true, // `vision`/`vlm` tags are the whole truth for modality on this host. dynamicInputAuthoritative: true, fetchDynamicModels: () => fetchDeepinfraModels({ baseUrl, apiKey, fetch: config?.fetch, references }), }; } // --------------------------------------------------------------------------- // CoralBricks // --------------------------------------------------------------------------- export const CORALBRICKS_BASE_URL = "https://inference.coralbricks.ai/v1"; /** CoralBricks OpenAI-compatible discovery configuration. */ export interface CoralbricksModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } /** * The CoralBricks-specific fields of a `GET /v1/models` row: per-million USD * pricing and the capability flags Coral documents as authoritative * (https://www.coralbricks.ai/docs.md). */ interface CoralbricksModelRecord extends OpenAICompatibleModelRecord { context_length?: unknown; pricing?: unknown; supports_chat?: unknown; supports_image_input?: unknown; supports_tools?: unknown; supports_reasoning?: unknown; reasoning?: unknown; } /** * Read one live per-million price; a missing or negative field falls back to * the bundled reference. An explicit `0` is a real price: the manager keeps it * because `coralbricksModelManagerOptions` declares `dynamicCostAuthoritative`. */ function coralRate(value: unknown, fallback: number): number { const parsed = toNumber(value); return parsed !== undefined && parsed >= 0 ? parsed : fallback; } /** * Map one CoralBricks catalog row to a chat model spec; non-chat rows * (`supports_chat: false`) are dropped. Pricing arrives in Coral's own * per-million field names; `cached_input_per_m` is $0 on every model and a * missing field falls back to the bundled reference. * Live reasoning capabilities and controls override the bundled fallback. * The endpoint publishes no output cap, so `maxTokens` keeps its reference * value rather than being invented from the context window. */ function mapCoralbricksModel( entry: CoralbricksModelRecord, defaults: ModelSpec<"openai-completions">, reference: ModelSpec<"openai-completions"> | undefined, ): ModelSpec<"openai-completions"> | null { if (entry.supports_chat === false) { return null; } const pricing = isRecord(entry.pricing) ? entry.pricing : {}; // A bundled reference may lend metadata, but its runner kind is not // evidence the chat roster advertised it. const { kind: _inheritedKind, ...chatReference } = reference ?? {}; const hasReasoningFlag = typeof entry.supports_reasoning === "boolean"; const reasoning = hasReasoningFlag ? entry.supports_reasoning === true : (reference?.reasoning ?? defaults.reasoning); const controls = isRecord(entry.reasoning) ? entry.reasoning : undefined; const wireEfforts = controls?.supported_efforts; let thinking = reasoning ? reference?.thinking : undefined; let compat = reference?.compat; if (reasoning && Array.isArray(wireEfforts)) { const efforts = THINKING_EFFORTS.filter(effort => wireEfforts.includes(effort)); // `none` is the server's off default, not an Effort (nor `minimal`). const defaultLevel = efforts.find(effort => effort === controls?.default_effort); thinking = efforts.length > 0 ? { mode: "effort", efforts, ...(defaultLevel !== undefined && { defaultLevel }), ...(typeof controls?.mandatory === "boolean" && { requiresEffort: controls.mandatory }), } : undefined; } if (hasReasoningFlag || Array.isArray(wireEfforts)) { // An explicit empty/unknown vocabulary must not regrow a guessed dial // from identity or KDL. Missing legacy metadata still uses the reference. compat = { ...compat, trustExplicitThinkingOnly: true }; } if (reasoning && controls) { if (controls.mandatory === true || controls.disable === null) { compat = { ...compat, reasoningDisableMode: "lowest-effort" }; } else if (isRecord(controls.disable) && controls.disable.reasoning_effort === "none") { compat = { ...compat, reasoningDisableMode: "none-effort" }; } } const input: ("text" | "image")[] = entry.supports_image_input === true ? ["text", "image"] : entry.supports_image_input === false ? ["text"] : (reference?.input ?? defaults.input); return { ...defaults, ...chatReference, id: defaults.id, name: reference?.name ?? defaults.name, api: defaults.api, provider: defaults.provider, baseUrl: defaults.baseUrl, reasoning, thinking, compat, input, ...(typeof entry.supports_tools === "boolean" ? { supportsTools: entry.supports_tools } : {}), cost: { input: coralRate(pricing.input_per_m, reference?.cost.input ?? 0), output: coralRate(pricing.output_per_m, reference?.cost.output ?? 0), cacheRead: coralRate(pricing.cached_input_per_m, reference?.cost.cacheRead ?? 0), cacheWrite: coralRate(pricing.cache_write_per_m, reference?.cost.cacheWrite ?? 0), }, contextWindow: toPositiveNumber(entry.context_length, reference?.contextWindow ?? null), maxTokens: reference?.maxTokens ?? null, }; } /** * Builds CoralBricks' model-discovery manager. `/v1/models` is key-protected * (401 without a bearer key), so a keyless config serves only the bundled * reviewed seed rows; with a key, live rows are authoritative over the bundle. */ export function coralbricksModelManagerOptions( config?: CoralbricksModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { const apiKey = config?.apiKey; const baseUrl = config?.baseUrl ?? CORALBRICKS_BASE_URL; const references = createBundledReferenceMap<"openai-completions">("coralbricks"); return { providerId: "coralbricks", dynamicModelsAuthoritative: true, dynamicReasoningAuthoritative: true, // `supports_image_input` is the row's whole truth for modality (Coral // answers unsupported content with `400 unsupported_content_type`). dynamicInputAuthoritative: true, // Coral's `pricing` block is the deployment tariff; an explicit live `0` // (a free model or no cache-write charge) must not revert to the bundle. dynamicCostAuthoritative: true, ...(apiKey && { fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-completions", provider: "coralbricks", baseUrl, apiKey, fetch: config?.fetch, mapModel: (entry, defaults) => mapCoralbricksModel(entry as CoralbricksModelRecord, defaults, references.get(defaults.id)), }), }), }; } // --------------------------------------------------------------------------- // 6. xAI // --------------------------------------------------------------------------- export interface XaiModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function xaiModelManagerOptions(config?: XaiModelManagerConfig): ModelManagerOptions<"openai-responses"> { return { ...createOpenAICompatibleModelManagerOptions({ api: "openai-responses", providerId: "xai", defaultBaseUrl: "https://api.x.ai/v1", config, requireApiKey: true, mapModel: mapWithBundledReference, }), // Completions → Responses migration: a fresh authoritative cache written // by the old resolver stores `api: "openai-completions"` for these ids. // Without a drop list, `online-if-uncached` skips the network and // `mergeDynamicModel` lets the cached api win over the new static // Responses entries until TTL expiry. dropCachedModelIdsOnStaticMismatch: getBundledModels("xai").map(model => model.id), }; } export interface XaiOAuthModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } // xAI /v1/models returns chat, image, voice, and STT entries. Tool surfaces // route through dedicated tools (generate_image, tts) with their own model // strings; the chat picker MUST exclude these prefixes or selecting them 400s. function withXaiOAuthCompatDefaults(model: ModelSpec<"openai-responses">): ModelSpec<"openai-responses"> { const compat = { ...model.compat, includeEncryptedReasoning: model.compat?.includeEncryptedReasoning ?? true, filterReasoningHistory: model.compat?.filterReasoningHistory ?? false, supportsImageDetailOriginal: model.compat?.supportsImageDetailOriginal ?? false, omitReasoningEffort: model.compat?.omitReasoningEffort ?? resolveModelPolicy(model).compat.omitReasoningEffort, }; return { ...model, compat }; } // Hermes-agent parity for `minimal -> low` (see hermes-agent/agent/transports/ // codex.py:92). Multi-agent Grok keeps `xhigh` unmapped (agent-count mode); // other first-party SKUs clamp leftover `xhigh`/`max` to `high`. // `resolveModelThinking` folds this into `model.thinking.effortMap`. /** * Bake first-party xAI Responses effort-dial metadata onto a catalog spec. * * models.dev marks many Grok SKUs as reasoners and the thinking rebake would * otherwise emit a default `minimal/low/medium/high` dial. api.x.ai * accepts `reasoning.effort` only for ids selected by the compat rule — * off-allowlist reasoners (`grok-code-fast-1`, `grok-build-0.1`, * `grok-4.20-0309-reasoning`, …) 400 if the param is sent. SuperGrok * (`xai-oauth`) already curates this via {@link mergeCuratedIntoModel}; paid * `xai` rows come from stencil.so and need the same wire facts in the exported * `models.json` so direct catalog readers do not present an unsupported dial. * * Explicit `compat.supportsReasoningEffort` / `omitReasoningEffort` win. */ export function applyXaiResponsesThinkingPolicy(model: ModelSpec<"openai-responses">): ModelSpec<"openai-responses"> { const effortCapable = model.compat?.supportsReasoningEffort ?? resolveModelPolicy(model).compat.supportsReasoningEffort; const compat = { ...model.compat, supportsReasoningEffort: effortCapable, omitReasoningEffort: model.compat?.omitReasoningEffort ?? !effortCapable, }; if (effortCapable) { compat.reasoningEffortMap = { ...xaiResponsesReasoningEffortMap(model.id) }; } else { delete compat.reasoningEffortMap; } return { ...model, compat }; } // xai-oauth's /v1/models exposes no per-request output limit on the OAuth // (Grok Build / SuperGrok) surface, so the curated catalog owns `maxTokens` // like it owns `contextWindow`: each entry mirrors its context window. The // openai-responses wire clamps the actual request to // min(requested, model.maxTokens, OPENAI_MAX_OUTPUT_TOKENS=64000), so this is // just "no model-specific sub-cap below 64k", not an unbounded output budget. // Single source of truth for curated → Model fan-in. Used by the static-seed // and the dynamic overlay/inject paths (applyXAIOAuthCuration) so curated // reasoning/effort flags survive an online refresh (xAI's /v1/models lacks // reasoning metadata and fetchOpenAICompatibleModels defaults reasoning to // false). Caller supplies a `base` Model (either a freshly synthesised seed // or a dynamic-fetched entry); the helper layers curated fields on top. // The effort remap from {@link xaiResponsesReasoningEffortMap} is merged // only onto effort-capable rows. Off-allowlist reasoners omit the wire // param, so a map on those specs is dead weight. // The effort-dial pair (`supportsReasoningEffort`/`omitReasoningEffort`) is // authoritative: a stale flag on `base` (previous snapshot or dynamic fetch) // must not outlive a compat-rule change. function mergeCuratedIntoModel( base: ModelSpec<"openai-responses">, curated: ModelSpec<"openai-responses">, ): ModelSpec<"openai-responses"> { const effortCapable = curated.compat?.supportsReasoningEffort ?? resolveModelPolicy({ ...base, id: curated.id, provider: "xai-oauth" }).compat.supportsReasoningEffort; const compat = { ...base.compat, includeEncryptedReasoning: base.compat?.includeEncryptedReasoning ?? true, filterReasoningHistory: false, supportsImageDetailOriginal: base.compat?.supportsImageDetailOriginal ?? false, omitReasoningEffort: !effortCapable, supportsReasoningEffort: effortCapable, }; if (effortCapable) { compat.reasoningEffortMap = { ...xaiResponsesReasoningEffortMap(curated.id) }; } else { delete compat.reasoningEffortMap; } return { ...base, contextWindow: curated.contextWindow ?? base.contextWindow, maxTokens: curated.contextWindow ?? base.maxTokens, name: curated.name, reasoning: curated.reasoning, input: curated.input, compat, }; } /** * Overlay/inject curated xai-oauth metadata onto dynamic-fetch results so * a successful `online refresh` doesn't regress vision capability, context * window, reasoning flags, or the effort-dial allowlist. * * Three passes: * 1. Filter KDL exclusions and runner seed ids out of the chat roster. * 2. Overlay curated metadata onto dynamic-fetch matches. xAI's /v1/models * does not return context_window or reasoning metadata, so without * this overlay the runtime falls back to the bundled-reference default * (effectively 128k context) and `reasoning: false` (suppressing the * effort dial and stripping thinking metadata downstream). * 3. Inject curated entries missing from the dynamic fetch. Clones the * first surviving entry as a template so required Model fields (api, * provider, baseUrl, cost, etc.) inherit sane defaults. If `filtered` * is empty (offline / no auth) injection is skipped — the descriptor's * defaultModel covers the fallback. * * Order: curated models first in declaration order; then dynamic remainder * in original order. */ function applyXAIOAuthCuration(dynamic: readonly ModelSpec<"openai-responses">[]): ModelSpec<"openai-responses">[] { const curatedModels: ModelSpec<"openai-responses">[] = []; const runnerIds = new Set(); for (const seed of seedModels("xai-oauth")) { if (isResponsesSeed(seed)) curatedModels.push(seed); else runnerIds.add(seed.id); } const filtered = dynamic.filter(e => !runnerIds.has(e.id) && !isExcludedModel("xai-oauth", e.id)); const byId = new Map>(filtered.map(e => [e.id, e])); for (const curated of curatedModels) { const existing = byId.get(curated.id); if (existing) { byId.set(curated.id, mergeCuratedIntoModel(existing, curated)); } } const template = filtered[0]; if (template) { for (const curated of curatedModels) { if (!byId.has(curated.id)) { const base: ModelSpec<"openai-responses"> = { ...template, id: curated.id, name: curated.id }; byId.set(curated.id, mergeCuratedIntoModel(base, curated)); } } } const curatedIds = new Set(curatedModels.map(c => c.id)); const curatedFirst = curatedModels .map(c => byId.get(c.id)) .filter((e): e is ModelSpec<"openai-responses"> => e !== undefined); const rest = filtered.filter(e => !curatedIds.has(e.id)).map(withXaiOAuthCompatDefaults); return [...curatedFirst, ...rest]; } function isResponsesSeed(seed: ModelSpec): seed is ModelSpec<"openai-responses"> { return seed.api === "openai-responses"; } /** * Render the xai-oauth KDL seed as the static runtime fallback consumed by * {@link xaiOAuthModelManagerOptions}. */ export function buildXaiOAuthStaticSeed(baseUrl?: string): ModelSpec[] { const resolvedBaseUrl = baseUrl ?? "https://api.x.ai/v1"; return seedModels("xai-oauth").map(seed => { if (!isResponsesSeed(seed)) { return { ...seed, baseUrl: resolvedBaseUrl }; } const base: ModelSpec<"openai-responses"> = { ...seed, baseUrl: resolvedBaseUrl, compat: { reasoningEffortMap: xaiResponsesReasoningEffortMap(seed.id) }, }; return mergeCuratedIntoModel(base, seed); }); } export function xaiOAuthModelManagerOptions(config?: XaiOAuthModelManagerConfig): ModelManagerOptions { const defaultBaseUrl = "https://api.x.ai/v1"; const resolvedBaseUrl = config?.baseUrl ?? defaultBaseUrl; const base = createOpenAICompatibleModelManagerOptions({ api: "openai-responses", providerId: "xai-oauth", defaultBaseUrl, config, requireApiKey: true, mapModel: mapWithBundledReference, }); // Static seed handed to the runtime model manager so the picker populates on // a fresh login even before `fetchDynamicModels` fires (it is gated on // `config.apiKey` at construction time, and OAuth tokens resolve later via // AuthStorage). \`generate-models.ts\` calls the same builder so \`models.json\` // carries these entries too — making the synchronous `#loadModels()` boot // path honor `modelRoles.default = "xai-oauth/"` without `await refresh()`. const staticModels = buildXaiOAuthStaticSeed(resolvedBaseUrl); if (!base.fetchDynamicModels) { return { ...base, staticModels }; } // Wrap fetchDynamicModels so an `online refresh` against xAI's /v1/models // runs through applyXAIOAuthCuration — preserves curated context windows, // vision modality, reasoning flags, and filters tool-only model ids // (grok-imagine-*, grok-stt-*, grok-voice-*) from the chat picker. const inner = base.fetchDynamicModels; return { ...base, staticModels, fetchDynamicModels: async () => { const dynamic = await inner(); return dynamic == null ? dynamic : applyXAIOAuthCuration(dynamic); }, }; } // --------------------------------------------------------------------------- // 6.4 AIML API // --------------------------------------------------------------------------- export function isLikelyAimlApiChatModelId(id: string): boolean { const normalized = id.trim().toLowerCase(); if (!normalized) return false; return !isExcludedModel("aimlapi", normalized); } export interface AimlApiModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function aimlApiModelManagerOptions( config?: AimlApiModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createOpenAICompatibleModelManagerOptions({ api: "openai-completions", providerId: "aimlapi", defaultBaseUrl: "https://api.aimlapi.com/v1", config, dynamicModelsAuthoritative: true, requireApiKey: true, filterModel: (_entry, model) => isLikelyAimlApiChatModelId(model.id), mapModel: mapWithBundledReference, }); } // --------------------------------------------------------------------------- // 6.5 DeepSeek // --------------------------------------------------------------------------- export interface DeepSeekModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function deepseekModelManagerOptions( config?: DeepSeekModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createSimpleOpenAICompletionsOptions("deepseek", "https://api.deepseek.com", config); } // --------------------------------------------------------------------------- // 6.6 SiliconFlow // --------------------------------------------------------------------------- export interface SiliconFlowModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } /** * SiliconFlow's `/v1/models` lists every served model — including embeddings, * rerankers, image, audio, and video generators that cannot serve chat * completions — and carries no per-model type field, so non-chat entries are * dropped by id to keep the picker usable. */ export function isLikelySiliconFlowChatModelId(id: string): boolean { const normalized = id.trim().toLowerCase(); if (!normalized) { return false; } return !isExcludedModel("siliconflow", normalized); } /** * models.dev mappings consulted ONLY as a runtime metadata reference during * dynamic discovery. They are deliberately absent from * `MODELS_DEV_PROVIDER_DESCRIPTORS` so `generate-models.ts` never bundles * SiliconFlow models — the live endpoint decides which models exist, while * these entries hydrate the pricing, limits, and reasoning metadata that the * endpoint's bare `{id}` rows do not carry. No filter: the join against live * discovered ids already restricts hydration to chat models. */ const SILICONFLOW_MODELS_DEV_DESCRIPTORS: readonly ModelsDevProviderDescriptor[] = [ openAiCompletionsDescriptor("siliconflow", "siliconflow", "https://api.siliconflow.com/v1", { filterModel: () => true, }), openAiCompletionsDescriptor("siliconflow-cn", "siliconflow-cn", "https://api.siliconflow.cn/v1", { filterModel: () => true, }), ]; const SILICONFLOW_MODELS_DEV_REFERENCE_TIMEOUT_MS = 5_000; async function loadSiliconFlowModelsDevReferences( providerId: "siliconflow" | "siliconflow-cn", fetchImpl?: FetchImpl, ): Promise>> { const descriptor = SILICONFLOW_MODELS_DEV_DESCRIPTORS.find(d => d.providerId === providerId); if (!descriptor) { return new Map(); } try { // Bounded: this enrichment is optional, so a stalled models.dev must not // hold back the authoritative endpoint request that runs after it. const payload = await withOpenAICompatibleDiscoveryTimeout(SILICONFLOW_MODELS_DEV_REFERENCE_TIMEOUT_MS, signal => fetchWellKnownModels(fetchImpl, signal), ); return createModelsDevReferenceMap<"openai-completions">( mapModelsDevToModels(payload as Record, [descriptor]), ); } catch { return new Map(); } } function createSiliconFlowModelManagerOptions( providerId: "siliconflow" | "siliconflow-cn", defaultBaseUrl: string, config?: SiliconFlowModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { const apiKey = config?.apiKey; const baseUrl = config?.baseUrl ?? defaultBaseUrl; return { providerId, cacheProviderId: resolveModelCacheProviderId(providerId), dynamicModelsAuthoritative: true, ...(apiKey && { fetchDynamicModels: async () => { const modelsDevReferences = await loadSiliconFlowModelsDevReferences(providerId, config?.fetch); // Resolved here, not at options construction: walking the bundled // reference index is only worth paying for when dynamic discovery // actually runs, keeping the ModelManager cache fast path cheap. const canonicalReferences = getBundledModelReferenceIndex(); return fetchOpenAICompatibleModels({ api: "openai-completions", provider: providerId, baseUrl, apiKey, filterModel: (_entry, model) => isLikelySiliconFlowChatModelId(model.id), mapModel: (entry, defaults) => { const modelsDevReference = modelsDevReferences.get(defaults.id); if (modelsDevReference) { return mapWithBundledReference(entry, defaults, modelsDevReference); } // ids missing from models.dev (new launches) still recover intrinsic // capabilities and canonical limits from any bundled upstream/reseller // entry — but never its pricing, which is provider-specific. const canonical = resolveModelReference(defaults.id, canonicalReferences) as | ModelSpec<"openai-completions"> | undefined; if (!canonical) { return defaults; } const contextWindow = canonical.contextWindow ?? defaults.contextWindow; const maxTokens = canonical.maxTokens != null && contextWindow != null ? Math.min(canonical.maxTokens, contextWindow) : (canonical.maxTokens ?? defaults.maxTokens); return { ...defaults, name: toModelName(entry.name, canonical.name ?? defaults.name), reasoning: canonical.reasoning, input: canonical.input, contextWindow, maxTokens, }; }, fetch: config?.fetch, }); }, }), }; } export function siliconflowModelManagerOptions( config?: SiliconFlowModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createSiliconFlowModelManagerOptions("siliconflow", "https://api.siliconflow.com/v1", config); } export function siliconflowCnModelManagerOptions( config?: SiliconFlowModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createSiliconFlowModelManagerOptions("siliconflow-cn", "https://api.siliconflow.cn/v1", config); } // --------------------------------------------------------------------------- // 6.7 Zhipu Coding Plan // --------------------------------------------------------------------------- export interface ZhipuCodingPlanModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function zhipuCodingPlanModelManagerOptions( config?: ZhipuCodingPlanModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { const apiKey = config?.apiKey; const baseUrl = config?.baseUrl ?? "https://open.bigmodel.cn/api/coding/paas/v4"; return { providerId: "zhipu-coding-plan", dynamicModelsAuthoritative: true, ...(apiKey && { fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-completions", provider: "zhipu-coding-plan", baseUrl, apiKey, mapModel: ( _entry: OpenAICompatibleModelRecord, defaults: ModelSpec<"openai-completions">, _context: OpenAICompatibleModelMapperContext<"openai-completions">, ): ModelSpec<"openai-completions"> => { const id = defaults.id; const identity = classifyModel("zhipu-coding-plan", id, { lenient: true }); return { ...defaults, reasoning: isGlmReasoningIdentity("zhipu-coding-plan", id, "4.5") || identity.thinkingVariant === true, // Vision-capable families are rule-owned (`providers/zhipu-coding-plan.kdl` // input-modalities) and corrected at build time. input: ["text"], compat: { thinkingFormat: "zai", reasoningContentField: "reasoning_content", supportsDeveloperRole: false, }, }; }, fetch: config?.fetch, }), }), }; } // --------------------------------------------------------------------------- // 7.5 Fireworks // --------------------------------------------------------------------------- /** * Fireworks Fast variants we surface: the Fast table on * https://docs.fireworks.ai/serverless/serverless-modes, minus the US-only * router (it needs the `us.api.fireworks.ai` host). That table, not a request * outcome, decides membership: add or drop a row when Fireworks changes it. * Each inherits the base model's limits/modalities/thinking and overrides * only the cost with the Fast prices from the Serverless pricing table; * `cacheWrite` stays 0 (Fireworks bills no cache-write). Derived from the * bundled base entries so metadata stays in lockstep, and the runtime * auto-falls back to the base id on a failed fast request. * See https://docs.fireworks.ai/serverless/pricing. */ const FIREWORKS_FAST_VARIANT_SPECS: ReadonlyArray<{ base: string; name: string; cost: { input: number; output: number; cacheRead: number }; }> = [ { base: "kimi-k3", name: "Kimi K3 Fast", cost: { input: 4.5, output: 22.5, cacheRead: 0.45 } }, { base: "glm-5.3", name: "GLM-5.3 Fast", cost: { input: 2.1, output: 6.6, cacheRead: 0.39 } }, { base: "glm-5.2", name: "GLM-5.2 Fast", cost: { input: 2.1, output: 6.6, cacheRead: 0.21 } }, ]; /** * Build the Fireworks Fast seed by projecting each base bundled spec into a * `-fast` variant. Pushed into the generated catalog (Fast routers never * appear in the serverless control-plane list, so discovery cannot surface * them) and deduped behind any identical previous-snapshot entry. */ export function buildFireworksFastSeed(): ModelSpec<"openai-completions">[] { const bundled = createBundledReferenceMap<"openai-completions">("fireworks"); const seeds: ModelSpec<"openai-completions">[] = []; for (const variant of FIREWORKS_FAST_VARIANT_SPECS) { const base = bundled.get(variant.base); if (!base) continue; seeds.push({ ...base, id: `${variant.base}${FIREWORKS_FAST_SUFFIX}`, name: variant.name, cost: { input: variant.cost.input, output: variant.cost.output, cacheRead: variant.cost.cacheRead, cacheWrite: 0, }, }); } return seeds; } /** * Fireworks DeepSeek V4 accepts effort via `reasoning_effort` but rejects the * DeepSeek-native binary `thinking` toggle when both are present. */ export function stripFireworksDeepSeekThinkingToggle( model: ModelSpec<"openai-completions">, publicModelId: string, ): ModelSpec<"openai-completions"> { if (!isDeepseekV4Generation("fireworks", publicModelId)) return model; const compat = model.compat; if (!compat?.extraBody || !("thinking" in compat.extraBody)) return model; const extraBody = { ...compat.extraBody }; delete extraBody.thinking; if (Object.keys(extraBody).length > 0) { return { ...model, compat: { ...compat, extraBody } }; } const nextCompat = { ...compat }; delete nextCompat.extraBody; return { ...model, compat: nextCompat }; } export interface FireworksModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } const FIREWORKS_CONTROL_PLANE_ACCOUNT = "fireworks"; const FIREWORKS_SERVERLESS_FILTER = "supports_serverless=true"; const FIREWORKS_CONTROL_PLANE_PAGE_SIZE = 200; const FIREWORKS_CONTROL_PLANE_MAX_PAGES = 25; /** * One record from the Fireworks control-plane catalog * (`GET /v1/accounts/{account}/models`). This is distinct from the * OpenAI-compatible `/v1/models` inference envelope: the control plane * enumerates the full serverless catalog with camelCase capability metadata, * including on-demand models (e.g. `kimi-k2p7-code`) that never surface in * `/v1/models`. Discovering here is what keeps new serverless models appearing * without catalog edits — see the Fireworks docs `List Models` API. */ interface FireworksControlPlaneModel { /** Resource name, e.g. `accounts/fireworks/models/kimi-k2p7-code`. */ name?: unknown; displayName?: unknown; contextLength?: unknown; supportsImageInput?: unknown; supportsTools?: unknown; supportsServerless?: unknown; state?: unknown; } /** * Derive the control-plane list endpoint from the inference base URL. The * inference API lives under `/inference/v1` while the control plane is * `/v1/accounts//models` on the same origin, so we route off origin. * Returns null for unparseable overrides (custom gateways) so discovery falls * back to the cached/bundled catalog. */ function toFireworksControlPlaneModelsUrl(baseUrl: string, account: string): string | null { try { return `${new URL(baseUrl).origin}/v1/accounts/${account}/models`; } catch { return null; } } function mapFireworksControlPlaneModel( record: FireworksControlPlaneModel, publicModelId: string, reference: ModelSpec<"openai-completions"> | undefined, baseUrl: string, cost: ModelSpec<"openai-completions">["cost"] | undefined, ): ModelSpec<"openai-completions"> { const name = toModelName(record.displayName, reference?.name ?? publicModelId); const supportsImage = toBoolean(record.supportsImageInput) === true; const supportsTools = toBoolean(record.supportsTools); const contextWindow = toPositiveNumber(record.contextLength, reference?.contextWindow ?? null); // The control plane reports no max-output budget; Fireworks' per-model // output caps and fallbacks are rule-owned (providers/fireworks.kdl). const maxTokens = reference?.maxTokens ?? null; const base: ModelSpec<"openai-completions"> = reference ?? { id: publicModelId, name, api: "openai-completions", provider: "fireworks", baseUrl, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow, maxTokens, }; const model: ModelSpec<"openai-completions"> = { ...base, id: publicModelId, api: "openai-completions", provider: "fireworks", baseUrl, name, cost: cost ?? base.cost, // The control plane exposes capability flags but no reasoning bit. Every // serverless chat LLM Fireworks ships reasons, and `buildModel` derives // the Fireworks effort map from the id at build time — so default // unbundled models to reasoning while bundled references keep their value. reasoning: reference?.reasoning ?? true, input: supportsImage ? ["text", "image"] : (reference?.input ?? ["text"]), contextWindow, maxTokens, ...(supportsTools === false ? { supportsTools: false } : {}), }; return stripFireworksDeepSeekThinkingToggle(model, publicModelId); } /** * Discover Fireworks serverless models via the control-plane `List Models` * API (`supports_serverless=true`), paginating the full catalog. Returns null * on any transport/protocol failure so the model manager keeps the cached or * bundled catalog rather than caching a truncated list as authoritative. */ async function fetchFireworksServerlessModels(options: { baseUrl: string; apiKey: string; resolveReference: (publicModelId: string) => ModelSpec<"openai-completions"> | undefined; resolveCost: (publicModelId: string) => ModelSpec<"openai-completions">["cost"] | undefined; fetch?: FetchImpl; }): Promise[] | null> { const listUrl = toFireworksControlPlaneModelsUrl(options.baseUrl, FIREWORKS_CONTROL_PLANE_ACCOUNT); if (!listUrl) return null; const fetchImpl = discoveryFetch(options.fetch); const collected = new Map>(); let pageToken = ""; for (let page = 0; page < FIREWORKS_CONTROL_PLANE_MAX_PAGES; page++) { const url = new URL(listUrl); url.searchParams.set("filter", FIREWORKS_SERVERLESS_FILTER); url.searchParams.set("pageSize", String(FIREWORKS_CONTROL_PLANE_PAGE_SIZE)); if (pageToken) url.searchParams.set("pageToken", pageToken); let response: Response; try { response = await fetchImpl(url.toString(), { method: "GET", headers: { Accept: "application/json", Authorization: `Bearer ${options.apiKey}` }, }); } catch { return null; } if (!response.ok) return null; let payload: unknown; try { payload = await response.json(); } catch { return null; } if (!isRecord(payload)) return null; const models = Array.isArray(payload.models) ? payload.models : []; for (const entry of models) { if (!isRecord(entry)) continue; const record = entry as FireworksControlPlaneModel; if (toBoolean(record.supportsServerless) !== true) continue; if (typeof record.state === "string" && record.state !== "READY") continue; const wireName = typeof record.name === "string" ? record.name : ""; if (!wireName) continue; const publicModelId = toFireworksPublicModelId(wireName); if (!publicModelId) continue; collected.set( publicModelId, mapFireworksControlPlaneModel( record, publicModelId, options.resolveReference(publicModelId), options.baseUrl, options.resolveCost(publicModelId), ), ); } const next = typeof payload.nextPageToken === "string" ? payload.nextPageToken : ""; if (!next) break; pageToken = next; } return Array.from(collected.values()); } function createModelsDevReferenceMap( models: readonly ModelSpec[], ): Map> { const references = new Map>(); for (const model of models) { const candidate = model as ModelSpec; if (!isBareIdReferenceProvider(candidate.provider)) { continue; } const existing = references.get(candidate.id); if (!existing) { references.set(candidate.id, candidate); continue; } if ((candidate.contextWindow ?? 0) > (existing.contextWindow ?? 0)) { references.set(candidate.id, candidate); continue; } if ( candidate.contextWindow === existing.contextWindow && (candidate.maxTokens ?? 0) > (existing.maxTokens ?? 0) ) { references.set(candidate.id, candidate); } } return references; } async function loadModelsDevReferences(fetchImpl?: FetchImpl): Promise>> { try { const payload = await fetchWellKnownModels(fetchImpl); return createModelsDevReferenceMap( mapModelsDevToModels(payload as Record, MODELS_DEV_PROVIDER_DESCRIPTORS), ); } catch { return new Map>(); } } /** * Fireworks' own models.dev rows, consulted only for pricing during dynamic * discovery. A bare-id reference comes from whichever host carries the id with * the largest window, so its price is often another host's, and Fireworks-only * models have none. models.dev keys these rows by wire id * (`accounts/fireworks/models/glm-5p3`), so they are re-keyed to public ids. * Rows that publish no price are skipped so the reference price stays; a * published zero is Fireworks' price and wins. Absent from * `MODELS_DEV_PROVIDER_DESCRIPTORS`: the control plane alone decides which * models exist. */ const FIREWORKS_MODELS_DEV_DESCRIPTORS: readonly ModelsDevProviderDescriptor[] = [ openAiCompletionsDescriptor("fireworks-ai", "fireworks", "https://api.fireworks.ai/inference/v1", { // `mapModelsDevToModels` maps an absent price to zeros, so tell them apart on the raw row. filterModel: (_id, raw) => typeof raw.cost?.input === "number" || typeof raw.cost?.output === "number", }), ]; async function loadFireworksModelsDevCosts( fetchImpl?: FetchImpl, ): Promise["cost"]>> { const costs = new Map["cost"]>(); try { const payload = await fetchWellKnownModels(fetchImpl); for (const model of mapModelsDevToModels(payload as Record, FIREWORKS_MODELS_DEV_DESCRIPTORS)) { costs.set(toFireworksPublicModelId(model.id), model.cost); } } catch { // Optional enrichment: without it, discovered rows keep their reference price. } return costs; } export function fireworksModelManagerOptions( config?: FireworksModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { const apiKey = config?.apiKey; const baseUrl = config?.baseUrl ?? "https://api.fireworks.ai/inference/v1"; const bundledReferences = createReferenceResolver(() => createBundledReferenceMap<"openai-completions">("fireworks"), ); return { providerId: "fireworks", ...(apiKey && { fetchDynamicModels: async () => { // Both loaders share one in-flight models.dev request. const [modelsDevReferences, fireworksCosts] = await Promise.all([ loadModelsDevReferences<"openai-completions">(config?.fetch), loadFireworksModelsDevCosts(config?.fetch), ]); return fetchFireworksServerlessModels({ baseUrl, apiKey, resolveReference: publicModelId => modelsDevReferences.get(publicModelId) ?? bundledReferences(publicModelId), resolveCost: publicModelId => fireworksCosts.get(publicModelId), fetch: config?.fetch, }); }, }), }; } // --------------------------------------------------------------------------- // 7.6 Fire Pass (Fireworks subscription) // --------------------------------------------------------------------------- export interface FirepassModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } /** * Fire Pass is a Fireworks subscription product that exposes router models * (GLM 5.2 Fast, Kimi K3 Fast) under `accounts/fireworks/routers/`. * The dedicated `fpk_…` keys do not authorize `/v1/models`, so this manager * never performs dynamic discovery — the bundled catalog entry is canonical. * See https://docs.fireworks.ai/firepass. */ export function firepassModelManagerOptions( _config?: FirepassModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return { providerId: "firepass", }; } const CLINEPASS_MODELS_URL = `${CLINEPASS_API_BASE_URL}/ai/cline/recommended-models`; const CLINEPASS_FALLBACK_CONTEXT_WINDOW = 128_000; const CLINEPASS_FALLBACK_MAX_TOKENS = 8_192; function buildClinePassFallbackModel(id: string): ModelSpec<"openai-completions"> { return { id, name: id, api: "openai-completions", provider: "cline-pass", baseUrl: CLINEPASS_API_BASE_URL, reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: CLINEPASS_FALLBACK_CONTEXT_WINDOW, maxTokens: CLINEPASS_FALLBACK_MAX_TOKENS, compat: { supportsReasoningEffort: false }, }; } /** * Live reference catalog mirroring Cline's own client enrichment: the official * CLI fills roster metadata from OpenRouter's public `/models` catalog (their * gateway routes through it), keyed by full id and by slug. Consulted only for * ids the bundled reference has no real upstream entry for: the bundle stays * authoritative for known ids, and unknown ids ride fresh live data instead of * the conservative constants until the next regen. */ interface ClinePassLiveCatalogEntry { name?: string; contextWindow?: number; maxTokens?: number; cost?: ModelSpec<"openai-completions">["cost"]; reasoning?: boolean; input?: ("text" | "image")[]; } interface ClinePassLiveCatalog { byId: ReadonlyMap; bySlug: ReadonlyMap; } const CLINE_PASS_LIVE_CATALOG_URL = "https://openrouter.ai/api/v1/models"; /** Enrichment tier, never authoritative: any failure degrades to the bundled path. */ async function fetchClinePassLiveCatalog(fetchImpl: FetchImpl): Promise { try { const response = await withOpenAICompatibleDiscoveryTimeout(5_000, signal => fetchImpl(CLINE_PASS_LIVE_CATALOG_URL, { signal }), ); if (!response.ok) return undefined; const payload: unknown = await response.json(); if (!isRecord(payload) || !Array.isArray(payload.data)) return undefined; const byId = new Map(); const bySlug = new Map(); for (const raw of payload.data) { if (!isRecord(raw) || typeof raw.id !== "string" || !raw.id) continue; const pricing = isRecord(raw.pricing) ? raw.pricing : undefined; const topProvider = isRecord(raw.top_provider) ? raw.top_provider : undefined; const params = Array.isArray(raw.supported_parameters) ? raw.supported_parameters : undefined; const modality = String(isRecord(raw.architecture) ? (raw.architecture.modality ?? "") : ""); const contextWindow = toPositiveNumber(raw.context_length, 0); const maxTokens = toPositiveNumber(topProvider?.max_completion_tokens, 0); const entry: ClinePassLiveCatalogEntry = { ...(typeof raw.name === "string" && raw.name ? { name: raw.name.replace(/^[^:]+:\s*/, "") } : {}), ...(contextWindow > 0 ? { contextWindow } : {}), ...(maxTokens > 0 ? { maxTokens } : {}), ...(pricing ? { cost: { input: parseFloat(String(pricing.prompt ?? "0")) * 1_000_000, output: parseFloat(String(pricing.completion ?? "0")) * 1_000_000, cacheRead: parseFloat(String(pricing.input_cache_read ?? "0")) * 1_000_000, cacheWrite: parseFloat(String(pricing.input_cache_write ?? "0")) * 1_000_000, }, } : {}), ...(params ? { reasoning: params.includes("reasoning") } : {}), ...(modality ? { input: modality.includes("image") ? (["text", "image"] as const) : (["text"] as const) } : {}), }; byId.set(raw.id, entry); // Cline's `buildModelsNameMap` algorithm: roster ids are lab-less slugs // ("glm-5.2"), so the catalog is also keyed by its last id segment. const slug = raw.id.split("/").at(-1); if (slug) bySlug.set(slug, entry); } return byId.size > 0 ? { byId, bySlug } : undefined; } catch { return undefined; } } /** Zero legs mean "unpriced upstream", not "free" — pass models bill quota regardless. */ function hasClinePassBillableCost(cost: ModelSpec<"openai-completions">["cost"] | undefined): boolean { return cost !== undefined && (cost.input > 0 || cost.output > 0); } function buildCuratedClinePassModel( id: string, tier: "subscription" | "free", ): ModelSpec<"openai-completions"> | undefined { const metadata = getClinePassModelMetadata(id); if (!metadata || metadata.tier !== tier) return undefined; return { ...buildClinePassFallbackModel(id), name: metadata.name, contextWindow: metadata.contextWindow, maxTokens: metadata.maxTokens, input: metadata.input, cost: metadata.cost, reasoning: metadata.reasoning, thinking: metadata.thinking, compat: { ...(metadata.thinking === undefined ? { supportsReasoningEffort: false } : {}), ...(tier === "free" ? { wireModelIdMode: "raw" as const } : {}), }, }; } /** * Subscription models bill against plan quota, not per token — but the catalog still * carries the upstream list price so cost display reads as API-equivalent spend. Same * policy as the codex/antigravity generator fixups and the zai PAYG descriptor swap * (issue #5598: an all-$0 roster surfaces every model as "free" in the picker, which * was treated as a bug there). The price comes from the bundled upstream reference, * so it works offline and survives roster refreshes (`preferDiscoveryCost` never lets * a zero overwrite it). Ids with no upstream reference keep the honest zero until * models.dev prices them. */ function buildClinePassSubscriptionModel( id: string, references: ReadonlyMap>, liveCatalog?: ClinePassLiveCatalog, ): ModelSpec<"openai-completions"> { const curated = buildCuratedClinePassModel(id, "subscription"); if (curated) return curated; const base = references.get(id) ?? buildClinePassFallbackModel(id); const reference = resolveModelReference(id, getBundledModelReferenceIndex()); // A reference pointing back at the ClinePass bundle means the last regen // knew nothing about the id and encoded fallback constants — treat as none. const upstream = reference && reference.provider !== "cline-pass" ? reference : undefined; // Same degeneracy on the bundle side: a bundled entry whose limits are the // fallback pair was written by a regen that had no upstream data for the id. const baseLimitsAreFallback = base.contextWindow === CLINEPASS_FALLBACK_CONTEXT_WINDOW && base.maxTokens === CLINEPASS_FALLBACK_MAX_TOKENS; if (upstream) { // Known to the bundle with real limits: overlay the list price only. // Otherwise full enrichment, like the free-tier path. return references.has(id) && !baseLimitsAreFallback ? { ...base, cost: upstream.cost } : { ...base, name: references.has(id) ? base.name : upstream.name, reasoning: upstream.reasoning, input: upstream.input, cost: upstream.cost, contextWindow: upstream.contextWindow, maxTokens: upstream.maxTokens, ...(upstream.reasoning ? {} : { thinking: undefined }), }; } const live = liveCatalog?.bySlug.get(id); if (!live) return base; return { ...base, ...(live.name ? { name: live.name } : {}), ...(live.contextWindow ? { contextWindow: live.contextWindow } : {}), ...(live.maxTokens ? { maxTokens: live.maxTokens } : {}), ...(hasClinePassBillableCost(live.cost) ? { cost: live.cost } : {}), ...(live.reasoning !== undefined ? { reasoning: live.reasoning } : {}), ...(live.input ? { input: live.input } : {}), ...(live.reasoning === false ? { thinking: undefined } : {}), }; } /** * Free-tier entries arrive as full OpenRouter-style ids (`deepseek/deepseek-v4-flash`, * `poolside/laguna-s-2.1:free`) and ride usage billing at $0 outside the subscription * quota — their cost is genuinely zero, unlike the subscription roster's list-price * display. Limits and modalities resolve through the bundled upstream reference (its * candidate expansion treats provider prefixes and trailing `:free`-style markers as * identity-preserving), so known models self-enrich offline. Compat and thinking are * deliberately NOT inherited: those describe the reference's native host, while the * Cline gateway dialect is resolved per provider/family. The raw wire tag is * bucket-derived — the gateway already namespaces these ids, so the cline-pass prefix * transform must never apply to them. */ function buildClinePassFreeModel(id: string, liveCatalog?: ClinePassLiveCatalog): ModelSpec<"openai-completions"> { const curated = buildCuratedClinePassModel(id, "free"); if (curated) return curated; const reference = resolveModelReference(id, getBundledModelReferenceIndex()); const upstream = reference && reference.provider !== "cline-pass" ? reference : undefined; // Free ids are OpenRouter-native, so the live catalog matches by full id // before falling back to the slug — the official client's own lookup order. const live = liveCatalog?.byId.get(id) ?? liveCatalog?.bySlug.get(id.split("/").at(-1) ?? id); // References can carry their own free markers (e.g. OpenRouter-style // "Laguna S 2.1 (free)"); strip before applying our single tier suffix. const baseName = (upstream?.name ?? live?.name ?? id.split("/").at(-1) ?? id) .replace(/\s*\(free\)\s*$/i, "") .replace(/:free$/i, "") .trim(); const fallback = buildClinePassFallbackModel(id); const reasoning = upstream?.reasoning ?? live?.reasoning ?? fallback.reasoning; return { ...fallback, name: `${baseName || id} (free)`, reasoning, input: upstream?.input ?? live?.input ?? fallback.input, contextWindow: upstream?.contextWindow ?? live?.contextWindow ?? fallback.contextWindow, maxTokens: upstream?.maxTokens ?? live?.maxTokens ?? fallback.maxTokens, // No reasoning-effort control means no effort ladder: the cline-pass // rules trust only explicit thinking, so a copied ladder would surface. thinking: undefined, compat: { supportsReasoningEffort: false, wireModelIdMode: "raw" }, }; } async function fetchClinePassModels( fetchImpl: FetchImpl, references: ReadonlyMap>, ): Promise[]> { // The roster is authoritative for ids; the live reference catalog is a pure // enrichment tier fetched alongside and tolerated away (offline → bundle). const [response, liveCatalog] = await Promise.all([ withOpenAICompatibleDiscoveryTimeout(5_000, signal => fetchImpl(CLINEPASS_MODELS_URL, { signal, headers: clinePassClientHeaders() }), ), fetchClinePassLiveCatalog(fetchImpl), ]); if (!response.ok) { throw new Error(`Failed to fetch ClinePass models: HTTP ${response.status}`); } const payload: unknown = await response.json(); if (!isRecord(payload) || !Array.isArray(payload.clinePass)) { throw new Error("ClinePass model catalog response is missing clinePass"); } const models = new Map>(); for (const entry of payload.clinePass) { if (!isRecord(entry) || typeof entry.id !== "string") { continue; } const wireId = entry.id.trim(); if (!wireId.startsWith("cline-pass/")) { continue; } const id = toClinePassPublicModelId(wireId).trim(); if (!id) { continue; } models.set(id, buildClinePassSubscriptionModel(id, references, liveCatalog)); } if (models.size === 0) { throw new Error("ClinePass model catalog contains no valid model IDs"); } // Free tier rides the same key and gateway at $0 outside the subscription // quota. Optional overlay: entries validate individually, a cline-pass/-shaped // entry belongs to the pass bucket, pass ids win any collision, and pass-first // insertion order keeps the registry's first-available fallback on a // subscription model. const freeEntries = Array.isArray(payload.free) ? payload.free : []; for (const entry of freeEntries) { if (!isRecord(entry) || typeof entry.id !== "string") { continue; } const id = entry.id.trim(); if (!id || id.startsWith("cline-pass/") || models.has(id)) { continue; } models.set(id, buildClinePassFreeModel(id, liveCatalog)); } return [...models.values()]; } /** * Cline's OpenAI-compatible `/models` route is absent, but its public * recommended-models endpoint is the authoritative ClinePass roster: the * `clinePass` bucket lists subscription models and the optional `free` bucket * adds $0 usage-billing models selectable with the same key. Metadata resolves * in three tiers: the current Cline-published metadata snapshot first, bundled * upstream references and OpenRouter enrichment for future ids second, then * conservative constants. Newly added ids remain usable before the next regen; * known ids retain exact Cline limits, plan pricing, and reasoning controls. */ export function clinePassModelManagerOptions(config?: ModelManagerConfig): ModelManagerOptions<"openai-completions"> { const references = new Map( (getBundledModels("cline-pass") as Model<"openai-completions">[]).map(model => [model.id, toModelSpec(model)]), ); const fetchImpl = discoveryFetch(config?.fetch); return { providerId: "cline-pass", dynamicModelsAuthoritative: true, fetchDynamicModels: () => fetchClinePassModels(fetchImpl, references), }; } // --------------------------------------------------------------------------- // 7.7 Wafer Serverless // --------------------------------------------------------------------------- export interface WaferModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } const WAFER_DEFAULT_BASE_URL = "https://pass.wafer.ai/v1"; const WAFER_MAX_TOKENS_CAP = 65536; /** * Mapper for Wafer Serverless `/v1/models` records. * * Wafer wraps each entry with a `wafer` envelope describing capabilities and * pricing. The mapper folds that metadata into the canonical * `ModelSpec<"openai-completions">` shape and applies upstream-specific thinking * compat when the entry advertises reasoning support. Wafer pricing is exposed * through internal wholesale units; the public Serverless rate equals * `cents × 125 / 10000`. */ interface WaferRecord { context_length?: unknown; tier?: unknown; provider?: unknown; capabilities?: { vision?: unknown; reasoning?: unknown; tools?: unknown }; pricing?: { input_cents_per_million?: unknown; output_cents_per_million?: unknown; cache_read_cents_per_million?: unknown; }; display_name?: unknown; } function readWaferRecord(entry: OpenAICompatibleModelRecord): WaferRecord | undefined { const raw = (entry as { wafer?: unknown }).wafer; return raw && typeof raw === "object" ? (raw as WaferRecord) : undefined; } type WaferThinkingFormat = "zai" | "qwen"; export function resolveWaferServerlessThinkingFormat( modelId: string, upstreamProvider: unknown, ): WaferThinkingFormat | undefined { // residue: parses Wafer's upstream-provider metadata FIELD (vendor // spellings), not a model id; the id fallback below is classify-driven. const upstream = typeof upstreamProvider === "string" ? upstreamProvider.trim().toLowerCase() : ""; if (upstream) { if ( upstream === "zai" || upstream === "z.ai" || upstream === "z-ai" || upstream.includes("zhipu") || upstream.includes("moonshot") || upstream.includes("kimi") ) { return "zai"; } if (upstream.includes("qwen") || upstream.includes("alibaba") || upstream.includes("dashscope")) { return "qwen"; } return undefined; } // Older Wafer snapshots (and some endpoint responses) do not carry the // upstream-provider hint. Only GLM/Kimi need a sparse override: qwen and // deepseek IDs are resolved safely by `buildOpenAICompat` from the model id. const identity = classifyModel("wafer-serverless", modelId, { lenient: true }); return isGlmReasoningIdentity("wafer-serverless", modelId, "4.5") || identity.class === "kimi" ? "zai" : undefined; } function mapWaferModel( providerId: "wafer-serverless", baseUrl: string, entry: OpenAICompatibleModelRecord, defaults: ModelSpec<"openai-completions">, ): ModelSpec<"openai-completions"> { const wafer = readWaferRecord(entry); const capabilities = wafer?.capabilities ?? {}; const reasoning = capabilities.reasoning === true; const vision = capabilities.vision === true; const supportsTools = toBoolean(capabilities.tools) === false ? false : undefined; const contextWindow = toPositiveNumber( wafer?.context_length, toPositiveNumber((entry as { max_model_len?: unknown }).max_model_len, defaults.contextWindow), ); const maxTokens = contextWindow !== null ? Math.min(contextWindow, WAFER_MAX_TOKENS_CAP) : null; const pricing = wafer?.pricing ?? {}; const cost = { input: (toPositiveNumber(pricing.input_cents_per_million, 0) * 125) / 10000, output: (toPositiveNumber(pricing.output_cents_per_million, 0) * 125) / 10000, cacheRead: (toPositiveNumber(pricing.cache_read_cents_per_million, 0) * 125) / 10000, cacheWrite: 0, }; const name = toModelName(wafer?.display_name, defaults.name); const base: ModelSpec<"openai-completions"> = { ...defaults, id: defaults.id, name, api: "openai-completions", provider: providerId, baseUrl, reasoning, input: vision ? (["text", "image"] as const) : ["text"], cost, contextWindow, maxTokens, ...(supportsTools === false ? { supportsTools } : {}), }; if (reasoning) { // Wafer's `wafer.provider` envelope tells us which upstream backend serves // the model. Each upstream accepts a different thinking-control parameter // on the wire — Wafer passes the body through, so we must mirror the // upstream's native shape: // - zai (GLM) and moonshotai (Kimi) → `thinking: { type: "enabled" | "disabled" }` // - qwen (Alibaba) → top-level `enable_thinking: boolean` // - deepseek → `reasoning_effort` (DeepSeek effort map; the model always // reasons when invoked, replay of `reasoning_content` is required on // tool-call turns — both handled by `detectOpenAICompat` from the id). // Unknown upstreams stay unset; missing upstreams fall back only for // model families that cannot be inferred safely from Wafer's host. const thinkingFormat = resolveWaferServerlessThinkingFormat(defaults.id, wafer?.provider); return { ...base, compat: { ...(thinkingFormat ? { thinkingFormat } : {}), reasoningContentField: "reasoning_content", supportsDeveloperRole: false, }, }; } return { ...base, compat: { supportsDeveloperRole: false }, }; } export function waferServerlessModelManagerOptions( config?: WaferModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { const apiKey = config?.apiKey; const baseUrl = config?.baseUrl ?? WAFER_DEFAULT_BASE_URL; const providerId = "wafer-serverless" as const; return { providerId, ...(apiKey && { fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-completions", provider: providerId, baseUrl, apiKey, mapModel: (entry, defaults) => mapWaferModel(providerId, baseUrl, entry, defaults), fetch: config?.fetch, }), }), }; } // --------------------------------------------------------------------------- // 7. Mistral // --------------------------------------------------------------------------- export interface MistralModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } /** * Mistral's `/v1/models` lists each alias as its own row, with the canonical * id in `name` and the other ids in `aliases`; `mistral-vibe-cli-latest`, the * Vibe CLI's default, is a row named `mistral-medium-latest`. An id with no * bundled row of its own reuses the bundled row of the model it aliases, so it * keeps that model's reasoning, image input, limits, and prices. Such an id is * listed only for keys whose `/v1/models` returns it. */ function mistralAliasReference( entry: OpenAICompatibleModelRecord, references: Map>, ): ModelSpec<"openai-completions"> | undefined { const aliases = Array.isArray(entry.aliases) ? entry.aliases : []; for (const candidate of [entry.name, ...aliases]) { if (typeof candidate !== "string") continue; const reference = references.get(candidate); if (reference) return reference; } return undefined; } /** * Mistral's `/v1/models` lists every SKU the key can call, including * embedding, OCR, moderation, transcription, and speech models, and flags chat * support per row in `capabilities.completion_chat`. Rows without it reject * `/chat/completions` with `invalid_model`, so they stay out of the chat * roster. A row with no `capabilities` object (a compatible gateway on a * custom `baseUrl`) is kept. * * The context window comes from `max_context_length`. `capabilities.vision` * and `capabilities.reasoning` can add image input or reasoning to a bundled * reference, never remove them. */ function mapMistralModel( entry: OpenAICompatibleModelRecord, defaults: ModelSpec<"openai-completions">, reference: ModelSpec<"openai-completions"> | undefined, references: Map>, ): ModelSpec<"openai-completions"> | null { const capabilities = isRecord(entry.capabilities) ? entry.capabilities : undefined; if (capabilities && capabilities.completion_chat !== true) return null; const model = mapWithBundledReference(entry, defaults, reference ?? mistralAliasReference(entry, references)); const input: ModelSpec<"openai-completions">["input"] = capabilities?.vision === true && !model.input.includes("image") ? [...model.input, "image"] : model.input; return { ...model, reasoning: model.reasoning || capabilities?.reasoning === true, input, contextWindow: toPositiveNumber(entry.max_context_length, model.contextWindow), }; } export function mistralModelManagerOptions( config?: MistralModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createOpenAICompatibleModelManagerOptions({ api: "openai-completions", providerId: "mistral", defaultBaseUrl: "https://api.mistral.ai/v1", config, requireApiKey: true, mapModel: mapMistralModel, }); } // --------------------------------------------------------------------------- // 8. OpenCode // --------------------------------------------------------------------------- export interface OpenCodeModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } const OPENCODE_ZEN_BASE_PATH = "https://opencode.ai/zen"; const OPENCODE_ZEN_GOOGLE_BASE_URL = "https://opencode.ai/inference/google/v1beta"; function normalizeOpenCodeBasePath(baseUrl: string | undefined, fallbackBasePath: string): string { const value = normalizeAnthropicBaseUrl(baseUrl, fallbackBasePath); return value.endsWith("/v1") ? value.slice(0, -3) : value; } function openCodeBaseUrlForApi(api: Api, basePath: string): string { if (api === "google-generative-ai" && basePath === OPENCODE_ZEN_BASE_PATH) { return OPENCODE_ZEN_GOOGLE_BASE_URL; } return api === "anthropic-messages" ? basePath : `${basePath}/v1`; } // Per-id API pins correcting upstream metadata mismatches on the OpenCode // gateways. Applied in two places: the models.dev resolver rules // (OPENCODE_ZEN_API_RESOLUTION / OPENCODE_GO_API_RESOLUTION) and the live // /v1/models discovery mapper in openCodeModelManagerOptions — the gateways // list ids that models.dev omits entirely (muse-spark-1.2[-contributor] on // opencode-go, #8957), so discovery cannot rely on bundled references alone. // The per-id gateway-verified route pins live in the `api-routes` behavior // rules (`runtime/behavior.kdl`; #887, #1617, #8957). // Runtime-discovered rows cached before model-identity corrections retain // stale capability metadata until the authoritative catalog TTL expires. const OPENCODE_CACHE_MIGRATION_MODEL_IDS = [ "deepseek-flash", "glm-5.3-flash", "longcat-2.5-preview-free", "space-bunny-free", ] as const; const OPENCODE_ZEN_CACHE_MIGRATION_MODEL_IDS = ["gemini-3.7-flash", "gemini-3.8-flash"] as const; // Billing-variant suffixes the OpenCode gateways append to a base model id // without changing its transport (`deepseek-v4-flash-free`, // `muse-spark-1.2-contributor`). const OPENCODE_VARIANT_SUFFIXES = ["-contributor", "-free"] as const; /** Strips a billing-variant suffix; null when `id` is not a variant. */ function openCodeBaseModelId(id: string): string | null { for (const suffix of OPENCODE_VARIANT_SUFFIXES) { if (id.endsWith(suffix) && id.length > suffix.length) return id.slice(0, -suffix.length); } return null; } function openCodeModelManagerOptions( providerId: "opencode-go" | "opencode-zen", config?: OpenCodeModelManagerConfig, ): ModelManagerOptions { const apiKey = config?.apiKey; const defaultBaseUrl = getDefaultModelDiscoveryBaseUrl(providerId)!; const defaultBasePath = defaultBaseUrl.endsWith("/v1") ? defaultBaseUrl.slice(0, -3) : defaultBaseUrl; const basePath = normalizeOpenCodeBasePath(config?.baseUrl, defaultBasePath); const discoveryBaseUrl = openCodeBaseUrlForApi("openai-completions", basePath); const references = createBundledReferenceMap(providerId); // Both gateways share one operator with identical endpoint semantics, so // the sibling's bundled catalog is a routing hint for ids models.dev has // not picked up under this gateway yet. const siblingReferences = createBundledReferenceMap( providerId === "opencode-go" ? "opencode-zen" : "opencode-go", ); const apiOverrides = (id: string): Api | undefined => { // Route pins live in the `api-routes` behavior rules. const route = apiRouteFor(providerId, id)?.api; return route === "openai-completions" || route === "openai-responses" || route === "anthropic-messages" || route === "google-generative-ai" ? route : undefined; }; // Routes a discovered id with no same-provider metadata. models.dev lags // the gateway (muse-spark-1.2[-contributor] shipped gateway-first, #8957), // so borrow the openai-responses route from the sibling gateway or the // billing-variant base id. Responses ONLY: openai-completions is already // the default, and anthropic-messages transports genuinely diverge across // gateways (e.g. minimax-m2.5), so borrowing them would import upstream // metadata noise as hard routing errors. const fallbackApi = (id: string, base: string | null): Api | undefined => { const hints = [ siblingReferences.get(id)?.api, base ? references.get(base)?.api : undefined, base ? siblingReferences.get(base)?.api : undefined, ]; return hints.includes("openai-responses") ? "openai-responses" : undefined; }; const resolveApi = (id: string, defaultApi: Api): Api => { const base = openCodeBaseModelId(id); return ( apiOverrides(id) ?? (base ? apiOverrides(base) : undefined) ?? references.get(id)?.api ?? fallbackApi(id, base) ?? defaultApi ); }; return { providerId, cacheProviderId: resolveModelCacheProviderId(providerId, { apiKey, baseUrl: discoveryBaseUrl }), dynamicModelsAuthoritative: true, // Per-id route pins and capability migrations are cache identity: // without this, rows cached before a correction keep the stale route or // thinking surface until TTL expiry (#8957, #9960, #10543). dropCachedModelIdsOnStaticMismatch: [ ...apiRouteExactModelIds(providerId), ...OPENCODE_CACHE_MIGRATION_MODEL_IDS, ...(providerId === "opencode-zen" ? OPENCODE_ZEN_CACHE_MIGRATION_MODEL_IDS : []), ], modelsDev: { fetch: () => fetchRevalidatedWellKnownModelsWithTimeout(config?.fetch), map: payload => { if (!isRecord(payload)) return []; return mapModelsDevToModels(payload, OPENCODE_MODELS_DEV_DESCRIPTORS) .filter(model => model.provider === providerId) .map(model => { const api = resolveApi(model.id, "openai-completions"); return { ...model, api, baseUrl: openCodeBaseUrlForApi(api, basePath) }; }); }, }, ...(apiKey && { fetchDynamicModels: async () => { const discovered = await fetchOpenAICompatibleModels({ api: "openai-completions", provider: providerId, baseUrl: discoveryBaseUrl, apiKey, // Live discovery hits the OpenCode gateway outside any // conversation: attribute with the stable install id // (x-opencode-session required from 09/06) and omp's UA // instead of Bun's default. headers: { "User-Agent": USER_AGENT, "x-opencode-session": getInstallId() }, mapModel: (entry, defaults) => { const reference = references.get(defaults.id); const name = toModelName(entry.name, reference?.name ?? defaults.name); // Pins and bundled routing hints win over the metadata-only // stencil fallback; the fallback never selects a transport. const api = resolveApi(defaults.id, defaults.api); const baseUrl = openCodeBaseUrlForApi(api, basePath); const lineage = museSparkLineageSpec(defaults.id); if (lineage) { // Gateway lists Muse Spark as bare ids with no capability // metadata and often no local bundled row, so the generic // defaults would hide the effort dial (`reasoning: false`). // Keep the pinned/fallback route and restore the lineage's // thinking surface. return { ...(reference ?? defaults), id: defaults.id, name, api, provider: providerId, baseUrl, reasoning: true, input: reference?.input ?? lineage.input, thinking: reference?.thinking ?? lineage.thinking, contextWindow: toPositiveNumber( entry.context_length, reference?.contextWindow ?? lineage.contextWindow, ), maxTokens: toPositiveNumber( entry.max_completion_tokens, reference?.maxTokens ?? lineage.maxTokens, ), }; } if (!reference) { return { ...defaults, name, api, baseUrl }; } return { ...reference, id: defaults.id, name, api, baseUrl, contextWindow: toPositiveNumber(entry.context_length, reference.contextWindow), maxTokens: toPositiveNumber(entry.max_completion_tokens, reference.maxTokens), }; }, fetch: config?.fetch, }); if (!discovered) return null; const seeds = seedModels(providerId).map(seed => ({ ...seed, baseUrl: basePath })); const seedIds = new Set(seeds.map(seed => seed.id)); return [...discovered.filter(model => !seedIds.has(model.id)), ...seeds]; }, }), }; } export function opencodeZenModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions { return openCodeModelManagerOptions("opencode-zen", config); } export function opencodeGoModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions { return openCodeModelManagerOptions("opencode-go", config); } // --------------------------------------------------------------------------- // 9. Ollama // --------------------------------------------------------------------------- export interface OllamaModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function ollamaModelManagerOptions(config?: OllamaModelManagerConfig): ModelManagerOptions<"openai-responses"> { const apiKey = config?.apiKey; const baseUrl = normalizeOllamaBaseUrl(config?.baseUrl); const nativeBaseUrl = toOllamaNativeBaseUrl(baseUrl); const references = createBundledReferenceMap<"openai-responses">("ollama" as Parameters[0]); const resolveMetadata = createOllamaMetadataResolver(nativeBaseUrl, config?.fetch); return { providerId: "ollama", cacheProviderId: resolveModelCacheProviderId("ollama", { baseUrl }), fetchDynamicModels: async () => { const openAiCompatible = await fetchOpenAICompatibleModels({ api: "openai-responses", provider: "ollama", baseUrl, apiKey, mapModel: (entry, defaults) => { const reference = references.get(defaults.id); if (!reference) { return { ...defaults, name: toModelName(entry.name, defaults.name), contextWindow: OLLAMA_FALLBACK_CONTEXT_WINDOW, maxTokens: OLLAMA_DEFAULT_MAX_TOKENS, }; } return mapWithBundledReference(entry, defaults, reference); }, fetch: config?.fetch, }); if (openAiCompatible && openAiCompatible.length > 0) { await Promise.all( openAiCompatible.map(async model => { const metadata = await resolveMetadata(model.id); model.contextWindow = metadata.contextWindow; if (metadata.reasoning !== undefined) { model.reasoning = metadata.reasoning; // The effort ladder is rule-owned (`providers/ollama.kdl`); // derived at build time from the reasoning flag. model.thinking = undefined; } if (metadata.input) { model.input = metadata.input; } }), ); return openAiCompatible; } const nativeFallback = await fetchOllamaNativeModels(baseUrl, resolveMetadata, config?.fetch); if (nativeFallback && nativeFallback.length > 0) { return nativeFallback; } return openAiCompatible; }, }; } // --------------------------------------------------------------------------- // 10. OpenRouter // --------------------------------------------------------------------------- export interface OpenRouterModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } /** * OpenRouter's Decisions API lives at `/api/alpha`, a sibling of the `/api/v1` * chat root; derive it so a custom gateway base URL keeps both aligned. */ export function openrouterDecisionsBaseUrl(chatBaseUrl: string): string { const base = chatBaseUrl.replace(/\/+$/g, ""); return base.endsWith("/v1") ? `${base.slice(0, -"/v1".length)}/alpha` : `${base}/alpha`; } function mapOpenRouterThinking(entry: OpenAICompatibleModelRecord): ThinkingConfig | undefined { const reasoning = entry.reasoning; if (!isRecord(reasoning)) return undefined; const supportedEfforts = reasoning.supported_efforts; if (!Array.isArray(supportedEfforts)) return undefined; const efforts = THINKING_EFFORTS.filter(effort => supportedEfforts.includes(effort)); if (efforts.length === 0) return undefined; const defaultLevel = typeof reasoning.default_effort === "string" ? THINKING_EFFORTS.find(effort => effort === reasoning.default_effort) : undefined; return { mode: "effort", efforts, ...(defaultLevel !== undefined && efforts.includes(defaultLevel) ? { defaultLevel } : {}), ...(reasoning.mandatory === true ? { requiresEffort: true } : {}), }; } export function openrouterModelManagerOptions(config?: OpenRouterModelManagerConfig): ModelManagerOptions { const apiKey = config?.apiKey; const baseUrl = (config?.baseUrl ?? "https://openrouter.ai/api/v1").replace(/\/+$/g, ""); const decisionsBaseUrl = openrouterDecisionsBaseUrl(baseUrl); const references = createBundledReferenceMap<"openrouter">("openrouter"); return { providerId: "openrouter", // Older builds cached OpenRouter discovery rows as `api: "openai-completions"`. // Namespace the refreshed pseudo-API cache separately so those rows cannot // override bundled `api: "openrouter"` models during online-if-uncached startup. cacheProviderId: resolveModelCacheProviderId("openrouter"), fetchDynamicModels: async () => { const [chatModels, imageModels, decisionModels, rerankModels, videoModels, embeddingModels] = await Promise.all([ fetchOpenAICompatibleModels({ api: "openrouter", provider: "openrouter", baseUrl, apiKey, filterModel: (entry: OpenAICompatibleModelRecord) => { const params = entry.supported_parameters; return Array.isArray(params) && params.includes("tools"); }, mapModel: ( entry: OpenAICompatibleModelRecord, defaults: ModelSpec<"openrouter">, _context: OpenAICompatibleModelMapperContext<"openrouter">, ): ModelSpec<"openrouter"> => { const reference = references.get(defaults.id); const baseModel = mapWithBundledReference(entry, defaults, reference); const pricing = isRecord(entry.pricing) ? entry.pricing : undefined; const params = Array.isArray(entry.supported_parameters) ? entry.supported_parameters.filter((value): value is string => typeof value === "string") : []; const thinking = mapOpenRouterThinking(entry); const architecture = isRecord(entry.architecture) ? entry.architecture : undefined; const input: ("text" | "image")[] = Array.isArray(architecture?.input_modalities) ? toInputCapabilities(architecture.input_modalities) : String(architecture?.modality ?? "").includes("image") ? ["text", "image"] : ["text"]; const topProvider = isRecord(entry.top_provider) ? entry.top_provider : undefined; const supportsToolChoice = params.includes("tool_choice"); return { ...baseModel, reasoning: params.includes("reasoning"), ...(thinking !== undefined ? { thinking } : {}), input, cost: { input: parseFloat(String(pricing?.prompt ?? "0")) * 1_000_000, output: parseFloat(String(pricing?.completion ?? "0")) * 1_000_000, cacheRead: parseFloat(String(pricing?.input_cache_read ?? "0")) * 1_000_000, cacheWrite: parseFloat(String(pricing?.input_cache_write ?? "0")) * 1_000_000, }, contextWindow: toPositiveNumber(entry.context_length, baseModel.contextWindow), maxTokens: toPositiveNumber(topProvider?.max_completion_tokens, baseModel.maxTokens), ...(!supportsToolChoice && { compat: { ...baseModel.compat, supportsToolChoice: false }, }), }; }, fetch: config?.fetch, }), fetchOpenAICompatibleModels({ api: "openrouter-images", provider: "openrouter", baseUrl: `${baseUrl}/images`, apiKey, filterModel: entry => { const architecture = isRecord(entry.architecture) ? entry.architecture : undefined; return ( Array.isArray(architecture?.output_modalities) && architecture.output_modalities.includes("image") ); }, mapModel: (_entry, defaults): ModelSpec<"openrouter-images"> => ({ ...defaults, baseUrl, kind: "image", reasoning: false, input: ["text", "image"], supportsTools: false, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: null, maxTokens: null, }), fetch: config?.fetch, }), // Decision models (`text->decisions`) are absent from the default roster // and answer only through the Decisions API, outside the `/v1` prefix. fetchOpenAICompatibleModels({ api: "openrouter-decisions", provider: "openrouter", baseUrl, apiKey, query: { output_modalities: "decisions" }, filterModel: entry => { const architecture = isRecord(entry.architecture) ? entry.architecture : undefined; return ( Array.isArray(architecture?.output_modalities) && architecture.output_modalities.includes("decisions") ); }, mapModel: (entry, defaults): ModelSpec<"openrouter-decisions"> => { const pricing = isRecord(entry.pricing) ? entry.pricing : undefined; const topProvider = isRecord(entry.top_provider) ? entry.top_provider : undefined; return { ...defaults, baseUrl: decisionsBaseUrl, kind: "judge", reasoning: false, input: ["text"], supportsTools: false, cost: { input: parseFloat(String(pricing?.prompt ?? "0")) * 1_000_000, output: parseFloat(String(pricing?.completion ?? "0")) * 1_000_000, cacheRead: 0, cacheWrite: 0, }, // Some rows (e.g. respan/span-01) advertise `0` for unknown limits. contextWindow: toPositiveNumber(entry.context_length, null), maxTokens: toPositiveNumber(topProvider?.max_completion_tokens, null), }; }, fetch: config?.fetch, }), fetchOpenAICompatibleModels({ api: "openrouter-rerank", provider: "openrouter", baseUrl, apiKey, query: { output_modalities: "rerank" }, filterModel: entry => { const architecture = isRecord(entry.architecture) ? entry.architecture : undefined; return ( Array.isArray(architecture?.output_modalities) && architecture.output_modalities.includes("rerank") ); }, mapModel: (entry, defaults): ModelSpec<"openrouter-rerank"> => { const architecture = isRecord(entry.architecture) ? entry.architecture : undefined; const topProvider = isRecord(entry.top_provider) ? entry.top_provider : undefined; return { ...defaults, baseUrl, kind: "rerank", reasoning: false, input: Array.isArray(architecture?.input_modalities) ? toInputCapabilities(architecture.input_modalities) : ["text"], supportsTools: false, // OpenRouter bills reranking per search; ModelCost has no search-unit axis. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: toPositiveNumber(entry.context_length, null), maxTokens: toPositiveNumber(topProvider?.max_completion_tokens, null), }; }, fetch: config?.fetch, }), fetchOpenAICompatibleModels({ api: "openrouter-video", provider: "openrouter", baseUrl: `${baseUrl}/videos`, apiKey, mapModel: (_entry, defaults): ModelSpec<"openrouter-video"> => ({ ...defaults, baseUrl, kind: "video", reasoning: false, input: ["text", "image"], supportsTools: false, // OpenRouter bills video by output second/SKU; ModelCost has no duration axis. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: null, maxTokens: null, }), fetch: config?.fetch, }), fetchOpenAICompatibleModels({ api: "openai-embeddings", provider: "openrouter", baseUrl: `${baseUrl}/embeddings`, apiKey, mapModel: (entry, defaults): ModelSpec<"openai-embeddings"> => { const pricing = isRecord(entry.pricing) ? entry.pricing : undefined; return { ...defaults, baseUrl, kind: "embedding", reasoning: false, input: ["text"], supportsTools: false, cost: { input: parseFloat(String(pricing?.prompt ?? "0")) * 1_000_000, output: 0, cacheRead: 0, cacheWrite: 0, }, contextWindow: toPositiveNumber(entry.context_length, null), maxTokens: null, }; }, fetch: config?.fetch, }), ]); if (imageModels === null) { logger.warn("OpenRouter image model discovery unavailable; preserving chat model discovery", { endpoint: `${baseUrl}/images/models`, }); } if (decisionModels === null) { logger.warn("OpenRouter decision model discovery unavailable; preserving chat model discovery", { endpoint: `${baseUrl}/models?output_modalities=decisions`, }); } if (rerankModels === null) { logger.warn("OpenRouter rerank model discovery unavailable; preserving other model discovery", { endpoint: `${baseUrl}/models?output_modalities=rerank`, }); } if (videoModels === null) { logger.warn("OpenRouter video model discovery unavailable; preserving other model discovery", { endpoint: `${baseUrl}/videos/models`, }); } if (embeddingModels === null) { logger.warn("OpenRouter embedding model discovery unavailable; preserving other model discovery", { endpoint: `${baseUrl}/embeddings/models`, }); } if ( chatModels === null && imageModels === null && decisionModels === null && rerankModels === null && videoModels === null && embeddingModels === null ) { return null; } const models = new Map>(); for (const model of chatModels ?? []) models.set(model.id, model); for (const model of imageModels ?? []) models.set(model.id, model); for (const model of decisionModels ?? []) models.set(model.id, model); for (const model of rerankModels ?? []) models.set(model.id, model); for (const model of videoModels ?? []) models.set(model.id, model); for (const model of embeddingModels ?? []) models.set(model.id, model); return Array.from(models.values()).sort((left, right) => left.id.localeCompare(right.id)); }, }; } const ZENMUX_OPENAI_BASE_URL = "https://zenmux.ai/api/v1"; const ZENMUX_ANTHROPIC_BASE_URL = "https://zenmux.ai/api/anthropic"; function normalizeZenMuxOpenAiBaseUrl(baseUrl?: string): string { const value = baseUrl?.trim(); if (!value) { return ZENMUX_OPENAI_BASE_URL; } const normalized = value.endsWith("/") ? value.slice(0, -1) : value; if (normalized.endsWith("/api/anthropic")) { return normalized.replace("/api/anthropic", "/api/v1"); } return normalized; } function toZenMuxAnthropicBaseUrl(openAiBaseUrl: string): string { try { const parsed = new URL(openAiBaseUrl); const trimmedPath = parsed.pathname.replace(/\/+$/g, ""); parsed.pathname = trimmedPath.endsWith("/api/v1") ? `${trimmedPath.slice(0, -"/api/v1".length)}/api/anthropic` : "/api/anthropic"; return `${parsed.protocol}//${parsed.host}${parsed.pathname}`; } catch { return ZENMUX_ANTHROPIC_BASE_URL; } } function isZenMuxAnthropicModel(entry: OpenAICompatibleModelRecord, modelId: string): boolean { if (typeof entry.owned_by === "string" && entry.owned_by.toLowerCase() === "anthropic") { return true; } return classifyModel("zenmux", modelId, { lenient: true }).class === "anthropic"; } function getZenMuxPricingValue(pricings: Record | undefined, key: string): number { const bucket = pricings?.[key]; if (!Array.isArray(bucket)) { return 0; } for (const item of bucket) { if (!isRecord(item)) { continue; } const value = toNumber(item.value); if (value !== undefined) { return value; } } return 0; } function getZenMuxCacheWritePrice(pricings: Record | undefined): number { const oneHour = getZenMuxPricingValue(pricings, "input_cache_write_1_h"); if (oneHour > 0) { return oneHour; } const fiveMinute = getZenMuxPricingValue(pricings, "input_cache_write_5_min"); if (fiveMinute > 0) { return fiveMinute; } return getZenMuxPricingValue(pricings, "input_cache_write"); } // --------------------------------------------------------------------------- // 10.5 ZenMux // --------------------------------------------------------------------------- export interface ZenMuxModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function zenmuxModelManagerOptions(config?: ZenMuxModelManagerConfig): ModelManagerOptions { const apiKey = config?.apiKey; const openAiBaseUrl = normalizeZenMuxOpenAiBaseUrl(config?.baseUrl); const anthropicBaseUrl = toZenMuxAnthropicBaseUrl(openAiBaseUrl); return { providerId: "zenmux", fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-completions", provider: "zenmux", baseUrl: openAiBaseUrl, apiKey, mapModel: (entry, defaults) => { const pricings = isRecord(entry.pricings) ? entry.pricings : undefined; const capabilities = isRecord(entry.capabilities) ? entry.capabilities : undefined; const isAnthropicModel = isZenMuxAnthropicModel(entry, defaults.id); return { ...defaults, name: toModelName(entry.display_name, defaults.name), api: isAnthropicModel ? "anthropic-messages" : "openai-completions", baseUrl: isAnthropicModel ? anthropicBaseUrl : openAiBaseUrl, reasoning: capabilities?.reasoning === true || defaults.reasoning, input: toInputCapabilities(entry.input_modalities), cost: { input: getZenMuxPricingValue(pricings, "prompt"), output: getZenMuxPricingValue(pricings, "completion"), cacheRead: getZenMuxPricingValue(pricings, "input_cache_read"), cacheWrite: getZenMuxCacheWritePrice(pricings), }, contextWindow: toPositiveNumber(entry.context_length, defaults.contextWindow), maxTokens: toPositiveNumber(entry.max_completion_tokens, defaults.maxTokens), }; }, fetch: config?.fetch, }), }; } // --------------------------------------------------------------------------- // 10.6 Kilo Gateway // --------------------------------------------------------------------------- export interface KiloModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function kiloModelManagerOptions(config?: KiloModelManagerConfig): ModelManagerOptions<"openai-completions"> { const apiKey = config?.apiKey; const baseUrl = config?.baseUrl ?? "https://api.kilo.ai/api/gateway"; return { providerId: "kilo", fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-completions", provider: "kilo", baseUrl, apiKey, mapModel: (entry, defaults) => ({ ...defaults, cost: perTokenPricingCost(entry.pricing) }), fetch: config?.fetch, }), }; } // --------------------------------------------------------------------------- // Alibaba Coding Plan // --------------------------------------------------------------------------- export interface AlibabaCodingPlanModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function alibabaCodingPlanModelManagerOptions( config?: AlibabaCodingPlanModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createOpenAICompatibleModelManagerOptions({ api: "openai-completions", providerId: "alibaba-coding-plan", defaultBaseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", config, mapModel: mapWithBundledReference, }); } // --------------------------------------------------------------------------- // Alibaba Token Plan // --------------------------------------------------------------------------- export { ALIBABA_TOKEN_PLAN_BASE_URL }; function isAlibabaTokenPlanChatModelId(id: string): boolean { const normalized = id.trim().toLowerCase(); return normalized.length > 0 && !isExcludedModel("alibaba-token-plan", normalized); } export interface AlibabaTokenPlanModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function alibabaTokenPlanModelManagerOptions( config?: AlibabaTokenPlanModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { const credential = config?.apiKey ? parseAlibabaTokenPlanCredential(config.apiKey) : undefined; const apiKey = credential?.token; // A region-locked credential (China/custom) dictates the discovery endpoint: // its key only authenticates against its own region, so fetching /models from // any other base URL would 401 (#6682). const baseUrl = credential?.baseUrl ?? config?.baseUrl ?? ALIBABA_TOKEN_PLAN_BASE_URL; const staticModels = seedModels<"openai-completions">("alibaba-token-plan"); return { providerId: "alibaba-token-plan", dynamicModelsAuthoritative: true, staticModels, ...(apiKey && { fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-completions", provider: "alibaba-token-plan", baseUrl, apiKey, filterModel: (_entry, model) => isAlibabaTokenPlanChatModelId(model.id), mapModel: (_entry, defaults) => { const reference = staticModels.find(model => model.id === defaults.id); if (reference) { return { ...reference, id: defaults.id, api: defaults.api, provider: defaults.provider, baseUrl: defaults.baseUrl, }; } const normalizedId = defaults.id.trim().toLowerCase(); // Discovery carries no limit fields; the `model-limits` behavior // rules pin values from official model documentation. const limits = modelLimitsFor("alibaba-token-plan", normalizedId); const enriched = limits ? { ...defaults, contextWindow: limits.context ?? defaults.contextWindow, maxTokens: limits.maxTokens ?? defaults.maxTokens, } : defaults; if (isDeepseekV4Generation("alibaba-token-plan", normalizedId)) { return { ...enriched, reasoning: true, thinking: { mode: "effort" as const, efforts: [Effort.High, Effort.Max], }, }; } return enriched; }, fetch: config?.fetch, }), }), }; } // --------------------------------------------------------------------------- // 11. Vercel AI Gateway // --------------------------------------------------------------------------- export interface VercelAiGatewayModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } function normalizeVercelAiGatewayBaseUrls(rawBaseUrl: string | undefined): { baseUrl: string; catalogBaseUrl: string } { const baseUrl = (rawBaseUrl === undefined ? "https://ai-gateway.vercel.sh" : rawBaseUrl.trim()).replace(/\/+$/, ""); const catalogBaseUrl = baseUrl === "" || baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; return { baseUrl: baseUrl.endsWith("/v1") ? baseUrl.slice(0, -3) : baseUrl, catalogBaseUrl, }; } export function vercelAiGatewayModelManagerOptions( config?: VercelAiGatewayModelManagerConfig, ): ModelManagerOptions<"anthropic-messages"> { const apiKey = config?.apiKey; const { baseUrl, catalogBaseUrl } = normalizeVercelAiGatewayBaseUrls(config?.baseUrl); return { providerId: "vercel-ai-gateway", fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: catalogBaseUrl, apiKey, filterModel: (entry: OpenAICompatibleModelRecord) => { const tags = entry.tags; return Array.isArray(tags) && tags.includes("tool-use"); }, mapModel: ( entry: OpenAICompatibleModelRecord, defaults: ModelSpec<"anthropic-messages">, _context: OpenAICompatibleModelMapperContext<"anthropic-messages">, ): ModelSpec<"anthropic-messages"> => { const pricing = entry.pricing as Record | undefined; const tags = Array.isArray(entry.tags) ? (entry.tags as string[]) : []; const reportedMaxTokens = typeof entry.max_tokens === "number" ? entry.max_tokens : defaults.maxTokens; const modelId = typeof entry.id === "string" ? entry.id : defaults.id; const maxTokens = modelId === "meta/muse-spark-1.2-contributor" && typeof reportedMaxTokens === "number" ? Math.min(reportedMaxTokens, 131_072) : reportedMaxTokens; return { ...defaults, baseUrl, reasoning: tags.includes("reasoning"), input: tags.includes("vision") ? ["text", "image"] : ["text"], cost: { input: (toNumber(pricing?.input) ?? 0) * 1_000_000, output: (toNumber(pricing?.output) ?? 0) * 1_000_000, cacheRead: (toNumber(pricing?.input_cache_read) ?? 0) * 1_000_000, cacheWrite: (toNumber(pricing?.input_cache_write) ?? 0) * 1_000_000, }, contextWindow: typeof entry.context_window === "number" ? entry.context_window : defaults.contextWindow, maxTokens, }; }, fetch: config?.fetch, }), }; } // --------------------------------------------------------------------------- // 12. Kimi Code // --------------------------------------------------------------------------- export interface KimiCodeModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } function mapKimiThinking(entry: OpenAICompatibleModelRecord): ThinkingConfig | undefined { const raw = entry.think_efforts; if (!isRecord(raw) || raw.support !== true) return undefined; const validEfforts = raw.valid_efforts; if (!Array.isArray(validEfforts)) return undefined; const efforts = THINKING_EFFORTS.filter(effort => validEfforts.includes(effort)); if (efforts.length === 0) return undefined; const thinking: ThinkingConfig = { mode: "effort", efforts }; if (entry.supports_thinking_type === "only") { thinking.requiresEffort = true; } if (typeof raw.default_effort === "string") { const defaultLevel = THINKING_EFFORTS.find(effort => effort === raw.default_effort); if (defaultLevel !== undefined && efforts.includes(defaultLevel)) { thinking.defaultLevel = defaultLevel; } } return thinking; } function kimiSupportsReasoning(entry: OpenAICompatibleModelRecord, modelId: string): boolean { switch (entry.supports_thinking_type) { case "only": case "both": return true; case "no": return false; default: // Without a reasoning bit, the classified thinking-variant fact names // the reasoning sibling. return ( entry.supports_reasoning === true || classifyModel("kimi-code", modelId, { lenient: true }).thinkingVariant === true ); } } function mapKimiApiFormat(protocol: unknown): OpenAICompat["kimiApiFormat"] { if (protocol === "anthropic") return "anthropic"; if (protocol === null) return "openai"; return undefined; } /** * Kimi Code output ceilings by model family. The `/coding/v1/models` discovery * envelope carries no output-limit field, so the mapper supplies the documented * per-family caps instead of a blanket constant. Values match models.dev's * `kimi-for-coding` and `moonshotai` kimi-k3 entries. See #6711. */ /** Fallback output cap for Kimi Code families without a documented ceiling (legacy K2 discovery rows). */ export const KIMI_CODE_DEFAULT_MAX_TOKENS = 32_000; /** * Resolve a Kimi Code model's output ceiling; the per-family caps are * rule-owned (`runtime/behavior.kdl` model-limits), everything else -> * `fallback`. */ export function kimiCodeMaxTokens(modelId: string, fallback?: number): number; export function kimiCodeMaxTokens(modelId: string, fallback: number | null): number | null; export function kimiCodeMaxTokens( modelId: string, fallback: number | null = KIMI_CODE_DEFAULT_MAX_TOKENS, ): number | null { return modelLimitsFor("kimi-code", modelId.trim().toLowerCase())?.maxTokens ?? fallback; } export function kimiCodeModelManagerOptions( config?: KimiCodeModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { const apiKey = config?.apiKey; const baseUrl = config?.baseUrl ?? getDefaultModelDiscoveryBaseUrl("kimi-code")!; return { providerId: "kimi-code", cacheProviderId: resolveModelCacheProviderId("kimi-code", { apiKey, baseUrl }), dynamicModelsAuthoritative: true, ...(apiKey && { fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-completions", provider: "kimi-code", baseUrl, apiKey, headers: { "User-Agent": "KimiCLI/1.0", "X-Msh-Platform": "kimi_cli", }, mapModel: ( entry: OpenAICompatibleModelRecord, defaults: ModelSpec<"openai-completions">, _context: OpenAICompatibleModelMapperContext<"openai-completions">, ): ModelSpec<"openai-completions"> => { const id = defaults.id; const reasoning = kimiSupportsReasoning(entry, id); const thinking = reasoning ? mapKimiThinking(entry) : undefined; return { ...defaults, name: typeof entry.display_name === "string" ? entry.display_name : defaults.name, reasoning, // K2.5's misreported vision support is rule-owned // (`providers/kimi-code.kdl` input-modalities). input: entry.supports_image_in === true ? ["text", "image"] : ["text"], contextWindow: typeof entry.context_length === "number" ? entry.context_length : 262144, maxTokens: kimiCodeMaxTokens(id), thinking, compat: { thinkingFormat: thinking ? "kimi" : "zai", kimiApiFormat: mapKimiApiFormat(entry.protocol), reasoningContentField: "reasoning_content", supportsDeveloperRole: false, }, }; }, fetch: config?.fetch, }), }), }; } // --------------------------------------------------------------------------- // 12.5. LM Studio // --------------------------------------------------------------------------- /** Native LM Studio metadata keyed by model id from `/api/v0/models` and `/api/v1/models`. */ export interface LmStudioNativeModelMetadata { input: ("text" | "image")[]; contextWindow?: number; /** * LM Studio reports a decision model (`type: "decision"`): it answers typed * questions on `/v1/decisions` and rejects chat completions. */ decision?: true; } /** Options for LM Studio's optional native metadata probe. */ export interface LmStudioNativeModelMetadataOptions { headers?: Record; signal?: AbortSignal; } const LM_STUDIO_NATIVE_METADATA_TIMEOUT_MS = 250; function toLmStudioNativeBaseUrl(baseUrl: string): string { const trimmed = baseUrl.trim(); const normalized = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; return normalized.endsWith("/v1") ? normalized.slice(0, -3) : normalized; } function getLmStudioCapabilityNames(value: unknown): string[] { if (!Array.isArray(value)) { return []; } return value.flatMap(item => (typeof item === "string" ? [item.toLowerCase()] : [])); } function getLmStudioModelType(entry: Record): string { return typeof entry.type === "string" ? entry.type.toLowerCase() : ""; } function getLmStudioNativeInput(entry: Record): ("text" | "image")[] { const modelType = getLmStudioModelType(entry); // A decision model stays `type: "decision"` when it reads images; LM Studio // says so through `has_vision_adapter` instead. if (modelType === "decision") { return entry.has_vision_adapter === true ? ["text", "image"] : ["text"]; } const capabilities = getLmStudioCapabilityNames(entry.capabilities); const supportsImage = modelType === "vlm" || capabilities.includes("vision") || capabilities.includes("image"); return supportsImage ? ["text", "image"] : ["text"]; } /** * Decision models from LM Studio's v1 listing (`GET /api/v1/models`), keyed by * model key and by every loaded instance id. The v0 listing predates decision * models, so this is where `type: "decision"` is reported. */ function getLmStudioV1DecisionModels(payload: unknown): Map { const metadata = new Map(); if (!isRecord(payload) || !Array.isArray(payload.models)) { return metadata; } for (const entry of payload.models) { if (!isRecord(entry) || getLmStudioModelType(entry) !== "decision") { continue; } const instances = Array.isArray(entry.loaded_instances) ? entry.loaded_instances.filter(isRecord) : []; const loadedContextWindow = instances .map(instance => (isRecord(instance.config) ? toPositiveNumber(instance.config.context_length, null) : null)) .find(window => window !== null); const contextWindow = loadedContextWindow ?? toPositiveNumber(entry.max_context_length, null) ?? undefined; const model: LmStudioNativeModelMetadata = { input: getLmStudioNativeInput(entry), ...(contextWindow === undefined ? {} : { contextWindow }), decision: true, }; const ids = [entry.key, ...instances.map(instance => instance.id)]; for (const id of ids) { if (typeof id === "string" && id.length > 0) { metadata.set(id, model); } } } return metadata; } function getLmStudioNativeContextWindow(entry: Record): number | undefined { // LM Studio serves `loaded_context_length` (the window the running instance // actually accepts) alongside `max_context_length` (the architectural // ceiling). A model is routinely loaded below its maximum — the user picks a // smaller window, or MLX context auto-fit shrinks it to fit unified memory — // so prefer what the runtime serves, matching the Ollama/llama.cpp rule from // #3754. Unloaded models report `loaded_context_length: null` and fall // through to the max/train chain unchanged. const loadedContextWindow = entry.state === "loaded" ? toPositiveNumber(entry.loaded_context_length, null) : null; return ( loadedContextWindow ?? toPositiveNumber(entry.max_context_length, null) ?? toPositiveNumber(entry.context_length, null) ?? toPositiveNumber(entry.max_model_len, null) ?? undefined ); } /** * Fetches LM Studio native model metadata used to mark VLM models as * image-capable and decision models as judges. */ export async function fetchLmStudioNativeModelMetadata( baseUrl: string, fetchImpl: FetchImpl = fetch, options?: LmStudioNativeModelMetadataOptions, ): Promise | null> { const nativeBaseUrl = toLmStudioNativeBaseUrl(baseUrl); const fetchJson = async (path: string, signal?: AbortSignal): Promise => { try { const response = await fetchImpl(`${nativeBaseUrl}${path}`, { method: "GET", headers: { Accept: "application/json", ...options?.headers }, signal, }); return response.ok ? await response.json() : null; } catch { return null; } }; const fetchMetadata = async (signal?: AbortSignal): Promise | null> => { // Builds without the v1 listing answer 404 there and keep v0 metadata alone. const [v0, v1] = await Promise.all([fetchJson("/api/v0/models", signal), fetchJson("/api/v1/models", signal)]); const decisions = getLmStudioV1DecisionModels(v1); if ((!isRecord(v0) || !Array.isArray(v0.data)) && decisions.size === 0) { return null; } const metadata = new Map(); for (const entry of isRecord(v0) && Array.isArray(v0.data) ? v0.data : []) { if (!isRecord(entry) || typeof entry.id !== "string" || entry.id.length === 0) { continue; } const contextWindow = getLmStudioNativeContextWindow(entry); metadata.set(entry.id, { input: getLmStudioNativeInput(entry), ...(contextWindow === undefined ? {} : { contextWindow }), ...(getLmStudioModelType(entry) === "decision" ? { decision: true as const } : {}), }); } for (const [id, model] of decisions) { metadata.set(id, model); } return metadata; }; if (options?.signal !== undefined) { return fetchMetadata(options.signal); } return withOpenAICompatibleDiscoveryTimeout(LM_STUDIO_NATIVE_METADATA_TIMEOUT_MS, fetchMetadata); } export interface LmStudioModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function lmStudioModelManagerOptions(config?: LmStudioModelManagerConfig): ModelManagerOptions { const apiKey = config?.apiKey; const baseUrl = config?.baseUrl ?? Bun.env.LM_STUDIO_BASE_URL ?? "http://127.0.0.1:1234/v1"; const references = createBundledReferenceMap<"openai-completions">("lm-studio" as any); return { providerId: "lm-studio", fetchDynamicModels: async () => { const nativeMetadataPromise = fetchLmStudioNativeModelMetadata(baseUrl, config?.fetch, { headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined, }); const models = await fetchOpenAICompatibleModels({ api: "openai-completions", provider: "lm-studio", baseUrl, apiKey, mapModel: (entry, defaults) => { const reference = references.get(defaults.id); return mapWithBundledReference(entry, defaults, reference); }, fetch: config?.fetch, }); if (!models) { return models; } const nativeMetadata = await nativeMetadataPromise; if (!nativeMetadata) { return models; } return models.map((model): ModelSpec => { const metadata = nativeMetadata.get(model.id); if (!metadata) { return model; } return { ...model, // Decision models answer only on `/v1/decisions` (OpenAI's Decisions API). ...(metadata.decision ? { api: "openai-decisions", kind: "judge", reasoning: false, supportsTools: false } : {}), input: metadata.input, contextWindow: metadata.contextWindow ?? model.contextWindow, }; }); }, }; } // --------------------------------------------------------------------------- // 13. Synthetic // --------------------------------------------------------------------------- export interface SyntheticModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } /** * Synthetic's `/openai/v1/models` entry shape (verified live against * api.synthetic.new). It shares no capability field names with the generic * OpenAI-compatible conventions: capabilities arrive in `supported_features`, * modalities in `input_modalities`, the output cap in `max_output_length`, the * accepted `reasoning_effort` vocabulary in `reasoning_parameters.efforts`, * and per-token prices in `pricing` as `$`-prefixed decimal strings. */ interface SyntheticModelRecord extends OpenAICompatibleModelRecord { supported_features?: unknown; input_modalities?: unknown; max_output_length?: unknown; reasoning_parameters?: unknown; pricing?: unknown; } /** Synthetic's thinking-off wire tier — a router state, not a user effort. */ const SYNTHETIC_WIRE_EFFORT_NONE = "none"; /** Output cap for routes that advertise no `max_output_length`. */ const SYNTHETIC_FALLBACK_MAX_TOKENS = 8192; function toSyntheticStringList(value: unknown): readonly string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; } /** * Translate Synthetic's per-model `reasoning_effort` vocabulary into an effort * ladder. Every advertised value that names an OMP tier maps verbatim; `none` * is the thinking-off state rather than a tier of its own, so it backs the * `minimal` selector through the wire map (same shape as the Fireworks * `minimal → none` map) and gives these routes a real no-thinking tier. * A route that advertises only `none` (or tiers this client doesn't know) * still gets the minimal-off mapping: falling through to identity inference * would fabricate an unadvertised ladder, and leaving thinking unset would * leak any stale reference ladder past the wire vocabulary. */ function resolveSyntheticThinking(wireEfforts: readonly string[]): ThinkingConfig | undefined { const efforts = THINKING_EFFORTS.filter(effort => wireEfforts.includes(effort)); const wireHasNone = wireEfforts.includes(SYNTHETIC_WIRE_EFFORT_NONE); if (efforts.length === 0) { return wireHasNone ? { mode: "effort", efforts: [Effort.Minimal], effortMap: { [Effort.Minimal]: SYNTHETIC_WIRE_EFFORT_NONE } } : undefined; } if (!wireHasNone || efforts.includes(Effort.Minimal)) { return { mode: "effort", efforts }; } return { mode: "effort", efforts: [Effort.Minimal, ...efforts], effortMap: { [Effort.Minimal]: SYNTHETIC_WIRE_EFFORT_NONE }, }; } /** Synthetic quotes per-token USD as `"$0.000001"`; catalog cost is per-million. */ function toSyntheticCostPerMillion(value: unknown): number | undefined { const parsed = toNumber(typeof value === "string" ? value.trim().replace(/^\$/, "") : value); if (parsed === undefined || parsed < 0) { return undefined; } // Scaling a per-token decimal by 1e6 drifts (4.5e-7 → 0.44999999999999996), so // settle on a millionth of a dollar per million tokens — finer than any real tier. return Math.round(parsed * 1e12) / 1e6; } function resolveSyntheticCost( pricing: unknown, fallback: ModelSpec<"openai-completions">["cost"], ): ModelSpec<"openai-completions">["cost"] { if (!isRecord(pricing)) { return fallback; } const input = toSyntheticCostPerMillion(pricing.prompt); const output = toSyntheticCostPerMillion(pricing.completion); if (input === undefined || output === undefined) { return fallback; } return { input, output, cacheRead: toSyntheticCostPerMillion(pricing.input_cache_reads) ?? fallback.cacheRead, cacheWrite: toSyntheticCostPerMillion(pricing.input_cache_writes) ?? fallback.cacheWrite, }; } export function syntheticModelManagerOptions( config?: SyntheticModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { const apiKey = config?.apiKey; const baseUrl = config?.baseUrl ?? "https://api.synthetic.new/openai/v1"; const references = new Map( (getBundledModels("synthetic") as Model<"openai-completions">[]).map(model => [model.id, toModelSpec(model)]), ); return { providerId: "synthetic", dynamicModelsAuthoritative: true, dynamicReasoningAuthoritative: true, ...(apiKey && { fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-completions", provider: "synthetic", baseUrl, apiKey, mapModel: ( entry: OpenAICompatibleModelRecord, defaults: ModelSpec<"openai-completions">, _context: OpenAICompatibleModelMapperContext<"openai-completions">, ): ModelSpec<"openai-completions"> => { const record = entry as SyntheticModelRecord; const reference = references.get(defaults.id); const referenceSupportsImage = reference?.input.includes("image") ?? false; const features = toSyntheticStringList(record.supported_features); const modalities = toSyntheticStringList(record.input_modalities); const wireEfforts = isRecord(record.reasoning_parameters) ? toSyntheticStringList(record.reasoning_parameters.efforts) : []; const wireReasoning = features.includes("reasoning") || wireEfforts.length > 0; const thinking = resolveSyntheticThinking(wireEfforts); // An advertised effort vocabulary is authoritative over the bundled // reference: when the wire names tiers (even only `none`), the // reference's reasoning flag must not re-add a dial the route // doesn't expose. A route with at least one named tier reasons — // even a single tier is a real effort the wire accepts. Only a // vocabulary of `none`/unrecognized values alone is the pure // off-switch: reporting `reasoning: true` there would light up the // effort dial for a dial with one stop. When the wire is silent on // reasoning entirely, the reference gets a vote. const namedTierCount = (thinking?.efforts.length ?? 0) - (wireEfforts.includes(SYNTHETIC_WIRE_EFFORT_NONE) ? 1 : 0); const reasoning = wireReasoning && namedTierCount > 0 ? true : wireEfforts.length > 0 ? false : entry.supports_reasoning === true || (reference?.reasoning ?? false); // The router aliases (`syn:*`) and newly added routes carry no // bundled reference, so these advertised capabilities are the only // truth available. Without them such a model lands non-reasoning // (which hides the thinking selector and drops `reasoning_effort` // from every request), text-only, priced at zero, and capped at the // 8k placeholder — a cap low enough that verbose models stop on // `length` each turn and trip recovery compaction. const base = reference ? { ...reference, id: defaults.id, baseUrl } : defaults; return { ...base, name: toModelName(entry.name, reference?.name ?? defaults.name), reasoning, ...(thinking ? { thinking } : {}), // Advertised `input_modalities` are authoritative, mirroring the // effort vocabulary above: a wire that names its modalities (even // text-only) must not regrow `image` from the bundled reference. // Only when the wire omits them do `supports_vision` and the // reference get a vote. input: modalities.length > 0 ? modalities.includes("image") ? ["text", "image"] : ["text"] : entry.supports_vision === true || referenceSupportsImage ? ["text", "image"] : ["text"], // A present `supported_features` list (even empty) is the route's // whole advertised surface: no `tools` entry means no tool // support. The reference still wins when it already vouched for // tools, since a populated wire list can be incomplete; an // explicit reference `false` stays `false` either way. ...(record.supported_features !== undefined && !features.includes("tools") && reference?.supportsTools !== true ? { supportsTools: false } : reference?.supportsTools === false ? { supportsTools: false } : {}), cost: resolveSyntheticCost(record.pricing, base.cost), contextWindow: toPositiveNumber( entry.context_length, reference?.contextWindow ?? defaults.contextWindow, ), maxTokens: toPositiveNumber( record.max_output_length ?? entry.max_tokens, reference?.maxTokens ?? SYNTHETIC_FALLBACK_MAX_TOKENS, ), }; }, fetch: config?.fetch, }), }), }; } // --------------------------------------------------------------------------- // 14. Venice // --------------------------------------------------------------------------- export interface VeniceModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function veniceModelManagerOptions( config?: VeniceModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createOpenAICompatibleModelManagerOptions({ api: "openai-completions", providerId: "venice", defaultBaseUrl: "https://api.venice.ai/api/v1", config, mapModel: (entry, defaults, reference) => { const model = mapWithBundledReference(entry, defaults, reference); return { ...model, compat: { ...model.compat, supportsUsageInStreaming: false } }; }, }); } // --------------------------------------------------------------------------- // 14.5 Baseten // --------------------------------------------------------------------------- export interface BasetenModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } // A previous version of OMP shipped these models without reasoning levels. // We've since fixed that (V4-generation whitelist). This const lets us bust // the cache so that users on that version of OMP pick up the reasoning levels // immediately. const BASETEN_CACHE_MIGRATION_MODEL_IDS = [ "zai-org/GLM-5.3", "zai-org/GLM-5.3-Flash", "deepseek-ai/DeepSeek-V4-Flash-0731", "deepseek-ai/DeepSeek-V4.1-Flash", "deepseek-ai/DeepSeek-V4-Pro-0813", ] as const; export function basetenModelManagerOptions( config?: BasetenModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createOpenAICompatibleModelManagerOptions({ api: "openai-completions", providerId: "baseten", defaultBaseUrl: "https://inference.baseten.co/v1", config, dynamicModelsAuthoritative: true, requireApiKey: true, dropCachedModelIdsOnStaticMismatch: BASETEN_CACHE_MIGRATION_MODEL_IDS, mapModel: (entry, defaults, reference) => { const raw = entry as Record & { supported_features?: unknown; input_modalities?: unknown; pricing?: Record; }; const features = Array.isArray(raw.supported_features) ? raw.supported_features : []; const modalities = Array.isArray(raw.input_modalities) ? raw.input_modalities : []; // Baseten's discovery flags are not enough to enable OMP reasoning for every // model. Only models with a verified Baseten reasoning policy are enabled // here; an unknown model may use a different reasoning wire shape or effort // vocabulary, which OMP must not guess. const identity = classifyModel("baseten", defaults.id, { lenient: true }); const isSupportedBasetenReasoningModel = (identity.class === "kimi" && identity.family === "k3") || isGlmReasoningIdentity("baseten", defaults.id, "5.2") || defaults.id === "openai/gpt-oss-120b" || isDeepseekV4Generation("baseten", defaults.id); const reasoning = isSupportedBasetenReasoningModel && (features.includes("reasoning") || features.includes("reasoning_effort")); const supportsTools = features.includes("tools") ? undefined : false; const vision = modalities.includes("image") || (reference?.input.includes("image") ?? false); const pricing = raw.pricing ?? {}; const cost = { input: toPositiveNumber(pricing.prompt, 0) * 1_000_000, output: toPositiveNumber(pricing.completion, 0) * 1_000_000, cacheRead: toPositiveNumber(pricing.input_cache_read, 0) * 1_000_000, cacheWrite: 0, }; const baseModel = mapWithBundledReference(entry, defaults, reference); return { ...baseModel, reasoning, input: vision ? ["text", "image"] : ["text"], cost, ...(supportsTools === false ? { supportsTools } : {}), }; }, }); } // --------------------------------------------------------------------------- // 15. Together // --------------------------------------------------------------------------- export interface TogetherModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function togetherModelManagerOptions( config?: TogetherModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createSimpleOpenAICompletionsOptions("together", "https://api.together.xyz/v1", config); } // --------------------------------------------------------------------------- // Inception // --------------------------------------------------------------------------- /** * Builds Inception's authenticated model-discovery manager. A successful * `/v1/models` fetch is the complete roster, keeping only rows that advertise * `chat.completions` (the shared roster also lists FIM/edit and Decisions-only * models); without an API key the bundled Mercury seeds are served. */ export function inceptionModelManagerOptions(config?: ModelManagerConfig): ModelManagerOptions<"openai-completions"> { return { ...createOpenAICompatibleModelManagerOptions({ api: "openai-completions", providerId: "inception", defaultBaseUrl: "https://api.inceptionlabs.ai/v1", config, requireApiKey: true, dynamicModelsAuthoritative: true, mapModel: (entry, defaults, reference) => { // The shared roster also contains FIM/edit and Decisions-only models; // `supported_endpoints` is required, so a row without it is not chat. if (!Array.isArray(entry.supported_endpoints) || !entry.supported_endpoints.includes("chat.completions")) { return null; } const model = mapWithBundledReference(entry, defaults, reference); return { ...model, input: Array.isArray(entry.input_modalities) ? toInputCapabilities(entry.input_modalities) : model.input, cost: isRecord(entry.pricing) ? perTokenPricingCost(entry.pricing) : model.cost, contextWindow: toPositiveNumber(entry.context_length, model.contextWindow), maxTokens: toPositiveNumber(entry.max_output_length, model.maxTokens), ...(Array.isArray(entry.supported_features) && { supportsTools: entry.supported_features.includes("tools"), }), }; }, }), staticModels: seedModels<"openai-completions">("inception"), }; } // --------------------------------------------------------------------------- // 15.5 CoreWeave Serverless Inference // --------------------------------------------------------------------------- export interface CoreWeaveModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function coreWeaveModelManagerOptions( config?: CoreWeaveModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createSimpleOpenAICompletionsOptions("coreweave", "https://api.inference.wandb.ai/v1", { ...config, headers: () => coreWeaveProjectHeaders(Bun.env), }); } // --------------------------------------------------------------------------- // 15.75 Meta Model API // --------------------------------------------------------------------------- const META_MODEL_API_BASE_URL = getDefaultModelDiscoveryBaseUrl("meta")!; const META_MUSE_MODEL_BY_ID: Partial>> = Object.fromEntries( seedModels<"openai-responses">("meta").map(model => [model.id, model]), ); /** * Lineage reference for a Muse Spark revision Meta ships before the seed * lists it. Every revision so far has kept the 1M window, the effort ladder, * and per-tier pricing, so a new one inherits them from the newest seeded * revision of its tier (with its own display name) instead of surfacing as a * text-only model with no limits. Only ids that classify into the * `muse-spark` family with a revision qualify. The template's explicit * `thinking` is dropped: only reviewed seed rows may advertise `max` (1.3 * standard and contributor), so an unknown revision takes the provider's * five-tier ladder from `providers/meta.kdl` at build time. */ function museSparkLineageSpec(id: string): ModelSpec<"openai-responses"> | undefined { const identity = classifyModel("meta", id, { lenient: true }); if (identity.family !== "muse-spark" || identity.revision === undefined) return undefined; const [major, minor, patch] = parseRevision(identity.revision) ?? [0, 0, 0]; const revision = patch === 0 ? `${major}.${minor}` : identity.revision; const contributor = billingVariantPlain(id) !== undefined; let template: ModelSpec<"openai-responses"> | undefined; let templateRevision: readonly [number, number, number] | undefined; for (const model of seedModels<"openai-responses">("meta")) { if ((billingVariantPlain(model.id) !== undefined) !== contributor) continue; const seedIdentity = classifyModel("meta", model.id, { lenient: true }); if (seedIdentity.family !== "muse-spark") continue; const candidate = parseRevision(seedIdentity.revision ?? ""); if (candidate === undefined) continue; if (templateRevision === undefined || compareRevision(candidate, templateRevision) > 0) { template = model; templateRevision = candidate; } } if (template === undefined) return undefined; const { thinking: _reviewedLadder, ...lineage } = template; return { ...lineage, id, name: contributor ? `Muse Spark ${revision} (C)` : `Muse Spark ${revision}` }; } // --------------------------------------------------------------------------- // 15.76 Amazon Bedrock Mantle // --------------------------------------------------------------------------- const BEDROCK_MANTLE_BASE_URL = "https://bedrock-mantle.{region}.api.aws/openai/v1"; const BEDROCK_MANTLE_MODEL_BY_ID: Partial>> = Object.fromEntries( seedModels<"openai-responses">("bedrock-mantle").map(model => [model.id, model]), ); export function bedrockMantleModelManagerOptions( config: ModelManagerConfig = {}, ): ModelManagerOptions<"openai-responses"> { const inferenceBaseUrl = config.baseUrl ?? BEDROCK_MANTLE_BASE_URL; const discoveryBaseUrl = inferenceBaseUrl.replace(/\/openai\/v1\/?$/, "/v1"); return { providerId: "bedrock-mantle", staticModels: seedModels<"openai-responses">("bedrock-mantle"), // The bearer-scoped /v1/models response lists only the models enabled for // the account; a successful fetch replaces the static seed instead of // merging, so disabled models are not selectable. dynamicModelsAuthoritative: true, ...(config.authenticated && { fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-responses", provider: "bedrock-mantle", baseUrl: discoveryBaseUrl, fetch: config.fetch, mapModel: (entry, defaults) => mapWithBundledReference( entry, { ...defaults, baseUrl: BEDROCK_MANTLE_BASE_URL }, BEDROCK_MANTLE_MODEL_BY_ID[defaults.id], ), }), }), }; } export interface MetaModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function metaModelManagerOptions(config?: MetaModelManagerConfig): ModelManagerOptions<"openai-responses"> { return { ...createOpenAICompatibleModelManagerOptions({ api: "openai-responses", providerId: "meta", defaultBaseUrl: META_MODEL_API_BASE_URL, config, requireApiKey: true, filterModel: (_entry, model) => !isExcludedModel("meta", model.id), // The seed backs ids the bundle has not been regenerated with yet; // the lineage spec backs revisions the seed has not been taught. mapModel: (entry, defaults, reference) => mapWithBundledReference( entry, defaults, reference ?? META_MUSE_MODEL_BY_ID[defaults.id] ?? museSparkLineageSpec(defaults.id), ), }), staticModels: seedModels<"openai-responses">("meta"), }; } const MUSE_CODE_MODEL_BY_ID: Partial>> = Object.fromEntries( seedModels<"openai-responses">("muse-code").map(model => [model.id, model]), ); function museCodeLineageSpec(id: string): ModelSpec<"openai-responses"> | undefined { const model = museSparkLineageSpec(id); return model ? { ...model, provider: "muse-code" } : undefined; } export function museCodeModelManagerOptions(config?: MetaModelManagerConfig): ModelManagerOptions<"openai-responses"> { return { ...createOpenAICompatibleModelManagerOptions({ api: "openai-responses", providerId: "muse-code", defaultBaseUrl: META_MODEL_API_BASE_URL, config, headers: { "x-api-version": "1.0.0" }, dynamicModelsAuthoritative: true, requireApiKey: true, filterModel: (_entry, model) => !isExcludedModel("muse-code", model.id), mapModel: (entry, defaults, reference) => mapWithBundledReference( entry, defaults, reference ?? MUSE_CODE_MODEL_BY_ID[defaults.id] ?? museCodeLineageSpec(defaults.id), ), }), cacheProviderId: resolveModelCacheProviderId("muse-code", { apiKey: config?.apiKey, baseUrl: config?.baseUrl ?? META_MODEL_API_BASE_URL, }), staticModels: seedModels<"openai-responses">("muse-code"), }; } // --------------------------------------------------------------------------- // 16. Moonshot // --------------------------------------------------------------------------- export interface MoonshotModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } /** * Moonshot Kimi K3 discovery metadata. K3 is dynamically discovered but absent * from models.dev and the bundled catalog, so `mapWithBundledReference` would * otherwise assign zero cost, null limits, text-only input, and no reasoning — * mislabeling a paid model as "Free" (#5756). Pricing/limits from Moonshot's * official chat-k3 pricing and quickstart guide: * https://platform.kimi.ai/docs/pricing/chat-k3.md * https://platform.kimi.ai/docs/guide/kimi-k3-quickstart * K3 always reasons and supports only `reasoning_effort: "max"` — it does NOT * use the K2.x binary `thinking: { type }` block, so the wire path routes it * through OpenAI-style `reasoning_effort` (see `buildOpenAICompat`). */ const MOONSHOT_KIMI_K3_COST = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3 } as const; export function moonshotModelManagerOptions( config?: MoonshotModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createOpenAICompatibleModelManagerOptions({ api: "openai-completions", providerId: "moonshot", // `MOONSHOT_BASE_URL` redirects discovery (and the streaming request that // inherits this baseUrl) at the Kimi China platform `api.moonshot.cn`; an // explicit `config.baseUrl` still wins. Mirrors LITELLM_BASE_URL/LM_STUDIO_BASE_URL. (#2883) defaultBaseUrl: Bun.env.MOONSHOT_BASE_URL ?? "https://api.moonshot.ai/v1", config, requireApiKey: true, mapModel: (entry, defaults, reference) => { const model = mapWithBundledReference(entry, defaults, reference); const id = model.id.toLowerCase(); // Kimi K3 is discovered but has no bundled/models.dev reference, so the // generic dynamic defaults would report it "Free" with no capabilities // (#5756). Modalities, limits, and the reasoning surface are rule-owned // (`providers/moonshot.kdl`, `runtime/behavior.kdl` model-limits) and // baked at build time; the mapper seeds only the official pricing when // the endpoint reports zero cost, plus the structured reasoning fact. const identity = classifyModel("moonshot", id, { lenient: true }); if (!reference && identity.class === "kimi" && identity.family === "k3") { const isZeroCost = model.cost.input === 0 && model.cost.output === 0 && model.cost.cacheRead === 0; const limits = modelLimitsFor("moonshot", id) ?? modelLimitsFor("moonshot", "kimi-k3"); return { ...model, reasoning: true, cost: isZeroCost ? { ...MOONSHOT_KIMI_K3_COST } : model.cost, contextWindow: model.contextWindow ?? limits?.context ?? null, maxTokens: model.maxTokens ?? limits?.maxTokens ?? null, }; } // Moonshot's K2.x generation and its thinking variants reason on the // native API (#2113); the effort ladder and modalities are rule-owned // and derived at build time from this flag. const isKimiK2Generation = identity.class === "kimi" && identity.family?.startsWith("k2") === true; return { ...model, reasoning: identity.thinkingVariant === true || isKimiK2Generation || model.reasoning, }; }, }); } // --------------------------------------------------------------------------- // 16.5 Sakana AI // --------------------------------------------------------------------------- const SAKANA_DEFAULT_BASE_URL = "https://api.sakana.ai/v1"; const SAKANA_FUGU_THINKING: ThinkingConfig = { mode: "effort", efforts: [Effort.High, Effort.Max], }; const SAKANA_RESPONSES_COMPAT: ModelSpec<"openai-responses">["compat"] = { includeEncryptedReasoning: false, streamIdleTimeoutMs: 0, }; function normalizeSakanaBaseUrl(baseUrl: string | undefined): string { const value = baseUrl?.trim() || SAKANA_DEFAULT_BASE_URL; const normalized = value.replace(/\/+$/, ""); return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`; } function isSakanaFuguModelId(modelId: string): boolean { return /^fugu(?:$|-)/i.test(modelId); } const SAKANA_FUGU_STATIC_MODEL_BY_ID = new Map( seedModels<"openai-responses">("sakana").map(model => [model.id, model] as const), ); const SAKANA_FUGU_STATIC_MODEL_IDS = seedModels<"openai-responses">("sakana").map(model => model.id); export interface SakanaModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function sakanaModelManagerOptions(config?: SakanaModelManagerConfig): ModelManagerOptions<"openai-responses"> { const apiKey = config?.apiKey; const baseUrl = normalizeSakanaBaseUrl(config?.baseUrl ?? Bun.env.SAKANA_BASE_URL ?? Bun.env.FUGU_BASE_URL); const references = createBundledReferenceMap<"openai-responses">("sakana"); return { providerId: "sakana", dynamicModelsAuthoritative: true, dropCachedModelIdsOnStaticMismatch: SAKANA_FUGU_STATIC_MODEL_IDS, ...(apiKey && { fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-responses", provider: "sakana", baseUrl, apiKey, mapModel: (entry, defaults) => { const reference = references.get(defaults.id) ?? SAKANA_FUGU_STATIC_MODEL_BY_ID.get(defaults.id); const model = mapWithBundledReference(entry, defaults, reference); if (!reference && isSakanaFuguModelId(model.id)) { return { ...model, reasoning: true, thinking: { ...SAKANA_FUGU_THINKING }, compat: { ...SAKANA_RESPONSES_COMPAT }, }; } return model; }, fetch: config?.fetch, }), }), }; } // --------------------------------------------------------------------------- // 16.6 ai& (aiand.com) // --------------------------------------------------------------------------- const AIAND_DEFAULT_BASE_URL = "https://api.aiand.com/v1"; /** Effort wire values discovery endpoints report (`reasoning_efforts`, `thinking`), mapped onto pi effort levels. */ const EFFORT_BY_WIRE_VALUE: Record = { minimal: Effort.Minimal, low: Effort.Low, medium: Effort.Medium, high: Effort.High, xhigh: Effort.XHigh, max: Effort.Max, }; function normalizeAiandBaseUrl(baseUrl: string | undefined): string { const value = baseUrl?.trim() || AIAND_DEFAULT_BASE_URL; const normalized = value.replace(/\/+$/, ""); return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`; } const AIAND_STATIC_MODEL_IDS = seedModels("aiand").map(model => model.id); /** Parse a discovered effort list, dropping unknown wire values; empty when absent or unrecognized. */ function parseWireEfforts(value: unknown): Effort[] { return Array.isArray(value) ? value.flatMap(item => typeof item === "string" && EFFORT_BY_WIRE_VALUE[item] ? [EFFORT_BY_WIRE_VALUE[item]] : [], ) : []; } function mapAiandThinking(entry: OpenAICompatibleModelRecord): ThinkingConfig | undefined { const efforts = parseWireEfforts(entry.reasoning_efforts); if (efforts.length === 0) { return undefined; } const defaultLevel = typeof entry.reasoning_effort_default === "string" ? EFFORT_BY_WIRE_VALUE[entry.reasoning_effort_default] : undefined; return { mode: "effort", efforts, ...(defaultLevel && efforts.includes(defaultLevel) && { defaultLevel }), }; } /** * ai& reports prices as decimal strings per 1M tokens in the org's billing * currency (`usd` or `jpy`). Costs are only mapped for USD orgs — JPY figures * would corrupt the USD-denominated cost model, so they fall back to zero. */ function mapAiandCost(entry: OpenAICompatibleModelRecord): ModelSpec<"openai-completions">["cost"] { if (typeof entry.currency === "string" && entry.currency !== "usd") { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; } return { input: toPositiveNumber(entry.input_per_1m, 0), output: toPositiveNumber(entry.output_per_1m, 0), cacheRead: 0, cacheWrite: 0, }; } function mapAiandModel( entry: OpenAICompatibleModelRecord, defaults: ModelSpec<"openai-completions">, ): ModelSpec<"openai-completions"> { const capabilities: unknown[] = Array.isArray(entry.capabilities) ? entry.capabilities : []; const reasoning = capabilities.includes("reasoning"); const thinking = reasoning ? mapAiandThinking(entry) : undefined; const description = typeof entry.description === "string" && entry.description.trim() ? entry.description : undefined; return { ...defaults, name: description ?? toModelName(entry.name, defaults.name), reasoning, input: capabilities.includes("vision") ? ["text", "image"] : ["text"], cost: mapAiandCost(entry), contextWindow: toPositiveNumber(entry.context_window, null), ...(thinking && { thinking }), }; } export interface AiandModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } /** * ai& (aiand.com) model manager: OpenAI-compatible chat completions with an * org-scoped `/v1/models` catalog carrying context, capability, effort, and * pricing metadata, so discovery is authoritative over the bundled seed. */ export function aiandModelManagerOptions(config?: AiandModelManagerConfig): ModelManagerOptions<"openai-completions"> { const apiKey = config?.apiKey; const baseUrl = normalizeAiandBaseUrl(config?.baseUrl ?? Bun.env.AIAND_BASE_URL); return { providerId: "aiand", dynamicModelsAuthoritative: true, dropCachedModelIdsOnStaticMismatch: AIAND_STATIC_MODEL_IDS, ...(apiKey && { fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-completions", provider: "aiand", baseUrl, apiKey, mapModel: (entry, defaults) => mapAiandModel(entry, defaults), fetch: config?.fetch, }), }), }; } // --------------------------------------------------------------------------- // 16.7 Abliteration (abliteration.ai) // --------------------------------------------------------------------------- const ABLITERATION_DEFAULT_BASE_URL = "https://api.abliteration.ai/v1"; function normalizeAbliterationBaseUrl(baseUrl: string | undefined): string { const value = baseUrl?.trim() || ABLITERATION_DEFAULT_BASE_URL; const normalized = value.replace(/\/+$/, ""); return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`; } const ABLITERATION_STATIC_MODEL_BY_ID = new Map( seedModels<"openai-responses">("abliteration").map(model => [model.id, model] as const), ); const ABLITERATION_STATIC_MODEL_IDS = seedModels<"openai-responses">("abliteration").map(model => model.id); export interface AbliterationModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function abliterationModelManagerOptions( config?: AbliterationModelManagerConfig, ): ModelManagerOptions<"openai-responses"> { const apiKey = config?.apiKey; const baseUrl = normalizeAbliterationBaseUrl(config?.baseUrl); const references = createBundledReferenceMap<"openai-responses">("abliteration"); return { providerId: "abliteration", dynamicModelsAuthoritative: true, dropCachedModelIdsOnStaticMismatch: ABLITERATION_STATIC_MODEL_IDS, ...(apiKey && { fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-responses", provider: "abliteration", baseUrl, apiKey, mapModel: (entry, defaults) => { const reference = references.get(defaults.id) ?? ABLITERATION_STATIC_MODEL_BY_ID.get(defaults.id); const model = mapWithBundledReference(entry, defaults, reference); // Every abliteration.ai model reasons; `/v1/models` carries no // capability metadata, so an unseeded id would otherwise lose // its effort dial. return reference ? model : { ...model, reasoning: true }; }, fetch: config?.fetch, }), }), }; } // --------------------------------------------------------------------------- // 16.8 Yolo-Auto (yolo-auto.com) // --------------------------------------------------------------------------- const YOLO_AUTO_BASE_URL = "https://yolo-auto.com/v1"; export interface YoloAutoModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } /** * Map a discovered entry onto its reference, then re-apply Yolo-Auto's * provider-wide wire constraints. A model that only exists in another * provider's bundle (e.g. `gpt-4o` served via the global reference index) * would otherwise inherit that provider's token pricing and `store`-capable * surface, which the flat-rate Yolo endpoint neither charges nor accepts. */ function mapYoloAutoModel( entry: OpenAICompatibleModelRecord, defaults: ModelSpec<"openai-completions">, reference: ModelSpec<"openai-completions"> | undefined, ): ModelSpec<"openai-completions"> { const model = mapWithBundledReference(entry, defaults, reference); // `/v1/models` advertises each reasoning model's accepted effort ladder in // `thinking`; it is authoritative over the seed/reference ladder. Rows // without the field (e.g. `yolo-small`) keep the reference surface. const efforts = parseWireEfforts(entry.thinking); const thinking: ThinkingConfig | undefined = efforts.length > 0 ? { ...model.thinking, mode: model.thinking?.mode ?? "effort", efforts, defaultLevel: model.thinking?.defaultLevel && efforts.includes(model.thinking.defaultLevel) ? model.thinking.defaultLevel : undefined, } : model.thinking; return { ...model, ...(efforts.length > 0 && { reasoning: true }), thinking, // Flat-rate and no-store are provider-wide: they must win whether the // reference came from the yolo bundle, the global index, or nowhere. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, compat: { ...model.compat, supportsStore: false, supportsDeveloperRole: false, }, }; } /** * Yolo-Auto model manager: flat-rate OpenAI-compatible chat completions * (`qwen3.8-27b`, `yolo`, ...). Live `/v1/models` discovery replaces the * bundled seed once a key is stored or present in `YOLO_AUTO_API_KEY`. Models * the provider adds later inherit metadata (reasoning, thinking, context) * from the global bundled reference index, so new ids work without a per-id * code change. */ export function yoloAutoModelManagerOptions( config?: YoloAutoModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { // Curated static models take precedence over the previously generated // bundle so later corrections to the curated metadata are honored; bundle // rows keep ids that earlier credentialed runs discovered, and the global // index covers everything else. const references = new Map>(); const bundled = createBundledReferenceMap<"openai-completions">("yolo-auto"); for (const model of bundled.values()) { references.set(model.id, model); } for (const model of seedModels<"openai-completions">("yolo-auto")) { const previous = references.get(model.id); references.set(model.id, { ...previous, ...model, // Generation-filled fields the curated seed leaves null: keep the // canonical values baked by the previous regen (max-output cap, // derived thinking) instead of dropping them. maxTokens: model.maxTokens ?? previous?.maxTokens ?? null, thinking: model.thinking ?? previous?.thinking, }); } const resolveReference = createReferenceResolver(() => references); return { ...createOpenAICompatibleModelManagerOptions({ api: "openai-completions", providerId: "yolo-auto", defaultBaseUrl: YOLO_AUTO_BASE_URL, config, requireApiKey: true, mapModel: (entry, defaults, reference) => mapYoloAutoModel(entry, defaults, resolveReference(defaults.id) ?? reference), dynamicModelsAuthoritative: true, }), }; } // --------------------------------------------------------------------------- // 16.9 StepFun // --------------------------------------------------------------------------- /** * StepFun discovery configuration: the API key plus optional base-URL and * fetch overrides. Consumed by {@link stepfunModelManagerOptions}, and exported * for extensions and tests that construct the manager directly. */ export interface StepfunModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } /** StepFun's `/v1/models` row shape beyond the generic OpenAI-compatible fields. */ interface StepfunModelRecord extends OpenAICompatibleModelRecord { /** Reasoning tiers the endpoint advertises for the model, e.g. `["low","medium","high"]`. */ reasoning_effort_support_list?: unknown; } /** * Translate StepFun's per-model `reasoning_effort_support_list` into a ladder. * Every advertised value that names an OMP tier maps verbatim, in OMP's tier * order; a row advertising nothing (or only tiers this client does not know) * resolves to no thinking, so the wire path never sends a `reasoning_effort` * the endpoint rejects. Same shape as `mapOpenRouterThinking` for OpenRouter's * `reasoning.supported_efforts`. */ function mapStepfunThinking(entry: StepfunModelRecord): ThinkingConfig | undefined { const advertised = Array.isArray(entry.reasoning_effort_support_list) ? entry.reasoning_effort_support_list.filter((value): value is string => typeof value === "string") : []; const efforts = THINKING_EFFORTS.filter(effort => advertised.includes(effort)); return efforts.length === 0 ? undefined : { mode: "effort", efforts }; } /** * Whether a StepFun `/v1/models` id is a chat model omp can route. StepFun's * roster interleaves its audio and image SKUs with the chat models; the * exclusion policy itself lives in `runtime/behavior.kdl` (`exclude-models * provider="stepfun"`), not here. */ export function isStepfunChatModelId(id: string): boolean { const normalized = id.trim().toLowerCase(); if (!normalized) return false; return !isExcludedModel("stepfun", normalized); } /** * StepFun model manager: plain OpenAI-compatible chat completions at * `api.stepfun.ai/v1`. A successful `/v1/models` snapshot is authoritative over * the bundled seed rows (`providers/stepfun.kdl`), so a model StepFun retires * leaves the picker instead of lingering as a dead seed row, while models added * later become selectable without an omp release. */ export function stepfunModelManagerOptions( config?: StepfunModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createOpenAICompatibleModelManagerOptions({ api: "openai-completions", providerId: "stepfun", defaultBaseUrl: "https://api.stepfun.ai/v1", config, requireApiKey: true, filterModel: (_entry, model) => isStepfunChatModelId(model.id), mapModel: (entry, model, reference) => { const mapped = mapWithBundledReference(entry, model, reference); // A model StepFun ships later has no bundled reference, so it starts // from the generic defaults (`reasoning: false`, no thinking) and // `mergeDynamicModels` adds it verbatim — the endpoint's own tiers are // then the only thing that can give it a reasoning dial. if (reference) return mapped; const thinking = mapStepfunThinking(entry); return thinking === undefined ? mapped : { ...mapped, reasoning: true, thinking }; }, // Must live on the manager options, not only the KDL descriptor: // `createModelManager()` prunes the bundled slice from this flag. dynamicModelsAuthoritative: true, }); } // --------------------------------------------------------------------------- // 17. Qwen Portal // --------------------------------------------------------------------------- export interface QwenPortalModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function qwenPortalModelManagerOptions( config?: QwenPortalModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createSimpleOpenAICompletionsOptions("qwen-portal", "https://portal.qwen.ai/v1", config); } // --------------------------------------------------------------------------- // 18. Qianfan // --------------------------------------------------------------------------- export interface QianfanModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function qianfanModelManagerOptions( config?: QianfanModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createSimpleOpenAICompletionsOptions("qianfan", "https://qianfan.baidubce.com/v2", config); } // --------------------------------------------------------------------------- // 19. Cloudflare AI Gateway // --------------------------------------------------------------------------- export interface CloudflareAiGatewayModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function cloudflareAiGatewayModelManagerOptions( config?: CloudflareAiGatewayModelManagerConfig, ): ModelManagerOptions<"anthropic-messages"> { return createSimpleAnthropicProviderOptions( "cloudflare-ai-gateway", "https://gateway.ai.cloudflare.com/v1///anthropic", config, ); } // --------------------------------------------------------------------------- // 20. Xiaomi // --------------------------------------------------------------------------- /** Region codes for Xiaomi Token Plan clusters exposed as separate login providers. */ export type XiaomiTokenPlanRegion = "sgp" | "ams" | "cn"; /** Configures Xiaomi standard or regional Token Plan OpenAI-compatible model discovery. */ export interface XiaomiModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; providerId?: Provider; tokenPlanRegion?: XiaomiTokenPlanRegion; } const XIAOMI_TOKEN_PLAN_BASE_URLS: Record = { sgp: "https://token-plan-sgp.xiaomimimo.com/v1", ams: "https://token-plan-ams.xiaomimimo.com/v1", cn: "https://token-plan-cn.xiaomimimo.com/v1", }; const XIAOMI_TOKEN_PLAN_FALLBACK_BASE_URLS = [ XIAOMI_TOKEN_PLAN_BASE_URLS.sgp, XIAOMI_TOKEN_PLAN_BASE_URLS.ams, XIAOMI_TOKEN_PLAN_BASE_URLS.cn, ]; /** Builds a Xiaomi model manager, preserving Token Plan region provider ids during discovery. */ export function xiaomiModelManagerOptions( config?: XiaomiModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { const apiKey = config?.apiKey; const providerId = config?.providerId ?? "xiaomi"; const tokenPlanBaseUrls = config?.tokenPlanRegion ? [XIAOMI_TOKEN_PLAN_BASE_URLS[config.tokenPlanRegion]] : XIAOMI_TOKEN_PLAN_FALLBACK_BASE_URLS; const XIAOMI_STANDARD_BASE_URL = "https://api.xiaomimimo.com/v1"; const isTokenPlanProvider = config?.tokenPlanRegion !== undefined || providerId.startsWith("xiaomi-token-plan-"); const isTokenPlanKey = isTokenPlanProvider || (apiKey !== undefined && isXiaomiTokenPlanKey(apiKey)); // Token-plan keys always use a TP cluster; config?.baseUrl (from catalog) // would incorrectly pin to the standard endpoint (api.xiaomimimo.com). const baseUrl = isTokenPlanKey ? tokenPlanBaseUrls[0] : (config?.baseUrl ?? XIAOMI_STANDARD_BASE_URL); const references = createBundledReferenceMap<"openai-completions">("xiaomi"); for (const seed of seedModels<"openai-completions">(providerId)) { references.set(seed.id, seed); } const fetchModels = (url: string) => fetchOpenAICompatibleModels({ api: "openai-completions", provider: providerId, baseUrl: url, apiKey, filterModel: (_entry, model) => !isExcludedModel(providerId, model.id), mapModel: (entry, defaults) => { const reference = references.get(defaults.id); const model = mapWithBundledReference(entry, defaults, reference); return { ...model, api: "openai-completions", provider: providerId, baseUrl: defaults.baseUrl, name: toModelName(entry.display_name, model.name), }; }, fetch: config?.fetch, }); return { providerId, ...(apiKey && { fetchDynamicModels: async () => { if (!isTokenPlanKey) { return fetchModels(baseUrl); } for (const url of tokenPlanBaseUrls) { const result = await fetchModels(url); if (result) return result; } return null; }, }), }; } // --------------------------------------------------------------------------- // 21. LiteLLM // --------------------------------------------------------------------------- export interface LiteLLMModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export interface FetchLiteLLMRichModelsOptions { api: TApi; provider: Provider; baseUrl: string; apiKey?: string; headers?: Record; fetch?: FetchImpl; signal?: AbortSignal; timeoutMs?: number; referenceResolver?: (modelId: string) => ModelSpec | undefined; resolveApi?: (entry: Record, modelId: string) => TApi; } type LiteLLMRichModelEntry = Record; type LiteLLMApiRoute = "openai" | "other" | "unknown"; type LiteLLMRichEndpointModel = { model: ModelSpec; apiRoute: LiteLLMApiRoute; supportsVision: unknown; supportsReasoning: unknown; hasContextWindow: boolean; hasMaxTokens: boolean; hasToolMetadata: boolean; hasSupportedOpenAIParams: boolean; hasCost: boolean; /** Whether an endpoint reported a string `mode`, the only signal that selects a runner api. */ hasMode: boolean; reportedCost: Partial["cost"]>; }; type LiteLLMRichEndpointFailure = { endpoint: string; reason: "http-status" | "invalid-json" | "invalid-pagination" | "network-error"; status?: number; error?: unknown; }; type LiteLLMRichEndpointResult = | { models: LiteLLMRichEndpointModel[]; excludedModelIds: ReadonlySet; incompleteVisionMetadata: boolean; } | { failure: LiteLLMRichEndpointFailure }; const LITELLM_RICH_ENDPOINTS = ["/model_group/info", "/v2/model/info", "/model/info", "/v1/model/info"] as const; // Bound paginated management responses before trusting their advertised page count. const LITELLM_MAX_RICH_PAGES = 100; export const OPENAI_COMPAT_DISCOVERY_DEFAULT_CONTEXT_WINDOW = 128_000; export const OPENAI_COMPAT_DISCOVERY_DEFAULT_MAX_TOKENS = 32_768; const UNKNOWN_PROXY_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as const; const warnedLiteLLMMetadataBases = new Set(); const LITELLM_UNUSABLE_SENTINEL_IDS: Record = { "all-team-models": true, "all-proxy-models": true, "no-default-models": true, }; function warnLiteLLMMetadataFallback(managementBaseUrl: string, failure: LiteLLMRichEndpointFailure): void { if (warnedLiteLLMMetadataBases.has(managementBaseUrl)) { return; } warnedLiteLLMMetadataBases.add(managementBaseUrl); logger.warn("LiteLLM rich model metadata unavailable; falling back to /v1/models", { endpoint: `${managementBaseUrl}${failure.endpoint}`, status: failure.status ?? "unavailable", reason: failure.reason, ...(failure.status === 403 ? { requiredPermission: "Grant this LiteLLM key access to the model metadata endpoints" } : {}), ...(failure.error !== undefined ? { error: failure.error } : {}), }); } /** Exclude only known non-conversational modes; unknown and non-string modes remain selectable for aliases. */ export function isSelectableLiteLLMModelMode(mode: unknown): boolean { return typeof mode !== "string" || !isExcludedDiscoveryMode("litellm", mode); } export function normalizeLiteLLMManagementBaseUrl(baseUrl: string): string { const trimmed = baseUrl.trim().replace(/\/+$/g, ""); if (!trimmed) { return ""; } try { const parsed = new URL(trimmed); const path = parsed.pathname.replace(/\/+$/g, ""); parsed.pathname = path.endsWith("/v1") ? path.slice(0, -3) || "/" : path || "/"; const normalized = `${parsed.protocol}//${parsed.host}${parsed.pathname}`; return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized; } catch { return trimmed.replace(/\/v1$/, ""); } } function normalizeLiteLLMRuntimeBaseUrl(baseUrl: string): string { const trimmed = baseUrl.trim(); return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; } const LITELLM_RESELLER_USAGE_SUFFIX = /\s+\(\d+(?:\.\d+)?[x×] usage\)$/i; function stripLiteLLMResellerUsageSuffix(name: string): string { const cleaned = name.replace(LITELLM_RESELLER_USAGE_SUFFIX, "").trim(); return cleaned.length > 0 ? cleaned : name; } function toLiteLLMDisplayName(modelName: string | undefined, referenceName: string | undefined, id: string): string { const cleanedModelName = modelName ? stripLiteLLMResellerUsageSuffix(modelName) : undefined; if (cleanedModelName && cleanedModelName !== id) { return cleanedModelName; } return referenceName ? stripLiteLLMResellerUsageSuffix(referenceName) : id; } function mapLiteLLMOpenAICompatibleModel( entry: OpenAICompatibleModelRecord, defaults: ModelSpec, reference: ModelSpec | undefined, ): ModelSpec | null { if (!isSelectableLiteLLMModelMode(entry.mode)) { return null; } const model = mapWithBundledReference(entry, defaults, reference); const api = resolveLiteLLMApi(entry, model.id); return { ...model, api, ...liteLLMRunnerKind(api), name: stripLiteLLMResellerUsageSuffix(model.name), }; } function toNonEmptyString(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } function extractLiteLLMRichEntries(payload: unknown): LiteLLMRichModelEntry[] | null { if (Array.isArray(payload)) { return payload.flatMap(entry => (isRecord(entry) ? [entry] : [])); } if (!isRecord(payload)) { return null; } for (const candidate of [payload.data, payload.models, payload.result, payload.items]) { if (candidate === undefined) { continue; } const entries = extractLiteLLMRichEntries(candidate); if (entries !== null) { return entries; } } return null; } function getLiteLLMModelInfo(entry: LiteLLMRichModelEntry): LiteLLMRichModelEntry | undefined { return isRecord(entry.model_info) ? entry.model_info : undefined; } function getLiteLLMParams(entry: LiteLLMRichModelEntry): LiteLLMRichModelEntry | undefined { return isRecord(entry.litellm_params) ? entry.litellm_params : undefined; } function getLiteLLMMetadataValue(entry: LiteLLMRichModelEntry, key: string): unknown { return entry[key] ?? getLiteLLMModelInfo(entry)?.[key]; } /** Per-million USD cost from a positive `*_per_token` LiteLLM field. */ function getLiteLLMPerMillionCost(entry: LiteLLMRichModelEntry, key: string): number | undefined { const perToken = toNumber(getLiteLLMMetadataValue(entry, key)); return perToken !== undefined && perToken > 0 ? perToken * 1_000_000 : undefined; } /** Map positive LiteLLM per-token prices onto their per-million cost fields. */ function getLiteLLMReportedCost(entry: LiteLLMRichModelEntry): Partial["cost"]> { const input = getLiteLLMPerMillionCost(entry, "input_cost_per_token"); const output = getLiteLLMPerMillionCost(entry, "output_cost_per_token"); const cacheRead = getLiteLLMPerMillionCost(entry, "cache_read_input_token_cost"); const cacheWrite = getLiteLLMPerMillionCost(entry, "cache_creation_input_token_cost"); return { ...(input !== undefined ? { input } : {}), ...(output !== undefined ? { output } : {}), ...(cacheRead !== undefined ? { cacheRead } : {}), ...(cacheWrite !== undefined ? { cacheWrite } : {}), }; } function getLiteLLMCost(entry: LiteLLMRichModelEntry): ModelSpec["cost"] | undefined { const cost = getLiteLLMReportedCost(entry); if (cost.input === undefined && cost.output === undefined) { return undefined; } return { input: cost.input ?? 0, output: cost.output ?? 0, cacheRead: cost.cacheRead ?? 0, cacheWrite: cost.cacheWrite ?? 0, }; } function getLiteLLMRichModelId(entry: LiteLLMRichModelEntry): string | undefined { return ( toNonEmptyString(entry.model_group) ?? toNonEmptyString(entry.model_name) ?? toNonEmptyString(entry.id) ?? toNonEmptyString(getLiteLLMParams(entry)?.model) ); } function getSupportedOpenAIParams(entry: LiteLLMRichModelEntry): string[] | undefined { const value = getLiteLLMMetadataValue(entry, "supported_openai_params"); if (!Array.isArray(value)) { return undefined; } return value.flatMap(item => (typeof item === "string" ? [item] : [])); } function getLiteLLMProviders(entry: LiteLLMRichModelEntry): string[] | undefined { if (!Array.isArray(entry.providers)) { return undefined; } const providers = entry.providers.flatMap(provider => { const normalized = toNonEmptyString(provider)?.toLowerCase(); return normalized ? [normalized] : []; }); return providers.length > 0 ? providers : undefined; } function classifyLiteLLMApiRoute(entry: LiteLLMRichModelEntry | undefined, id: string): LiteLLMApiRoute { if (entry) { const providers = getLiteLLMProviders(entry); if (providers) { return providers.every(provider => provider === "openai") ? "openai" : "other"; } const params = getLiteLLMParams(entry); const configuredProvider = toNonEmptyString(params?.custom_llm_provider)?.toLowerCase(); if (configuredProvider) { return configuredProvider === "openai" ? "openai" : "other"; } const backendModel = toNonEmptyString(params?.model); const backendSeparator = backendModel?.indexOf("/") ?? -1; if (backendModel && backendSeparator > 0) { return backendModel.slice(0, backendSeparator).toLowerCase() === "openai" ? "openai" : "other"; } const baseModel = toNonEmptyString(getLiteLLMMetadataValue(entry, "base_model")); const baseSeparator = baseModel?.indexOf("/") ?? -1; if (baseModel && baseSeparator > 0) { return baseModel.slice(0, baseSeparator).toLowerCase() === "openai" ? "openai" : "other"; } } const modelId = id.toLowerCase().startsWith("openai/") ? id.slice("openai/".length) : id; return isLikelyOpenAIResponsesModelId(modelId) ? "openai" : "unknown"; } /** * Transport for a LiteLLM model group: `mode: "decisions"` answers only on the * proxy's `/v1/decisions` route, OpenAI-backed groups use Responses, and every * other group uses `fallbackApi`. */ export function resolveLiteLLMApi( entry: Record | undefined, id: string, fallbackApi: Api = "openai-completions", ): Api { if (entry && getLiteLLMMetadataValue(entry, "mode") === "decisions") { return "openai-decisions"; } return classifyLiteLLMApiRoute(entry, id) === "openai" ? "openai-responses" : fallbackApi; } /** `kind` a LiteLLM row takes on `api`: runner apis (Decisions → judge) leave the chat roster; chat transports add nothing. */ function liteLLMRunnerKind(api: Api): { kind?: ModelKind } { const kind = runnerApiKind(api); return kind ? { kind } : {}; } function isLiteLLMUnusableSentinelPlaceholder(entry: LiteLLMRichModelEntry): boolean { const modelGroup = toNonEmptyString(entry.model_group); const id = toNonEmptyString(entry.id); if ( (modelGroup === undefined || LITELLM_UNUSABLE_SENTINEL_IDS[modelGroup] !== true) && (id === undefined || LITELLM_UNUSABLE_SENTINEL_IDS[id] !== true) ) { return false; } const providers = entry.providers; if (providers !== undefined && (!Array.isArray(providers) || providers.length > 0)) { return false; } const modelName = toNonEmptyString(entry.model_name); if (modelName && LITELLM_UNUSABLE_SENTINEL_IDS[modelName] !== true) { return false; } if (id && LITELLM_UNUSABLE_SENTINEL_IDS[id] !== true) { return false; } const backendModel = toNonEmptyString(getLiteLLMParams(entry)?.model); if (backendModel && LITELLM_UNUSABLE_SENTINEL_IDS[backendModel] !== true) { return false; } if ( toPositiveNumber(getLiteLLMMetadataValue(entry, "max_input_tokens"), null) !== null || toPositiveNumber(getLiteLLMMetadataValue(entry, "max_output_tokens"), null) !== null ) { return false; } if ( getLiteLLMMetadataValue(entry, "supports_vision") === true || getLiteLLMMetadataValue(entry, "supports_reasoning") === true || getLiteLLMMetadataValue(entry, "supports_function_calling") === true || getLiteLLMMetadataValue(entry, "supports_tools") === true ) { return false; } const supportedOpenAIParams = getSupportedOpenAIParams(entry); if (supportedOpenAIParams && supportedOpenAIParams.length > 0) { return false; } return true; } function mapLiteLLMRichEntry( entry: LiteLLMRichModelEntry, options: FetchLiteLLMRichModelsOptions, runtimeBaseUrl: string, ): ModelSpec | null { if ( !isSelectableLiteLLMModelMode(getLiteLLMMetadataValue(entry, "mode")) || isLiteLLMUnusableSentinelPlaceholder(entry) ) { return null; } const id = getLiteLLMRichModelId(entry); if (!id) { return null; } const reference = options.referenceResolver?.(id); const modelName = toNonEmptyString(entry.model_name); const contextWindow = toPositiveNumber( getLiteLLMMetadataValue(entry, "max_input_tokens"), reference?.contextWindow ?? OPENAI_COMPAT_DISCOVERY_DEFAULT_CONTEXT_WINDOW, ); const maxTokens = toPositiveNumber( getLiteLLMMetadataValue(entry, "max_output_tokens"), reference?.maxTokens ?? Math.min(contextWindow, OPENAI_COMPAT_DISCOVERY_DEFAULT_MAX_TOKENS), ); const supportsVision = getLiteLLMMetadataValue(entry, "supports_vision"); const supportsReasoning = getLiteLLMMetadataValue(entry, "supports_reasoning"); const supportedOpenAIParams = getSupportedOpenAIParams(entry); const supportsFunctionCalling = getLiteLLMMetadataValue(entry, "supports_function_calling"); const supportsTools = supportsFunctionCalling === true ? true : supportsFunctionCalling === false ? false : supportedOpenAIParams !== undefined ? supportedOpenAIParams.some(param => ["tools", "tool_choice", "functions", "function_call"].includes(param), ) : reference?.supportsTools; // Enrich from the bundled reference with provider-INDEPENDENT reasoning // hints only. The reference is resolved against the global bundled catalog, // so a custom endpoint exposing an alias that collides with a bundled model // (a LiteLLM proxy serving `kimi-k3`, which matches Fireworks' bundled // `kimi-k3`) must not inherit that provider's transport compat. Spreading // the resolved `reference.compat` wholesale leaked `wireModelIdMode`, // `toolSchemaFlavor`, `thinkingFormat`, etc. across the provider boundary — // rewriting the wire id to `accounts/fireworks/models/kimi-k3` for a // non-Fireworks endpoint (issue #9938). `buildModel` re-derives every // transport field from the discovered provider and model id, so only the // effort vocabulary flows through here. Mirrors `discoverOpenAIModelsList`. const referenceCompat = reference?.compat as OpenAICompat | undefined; const compat: OpenAICompat = { supportsStore: false, supportsDeveloperRole: false, ...(supportedOpenAIParams !== undefined ? { supportsReasoningEffort: supportedOpenAIParams.includes("reasoning_effort") } : referenceCompat?.supportsReasoningEffort !== undefined ? { supportsReasoningEffort: referenceCompat.supportsReasoningEffort } : {}), ...(referenceCompat?.reasoningEffortMap ? { reasoningEffortMap: referenceCompat.reasoningEffortMap } : {}), ...(referenceCompat?.omitReasoningEffort !== undefined ? { omitReasoningEffort: referenceCompat.omitReasoningEffort } : {}), // The deployment is authoritative about its own groups. When its // `model_info.supports_vision` says the group reads images, the derived // opt-out has to outrank the class-wide text-only guard // (`classes/deepseek.kdl`), which keys on the model id alone and would // otherwise replace the attachment with `[image omitted: model does not // support vision]` for a group the endpoint reads. LiteLLM cannot take a // reviewed model list the way OpenRouter or OpenCode Go can — its aliases // are chosen per deployment — so the declaration decides. // `applyCompatOverrides` runs after the cascade, so this beats the class // default; `supports_vision: false` or absent metadata leaves the guard // in place. `mergeLiteLLMCompat` keeps it when another endpoint wins the // compat merge (issue #11982). ...(supportsVision === true ? { stripImageInput: false } : {}), }; const api = options.resolveApi?.(entry, id) ?? options.api; return { id, name: toLiteLLMDisplayName(modelName, reference?.name, id), api, ...liteLLMRunnerKind(api), provider: options.provider, baseUrl: runtimeBaseUrl, contextWindow, maxTokens, input: supportsVision === true ? ["text", "image"] : supportsVision === false ? ["text"] : (reference?.input ?? ["text"]), reasoning: typeof supportsReasoning === "boolean" ? supportsReasoning : (reference?.reasoning ?? false), thinking: reference?.thinking, cost: getLiteLLMCost(entry) ?? reference?.cost ?? UNKNOWN_PROXY_COST, ...(supportsTools !== undefined ? { supportsTools } : {}), compat: compat as ModelSpec["compat"], }; } /** * Field-wise compat union for the management endpoints describing one group. * * The endpoints are complementary, not ranked: `/model_group/info` reports the * gateway's view while `/model/info` and its `/v1` twin report the operator's * own `model_info`, and any of them may answer partially. Picking one side * wholesale dropped every axis the other had reported — a later endpoint that * merely listed `supported_openai_params` erased what an earlier one declared. * Merge per axis instead: a later endpoint overrides the axes it reports and * leaves the rest alone, so an absent axis means "no news", never "retract". */ function mergeLiteLLMCompat( existing: ModelSpec["compat"], next: ModelSpec["compat"], evidence: { existingReportedParams: boolean; nextReportedParams: boolean }, ): ModelSpec["compat"] { if (!existing) return next; if (!next) return existing; const merged: Record = { ...(existing as Record) }; for (const axis in next as Record) { const value = (next as Record)[axis]; if (value !== undefined) merged[axis] = value; } // `supportsReasoningEffort` is the one axis with two sources: the endpoint's // own `supported_openai_params` list, and the models.dev reference it falls // back to when no list was reported. Only the list is evidence, so an // inferred value must not override the other endpoint's verdict — a fallback // `true` would send `reasoning_effort` to a group whose own metadata omitted // it (#11985 review). if (!evidence.nextReportedParams) { const reported = (existing as Record).supportsReasoningEffort; if (reported === undefined) delete merged.supportsReasoningEffort; else merged.supportsReasoningEffort = reported; } return merged as unknown as ModelSpec["compat"]; } function mergeLiteLLMRichEndpointModels( existing: LiteLLMRichEndpointModel, next: LiteLLMRichEndpointModel, ): LiteLLMRichEndpointModel { const apiRoute = existing.apiRoute === "other" || next.apiRoute === "other" ? "other" : existing.apiRoute === "openai" || next.apiRoute === "openai" ? "openai" : "unknown"; // Only a group's `mode` selects a runner api, and an endpoint omitting the // mode carries no news for it, so a runner api reported by either side wins. const runnerApi = [next.model.api, existing.model.api].find(api => runnerApiKind(api) !== undefined); const api = runnerApi ?? (next.apiRoute === apiRoute ? next.model.api : existing.model.api); const model: ModelSpec = { ...existing.model, api, ...liteLLMRunnerKind(api), name: next.model.name === next.model.id ? existing.model.name : next.model.name, contextWindow: next.hasContextWindow ? next.model.contextWindow : existing.model.contextWindow, maxTokens: next.hasMaxTokens ? next.model.maxTokens : existing.model.maxTokens, input: next.supportsVision === true || next.supportsVision === false ? next.model.input : existing.model.input, reasoning: typeof next.supportsReasoning === "boolean" ? next.model.reasoning : existing.model.reasoning, cost: { ...existing.model.cost, ...existing.reportedCost, ...next.reportedCost }, compat: mergeLiteLLMCompat(existing.model.compat, next.model.compat, { existingReportedParams: existing.hasSupportedOpenAIParams, nextReportedParams: next.hasSupportedOpenAIParams, }), }; if (next.hasToolMetadata) { model.supportsTools = next.model.supportsTools; } return { ...next, apiRoute, model, reportedCost: { ...existing.reportedCost, ...next.reportedCost }, hasContextWindow: existing.hasContextWindow || next.hasContextWindow, hasMaxTokens: existing.hasMaxTokens || next.hasMaxTokens, hasToolMetadata: existing.hasToolMetadata || next.hasToolMetadata, hasSupportedOpenAIParams: existing.hasSupportedOpenAIParams || next.hasSupportedOpenAIParams, hasCost: existing.hasCost || next.hasCost, hasMode: existing.hasMode || next.hasMode, }; } async function fetchLiteLLMRichEndpoint( endpoint: string, options: FetchLiteLLMRichModelsOptions, managementBaseUrl: string, runtimeBaseUrl: string, signal?: AbortSignal, ): Promise | null> { const fetchImpl = discoveryFetch(options.fetch); const requestHeaders: Record = { Accept: "application/json", ...options.headers, }; if (options.apiKey) { requestHeaders.Authorization = `Bearer ${options.apiKey}`; } const fetchPage = async ( page?: number, ): Promise<{ payload: unknown; status: number } | { failure: LiteLLMRichEndpointFailure } | null> => { let response: Response; try { response = await fetchImpl(`${managementBaseUrl}${endpoint}${page === undefined ? "" : `?page=${page}`}`, { method: "GET", headers: requestHeaders, signal, }); } catch (error) { return { failure: { endpoint, reason: "network-error", error } }; } if (!response.ok) { return response.status === 404 && page === undefined ? null : { failure: { endpoint, reason: "http-status", status: response.status } }; } try { return { payload: await response.json(), status: response.status }; } catch (error) { return { failure: { endpoint, reason: "invalid-json", status: response.status, error } }; } }; const firstPage = await fetchPage(); if (!firstPage || "failure" in firstPage) { return firstPage; } const entries = extractLiteLLMRichEntries(firstPage.payload); if (!entries || entries.length === 0) { return null; } // /v2/model/info returns {current_page, total_pages, data}; older // endpoints and unpaginated /v2 proxies must keep their existing behavior. if ( endpoint === "/v2/model/info" && isRecord(firstPage.payload) && ("current_page" in firstPage.payload || "total_pages" in firstPage.payload) ) { const { current_page: currentPage, total_pages: totalPages, total_count: totalCount } = firstPage.payload; const invalidPage = (status: number): LiteLLMRichEndpointResult => ({ failure: { endpoint, reason: "invalid-pagination", status }, }); if ( currentPage !== 1 || typeof totalPages !== "number" || !Number.isSafeInteger(totalPages) || totalPages < 1 || totalPages > LITELLM_MAX_RICH_PAGES || (totalCount !== undefined && (typeof totalCount !== "number" || !Number.isSafeInteger(totalCount) || totalCount < entries.length)) ) { return invalidPage(firstPage.status); } const remainingPages = await Promise.all( Array.from({ length: totalPages - 1 }, (_, index) => fetchPage(index + 2)), ); for (const [index, nextPage] of remainingPages.entries()) { if (!nextPage || "failure" in nextPage) { return nextPage ?? invalidPage(firstPage.status); } const nextEntries = extractLiteLLMRichEntries(nextPage.payload); if ( !isRecord(nextPage.payload) || nextPage.payload.current_page !== index + 2 || nextPage.payload.total_pages !== totalPages || (totalCount !== undefined && nextPage.payload.total_count !== totalCount) || !nextEntries?.length ) { return invalidPage(nextPage.status); } entries.push(...nextEntries); } if (totalCount !== undefined && entries.length !== totalCount) { return invalidPage(firstPage.status); } } const deduped = new Map>(); const excludedModelIds = new Set(); for (const entry of entries) { if (isLiteLLMUnusableSentinelPlaceholder(entry)) { continue; } const modelId = getLiteLLMRichModelId(entry); if (!isSelectableLiteLLMModelMode(getLiteLLMMetadataValue(entry, "mode"))) { if (modelId) { excludedModelIds.add(modelId); deduped.delete(modelId); } continue; } if (modelId && excludedModelIds.has(modelId)) { continue; } const model = mapLiteLLMRichEntry(entry, options, runtimeBaseUrl); if (model) { const supportsVision = getLiteLLMMetadataValue(entry, "supports_vision"); const supportsReasoning = getLiteLLMMetadataValue(entry, "supports_reasoning"); const supportsFunctionCalling = getLiteLLMMetadataValue(entry, "supports_function_calling"); const supportedOpenAIParams = getSupportedOpenAIParams(entry); const next: LiteLLMRichEndpointModel = { model, apiRoute: classifyLiteLLMApiRoute(entry, model.id), supportsVision, supportsReasoning, hasContextWindow: toPositiveNumber(getLiteLLMMetadataValue(entry, "max_input_tokens"), null) !== null, hasMaxTokens: toPositiveNumber(getLiteLLMMetadataValue(entry, "max_output_tokens"), null) !== null, hasToolMetadata: supportsFunctionCalling === true || supportsFunctionCalling === false || supportedOpenAIParams !== undefined, hasSupportedOpenAIParams: supportedOpenAIParams !== undefined, hasCost: getLiteLLMCost(entry) !== undefined, hasMode: typeof getLiteLLMMetadataValue(entry, "mode") === "string", reportedCost: getLiteLLMReportedCost(entry), }; const existing = deduped.get(model.id); deduped.set(model.id, existing ? mergeLiteLLMRichEndpointModels(existing, next) : next); } } if (deduped.size === 0 && excludedModelIds.size === 0) { return null; } const models = Array.from(deduped.values()).sort((left, right) => left.model.id.localeCompare(right.model.id)); return { models, excludedModelIds, incompleteVisionMetadata: models.some(entry => entry.supportsVision !== true && entry.supportsVision !== false), }; } async function fetchLiteLLMRichModelsInternal( options: FetchLiteLLMRichModelsOptions, ): Promise<{ models: ModelSpec[]; excludedModelIds: ReadonlySet } | null> { const managementBaseUrl = normalizeLiteLLMManagementBaseUrl(options.baseUrl); const runtimeBaseUrl = normalizeLiteLLMRuntimeBaseUrl(options.baseUrl); if (!managementBaseUrl || !runtimeBaseUrl) { return null; } const fetchModels = async ( signal?: AbortSignal, ): Promise<{ models: ModelSpec[]; excludedModelIds: ReadonlySet } | null> => { const deduped = new Map>(); const excludedModelIds = new Set(); let metadataFailure: LiteLLMRichEndpointFailure | undefined; for (const endpoint of LITELLM_RICH_ENDPOINTS) { const result = await fetchLiteLLMRichEndpoint(endpoint, options, managementBaseUrl, runtimeBaseUrl, signal); if (!result) { continue; } if ("failure" in result) { // A 401 is a retryable auth failure owned by the caller's auth-retry // path (withAuth refresh/sibling rotation in discoverLiteLLMModels); // recording it here would log a fallback warning on a stale first // credential before the refreshed retry ultimately serves rich // metadata. Forbidden (403) and other failures are never retried, so // they remain warn-worthy and keep priority. if ( result.failure.status !== 401 && (!metadataFailure || (metadataFailure.status !== 403 && result.failure.status === 403)) ) { metadataFailure = result.failure; } continue; } for (const modelId of result.excludedModelIds) { excludedModelIds.add(modelId); deduped.delete(modelId); } const hadPriorModels = deduped.size > 0; for (const next of result.models) { if (excludedModelIds.has(next.model.id)) { continue; } const existing = deduped.get(next.model.id); if (!existing) { if (!hadPriorModels) { deduped.set(next.model.id, next); } continue; } deduped.set(next.model.id, mergeLiteLLMRichEndpointModels(existing, next)); } if (deduped.size === 0) { continue; } let needsMoreMetadata = false; for (const entry of deduped.values()) { if ( (entry.supportsVision !== true && entry.supportsVision !== false) || (options.resolveApi !== undefined && (entry.apiRoute === "unknown" || !entry.hasMode)) || (Object.keys(entry.reportedCost).length > 0 && (entry.reportedCost.input === undefined || entry.reportedCost.output === undefined || entry.reportedCost.cacheRead === undefined || entry.reportedCost.cacheWrite === undefined)) ) { needsMoreMetadata = true; break; } } if (!needsMoreMetadata) { break; } } if (deduped.size === 0) { if (excludedModelIds.size > 0) { return { models: [], excludedModelIds }; } if (metadataFailure) { warnLiteLLMMetadataFallback(managementBaseUrl, metadataFailure); } return null; } return { models: Array.from(deduped.values()) .map(entry => entry.model) .sort((left, right) => left.id.localeCompare(right.id)), excludedModelIds, }; }; if (options.signal !== undefined) { return fetchModels(options.signal); } return options.timeoutMs !== undefined ? withOpenAICompatibleDiscoveryTimeout(options.timeoutMs, fetchModels) : fetchModels(); } export async function fetchLiteLLMRichModels( options: FetchLiteLLMRichModelsOptions, ): Promise[] | null> { return (await fetchLiteLLMRichModelsInternal(options))?.models ?? null; } export function litellmModelManagerOptions(config?: LiteLLMModelManagerConfig): ModelManagerOptions { const apiKey = config?.apiKey; const baseUrl = config?.baseUrl ?? getDefaultModelDiscoveryBaseUrl("litellm")!; return { providerId: "litellm", dynamicModelsAuthoritative: true, // Cache namespaces include the credential: virtual keys on one proxy // can see different model rosters. cacheProviderId: resolveModelCacheProviderId("litellm", { apiKey, baseUrl }), // LiteLLM's authenticated /v1/models is the selectable roster; management // endpoints only supply metadata for those exact ids. fetchDynamicModels: async () => { const modelsDevReferences = await loadModelsDevReferences(config?.fetch); const resolveReference = createReferenceResolver(modelsDevReferences); const scopedModels = await fetchOpenAICompatibleModels({ api: "openai-completions", provider: "litellm", baseUrl, apiKey, mapModel: (entry, defaults) => mapLiteLLMOpenAICompatibleModel(entry, defaults, resolveReference(defaults.id)), fetch: config?.fetch, }); if (scopedModels === null) { logger.warn("LiteLLM /v1/models unavailable; cannot determine the authorized model roster", { baseUrl }); return null; } if (scopedModels.length === 0) return []; const rich = await fetchLiteLLMRichModelsInternal({ api: "openai-completions", provider: "litellm", baseUrl, apiKey, fetch: config?.fetch, referenceResolver: resolveReference, resolveApi: resolveLiteLLMApi, timeoutMs: 10_000, }); const richById = new Map(rich?.models.map(model => [model.id, model])); const models = scopedModels .filter(model => !rich?.excludedModelIds.has(model.id)) .map(model => richById.get(model.id) ?? model); // Bare catalog names can label proxy namespaces, but their pricing, // limits and request routing must never enrich a different deployment. return models.map(model => { const name = toLiteLLMDisplayName(model.name, resolveReference(bareModelId(model.id))?.name, model.id); return name === model.name ? model : { ...model, name }; }); }, }; } // --------------------------------------------------------------------------- // 22. vLLM // --------------------------------------------------------------------------- const VLLM_DISCOVERY_TIMEOUT_MS = 10_000; export interface VllmModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function vllmModelManagerOptions(config?: VllmModelManagerConfig): ModelManagerOptions<"openai-completions"> { const apiKey = config?.apiKey; const baseUrl = config?.baseUrl ?? getDefaultModelDiscoveryBaseUrl("vllm")!; const references = createBundledReferenceMap<"openai-completions">("vllm" as Parameters[0]); return { providerId: "vllm", cacheProviderId: resolveModelCacheProviderId("vllm", { baseUrl }), fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-completions", provider: "vllm", baseUrl, apiKey, mapModel: (entry, defaults) => { const model = mapWithBundledReference(entry, defaults, references.get(defaults.id)); const identity = classifyModel("vllm", model.id, { lenient: true }); return { ...model, contextWindow: toPositiveNumber(entry.max_model_len, model.contextWindow), // vLLM's /v1/models reports no reasoning capability. Qwen 3.8+ // open weights always think (the template cannot disable it), so // light up the effort dial; buildModel derives the template // ladder from the id + local-backend compat. reasoning: model.reasoning || (identity.class === "qwen" && revisionAtLeast(identity.revision, "3.8")), }; }, fetch: config?.fetch, timeoutMs: VLLM_DISCOVERY_TIMEOUT_MS, }), }; } // --------------------------------------------------------------------------- // 23. NanoGPT // --------------------------------------------------------------------------- export interface NanoGptModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function nanoGptModelManagerOptions( config?: NanoGptModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { const apiKey = config?.apiKey; const baseUrl = config?.baseUrl ?? "https://nano-gpt.com/api/v1"; const resolveReference = createReferenceResolver(() => createBundledReferenceMap<"openai-completions">("nanogpt" as Parameters[0]), ); return { providerId: "nanogpt", ...(apiKey && { fetchDynamicModels: async () => { // Track base IDs that have :thinking variants so we can mark them reasoning-capable. const thinkingBaseIds = new Set(); const models = await fetchOpenAICompatibleModels({ api: "openai-completions", provider: "nanogpt", baseUrl, apiKey, // Only the detailed roster carries per-model pricing, names, and limits. query: { detailed: "true" }, mapModel: (entry, defaults) => { const reference = resolveReference(defaults.id); // The detailed roster names its output cap `max_output_tokens`. const maxOutputTokens = entry.max_output_tokens ?? entry.max_completion_tokens; const mapped = mapWithBundledReference( { ...entry, max_completion_tokens: maxOutputTokens }, defaults, reference, ); return { ...mapped, api: "openai-completions", provider: "nanogpt", cost: { ...mapped.cost, ...nanoGptPricingCost(entry.pricing, mapped.cost) }, }; }, filterModel: (_entry, model) => { const match = NANO_GPT_THINKING_SUFFIX_RE.exec(model.id); if (match) { thinkingBaseIds.add(model.id.slice(0, match.index)); return false; } return isLikelyNanoGptTextModelId(model.id); }, fetch: config?.fetch, }); if (!models) return null; // Mark base models as reasoning-capable when a :thinking variant existed. for (const model of models) { if (!model.reasoning && thinkingBaseIds.has(model.id)) { (model as { reasoning: boolean }).reasoning = true; } } return models; }, }), }; } // --------------------------------------------------------------------------- // 24. GitHub Copilot // --------------------------------------------------------------------------- export interface GithubCopilotModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } // Copilot ids whose cached route predates the Responses pin: a cache written by // an older build still says openai-completions, which Copilot answers with 400 // unsupported_api_for_model (#7096, #8807, #12901). const COPILOT_CACHE_INVALIDATED_MODEL_IDS = [ "gpt-6-astra", "gpt-6-astra-1m", "grok-4.5", "grok-4.5-1m", "grok-4.6", "grok-4.6-1m", "grok-4.7", "grok-4.7-1m", "mai-code-1-flash-picker", ]; function inferCopilotApi(modelId: string): Api { const route = apiRouteFor("github-copilot", modelId); if (route?.api === "anthropic-messages" || route?.api === "openai-responses") return route.api; return "openai-completions"; } function extractCopilotLimits(entry: OpenAICompatibleModelRecord): { maxPromptTokens?: number; maxContextWindowTokens?: number; maxOutputTokens?: number; maxNonStreamingOutputTokens?: number; } { if (!isRecord(entry.capabilities)) { return {}; } const limitsValue = entry.capabilities.limits; if (!isRecord(limitsValue)) { return {}; } return { maxPromptTokens: toNumber(limitsValue.max_prompt_tokens), maxContextWindowTokens: toNumber(limitsValue.max_context_window_tokens), maxOutputTokens: toNumber(limitsValue.max_output_tokens), maxNonStreamingOutputTokens: toNumber(limitsValue.max_non_streaming_output_tokens), }; } /** Local id/name suffixes for synthesized Copilot long-context variants. */ export const COPILOT_LONG_CONTEXT_ID_SUFFIX = "-1m"; const COPILOT_LONG_CONTEXT_NAME_SUFFIX = " (1M)"; /** One tier of Copilot token pricing (`billing.token_prices.{default,long_context}`). Prices are hundredths of a dollar per 1M tokens. */ interface CopilotTokenPriceTier { contextMax?: number; inputPrice?: number; outputPrice?: number; cachePrice?: number; } function parseCopilotTokenPriceTier(value: unknown): CopilotTokenPriceTier | undefined { if (!isRecord(value)) { return undefined; } return { contextMax: toNumber(value.max_prompt_tokens) ?? toNumber(value.context_max), inputPrice: toNumber(value.input_price), outputPrice: toNumber(value.output_price), cachePrice: toNumber(value.cache_price), }; } /** * Tiered context boundaries/prices from `billing.token_prices`. Served only * when discovery requests `X-GitHub-Api-Version` ≥ 2026-06-01; absent on the * legacy response shape (where `capabilities.limits` is already tier-capped). */ function extractCopilotTokenPrices(entry: OpenAICompatibleModelRecord): { defaultTier?: CopilotTokenPriceTier; longContext?: CopilotTokenPriceTier; } { if (!isRecord(entry.billing)) { return {}; } const tokenPrices = entry.billing.token_prices; if (!isRecord(tokenPrices)) { return {}; } return { defaultTier: parseCopilotTokenPriceTier(tokenPrices.default), longContext: parseCopilotTokenPriceTier(tokenPrices.long_context), }; } function extractCopilotSupportsVision(entry: OpenAICompatibleModelRecord): boolean | undefined { if (!isRecord(entry.capabilities)) { return undefined; } const supports = entry.capabilities.supports; if (!isRecord(supports)) { return undefined; } return toBoolean(supports.vision); } /** Copilot's `/models` mixes chat and embedding models; only `type: "chat"` entries are usable here. */ function isCopilotChatModel(entry: OpenAICompatibleModelRecord): boolean { if (!isRecord(entry.capabilities)) { return true; } const type = entry.capabilities.type; return typeof type !== "string" || type === "chat"; } function copilotTierCost( tier: CopilotTokenPriceTier | undefined, ): Omit["cost"], "cacheWrite"> | undefined { if (tier?.inputPrice === undefined || tier.outputPrice === undefined) { return undefined; } return { input: tier.inputPrice / 100, output: tier.outputPrice / 100, cacheRead: (tier.cachePrice ?? 0) / 100, }; } /** * Synthesize the opt-in long-context sibling for a Copilot model that reports * a `billing.token_prices.long_context` tier (e.g. Claude Opus 200k → 1M, as * selectable in copilot-cli). The variant is a local catalog entry: it keeps * the upstream model id on the wire via `requestModelId` — the tier is purely * a client-side context budget with its own pricing, not a served model id. * The base entry stays on the default tier so nobody silently pays * long-context rates. */ function createCopilotLongContextVariant( base: ModelSpec, fullContextWindow: number | null, maxTokens: number | null, longContext: CopilotTokenPriceTier | undefined, ): ModelSpec | undefined { const longContextMax = longContext?.contextMax; if (longContextMax === undefined || longContextMax <= 0 || fullContextWindow === null || maxTokens === null) { return undefined; } const variantWindow = Math.min(fullContextWindow, longContextMax + maxTokens); if (base.contextWindow === null || variantWindow <= base.contextWindow) { return undefined; } const longCost = copilotTierCost(longContext); return { ...base, id: `${base.id}${COPILOT_LONG_CONTEXT_ID_SUFFIX}`, requestModelId: base.id, name: `${base.name}${COPILOT_LONG_CONTEXT_NAME_SUFFIX}`, contextWindow: variantWindow, // Long-context tier has its own token prices (Gemini/GPT bill ~2x above // the default boundary). cacheWrite is not reported per tier; inherit. ...(longCost && { cost: { ...longCost, cacheWrite: base.cost.cacheWrite } }), contextPromotionTarget: undefined, }; } export function githubCopilotModelManagerOptions(config?: GithubCopilotModelManagerConfig): ModelManagerOptions { const rawApiKey = config?.apiKey; const configuredBaseUrl = config?.baseUrl ?? "https://api.githubcopilot.com"; const parsedApiKey = rawApiKey ? parseGitHubCopilotApiKey(rawApiKey) : undefined; const apiKey = parsedApiKey?.accessToken; const baseUrl = parsedApiKey?.apiEndpoint && configuredBaseUrl.includes("githubcopilot.com") ? parsedApiKey.apiEndpoint : parsedApiKey?.enterpriseUrl && configuredBaseUrl.includes("githubcopilot.com") ? getGitHubCopilotBaseUrl(parsedApiKey.enterpriseUrl) : configuredBaseUrl; let providerReferences: Map> | undefined; const getProviderReferences = () => (providerReferences ??= createBundledReferenceMap("github-copilot")); const resolveReference = createReferenceResolver(getProviderReferences); return { providerId: "github-copilot", cacheProviderId: resolveModelCacheProviderId("github-copilot", { apiKey: rawApiKey, baseUrl }), // Copilot discovery pre-applies the correct image fallback for omitted // `supports.vision`; the live row's modality is authoritative. dynamicInputAuthoritative: true, dropCachedModelIdsOnStaticMismatch: COPILOT_CACHE_INVALIDATED_MODEL_IDS, // COPILOT_API_HEADERS are compile-time wire identity constants, not // credentials. The cache omits all request headers for // safety and can only restore them from a bundled static entry — so a // Copilot model with no bundled reference (e.g. a freshly served // claude-opus-5 and its synthesized -1m sibling) is dropped on offline // reads. Declaring the constant lets the cache restore it by value. restorableHeaderFallback: { ...COPILOT_API_HEADERS }, ...(apiKey && { fetchDynamicModels: async () => { const fetchImpl = discoveryFetch(config?.fetch); const requestBaseUrl = isPersonalGitHubCopilotBaseUrl(baseUrl) ? ((await withOpenAICompatibleDiscoveryTimeout(DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, signal => discoverGitHubCopilotApiEndpoint(apiKey, fetchImpl, signal), )) ?? baseUrl) : baseUrl; const longContextVariants: ModelSpec[] = []; const models = await fetchOpenAICompatibleModels({ api: "openai-completions", provider: "github-copilot", baseUrl: requestBaseUrl, apiKey, headers: COPILOT_DISCOVERY_HEADERS, mapModel: ( entry: OpenAICompatibleModelRecord, defaults: ModelSpec, _context: OpenAICompatibleModelMapperContext, ): ModelSpec | null => { if (!isCopilotChatModel(entry)) { return null; } const reference = resolveReference(defaults.id); const copilotLimits = extractCopilotLimits(entry); // Copilot exposes token limits under capabilities.limits.*. // max_context_window_tokens is the model's total usable window; // max_prompt_tokens is Copilot's prompt/summarization budget and // must only be a fallback when total-window fields are absent. const contextWindow = toPositiveNumber( copilotLimits.maxContextWindowTokens, toPositiveNumber( entry.context_length, toPositiveNumber( copilotLimits.maxPromptTokens, reference?.contextWindow ?? defaults.contextWindow, ), ), ); const maxTokens = toPositiveNumber( copilotLimits.maxOutputTokens, toPositiveNumber( entry.max_completion_tokens, toPositiveNumber( copilotLimits.maxNonStreamingOutputTokens, reference?.maxTokens ?? defaults.maxTokens, ), ), ); const name = typeof entry.name === "string" && entry.name.trim().length > 0 ? entry.name : (reference?.name ?? defaults.name); const api = inferCopilotApi(defaults.id); const supportsVision = extractCopilotSupportsVision(entry); const input: ModelSpec["input"] = supportsVision === true ? ["text", "image"] : supportsVision === false || !isPersonalGitHubCopilotBaseUrl(requestBaseUrl) ? ["text"] : (reference?.input ?? defaults.input); // With COPILOT_API_HEADERS the served window is the long-context // ceiling; the default tier reports its prompt boundary in // token_prices.default.max_prompt_tokens (or legacy context_max). // Cap the base entry to the default tier — the long tier is // the opt-in `-1m` sibling below. On tiered legacy rows the // model-wide max_prompt_tokens may be tighter than the billed // default ceiling (#13912), so use the smaller bound. const tokenPrices = extractCopilotTokenPrices(entry); const billedDefaultMax = tokenPrices.defaultTier?.contextMax; const tieredPromptBudget = (tokenPrices.longContext?.contextMax ?? 0) > 0 ? (copilotLimits.maxPromptTokens ?? 0) : 0; const defaultContextMax = tieredPromptBudget > 0 && (billedDefaultMax === undefined || billedDefaultMax <= 0 || tieredPromptBudget < billedDefaultMax) ? tieredPromptBudget : billedDefaultMax; const defaultTierWindow = defaultContextMax !== undefined && defaultContextMax > 0 && contextWindow !== null && maxTokens !== null ? Math.min(contextWindow, defaultContextMax + maxTokens) : contextWindow; const unreferencedIdentity = reference === undefined && api === "anthropic-messages" ? classifyModel("github-copilot", defaults.id, { lenient: true }) : undefined; const base: ModelSpec = reference ? { ...reference, api, provider: "github-copilot", baseUrl: requestBaseUrl, name, input, contextWindow: defaultTierWindow, maxTokens, headers: mergeCopilotApiHeaders(getProviderReferences().get(defaults.id)?.headers), ...(api === "openai-completions" ? { compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false, }, } : // The bundled row for an id whose route later moved to // Responses/Messages still carries this chat-completions // block, and `supportsReasoningEffort: false` suppresses // the effort dial on a transport that supports it // (grok-4.7, #12901). Compat is transport-scoped: let the // rules resolve it for the route actually in use. { compat: undefined }), } : { ...defaults, api, baseUrl: requestBaseUrl, name, input, contextWindow: defaultTierWindow, maxTokens, headers: mergeCopilotApiHeaders(), // Copilot's `/models` advertises no reasoning bit, so a // thinking-capable Claude with no bundled reference would // fall back to `reasoning: false` and lose its effort dial. // Gate on the id classifier (not the transport alone) so a // lagging enterprise catalog serving a pre-thinking Claude // (<= 3.5) over the Messages proxy is not handed a fabricated // dial it would reject; a modern reference-less model (e.g. // claude-opus-5) is marked so `buildModel` derives the ladder. ...(unreferencedIdentity?.class === "anthropic" && revisionAtLeast(unreferencedIdentity.revision, "3.7") ? { reasoning: true } : {}), ...(api === "openai-completions" ? { compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false, }, } : {}), }; // Cross-provider fallback references (e.g. a Cursor // collapsed family for an enterprise-only sibling id) // carry provider-specific wire routing that must not // transfer: the off-tier `requestModelId` pin would send // every Copilot request under the `-none` sibling id // regardless of thinking level. if (reference && reference.provider !== "github-copilot") { delete base.requestModelId; if (base.thinking) { // `base` is a shallow copy of the shared global // reference: clone before deleting or the bundled // entry loses its routing process-wide. base.thinking = { ...base.thinking }; delete base.thinking.effortRouting; } } const defaultCost = copilotTierCost(tokenPrices.defaultTier); if (defaultCost) { // Cache writes are not reported per tier; retain the bundled provider rate. base.cost = { ...defaultCost, cacheWrite: base.cost.cacheWrite }; } const variant = createCopilotLongContextVariant( base, contextWindow, maxTokens, tokenPrices.longContext, ); if (variant) { longContextVariants.push(variant); // Overflowing the default tier promotes into the 1M sibling // unless the reference already pins a target. base.contextPromotionTarget ??= `github-copilot/${variant.id}`; } return base; }, fetch: fetchImpl, }); if (models === null) { return null; } // Append synthesized tiers; a real upstream id always wins over a // local variant with the same id. const takenIds = new Set(models.map(model => model.id)); for (const variant of longContextVariants) { if (takenIds.has(variant.id)) { continue; } takenIds.add(variant.id); models.push(variant); } return models.sort((left, right) => left.id.localeCompare(right.id)); }, }), }; } // --------------------------------------------------------------------------- // 24. Anthropic // --------------------------------------------------------------------------- export interface AnthropicModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } export function anthropicModelManagerOptions( config?: AnthropicModelManagerConfig, ): ModelManagerOptions<"anthropic-messages"> { const apiKey = config?.apiKey; // The registry derives `config.baseUrl` from an existing bundled model, and // bundled Anthropic rows use both `https://api.anthropic.com` and `.../v1`. // Discovery must always hit `/v1/models`, so the `/v1` suffix is enforced on // the discovery URL while model rows keep the provider base (#6563). const baseUrl = normalizeAnthropicBaseUrl(config?.baseUrl, ANTHROPIC_BASE_URL); const discoveryBaseUrl = toAnthropicDiscoveryBaseUrl(baseUrl); return { providerId: "anthropic", modelsDev: { fetch: () => fetchRevalidatedWellKnownModelsWithTimeout(config?.fetch), map: payload => mapAnthropicModelsDev(payload, baseUrl), }, ...(apiKey && { fetchDynamicModels: async () => { const modelsDevModels = await fetchWellKnownModels(config?.fetch) .then(payload => mapAnthropicModelsDev(payload, baseUrl)) .catch(() => []); const references = buildAnthropicReferenceMap(modelsDevModels); return ( fetchOpenAICompatibleModels({ api: "anthropic-messages", provider: "anthropic", baseUrl: discoveryBaseUrl, headers: buildAnthropicDiscoveryHeaders(apiKey, discoveryBaseUrl), mapModel: ( entry: OpenAICompatibleModelRecord, defaults: ModelSpec<"anthropic-messages">, _context: OpenAICompatibleModelMapperContext<"anthropic-messages">, ): ModelSpec<"anthropic-messages"> => { const discoveredName = typeof entry.display_name === "string" ? entry.display_name : defaults.name; const reference = references.get(defaults.id); if (!reference) { return { ...defaults, name: discoveredName, baseUrl, }; } return { ...reference, id: defaults.id, name: discoveredName, api: "anthropic-messages", provider: "anthropic", baseUrl, }; }, fetch: config?.fetch, }) ?? null ); }, }), }; } // --------------------------------------------------------------------------- // Models.dev provider descriptors for generate-models.ts // --------------------------------------------------------------------------- /** Describes how to map models.dev API data for a single provider. */ export interface ModelsDevProviderDescriptor { /** Key in the models.dev API response JSON (e.g., "anthropic", "amazon-bedrock") */ modelsDevKey: string; /** Provider ID in our system */ providerId: string; /** Default API type for this provider's models */ api: Api; /** Default base URL */ baseUrl: string; /** Default context window fallback (default: UNKNNOWN_CONTEXT_WINDOW) */ defaultContextWindow?: number; /** Default max tokens fallback (default: UNKNNOWN_MAX_TOKENS) */ defaultMaxTokens?: number; /** Optional compat overrides applied to every model from this provider */ compat?: ModelSpec["compat"]; /** Optional static headers applied to every model */ headers?: Record; /** * Optional filter: return false to skip a model. * Called with (modelId, rawModel). Default: skip if tool_call !== true. */ filterModel?: (modelId: string, model: ModelsDevModel) => boolean; /** * Optional transform: modify the mapped model before it's added. * Can return null to skip the model, or an array to emit multiple models. */ transformModel?: ( model: ModelSpec, modelId: string, raw: ModelsDevModel, ) => ModelSpec | ModelSpec[] | null; /** * Optional: override the API type per-model. * Called with (modelId, raw). Return the API type to use. * If not provided, uses the `api` field. */ resolveApi?: (modelId: string, raw: ModelsDevModel) => { api: Api; baseUrl: string } | null; } /** Generic mapper that converts models.dev data using provider descriptors. */ export function mapModelsDevToModels( data: Record, descriptors: readonly ModelsDevProviderDescriptor[], ): ModelSpec[] { const models: ModelSpec[] = []; const providers = providerEntries(); for (const desc of descriptors) { const providerData = data[desc.modelsDevKey]; if (!isRecord(providerData) || !isRecord(providerData.models)) continue; for (const modelId in providerData.models) { const rawModel = providerData.models[modelId]; if (!isRecord(rawModel)) continue; const m = rawModel as ModelsDevModel; const name = toModelName(m.name, modelId); let kind: ModelKind | undefined; let kindApi: Api | undefined; if (m.kind !== undefined) { const policy = resolveModelPolicy({ id: modelId, name, api: desc.api, provider: desc.providerId, baseUrl: desc.baseUrl, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: null, maxTokens: null, }); const normalizedKind = policy.catalog.kind ?? m.kind; if (typeof normalizedKind !== "string" || !MODEL_KINDS.some(value => value === normalizedKind)) continue; if (normalizedKind !== "chat") { const kindApiKind = KIND_API_KINDS.find(value => value === normalizedKind); if (kindApiKind === undefined) continue; kindApi = providers[desc.providerId]?.kindApis?.[kindApiKind]; if (kindApi === undefined) continue; kind = kindApiKind; } } if (kind === undefined) { // Ordinary chat rows retain the provider-specific/default tool filter. if (desc.filterModel) { if (!desc.filterModel(modelId, m)) continue; } else if (m.tool_call !== true) { continue; } } // Non-chat rows use the provider-authored runner API; chat rows retain // per-model API/base URL resolution (for example OpenCode route pins). let resolved: { api: Api; baseUrl: string } | null; if (kind === undefined) { resolved = desc.resolveApi?.(modelId, m) ?? { api: desc.api, baseUrl: desc.baseUrl }; } else { if (kindApi === undefined) continue; resolved = { api: kindApi, baseUrl: desc.baseUrl }; } if (!resolved) continue; const mapped: ModelSpec = { id: modelId, name, api: resolved.api, provider: desc.providerId, baseUrl: resolved.baseUrl, reasoning: kind === undefined && m.reasoning === true, input: toInputCapabilities(m.modalities?.input), cost: { input: toNumber(m.cost?.input) ?? 0, output: toNumber(m.cost?.output) ?? 0, cacheRead: toNumber(m.cost?.cache_read) ?? 0, cacheWrite: toNumber(m.cost?.cache_write) ?? 0, }, contextWindow: toPositiveNumber(m.limit?.context, desc.defaultContextWindow ?? null), maxTokens: toPositiveNumber(m.limit?.output, desc.defaultMaxTokens ?? null), ...(kind !== undefined ? { kind, supportsTools: false } : {}), ...(m.int != null ? { int: m.int } : {}), ...(m.tps != null ? { tps: m.tps } : {}), ...(kind === undefined && m.tool_call === false ? { supportsTools: false } : {}), ...(desc.compat && { compat: desc.compat }), ...(desc.headers && { headers: { ...desc.headers } }), }; // Provider transforms are chat-specific. Normalized non-chat rows are // complete once their authored runner API has been assigned. if (kind !== undefined) { models.push(mapped); } else if (desc.transformModel) { const result = desc.transformModel(mapped, modelId, m); if (result === null) continue; if (Array.isArray(result)) { models.push(...result); } else { models.push(result); } } else { models.push(mapped); } } } return models; } // Bedrock cross-region prefix helpers const BEDROCK_GLOBAL_PREFIXES = [ "anthropic.claude-fable-5", "anthropic.claude-mythos-5", "anthropic.claude-haiku-4-5", "anthropic.claude-sonnet-4", "anthropic.claude-opus-4-5", "amazon.nova-2-lite", "cohere.embed-v4", "twelvelabs.pegasus-1-2", ]; const BEDROCK_US_PREFIXES = [ "amazon.nova-lite", "amazon.nova-micro", "amazon.nova-premier", "amazon.nova-pro", "anthropic.claude-3-7-sonnet", "anthropic.claude-opus-4-1", "anthropic.claude-opus-4-20250514", "deepseek.r1", "meta.llama3-2", "meta.llama3-3", "meta.llama4", ]; function bedrockCrossRegionId(id: string): string { if (BEDROCK_GLOBAL_PREFIXES.some(p => id.startsWith(p))) return `global.${id}`; if (BEDROCK_US_PREFIXES.some(p => id.startsWith(p))) return `us.${id}`; return id; } interface ApiResolutionRule { matches: (modelId: string, raw: ModelsDevModel) => boolean; resolved: { api: Api; baseUrl: string }; } function resolveApiByRules( modelId: string, raw: ModelsDevModel, rules: readonly ApiResolutionRule[], fallback: { api: Api; baseUrl: string }, ): { api: Api; baseUrl: string } { for (const rule of rules) { if (rule.matches(modelId, raw)) return rule.resolved; } return fallback; } function createOpenCodeApiResolution( basePath: string, providerId: "opencode-zen" | "opencode-go", ): { defaultResolution: { api: Api; baseUrl: string }; rules: ApiResolutionRule[]; } { const completionsBaseUrl = `${basePath}/v1`; const baseUrlForApi = (api: Api): string => openCodeBaseUrlForApi(api, basePath); // Per-id route pins live in the `api-routes` behavior rules. const overrideRules: ApiResolutionRule[] = ( ["openai-completions", "openai-responses", "anthropic-messages", "google-generative-ai"] as const ).map(api => ({ matches: modelId => apiRouteFor(providerId, modelId)?.api === api, resolved: { api, baseUrl: baseUrlForApi(api) }, })); return { defaultResolution: { api: "openai-completions", baseUrl: completionsBaseUrl }, rules: [ // Per-id overrides take precedence over npm-based heuristics so we can // correct upstream metadata mismatches (see OPENCODE_GO_API_RESOLUTION). ...overrideRules, { matches: (_modelId, raw) => raw.provider?.npm === "@ai-sdk/openai", resolved: { api: "openai-responses", baseUrl: completionsBaseUrl }, }, { matches: (_modelId, raw) => raw.provider?.npm === "@ai-sdk/anthropic", resolved: { api: "anthropic-messages", baseUrl: basePath }, }, { matches: (_modelId, raw) => raw.provider?.npm === "@ai-sdk/google", resolved: { api: "google-generative-ai", baseUrl: completionsBaseUrl }, }, ], }; } // Resolver rules for the models.dev descriptor path; the per-id pins live in // the `api-routes` behavior rules, which also drive the live-discovery mapper. const OPENCODE_ZEN_API_RESOLUTION = createOpenCodeApiResolution(OPENCODE_ZEN_BASE_PATH, "opencode-zen"); const OPENCODE_GO_BASE_PATH = "https://opencode.ai/inference/go"; const OPENCODE_GO_API_RESOLUTION = createOpenCodeApiResolution(OPENCODE_GO_BASE_PATH, "opencode-go"); /** * OpenCode Go endpoint for one request API. The model generator re-bases * previous-snapshot rows with it so retained rows follow endpoint moves. */ export function openCodeGoBaseUrlForApi(api: Api): string { return openCodeBaseUrlForApi(api, OPENCODE_GO_BASE_PATH); } const COPILOT_BASE_URL = "https://api.githubcopilot.com"; const ZAI_ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic"; // The `zai` catalog provider is the GLM Coding Plan: `/login zai` validates and // stores credentials against the coding-plan endpoint (see auth/zai.kdl), so // the native OpenAI transport must ride the coding-plan base rather than the // general PAYG `/api/paas/v4`, which would bypass plan quota or fail auth. const ZAI_OPENAI_BASE_URL = "https://api.z.ai/api/coding/paas/v4"; /** Resolves the transport and endpoint for one Z.AI model catalog entry. */ export function resolveZaiApi(modelId: string): { api: "anthropic-messages" | "openai-completions"; baseUrl: string } { const api = apiRouteFor("zai", modelId)?.api ?? "anthropic-messages"; switch (api) { case "anthropic-messages": return { api, baseUrl: ZAI_ANTHROPIC_BASE_URL }; case "openai-completions": return { api, baseUrl: ZAI_OPENAI_BASE_URL }; default: throw new Error(`Unsupported Z.AI API route: ${api}`); } } const COPILOT_DEFAULT_RESOLUTION = { api: "openai-completions", baseUrl: COPILOT_BASE_URL, } as const satisfies { api: Api; baseUrl: string }; const COPILOT_API_RESOLUTION_RULES: readonly ApiResolutionRule[] = [ { matches: modelId => apiRouteFor("github-copilot", modelId)?.api === "anthropic-messages", resolved: { api: "anthropic-messages", baseUrl: COPILOT_BASE_URL }, }, { matches: modelId => apiRouteFor("github-copilot", modelId)?.api === "openai-responses", resolved: { api: "openai-responses", baseUrl: COPILOT_BASE_URL }, }, ]; function simpleModelsDevDescriptor( modelsDevKey: string, providerId: string, api: Api, baseUrl: string, options: Omit = {}, ): ModelsDevProviderDescriptor { return { modelsDevKey, providerId, api, baseUrl, ...options, }; } function openAiCompletionsDescriptor( modelsDevKey: string, providerId: string, baseUrl: string, options: Omit = {}, ): ModelsDevProviderDescriptor { return simpleModelsDevDescriptor(modelsDevKey, providerId, "openai-completions", baseUrl, options); } function openAiResponsesDescriptor( modelsDevKey: string, providerId: string, baseUrl: string, options: Omit = {}, ): ModelsDevProviderDescriptor { return simpleModelsDevDescriptor(modelsDevKey, providerId, "openai-responses", baseUrl, options); } function anthropicMessagesDescriptor( modelsDevKey: string, providerId: string, baseUrl: string, options: Omit = {}, ): ModelsDevProviderDescriptor { return simpleModelsDevDescriptor(modelsDevKey, providerId, "anthropic-messages", baseUrl, options); } const GOOGLE_VERTEX_BASE_URL = "https://{location}-aiplatform.googleapis.com"; const GOOGLE_VERTEX_OPENAI_BASE_URL = "https://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/endpoints/openapi"; const GOOGLE_VERTEX_ANTHROPIC_BASE_URL = "https://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/publishers/anthropic/models/{model}:streamRawPredict"; function resolveGoogleVertexApi(modelId: string, raw: ModelsDevModel): { api: Api; baseUrl: string } { if (raw.provider?.npm === "@ai-sdk/google-vertex/anthropic") { return { api: "anthropic-messages", baseUrl: GOOGLE_VERTEX_ANTHROPIC_BASE_URL.replace("{model}", modelId), }; } if (modelId.includes("/") || raw.provider?.npm === "@ai-sdk/openai-compatible") { return { api: "openai-completions", baseUrl: GOOGLE_VERTEX_OPENAI_BASE_URL }; } return { api: "google-vertex", baseUrl: GOOGLE_VERTEX_BASE_URL }; } const MODELS_DEV_PROVIDER_DESCRIPTORS_BEDROCK: readonly ModelsDevProviderDescriptor[] = [ // --- Amazon Bedrock --- { modelsDevKey: "amazon-bedrock", providerId: "amazon-bedrock", api: "bedrock-converse-stream", baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", filterModel: (id, m) => { // Undrivable SKUs are rule-owned (`runtime/behavior.kdl` exclude-models). return m.tool_call === true && !isExcludedModel("amazon-bedrock", id); }, transformModel: (model, modelId, m) => { const crossRegionId = bedrockCrossRegionId(modelId); const bedrockModel: ModelSpec = { ...model, id: crossRegionId, name: toModelName(m.name, crossRegionId), }; // Also emit EU and AWS GovCloud (`us-gov.`) geo inference-profile // variants for Claude models. GovCloud accounts list system profiles // under the `us-gov.` prefix (e.g. us-gov.anthropic.claude-sonnet-4-5-…); // without these rows the catalog only has commercial geos (`us.`/`eu.`/…) // and model resolution rejects the GovCloud id (or misroutes commercial // geos onto us-east-1 with GovCloud credentials → 403). // Fan out only bare vendor-namespaced source ids (`anthropic.`); // ids already carrying a geo prefix (`us.`/`eu.`/`global.`/`au.`…) // are this fan-out's own output shape. if (modelId.split(".", 1)[0] === "anthropic") { const displayName = toModelName(m.name, modelId); return [ bedrockModel, { ...bedrockModel, id: `eu.${modelId}`, name: `${displayName} (EU)`, }, { ...bedrockModel, id: `us-gov.${modelId}`, name: `${displayName} (GovCloud)`, }, ]; } return bedrockModel; }, }, ]; const CLINEPASS_REASONING_BUDGET_RATIOS: Readonly> = { [Effort.Minimal]: 0.1, [Effort.Low]: 0.2, [Effort.Medium]: 0.5, [Effort.High]: 0.8, [Effort.XHigh]: 0.95, [Effort.Max]: 1, }; function buildClinePassThinking(raw: ModelsDevModel, model: ModelSpec): ThinkingConfig | undefined { const effortValues = raw.reasoning_options?.find(option => option.type === "effort")?.values; if (effortValues) { const efforts = THINKING_EFFORTS.filter(effort => effortValues.includes(effort)); return efforts.length > 0 ? { mode: "effort", efforts } : undefined; } const budget = raw.reasoning_options?.find(option => option.type === "budget_tokens"); if (!budget) return undefined; const modelMaxTokens = model.maxTokens ?? CLINEPASS_FALLBACK_MAX_TOKENS; const maximum = toPositiveNumber(budget.max, modelMaxTokens) ?? modelMaxTokens; const minimum = toPositiveNumber(budget.min, 1) ?? 1; const scale = Math.min(modelMaxTokens, maximum); return { mode: "budget", efforts: THINKING_EFFORTS, effortBudgets: Object.fromEntries( THINKING_EFFORTS.map(effort => [ effort, Math.min(Math.max(Math.floor(scale * CLINEPASS_REASONING_BUDGET_RATIOS[effort]), minimum), maximum), ]), ), }; } /** * DeepSeek's V4-generation lineage (`v4`, `v4-flash`, `v4.1-pro`) by * structured family. The native roster serves only V4-era SKUs today; the * `tool_call` gate on the descriptor filters the rest. */ function isDeepseekV4Generation(providerId: string, modelId: string): boolean { const identity = classifyModel(providerId, modelId, { lenient: true }); if (identity.class !== "deepseek") return false; return identity.family === "v4" || identity.family === "flash" || identity.family === "pro"; } const MODELS_DEV_PROVIDER_DESCRIPTORS_CORE: readonly ModelsDevProviderDescriptor[] = [ // --- Anthropic --- anthropicMessagesDescriptor("anthropic", "anthropic", "https://api.anthropic.com", { filterModel: (id, m) => { if (m.tool_call !== true) return false; return !isExcludedModel("anthropic", id); }, }), // --- Google --- simpleModelsDevDescriptor( "google", "google", "google-generative-ai", "https://generativelanguage.googleapis.com/v1beta", ), // --- OpenAI --- simpleModelsDevDescriptor("openai", "openai", "openai-responses", "https://api.openai.com/v1"), // --- Groq --- openAiCompletionsDescriptor("groq", "groq", "https://api.groq.com/openai/v1"), // --- Cerebras --- openAiCompletionsDescriptor("cerebras", "cerebras", "https://api.cerebras.ai/v1"), // --- ClinePass --- // The live roster owns membership; this snapshot pins Cline's exact limits, // pricing, modalities, and per-model reasoning controls for known ids. openAiCompletionsDescriptor("cline-pass", "cline-pass", "https://api.cline.bot/api/v1", { transformModel: (model, modelId, raw) => { const id = toClinePassPublicModelId(modelId); const metadata = getClinePassModelMetadata(id); if (metadata) { return { ...model, id, name: metadata.name, contextWindow: metadata.contextWindow, maxTokens: metadata.maxTokens, input: metadata.input, cost: metadata.cost, reasoning: metadata.reasoning, thinking: metadata.thinking, compat: metadata.thinking === undefined ? { ...model.compat, supportsReasoningEffort: false } : model.compat, }; } return { ...model, id, name: id, thinking: model.reasoning ? buildClinePassThinking(raw, model) : undefined, }; }, }), // --- Together --- openAiCompletionsDescriptor("togetherai", "together", "https://api.together.xyz/v1"), // --- CoreWeave Serverless Inference --- openAiCompletionsDescriptor("wandb", "coreweave", "https://api.inference.wandb.ai/v1", { transformModel: model => { // CoreWeave's roster omits the reasoning bit for gpt-oss; flag it and // let the class rules own the effort ladder. if (classifyModel("coreweave", model.id, { lenient: true }).class !== "gpt-oss") { return model; } return { ...model, reasoning: true }; }, }), // --- NVIDIA --- openAiCompletionsDescriptor("nvidia", "nvidia", "https://integrate.api.nvidia.com/v1", { defaultContextWindow: 131072, }), // --- xAI --- openAiResponsesDescriptor("xai", "xai", "https://api.x.ai/v1", { transformModel: model => applyXaiResponsesThinkingPolicy(model as ModelSpec<"openai-responses">), }), // --- DeepSeek --- openAiCompletionsDescriptor("deepseek", "deepseek", "https://api.deepseek.com", { // Only ship the v4 family as built-ins; older deepseek-chat / deepseek-reasoner // ids are kept off the catalog until the issue thread asks for them. filterModel: (id, m) => m.tool_call === true && isDeepseekV4Generation("deepseek", id), compat: { // DeepSeek V4 effort remapping is derived in model-thinking metadata; this // descriptor keeps only transport-shape compat. supportsDeveloperRole: false, supportsReasoningEffort: true, maxTokensField: "max_tokens", // DeepSeek V4 thinking mode rejects the `tool_choice` control parameter. // Tool calls still work without it; the API defaults to auto when tools exist. supportsToolChoice: false, // DeepSeek V4's OpenAI format docs enable thinking with both the toggle and // reasoning_effort. Keep the toggle explicit for built-in models. extraBody: { thinking: { type: "enabled" } }, // DeepSeek emits chain-of-thought via `reasoning_content` and requires it // to round-trip on assistant tool-call messages so the model can resume // from prior thinking (interleaved.field=reasoning_content on models.dev, // matches the kimi/openrouter handling already in detectCompat). reasoningContentField: "reasoning_content", requiresReasoningContentForToolCalls: true, requiresAssistantContentForToolCalls: true, }, }), ]; const MODELS_DEV_PROVIDER_DESCRIPTORS_CODING_PLANS: readonly ModelsDevProviderDescriptor[] = [ // --- zAI --- // Source the models.dev `zai` (pay-as-you-go) key rather than `zai-coding-plan`: // the coding-plan key reports all-$0 subscription rates, which surface every GLM // SKU as "Free" in `/models`. The PAYG key carries the real per-token rates for // the identical model ids, so the enumerated token costs line up with the other // subscription providers for comparison (issue #5598). anthropicMessagesDescriptor("zai", "zai", ZAI_ANTHROPIC_BASE_URL, { resolveApi: modelId => resolveZaiApi(modelId), }), // --- Umans AI --- // Source the pay-as-you-go catalog: the coding-plan key publishes subscription // costs as zero, while `/models/info` omits pricing entirely. The generator // overlays these rates onto the authoritative endpoint discovery (issue #5733). anthropicMessagesDescriptor("umans-ai", "umans", UMANS_BASE_URL), // --- Xiaomi --- openAiCompletionsDescriptor("xiaomi", "xiaomi", "https://api.xiaomimimo.com/v1", { defaultContextWindow: 262144, defaultMaxTokens: 8192, compat: { supportsStore: false, thinkingFormat: "zai", reasoningContentField: "reasoning_content", requiresReasoningContentForToolCalls: true, allowsSyntheticReasoningContentForToolCalls: false, }, }), // --- MiniMax Token Plan --- // MiniMax documents its Anthropic-compatible API as the recommended // protocol: signed thinking blocks, `output_config.effort` depth control, // and prompt-cache usage reporting. anthropicMessagesDescriptor("minimax-coding-plan", "minimax-code", "https://api.minimax.io/anthropic"), anthropicMessagesDescriptor("minimax-cn-coding-plan", "minimax-code-cn", "https://api.minimaxi.com/anthropic"), // --- Alibaba Coding Plan --- openAiCompletionsDescriptor( "alibaba-coding-plan", "alibaba-coding-plan", "https://coding-intl.dashscope.aliyuncs.com/v1", { compat: { supportsDeveloperRole: false, }, }, ), // --- Zhipu Coding Plan --- openAiCompletionsDescriptor( "zhipuai-coding-plan", "zhipu-coding-plan", "https://open.bigmodel.cn/api/coding/paas/v4", { compat: { thinkingFormat: "zai", reasoningContentField: "reasoning_content", supportsDeveloperRole: false, }, }, ), ]; const filterActiveToolCallModels = (_id: string, m: ModelsDevModel): boolean => { if (m.tool_call !== true) return false; if (m.status === "deprecated") return false; return true; }; const MODELS_DEV_PROVIDER_DESCRIPTORS_GOOGLE_VERTEX: readonly ModelsDevProviderDescriptor[] = [ simpleModelsDevDescriptor("google-vertex", "google-vertex", "google-vertex", GOOGLE_VERTEX_BASE_URL, { filterModel: filterActiveToolCallModels, resolveApi: resolveGoogleVertexApi, }), ]; const OPENCODE_MODELS_DEV_DESCRIPTORS: readonly ModelsDevProviderDescriptor[] = [ openAiCompletionsDescriptor("opencode", "opencode-zen", "https://opencode.ai/zen/v1", { filterModel: filterActiveToolCallModels, resolveApi: (modelId, raw) => resolveApiByRules( modelId, raw, OPENCODE_ZEN_API_RESOLUTION.rules, OPENCODE_ZEN_API_RESOLUTION.defaultResolution, ), }), openAiCompletionsDescriptor("opencode-go", "opencode-go", `${OPENCODE_GO_BASE_PATH}/v1`, { filterModel: filterActiveToolCallModels, resolveApi: (modelId, raw) => resolveApiByRules( modelId, raw, OPENCODE_GO_API_RESOLUTION.rules, OPENCODE_GO_API_RESOLUTION.defaultResolution, ), }), ]; const MODELS_DEV_PROVIDER_DESCRIPTORS_SPECIALIZED: readonly ModelsDevProviderDescriptor[] = [ // --- Azure OpenAI --- // OpenAI-family models hosted on Azure, served via the Responses API. baseUrl // is empty: the deployment host is per-resource and resolved at runtime from // AZURE_OPENAI_BASE_URL / AZURE_OPENAI_RESOURCE_NAME (see resolveAzureConfig). simpleModelsDevDescriptor("azure", "azure", "azure-openai-responses", "", { filterModel: (modelId, m) => { if (m.tool_call !== true) return false; // OpenAI-family only (not Foundry/DeepSeek/Claude/Llama/Mistral/Phi, which // Azure serves via non-Responses APIs under a per-model provider override). return /^(gpt-|o1|o3|o4|codex|chatgpt)/.test(modelId); }, }), // --- Cloudflare AI Gateway --- anthropicMessagesDescriptor( "cloudflare-ai-gateway", "cloudflare-ai-gateway", "https://gateway.ai.cloudflare.com/v1///anthropic", ), openAiCompletionsDescriptor( "cloudflare-workers-ai", "cloudflare-ai-gateway", CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL, { filterModel: filterActiveToolCallModels, transformModel: model => ({ ...model, id: `workers-ai/${model.id}` }), }, ), // --- Mistral --- openAiCompletionsDescriptor("mistral", "mistral", "https://api.mistral.ai/v1"), // --- OpenCode Zen / Go --- ...OPENCODE_MODELS_DEV_DESCRIPTORS, // --- GitHub Copilot --- openAiCompletionsDescriptor("github-copilot", "github-copilot", COPILOT_BASE_URL, { defaultContextWindow: 128000, defaultMaxTokens: 8192, headers: { ...COPILOT_API_HEADERS }, filterModel: filterActiveToolCallModels, resolveApi: (modelId, raw) => resolveApiByRules(modelId, raw, COPILOT_API_RESOLUTION_RULES, COPILOT_DEFAULT_RESOLUTION), transformModel: model => { // compat only applies to openai-completions models if (model.api === "openai-completions") { return { ...model, compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false, }, }; } return model; }, }), // --- MiniMax (Anthropic) --- anthropicMessagesDescriptor("minimax", "minimax", "https://api.minimax.io/anthropic"), anthropicMessagesDescriptor("minimax-cn", "minimax-cn", "https://api.minimaxi.com/anthropic"), // --- Hugging Face --- openAiCompletionsDescriptor("huggingface", "huggingface", "https://router.huggingface.co/v1"), // --- Kilo Gateway --- openAiCompletionsDescriptor("kilo", "kilo", "https://api.kilo.ai/api/gateway"), // --- Moonshot AI --- openAiCompletionsDescriptor("moonshotai", "moonshot", "https://api.moonshot.ai/v1"), // --- NanoGPT --- openAiCompletionsDescriptor("nano-gpt", "nanogpt", "https://nano-gpt.com/api/v1"), // --- Synthetic --- openAiCompletionsDescriptor("synthetic", "synthetic", "https://api.synthetic.new/openai/v1"), // --- Venice AI --- openAiCompletionsDescriptor("venice", "venice", "https://api.venice.ai/api/v1"), // --- Ollama Cloud --- simpleModelsDevDescriptor("ollama-cloud", "ollama-cloud", "ollama-chat", "https://ollama.com"), // --- Xiaomi Token Plan --- openAiCompletionsDescriptor( "xiaomi-token-plan-ams", "xiaomi-token-plan-ams", "https://token-plan-ams.xiaomimimo.com/v1", ), openAiCompletionsDescriptor( "xiaomi-token-plan-cn", "xiaomi-token-plan-cn", "https://token-plan-cn.xiaomimimo.com/v1", ), openAiCompletionsDescriptor( "xiaomi-token-plan-sgp", "xiaomi-token-plan-sgp", "https://token-plan-sgp.xiaomimimo.com/v1", ), // --- Qwen Portal --- openAiCompletionsDescriptor("qwen-portal", "qwen-portal", "https://portal.qwen.ai/v1", { defaultContextWindow: 128000, defaultMaxTokens: 8192, }), // --- ZenMux --- openAiCompletionsDescriptor("zenmux", "zenmux", ZENMUX_OPENAI_BASE_URL, { filterModel: filterActiveToolCallModels, resolveApi: modelId => { if (apiRouteFor("zenmux", modelId)?.api === "anthropic-messages") { return { api: "anthropic-messages" as const, baseUrl: ZENMUX_ANTHROPIC_BASE_URL }; } return { api: "openai-completions" as const, baseUrl: ZENMUX_OPENAI_BASE_URL }; }, }), // --- Modular --- openAiCompletionsDescriptor("modular", "modular", "https://api.modular.com/v1"), ]; /** All provider descriptors for models.dev data mapping in generate-models.ts. */ export const MODELS_DEV_PROVIDER_DESCRIPTORS: readonly ModelsDevProviderDescriptor[] = [ ...MODELS_DEV_PROVIDER_DESCRIPTORS_BEDROCK, ...MODELS_DEV_PROVIDER_DESCRIPTORS_GOOGLE_VERTEX, ...MODELS_DEV_PROVIDER_DESCRIPTORS_CORE, ...MODELS_DEV_PROVIDER_DESCRIPTORS_CODING_PLANS, ...MODELS_DEV_PROVIDER_DESCRIPTORS_SPECIALIZED, ]; const MODELS_DEV_DESCRIPTORS_BY_PROVIDER: Record = Object.create(null); for (const descriptor of MODELS_DEV_PROVIDER_DESCRIPTORS) { const providerDescriptors = MODELS_DEV_DESCRIPTORS_BY_PROVIDER[descriptor.providerId]; if (providerDescriptors) { providerDescriptors.push(descriptor); } else { MODELS_DEV_DESCRIPTORS_BY_PROVIDER[descriptor.providerId] = [descriptor]; } } /** Providers whose bundled catalog can receive additive models.dev updates at runtime. */ export const MODELS_DEV_CATALOG_PROVIDER_IDS: readonly string[] = Object.freeze( Object.keys(MODELS_DEV_DESCRIPTORS_BY_PROVIDER), ); /** * Build the shared models.dev fallback for one known provider. * * Provider managers sharing one fetch implementation reuse its conditional * catalog session. Each mapped provider slice is persisted independently so * startup can restore it without parsing the full catalog. * * `timeoutMs` bounds the catalog request. It is configurable for callers with a * stricter startup budget and for deterministic timeout tests. */ export function modelsDevCatalogFallback( providerId: string, fetchImpl?: FetchImpl, timeoutMs = DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS, ): ModelsDevFallback | undefined { const descriptors = MODELS_DEV_DESCRIPTORS_BY_PROVIDER[providerId]; if (!descriptors) return undefined; return { additiveOnly: true, fetch: () => fetchRevalidatedWellKnownModelsWithTimeout(fetchImpl, timeoutMs), map: payload => (isRecord(payload) ? filterModelsDevCatalogRows(mapModelsDevToModels(payload, descriptors)) : []), }; } // --------------------------------------------------------------------------- // Command Code // --------------------------------------------------------------------------- /** * Configuration for the Command Code Provider API model manager. * * `baseUrl` overrides the Provider API base path for testing; it is * normalized to the shared `/provider` root (a trailing `/v1` is stripped) * so Claude ids route to the Anthropic-compatible Messages endpoint at the * root, the ten GPT ids listed in the `api-routes` table to the Responses * endpoint under `/v1`, and every other id to chat completions under `/v1`. */ export interface CommandCodeModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } const COMMAND_CODE_PROVIDER_BASE_PATH = "https://api.commandcode.ai/provider"; function normalizeCommandCodeBasePath(baseUrl: string | undefined): string { const normalized = (baseUrl ?? COMMAND_CODE_PROVIDER_BASE_PATH).trim().replace(/\/+$/, ""); return normalized.endsWith("/v1") ? normalized.slice(0, -3) : normalized; } /** * Builds the Command Code model manager: a mixed-protocol OpenAI-compatible * discovery client. The public `/v1/models` catalog is fetched once per * options instance; `mapModel` pins each row's transport from the * `api-routes` table (Claude ids to `anthropic-messages`, the listed GPT ids * to `openai-responses`, everything else to `openai-completions`) and seeds * neutral capability defaults. Reviewed Command Code policy (effort ladders, * pricing, limits, modalities) is applied later by `buildModel` from * `providers/commandcode.kdl` — the mapper never inherits another provider's * reasoning, rates, image support, or context window. A successful fetch also * appends the KDL `seed` rows (typesafe/jev), rebased onto the configured * base, because `dynamicModelsAuthoritative` would prune `staticModels`. */ export function commandCodeModelManagerOptions(config?: CommandCodeModelManagerConfig): ModelManagerOptions { const basePath = normalizeCommandCodeBasePath(config?.baseUrl); const discoveryBaseUrl = `${basePath}/v1`; return { providerId: "commandcode", cacheProviderId: resolveModelCacheProviderId("commandcode", { apiKey: config?.apiKey, baseUrl: discoveryBaseUrl, }), dynamicModelsAuthoritative: true, fetchDynamicModels: async () => { const discovered = await fetchOpenAICompatibleModels({ api: "openai-completions", provider: "commandcode", baseUrl: discoveryBaseUrl, // The catalog endpoint is public, but forward the key when the // caller has one so entitled rows resolve identically to // inference. The helper only sends Authorization when set. apiKey: config?.apiKey, mapModel: (entry, defaults) => { const route = apiRouteFor("commandcode", defaults.id)?.api; const api = route === "anthropic-messages" || route === "openai-responses" ? route : "openai-completions"; return { ...defaults, name: toModelName(entry.name, defaults.name), api, baseUrl: api === "anthropic-messages" ? basePath : discoveryBaseUrl, // Neutral reasoning: the catalog row carries no // reasoning metadata. Verified effort ids opt back in // through exact `thinking-efforts` in KDL (the cascade // upgrades the target and the engine materializes // `reasoning: true` alongside the ladder); every other // served id keeps no effort dial. reasoning: defaults.reasoning, // Keep the discovery default (`["text"]`): the catalog // row carries no modality metadata and a bundled // reference from another host must not advertise image // support for this deployment. Verified image routes // opt back in via `input-modalities` in KDL. input: defaults.input, // Neutral context window: an omitted or invalid // `context_length` retains an unknown limit (`null`) // instead of copying another host's deployment limit. // Verified corrections live in KDL (`limits-patch`, // `model-limits`, `context-window-floor`). contextWindow: toPositiveNumber(entry.context_length, null), maxTokens: null, }; }, fetch: config?.fetch, }); if (!discovered) return null; const seeds = seedModels("commandcode").map(seed => ({ ...seed, baseUrl: basePath })); const seedIds = new Set(seeds.map(seed => seed.id)); return [...discovered.filter(model => !seedIds.has(model.id)), ...seeds]; }, }; } // --------------------------------------------------------------------------- // Charm Hyper // --------------------------------------------------------------------------- /** * Configuration for the Charm Hyper model manager. * * `baseUrl` overrides the gateway root for tests and self-hosted proxies; a * value that omits the `/v1` surface gains one, so a host-only override * behaves like every sibling provider's. */ export interface CharmHyperModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } /** * Charm Hyper's `/v1/models` row shape, verified live against * hyper.charm.land (2026-09-11). The gateway is unusually complete: every row * carries its own display name, context window, output cap, vision flag, * accepted `reasoning_effort` vocabulary and per-million-token tariff, so * discovery reads capabilities straight off the wire instead of borrowing a * bundled reference from another host. Rows are served publicly — the * endpoint answers 200 with no credentials at all. */ interface CharmHyperModelRecord extends OpenAICompatibleModelRecord { display_name?: unknown; context_window?: unknown; max_output_tokens?: unknown; capabilities?: unknown; reasoning?: unknown; pricing?: unknown; } /** * Hyper's thinking-off wire tier. It is a disable state, not a rung: sending * `reasoning_effort: "none"` suppresses reasoning outright (verified — zero * reasoning tokens), while `minimal` still thinks. Advertising models * therefore keep their whole ladder and route the off switch through * `reasoningDisableMode`, the same split first-party GPT-5.6 uses. Efforts the * gateway does not advertise are accepted and silently ignored rather than * rejected, so the advertised vocabulary is the only reliable ladder. */ const CHARM_HYPER_WIRE_EFFORT_NONE = "none"; /** Distinct `reasoning.effort_levels[].value` strings advertised for a row. */ function charmHyperWireEfforts(reasoning: unknown): readonly string[] { if (!isRecord(reasoning) || !Array.isArray(reasoning.effort_levels)) return []; const values: string[] = []; for (const level of reasoning.effort_levels) { const value = isRecord(level) ? level.value : undefined; if (typeof value === "string" && !values.includes(value)) values.push(value); } return values; } /** * Build the effort ladder from the advertised vocabulary, preserving every * named tier. `none` is deliberately excluded: it is the disable state, and * folding it into `minimal` would make the lowest rung silently stop thinking * on the models that advertise both. */ function resolveCharmHyperThinking(reasoning: unknown, wireEfforts: readonly string[]): ThinkingConfig | undefined { const efforts = THINKING_EFFORTS.filter(effort => wireEfforts.includes(effort)); if (efforts.length === 0) return undefined; const advertisedDefault = isRecord(reasoning) ? reasoning.default_effort_level : undefined; const defaultLevel = efforts.find(effort => effort === advertisedDefault); return { mode: "effort", efforts, ...(defaultLevel !== undefined && { defaultLevel }) }; } /** * Hyper quotes per-million-token USD directly, so rates pass through * unscaled. `toPositiveNumber` is unusable here: a free tier and the common * `cache_create: 0` are legitimate zero rates, not missing values. */ function toCharmHyperRate(value: unknown): number { const parsed = toNumber(value); return parsed !== undefined && parsed >= 0 ? parsed : 0; } function resolveCharmHyperCost(pricing: unknown): ModelSpec<"openai-completions">["cost"] { if (!isRecord(pricing)) return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; return { input: toCharmHyperRate(pricing.input), output: toCharmHyperRate(pricing.output), cacheRead: toCharmHyperRate(pricing.cache_hit), cacheWrite: toCharmHyperRate(pricing.cache_create), }; } /** * Charm Hyper's gateway catalog. `/v1/models` is public and carries the live * tariff, so discovery runs with or without a key and the snapshot is * authoritative: a model the gateway stops serving is pruned rather than kept * alive by a stale bundled row. */ export function charmHyperModelManagerOptions( config?: CharmHyperModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { const baseUrl = normalizeCharmHyperBaseUrl(config?.baseUrl); return { providerId: "charm-hyper", cacheProviderId: resolveModelCacheProviderId("charm-hyper", { baseUrl }), dynamicModelsAuthoritative: true, fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-completions", provider: "charm-hyper", baseUrl, apiKey: config?.apiKey, mapModel: ( entry: OpenAICompatibleModelRecord, defaults: ModelSpec<"openai-completions">, ): ModelSpec<"openai-completions"> => { const record = entry as CharmHyperModelRecord; const wireEfforts = charmHyperWireEfforts(record.reasoning); const thinking = resolveCharmHyperThinking(record.reasoning, wireEfforts); const capabilities = isRecord(record.capabilities) ? record.capabilities : undefined; return { ...defaults, name: toModelName(record.display_name, defaults.name), // A row without an `effort_levels` vocabulary exposes no dial. // The gateway is silent rather than negative about always-on // reasoners, so the handful that think anyway are corrected by // exact `thinking-efforts` rules in KDL, which upgrade the // target and materialize `reasoning: true` alongside them. reasoning: thinking !== undefined, ...(thinking && { thinking }), input: capabilities?.vision === true ? ["text", "image"] : ["text"], contextWindow: toPositiveNumber(record.context_window, defaults.contextWindow), maxTokens: toPositiveNumber(record.max_output_tokens, defaults.maxTokens), cost: resolveCharmHyperCost(record.pricing), // Thinking-capable rows that advertise the `none` tier can be // switched off on the wire; the rest have no off switch and // keep the dialect default. ...(thinking && wireEfforts.includes(CHARM_HYPER_WIRE_EFFORT_NONE) ? { compat: { reasoningDisableMode: "none-effort" as const } } : {}), }; }, fetch: config?.fetch, }), }; } // --------------------------------------------------------------------------- // SingularityAPI // --------------------------------------------------------------------------- export interface SingularityApiModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } interface SingularityApiCapability extends Record { endpoint?: unknown; context_window_tokens?: unknown; maximum_output_tokens?: unknown; default_output_tokens?: unknown; pricing?: unknown; } /** Endpoints that decide which transport serves a `/v1/models` row. */ const SINGULARITYAPI_CHAT_ENDPOINT = "/v1/chat/completions"; const SINGULARITYAPI_IMAGE_ENDPOINT = "/v1/images/generations"; function singularityApiCapabilities(entry: OpenAICompatibleModelRecord): readonly SingularityApiCapability[] { const capabilities = entry.capabilities; if (!Array.isArray(capabilities)) return []; return capabilities.filter((capability): capability is SingularityApiCapability => isRecord(capability)); } function toSingularityApiRate(value: unknown): number { const parsed = toNumber(value); return parsed !== undefined && parsed >= 0 ? parsed : 0; } function resolveSingularityApiCost(capability: SingularityApiCapability | undefined): ModelSpec["cost"] { const pricing = capability !== undefined && isRecord(capability.pricing) ? capability.pricing : undefined; if (!pricing) return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; return { input: toSingularityApiRate(pricing.input_per_million_usd), output: toSingularityApiRate(pricing.output_per_million_usd), cacheRead: 0, cacheWrite: 0, }; } /** * Map one `/v1/models` row onto its serving transport. * * The wire's own `capabilities` list decides the transport: a row that serves * chat completions is a chat model, and a row whose only surface is * `/v1/images/generations` is routed to `openai-images` so * `generateImage`-style dispatch can reach it. Without that assignment the row * kept the discovery default (`openai-completions`) while still being marked * as an image model, so it was offered as an image target and then rejected by * every image client. The gateway bills image requests per request, never by * tokens, so those rows carry no token tariff. */ function mapSingularityApiModel(entry: OpenAICompatibleModelRecord, defaults: ModelSpec): ModelSpec { const capabilities = singularityApiCapabilities(entry); const capability = capabilities.find(candidate => candidate.endpoint === SINGULARITYAPI_CHAT_ENDPOINT); if ( capability === undefined && capabilities.some(candidate => candidate.endpoint === SINGULARITYAPI_IMAGE_ENDPOINT) ) { return { ...defaults, api: "openai-images", name: toModelName(entry.name, defaults.name), kind: "image", reasoning: false, input: ["text", "image"], supportsTools: false, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: null, maxTokens: null, }; } return { ...defaults, name: toModelName(entry.name, defaults.name), contextWindow: toPositiveNumber(capability?.context_window_tokens, defaults.contextWindow), maxTokens: toPositiveNumber(capability?.maximum_output_tokens, defaults.maxTokens), cost: resolveSingularityApiCost(capability), }; } /** * Core options shared by both SingularityAPI products. `mapModel` is supplied * only by the universal gateway, whose rows publish capability metadata; the * lane roster answers with bare ids and keeps the discovery defaults. */ function singularityApiModelManagerOptions( providerId: "singularityapi-dev" | "singularityapi-tech", canonical: string, config: SingularityApiModelManagerConfig | undefined, mapModel?: (entry: OpenAICompatibleModelRecord, defaults: ModelSpec) => ModelSpec, ): ModelManagerOptions { const apiKey = config?.apiKey; const baseUrl = normalizeSingularityApiBaseUrl(config?.baseUrl, canonical); return { providerId, cacheProviderId: resolveModelCacheProviderId(providerId, { apiKey, baseUrl }), dynamicModelsAuthoritative: true, ...(apiKey && { fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-completions", provider: providerId, baseUrl, apiKey, ...(mapModel && { mapModel }), fetch: config?.fetch, }), }), }; } /** * `singularityapi-dev` — SingularityAPI's pay-as-you-go universal gateway * (`api.singularityapi.dev`): chat completions over a 300+ model catalog, * plus image generation for the rows that advertise it. * `GET /v1/models` publishes each row's per-endpoint capabilities — context * window, max output tokens, and per-million pricing as 12-decimal strings — * with `cache-control: no-store`, so discovery reads limits and tariffs * straight off the wire and the endpoint list picks each row's transport. * Rows without a reasoning vocabulary stay non-reasoning; reviewed KDL rules * own the ladders the gateway leaves implicit (DeepSeek Flash/Pro, GPT-5.6 * flagships), because a model discovered as non-reasoning never sends a * `reasoning_effort` and the gateway requires one alongside tools. */ export function singularityApiDevModelManagerOptions( config?: SingularityApiModelManagerConfig, ): ModelManagerOptions { return singularityApiModelManagerOptions( "singularityapi-dev", SINGULARITYAPI_DEV_API_BASE_URL, config, mapSingularityApiModel, ); } /** * `singularityapi-tech` — SingularityAPI's slot-reserved DeepSeek lanes * (`api.singularityapi.tech`). `/v1/models` answers with bare `{id}` rows and * no capability metadata, so rows keep the discovery defaults and the * reviewed KDL rules own the wire shape, limits patch, and effort ladder. */ export function singularityApiTechModelManagerOptions( config?: SingularityApiModelManagerConfig, ): ModelManagerOptions { return singularityApiModelManagerOptions("singularityapi-tech", SINGULARITYAPI_TECH_API_BASE_URL, config); } // --------------------------------------------------------------------------- // Scaleway // --------------------------------------------------------------------------- /** * Scaleway discovery configuration: the API key plus optional base-URL and * fetch overrides. Consumed by {@link scalewayModelManagerOptions}, and * exported for extensions and tests that construct the manager directly. */ export interface ScalewayModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } /** * Scaleway model manager: plain OpenAI-compatible chat completions at * `api.scaleway.ai/v1` (fr-par serverless Generative APIs). `/v1/models` * returns bare ids, so the authored seed in `providers/scaleway.kdl` is the * reference each live row merges names, modalities, prices, and limits from; * a successful fetch is the complete roster and prunes retired rows. */ export function scalewayModelManagerOptions( config?: ScalewayModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { return createOpenAICompatibleModelManagerOptions({ api: "openai-completions", providerId: "scaleway", defaultBaseUrl: "https://api.scaleway.ai/v1", config, dynamicModelsAuthoritative: true, requireApiKey: true, filterModel: (_entry, model) => !isExcludedModel("scaleway", model.id), mapModel: mapWithBundledReference, }); } // --------------------------------------------------------------------------- // Otari // --------------------------------------------------------------------------- /** * Otari discovery configuration: the workspace API key plus optional base-URL * (a self-hosted API root such as `https://host.example.com/api/v1`) and fetch * overrides. Consumed by {@link otariModelManagerOptions}. */ export interface OtariModelManagerConfig { apiKey?: string; baseUrl?: string; fetch?: FetchImpl; } /** * Otari model manager: the workspace's live `/models` roster is the whole * catalog, so nothing is bundled and every field comes from the row. Rows are * OpenRouter-style (`supported_parameters`, `architecture` modalities, * per-million USD `pricing`); rows the gateway has no metadata for omit them * and stay text-only and non-reasoning. Rows that write no text are not chat * models; metadata-less non-chat rows fall to `exclude-models provider="otari"`. */ export function otariModelManagerOptions(config?: OtariModelManagerConfig): ModelManagerOptions<"openai-completions"> { const apiKey = config?.apiKey; const baseUrl = config?.baseUrl ?? getDefaultModelDiscoveryBaseUrl("otari")!; return { providerId: "otari", cacheProviderId: resolveModelCacheProviderId("otari", { apiKey, baseUrl }), dynamicModelsAuthoritative: true, dynamicCostAuthoritative: true, ...(apiKey && { fetchDynamicModels: () => fetchOpenAICompatibleModels({ api: "openai-completions", provider: "otari", baseUrl, apiKey, filterModel: (entry, model) => { const output = isRecord(entry.architecture) ? entry.architecture.output_modalities : undefined; if (Array.isArray(output) && output.length > 0 && !output.includes("text")) return false; return !isExcludedModel("otari", model.id); }, mapModel: (entry, defaults) => { const pricing = isRecord(entry.pricing) ? entry.pricing : {}; return { ...defaults, reasoning: Array.isArray(entry.supported_parameters) && entry.supported_parameters.includes("reasoning"), input: toInputCapabilities( isRecord(entry.architecture) ? entry.architecture.input_modalities : undefined, ), cost: { input: toNumber(pricing.input_price_per_million) ?? 0, output: toNumber(pricing.output_price_per_million) ?? 0, cacheRead: toNumber(pricing.cache_read_price_per_million) ?? 0, cacheWrite: toNumber(pricing.cache_write_price_per_million) ?? 0, }, contextWindow: toPositiveNumber(entry.context_window, defaults.contextWindow), maxTokens: toPositiveNumber(entry.max_output_tokens, defaults.maxTokens), }; }, fetch: config?.fetch, }), }), }; }