/** * 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 { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; 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"; import { logger as _logger, setLogLevel, type LogLevel as _LogLevel } from "./logger.js"; import { PROVIDER_TAG_SEPARATOR as _PROVIDER_TAG_SEPARATOR, shortProviderLabel as _shortProviderLabel, normaliseFreeLabel as _normaliseFreeLabel, formatAutoComboName, autoComboModelId, formatFreeBudget, type AutoVariant, AUTO_VARIANTS, AUTO_VARIANT_DESCRIPTIONS, type FreeModelFreeType, } from "./naming.js"; /** * 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. * - `debugLog` Capture every outbound request + response to a * JSONL file at * `~/.local/share/opencode/plugins/omniroute-debug-{providerId}.jsonl`. * Each line: `{ reqId, ts, url, method, reqBody, * resStatus, resBody, durationMs }`. * Default false. Opt-in. * - `apiFormat` Per-provider-prefix API format routing. Model IDs * whose prefix (the part before `/`) matches an entry * in `anthropicPrefixes` are served via the Anthropic * SDK (`@ai-sdk/anthropic`, sends to `/v1/messages` * with native cache_control, tool_choice, etc.). * All other models fall back to `openai-compatible`. * * Default `anthropicPrefixes`: * ["cc", "claude", "anthropic", "kiro", "kr"] * (covers OmniRoute's canonical Anthropic aliases). * * Set `anthropicPrefixes: []` to disable and force * everything through OpenAI-compat. * * Example: * ```json * "apiFormat": { "anthropicPrefixes": ["cc","claude","anthropic","kiro"] } * ``` */ const apiFormatSchema = z .object({ anthropicPrefixes: z.array(z.string()).optional(), }) .strict() .optional(); const featuresSchema = z .object({ combos: z.boolean().optional(), autoCombos: 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(), debugLog: z.boolean().optional(), startupDebug: z.boolean().optional(), logLevel: z.enum(["error", "warn", "info", "debug"]).optional(), apiFormat: apiFormatSchema, }) .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; /** Deployed plugin version (injected at build time by tsup define). */ export const PLUGIN_VERSION: string = ((globalThis as Record).__PLUGIN_VERSION__ as string) ?? "dev"; /** Deployed plugin git commit hash (injected at build time by tsup define). */ export const PLUGIN_GIT_HASH: string = ((globalThis as Record).__PLUGIN_GIT_HASH__ as string) ?? "unknown"; 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 rawProviderId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY; // OC 1.17.8+ native-adapter gate rejects providerID not in // {openai, anthropic, opencode*}. Silently prefix so existing // configs (providerId: "omniroute") keep working. const providerId = rawProviderId.startsWith("opencode-") ? rawProviderId : `opencode-${rawProviderId}`; const displayName = opts?.displayName ?? (providerId === `opencode-${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); } // ──────────────────────────────────────────────────────────────────────────── // Per-prefix API format routing (apiFormat feature) // ──────────────────────────────────────────────────────────────────────────── /** * Default provider-prefix list that triggers the Anthropic SDK format. * Covers OmniRoute's canonical Anthropic aliases: `cc/`, `claude/`, * `anthropic/`, plus the user-configured `kiro/` and `kr/` upstream * connections that proxy Anthropic models. */ export const DEFAULT_ANTHROPIC_PREFIXES = ["cc", "claude", "anthropic", "kiro", "kr"]; /** * Ensure a baseURL ends with `/v1` so the OpenAI-compat SDK constructs * `/v1/chat/completions` correctly. The Anthropic SDK does NOT want `/v1` * (it appends `/v1/messages` automatically), so callers should branch on * format first. */ export function ensureV1Suffix(url: string): string { const trimmed = trimTrailingSlashes(url); return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`; } /** * Resolve the API block (id + url + npm package) for a given model id. * * Decision matrix: * - If the model id's prefix (the substring before the first `/`) is in * `apiFormat.anthropicPrefixes` (or the default list), return the * Anthropic SDK block: `id: "anthropic"`, `url: baseURL` (no `/v1`), * `npm: "@ai-sdk/anthropic"`. * - Otherwise return the OpenAI-compat block: `id: "openai-compatible"`, * `url: baseURL + "/v1"`, `npm: "@ai-sdk/openai-compatible"`. * * Combos span multiple providers. Callers should pass each combo member's * id through this function and pick the LCD format (lowest common * denominator that every upstream actually understands). */ export function resolveApiBlock( modelId: string, baseURL: string, apiFormat?: { anthropicPrefixes?: string[] } ): { id: string; url: string; npm: string } { const prefixes = apiFormat?.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES; const slash = modelId.indexOf("/"); const prefix = slash === -1 ? modelId : modelId.slice(0, slash); const isAnthropic = prefixes.includes(prefix); return isAnthropic ? { id: "anthropic", url: trimTrailingSlashes(baseURL), npm: "@ai-sdk/anthropic", } : { id: "openai-compatible", url: ensureV1Suffix(baseURL), npm: "@ai-sdk/openai-compatible", }; } /** * 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 wantDebugLog = (features ?? {}).debugLog === true; 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); } if (wantDebugLog || debugLogEnabled(providerId)) { composedFetch = createDebugLoggingFetch(composedFetch ?? fetch, providerId, wantDebugLog); } 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. const _ver: string = ((globalThis as Record).__PLUGIN_VERSION__ as string) ?? "dev"; const _hash: string = ((globalThis as Record).__PLUGIN_GIT_HASH__ as string) ?? "unknown"; const _prefixes = resolved.features?.apiFormat?.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES; _logger.always( `v${_ver} (${_hash}) initialized` + ` providerId=${resolved.providerId}` + ` baseURL=${resolved.baseURL ?? "(from auth.json)"}` + ` modelCacheTtl=${resolved.modelCacheTtl}ms` + ` apiFormat=anthropic:[${_prefixes.join(",")}]` + ` debugLog=${resolved.features?.debugLog ?? false}` + ` logLevel=${resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")}` ); // Wire log level: startupDebug:true → "debug", explicit logLevel wins. setLogLevel(resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")); 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; apiFormat?: { anthropicPrefixes?: string[] } } ): ModelV2 { const caps = raw.capabilities ?? {}; const inMods = new Set(raw.input_modalities ?? ["text"]); const outMods = new Set(raw.output_modalities ?? ["text"]); return { // OC's static-catalog reader parses the key on `/` to recover // `(providerID, modelID)`. If the raw id is already provider-prefixed // (e.g. `cc/claude-opus-4-7` from the `cc` Claude Code alias, or // `nvidia/llama-3-70b` from a provider that ships prefixed ids), leave // it as-is — double-prefixing breaks OC's lookup. Otherwise prefix with // the resolved `providerId` so a bare key like `claude-opus-4` parses as // `(omniroute, claude-opus-4)` and the credentials resolve correctly. id: raw.id.includes("/") ? raw.id : `${ctx.providerId}/${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: _normaliseFreeLabel(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: Boolean(caps.thinking), }, 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: resolveApiBlock(raw.id, ctx.baseURL, ctx.apiFormat), }; } // ──────────────────────────────────────────────────────────────────────────── // 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, apiFormat?: { anthropicPrefixes?: 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: hasMembers && members.every((m) => Boolean(m.capabilities?.thinking)), }; // Combos span multiple providers. Use Anthropic format only when ALL // members resolve to Anthropic — otherwise fall back to OpenAI-compat // (lowest common denominator that every upstream understands). const comboApiBlock = (() => { if (!hasMembers) return resolveApiBlock("", baseURL, apiFormat); const allAnthropic = members.every( (m) => resolveApiBlock(m.id, baseURL, apiFormat).id === "anthropic" ); return allAnthropic ? resolveApiBlock(members[0].id, baseURL, apiFormat) : { id: "openai-compatible", url: ensureV1Suffix(baseURL), npm: "@ai-sdk/openai-compatible", }; })(); return { id: combo.id, providerID: providerId, api: comboApiBlock, 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 ?? "", }; } // ───────────────────────────────────────────────────────────────────────── // AUTO COMBOS — virtual server-side combos exposed via /api/combos/auto // ───────────────────────────────────────────────────────────────────────── /** * Raw shape of an auto combo entry as returned by OmniRoute's * `/api/combos/auto` endpoint. Auto combos are virtual — they self-manage * provider selection via scoring/bandit exploration at runtime. */ export interface OmniRouteRawAutoCombo { /** Stable id (e.g. "auto", "auto/coding"). */ id: string; /** Human-readable name (e.g. "Auto", "Auto Coding"). */ name: string; /** Variant key or undefined for the default auto. */ variant?: AutoVariant; /** Provider names eligible for this auto combo. */ candidatePool?: string[]; /** Number of candidates resolved at fetch time. */ candidateCount?: number; /** MAX of candidates' context windows, served by newer OmniRoute builds. * Absent on older servers — mapper falls back to a safe positive default. */ context_length?: number; /** MAX of candidates' max output tokens (same provenance as context_length). */ max_output_tokens?: number; /** Whether this auto combo should be hidden from the picker. */ isHidden?: boolean; /** Auto-combo configuration. */ config?: { auto?: { candidatePool?: string[]; explorationRate?: number; routerStrategy?: string; }; }; } /** * Fetcher contract for `/api/combos/auto`. Returns the list of virtual * auto combos the server can create. Same DI pattern as other fetchers. */ export type OmniRouteAutoCombosFetcher = ( baseURL: string, apiKey: string, timeoutMs?: number ) => Promise; /** * Default auto combos fetcher: `GET /api/combos/auto`. * * Fault-tolerant: returns empty array on 404 (endpoint doesn't exist yet) * or any non-2xx / network error. Logs a warning in those cases. */ export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = async ( baseURL, apiKey, timeoutMs = 5_000 ) => { if (!apiKey || !baseURL) return []; const trimmed = trimTrailingSlashes(baseURL); const root = trimmed.replace(/\/v\d+$/, ""); const url = `${root}/api/combos/auto`; 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, }); // 404 = endpoint not deployed yet — expected during rollout if (res.status === 404) { console.warn( `[omniroute-plugin] /api/combos/auto not available (404) — auto combos disabled` ); return []; } if (!res.ok) { console.warn( `[omniroute-plugin] /api/combos/auto failed: ${res.status} ${res.statusText} — auto combos disabled` ); return []; } 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: OmniRouteRawAutoCombo[] = []; for (const r of rawList) { if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") { out.push(r as OmniRouteRawAutoCombo); } } return out; } catch (err) { // Network error, timeout, abort — all non-fatal console.warn( `[omniroute-plugin] /api/combos/auto fetch failed: ${err instanceof Error ? err.message : String(err)} — auto combos disabled` ); return []; } finally { clearTimeout(timer); } }; /** Fallbacks when the server does not advertise auto-combo limits (older * OmniRoute builds). MUST be positive: OpenCode's overflow guard treats * `limit.context === 0` as "never overflow" and silently DISABLES smart * auto-compaction, letting the session grow until the gateway's destructive * history purge kicks in (the "agent keeps forgetting things" bug). */ const AUTO_COMBO_FALLBACK_CONTEXT = 128_000; const AUTO_COMBO_FALLBACK_OUTPUT = 8_192; /** * Convert a raw auto combo into a static model entry for the OpenCode picker. * Auto combos have tool_call=true, reasoning=true by default (they route * to capable models). Context/output limits come from the server (MAX of * the candidate pool's windows — the gateway's context pre-filter routes * oversized requests to large-window candidates); a safe positive fallback * applies when the server omits them. Never 0. */ export function mapAutoComboToStaticEntry( autoCombo: OmniRouteRawAutoCombo ): OmniRouteStaticModelEntry { const variant = autoCombo.variant; const name = formatAutoComboName(variant, autoCombo.candidateCount); const context = typeof autoCombo.context_length === "number" && autoCombo.context_length > 0 ? autoCombo.context_length : AUTO_COMBO_FALLBACK_CONTEXT; const output = typeof autoCombo.max_output_tokens === "number" && autoCombo.max_output_tokens > 0 ? autoCombo.max_output_tokens : AUTO_COMBO_FALLBACK_OUTPUT; // No `providerID` field on static-catalog entries — OC ignores it on the static // path, and stamping it on auto-combos but not on raw/combo entries was an // internal inconsistency. The dynamic-hook path builds its ModelV2 from the // individual fields below and never read this field either. return { name, attachment: false, reasoning: true, temperature: true, tool_call: true, limit: { context, output }, modalities: { input: ["text"], output: ["text"], }, cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 }, }; } // ───────────────────────────────────────────────────────────────────────── // 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`). * 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`, `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; /** Free-model budget type (from freeModelCatalog). */ freeType?: FreeModelFreeType; /** Monthly token budget for recurring free models. */ monthlyTokens?: number; /** Credit token budget for credit-based free models. */ creditTokens?: number; } /** 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); } // ── 3. Free model budgets from /api/free-tier/summary ────────────────── // Best-effort fetch: populates freeType/monthlyTokens/creditTokens on // enrichment entries that match. 404 = endpoint doesn't exist — skip. // Uses the EXISTING /api/free-tier/summary endpoint (no new server code). const freeAc = new AbortController(); const freeTimer = setTimeout(() => freeAc.abort(), timeoutMs); try { const res = await fetch(`${root}/api/free-tier/summary`, { method: "GET", headers, signal: freeAc.signal, }); if (res.ok) { const body = (await res.json()) as unknown; // Response shape: { perModel: FreeModelBudget[], ... } const perModel: unknown[] = body && typeof body === "object" && Array.isArray((body as { perModel?: unknown }).perModel) ? ((body as { perModel: unknown[] }).perModel as unknown[]) : Array.isArray(body) ? (body as unknown[]) : []; let matched = 0; for (const fm of perModel) { if (!fm || typeof fm !== "object") continue; const fmObj = fm as Record; const provider = typeof fmObj.provider === "string" ? fmObj.provider : ""; const modelId = typeof fmObj.modelId === "string" ? fmObj.modelId : ""; const freeType = typeof fmObj.freeType === "string" ? fmObj.freeType : ""; if (!modelId || !freeType) continue; const monthlyTokens = typeof fmObj.monthlyTokens === "number" ? fmObj.monthlyTokens : undefined; const creditTokens = typeof fmObj.creditTokens === "number" ? fmObj.creditTokens : undefined; // Match against enrichment entries: namespaced, bare, and displayName const displayName = typeof fmObj.displayName === "string" ? fmObj.displayName : ""; const candidates = [ `${provider}/${modelId}`, modelId, ...(displayName ? [displayName] : []), ]; for (const key of candidates) { const entry = out.get(key); if (entry) { entry.freeType = freeType as FreeModelFreeType; if (monthlyTokens !== undefined) entry.monthlyTokens = monthlyTokens; if (creditTokens !== undefined) entry.creditTokens = creditTokens; matched++; break; } } } _logger.debug( `free-tier/summary: ${perModel.length} models returned, ${matched} matched enrichment entries` ); } } catch { // Soft-fail; free metadata is optional. } finally { clearTimeout(freeTimer); } return out; }; // ── Startup diagnostics writer (file-based) ────────────────────────────── // OC doesn't capture plugin console.warn in its log file. Write diagnostics // to a file so they're readable after session starts. Capped at 64KB. async function writeStartupDiagnostics(params: { providerId: string; baseURL: string; modelCount: number; comboCount: number; enrichmentSize: number; autoComboCount: number; enrichment: OmniRouteEnrichmentMap; autoCombos: OmniRouteRawAutoCombo[]; }): Promise { const { providerId, baseURL, modelCount, comboCount, enrichmentSize, autoComboCount, enrichment, autoCombos, } = params; const enriched = [...enrichment.entries()]; const withName = enriched.filter(([, e]) => e.name); const withPricing = enriched.filter(([, e]) => e.pricing); const withFree = enriched.filter(([, e]) => e.freeType); const lines: string[] = []; lines.push(`=== startupDebug ${new Date().toISOString()} ===`); lines.push(`providerId=${providerId} baseURL=${baseURL}`); lines.push( `models=${modelCount} combos=${comboCount} enrichment=${enrichmentSize} autoCombos=${autoComboCount}` ); lines.push( `enrichment: ${withName.length} with name, ${withPricing.length} with pricing, ${withFree.length} free` ); if (withFree.length > 0) { lines.push(`free models (${withFree.length}):`); for (const [k, e] of withFree.slice(0, 10)) { lines.push( ` ${k} → name=${e.name ?? "(none)"}, freeType=${e.freeType}, monthly=${e.monthlyTokens ?? 0}, credits=${e.creditTokens ?? 0}` ); } } else { lines.push( `NO free models detected. ` + (enrichmentSize === 0 ? "Enrichment map is EMPTY." : `Enrichment has ${enrichmentSize} entries but none have freeType.`) ); } const sampleNames = enriched .filter(([, e]) => e.name) .slice(0, 5) .map(([k, e]) => ` ${k} → "${e.name}"`); if (sampleNames.length > 0) { lines.push(`sample enriched names:`); lines.push(sampleNames.join("\n")); } if (autoCombos.length > 0) { lines.push( `auto combos: ${autoCombos.length} — ${autoCombos.map((ac) => `${ac.id}(${ac.candidateCount ?? "?"}p)`).join(", ")}` ); } lines.push(`=== end startupDebug ===\n`); const diagnostics = lines.join("\n"); _logger.debug(diagnostics); try { const diagDir = process.env.OPENCODE_DATA_DIR ?? path.join(os.homedir(), ".local/share/opencode"); const diagPath = path.join(diagDir, "plugins", "omniroute-startup-diagnostics.log"); let existing = ""; try { existing = await readFile(diagPath, "utf8"); } catch { /* first write */ } const KEEP = 65_536; const combined = existing + diagnostics; const trimmed = combined.length > KEEP ? combined.slice(combined.length - KEEP) : combined; await writeFile(diagPath, trimmed, "utf8"); } catch { /* best effort */ } } /** * 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 = _PROVIDER_TAG_SEPARATOR; // Re-export from naming.ts — thin wrapper preserving OmniRouteEnrichmentEntry signature export function shortProviderLabel( enrichment: OmniRouteEnrichmentEntry | undefined ): string | undefined { return _shortProviderLabel(enrichment); } /** * Prepend the upstream provider label to `model.name` so the OC TUI * picker can differentiate the same model id sold through different * upstream connections (e.g. `cc/claude-opus-4-7` via Anthropic * vs `kr/claude-opus-4-7` via Kiro). Result shape: * * `