diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 68b9a648f7..4074ebdace 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -56,6 +56,23 @@ import { randomUUID } from "node:crypto"; import type { AuthHook, Config, Plugin, PluginOptions, ProviderHook } from "@opencode-ai/plugin"; import type { Model as ModelV2 } from "@opencode-ai/sdk/v2"; import { z } from "zod"; +import { + logger as _logger, + setLogLevel, + type LogLevel as _LogLevel, +} from "./logger.js"; +import { + PROVIDER_TAG_SEPARATOR as _PROVIDER_TAG_SEPARATOR, + shortProviderLabel as _shortProviderLabel, + normaliseFreeLabel as _normaliseFreeLabel, + formatAutoComboName, + autoComboModelId, + formatFreeBudget, + type AutoVariant, + AUTO_VARIANTS, + AUTO_VARIANT_DESCRIPTIONS, + type FreeModelFreeType, +} from "./naming.js"; /** * Zod schema for plugin options accepted as the second element of the @@ -146,6 +163,7 @@ const apiFormatSchema = z const featuresSchema = z .object({ combos: z.boolean().optional(), + autoCombos: z.boolean().optional(), enrichment: z.boolean().optional(), compressionMetadata: z.boolean().optional(), geminiSanitization: z.boolean().optional(), @@ -185,6 +203,16 @@ export type OmniRoutePluginOptions = z.infer; export const OMNIROUTE_PROVIDER_KEY = "omniroute" as const; +/** Deployed plugin version (injected at build time by tsup define). */ +export const PLUGIN_VERSION: string = + ((globalThis as Record).__PLUGIN_VERSION__ as string) ?? + "dev"; + +/** Deployed plugin git commit hash (injected at build time by tsup define). */ +export const PLUGIN_GIT_HASH: string = + ((globalThis as Record).__PLUGIN_GIT_HASH__ as string) ?? + "unknown"; + export const DEFAULT_MODEL_CACHE_TTL_MS = 300_000 as const; // Manual trim helpers avoid polynomial-regex CodeQL warnings on @@ -195,6 +223,60 @@ function trimTrailingSlashes(value: string): string { while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--; return i === value.length ? value : value.slice(0, i); } + +/** + * Ensure a baseURL ends with `/v1` so the OpenAI-compat SDK constructs + * `/v1/chat/completions` instead of `/chat/completions`. + * No-op when the URL already ends with `/v1` or `/v1/`. + */ +export function ensureV1Suffix(url: string): string { + const trimmed = trimTrailingSlashes(url); + return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`; +} + +/** + * Default provider prefixes that should use the Anthropic-native SDK. + * Covers all OmniRoute aliases that route to Anthropic upstream. + */ +export const DEFAULT_ANTHROPIC_PREFIXES = [ + "cc", + "claude", + "anthropic", + "kiro", + "kr", +]; + +/** + * Resolve the `api` block for a ModelV2 entry given a model id and the + * active `apiFormat` feature config. + * + * - Models whose prefix (before the first `/`) is in `anthropicPrefixes` → + * `{ id: "anthropic", url: baseURL/v1, npm: "@ai-sdk/anthropic" }` + * + * - All others → + * `{ id: "openai-compatible", url: baseURL/v1, npm: "@ai-sdk/openai-compatible" }` + */ +export function resolveApiBlock( + modelId: string, + baseURL: string, + apiFormat?: { anthropicPrefixes?: string[] }, +): { id: string; url: string; npm: string } { + const prefixes = apiFormat?.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES; + const slash = modelId.indexOf("/"); + const prefix = slash === -1 ? modelId : modelId.slice(0, slash); + const isAnthropic = prefixes.includes(prefix); + return isAnthropic + ? { + id: "anthropic", + url: ensureV1Suffix(baseURL), + npm: "@ai-sdk/anthropic", + } + : { + id: "openai-compatible", + url: ensureV1Suffix(baseURL), + npm: "@ai-sdk/openai-compatible", + }; +} function trimTrailingDashes(value: string): string { let i = value.length; while (i > 0 && value.charCodeAt(i - 1) === 0x2d /* "-" */) i--; @@ -212,13 +294,17 @@ function trimLeadingDashes(value: string): string { * sees a consistent identifier. */ export function resolveOmniRoutePluginOptions( - opts?: OmniRoutePluginOptions -): Required> & + opts?: OmniRoutePluginOptions, +): Required< + Pick +> & Pick { const providerId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY; const displayName = opts?.displayName ?? - (providerId === OMNIROUTE_PROVIDER_KEY ? "OmniRoute" : `OmniRoute (${providerId})`); + (providerId === OMNIROUTE_PROVIDER_KEY + ? "OmniRoute" + : `OmniRoute (${providerId})`); const modelCacheTtl = typeof opts?.modelCacheTtl === "number" && opts.modelCacheTtl > 0 ? opts.modelCacheTtl @@ -249,7 +335,9 @@ export function resolveOmniRoutePluginOptions( * Exported so callers and tests can validate options independent of the * full plugin factory invocation. */ -export function parseOmniRoutePluginOptions(opts: unknown): OmniRoutePluginOptions { +export function parseOmniRoutePluginOptions( + opts: unknown, +): OmniRoutePluginOptions { if (opts === null || opts === undefined) return {}; const result = optionsSchema.safeParse(opts); if (!result.success) { @@ -391,8 +479,11 @@ export function normaliseFreeLabel(name: string): string { * 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); +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 @@ -434,7 +525,8 @@ export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook // 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 : ""); + 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 @@ -468,8 +560,8 @@ export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook ); } return composedFetch - ? { apiKey, baseURL: resolvedBaseURL, fetch: composedFetch } - : { apiKey, baseURL: resolvedBaseURL }; + ? { apiKey, baseURL: sdkBaseURL, fetch: composedFetch } + : { apiKey, baseURL: sdkBaseURL }; } return {}; }, @@ -502,8 +594,30 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => { 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` + const _ver: string = + ((globalThis as Record).__PLUGIN_VERSION__ as string) ?? + "dev"; + const _hash: string = + ((globalThis as Record).__PLUGIN_GIT_HASH__ as string) ?? + "unknown"; + const _prefixes = + resolved.features?.apiFormat?.anthropicPrefixes ?? + DEFAULT_ANTHROPIC_PREFIXES; + _logger.always( + `v${_ver} (${_hash}) initialized` + + ` providerId=${resolved.providerId}` + + ` baseURL=${resolved.baseURL ?? "(from auth.json)"}` + + ` modelCacheTtl=${resolved.modelCacheTtl}ms` + + ` apiFormat=anthropic:[${_prefixes.join(",")}]` + + ` debugLog=${resolved.features?.debugLog ?? false}` + + ` logLevel=${resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")}`, + ); + + // Wire log level: startupDebug:true → "debug", explicit logLevel wins. + setLogLevel( + resolved.features?.startupDebug + ? "debug" + : (resolved.features?.logLevel ?? "warn"), ); return { auth: createOmniRouteAuthHook(resolved), @@ -583,7 +697,7 @@ export interface OmniRouteRawModelEntry { export type OmniRouteModelsFetcher = ( baseURL: string, apiKey: string, - timeoutMs?: number + timeoutMs?: number, ) => Promise; /** @@ -596,15 +710,23 @@ export type OmniRouteModelsFetcher = ( export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async ( baseURL, apiKey, - timeoutMs = 10_000 + 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"); + 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 url = /\/v\d+$/.test(trimmed) + ? `${trimmed}/models` + : `${trimmed}/v1/models`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -619,18 +741,24 @@ export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async ( }); if (!res.ok) { throw new Error( - `@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}` + `@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 && + 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") { + if ( + r && + typeof r === "object" && + typeof (r as { id?: unknown }).id === "string" + ) { out.push(r as OmniRouteRawModelEntry); } } @@ -712,7 +840,7 @@ export function mapRawModelToModelV2( * `/api/pricing/models` data via `applyEnrichment` when * `features.enrichment` is true. */ - name: raw.id, + name: _normaliseFreeLabel(raw.id), capabilities: { temperature: caps.temperature ?? true, reasoning: Boolean(caps.reasoning || caps.thinking), @@ -741,8 +869,11 @@ export function mapRawModelToModelV2( }, 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, + ...(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: {}, @@ -817,7 +948,7 @@ export interface OmniRouteRawCombo { export type OmniRouteCombosFetcher = ( baseURL: string, apiKey: string, - timeoutMs?: number + timeoutMs?: number, ) => Promise; /** @@ -841,11 +972,16 @@ export type OmniRouteCombosFetcher = ( export const defaultOmniRouteCombosFetcher: OmniRouteCombosFetcher = async ( baseURL, apiKey, - timeoutMs = 10_000 + timeoutMs = 10_000, ) => { - if (!apiKey) throw new Error("@omniroute/opencode-plugin: apiKey required to fetch /api/combos"); + 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"); + 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 @@ -867,18 +1003,24 @@ export const defaultOmniRouteCombosFetcher: OmniRouteCombosFetcher = async ( }); if (!res.ok) { throw new Error( - `@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}` + `@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 && + 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") { + if ( + r && + typeof r === "object" && + typeof (r as { id?: unknown }).id === "string" + ) { out.push(r as OmniRouteRawCombo); } } @@ -930,14 +1072,19 @@ export function mapComboToModelV2( combo: OmniRouteRawCombo, members: OmniRouteRawModelEntry[], providerId: string, - baseURL: string + baseURL: string, + apiFormat?: { anthropicPrefixes?: string[] }, ): ModelV2 { // `every` over an empty array returns true (would lie about an empty // combo's capabilities) — short-circuit to all-false when no members. const hasMembers = members.length > 0; - const memberInMods = members.map((m) => new Set(m.input_modalities ?? ["text"])); - const memberOutMods = members.map((m) => new Set(m.output_modalities ?? ["text"])); + const memberInMods = members.map( + (m) => new Set(m.input_modalities ?? ["text"]), + ); + const memberOutMods = members.map( + (m) => new Set(m.output_modalities ?? ["text"]), + ); const modalityAllHave = (sets: Array>, key: string): boolean => hasMembers && sets.every((s) => s.has(key)); @@ -952,18 +1099,26 @@ export function mapComboToModelV2( .map((m) => m.max_input_tokens) .filter((v): v is number => typeof v === "number" && v > 0); - const everyDeclaresInput = hasMembers && inputValues.length === members.length; + const everyDeclaresInput = + hasMembers && inputValues.length === members.length; const capabilities: ModelV2["capabilities"] = { temperature: - hasMembers && members.every((m) => (m.capabilities?.temperature ?? true) !== false), + hasMembers && + members.every((m) => (m.capabilities?.temperature ?? true) !== false), reasoning: hasMembers && - members.every((m) => Boolean(m.capabilities?.reasoning || m.capabilities?.thinking)), + 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)), + 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"), @@ -978,17 +1133,31 @@ export function mapComboToModelV2( video: modalityAllHave(memberOutMods, "video"), pdf: modalityAllHave(memberOutMods, "pdf"), }, - interleaved: hasMembers && members.every((m) => Boolean(m.capabilities?.thinking)), + interleaved: + hasMembers && members.every((m) => Boolean(m.capabilities?.thinking)), }; + // Combos span multiple providers. Use Anthropic format only when ALL + // members resolve to Anthropic — otherwise fall back to OpenAI-compat + // (lowest common denominator that every upstream understands). + const comboApiBlock = (() => { + if (!hasMembers) return resolveApiBlock("", baseURL, apiFormat); + const allAnthropic = members.every( + (m) => resolveApiBlock(m.id, baseURL, apiFormat).id === "anthropic", + ); + return allAnthropic + ? resolveApiBlock(members[0].id, baseURL, apiFormat) + : { + id: "openai-compatible", + url: ensureV1Suffix(baseURL), + npm: "@ai-sdk/openai-compatible", + }; + })(); + return { id: combo.id, providerID: providerId, - api: { - id: "openai-compatible", - url: baseURL, - npm: "@ai-sdk/openai-compatible", - }, + api: comboApiBlock, name: combo.name && combo.name.trim().length > 0 ? combo.name : combo.id, capabilities, cost: { @@ -1008,6 +1177,142 @@ export function mapComboToModelV2( }; } +// ───────────────────────────────────────────────────────────────────────── +// AUTO COMBOS — virtual server-side combos exposed via /api/combos/auto +// ───────────────────────────────────────────────────────────────────────── + +/** + * Raw shape of an auto combo entry as returned by OmniRoute's + * `/api/combos/auto` endpoint. Auto combos are virtual — they self-manage + * provider selection via scoring/bandit exploration at runtime. + */ +export interface OmniRouteRawAutoCombo { + /** Stable id (e.g. "auto", "auto/coding"). */ + id: string; + /** Human-readable name (e.g. "Auto", "Auto Coding"). */ + name: string; + /** Variant key or undefined for the default auto. */ + variant?: AutoVariant; + /** Provider names eligible for this auto combo. */ + candidatePool?: string[]; + /** Number of candidates resolved at fetch time. */ + candidateCount?: number; + /** Whether this auto combo should be hidden from the picker. */ + isHidden?: boolean; + /** Auto-combo configuration. */ + config?: { + auto?: { + candidatePool?: string[]; + explorationRate?: number; + routerStrategy?: string; + }; + }; +} + +/** + * Fetcher contract for `/api/combos/auto`. Returns the list of virtual + * auto combos the server can create. Same DI pattern as other fetchers. + */ +export type OmniRouteAutoCombosFetcher = ( + baseURL: string, + apiKey: string, + timeoutMs?: number, +) => Promise; + +/** + * Default auto combos fetcher: `GET /api/combos/auto`. + * + * Fault-tolerant: returns empty array on 404 (endpoint doesn't exist yet) + * or any non-2xx / network error. Logs a warning in those cases. + */ +export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = + async (baseURL, apiKey, timeoutMs = 5_000) => { + if (!apiKey || !baseURL) return []; + + const trimmed = trimTrailingSlashes(baseURL); + const root = trimmed.replace(/\/v\d+$/, ""); + const url = `${root}/api/combos/auto`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + signal: controller.signal, + }); + // 404 = endpoint not deployed yet — expected during rollout + if (res.status === 404) { + console.warn( + `[omniroute-plugin] /api/combos/auto not available (404) — auto combos disabled`, + ); + return []; + } + if (!res.ok) { + console.warn( + `[omniroute-plugin] /api/combos/auto failed: ${res.status} ${res.statusText} — auto combos disabled`, + ); + return []; + } + const body = (await res.json()) as unknown; + const rawList: unknown[] = Array.isArray(body) + ? body + : body && + typeof body === "object" && + Array.isArray((body as { combos?: unknown }).combos) + ? ((body as { combos: unknown[] }).combos as unknown[]) + : []; + const out: OmniRouteRawAutoCombo[] = []; + for (const r of rawList) { + if ( + r && + typeof r === "object" && + typeof (r as { id?: unknown }).id === "string" + ) { + out.push(r as OmniRouteRawAutoCombo); + } + } + return out; + } catch (err) { + // Network error, timeout, abort — all non-fatal + console.warn( + `[omniroute-plugin] /api/combos/auto fetch failed: ${err instanceof Error ? err.message : String(err)} — auto combos disabled`, + ); + return []; + } finally { + clearTimeout(timer); + } + }; + +/** + * Convert a raw auto combo into a static model entry for the OpenCode picker. + * Auto combos have tool_call=true, reasoning=true by default (they route + * to capable models). Context/output limits are set to 0 since the actual + * limits depend on which provider is selected at runtime. + */ +export function mapAutoComboToStaticEntry( + autoCombo: OmniRouteRawAutoCombo, +): OmniRouteStaticModelEntry { + const variant = autoCombo.variant; + const name = formatAutoComboName(variant, autoCombo.candidateCount); + return { + name, + attachment: false, + reasoning: true, + temperature: true, + tool_call: true, + limit: { context: 0, output: 0 }, + modalities: { + input: ["text"], + output: ["text"], + }, + cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 }, + }; +} + // ───────────────────────────────────────────────────────────────────────── // ENRICHMENT — pull display names + pricing from /api/pricing/models so // the UI doesn't have to render raw model ids. Gated by features.enrichment. @@ -1054,6 +1359,12 @@ export interface OmniRouteEnrichmentEntry { * model id sold through different upstream connections. */ providerDisplayName?: string; + /** Free-model budget type (from freeModelCatalog). */ + freeType?: FreeModelFreeType; + /** Monthly token budget for recurring free models. */ + monthlyTokens?: number; + /** Credit token budget for credit-based free models. */ + creditTokens?: number; } /** Map keyed by full model id (possibly namespaced, e.g. `cc/claude-sonnet-4-6`). */ @@ -1062,7 +1373,7 @@ export type OmniRouteEnrichmentMap = Map; export type OmniRouteEnrichmentFetcher = ( baseURL: string, apiKey: string, - timeoutMs?: number + timeoutMs?: number, ) => Promise; /** @@ -1086,138 +1397,321 @@ export type OmniRouteEnrichmentFetcher = ( * 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", +export const defaultOmniRouteEnrichmentFetcher: OmniRouteEnrichmentFetcher = + async (baseURL, apiKey, timeoutMs = 10_000) => { + const out: OmniRouteEnrichmentMap = new Map(); + if (!baseURL || !apiKey) return out; + const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, ""); + const headers = { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }; + + // ── 1. Catalog with nice display names ──────────────────────────────── + const catalogAc = new AbortController(); + const catalogTimer = setTimeout(() => catalogAc.abort(), timeoutMs); + try { + const res = await fetch(`${root}/api/pricing/models`, { + method: "GET", + headers, + signal: catalogAc.signal, + }); + if (res.ok) { + const body = (await res.json()) as unknown; + const providers = + (body as { providers?: Record }) + ?.providers ?? (body as Record); + if (providers && typeof providers === "object") { + for (const [providerAlias, slot] of Object.entries(providers)) { + if (!slot || typeof slot !== "object") continue; + const models = (slot as { models?: unknown[] }).models; + if (!Array.isArray(models)) continue; + // Canonical id sits at the per-provider top level (e.g. + // `pricing-models.cc.id === 'claude'`). Falls back to the alias + // itself when missing — common case alias===canonical. + const canonicalRaw = (slot as { id?: unknown }).id; + const providerCanonical = + typeof canonicalRaw === "string" && canonicalRaw.length > 0 + ? canonicalRaw + : providerAlias; + // Upstream provider human label (e.g. `Claude`, `Kiro`, + // `GitHub Models`). Optional — falls back to undefined when + // OmniRoute hasn't curated a label for this slot. + const slotNameRaw = (slot as { name?: unknown }).name; + const providerDisplayName = + typeof slotNameRaw === "string" && slotNameRaw.trim().length > 0 + ? slotNameRaw.trim() + : undefined; + for (const m of models) { + if (!m || typeof m !== "object") continue; + const id = (m as { id?: unknown }).id; + if (typeof id !== "string" || id.length === 0) continue; + const name = (m as { name?: unknown }).name; + const entry: OmniRouteEnrichmentEntry = { + providerAlias, + providerCanonical, + }; + if (providerDisplayName) + entry.providerDisplayName = providerDisplayName; + if (typeof name === "string" && name.trim().length > 0) + entry.name = name; + const namespaced = `${providerAlias}/${id}`; + if (!out.has(namespaced)) out.set(namespaced, entry); + if (!out.has(id)) out.set(id, entry); + } + } + } + } + } catch { + // Soft-fail; keep going to pricing fetch. + } finally { + clearTimeout(catalogTimer); + } + + // ── 2. Pricing values from /api/pricing ─────────────────────────────── + const priceAc = new AbortController(); + const priceTimer = setTimeout(() => priceAc.abort(), timeoutMs); + try { + const res = await fetch(`${root}/api/pricing`, { + method: "GET", + headers, + signal: priceAc.signal, + }); + if (res.ok) { + const body = (await res.json()) as unknown; + if (body && typeof body === "object" && !Array.isArray(body)) { + for (const [providerAlias, slot] of Object.entries( + body as Record, + )) { + if (!slot || typeof slot !== "object" || Array.isArray(slot)) + continue; + for (const [modelId, raw] of Object.entries( + slot as Record, + )) { + if (!raw || typeof raw !== "object") continue; + const p = raw as Record; + const parsed: NonNullable = + {}; + // OmniRoute `/api/pricing` keys: + // input → cost.input + // output → cost.output + // cached → cost.cache.read (alias: cacheRead) + // cache_creation → cost.cache.write (alias: cacheWrite) + // Tolerate alternative spellings for forward-compat. + if (typeof p.input === "number") parsed.input = p.input; + if (typeof p.output === "number") parsed.output = p.output; + const cacheRead = + typeof p.cached === "number" + ? p.cached + : typeof p.cacheRead === "number" + ? p.cacheRead + : undefined; + if (typeof cacheRead === "number") parsed.cacheRead = cacheRead; + const cacheWrite = + typeof p.cache_creation === "number" + ? p.cache_creation + : typeof p.cacheWrite === "number" + ? p.cacheWrite + : undefined; + if (typeof cacheWrite === "number") + parsed.cacheWrite = cacheWrite; + if (Object.keys(parsed).length === 0) continue; + const namespaced = `${providerAlias}/${modelId}`; + const existingNs = out.get(namespaced); + if (existingNs) + existingNs.pricing = { + ...(existingNs.pricing ?? {}), + ...parsed, + }; + else out.set(namespaced, { pricing: parsed }); + const existingBare = out.get(modelId); + if (existingBare) + existingBare.pricing = { + ...(existingBare.pricing ?? {}), + ...parsed, + }; + else out.set(modelId, { pricing: parsed }); + } + } + } + } + } catch { + // Soft-fail; return whatever names we collected. + } finally { + clearTimeout(priceTimer); + } + + // ── 3. Free model budgets from /api/free-tier/summary ────────────────── + // Best-effort fetch: populates freeType/monthlyTokens/creditTokens on + // enrichment entries that match. 404 = endpoint doesn't exist — skip. + // Uses the EXISTING /api/free-tier/summary endpoint (no new server code). + const freeAc = new AbortController(); + const freeTimer = setTimeout(() => freeAc.abort(), timeoutMs); + try { + const res = await fetch(`${root}/api/free-tier/summary`, { + method: "GET", + headers, + signal: freeAc.signal, + }); + if (res.ok) { + const body = (await res.json()) as unknown; + // Response shape: { perModel: FreeModelBudget[], ... } + const perModel: unknown[] = + body && + typeof body === "object" && + Array.isArray((body as { perModel?: unknown }).perModel) + ? ((body as { perModel: unknown[] }).perModel as unknown[]) + : Array.isArray(body) + ? (body as unknown[]) + : []; + let matched = 0; + for (const fm of perModel) { + if (!fm || typeof fm !== "object") continue; + const fmObj = fm as Record; + const provider = + typeof fmObj.provider === "string" ? fmObj.provider : ""; + const modelId = + typeof fmObj.modelId === "string" ? fmObj.modelId : ""; + const freeType = + typeof fmObj.freeType === "string" ? fmObj.freeType : ""; + if (!modelId || !freeType) continue; + const monthlyTokens = + typeof fmObj.monthlyTokens === "number" + ? fmObj.monthlyTokens + : undefined; + const creditTokens = + typeof fmObj.creditTokens === "number" + ? fmObj.creditTokens + : undefined; + // Match against enrichment entries: namespaced, bare, and displayName + const displayName = + typeof fmObj.displayName === "string" ? fmObj.displayName : ""; + const candidates = [ + `${provider}/${modelId}`, + modelId, + ...(displayName ? [displayName] : []), + ]; + for (const key of candidates) { + const entry = out.get(key); + if (entry) { + entry.freeType = freeType as FreeModelFreeType; + if (monthlyTokens !== undefined) + entry.monthlyTokens = monthlyTokens; + if (creditTokens !== undefined) entry.creditTokens = creditTokens; + matched++; + break; + } + } + } + _logger.debug( + `free-tier/summary: ${perModel.length} models returned, ${matched} matched enrichment entries`, + ); + } + } catch { + // Soft-fail; free metadata is optional. + } finally { + clearTimeout(freeTimer); + } + + return out; }; - // ── 1. Catalog with nice display names ──────────────────────────────── - const catalogAc = new AbortController(); - const catalogTimer = setTimeout(() => catalogAc.abort(), timeoutMs); - try { - const res = await fetch(`${root}/api/pricing/models`, { - method: "GET", - headers, - signal: catalogAc.signal, - }); - if (res.ok) { - const body = (await res.json()) as unknown; - const providers = - (body as { providers?: Record })?.providers ?? - (body as Record); - if (providers && typeof providers === "object") { - for (const [providerAlias, slot] of Object.entries(providers)) { - if (!slot || typeof slot !== "object") continue; - const models = (slot as { models?: unknown[] }).models; - if (!Array.isArray(models)) continue; - // Canonical id sits at the per-provider top level (e.g. - // `pricing-models.cc.id === 'claude'`). Falls back to the alias - // itself when missing — common case alias===canonical. - const canonicalRaw = (slot as { id?: unknown }).id; - const providerCanonical = - typeof canonicalRaw === "string" && canonicalRaw.length > 0 - ? canonicalRaw - : providerAlias; - // Upstream provider human label (e.g. `Claude`, `Kiro`, - // `GitHub Models`). Optional — falls back to undefined when - // OmniRoute hasn't curated a label for this slot. - const slotNameRaw = (slot as { name?: unknown }).name; - const providerDisplayName = - typeof slotNameRaw === "string" && slotNameRaw.trim().length > 0 - ? slotNameRaw.trim() - : undefined; - for (const m of models) { - if (!m || typeof m !== "object") continue; - const id = (m as { id?: unknown }).id; - if (typeof id !== "string" || id.length === 0) continue; - const name = (m as { name?: unknown }).name; - const entry: OmniRouteEnrichmentEntry = { - providerAlias, - providerCanonical, - }; - if (providerDisplayName) entry.providerDisplayName = providerDisplayName; - if (typeof name === "string" && name.trim().length > 0) entry.name = name; - const namespaced = `${providerAlias}/${id}`; - if (!out.has(namespaced)) out.set(namespaced, entry); - if (!out.has(id)) out.set(id, entry); - } - } - } - } - } catch { - // Soft-fail; keep going to pricing fetch. - } finally { - clearTimeout(catalogTimer); - } +// ── Startup diagnostics writer (file-based) ────────────────────────────── +// OC doesn't capture plugin console.warn in its log file. Write diagnostics +// to a file so they're readable after session starts. Capped at 64KB. +async function writeStartupDiagnostics(params: { + providerId: string; + baseURL: string; + modelCount: number; + comboCount: number; + enrichmentSize: number; + autoComboCount: number; + enrichment: OmniRouteEnrichmentMap; + autoCombos: OmniRouteRawAutoCombo[]; +}): Promise { + const { + providerId, + baseURL, + modelCount, + comboCount, + enrichmentSize, + autoComboCount, + enrichment, + autoCombos, + } = params; + const enriched = [...enrichment.entries()]; + const withName = enriched.filter(([, e]) => e.name); + const withPricing = enriched.filter(([, e]) => e.pricing); + const withFree = enriched.filter(([, e]) => e.freeType); - // ── 2. Pricing values from /api/pricing ─────────────────────────────── - const priceAc = new AbortController(); - const priceTimer = setTimeout(() => priceAc.abort(), timeoutMs); - try { - const res = await fetch(`${root}/api/pricing`, { - method: "GET", - headers, - signal: priceAc.signal, - }); - if (res.ok) { - const body = (await res.json()) as unknown; - if (body && typeof body === "object" && !Array.isArray(body)) { - for (const [providerAlias, slot] of Object.entries(body as Record)) { - if (!slot || typeof slot !== "object" || Array.isArray(slot)) continue; - for (const [modelId, raw] of Object.entries(slot as Record)) { - if (!raw || typeof raw !== "object") continue; - const p = raw as Record; - const parsed: NonNullable = {}; - // OmniRoute `/api/pricing` keys: - // input → cost.input - // output → cost.output - // cached → cost.cache.read (alias: cacheRead) - // cache_creation → cost.cache.write (alias: cacheWrite) - // Tolerate alternative spellings for forward-compat. - if (typeof p.input === "number") parsed.input = p.input; - if (typeof p.output === "number") parsed.output = p.output; - const cacheRead = - typeof p.cached === "number" - ? p.cached - : typeof p.cacheRead === "number" - ? p.cacheRead - : undefined; - if (typeof cacheRead === "number") parsed.cacheRead = cacheRead; - const cacheWrite = - typeof p.cache_creation === "number" - ? p.cache_creation - : typeof p.cacheWrite === "number" - ? p.cacheWrite - : undefined; - if (typeof cacheWrite === "number") parsed.cacheWrite = cacheWrite; - if (Object.keys(parsed).length === 0) continue; - const namespaced = `${providerAlias}/${modelId}`; - const existingNs = out.get(namespaced); - if (existingNs) existingNs.pricing = { ...(existingNs.pricing ?? {}), ...parsed }; - else out.set(namespaced, { pricing: parsed }); - const existingBare = out.get(modelId); - if (existingBare) existingBare.pricing = { ...(existingBare.pricing ?? {}), ...parsed }; - else out.set(modelId, { pricing: parsed }); - } - } - } + const lines: string[] = []; + lines.push(`=== startupDebug ${new Date().toISOString()} ===`); + lines.push(`providerId=${providerId} baseURL=${baseURL}`); + lines.push( + `models=${modelCount} combos=${comboCount} enrichment=${enrichmentSize} autoCombos=${autoComboCount}`, + ); + lines.push( + `enrichment: ${withName.length} with name, ${withPricing.length} with pricing, ${withFree.length} free`, + ); + if (withFree.length > 0) { + lines.push(`free models (${withFree.length}):`); + for (const [k, e] of withFree.slice(0, 10)) { + lines.push( + ` ${k} → name=${e.name ?? "(none)"}, freeType=${e.freeType}, monthly=${e.monthlyTokens ?? 0}, credits=${e.creditTokens ?? 0}`, + ); } - } catch { - // Soft-fail; return whatever names we collected. - } finally { - clearTimeout(priceTimer); + } else { + lines.push( + `NO free models detected. ` + + (enrichmentSize === 0 + ? "Enrichment map is EMPTY." + : `Enrichment has ${enrichmentSize} entries but none have freeType.`), + ); } + const sampleNames = enriched + .filter(([, e]) => e.name) + .slice(0, 5) + .map(([k, e]) => ` ${k} → "${e.name}"`); + if (sampleNames.length > 0) { + lines.push(`sample enriched names:`); + lines.push(sampleNames.join("\n")); + } + if (autoCombos.length > 0) { + lines.push( + `auto combos: ${autoCombos.length} — ${autoCombos.map((ac) => `${ac.id}(${ac.candidateCount ?? "?"}p)`).join(", ")}`, + ); + } + lines.push(`=== end startupDebug ===\n`); - return out; -}; + const diagnostics = lines.join("\n"); + _logger.debug(diagnostics); + + try { + const diagDir = + process.env.OPENCODE_DATA_DIR ?? + path.join(os.homedir(), ".local/share/opencode"); + const diagPath = path.join( + diagDir, + "plugins", + "omniroute-startup-diagnostics.log", + ); + let existing = ""; + try { + existing = await readFile(diagPath, "utf8"); + } catch { + /* first write */ + } + const KEEP = 65_536; + const combined = existing + diagnostics; + const trimmed = + combined.length > KEEP + ? combined.slice(combined.length - KEEP) + : combined; + await writeFile(diagPath, trimmed, "utf8"); + } catch { + /* best effort */ + } +} /** * Separator used by `applyProviderTag` between the upstream provider @@ -1229,64 +1723,13 @@ export const defaultOmniRouteEnrichmentFetcher: OmniRouteEnrichmentFetcher = asy * group by provider — e.g. `Claude - Claude Opus 4.7`, * `Kiro - Claude Opus 4.7`). */ -export const PROVIDER_TAG_SEPARATOR = " - "; +export const PROVIDER_TAG_SEPARATOR = _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 - * `