/** * OpenCode plugin for the OmniRoute AI Gateway. * * Implements the official `@opencode-ai/plugin` Plugin contract (auth + * provider + config hooks) to drive a running OmniRoute instance from * OpenCode without hand-curated `provider..models` blocks in * opencode.json[c]: * * - `auth` — registers `/connect ` flow (API key prompt) * - `provider` — dynamic `/v1/models` fetch with TTL cache, capabilities * pass-through (OmniRoute is the source of truth — no * client-side variant synthesis) * - `config` — backward-compat shim for OC versions that predate the * `provider.models` hook (≤ 1.14.48) * * Two ways to consume the plugin: * * 1. Single-instance (default `providerId: "omniroute"`): * * ```json * { * "$schema": "https://opencode.ai/config.json", * "plugin": ["@omniroute/opencode-plugin"] * } * ``` * * 2. Multi-instance via plugin options (prod + preprod side by side): * * ```json * { * "$schema": "https://opencode.ai/config.json", * "plugin": [ * ["@omniroute/opencode-plugin", { "providerId": "omniroute" }], * ["@omniroute/opencode-plugin", { "providerId": "omniroute-preprod" }] * ] * } * ``` * * Then `opencode connect ` to provision the API key per instance. * * Companion library: `@omniroute/opencode-provider` (build-time config generator) * remains supported for users who can't run plugins (CI, scripted scaffolding). * * @see https://opencode.ai/docs/plugins for the OpenCode plugin contract. * @see https://github.com/diegosouzapw/OmniRoute for the AI Gateway. */ import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { AuthHook, Config, Plugin, PluginOptions, ProviderHook } from "@opencode-ai/plugin"; import type { Model as ModelV2 } from "@opencode-ai/sdk/v2"; import { z } from "zod"; /** * Zod schema for plugin options accepted as the second element of the * `plugin: [name, opts]` tuple in opencode.json. Strict by design — unknown * keys are rejected so typos in opencode.json surface immediately at plugin * construction time instead of silently being dropped. * * Doc per field: * * - `providerId` OpenCode provider id this plugin instance binds to. * Multiple plugin instances coexist by giving each a * different `providerId` ("omniroute", "omniroute-preprod", * "omniroute-local"). Maps to `ProviderHook.id` and * `AuthHook.provider` in the @opencode-ai/plugin contract. * Default: "omniroute". * - `displayName` Label rendered in the OpenCode UI. Default derives * from providerId. * - `modelCacheTtl` `/v1/models` TTL cache duration in milliseconds. * Default: 300_000 (5 min). * - `baseURL` Override base URL for this OmniRoute instance. When * absent, the loader falls back to a credential-attached * baseURL set by `/connect`. */ /** * Optional feature toggles. Every field is opt-in/out per call; defaults * mirror the v0.1.0 behaviour so existing opencode.json files do not need * to change. * * - `combos` Discover `/api/combos` and surface them as * pseudo-models with LCD capabilities. Default true. * - `enrichment` Pull display names + pricing from * `/api/pricing/models` and overlay them onto the * ModelV2 entries derived from `/v1/models`. Solves * the "raw id in UI" complaint without client-side * heuristics. Default true. * - `compressionMetadata` Pull `/api/context/combos` so combo entries can * be tagged with their compression pipeline * (e.g. `rtk:standard → caveman:full`). Off by * default — adds one network call per refresh and * the data is only useful for combo entries. * - `geminiSanitization` Strip `$schema`/`$ref`/`additionalProperties` * from `tools[].function.parameters` when the * model id contains "gemini". Default true. * - `mcpAutoEmit` Auto-write an `mcp.` remote entry * into the OC config pointing at * `/api/mcp/stream` with the resolved * Bearer token. Default false — keeps opencode.json * in control unless explicitly opted in. * - `mcpToken` Optional separate Bearer token to use in the * auto-emitted MCP entry. Falls back to the * provider's API key (from auth.json) when unset. * Useful when a narrower-scoped MCP-only key is * preferred over the chat/inference key. * - `fetchInterceptor` Inject Authorization: Bearer + Content-Type on * every outbound request to baseURL. Default true. */ const featuresSchema = z .object({ combos: z.boolean().optional(), enrichment: z.boolean().optional(), compressionMetadata: z.boolean().optional(), geminiSanitization: z.boolean().optional(), mcpAutoEmit: z.boolean().optional(), mcpToken: z.string().min(1).optional(), fetchInterceptor: z.boolean().optional(), usableOnly: z.boolean().optional(), diskCache: z.boolean().optional(), providerTag: z.boolean().optional(), }) .strict(); const optionsSchema = z .object({ providerId: z .string() .min(1) .regex(/^[a-z0-9-]+$/i, "providerId must be a slug") .optional(), displayName: z.string().min(1).optional(), modelCacheTtl: z.number().positive().optional(), baseURL: z.string().url().optional(), features: featuresSchema.optional(), }) .strict(); /** * Plugin options shape — inferred directly from the Zod schema so the * validator and the static type can never drift. Replaces the standalone * interface previously declared here (T-02). Every consumer continues to * import `OmniRoutePluginOptions` as before; only the source of truth * shifted from a hand-written interface to `z.infer`. */ export type OmniRoutePluginOptions = z.infer; export const OMNIROUTE_PROVIDER_KEY = "omniroute" as const; export const DEFAULT_MODEL_CACHE_TTL_MS = 300_000 as const; // Manual trim helpers avoid polynomial-regex CodeQL warnings on // user-supplied baseURL strings (string.replace(/\/+$/, "")). The same // behaviour, no backtracking. function trimTrailingSlashes(value: string): string { let i = value.length; while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--; return i === value.length ? value : value.slice(0, i); } function trimTrailingDashes(value: string): string { let i = value.length; while (i > 0 && value.charCodeAt(i - 1) === 0x2d /* "-" */) i--; return i === value.length ? value : value.slice(0, i); } function trimLeadingDashes(value: string): string { let i = 0; while (i < value.length && value.charCodeAt(i) === 0x2d /* "-" */) i++; return i === 0 ? value : value.slice(i); } /** * Resolve effective options from the optional plugin-options object, * applying defaults. Centralises the providerId fallback so every hook * sees a consistent identifier. */ export function resolveOmniRoutePluginOptions( opts?: OmniRoutePluginOptions ): Required> & Pick { const providerId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY; const displayName = opts?.displayName ?? (providerId === OMNIROUTE_PROVIDER_KEY ? "OmniRoute" : `OmniRoute (${providerId})`); const modelCacheTtl = typeof opts?.modelCacheTtl === "number" && opts.modelCacheTtl > 0 ? opts.modelCacheTtl : DEFAULT_MODEL_CACHE_TTL_MS; return { providerId, displayName, modelCacheTtl, baseURL: opts?.baseURL, features: opts?.features, }; } /** * Strict parse of raw plugin options (as received from opencode.json or a * direct factory call) into the validated `OmniRoutePluginOptions` shape. * * - `null` / `undefined` → `{}` (no opts is valid, defaults take over). * - Unknown keys → throws (strict schema catches typos in opencode.json). * - Empty / malformed values (e.g. empty providerId, non-URL baseURL, * negative modelCacheTtl) → throws. * * Validation happens at plugin invocation time (inside `OmniRoutePlugin`), * NOT at module import — so a bad opencode.json fails the affected plugin * instance with an actionable message instead of crashing the whole TUI on * startup. * * Exported so callers and tests can validate options independent of the * full plugin factory invocation. */ export function parseOmniRoutePluginOptions(opts: unknown): OmniRoutePluginOptions { if (opts === null || opts === undefined) return {}; const result = optionsSchema.safeParse(opts); if (!result.success) { const errs = result.error.issues .map((i) => { const path = i.path.length > 0 ? i.path.join(".") : ""; return `${path}: ${i.message}`; }) .join("; "); throw new Error(`Invalid @omniroute/opencode-plugin options: ${errs}`); } return result.data; } /** * Internal coercion shim. Delegates to `parseOmniRoutePluginOptions` to keep * the public surface stable while routing all validation through the Zod * schema. Always returns an object (never undefined) so downstream hooks see * the same shape regardless of whether opencode.json passed `null`, * `undefined`, or an empty bag. */ function coercePluginOptions(opts?: PluginOptions): OmniRoutePluginOptions { return parseOmniRoutePluginOptions(opts); } /** * Build the AuthHook portion of the plugin for a given options bag. Exported * standalone so the auth contract can be unit-tested without faking the full * PluginInput / Hooks surface. * * Contract notes: * - `provider` binds to `providerId` (NOT a hardcoded module constant — fixes * the multi-instance bug in opencode-omniroute-auth@1.2.1 which pinned * `OMNIROUTE_PROVIDER_ID = "omniroute"` at module scope). * - `methods[0]` is the `api` flavor (no OAuth flow; OmniRoute issues bearer * keys directly). Label includes the resolved displayName so multi-instance * setups stay distinguishable in the OC TUI. * - `methods[0].prompts` uses the official `{type:"text", key, message}` * shape from `@opencode-ai/plugin@1.15.6`. The contract does NOT expose * a `mask: true` flag on text prompts — the OC TUI is expected to handle * credential masking by itself (per OC's `auth login` UX). * - `loader` reads the stored credentials via `getAuth()` and projects them * into the AI-SDK `openai-compatible` options shape (`apiKey`, `baseURL`). * The fetch interceptor (`fetch`) is wired in T-04; left absent here so * downstream code falls back to the SDK default fetch. * - The loader rejects non-`api` auth flavors (oauth / wellknown) and empty * keys by returning `{}` — OC then surfaces the `/connect` flow to the * user instead of dispatching a request with bogus credentials. */ export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook { const { providerId, displayName, baseURL, features } = resolveOmniRoutePluginOptions(opts); // Both fetch-layer features default ON (parity with the rest of the plugin's // `features.X !== false` convention). Honoring them here lets users disable // the interceptor/sanitizer from opencode.json — previously these flags were // documented and schema-validated but silently ignored. const wantFetchInterceptor = (features ?? {}).fetchInterceptor !== false; const wantGeminiSanitization = (features ?? {}).geminiSanitization !== false; const hook: AuthHook = { provider: providerId, methods: [ { type: "api", label: `${displayName} API Key`, prompts: [ { type: "text", key: "apiKey", message: `OmniRoute API key (${providerId})`, }, ], }, ], loader: async (getAuth, _provider) => { const auth = await getAuth(); if ( auth && typeof auth === "object" && (auth as { type?: unknown }).type === "api" && typeof (auth as { key?: unknown }).key === "string" && (auth as { key: string }).key.length > 0 ) { const apiKey = (auth as { key: string }).key; // baseURL resolution: plugin opts win, then a credential-attached // baseURL (some auth backends stash it alongside the key), else empty. // Re-cast through `unknown` first: Auth is a discriminated union // (api | oauth | wellknown) and TS refuses a direct narrowing to a // hypothetical `{ baseURL: string }` shape because WellKnownAuth has // no `baseURL`. We've already checked the runtime type via typeof so // the unknown-bridge is a safe assertion, not a lie. const authBaseURL = (auth as unknown as { baseURL?: unknown }).baseURL; const resolvedBaseURL = baseURL ?? (typeof authBaseURL === "string" ? authBaseURL : ""); // Without a baseURL the interceptor can't tell which requests to // intercept (it would either gate-keep nothing or, worse, all // outbound traffic). Fall back to apiKey-only and let the SDK use // its default fetch. The /connect flow + plugin opts should make // this branch unreachable in practice. if (!resolvedBaseURL) { return { apiKey }; } // Composition: sanitise Gemini tool schemas FIRST (T-06), then inject // Bearer (T-04). Both layers are pure with respect to the other's // concern (body vs headers) so order is logically free; wrapping the // pure body-transform around the header-injecting interceptor reads // cleaner and keeps T-06 testable in isolation against any inner fetch // (real or stub). Each layer is gated by its feature flag; when both // are disabled we fall back to the SDK's default fetch (apiKey only). let composedFetch: typeof fetch | undefined; if (wantFetchInterceptor) { composedFetch = createOmniRouteFetchInterceptor({ apiKey, baseURL: resolvedBaseURL, }); } if (wantGeminiSanitization) { composedFetch = createGeminiSanitizingFetch(composedFetch ?? fetch); } return composedFetch ? { apiKey, baseURL: resolvedBaseURL, fetch: composedFetch } : { apiKey, baseURL: resolvedBaseURL }; } return {}; }, }; return hook; } /** * Plugin factory. Returns the OpenCode Plugin object wired with the three * hooks. Concrete hook bodies land in subsequent tickets (T-03 provider.models, * T-04 fetch interceptor, T-06 Gemini sanitization, T-07 config backward-compat). * * Per `@opencode-ai/plugin@1.15.6`, the Plugin signature is * `(input: PluginInput, options?: PluginOptions) => Promise` — opts * arrive as the SECOND argument (from the `[name, opts]` tuple in * opencode.json), NOT as a closure binding. Multi-instance support follows * from each plugin tuple invoking the factory with its own opts. */ export const OmniRoutePlugin: Plugin = async (_input, options) => { const resolved = coercePluginOptions(options); // T-07: a single per-plugin-instance cache shared between the provider // hook (T-03/T-05) and the config-shim hook (T-07). On OC ≥1.14.49 both // hooks fire within the same Plugin invocation, so a shared cache keeps // /v1/models + /api/combos at exactly one round-trip per TTL refresh // instead of two. On OC ≤1.14.48 only the config hook runs; the cache // still works (single producer + single consumer through the same map). // Each `OmniRoutePlugin(...)` invocation gets its OWN cache via closure, // so prod + preprod side-by-side instances do NOT collide. const sharedCache: OmniRouteFetchCache = new Map(); // Debug breadcrumb: confirm server() invocation + resolved options. // Useful when diagnosing "is the plugin even running" from OC logs. console.warn( `[omniroute-plugin] initialized providerId=${resolved.providerId} displayName="${resolved.displayName}" baseURL=${resolved.baseURL ?? "(from auth.json)"} modelCacheTtl=${resolved.modelCacheTtl}ms` ); return { auth: createOmniRouteAuthHook(resolved), provider: createOmniRouteProviderHook(resolved, { cache: sharedCache }), config: createOmniRouteConfigHook(resolved, { cache: sharedCache }), }; }; /** * v1 plugin shape per OC plugin loader (`packages/opencode/src/plugin/shared.ts:readV1Plugin`). * OC checks the default export for an object with `{id, server}` shape FIRST. * If that fails it falls back to legacy `getLegacyPlugins` which walks every * named export and rejects any non-function value — our package has * constants (OMNIROUTE_PROVIDER_KEY, DEFAULT_MODEL_CACHE_TTL_MS) + types + * schemas as named exports, so the legacy path always fails for us. * * Using v1 shape skips the legacy walk entirely. The `id` field is the * plugin MODULE identifier (one per published package); per-instance * `providerId` still flows through `options.providerId` as before. */ const OmniRouteV1Plugin = { id: "@omniroute/opencode-plugin", server: OmniRoutePlugin, }; export default OmniRouteV1Plugin; // ──────────────────────────────────────────────────────────────────────────── // Provider hook (T-03) — /v1/models pass-through with TTL cache // ──────────────────────────────────────────────────────────────────────────── /** * Raw shape of a `/v1/models` entry from OmniRoute. Captured verbatim from * the prod gateway response (sample at /tmp/prod-v1-models.json: 455 entries). * STRICT source-of-truth (OQ-3): every field that lands in ModelV2 traces * back to this shape — no client-side variant synthesis. */ export interface OmniRouteRawModelEntry { id: string; object?: string; owned_by?: string; root?: string | null; parent?: string | null; context_length?: number; max_input_tokens?: number; max_output_tokens?: number; input_modalities?: string[]; output_modalities?: string[]; capabilities?: { tool_calling?: boolean; reasoning?: boolean; vision?: boolean; thinking?: boolean; attachment?: boolean; structured_output?: boolean; temperature?: boolean; }; release_date?: string; last_updated?: string; api_format?: string; } /** * Fetcher contract: returns the raw `/v1/models` entry list from a running * OmniRoute instance. Surfaced as a dependency so unit tests can inject a * stub without monkey-patching global `fetch`. * * Why we inline this instead of using `@omniroute/opencode-provider`'s * `fetchLiveModels`: the sibling helper returns a stripped `{id, name, * contextLength?}` shape (see opencode-provider/src/index.ts:480-569) that * drops the `capabilities` / `*_modalities` / `max_*_tokens` blocks T-03 * needs for ModelV2 pass-through. Adopting the sibling here would force a * client-side re-fetch or re-introduce the synthesis we explicitly rejected * in OQ-3. A 30-line raw fetcher is cheaper than mutating the sibling's * stable v0.1.0 contract. */ export type OmniRouteModelsFetcher = ( baseURL: string, apiKey: string, timeoutMs?: number ) => Promise; /** * Default fetcher: `GET /v1/models` with bearer auth + AbortController * timeout. Accepts both the `{object:"list", data:[…]}` envelope OmniRoute * emits today and a bare-array envelope (defensive — keeps the plugin * working if a future OmniRoute build trims the wrapper). Anything that * isn't an object with a string `id` is filtered out silently. */ export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async ( baseURL, apiKey, timeoutMs = 10_000 ) => { if (!apiKey) throw new Error("@omniroute/opencode-plugin: apiKey required to fetch /v1/models"); if (!baseURL) throw new Error("@omniroute/opencode-plugin: baseURL required to fetch /v1/models"); const trimmed = trimTrailingSlashes(baseURL); // Tolerate both `https://host` and `https://host/v1` forms — the gateway // exposes /v1/models either way; we just don't want a double `/v1/v1`. const url = /\/v\d+$/.test(trimmed) ? `${trimmed}/models` : `${trimmed}/v1/models`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { method: "GET", headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json", }, signal: controller.signal, }); if (!res.ok) { throw new Error( `@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}` ); } const body = (await res.json()) as unknown; const rawList: unknown[] = Array.isArray(body) ? body : body && typeof body === "object" && Array.isArray((body as { data?: unknown }).data) ? ((body as { data: unknown[] }).data as unknown[]) : []; const out: OmniRouteRawModelEntry[] = []; for (const r of rawList) { if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") { out.push(r as OmniRouteRawModelEntry); } } return out; } finally { clearTimeout(timer); } }; /** * Map a raw `/v1/models` entry → `ModelV2` (the type @opencode-ai/sdk/v2 * exports as `Model`, re-exported by @opencode-ai/plugin as `ModelV2`). * * ModelV2 (as of @opencode-ai/sdk@v2 — see node_modules path * `@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts:964-1043`) requires a much * richer shape than the T-03 spec's mapping table assumed. Concretely it * expects: * - flat `id`, `name`, `providerID`, `api: {id,url,npm}` * - nested `capabilities: { temperature, reasoning, attachment, toolcall, * input:{text,audio,image,video,pdf}, output:{…}, interleaved }` * - `cost: { input, output, cache:{read,write} }` (NOT optional) * - `limit: { context, input?, output }` * - `status: "alpha"|"beta"|"deprecated"|"active"`, `options:{}`, `headers:{}` * - `release_date: string` * * Deviations from the T-03 spec (documented per ticket §2 "CRITICAL: Check * the actual ModelV2 type and adapt if field names differ"): * 1. Spec's flat `tool_call` / `reasoning` / `attachment` / `modalities` * top-level fields don't exist in ModelV2 — folded into * `capabilities.{toolcall, reasoning, attachment, input.*, output.*}`. * 2. `cost: undefined` is illegal (cost is required). OmniRoute doesn't * surface pricing on /v1/models, so we emit a zeroed cost block. * Downstream OC reads this for display only — the live pricing is * OmniRoute's responsibility at routing time. * 3. `tool_call` (spec) → `toolcall` (ModelV2 field name; one word). * 4. `attachment` (spec) maps from `capabilities.vision` per OmniRoute * convention: vision = ability to receive image attachments. If the * raw entry happens to expose an explicit `capabilities.attachment` * (some combo entries do), that wins. * 5. `thinking` from OmniRoute has no 1:1 ModelV2 slot. We OR it into * `reasoning` so thinking-only models still surface a non-false * reasoning flag. * 6. `last_updated` from OmniRoute has no ModelV2 slot — dropped (the * spec also flagged this as "may not exist", and the prod sample * confirms it's optional). `release_date` lands in ModelV2.release_date * with `""` fallback (the field is required as `string`). * 7. `temperature: true` per OmniRoute convention (OpenAI-compat mode * always supports the temperature knob). If a raw entry sets * `capabilities.temperature` explicitly, that wins. * 8. Input/output modality arrays: each known modality flips its boolean. * Unknown strings (future OmniRoute additions) are ignored — when the * server adds new modalities we can map them here without breaking * existing entries. * 9. `status: "active"` — OmniRoute doesn't tier models alpha/beta on * /v1/models, and OC needs a non-deprecated status to expose the * model in the picker. If a future entry surfaces an explicit * lifecycle hint we can map it then. * 10. `options: {}` and `headers: {}` left empty — they're escape hatches * for OC users to attach per-model overrides; the provider plugin * must not preempt them. * 11. `limit.input` is OPTIONAL on ModelV2 (the `?` modifier). We only * emit it when OmniRoute supplies `max_input_tokens` — keeps the * shape clean for combo entries that only carry context_length. */ export function mapRawModelToModelV2( raw: OmniRouteRawModelEntry, ctx: { providerId: string; baseURL: string } ): ModelV2 { const caps = raw.capabilities ?? {}; const inMods = new Set(raw.input_modalities ?? ["text"]); const outMods = new Set(raw.output_modalities ?? ["text"]); return { id: raw.id, /** * Display name. Falls back to raw.id when no enrichment is available; * the caller (`createOmniRouteProviderHook`) overlays * `/api/pricing/models` data via `applyEnrichment` when * `features.enrichment` is true. */ name: raw.id, capabilities: { temperature: caps.temperature ?? true, reasoning: Boolean(caps.reasoning || caps.thinking), attachment: Boolean(caps.attachment ?? caps.vision ?? false), toolcall: Boolean(caps.tool_calling ?? false), input: { text: inMods.has("text"), audio: inMods.has("audio"), image: inMods.has("image"), video: inMods.has("video"), pdf: inMods.has("pdf"), }, output: { text: outMods.has("text"), audio: outMods.has("audio"), image: outMods.has("image"), video: outMods.has("video"), pdf: outMods.has("pdf"), }, interleaved: false, }, cost: { input: 0, output: 0, cache: { read: 0, write: 0 }, }, limit: { context: typeof raw.context_length === "number" ? raw.context_length : 0, ...(typeof raw.max_input_tokens === "number" ? { input: raw.max_input_tokens } : {}), output: typeof raw.max_output_tokens === "number" ? raw.max_output_tokens : 0, }, status: "active", options: {}, headers: {}, release_date: raw.release_date ?? "", providerID: ctx.providerId, api: { id: "openai-compatible", url: ctx.baseURL, npm: "@ai-sdk/openai-compatible", }, }; } // ──────────────────────────────────────────────────────────────────────────── // Combo discovery (T-05) — /api/combos pass-through with LCD capability roll-up // ──────────────────────────────────────────────────────────────────────────── /** * Raw shape of a single combo entry as returned by OmniRoute's `/api/combos`. * * Schema established via a live probe against * an OmniRoute `/api/combos` endpoint with a management-scoped key * (response saved at /tmp/t05-combos.json) cross-referenced against the * source-of-truth in this repo: * * - `src/app/api/combos/route.ts` GET handler — emits `{combos: [...]}` * envelope after `getCombos()`. * - `src/lib/db/combos.ts` `getCombos()` — returns rows persisted via * `createCombo` / `updateCombo`, each shaped by `normalizeStoredCombo`. * - `src/lib/combos/steps.ts` `ComboModelStep` + `ComboRefStep` — define * the `models[]` array entry shape (a step references a member model * by its full provider-prefixed id, e.g. `"claude-opus-4-5-thinking"`). * * Note: the preprod gateway returned `{combos: []}` at probe time (no combos * provisioned). The defensive parser accepts both `{combos:[...]}` and a * bare array envelope so the plugin keeps working if a future OmniRoute * build trims the wrapper (mirrors the same pattern in the sibling * `@omniroute/opencode-provider#listCombos`). * * STRICT source-of-truth (OQ-3, per T-03): every ModelV2 field a combo * surfaces traces back to either (a) this raw combo entry or (b) the LCD * roll-up across its raw member models. No client-side variant synthesis. */ export interface OmniRouteRawComboMemberRef { /** Step kind: "model" references a raw model id; "combo-ref" nests another combo. */ kind?: "model" | "combo-ref"; /** Full model id referenced by this step (when kind === "model"). */ model?: string; /** Nested combo name (when kind === "combo-ref"). */ comboName?: string; /** Routing weight inside the combo (0–100, advisory at LCD time). */ weight?: number; /** Step-local label, distinct from the parent combo's display name. */ label?: string; } export interface OmniRouteRawCombo { id: string; name?: string; /** Routing strategy. Surfaced for forward-compat but not consumed by LCD. */ strategy?: string; /** Member step list. Only `kind: "model"` steps participate in LCD. */ models?: OmniRouteRawComboMemberRef[]; /** Hidden combos are excluded from the OC model picker. */ isHidden?: boolean; /** When OmniRoute attaches a lifecycle hint we forward it; today it doesn't. */ release_date?: string; } /** * Fetcher contract for `/api/combos`. Same DI shape as * `OmniRouteModelsFetcher` so unit tests can inject a stub instead of * monkey-patching global `fetch`. */ export type OmniRouteCombosFetcher = ( baseURL: string, apiKey: string, timeoutMs?: number ) => Promise; /** * Default fetcher: `GET /api/combos` with bearer auth + * AbortController timeout. Accepts both the `{combos: [...]}` envelope the * gateway emits today and a bare-array envelope (defensive — keeps the * plugin working if a future OmniRoute build trims the wrapper). * * Differences from `defaultOmniRouteModelsFetcher`: * - URL is `/api/combos`, NOT `/v1/combos`. The `/v1/...` namespace is the * OpenAI-compatible surface (chat completions, models); combo discovery * lives on the management plane under `/api/...`. We tolerate both * `https://host` and `https://host/v1` baseURL forms by stripping the * trailing `/v1` segment before appending `/api/combos`. * - Combos endpoint requires a management-scoped API key when * `REQUIRE_API_KEY` is enabled. We don't enforce that here; the * gateway returns 401/403 with an actionable error which we propagate. * * Anything that isn't an object with a string `id` is filtered out silently. */ export const defaultOmniRouteCombosFetcher: OmniRouteCombosFetcher = async ( baseURL, apiKey, timeoutMs = 10_000 ) => { if (!apiKey) throw new Error("@omniroute/opencode-plugin: apiKey required to fetch /api/combos"); if (!baseURL) throw new Error("@omniroute/opencode-plugin: baseURL required to fetch /api/combos"); // Strip trailing slashes, then strip a trailing `/v1` so we land on the // management plane. Models live under `/v1/models`; combos live under // `/api/combos` from the same gateway root. const trimmed = trimTrailingSlashes(baseURL); const root = trimmed.replace(/\/v\d+$/, ""); const url = `${root}/api/combos`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { method: "GET", headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json", }, signal: controller.signal, }); if (!res.ok) { throw new Error( `@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}` ); } const body = (await res.json()) as unknown; const rawList: unknown[] = Array.isArray(body) ? body : body && typeof body === "object" && Array.isArray((body as { combos?: unknown }).combos) ? ((body as { combos: unknown[] }).combos as unknown[]) : []; const out: OmniRouteRawCombo[] = []; for (const r of rawList) { if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") { out.push(r as OmniRouteRawCombo); } } return out; } finally { clearTimeout(timer); } }; /** * Map a raw combo entry → `ModelV2` by computing the lowest-common-denominator * (LCD) of its underlying member models. The LCD policy is the only way to * surface a single capability vector to OpenCode without lying: if any member * lacks a capability, the combo as a whole cannot guarantee it. * * LCD rules: * - `limit.context` = `min(...members.context_length)`. * - `limit.output` = `min(...members.max_output_tokens)`. * - `limit.input` = `min(...members.max_input_tokens)` ONLY when every * member declares one (ModelV2.limit.input is optional — better to * omit than to fabricate a min over partial data). * - `capabilities.toolcall` / `reasoning` / `attachment` / `temperature`: * `every(member ⇒ supports?)`. The `reasoning` axis ORs across * `reasoning` and `thinking` per member before AND-ing across the * combo (mirrors `mapRawModelToModelV2`). The `attachment` axis ORs * across `attachment` and `vision` per member. The `temperature` axis * uses default-true semantics: a member supports temperature unless * it explicitly declares `temperature: false`. * - `capabilities.input.*` / `output.*`: flattened AND across members' * modality flags. Missing arrays default to `["text"]` (same default * as `mapRawModelToModelV2`). * * Defensive: empty members array → ALL capabilities `false`, limits zero. * That's an intentional safety posture (you can't route through an empty * combo, so OC should grey it out in the picker). * * Spec mapping (T-05 §Scope.3): `cost` zeroed; `status = "active"`; * `release_date = combo.release_date ?? ""`; `api.id = "openai-compatible"`; * `name = combo.name ?? combo.id`. * * @param combo Raw `/api/combos` entry. * @param members Raw `/v1/models` entries for THIS combo's member ids. * Caller resolves `combo.models[].model` ids; unknown ids * are silently dropped before this call. * @param providerId OpenCode provider id (multi-instance aware). * @param baseURL Resolved gateway base URL for ModelV2.api.url. */ export function mapComboToModelV2( combo: OmniRouteRawCombo, members: OmniRouteRawModelEntry[], providerId: string, baseURL: string ): ModelV2 { // `every` over an empty array returns true (would lie about an empty // combo's capabilities) — short-circuit to all-false when no members. const hasMembers = members.length > 0; const memberInMods = members.map((m) => new Set(m.input_modalities ?? ["text"])); const memberOutMods = members.map((m) => new Set(m.output_modalities ?? ["text"])); const modalityAllHave = (sets: Array>, key: string): boolean => hasMembers && sets.every((s) => s.has(key)); const contextValues = members .map((m) => m.context_length) .filter((v): v is number => typeof v === "number" && v > 0); const outputValues = members .map((m) => m.max_output_tokens) .filter((v): v is number => typeof v === "number" && v > 0); const inputValues = members .map((m) => m.max_input_tokens) .filter((v): v is number => typeof v === "number" && v > 0); const everyDeclaresInput = hasMembers && inputValues.length === members.length; const capabilities: ModelV2["capabilities"] = { temperature: hasMembers && members.every((m) => (m.capabilities?.temperature ?? true) !== false), reasoning: hasMembers && members.every((m) => Boolean(m.capabilities?.reasoning || m.capabilities?.thinking)), attachment: hasMembers && members.every((m) => Boolean(m.capabilities?.attachment ?? m.capabilities?.vision ?? false)), toolcall: hasMembers && members.every((m) => Boolean(m.capabilities?.tool_calling ?? false)), input: { text: modalityAllHave(memberInMods, "text"), audio: modalityAllHave(memberInMods, "audio"), image: modalityAllHave(memberInMods, "image"), video: modalityAllHave(memberInMods, "video"), pdf: modalityAllHave(memberInMods, "pdf"), }, output: { text: modalityAllHave(memberOutMods, "text"), audio: modalityAllHave(memberOutMods, "audio"), image: modalityAllHave(memberOutMods, "image"), video: modalityAllHave(memberOutMods, "video"), pdf: modalityAllHave(memberOutMods, "pdf"), }, interleaved: false, }; return { id: combo.id, providerID: providerId, api: { id: "openai-compatible", url: baseURL, npm: "@ai-sdk/openai-compatible", }, name: combo.name && combo.name.trim().length > 0 ? combo.name : combo.id, capabilities, cost: { input: 0, output: 0, cache: { read: 0, write: 0 }, }, limit: { context: contextValues.length > 0 ? Math.min(...contextValues) : 0, ...(everyDeclaresInput ? { input: Math.min(...inputValues) } : {}), output: outputValues.length > 0 ? Math.min(...outputValues) : 0, }, status: "active", options: {}, headers: {}, release_date: combo.release_date ?? "", }; } // ───────────────────────────────────────────────────────────────────────── // ENRICHMENT — pull display names + pricing from /api/pricing/models so // the UI doesn't have to render raw model ids. Gated by features.enrichment. // ───────────────────────────────────────────────────────────────────────── /** * Per-model enrichment overlay derived from OmniRoute's * `/api/pricing/models` endpoint. The endpoint returns a per-provider * catalog with curated `name` strings (e.g. `Claude 4.7 Opus`, * `GPT 5.5 Pro`, `Gemini 3.1 Pro`) and per-million-token pricing * (`pricing.input`, `pricing.output`, `pricing.cacheRead`, * `pricing.cacheWrite`). These overlay the ModelV2 entries produced by * `mapRawModelToModelV2`. */ export interface OmniRouteEnrichmentEntry { /** Human-readable display name. Replaces ModelV2.name when present. */ name?: string; /** Per-million-token cost overlay onto ModelV2.cost. */ pricing?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; }; /** * Provider alias prefix seen in `/v1/models` ids (e.g. `cc`, `gemini-cli`). * Populated by `defaultOmniRouteEnrichmentFetcher` from * `/api/pricing/models` keys. Drives the `usableOnly` alias↔canonical * resolution. */ providerAlias?: string; /** * Canonical provider id used by `/api/providers` connections (e.g. * `claude`, `gemini-cli`, `kiro`). Populated from the per-provider * `entry.id` field inside `/api/pricing/models`. */ providerCanonical?: string; /** * Human-readable upstream provider label (e.g. `Claude`, `Kiro`, * `Windsurf`, `GitHub Models`). Populated from the per-provider * `entry.name` field inside `/api/pricing/models`. Used by the * `providerTag` feature to suffix `ModelV2.name` with the routing * destination so the OC TUI picker can differentiate the same * model id sold through different upstream connections. */ providerDisplayName?: string; } /** Map keyed by full model id (possibly namespaced, e.g. `cc/claude-sonnet-4-6`). */ export type OmniRouteEnrichmentMap = Map; export type OmniRouteEnrichmentFetcher = ( baseURL: string, apiKey: string, timeoutMs?: number ) => Promise; /** * Default enrichment fetcher — pulls nice display names from * `GET /api/pricing/models` and merges per-million-token pricing from * `GET /api/pricing` (the actual pricing source — `/api/pricing/models` is * a catalog endpoint whose entries are `{id, name, custom}` only). * * `/api/pricing/models` shape (catalog): * - `{ [providerAlias]: { id, alias, name, models: [{ id, name, custom }] } }` * * `/api/pricing` shape (pricing only): * - `{ [providerAlias]: { [modelId]: { input, output, cached, reasoning, cache_creation } } }` * where values are USD per million tokens. * * The two responses are joined on `(providerAlias, modelId)` and the merged * entries are stored under both `${providerAlias}/${modelId}` and bare * `${modelId}` keys so downstream lookups against either form succeed. * * Soft-fails (returns whatever was collected) on non-2xx or parse errors; * the two fetches are independent so one missing source still surfaces the * other. */ export const defaultOmniRouteEnrichmentFetcher: OmniRouteEnrichmentFetcher = async ( baseURL, apiKey, timeoutMs = 10_000 ) => { const out: OmniRouteEnrichmentMap = new Map(); if (!baseURL || !apiKey) return out; const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, ""); const headers = { Authorization: `Bearer ${apiKey}`, Accept: "application/json", }; // ── 1. Catalog with nice display names ──────────────────────────────── const catalogAc = new AbortController(); const catalogTimer = setTimeout(() => catalogAc.abort(), timeoutMs); try { const res = await fetch(`${root}/api/pricing/models`, { method: "GET", headers, signal: catalogAc.signal, }); if (res.ok) { const body = (await res.json()) as unknown; const providers = (body as { providers?: Record })?.providers ?? (body as Record); if (providers && typeof providers === "object") { for (const [providerAlias, slot] of Object.entries(providers)) { if (!slot || typeof slot !== "object") continue; const models = (slot as { models?: unknown[] }).models; if (!Array.isArray(models)) continue; // Canonical id sits at the per-provider top level (e.g. // `pricing-models.cc.id === 'claude'`). Falls back to the alias // itself when missing — common case alias===canonical. const canonicalRaw = (slot as { id?: unknown }).id; const providerCanonical = typeof canonicalRaw === "string" && canonicalRaw.length > 0 ? canonicalRaw : providerAlias; // Upstream provider human label (e.g. `Claude`, `Kiro`, // `GitHub Models`). Optional — falls back to undefined when // OmniRoute hasn't curated a label for this slot. const slotNameRaw = (slot as { name?: unknown }).name; const providerDisplayName = typeof slotNameRaw === "string" && slotNameRaw.trim().length > 0 ? slotNameRaw.trim() : undefined; for (const m of models) { if (!m || typeof m !== "object") continue; const id = (m as { id?: unknown }).id; if (typeof id !== "string" || id.length === 0) continue; const name = (m as { name?: unknown }).name; const entry: OmniRouteEnrichmentEntry = { providerAlias, providerCanonical, }; if (providerDisplayName) entry.providerDisplayName = providerDisplayName; if (typeof name === "string" && name.trim().length > 0) entry.name = name; const namespaced = `${providerAlias}/${id}`; if (!out.has(namespaced)) out.set(namespaced, entry); if (!out.has(id)) out.set(id, entry); } } } } } catch { // Soft-fail; keep going to pricing fetch. } finally { clearTimeout(catalogTimer); } // ── 2. Pricing values from /api/pricing ─────────────────────────────── const priceAc = new AbortController(); const priceTimer = setTimeout(() => priceAc.abort(), timeoutMs); try { const res = await fetch(`${root}/api/pricing`, { method: "GET", headers, signal: priceAc.signal, }); if (res.ok) { const body = (await res.json()) as unknown; if (body && typeof body === "object" && !Array.isArray(body)) { for (const [providerAlias, slot] of Object.entries(body as Record)) { if (!slot || typeof slot !== "object" || Array.isArray(slot)) continue; for (const [modelId, raw] of Object.entries(slot as Record)) { if (!raw || typeof raw !== "object") continue; const p = raw as Record; const parsed: NonNullable = {}; // OmniRoute `/api/pricing` keys: // input → cost.input // output → cost.output // cached → cost.cache.read (alias: cacheRead) // cache_creation → cost.cache.write (alias: cacheWrite) // Tolerate alternative spellings for forward-compat. if (typeof p.input === "number") parsed.input = p.input; if (typeof p.output === "number") parsed.output = p.output; const cacheRead = typeof p.cached === "number" ? p.cached : typeof p.cacheRead === "number" ? p.cacheRead : undefined; if (typeof cacheRead === "number") parsed.cacheRead = cacheRead; const cacheWrite = typeof p.cache_creation === "number" ? p.cache_creation : typeof p.cacheWrite === "number" ? p.cacheWrite : undefined; if (typeof cacheWrite === "number") parsed.cacheWrite = cacheWrite; if (Object.keys(parsed).length === 0) continue; const namespaced = `${providerAlias}/${modelId}`; const existingNs = out.get(namespaced); if (existingNs) existingNs.pricing = { ...(existingNs.pricing ?? {}), ...parsed }; else out.set(namespaced, { pricing: parsed }); const existingBare = out.get(modelId); if (existingBare) existingBare.pricing = { ...(existingBare.pricing ?? {}), ...parsed }; else out.set(modelId, { pricing: parsed }); } } } } } catch { // Soft-fail; return whatever names we collected. } finally { clearTimeout(priceTimer); } return out; }; /** * Separator used by `applyProviderTag` between the upstream provider * label (prefix) and the enriched model name. ASCII hyphen with * surrounding spaces — terminal-safe everywhere, never collides with * a model id (those use slashes / dots / underscores). * * Layout: ` - ` (label leads so column scans * group by provider — e.g. `Claude - Claude Opus 4.7`, * `Kiro - Claude Opus 4.7`). */ export const PROVIDER_TAG_SEPARATOR = " - "; /** * Threshold beyond which `providerDisplayName` is abbreviated. Raised * from 8 → 12 so curated brand casing (`AssemblyAI`, `Antigravity`, * `Pollinations`, `GEMINI-CLI` curated form) wins over a shouty * UPPER(alias) fallback for the common case. */ const PROVIDER_LABEL_MAX_CHARS = 12; /** * Aliases longer than this get title-case fallback instead of UPPER — * keeps short-token UPPER (`cc`→`CC`, `ghm`→`GHM`) but tames long * lowercase aliases (`antigravity`→`Antigravity`). */ const ALIAS_UPPER_MAX_CHARS = 5; /** * Title-case a long, lowercase-looking alias (e.g. `antigravity` → * `Antigravity`) so the prefix doesn't shout when neither * `providerDisplayName` nor a short alias is available. */ function titleCaseAlias(alias: string): string { if (alias.length === 0) return alias; return alias.charAt(0).toUpperCase() + alias.slice(1).toLowerCase(); } /** * Pick the short label for an upstream provider that goes into the * `