automindy's picture
Upload 2581 files
5448d8b verified
Raw
History Blame Contribute Delete
162 kB
/**
* 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.<id>.models` blocks in
* opencode.json[c]:
*
* - `auth` β€” registers `/connect <providerId>` 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 <providerId>` 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.<providerId>` remote entry
* into the OC config pointing at
* `<baseURL>/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<typeof optionsSchema>`.
*/
export type OmniRoutePluginOptions = z.infer<typeof optionsSchema>;
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<OmniRoutePluginOptions, "providerId" | "displayName" | "modelCacheTtl">> &
Pick<OmniRoutePluginOptions, "baseURL" | "features"> {
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(".") : "<root>";
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<Hooks>` β€” 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<OmniRouteRawModelEntry[]>;
/**
* Default fetcher: `GET <baseURL>/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: 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: {
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<OmniRouteRawCombo[]>;
/**
* Default fetcher: `GET <baseURL>/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<Set<string>>, 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)),
};
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<string, OmniRouteEnrichmentEntry>;
export type OmniRouteEnrichmentFetcher = (
baseURL: string,
apiKey: string,
timeoutMs?: number
) => Promise<OmniRouteEnrichmentMap>;
/**
* 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<string, { models?: unknown[] }> })?.providers ??
(body as Record<string, { models?: unknown[] }>);
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<string, unknown>)) {
if (!slot || typeof slot !== "object" || Array.isArray(slot)) continue;
for (const [modelId, raw] of Object.entries(slot as Record<string, unknown>)) {
if (!raw || typeof raw !== "object") continue;
const p = raw as Record<string, unknown>;
const parsed: NonNullable<OmniRouteEnrichmentEntry["pricing"]> = {};
// 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: `<short-label> - <model name>` (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
* `<label> - <model>` prefix.
*
* Rule (matches user spec β€” no hardcoded registry, fully data-driven):
*
* 1. Trim `enrichment.providerDisplayName` (= `/api/pricing/models[<alias>].name`).
* 2. If the trimmed label is non-empty AND ≀ {@link PROVIDER_LABEL_MAX_CHARS} (12),
* use it verbatim (e.g. `Claude`, `Kiro`, `AssemblyAI`, `Antigravity`).
* 3. Otherwise look at the alias:
* - if the alias is short (≀ {@link ALIAS_UPPER_MAX_CHARS}) β†’
* `UPPER(alias)` (e.g. `cc` β†’ `CC`, `ghm` β†’ `GHM`).
* - if the alias is longer β†’ title-case it (`antigravity` β†’
* `Antigravity`) so the prefix is readable, not shouty.
* 4. If neither field is usable, return `undefined` (caller should
* skip the prefix decoration).
*/
export function shortProviderLabel(
enrichment: OmniRouteEnrichmentEntry | undefined
): string | undefined {
if (!enrichment) return undefined;
const raw =
typeof enrichment.providerDisplayName === "string" ? enrichment.providerDisplayName.trim() : "";
if (raw.length > 0 && raw.length <= PROVIDER_LABEL_MAX_CHARS) return raw;
const alias = typeof enrichment.providerAlias === "string" ? enrichment.providerAlias.trim() : "";
if (alias.length > 0) {
return alias.length <= ALIAS_UPPER_MAX_CHARS ? alias.toUpperCase() : titleCaseAlias(alias);
}
// Tolerate "label too long + no alias" by falling back to the long
// label itself β€” better than dropping the prefix entirely. Rare case.
return raw.length > 0 ? raw : undefined;
}
/**
* 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:
*
* `<label>${PROVIDER_TAG_SEPARATOR}<enriched name>`
* β†’ `Claude - Claude Opus 4.7`
* β†’ `Kiro - Claude Opus 4.7`
* β†’ `AssemblyAI - Universal 2 (Transcription)` (slot.name fits, used verbatim)
* β†’ `GHM - GPT 5` (slot.name "GitHub Models" > 12 chars β†’ UPPER(alias))
*
* Mutates the model in place and is idempotent β€” running twice never
* double-prefixes. No-op when:
*
* - `enrichment` is undefined,
* - {@link shortProviderLabel} returns `undefined`
* (no `providerDisplayName` AND no `providerAlias`),
* - the current `model.name` already starts with the prefix.
*
* Combos are intentionally skipped by callers (they're multi-upstream
* by definition; the `Combo: ` prefix conveys that). Raw models call
* this after `applyEnrichment` so the tag layers on top of the
* friendly name.
*/
export function applyProviderTag(
model: ModelV2,
enrichment: OmniRouteEnrichmentEntry | undefined
): ModelV2 {
const label = shortProviderLabel(enrichment);
if (!label) return model;
const prefix = `${label}${PROVIDER_TAG_SEPARATOR}`;
if (model.name.startsWith(prefix)) return model;
model.name = `${prefix}${model.name}`;
return model;
}
/**
* Reverse-index the enrichment map from `providerCanonical β†’ providerAlias`.
*
* OmniRoute's `/api/pricing/models` is keyed by short ALIAS (`cc`, `cx`,
* `pol`). But `/v1/models` exposes some models a SECOND time under their
* CANONICAL name (`claude/claude-opus-4-7`, `codex/gpt-5.5`,
* `pollinations/midjourney`). Without a reverse map, those canonical
* rows miss enrichment entirely and surface as raw ids in the picker.
*
* Built once per refresh from the enrichment entries themselves β€” no
* hardcoded registry. Only records `canonical β†’ alias` mappings when
* both are present AND distinct (skips slots where alias === canonical
* like `kiro`).
*/
export function buildCanonicalToAliasMap(
enrichment: OmniRouteEnrichmentMap | undefined
): Map<string, string> {
const out = new Map<string, string>();
if (!enrichment) return out;
for (const entry of enrichment.values()) {
const alias = typeof entry.providerAlias === "string" ? entry.providerAlias.trim() : "";
const canonical =
typeof entry.providerCanonical === "string" ? entry.providerCanonical.trim() : "";
if (alias.length === 0 || canonical.length === 0) continue;
if (alias === canonical) continue;
if (!out.has(canonical)) out.set(canonical, alias);
}
return out;
}
/**
* Enrichment lookup with alias-fallback chain.
*
* Resolution order (first hit wins):
*
* 1. `enrichment.get(rawId)` β€” direct hit on `<prefix>/<modelId>` or
* bare id (the fetcher writes under both forms).
* 2. If `rawId` is `<canonical>/<modelId>` and `canonicalToAlias` has
* a mapping for `canonical`, try `<alias>/<modelId>`. This rescues
* duplicate rows like `claude/claude-opus-4-7` (canonical) when
* enrichment only indexed under `cc/claude-opus-4-7` (alias).
* 3. Bare `<modelId>` as a last resort. Already covered by step 1 in
* practice (fetcher writes bare keys), but kept defensive.
*
* Returns `undefined` when no lookup hits.
*/
export function lookupEnrichment(
rawId: string,
enrichment: OmniRouteEnrichmentMap | undefined,
canonicalToAlias: Map<string, string>
): OmniRouteEnrichmentEntry | undefined {
if (!enrichment) return undefined;
const direct = enrichment.get(rawId);
if (direct) return direct;
const slash = rawId.indexOf("/");
if (slash > 0) {
const prefix = rawId.slice(0, slash);
const modelId = rawId.slice(slash + 1);
const alias = canonicalToAlias.get(prefix);
if (alias && alias !== prefix) {
const viaAlias = enrichment.get(`${alias}/${modelId}`);
if (viaAlias) return viaAlias;
}
const bare = enrichment.get(modelId);
if (bare) return bare;
}
return undefined;
}
/**
* Pre-pass: detect raw rows that are the CANONICAL twin of an ALIAS row
* already in the catalog. Returns the set of canonical-keyed ids to skip
* during the raw-model loop so each model surfaces exactly once under
* its enriched alias key.
*
* Example: `/v1/models` returns BOTH `cc/claude-opus-4-7` and
* `claude/claude-opus-4-7`. The former is enriched (alias `cc` exists
* in `/api/pricing/models`); the latter is raw. We keep `cc/...` and
* drop `claude/...`.
*
* Built once per refresh. Cheap β€” O(M) where M = raw model count.
*/
export function canonicalDedupSet(
rawModels: ReadonlyArray<OmniRouteRawModelEntry>,
canonicalToAlias: Map<string, string>
): Set<string> {
const drop = new Set<string>();
if (canonicalToAlias.size === 0) return drop;
// Index every alias key present in the raw catalog.
const aliasKeys = new Set<string>();
for (const m of rawModels) {
if (typeof m.id === "string" && m.id.length > 0) aliasKeys.add(m.id);
}
for (const m of rawModels) {
if (typeof m.id !== "string" || m.id.length === 0) continue;
const slash = m.id.indexOf("/");
if (slash <= 0) continue;
const prefix = m.id.slice(0, slash);
const modelId = m.id.slice(slash + 1);
const alias = canonicalToAlias.get(prefix);
if (!alias || alias === prefix) continue;
// Canonical row only gets suppressed if the alias row actually
// exists β€” otherwise we'd hide the model entirely.
if (aliasKeys.has(`${alias}/${modelId}`)) drop.add(m.id);
}
return drop;
}
/**
* Build a per-alias index of enrichment metadata so we can render the
* provider prefix even for raw models that don't have their own
* curated `/api/pricing/models` entry.
*
* Real example: OmniRoute's `pricing['cohere']` slot lists 10 curated
* models but `/v1/models` also returns `cohere/rerank-multilingual-v3.0`
* and `cohere/rerank-v4.0-fast` (not in the curated 10). Without this
* index, those rows surface in the picker as `cohere/...` with no
* `Cohere - ` prefix because the per-model enrichment lookup misses.
*
* This index records the first non-empty `providerDisplayName` seen
* for each alias, plus the alias itself. Callers use it to synthesize
* a minimal `OmniRouteEnrichmentEntry` whenever the direct lookup
* misses but the raw id's prefix matches a known alias.
*
* Built once per refresh; first-wins on duplicate alias (matches
* `buildCanonicalToAliasMap` semantics).
*/
export function buildAliasIndex(
enrichment: OmniRouteEnrichmentMap | undefined
): Map<string, OmniRouteEnrichmentEntry> {
const out = new Map<string, OmniRouteEnrichmentEntry>();
if (!enrichment) return out;
for (const entry of enrichment.values()) {
const alias = typeof entry.providerAlias === "string" ? entry.providerAlias.trim() : "";
if (alias.length === 0) continue;
if (out.has(alias)) {
// First-wins, but upgrade to the first entry that carries a
// non-empty providerDisplayName so the prefix renders nicely.
const existing = out.get(alias);
if (
existing &&
(!existing.providerDisplayName || existing.providerDisplayName.trim().length === 0) &&
typeof entry.providerDisplayName === "string" &&
entry.providerDisplayName.trim().length > 0
) {
out.set(alias, entry);
}
continue;
}
out.set(alias, entry);
}
return out;
}
/**
* Resolve a synthesised enrichment entry for `applyProviderTag` /
* `shortProviderLabel` consumption, combining two sources:
*
* 1. The direct per-model enrichment match (if present).
* 2. A per-alias fallback derived from `buildAliasIndex` β€” covers raw
* ids whose prefix matches a known alias but the specific model
* id wasn't curated in `/api/pricing/models`. Example:
* `cohere/rerank-multilingual-v3.0` falls back to the cohere slot's
* `providerDisplayName='Cohere'` even though that specific id
* isn't in the curated 10-model list.
*
* Returns `undefined` when neither source surfaces an alias.
*
* NOTE: this function is read-only over its inputs; it never mutates
* the underlying `direct` entry. When it falls back to the alias
* index, it constructs a fresh minimal entry exposing only the
* provider-prefix fields (`providerAlias`, `providerCanonical`,
* `providerDisplayName`). Other fields (name, pricing) are explicitly
* left undefined so `applyEnrichment` won't accidentally overwrite a
* model name with the alias-slot label.
*/
export function resolveProviderTagEntry(
rawId: string,
direct: OmniRouteEnrichmentEntry | undefined,
aliasIndex: Map<string, OmniRouteEnrichmentEntry>,
canonicalToAlias?: Map<string, string>
): OmniRouteEnrichmentEntry | undefined {
if (direct) {
const alias = typeof direct.providerAlias === "string" ? direct.providerAlias.trim() : "";
const display =
typeof direct.providerDisplayName === "string" ? direct.providerDisplayName.trim() : "";
if (alias.length > 0 || display.length > 0) return direct;
}
const slash = rawId.indexOf("/");
if (slash <= 0) return direct;
const prefix = rawId.slice(0, slash);
// 1. Direct alias lookup (`cohere/...` β†’ cohere slot keyed by alias=cohere).
let fromAlias = aliasIndex.get(prefix);
// 2. Canonical fallback (`pollinations/...` β†’ look up via alias `pol`).
if (!fromAlias && canonicalToAlias) {
const alias = canonicalToAlias.get(prefix);
if (alias) fromAlias = aliasIndex.get(alias);
}
if (!fromAlias) return direct;
// Synthesize: borrow only the provider-prefix metadata.
return {
providerAlias: fromAlias.providerAlias,
providerCanonical: fromAlias.providerCanonical,
providerDisplayName: fromAlias.providerDisplayName,
};
}
/**
* Apply enrichment overlay onto a ModelV2 entry. Mutates and returns the
* passed entry for convenience.
*/
export function applyEnrichment(
model: ModelV2,
enrichment: OmniRouteEnrichmentEntry | undefined
): ModelV2 {
if (!enrichment) return model;
if (enrichment.name && enrichment.name.trim().length > 0) {
model.name = enrichment.name;
}
if (enrichment.pricing) {
if (typeof enrichment.pricing.input === "number") {
model.cost.input = enrichment.pricing.input;
}
if (typeof enrichment.pricing.output === "number") {
model.cost.output = enrichment.pricing.output;
}
if (typeof enrichment.pricing.cacheRead === "number") {
model.cost.cache.read = enrichment.pricing.cacheRead;
}
if (typeof enrichment.pricing.cacheWrite === "number") {
model.cost.cache.write = enrichment.pricing.cacheWrite;
}
}
return model;
}
// ─────────────────────────────────────────────────────────────────────────
// COMPRESSION METADATA β€” pull /api/context/combos so combo entries can be
// tagged with their compression pipeline. Gated by
// features.compressionMetadata (off by default).
// ─────────────────────────────────────────────────────────────────────────
/** Single step in a compression combo's pipeline. */
export interface OmniRouteCompressionStep {
engine: string; // "rtk" | "caveman" | "aggressive" | ...
intensity?: string; // "minimal" | "lite" | "standard" | "full" | "ultra" | "aggressive"
}
/** Compression combo as returned by /api/context/combos. */
export interface OmniRouteCompressionCombo {
id: string;
name?: string;
description?: string;
pipeline: OmniRouteCompressionStep[];
isDefault?: boolean;
}
export type OmniRouteCompressionMetaFetcher = (
baseURL: string,
apiKey: string,
timeoutMs?: number
) => Promise<OmniRouteCompressionCombo[]>;
/**
* Default compression-metadata fetcher β€” calls `GET /api/context/combos`.
* Tolerates envelope shapes `{ combos: [...] }`, `[...]`, or
* `{ data: [...] }`. Soft-fails (returns []) on non-2xx or parse errors.
*/
export const defaultOmniRouteCompressionMetaFetcher: OmniRouteCompressionMetaFetcher = async (
baseURL,
apiKey,
timeoutMs = 10_000
) => {
const empty: OmniRouteCompressionCombo[] = [];
if (!baseURL || !apiKey) return empty;
const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, "");
const url = `${root}/api/context/combos`;
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
signal: ac.signal,
});
if (!res.ok) return empty;
const body = (await res.json()) as unknown;
const list = Array.isArray(body)
? body
: Array.isArray((body as { combos?: unknown[] })?.combos)
? (body as { combos: unknown[] }).combos
: Array.isArray((body as { data?: unknown[] })?.data)
? (body as { data: unknown[] }).data
: [];
const out: OmniRouteCompressionCombo[] = [];
for (const raw of list) {
if (!raw || typeof raw !== "object") continue;
const id = (raw as { id?: unknown }).id;
const pipeline = (raw as { pipeline?: unknown }).pipeline;
if (typeof id !== "string" || id.length === 0) continue;
if (!Array.isArray(pipeline)) continue;
const steps: OmniRouteCompressionStep[] = [];
for (const step of pipeline) {
if (!step || typeof step !== "object") continue;
const engine = (step as { engine?: unknown }).engine;
if (typeof engine !== "string" || engine.length === 0) continue;
const intensity = (step as { intensity?: unknown }).intensity;
const entry: OmniRouteCompressionStep = { engine };
if (typeof intensity === "string" && intensity.length > 0) {
entry.intensity = intensity;
}
steps.push(entry);
}
const combo: OmniRouteCompressionCombo = { id, pipeline: steps };
const name = (raw as { name?: unknown }).name;
if (typeof name === "string" && name.length > 0) combo.name = name;
const description = (raw as { description?: unknown }).description;
if (typeof description === "string") combo.description = description;
const isDefault = (raw as { isDefault?: unknown }).isDefault;
if (typeof isDefault === "boolean") combo.isDefault = isDefault;
out.push(combo);
}
return out;
} catch {
return empty;
} finally {
clearTimeout(timer);
}
};
/**
* Map of well-known compression-intensity tokens to a single emoji
* conveying "how much" compression is applied. Traffic-light palette:
*
* 🟒 minimal / lite β€” almost no loss
* 🟑 standard β€” balanced
* 🟠 aggressive / full β€” heavy
* πŸ”΄ ultra β€” extreme
*
* Lookup is case-insensitive. Unknown intensities fall through to the
* raw text form (`engine:<intensity>`) so we never hide a value that
* OmniRoute knows but the plugin doesn't.
*
* Exported for callers (and tests) that want to assemble their own
* pipeline strings.
*/
export const COMPRESSION_INTENSITY_EMOJI: Record<string, string> = {
minimal: "🟒",
lite: "🟒",
standard: "🟑",
aggressive: "🟠",
full: "🟠",
ultra: "πŸ”΄",
};
/**
* Format a compression pipeline as a short human-readable string for
* combo `name` decoration. Intensity tokens render as a traffic-light
* emoji so a column scan reveals "how compressed" the combo is at a
* glance:
*
* `[rtk🟑 β†’ caveman🟠]` (rtk:standard β†’ caveman:full)
* `[rtkπŸ”΄]` (rtk:ultra, single-step)
* `[caveman]` (engine without intensity, no emoji)
* `[rtk:custom-thing]` (unknown intensity, raw-text fallback)
*/
export function formatCompressionPipeline(pipeline: OmniRouteCompressionStep[]): string {
if (!pipeline || pipeline.length === 0) return "";
return (
"[" +
pipeline
.map((s) => {
if (!s.intensity) return s.engine;
const emoji = COMPRESSION_INTENSITY_EMOJI[s.intensity.toLowerCase()];
return emoji ? `${s.engine}${emoji}` : `${s.engine}:${s.intensity}`;
})
.join(" β†’ ") +
"]"
);
}
// ─────────────────────────────────────────────────────────────────────────
// /api/providers (provider-connection status) β€” optional read used by the
// `features.usableOnly` filter. Returns the operator's installed OmniRoute
// provider connections, each with `provider` (canonical id), `isActive`,
// `testStatus`. We treat a provider as USABLE when at least one of its
// connections is `isActive: true && testStatus: 'active'`. Aliases (e.g.
// `cc β†’ claude`) are resolved through the enrichment map.
// ─────────────────────────────────────────────────────────────────────────
/** Subset of `/api/providers/connections[]` we read. Other fields are kept as a permissive index signature. */
export interface OmniRouteProviderConnection {
/** Connection UUID. */
id: string;
/** Canonical provider id, e.g. `claude`, `gemini-cli`, `kiro`. Matches `entry.id` in `/api/pricing/models`. */
provider: string;
/** Connection auth flavor, e.g. `apikey`, `oauth`, `cookie`. */
authType?: string;
/** Operator-visible label. */
name?: string;
/** Operator toggle β€” when false, the connection is provisioned but disabled. */
isActive?: boolean;
/** Health-check verdict β€” `active` means routable; `expired`/`error`/`unavailable` mean not. */
testStatus?: string;
/** Permissive bag β€” additional fields (priority, backoffLevel, etc.) pass through untouched. */
[k: string]: unknown;
}
export type OmniRouteProvidersFetcher = (
baseURL: string,
apiKey: string,
timeoutMs?: number
) => Promise<OmniRouteProviderConnection[]>;
/**
* Default providers fetcher β€” calls `GET /api/providers`. Tolerates envelope
* shapes `{ connections: [...] }`, `[...]`, or `{ data: [...] }`. Soft-fails
* (returns []) on non-2xx or parse errors so the `usableOnly` filter
* gracefully degrades to "no filter" instead of hiding the whole catalog.
*/
export const defaultOmniRouteProvidersFetcher: OmniRouteProvidersFetcher = async (
baseURL,
apiKey,
timeoutMs = 10_000
) => {
const empty: OmniRouteProviderConnection[] = [];
if (!baseURL || !apiKey) return empty;
const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, "");
const url = `${root}/api/providers`;
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
signal: ac.signal,
});
if (!res.ok) return empty;
const body = (await res.json()) as unknown;
const list = Array.isArray(body)
? body
: Array.isArray((body as { connections?: unknown[] })?.connections)
? (body as { connections: unknown[] }).connections
: Array.isArray((body as { data?: unknown[] })?.data)
? (body as { data: unknown[] }).data
: [];
const out: OmniRouteProviderConnection[] = [];
for (const raw of list) {
if (!raw || typeof raw !== "object") continue;
const provider = (raw as { provider?: unknown }).provider;
if (typeof provider !== "string" || provider.length === 0) continue;
const id = (raw as { id?: unknown }).id;
const idStr = typeof id === "string" && id.length > 0 ? id : provider;
out.push({ ...(raw as Record<string, unknown>), id: idStr, provider });
}
return out;
} catch {
return empty;
} finally {
clearTimeout(timer);
}
};
/**
* Compute the set of provider aliases that have at least one healthy,
* active connection. Resolves alias β†’ canonical id through the enrichment
* map (which is keyed under both `${alias}/${id}` and bare `${id}` β€” we
* walk only the namespaced keys to derive the alias↔canonical mapping).
*
* Returns:
* - `aliases`: set of alias prefixes safe to keep (e.g. `cc`, `gemini-cli`).
* - `canonicals`: set of canonical provider ids (e.g. `claude`, `kiro`).
*
* Callers should treat membership in EITHER set as "usable" β€” raw model
* ids may be `<alias>/<model>` (`cc/claude-opus-4-7`) OR `<canonical>/<model>`
* (`claude/sonnet-4`) depending on the OmniRoute deployment's `/v1/models`
* surface shape.
*
* Subtract-filter semantics: callers MUST also keep models whose prefix is
* unknown to BOTH `/api/pricing/models` and `/api/providers` (e.g.
* agentrouter-style synthetic prefixes). The right boolean is "if I see this
* prefix in EITHER catalog table AND it's not usable, drop; otherwise keep".
*/
export function usableProviderAliasSet(
connections: OmniRouteProviderConnection[],
enrichment: OmniRouteEnrichmentMap | undefined
): { aliases: Set<string>; canonicals: Set<string>; knownAliases: Set<string> } {
const usableCanonicals = new Set<string>();
for (const c of connections) {
if (!c || c.isActive !== true) continue;
if (typeof c.testStatus === "string" && c.testStatus !== "active") continue;
if (typeof c.provider === "string" && c.provider.length > 0) {
usableCanonicals.add(c.provider);
}
}
const aliases = new Set<string>();
const knownAliases = new Set<string>();
if (enrichment) {
// Walk enrichment entries to map alias β†’ canonical via the metadata
// populated by `defaultOmniRouteEnrichmentFetcher`. Every entry carries
// its providerAlias + providerCanonical so the namespaced/bare key
// duplication is harmless. Collect EVERY alias we encounter (regardless
// of usability) into `knownAliases` so the downstream filter can decide
// "this prefix was in /api/pricing/models" in O(1) instead of O(E).
for (const entry of enrichment.values()) {
const alias = entry.providerAlias;
const canonical = entry.providerCanonical;
if (typeof alias !== "string" || alias.length === 0) continue;
knownAliases.add(alias);
if (typeof canonical !== "string" || canonical.length === 0) continue;
if (usableCanonicals.has(canonical)) aliases.add(alias);
}
}
// Always include every usable canonical as an alias too β€” handles the
// common case where `/v1/models` ids use the canonical id directly
// (e.g. `gemini-cli/gemini-1.5-pro`).
for (const canonical of usableCanonicals) aliases.add(canonical);
return { aliases, canonicals: usableCanonicals, knownAliases };
}
/**
* Decide whether a raw `/v1/models` id passes the `usableOnly` filter.
*
* Rules (subtract-filter β€” bias toward keep):
* - id has no `/` β†’ keep (combos/synthetic entries handled separately).
* - prefix matches a known usable alias OR canonical β†’ keep.
* - prefix is unknown to BOTH the connection table AND the enrichment
* map β†’ keep (we can't prove it's NOT usable; could be agentrouter).
* - prefix is known to the enrichment map BUT not in usable set β†’ drop.
*
* Pure function β€” exported so static + dynamic hooks share the same
* verdict logic without divergence.
*/
export function isUsableRawModelId(
id: string,
usable: { aliases: Set<string>; canonicals: Set<string>; knownAliases: Set<string> },
enrichment: OmniRouteEnrichmentMap | undefined
): boolean {
const slash = id.indexOf("/");
if (slash <= 0) return true;
const prefix = id.slice(0, slash);
if (usable.aliases.has(prefix) || usable.canonicals.has(prefix)) return true;
// O(1) "known prefix" check via pre-calculated knownAliases set.
// If prefix was in /api/pricing/models but is NOT in usable set,
// drop the model. Unknown prefixes (e.g. agentrouter-style synthetic)
// pass through (subtract-filter semantics).
if (usable.knownAliases.has(prefix)) return false;
return true;
}
/**
* Decide whether a combo passes the `usableOnly` filter. A combo keeps
* when AT LEAST ONE of its members maps to a usable canonical provider.
* Combos with zero resolvable members pass through (already degraded to
* all-false LCD posture and surfaced as cosmetic-only entries).
*/
export function isUsableCombo(
combo: OmniRouteRawCombo,
usable: { aliases: Set<string>; canonicals: Set<string>; knownAliases: Set<string> }
): boolean {
const steps = Array.isArray(combo.models) ? combo.models : [];
if (steps.length === 0) return true;
// The provider id is folded INTO the full model string by OmniRoute's
// `normalizeComboRecord` (e.g. "cc/claude-opus-4-7") β€” combo member refs do
// NOT carry a separate `providerId` field. Derive the prefix from `step.model`
// and apply the same subtract-filter verdict as `isUsableRawModelId`.
let sawResolvableMember = false;
for (const step of steps) {
// Nested combo refs carry no model id we can resolve to a provider here.
if (step?.kind === "combo-ref") continue;
const modelId = typeof step?.model === "string" ? step.model : "";
const slash = modelId.indexOf("/");
if (slash <= 0) continue; // no provider prefix to evaluate
sawResolvableMember = true;
const prefix = modelId.slice(0, slash);
if (usable.aliases.has(prefix) || usable.canonicals.has(prefix)) return true;
// Unknown prefix (not in the known-alias universe) β†’ can't prove
// unroutable; keep. Known-but-not-usable prefixes keep scanning.
if (!usable.knownAliases.has(prefix)) return true;
}
// No member resolved to a provider prefix β†’ can't prove unroutable; keep.
if (!sawResolvableMember) return true;
// Every resolvable member used a known-but-non-usable prefix β†’ drop.
return false;
}
/**
* Slugify a combo display name into a copy/paste-friendly URL-safe segment.
* Lowercases, replaces any run of non-alphanumeric chars with a single dash,
* trims leading/trailing dashes. Empty input or all-special input returns
* the empty string (caller must fall back to the combo's UUID id).
*
* Example: `Claude Tier` β†’ `claude-tier`, `GPT 5.5 / Pro` β†’ `gpt-5-5-pro`.
*/
export function slugifyComboName(name: string): string {
if (typeof name !== "string") return "";
return trimLeadingDashes(trimTrailingDashes(name.toLowerCase().replace(/[^a-z0-9]+/g, "-")));
}
/**
* Build a combo's static-block key (`combo/<slug>`), guaranteeing uniqueness
* across an entire static catalog. If `<slug>` is already present in `used`,
* suffixes a short UUID-prefix disambiguator from `combo.id` so the second
* combo doesn't silently overwrite the first. Mutates `used` in place by
* recording the chosen key. Returns the final `combo/<...>` key.
*
* Falls back to `combo/<id>` when the friendly name slugifies to the empty
* string (e.g. a combo named just punctuation).
*/
export function buildComboKey(combo: OmniRouteRawCombo, used: Set<string>): string {
const friendlyName = combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id;
let slug = slugifyComboName(friendlyName);
if (slug.length === 0) slug = combo.id;
let key = `combo/${slug}`;
if (used.has(key)) {
const tail = combo.id.split("-")[0] ?? combo.id;
key = `combo/${slug}-${tail}`;
// Defensive: in the (impossible) event the disambiguated key also
// collides, append the full id.
if (used.has(key)) key = `combo/${slug}-${combo.id}`;
}
used.add(key);
return key;
}
/**
* Internal cache key: `${baseURL}::sha256(apiKey)`. We hash the apiKey so
* the key is safe to log / inspect via debugger without leaking the secret.
* Different (baseURL, apiKey) tuples MUST keep independent cache entries:
* a single OC user may register prod + preprod OmniRoute side-by-side with
* distinct keys, and serving one's catalog from the other's cache would be
* a correctness bug, not just a privacy one.
*/
// codeql[js/insufficient-password-hash]: the input here is an API-key
// identifier we use solely to derive an in-memory cache lookup key β€” it is
// never stored, transmitted, compared against a hash, or used as a password.
// SHA-256 is intentional: cheap + deterministic, prevents the raw secret
// from sitting in memory dumps alongside the cache map. Slow KDFs (bcrypt/
// argon2) would defeat the purpose (sub-ms lookups on every request).
function modelsCacheKey(baseURL: string, credentialId: string): string {
const h = createHash("sha256").update(credentialId).digest("hex");
return `${baseURL}::${h}`;
}
/**
* Shared fetch-result cache entry. Holds the RAW `/v1/models` + `/api/combos`
* responses (NOT a pre-derived ModelV2 / static-entry shape) so the provider
* hook (T-03/T-05) and the config-shim hook (T-07) can derive their own
* output shapes from the same source without re-fetching.
*
* Why raw instead of derived:
* - provider hook emits ModelV2 (rich nested capabilities + cost + limits).
* - config hook emits the stripped sibling shape
* (`{name, attachment, reasoning, tool_call, temperature, limit?}`).
* - These overlap but neither is a superset of the other (ModelV2 has no
* `tool_call` field β€” it's `toolcall`; the stripped shape has no
* `cost`/`status`/`headers`). Caching the raw responses is the only
* lossless option.
* - On OC β‰₯1.14.49 cold start BOTH hooks fire within the same
* OmniRoutePlugin instance β€” sharing the cache means /v1/models +
* /api/combos each hit the gateway exactly ONCE per TTL refresh, not
* twice.
*/
export interface OmniRouteFetchCacheEntry {
rawModels: OmniRouteRawModelEntry[];
rawCombos: OmniRouteRawCombo[];
/** Display-name + pricing overlay from /api/pricing/models. Empty Map when feature is disabled or fetch failed. */
rawEnrichment: OmniRouteEnrichmentMap;
/** Compression combos from /api/context/combos. Empty array when feature is disabled or fetch failed. */
rawCompressionCombos: OmniRouteCompressionCombo[];
/** Provider connections from /api/providers. Empty array when feature is disabled or fetch failed. */
rawConnections: OmniRouteProviderConnection[];
expiresAt: number;
}
export type OmniRouteFetchCache = Map<string, OmniRouteFetchCacheEntry>;
/**
* Build the ProviderHook portion of the plugin for a given options bag.
* Exported standalone so the contract is unit-testable without faking the
* full PluginInput / Hooks surface, and so multi-instance setups can each
* own their own cache (a fresh hook closure per plugin tuple).
*
* Behavioural contract:
* - `id` binds to the resolved `providerId` (multi-instance: each plugin
* tuple's hook lists models under its own provider id).
* - `models(provider, ctx)` extracts the api key from `ctx.auth` (rejecting
* non-`api` flavors with `{}` β€” same posture as the auth loader); calls
* both `/v1/models` and `/api/combos` fetchers; maps raw `/v1/models`
* entries through `mapRawModelToModelV2`; maps each `/api/combos` entry
* through `mapComboToModelV2` (LCD across its member models); merges
* combos into the same map under their combo id; caches the unified
* result by `(baseURL, sha256(apiKey))` for `modelCacheTtl`.
* - **Combo / model ID collisions: combos win.** OmniRoute treats combos
* as the curated routing surface; if a combo and a raw model share an
* id the operator's intent is clearly the combo. We emit a
* `console.warn` exactly once per `(baseURL, apiKey, comboId)`
* collision so the operator can spot the unusual naming choice
* without log spam on every cache refresh.
* - **Combos fetch failure does NOT break the catalog**: soft-fail with
* a `console.warn` and fall back to a models-only catalog. Rationale:
* `/api/combos` requires a management-scoped key and OmniRoute may
* not have any combos provisioned (preprod returned `{combos: []}`
* at probe time). Hard-failing the entire catalog when combos are
* optional would silently hide the whole provider from OC's model
* picker.
* - **`/v1/models` fetch failure DOES propagate.** Without models
* there's no catalog at all, so an empty `{}` would just mask the
* error.
* - Cache is in-memory per hook instance, shared between models and
* combos (one fetch pair per (baseURL, apiKey) per TTL refresh).
*
* @param opts Plugin options (providerId, baseURL, modelCacheTtl, …).
* @param deps Dependency injection. `fetcher` defaults to the live
* `/v1/models` HTTP fetcher; `combosFetcher` defaults to the
* live `/api/combos` HTTP fetcher (override for tests / to
* disable combos by injecting one that returns `[]`). `now`
* defaults to `Date.now` (overridable for TTL tests). `cache`
* lets the caller share state across reconstructions (unused
* outside tests today).
*/
export function createOmniRouteProviderHook(
opts?: OmniRoutePluginOptions,
deps: {
fetcher?: OmniRouteModelsFetcher;
combosFetcher?: OmniRouteCombosFetcher;
enrichmentFetcher?: OmniRouteEnrichmentFetcher;
compressionMetaFetcher?: OmniRouteCompressionMetaFetcher;
providersFetcher?: OmniRouteProvidersFetcher;
now?: () => number;
cache?: OmniRouteFetchCache;
} = {}
): ProviderHook {
const resolved = resolveOmniRoutePluginOptions(opts);
const fetcher = deps.fetcher ?? defaultOmniRouteModelsFetcher;
// T-05: combo discovery merges `/api/combos` entries into the same map as
// `/v1/models`. Default fetcher is declared further down the file; the
// reference resolves at hook-invocation time, not at hook-construction
// time, so source-order beyond hoisting rules has no semantic effect.
const combosFetcher = deps.combosFetcher ?? defaultOmniRouteCombosFetcher;
const enrichmentFetcher = deps.enrichmentFetcher ?? defaultOmniRouteEnrichmentFetcher;
const compressionMetaFetcher =
deps.compressionMetaFetcher ?? defaultOmniRouteCompressionMetaFetcher;
const providersFetcher = deps.providersFetcher ?? defaultOmniRouteProvidersFetcher;
// Features defaults (mirror v0.1.0 behavior when unset).
const features = resolved.features ?? {};
const wantCombos = features.combos !== false;
const wantEnrichment = features.enrichment !== false;
const wantCompressionMeta = features.compressionMetadata === true;
const wantUsableOnly = features.usableOnly === true;
const wantProviderTag = features.providerTag !== false;
const now = deps.now ?? Date.now;
// T-07: cache holds RAW fetch results (not pre-derived ModelV2) so that
// the config-shim hook can share the same cache and derive its stripped
// sibling shape from the same source without a second round-trip.
const cache: OmniRouteFetchCache = deps.cache ?? new Map();
// T-05: collision-warning deduper. Emit warn once per (cacheKey, comboId)
// tuple per hook instance so the operator sees the unusual naming choice
// once per session, not once per cache refresh.
const collisionWarned = new Set<string>();
return {
id: resolved.providerId,
async models(_provider, ctx) {
// Auth narrowing β€” same posture as the auth loader (T-02). Non-api
// flavors and empty keys β†’ empty catalog. OC then exposes the
// /connect flow rather than spamming /v1/models with bad creds.
const auth = ctx?.auth;
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
) {
return {};
}
const apiKey = (auth as { key: string }).key;
// baseURL resolution: plugin opts first, then credential-attached
// baseURL (auth backends sometimes stash it next to the key). No
// silent default to localhost: a misconfigured plugin should surface
// a clear error, not phantom /v1/models calls. Cast through unknown
// because the Auth union (OAuth | ApiAuth | WellKnownAuth) doesn't
// declare baseURL on any branch β€” we duck-type it as a defensive
// extension point.
const authBaseURL = (auth as unknown as { baseURL?: unknown }).baseURL;
const baseURL = resolved.baseURL ?? (typeof authBaseURL === "string" ? authBaseURL : "");
if (!baseURL) {
return {};
}
const cacheKey = modelsCacheKey(baseURL, apiKey);
const t = now();
const cached = cache.get(cacheKey);
let rawModels: OmniRouteRawModelEntry[];
let rawCombos: OmniRouteRawCombo[];
let rawEnrichment: OmniRouteEnrichmentMap;
let rawCompressionCombos: OmniRouteCompressionCombo[];
let rawConnections: OmniRouteProviderConnection[];
if (cached && cached.expiresAt > t) {
rawModels = cached.rawModels;
rawCombos = cached.rawCombos;
rawEnrichment = cached.rawEnrichment;
rawCompressionCombos = cached.rawCompressionCombos;
rawConnections = cached.rawConnections;
} else {
// Models fetch is required (no catalog otherwise β†’ silent provider
// disappearance). We do NOT wrap this in a try; let the error
// propagate to OC's UI.
rawModels = await fetcher(baseURL, apiKey, 10_000);
// T-05: combos fetch is best-effort, gated by features.combos.
// Soft-fail on any error: emit a console.warn and fall back to a
// models-only catalog. Rationale: /api/combos requires a
// management-scoped key and OmniRoute may not have any combos
// provisioned. Hard-failing when combos are optional would
// silently hide the whole provider from OC's picker.
rawCombos = [];
if (wantCombos) {
try {
rawCombos = await combosFetcher(baseURL, apiKey, 10_000);
} catch (err) {
console.warn(
"[omniroute-plugin] combos fetch failed, falling back to models-only catalog",
err
);
}
}
// Enrichment fetch (nice names + pricing). Best-effort, gated by
// features.enrichment. Soft-fails to empty map.
rawEnrichment = new Map();
if (wantEnrichment) {
try {
rawEnrichment = await enrichmentFetcher(baseURL, apiKey, 10_000);
} catch (err) {
console.warn(
"[omniroute-plugin] enrichment fetch failed, falling back to raw ids",
err
);
}
}
// Compression metadata fetch. Off by default, gated by
// features.compressionMetadata. Soft-fails to empty array.
rawCompressionCombos = [];
if (wantCompressionMeta) {
try {
rawCompressionCombos = await compressionMetaFetcher(baseURL, apiKey, 10_000);
} catch (err) {
console.warn("[omniroute-plugin] compression-metadata fetch failed", err);
}
}
// Provider-connections fetch. Off by default, gated by
// features.usableOnly. Soft-fails to empty array β€” when the
// connection table is unreadable we skip the filter entirely
// (subtract-filter semantics: don't drop everything we couldn't
// verify).
rawConnections = [];
if (wantUsableOnly) {
try {
rawConnections = await providersFetcher(baseURL, apiKey, 10_000);
} catch (err) {
console.warn(
"[omniroute-plugin] /api/providers fetch failed; usableOnly filter disabled for this refresh",
err
);
}
}
cache.set(cacheKey, {
rawModels,
rawCombos,
rawEnrichment,
rawCompressionCombos,
rawConnections,
expiresAt: t + resolved.modelCacheTtl,
});
// Debug breadcrumb: surface fetch result so operators can confirm
// the dynamic pipeline fired and how much catalog OmniRoute returned.
// Emitted once per cache miss (TTL refresh) β€” quiet on cache hits.
console.warn(
`[omniroute-plugin] catalog refreshed for providerId=${resolved.providerId} baseURL=${baseURL}: ` +
`${rawModels.length} models + ${rawCombos.length} combos + ` +
`${rawEnrichment.size} enrichment entries + ` +
`${rawCompressionCombos.length} compression combos + ` +
`${rawConnections.length} connections ` +
`(TTL=${resolved.modelCacheTtl}ms)`
);
}
// Lookup index for LCD member resolution: O(1) per member lookup.
// Indexed by raw model `id` β€” combo steps reference this exact
// string per ComboModelStep in src/lib/combos/steps.ts.
const rawModelById = new Map<string, OmniRouteRawModelEntry>();
for (const entry of rawModels) {
if (entry.id) rawModelById.set(entry.id, entry);
}
// usableOnly filter β€” compute the set of usable alias prefixes once
// per refresh. Empty when feature is off OR connection fetch failed
// OR no connections returned, in which case we keep everything
// (subtract-filter semantics: only drop when we can prove a prefix
// is NOT usable; never hide the catalog on a soft-fail).
const usable =
wantUsableOnly && rawConnections.length > 0
? usableProviderAliasSet(rawConnections, rawEnrichment)
: undefined;
// Build the canonical→alias reverse map AND the canonical-dedup
// set once per refresh. Together they fix the dual-keyed
// `/v1/models` problem where the same model surfaces under BOTH
// `<alias>/<id>` (enriched) AND `<canonical>/<id>` (raw): we keep
// the alias key and skip the canonical twin entirely.
const canonicalToAlias = buildCanonicalToAliasMap(rawEnrichment);
const canonicalDedup = canonicalDedupSet(rawModels, canonicalToAlias);
const aliasIndex = buildAliasIndex(rawEnrichment);
// Map raw models β†’ ModelV2 keyed by id. When enrichment data is
// present (features.enrichment, default on), overlay the nicer
// display name + pricing from /api/pricing/models via the
// alias-fallback lookup chain (covers canonical rows lacking
// direct pricing entries).
const models: Record<string, ModelV2> = {};
for (const entry of rawModels) {
if (!entry.id) continue;
if (canonicalDedup.has(entry.id)) continue;
if (usable && !isUsableRawModelId(entry.id, usable, rawEnrichment)) continue;
const model = mapRawModelToModelV2(entry, {
providerId: resolved.providerId,
baseURL,
});
const enrichEntry = lookupEnrichment(entry.id, rawEnrichment, canonicalToAlias);
applyEnrichment(model, enrichEntry);
// Prepend upstream provider label (e.g. `Claude - Claude Opus 4.7`)
// so the picker groups same-model rows by upstream connection.
// Idempotent + gated by `features.providerTag` (default-on).
// Combos skip this on purpose. The alias-index fallback rescues
// raw rows like `cohere/rerank-multilingual-v3.0` whose specific
// model id isn't in `/api/pricing/models` but whose slot is.
if (wantProviderTag) {
const tagEntry = resolveProviderTagEntry(
entry.id,
enrichEntry,
aliasIndex,
canonicalToAlias
);
applyProviderTag(model, tagEntry);
}
models[entry.id] = model;
}
// Default compression combo (used to decorate ALL combo names when
// compression metadata is present). OmniRoute returns at most one
// entry with `isDefault: true` per /api/context/combos.
const defaultCompression = wantCompressionMeta
? rawCompressionCombos.find((c) => c.isDefault === true)
: undefined;
// T-05: map raw combos β†’ ModelV2. Skip hidden combos (operator
// preference β€” provisioned but intentionally not surfaced).
// Resolve each combo's member step list into the matching raw
// model entries; unknown member ids are silently dropped before
// mapComboToModelV2 sees them, which then degrades to the
// all-false LCD posture if zero members remain.
//
// Combos are keyed under the `combo/<slug>` namespace so the TUI
// picker separates them from provider/model pairs and the UUID
// never surfaces. This mirrors `buildStaticProviderEntry` so the
// static + dynamic catalogs publish identical keys.
const comboNames = new Set<string>();
for (const combo of rawCombos) {
if (!combo || combo.isHidden === true) continue;
const n = combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id;
if (typeof n === "string" && n.length > 0) comboNames.add(n);
}
for (const key of Object.keys(models)) {
if (comboNames.has(key)) delete models[key];
}
const usedComboKeys = new Set<string>();
for (const combo of rawCombos) {
if (!combo.id) continue;
if (combo.isHidden === true) continue;
// usableOnly filter β€” drop combos whose members all map to
// non-usable providers.
if (usable && !isUsableCombo(combo, usable)) continue;
const memberSteps = Array.isArray(combo.models) ? combo.models : [];
const memberEntries: OmniRouteRawModelEntry[] = [];
for (const step of memberSteps) {
// Use the unknown-bridge pattern from commit 91b137e6 so the
// DTS pass stays clean: ComboMemberRef declares `model?: string`
// but we still verify the runtime shape before consuming it.
const modelId = (step as unknown as { model?: unknown }).model;
if (typeof modelId !== "string" || modelId.length === 0) continue;
const member = rawModelById.get(modelId);
if (member) memberEntries.push(member);
}
const mapped = mapComboToModelV2(combo, memberEntries, resolved.providerId, baseURL);
const hasMembers = memberEntries.length > 0;
// Apply enrichment overlay to combos too (OmniRoute's
// /api/pricing/models surfaces combos alongside provider-scoped
// models with curated names).
applyEnrichment(mapped, rawEnrichment.get(combo.id));
// `Combo: ` prefix surfaces the combo nature in OC's model picker.
// Idempotent guard covers the case where enrichment overwrote
// mapped.name with an already-prefixed string. Mirrors the
// static-hook Combo:-prefix decoration.
if (!mapped.name.startsWith("Combo: ")) {
mapped.name = `Combo: ${mapped.name}`;
}
// Optionally decorate combo name with its compression pipeline.
// Only fires when features.compressionMetadata: true, OmniRoute
// returned at least one default compression combo, AND the
// combo has resolvable members β€” claiming compression on an
// unroutable combo would mislead the picker.
if (hasMembers && defaultCompression && defaultCompression.pipeline.length > 0) {
const tag = formatCompressionPipeline(defaultCompression.pipeline);
if (tag.length > 0 && !mapped.name.includes(tag)) {
mapped.name = `${mapped.name} ${tag}`;
}
}
const comboKey = buildComboKey(combo, usedComboKeys);
// Collision policy: combos win. Warn ONCE per (cacheKey, comboKey)
// when overwriting a same-key raw model so the operator can spot
// the unusual naming choice without log spam.
if (Object.prototype.hasOwnProperty.call(models, comboKey)) {
const dedupeKey = `${cacheKey}::${comboKey}`;
if (!collisionWarned.has(dedupeKey)) {
collisionWarned.add(dedupeKey);
console.warn(
`[omniroute-plugin] combo key "${comboKey}" collides with a model id; combo wins.`
);
}
}
models[comboKey] = mapped;
}
return models;
},
};
}
// ────────────────────────────────────────────────────────────────────────────
// Fetch interceptor (T-04) β€” Bearer + Content-Type injection on outbound
// provider requests targeting the configured OmniRoute baseURL
// ────────────────────────────────────────────────────────────────────────────
/**
* Build a `fetch`-compatible interceptor that injects `Authorization: Bearer`
* (and a default `Content-Type`) onto outbound requests targeting the given
* `baseURL`. Requests to any other host pass through untouched β€” the apiKey
* is treated as a secret bound to the configured OmniRoute instance and
* MUST NOT leak to third-party endpoints (a vector AI-SDKs occasionally
* exercise when a tool call rewrites the URL mid-flight).
*
* Ported from Alph4d0g's `opencode-omniroute-auth@1.2.1` `createFetchInterceptor`
* (their `dist/src/plugin.js:477-516`) with these intentional deviations:
*
* - **`baseURL` is required** here (no `localhost:20128/v1` fallback). T-04
* callers always have an authoritative baseURL (from plugin opts or
* auth.json); a silent local default would be a footgun.
* - **Content-Type defaulting is gated on `init.body` presence**. Their
* version unconditionally sets `application/json` even on `GET /v1/models`,
* which is harmless but noisy; we only set it when there's a body to
* describe.
* - **Gemini schema sanitisation is NOT applied here** β€” that's T-06's
* responsibility and will land as a body-transform step inside this
* same function (or as a thin wrapper around it).
* - **Header merge strategy mirrors theirs**: Request-attached headers
* first, then `init.headers` overlay, then our injected
* Authorization/Content-Type β€” so the apiKey we own ALWAYS wins over
* any caller-supplied Bearer for the same OmniRoute provider.
*
* @see https://opencode.ai/docs/plugins for the AuthLoaderResult.fetch contract
* (the returned function is invoked by the AI-SDK in lieu of global fetch).
*/
export function createOmniRouteFetchInterceptor(config: {
apiKey: string;
baseURL: string;
}): typeof fetch {
const trimmed = trimTrailingSlashes(config.baseURL);
// Use `<base>/` for prefix matching to prevent suffix-spoof attacks
// (e.g. baseURL `https://or.example.com/v1` should NOT match
// `https://or.example.com/v1-attacker.evil/...`).
const prefix = `${trimmed}/`;
return async (input, init = {}) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const targetsOmniRoute = url === trimmed || url.startsWith(prefix);
if (!targetsOmniRoute) {
return fetch(input, init);
}
// Merge order: Request-attached headers (when input is a Request) β†’
// init.headers overlay β†’ our injected headers last (so we win).
const headers = new Headers(input instanceof Request ? input.headers : undefined);
if (init.headers) {
const initHeaders = new Headers(init.headers);
initHeaders.forEach((value, key) => {
headers.set(key, value);
});
}
headers.set("Authorization", `Bearer ${config.apiKey}`);
// Only default Content-Type when the caller actually has a body AND
// hasn't already declared the media type themselves.
const hasBody = init.body != null || input instanceof Request;
if (!headers.has("Content-Type") && hasBody) {
headers.set("Content-Type", "application/json");
}
return fetch(input, { ...init, headers });
};
}
// ────────────────────────────────────────────────────────────────────────────
// Gemini tool-schema sanitisation (T-06) β€” strip JSON-schema keywords that
// the Gemini API rejects from outbound chat-completion / responses bodies
// when the target model is a Gemini variant.
// ────────────────────────────────────────────────────────────────────────────
/**
* JSON-Schema keywords that the Gemini API rejects when present anywhere in
* a function-calling tool definition. Standard OpenAI / Anthropic clients
* happily emit these (they're valid Draft-07 schema) but Gemini's tool
* validator throws on them, breaking OmniRoute β†’ Gemini chains transparently.
*
* Source: behavioural reverse-engineering from Alph4d0g's
* opencode-omniroute-auth@1.2.1 (dist/src/plugin.js:517).
*/
const GEMINI_SCHEMA_KEYS_TO_REMOVE = new Set(["$schema", "$ref", "ref", "additionalProperties"]);
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
/**
* Recursively strip `GEMINI_SCHEMA_KEYS_TO_REMOVE` from an arbitrary
* JSON-Schema-shaped record. Walks both the record's own properties and
* any nested objects / arrays so deeply nested `properties.x.properties.y`
* trees are reached without a separate traversal pass. Mutates in place
* and reports whether any key was deleted so callers can skip a
* `JSON.stringify` round-trip when nothing changed.
*/
function stripSchemaKeys(schema: Record<string, unknown>): boolean {
let changed = false;
for (const key of Object.keys(schema)) {
if (GEMINI_SCHEMA_KEYS_TO_REMOVE.has(key)) {
delete schema[key];
changed = true;
continue;
}
const value = schema[key];
if (Array.isArray(value)) {
for (const item of value) {
if (isRecord(item)) {
changed = stripSchemaKeys(item) || changed;
}
}
continue;
}
if (isRecord(value)) {
changed = stripSchemaKeys(value) || changed;
}
}
return changed;
}
/**
* Walk every tool definition in the payload and strip Gemini-incompatible
* schema keywords. Handles both chat-completion shape
* (`tools[].function.parameters`) and Responses-API shape
* (`tools[].input_schema`), plus the Gemini-native `function_declaration`
* variant some adapters use.
*
* Also strips top-level schema keywords from the payload itself β€” clients
* occasionally attach a top-level `$schema` declaration when re-serialising
* tool bundles, and Gemini rejects those too.
*/
function sanitizeToolSchemaContainer(payload: Record<string, unknown>): boolean {
let changed = false;
// Top-level keyword strip β€” covers payload-level `$schema` etc.
for (const key of Object.keys(payload)) {
if (GEMINI_SCHEMA_KEYS_TO_REMOVE.has(key)) {
delete payload[key];
changed = true;
}
}
const tools = (payload as { tools?: unknown }).tools;
if (!Array.isArray(tools)) {
return changed;
}
for (const tool of tools) {
if (!isRecord(tool)) continue;
const fn = (tool as { function?: unknown }).function;
if (isRecord(fn) && isRecord((fn as { parameters?: unknown }).parameters)) {
changed = stripSchemaKeys(fn.parameters as Record<string, unknown>) || changed;
}
const fnDecl = (tool as { function_declaration?: unknown }).function_declaration;
if (isRecord(fnDecl) && isRecord((fnDecl as { parameters?: unknown }).parameters)) {
changed = stripSchemaKeys(fnDecl.parameters as Record<string, unknown>) || changed;
}
const inputSchema = (tool as { input_schema?: unknown }).input_schema;
if (isRecord(inputSchema)) {
changed = stripSchemaKeys(inputSchema) || changed;
}
}
return changed;
}
/**
* Pure function β€” recursively strip Gemini-incompatible JSON-Schema
* keywords (`$schema`, `$ref`, `ref`, `additionalProperties`) from the
* tool definitions on a chat-completions / responses payload.
*
* Walks:
* - `payload.tools[].function.parameters` (OpenAI chat-completions shape)
* - `payload.tools[].function_declaration.parameters` (Gemini-native shape
* some adapters round-trip)
* - `payload.tools[].input_schema` (Responses-API shape)
* - all `properties.<x>` (and `properties.<x>.properties.<y>`…) inside
* each container, recursing through nested objects and arrays.
* - top-level payload keys (some clients attach a payload-level `$schema`).
*
* Returns the cleaned payload. Does NOT mutate input β€” clones first via
* `structuredClone` so callers can keep a reference to the original. If
* the payload is not a record, or carries no tools and no top-level
* stripped keys, returns a (still cloned) equivalent.
*
* Exported so the body-transform layer is unit-testable independent of the
* fetch wrapper.
*/
export function sanitizeGeminiToolSchemas(payload: unknown): unknown {
if (!isRecord(payload)) {
// Non-record payloads (string, array, number, null) can't carry tool
// schemas. Pass back the same value β€” there's nothing to clone-and-strip
// and propagating the original keeps caller semantics simple.
return payload;
}
// structuredClone is available in Node 18+; the package's engines field
// already requires Node >=22.22.3 so we can rely on it without a
// JSON round-trip fallback.
const cloned = structuredClone(payload) as Record<string, unknown>;
sanitizeToolSchemaContainer(cloned);
return cloned;
}
/**
* Detect whether a payload is bound for a Gemini model. Returns true if
* `payload.model` is a string AND matches any known Gemini routing pattern:
*
* - case-insensitive substring `gemini` (covers bare `gemini-1.5-pro`,
* `gemini-2.5-flash`, etc.)
* - `models/gemini-…` (Google Generative AI canonical id form)
* - `google-vertex/gemini-…` (OpenCode + AI-SDK Vertex routing prefix)
* - `gemini-cli/…` (real OmniRoute alias surfaced on b35 prod `/v1/models`)
*
* Liberal by design: a false positive (cleaning a payload that didn't
* need cleaning) costs only a structuredClone + one walk; a false negative
* breaks the whole chain by forwarding $schema/additionalProperties to
* Gemini which throws 400 INVALID_ARGUMENT. The first three checks
* collapse into the case-insensitive substring check, but they're
* documented separately so future maintainers see the intent.
*
* Exported so callers and tests can probe detection independent of the
* fetch wrapper.
*/
export function shouldSanitizeForGemini(payload: unknown): boolean {
if (!isRecord(payload)) return false;
const model = (payload as { model?: unknown }).model;
if (typeof model !== "string") return false;
return /gemini/i.test(model);
}
/**
* Module-level latch so the streaming-body warning fires AT MOST once per
* Node process. ReadableStream bodies can't be safely cloned + JSON-parsed
* without consuming the stream (and re-creating a stream that survives both
* read paths is non-trivial), so the sanitiser skips them β€” but we want
* the operator to see one heads-up that schema stripping won't run on
* those requests.
*/
let geminiStreamingWarningEmitted = false;
/**
* Wrapper over an inner `fetch` that applies Gemini schema sanitisation to
* outbound chat-completion / responses request bodies.
*
* Behaviour:
* - URL gate: only inspects requests whose URL path contains
* `/chat/completions` or `/responses` (lenient about prefix β€” works for
* `/v1/chat/completions`, `/openai/v1/chat/completions`, …).
* - Body extraction handles `string`, `Buffer` / `Uint8Array`,
* `URLSearchParams` (calls `.toString()`), `Blob` (`await .text()`),
* AND `Request` input where the body lives on the Request not init.
* `ReadableStream` bodies are skipped (see below).
* - Body must JSON.parse to a record; otherwise pass-through.
* - `shouldSanitizeForGemini` gates the actual transform β€” non-Gemini
* payloads pass through unchanged regardless of endpoint.
* - Fail-open: ANY error during extraction / parse / sanitise falls back
* to forwarding the original `(input, init)` to the inner fetch.
* Sanitisation is a best-effort guard, never a hard failure mode.
* - `ReadableStream` bodies β†’ skipped with a ONE-TIME `console.warn`.
* The Gemini-quirk only manifests with tool calls in the body, and
* OC streams plain text deltas; the operator should still know.
*
* @param inner The next fetch in the chain (typically the Bearer-injecting
* interceptor from `createOmniRouteFetchInterceptor`).
*/
export function createGeminiSanitizingFetch(inner: typeof fetch): typeof fetch {
return async (input, init) => {
try {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input instanceof Request
? input.url
: "";
// URL gate β€” match the path substring with prefix tolerance.
const targetsCompletions = url.includes("/chat/completions") || url.includes("/responses");
if (!targetsCompletions) {
return inner(input, init);
}
// Body extraction. Cover the body shapes the AI-SDK + adapter layer
// actually emit; bail to pass-through on anything we can't read
// synchronously without consuming a stream.
let rawBody: string | undefined;
const initBody = init?.body as unknown;
if (typeof initBody === "string") {
rawBody = initBody;
} else if (initBody instanceof URLSearchParams) {
// Form-encoded bodies are never chat-completion JSON; pass-through.
return inner(input, init);
} else if (typeof Buffer !== "undefined" && initBody instanceof Buffer) {
rawBody = initBody.toString("utf8");
} else if (initBody instanceof Uint8Array) {
rawBody = new TextDecoder().decode(initBody);
} else if (initBody instanceof ReadableStream) {
// Streaming body β€” skip with one-shot warning.
if (!geminiStreamingWarningEmitted) {
geminiStreamingWarningEmitted = true;
console.warn(
"[omniroute-plugin] sanitizeGemini: streaming Request body, skipping schema strip (Gemini may reject)"
);
}
return inner(input, init);
} else if (
initBody !== null &&
initBody !== undefined &&
typeof (initBody as { text?: unknown }).text === "function"
) {
// Blob-like (has .text(): Promise<string>). Streaming was already
// matched above β€” anything left with a `.text` method we can buffer.
try {
rawBody = await (initBody as { text(): Promise<string> }).text();
} catch {
return inner(input, init);
}
} else if (initBody === undefined && input instanceof Request) {
// Body lives on the Request object itself, not init. Clone before
// reading β€” consuming the original Request body would make it
// unreadable downstream.
try {
rawBody = await (input as Request).clone().text();
} catch {
return inner(input, init);
}
}
if (rawBody === undefined || rawBody.length === 0) {
return inner(input, init);
}
let payload: unknown;
try {
payload = JSON.parse(rawBody);
} catch {
// Non-JSON body β†’ pass-through, never throw.
return inner(input, init);
}
if (!shouldSanitizeForGemini(payload)) {
return inner(input, init);
}
const cleaned = sanitizeGeminiToolSchemas(payload);
const newBody = JSON.stringify(cleaned);
// Cloning init: we need to replace `body` without mutating the caller's
// init bag. If init was undefined (Request-input path), construct one.
const newInit: RequestInit = { ...(init ?? {}), body: newBody };
return inner(input, newInit);
} catch {
// Total fail-open β€” never let a sanitiser bug break the request path.
return inner(input, init);
}
};
}
/**
* Test-only hook: reset the module-level streaming-warning latch so each
* test can independently assert the one-shot semantics. Not part of the
* public stability contract β€” prefixed with `__` per convention to signal
* "do not depend on this from production code".
*/
export function __resetGeminiStreamingWarning(): void {
geminiStreamingWarningEmitted = false;
}
// ────────────────────────────────────────────────────────────────────────────
// Config hook (T-07) β€” backward-compat shim for OC ≀1.14.48
//
// OC ≀1.14.48 does NOT call `provider.models()` at startup; it reads the
// catalog from the static `provider.<id>` config block instead. OC β‰₯1.14.49
// calls `provider.models()` dynamically AND merges the dynamic catalog over
// any static block (dynamic wins on collision). To support both, the plugin
// publishes a static block via `config` AND a dynamic one via `provider.models`
// β€” OC's resolution order picks the right one per OC version. This module
// implements the static-publish half.
//
// Sibling shape source-of-truth: see
// `@omniroute/opencode-provider/src/index.ts` (`createOmniRouteProvider`,
// `OpenCodeProviderEntry`, `OpenCodeModelEntry`). We replicate that shape
// here rather than depending on the sibling package β€” the plugin must stay
// self-contained (npm-installable on its own, no peer dep on the provider
// builder).
// ────────────────────────────────────────────────────────────────────────────
/**
* Per-model entry shape under `provider.<id>.models[modelId]`. Mirrors
* `OpenCodeModelEntry` exported by `@omniroute/opencode-provider`. Stripped
* down to the fields OC's static catalog reader actually consumes β€” NOT a
* full ModelV2 (that's the dynamic-hook shape). Optional fields are omitted
* when OmniRoute didn't surface a value, NOT emitted as `undefined` β€” the
* resulting JSON must be diffable across OmniRoute deployments without
* `undefined` noise.
*/
/** Modalities accepted by OC's static catalog reader (see `@opencode-ai/sdk`). */
export type OmniRouteModalityKind = "text" | "audio" | "image" | "video" | "pdf";
const STATIC_MODALITY_VALUES: ReadonlySet<OmniRouteModalityKind> = new Set([
"text",
"audio",
"image",
"video",
"pdf",
]);
/** Normalise + filter raw modality list to the values OC accepts. Deduped. */
function normaliseModalities(raw: unknown): OmniRouteModalityKind[] {
if (!Array.isArray(raw)) return [];
const out: OmniRouteModalityKind[] = [];
const seen = new Set<string>();
for (const v of raw) {
if (typeof v !== "string") continue;
const lower = v.toLowerCase() as OmniRouteModalityKind;
if (!STATIC_MODALITY_VALUES.has(lower)) continue;
if (seen.has(lower)) continue;
seen.add(lower);
out.push(lower);
}
return out;
}
export interface OmniRouteStaticModelEntry {
/** Display label rendered in OC's model picker. Defaults to the model id. */
name: string;
/** ISO date the model was released. Surfaces in OC's model card when present. */
release_date?: string;
/** Model accepts image / file attachments. */
attachment?: boolean;
/** Model exposes a reasoning / extended-thinking surface. */
reasoning?: boolean;
/** Model honours the `temperature` parameter. */
temperature?: boolean;
/** Model supports function / tool calling. */
tool_call?: boolean;
/**
* Per-million-token cost. Maps from OmniRoute `/api/pricing` shape:
* `input`/`output` pass through; `cached` β†’ `cache_read`;
* `cache_creation` β†’ `cache_write`. Omitted when no pricing slot resolves.
*/
cost?: {
input: number;
output: number;
cache_read?: number;
cache_write?: number;
};
/**
* Context-window limits. OC's static reader requires both `context` AND
* `output` when `limit` is present, so the field is only emitted when
* BOTH are known.
*/
limit?: {
context: number;
output: number;
};
/**
* Modality lists the model accepts (input) and emits (output). Maps from
* OmniRoute's `input_modalities` / `output_modalities` on `/v1/models`.
* Emitted only when at least one modality is known β€” without this field
* OC's runtime catalog defaults `input.image: false` even when the model
* card has `attachment: true`, which blocks clipboard image paste in the
* TUI for vision-capable models.
*/
modalities?: {
input: OmniRouteModalityKind[];
output: OmniRouteModalityKind[];
};
}
/**
* Static `provider.<id>` block written to `input.provider` by the config hook.
* Mirrors `OpenCodeProviderEntry` from `@omniroute/opencode-provider`.
*
* - `npm` is always `"@ai-sdk/openai-compatible"` β€” OmniRoute exposes an
* OpenAI-compatible surface and that's the AI-SDK adapter that speaks it.
* - `options.baseURL` MUST be the fully-qualified `/v1` URL (the AI-SDK
* appends paths like `/chat/completions` directly under it).
* - `options.apiKey` is the bearer token; the fetch interceptor (T-04)
* also injects it on the dynamic path, but the static block needs it
* embedded too so OC ≀1.14.48 can construct the SDK client without
* going through the auth hook.
*/
export interface OmniRouteStaticProviderEntry {
npm: "@ai-sdk/openai-compatible";
name: string;
options: {
baseURL: string;
apiKey: string;
};
models: Record<string, OmniRouteStaticModelEntry>;
}
/**
* Build the static `provider.<id>` block from raw `/v1/models` + `/api/combos`
* responses. Pure function β€” no I/O, no side effects, no dependency on the
* sibling provider package. Exported so callers and tests can construct the
* block independently of the auth.json + fetch pipeline.
*
* Mapping rules (per the sibling `createOmniRouteProvider` output spec):
*
* - One entry per raw model AND one entry per non-hidden combo.
* - `name` = model id (no separate display name on `/v1/models`).
* - `attachment` = `caps.attachment ?? caps.vision ?? false` β€” same
* convention as `mapRawModelToModelV2` (T-03).
* - `reasoning` = `caps.reasoning || caps.thinking`. Booleans only β€” we
* do NOT emit the field when both source flags are absent (keeps the
* stripped shape minimal).
* - `temperature` = `caps.temperature ?? true` β€” OpenAI-compat surface
* supports temperature by default; only an explicit `false` suppresses.
* - `tool_call` = `caps.tool_calling ?? false`.
* - `limit.context` = raw `context_length` when > 0; omitted otherwise.
* - `limit.input` = raw `max_input_tokens` when present.
* - `limit.output` = raw `max_output_tokens` when present.
*
* For combos: LCD across member raw models (matches `mapComboToModelV2`):
*
* - `attachment`, `reasoning`, `tool_call`, `temperature`: `every` member.
* - `limit.context` = min(member context_lengths).
* - `limit.input` = min(member max_input_tokens) ONLY when every member
* declares one.
* - `limit.output` = min(member max_output_tokens).
* - Empty members β†’ all-false / limits omitted.
*
* Collision: combos win (matches the dynamic provider hook).
*
* @param rawModels Raw `/v1/models` entries (may be empty).
* @param rawCombos Raw `/api/combos` entries (may be empty).
* @param opts Resolved plugin options (we read `displayName` + `providerId`).
* @param baseURL Fully-qualified `/v1` base URL β€” written verbatim to
* `options.baseURL`. Caller is responsible for `/v1`
* normalisation; we do NOT touch it here.
* @param apiKey Bearer token β€” written verbatim to `options.apiKey`.
*/
export function buildStaticProviderEntry(
rawModels: OmniRouteRawModelEntry[],
rawCombos: OmniRouteRawCombo[],
opts: ReturnType<typeof resolveOmniRoutePluginOptions>,
baseURL: string,
apiKey: string,
enrichment?: OmniRouteEnrichmentMap,
compressionCombos?: OmniRouteCompressionCombo[],
connections?: OmniRouteProviderConnection[]
): OmniRouteStaticProviderEntry {
const models: Record<string, OmniRouteStaticModelEntry> = {};
// usableOnly filter β€” compute once when feature enabled AND we have
// connection data to filter against. Soft-fail (empty connections list)
// disables the filter rather than hiding the catalog.
const wantUsableOnly = opts.features?.usableOnly === true;
const usable =
wantUsableOnly && connections && connections.length > 0
? usableProviderAliasSet(connections, enrichment)
: undefined;
// Provider-tag suffix β€” default-on, opt-out via `features.providerTag: false`.
// Prepends e.g. `Claude - ` to enriched raw-model names so the picker
// can tell `cc/claude-opus-4-7` (Anthropic) apart from `kr/claude-opus-4-7`
// (Kiro). Combos skip this by design.
const wantProviderTag = opts.features?.providerTag !== false;
// Build a name-set of every non-hidden combo from `/api/combos`. OmniRoute
// pre-mirrors combos into `/v1/models` with the friendly name as the raw
// id (e.g. `claude-primary`, `gemini-pro`), so without dedup the static
// catalog ends up with both `claude-primary` (raw, opaque) AND the same
// combo under `combo/claude-primary` (rich LCD). We suppress the raw twin
// so each combo surfaces exactly once, under the `combo/` namespace.
const comboNames = new Set<string>();
for (const combo of rawCombos) {
if (!combo || combo.isHidden === true) continue;
const name = combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id;
if (typeof name === "string" && name.length > 0) comboNames.add(name);
}
// Build the canonical→alias reverse map AND the canonical-dedup set
// once per static-block construction. Same shape as the dynamic hook
// so both catalogs publish identical keys (no `claude/X` raw twin
// shadowing the enriched `cc/X` row).
const canonicalToAlias = buildCanonicalToAliasMap(enrichment);
const canonicalDedup = canonicalDedupSet(rawModels, canonicalToAlias);
const aliasIndex = buildAliasIndex(enrichment);
// Raw model entries β†’ stripped per-model shape.
for (const raw of rawModels) {
if (!raw.id) continue;
// Skip the 20 named no-slash entries that shadow combos under the
// `combo/<name>` namespace. We keep `codex-auto-review` and any other
// future no-slash raw entry that doesn't have a matching combo.
if (comboNames.has(raw.id)) continue;
// Skip canonical-named twins when the alias-keyed enriched row exists.
if (canonicalDedup.has(raw.id)) continue;
if (usable && !isUsableRawModelId(raw.id, usable, enrichment)) continue;
const caps = raw.capabilities ?? {};
// Enrichment overlay: `/api/pricing/models` carries human display names
// (e.g. "Claude Opus 4.7" for raw id "cc/claude-opus-4-7"). The OC TUI
// model picker reads this `name` straight from the static block on
// OC ≀1.15.5 where the dynamic provider hook never fires. Falls back
// to the raw id when no enrichment entry is found. The alias-fallback
// lookup rescues `<canonical>/<id>` rows whose enrichment indexed only
// under `<alias>/<id>`.
const enrichmentEntry = lookupEnrichment(raw.id, enrichment, canonicalToAlias);
const enrichmentName = enrichmentEntry?.name;
let displayName = enrichmentName && enrichmentName.length > 0 ? enrichmentName : raw.id;
// Provider-tag PREFIX β€” `<label> - <name>` so the picker groups by
// upstream provider when scanning a column of model names. Mirrors
// `applyProviderTag` used in the dynamic hook. Idempotent: skip
// when the name already starts with the prefix. The alias-index
// fallback rescues raw rows like `cohere/rerank-multilingual-v3.0`
// whose specific model id isn't in `/api/pricing/models` but whose
// slot is.
if (wantProviderTag) {
const tagEntry = resolveProviderTagEntry(
raw.id,
enrichmentEntry,
aliasIndex,
canonicalToAlias
);
const label = shortProviderLabel(tagEntry);
if (label) {
const prefix = `${label}${PROVIDER_TAG_SEPARATOR}`;
if (!displayName.startsWith(prefix)) displayName = `${prefix}${displayName}`;
}
}
const entry: OmniRouteStaticModelEntry = { name: displayName };
const attachment = caps.attachment ?? caps.vision;
if (typeof attachment === "boolean") entry.attachment = attachment;
if (typeof caps.reasoning === "boolean" || typeof caps.thinking === "boolean") {
entry.reasoning = Boolean(caps.reasoning || caps.thinking);
}
if (typeof caps.temperature === "boolean") {
entry.temperature = caps.temperature;
}
if (typeof caps.tool_calling === "boolean") {
entry.tool_call = caps.tool_calling;
}
// OC's SDK schema requires BOTH `context` and `output` when `limit` is
// present. We previously emitted `limit.input` too, but the SDK reader
// doesn't accept it β€” drop it. Only emit `limit` when both required
// values are known.
if (
typeof raw.context_length === "number" &&
raw.context_length > 0 &&
typeof raw.max_output_tokens === "number" &&
raw.max_output_tokens > 0
) {
entry.limit = {
context: raw.context_length,
output: raw.max_output_tokens,
};
}
// Modalities β€” emit when OmniRoute surfaced any. Without this field
// OC's runtime model defaults `input.image: false` even for vision-
// capable models, blocking clipboard image paste in the TUI.
const inModalities = normaliseModalities(raw.input_modalities);
const outModalities = normaliseModalities(raw.output_modalities);
if (inModalities.length > 0 || outModalities.length > 0) {
entry.modalities = {
input: inModalities.length > 0 ? inModalities : ["text"],
output: outModalities.length > 0 ? outModalities : ["text"],
};
}
// Cost from enrichment pricing (sourced from `/api/pricing`). Map
// OmniRoute field names to OC's static-schema field names.
const pricing = enrichmentEntry?.pricing;
if (pricing && (typeof pricing.input === "number" || typeof pricing.output === "number")) {
const cost: NonNullable<OmniRouteStaticModelEntry["cost"]> = {
input: typeof pricing.input === "number" ? pricing.input : 0,
output: typeof pricing.output === "number" ? pricing.output : 0,
};
if (typeof pricing.cacheRead === "number") cost.cache_read = pricing.cacheRead;
if (typeof pricing.cacheWrite === "number") cost.cache_write = pricing.cacheWrite;
entry.cost = cost;
}
// release_date from /v1/models β€” surfaces in OC's model card when present.
if (typeof raw.release_date === "string" && raw.release_date.length > 0) {
entry.release_date = raw.release_date;
}
models[raw.id] = entry;
}
// Combo entries β†’ stripped LCD shape. Each combo is keyed as
// `combo/<friendly-name>` so the OC TUI model picker shows them under a
// distinct namespace (e.g. `combo/claude-primary`) instead of the opaque
// upstream UUID id (e.g. `b4a0211e-e3e1-472d-b252-fb9bf6d1c935`).
const rawModelById = new Map<string, OmniRouteRawModelEntry>();
for (const m of rawModels) {
if (m.id) rawModelById.set(m.id, m);
}
// Resolve the default compression pipeline once β€” its short signature
// (e.g. `[rtk:standard β†’ caveman:full]`) is appended to every routable
// combo `name` so operators can see what compression a combo applies
// at a glance. Provider hook does the same decoration when feature is
// on. Suffix is suppressed for combos with no resolvable members β€”
// claiming compression on an unroutable combo would mislead the
// picker.
let compressionSuffix = "";
if (compressionCombos && compressionCombos.length > 0) {
const def = compressionCombos.find((c) => c.isDefault === true);
if (def) {
const sig = formatCompressionPipeline(def.pipeline);
if (sig.length > 0) compressionSuffix = ` ${sig}`;
}
}
// Track combo keys to detect slug collisions across the catalog.
const usedComboKeys = new Set<string>();
for (const combo of rawCombos) {
if (!combo.id) continue;
if (combo.isHidden === true) continue;
if (usable && !isUsableCombo(combo, usable)) continue;
const memberSteps = Array.isArray(combo.models) ? combo.models : [];
const memberEntries: OmniRouteRawModelEntry[] = [];
for (const step of memberSteps) {
const modelId = (step as unknown as { model?: unknown }).model;
if (typeof modelId !== "string" || modelId.length === 0) continue;
const member = rawModelById.get(modelId);
if (member) memberEntries.push(member);
}
const hasMembers = memberEntries.length > 0;
const friendlyName = combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id;
// `Combo: ` prefix surfaces the combo nature in OC's model picker β€” the
// catalog key (`combo/<slug>`) is already namespaced, but the picker
// shows `name`, so prefix the display string too.
const prefixedName = `Combo: ${friendlyName}`;
const displayName =
hasMembers && compressionSuffix ? `${prefixedName}${compressionSuffix}` : prefixedName;
const entry: OmniRouteStaticModelEntry = { name: displayName };
if (hasMembers) {
// LCD across capabilities β€” every member must support for the combo
// to support. Mirrors mapComboToModelV2.
entry.attachment = memberEntries.every((m) =>
Boolean(m.capabilities?.attachment ?? m.capabilities?.vision ?? false)
);
entry.reasoning = memberEntries.every((m) =>
Boolean(m.capabilities?.reasoning || m.capabilities?.thinking)
);
entry.temperature = memberEntries.every(
(m) => (m.capabilities?.temperature ?? true) !== false
);
entry.tool_call = memberEntries.every((m) => Boolean(m.capabilities?.tool_calling ?? false));
// LCD across limits β€” min over declared values. OC's SDK static schema
// accepts only `context` + `output` on `limit`, so we drop the legacy
// `input` emission. Emit only when BOTH context AND output are known
// across at least one member (mirrors the required-field constraint).
const contextValues = memberEntries
.map((m) => m.context_length)
.filter((v): v is number => typeof v === "number" && v > 0);
const outputValues = memberEntries
.map((m) => m.max_output_tokens)
.filter((v): v is number => typeof v === "number" && v > 0);
if (contextValues.length > 0 && outputValues.length > 0) {
entry.limit = {
context: Math.min(...contextValues),
output: Math.min(...outputValues),
};
}
// LCD across modalities β€” combo accepts modality M iff every member
// accepts M. Same intersection rule as runtime capabilities.
const inSets = memberEntries.map((m) => new Set(normaliseModalities(m.input_modalities)));
const outSets = memberEntries.map((m) => new Set(normaliseModalities(m.output_modalities)));
const intersect = (sets: Set<OmniRouteModalityKind>[]): OmniRouteModalityKind[] => {
if (sets.length === 0) return [];
const [first, ...rest] = sets;
const out: OmniRouteModalityKind[] = [];
for (const v of first) {
if (rest.every((s) => s.has(v))) out.push(v);
}
return out;
};
const inModalities = intersect(inSets);
const outModalities = intersect(outSets);
if (inModalities.length > 0 || outModalities.length > 0) {
entry.modalities = {
input: inModalities.length > 0 ? inModalities : ["text"],
output: outModalities.length > 0 ? outModalities : ["text"],
};
}
} else {
// Empty members β†’ safety posture: all caps false. Caller's OC picker
// will grey out an unroutable combo rather than promise capabilities
// we can't honour.
entry.attachment = false;
entry.reasoning = false;
entry.temperature = false;
entry.tool_call = false;
}
// Key under `combo/<slug>` (e.g. `combo/claude-primary`) so the
// namespace cleanly separates combos from raw provider/model pairs
// and so the key is copy/paste-friendly. Slug collisions across
// combos are disambiguated with a short UUID-prefix suffix; see
// `buildComboKey` for the policy.
models[buildComboKey(combo, usedComboKeys)] = entry;
}
return {
npm: "@ai-sdk/openai-compatible",
name: opts.displayName,
options: { baseURL, apiKey },
models,
};
}
/**
* Shape we expect inside `auth.json`. The file is keyed by providerId, with
* each entry being a flavor-tagged credential. Today only the `api` flavor
* is consumed by this plugin (OAuth + WellKnown flavors are passed through
* but never decoded into a static block).
*/
interface AuthJsonApiEntry {
type: "api";
key: string;
baseURL?: string;
}
type AuthJsonShape = Record<string, AuthJsonApiEntry | { type?: string; [k: string]: unknown }>;
/**
* Read & parse `auth.json` from OC's data dir. The path resolution mirrors
* OC core's:
*
* `${OPENCODE_DATA_DIR ?? path.join(os.homedir(), ".local/share/opencode")}/auth.json`
*
* Returns `undefined` when the file is missing (most-common case on a fresh
* install β€” silent no-op). Returns `null` when the file exists but doesn't
* parse as JSON (logs ONE warn so the operator sees the corruption).
*
* Exported as a dependency-injectable function on `createOmniRouteConfigHook`
* so tests can stub it without monkey-patching `node:fs/promises`.
*/
// ─────────────────────────────────────────────────────────────────────────
// Disk-cache fallback. Persists the last successful raw-fetch snapshot to
// `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`.
// When `/v1/models` is unreachable (e.g. IP whitelist drop, offline laptop)
// AND the in-memory cache is cold, the config hook reads from disk so the
// last-known catalog still surfaces in OC's model picker. Feature-flagged:
// `features.diskCache !== false` (default-on).
// ─────────────────────────────────────────────────────────────────────────
/** Disk snapshot envelope. Versioned for forward-compat. */
interface OmniRouteDiskSnapshot {
v: 1;
rawModels: OmniRouteRawModelEntry[];
rawCombos: OmniRouteRawCombo[];
/** Serialised as array-of-pairs (Map is not JSON-friendly). */
rawEnrichment: Array<[string, OmniRouteEnrichmentEntry]>;
rawCompressionCombos: OmniRouteCompressionCombo[];
rawConnections: OmniRouteProviderConnection[];
/** When the snapshot was written (epoch ms). */
writtenAt: number;
}
/** Resolve the disk-snapshot path for a given providerId. */
export function diskSnapshotPath(providerId: string): string {
const dir = process.env.OPENCODE_DATA_DIR ?? path.join(os.homedir(), ".local/share/opencode");
return path.join(dir, "plugins", `omniroute-${providerId}.json`);
}
export type OmniRouteDiskSnapshotWriter = (
providerId: string,
entry: Omit<OmniRouteFetchCacheEntry, "expiresAt">
) => Promise<void>;
export type OmniRouteDiskSnapshotReader = (
providerId: string
) => Promise<Omit<OmniRouteFetchCacheEntry, "expiresAt"> | undefined>;
/** Best-effort disk write. Soft-fails on any I/O error (no exception thrown). */
export const defaultDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async (providerId, entry) => {
try {
const file = diskSnapshotPath(providerId);
// Restrict perms to the owner: the snapshot lives alongside auth.json
// (0o600) and embeds provider topology + masked connection records.
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
const snapshot: OmniRouteDiskSnapshot = {
v: 1,
rawModels: entry.rawModels,
rawCombos: entry.rawCombos,
rawEnrichment: Array.from(entry.rawEnrichment.entries()),
rawCompressionCombos: entry.rawCompressionCombos,
rawConnections: entry.rawConnections,
writtenAt: Date.now(),
};
await writeFile(file, JSON.stringify(snapshot), { encoding: "utf8", mode: 0o600 });
} catch {
// Soft-fail; caller already has the in-memory cache.
}
};
/** Best-effort disk read. Returns `undefined` when missing/corrupt/unreadable. */
export const defaultDiskSnapshotReader: OmniRouteDiskSnapshotReader = async (providerId) => {
try {
const file = diskSnapshotPath(providerId);
const body = await readFile(file, "utf8");
const parsed = JSON.parse(body) as Partial<OmniRouteDiskSnapshot>;
if (!parsed || parsed.v !== 1) return undefined;
return {
rawModels: Array.isArray(parsed.rawModels) ? parsed.rawModels : [],
rawCombos: Array.isArray(parsed.rawCombos) ? parsed.rawCombos : [],
rawEnrichment: new Map(Array.isArray(parsed.rawEnrichment) ? parsed.rawEnrichment : []),
rawCompressionCombos: Array.isArray(parsed.rawCompressionCombos)
? parsed.rawCompressionCombos
: [],
rawConnections: Array.isArray(parsed.rawConnections) ? parsed.rawConnections : [],
};
} catch {
return undefined;
}
};
/** No-op disk-cache pair β€” used by tests to avoid filesystem side effects. */
export const noopDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
export const noopDiskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
export type OmniRouteReadAuthJson = () => Promise<AuthJsonShape | undefined | null>;
export const defaultReadAuthJson: OmniRouteReadAuthJson = async () => {
const dir = process.env.OPENCODE_DATA_DIR ?? path.join(os.homedir(), ".local/share/opencode");
const file = path.join(dir, "auth.json");
let body: string;
try {
body = await readFile(file, "utf8");
} catch {
// File missing or unreadable β€” silent no-op. This is the expected path
// on a fresh install BEFORE `/connect` has been run.
return undefined;
}
try {
const parsed = JSON.parse(body) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as AuthJsonShape;
}
return null;
} catch {
return null;
}
};
/**
* Build the config-hook portion of the plugin for a given options bag.
* Exported standalone so the contract is unit-testable without faking the
* full PluginInput / Hooks surface, and so multi-instance setups can each
* own their own (auth.json reader, fetch cache, fetcher) trio.
*
* Behavioural contract:
* - Runs BEFORE `auth.loader` in the OC startup sequence (per the
* @opencode-ai/plugin contract). `getAuth()` is NOT available here,
* so we read `auth.json` directly via the injected reader.
* - No-op when:
* (a) `auth.json` is missing / unreadable (fresh install before
* `/connect`),
* (b) `auth.json[providerId]` is missing or not type-api,
* (c) `apiKey` is empty after extraction,
* (d) `baseURL` is unresolvable (neither opts.baseURL nor
* `auth.json[providerId].baseURL`),
* (e) `input.provider[providerId]` is ALREADY set (operator override
* wins β€” we never clobber manually-curated catalogs).
* Each no-op path emits ONE debug-level breadcrumb to `console.warn`
* so the operator can diagnose without log spam. Malformed `auth.json`
* warns once and continues as if the file were missing.
* - Fail-open on fetcher errors: a `/v1/models` failure β†’ still publish
* a stub `{models: {}}` provider block (so OC has a complete-shape
* entry to render). A `/api/combos` failure β†’ publish models-only.
* Both paths emit ONE `console.warn`.
* - When the provider hook (T-03/T-05) has ALREADY populated the shared
* cache for this (baseURL, apiKey) tuple, we reuse the raw payloads
* directly β€” no second fetch. (And vice-versa: the config hook fires
* first on OC β‰₯1.14.49 cold start, populating the cache for the
* provider hook moments later.)
* - DUAL-PUBLISH SAFE: on OC β‰₯1.14.49 BOTH this static block and the
* dynamic `provider.models()` result will land in OC's catalog
* reducer. The dynamic block wins by OC's own merge rule β€” see
* OpenCode core's provider resolution order β€” so emitting both is a
* correctness-positive: ≀1.14.48 reads static, β‰₯1.14.49 prefers
* dynamic but the static one keeps things responsive during the
* ~50ms window before the dynamic fetch resolves.
*
* @param opts Plugin options (validated, resolved with defaults).
* @param deps Dependency injection.
* - `readAuthJson` β€” replaces `defaultReadAuthJson` (test stub).
* - `fetcher` β€” replaces `defaultOmniRouteModelsFetcher`.
* - `combosFetcher` β€” replaces `defaultOmniRouteCombosFetcher`.
* - `now` β€” clock for cache TTL (default `Date.now`).
* - `cache` β€” shared fetch-result cache (see
* `OmniRouteFetchCache`). Pass the same Map the
* provider hook owns to dedupe round-trips.
* - `logger` β€” `{warn}` sink for breadcrumb capture in tests.
* Defaults to `console`.
*/
export function createOmniRouteConfigHook(
opts?: OmniRoutePluginOptions,
deps: {
readAuthJson?: OmniRouteReadAuthJson;
fetcher?: OmniRouteModelsFetcher;
combosFetcher?: OmniRouteCombosFetcher;
enrichmentFetcher?: OmniRouteEnrichmentFetcher;
compressionMetaFetcher?: OmniRouteCompressionMetaFetcher;
providersFetcher?: OmniRouteProvidersFetcher;
diskSnapshotReader?: OmniRouteDiskSnapshotReader;
diskSnapshotWriter?: OmniRouteDiskSnapshotWriter;
now?: () => number;
cache?: OmniRouteFetchCache;
logger?: { warn: (...args: unknown[]) => void };
} = {}
): (input: Config) => Promise<void> {
const resolved = resolveOmniRoutePluginOptions(opts);
const readAuthJson = deps.readAuthJson ?? defaultReadAuthJson;
const fetcher = deps.fetcher ?? defaultOmniRouteModelsFetcher;
const combosFetcher = deps.combosFetcher ?? defaultOmniRouteCombosFetcher;
const enrichmentFetcher = deps.enrichmentFetcher ?? defaultOmniRouteEnrichmentFetcher;
const compressionMetaFetcher =
deps.compressionMetaFetcher ?? defaultOmniRouteCompressionMetaFetcher;
const providersFetcher = deps.providersFetcher ?? defaultOmniRouteProvidersFetcher;
const diskSnapshotReader = deps.diskSnapshotReader ?? defaultDiskSnapshotReader;
const diskSnapshotWriter = deps.diskSnapshotWriter ?? defaultDiskSnapshotWriter;
const now = deps.now ?? Date.now;
const cache: OmniRouteFetchCache = deps.cache ?? new Map();
const logger = deps.logger ?? console;
const features = resolved.features ?? {};
const wantEnrichment = features.enrichment !== false;
const wantCompressionMeta = features.compressionMetadata === true;
const wantUsableOnly = features.usableOnly === true;
const wantDiskCache = features.diskCache !== false;
const wantProviderTag = features.providerTag !== false;
return async (input: Config) => {
// (e) operator override β€” `input.provider[providerId]` already set β†’
// leave it alone. Manually curated catalogs ALWAYS win over the plugin's
// generated block. Detect-and-respect before any I/O.
const existingProviders = (input as { provider?: Record<string, unknown> }).provider;
if (existingProviders && existingProviders[resolved.providerId] !== undefined) {
logger.warn(
`[omniroute-plugin] config shim skipped: provider.${resolved.providerId} already set by user`
);
return;
}
// Read auth.json. `undefined` = missing file (silent path), `null` =
// malformed JSON (warn once and treat as missing).
let authJson: AuthJsonShape | undefined | null;
try {
authJson = await readAuthJson();
} catch {
// Reader threw β€” be conservative and treat like a missing file.
authJson = undefined;
}
if (authJson === null) {
logger.warn("[omniroute-plugin] config shim: auth.json failed to parse; treating as missing");
authJson = undefined;
}
const entry = authJson?.[resolved.providerId] as AuthJsonApiEntry | undefined;
const apiKey = entry && entry.type === "api" && typeof entry.key === "string" ? entry.key : "";
if (!apiKey) {
// (c) no apiKey β€” silent no-op (with debug breadcrumb). The operator
// hasn't run `/connect <providerId>` yet, OR the stored credential
// isn't api-flavored. OC will handle the `/connect` flow at runtime.
logger.warn(
`[omniroute-plugin] config shim skipped: no apiKey for providerId=${resolved.providerId}`
);
return;
}
// baseURL resolution: opts.baseURL wins, then auth.json's stored baseURL.
// No silent localhost default β€” a misconfigured plugin should surface a
// breadcrumb and skip, not phantom requests.
const storedBaseURL = entry && typeof entry.baseURL === "string" ? entry.baseURL : undefined;
const baseURL = resolved.baseURL ?? storedBaseURL ?? "";
if (!baseURL) {
logger.warn(
`[omniroute-plugin] config shim skipped: no baseURL for providerId=${resolved.providerId}`
);
return;
}
// Try the shared cache first. On OC β‰₯1.14.49 the provider hook may have
// populated it moments earlier; on OC ≀1.14.48 only this hook runs but
// the cache still works (single producer + consumer through one Map).
const cacheKey = modelsCacheKey(baseURL, apiKey);
const t = now();
const cached = cache.get(cacheKey);
let rawModels: OmniRouteRawModelEntry[];
let rawCombos: OmniRouteRawCombo[];
let rawEnrichment: OmniRouteEnrichmentMap;
let rawCompressionCombos: OmniRouteCompressionCombo[];
let rawConnections: OmniRouteProviderConnection[];
if (cached && cached.expiresAt > t) {
rawModels = cached.rawModels;
rawCombos = cached.rawCombos;
rawEnrichment = cached.rawEnrichment;
rawCompressionCombos = cached.rawCompressionCombos;
rawConnections = cached.rawConnections;
} else {
// Fail-open fetcher errors: on /v1/models throw, fall back to empty
// catalog (still publish a stub block so OC has a complete-shape
// entry); on /api/combos throw, publish models-only. Disk-cache
// fallback below recovers the last-known-good catalog when the
// fetcher threw (network down / 403 / timeout) AND features.diskCache
// !== false. A 0-entry SUCCESS (fresh tenant) does NOT trigger
// disk fallback β€” that's a valid empty catalog.
let modelsFetchThrew = false;
try {
rawModels = await fetcher(baseURL, apiKey, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /v1/models fetch failed; publishing stub provider entry",
err
);
rawModels = [];
modelsFetchThrew = true;
}
const modelsFetchOk = !modelsFetchThrew && rawModels.length > 0;
rawCombos = [];
try {
rawCombos = await combosFetcher(baseURL, apiKey, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/combos fetch failed; publishing models-only static catalog",
err
);
}
// Eagerly fetch enrichment so the static block can overlay human
// display names on raw model ids. On OC ≀1.15.5 the dynamic
// `provider.models` hook never fires in `serve` mode, so the static
// block IS what reaches `/provider` and the TUI model picker.
// Gated by `features.enrichment` (default-on). Soft-fail on error β€”
// we still publish a name-less catalog if /api/pricing/models is
// unreachable.
rawEnrichment = new Map();
if (wantEnrichment) {
try {
rawEnrichment = await enrichmentFetcher(baseURL, apiKey, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/pricing/models fetch failed; publishing raw-id static catalog",
err
);
}
}
// Compression-metadata fetch β€” opt-in via features.compressionMetadata.
// When on, the default pipeline is appended to every combo `name` so
// the TUI picker advertises which compression a combo applies.
rawCompressionCombos = [];
if (wantCompressionMeta) {
try {
rawCompressionCombos = await compressionMetaFetcher(baseURL, apiKey, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/context/combos fetch failed; publishing combos without compression suffix",
err
);
}
}
// Provider-connections fetch β€” opt-in via features.usableOnly. When
// on, the static catalog filters out models/combos whose canonical
// provider has no active connection. Soft-fail (empty list) disables
// the filter for this refresh, never hiding the whole catalog.
rawConnections = [];
if (wantUsableOnly) {
try {
rawConnections = await providersFetcher(baseURL, apiKey, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/providers fetch failed; usableOnly filter disabled for this refresh",
err
);
}
}
// Disk-cache fallback: when the live fetch returned no models AND
// features.diskCache !== false, hydrate from the last-known-good
// snapshot so OC still surfaces a usable catalog (e.g. IP whitelist
// drop, offline laptop). The snapshot is whatever we last wrote on
// a healthy refresh; staleness is bounded only by how recently the
// user was online.
if (modelsFetchThrew && wantDiskCache) {
const snapshot = await diskSnapshotReader(resolved.providerId);
if (snapshot && snapshot.rawModels.length > 0) {
logger.warn(
`[omniroute-plugin] config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)`
);
rawModels = snapshot.rawModels;
rawCombos = snapshot.rawCombos;
rawEnrichment = snapshot.rawEnrichment;
rawCompressionCombos = snapshot.rawCompressionCombos;
rawConnections = snapshot.rawConnections;
}
}
// Cache even partial results β€” a subsequent provider-hook call should
// not re-burn the timeout window on the same broken endpoint.
cache.set(cacheKey, {
rawModels,
rawCombos,
rawEnrichment,
rawCompressionCombos,
rawConnections,
expiresAt: t + resolved.modelCacheTtl,
});
// Disk-cache write: persist the last successful (or any non-empty)
// catalog so a subsequent cold start with a failed fetch can recover.
// Best-effort; soft-fail keeps us moving when the data dir isn't
// writable (e.g. read-only container).
if (modelsFetchOk && wantDiskCache) {
await diskSnapshotWriter(resolved.providerId, {
rawModels,
rawCombos,
rawEnrichment,
rawCompressionCombos,
rawConnections,
});
}
}
const block = buildStaticProviderEntry(
rawModels,
rawCombos,
resolved,
baseURL,
apiKey,
rawEnrichment,
rawCompressionCombos,
rawConnections
);
// Mutate the input.provider map. The Config type declares
// `provider?: {[key: string]: ProviderConfig}` β€” we initialise the
// bag when absent so users who never set `provider` in opencode.json
// still get the static block.
const inputWithProvider = input as { provider?: Record<string, unknown> };
if (!inputWithProvider.provider) {
inputWithProvider.provider = {};
}
inputWithProvider.provider[resolved.providerId] = block;
// ─────────────────────────────────────────────────────────────────────
// MCP auto-emit β€” opt-in via features.mcpAutoEmit. When enabled, writes
// an `input.mcp[<providerId>]` remote entry pointing at
// `<baseURL>/api/mcp/stream` with the resolved Bearer token. Token
// resolution: features.mcpToken wins if set; otherwise falls back to
// the same apiKey used for chat. Operator overrides win (same posture
// as provider-block emit): if input.mcp[providerId] is already set,
// we leave it alone.
// ─────────────────────────────────────────────────────────────────────
if (features.mcpAutoEmit === true) {
const mcpKey = features.mcpToken ?? apiKey;
if (!mcpKey) {
logger.warn(
`[omniroute-plugin] mcp auto-emit skipped: no Bearer token for providerId=${resolved.providerId}`
);
} else {
const inputWithMcp = input as { mcp?: Record<string, unknown> };
if (!inputWithMcp.mcp) {
inputWithMcp.mcp = {};
}
if (inputWithMcp.mcp[resolved.providerId] !== undefined) {
logger.warn(
`[omniroute-plugin] mcp auto-emit skipped: mcp.${resolved.providerId} already set by user`
);
} else {
// Strip a trailing `/v1` from baseURL when present so we land on
// the MCP transport at /api/mcp/stream, not /v1/api/mcp/stream.
const mcpRoot = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, "");
inputWithMcp.mcp[resolved.providerId] = {
type: "remote",
url: `${mcpRoot}/api/mcp/stream`,
enabled: true,
headers: {
Authorization: `Bearer ${mcpKey}`,
},
};
}
}
}
};
}