diff --git a/.env.example b/.env.example index ccabb13ebcb0..80900718706e 100644 --- a/.env.example +++ b/.env.example @@ -1072,6 +1072,14 @@ APP_LOG_TO_FILE=true # Default: 86400000 (24 hours) # OPENROUTER_CATALOG_TTL_MS=86400000 +# ── Model catalog response shape ── +# Include display-friendly name fields in /v1/models responses. +# Disable for clients that expect model IDs only. +# Defined in: src/shared/constants/featureFlagDefinitions.ts +# Used by: src/app/api/v1/models/catalog.ts +# Default: true +# MODEL_CATALOG_INCLUDE_NAMES=true + # ── NanoBanana (Image Generation) ── # Polling config for async image generation jobs. # Used by: open-sse/handlers/imageGeneration.ts @@ -1275,6 +1283,13 @@ APP_LOG_TO_FILE=true # Used by: src/app/api/providers/command-code/auth/shared.ts. # COMMAND_CODE_CALLBACK_PORT= +# ── Command Code CLI version header ── +# Value sent as the x-command-code-version header to the Command Code upstream. +# Overrides the built-in default; bump if the upstream requires a newer CLI version. +# Used by: open-sse/executors/commandCode.ts +# Default: 0.33.2 +# COMMAND_CODE_VERSION=0.33.2 + # ── MITM debug proxy (development only) ── # Used by: src/mitm/server.cjs — captures upstream traffic for inspection. # MITM_LOCAL_PORT=443 @@ -1468,3 +1483,16 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo) # QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa # QUOTA_CONSUMPTION_RETENTION_DAYS=14 # GC de buckets quota_consumption.updated_at antigos + +# ─── OpenCode config regeneration (scripts/ad-hoc/regen-opencode-config.ts) ─── +# Base URL of the OmniRoute instance to query for /v1/models when regenerating +# an opencode.json with accurate limit.context values. Used by: +# scripts/ad-hoc/regen-opencode-config.ts. Default: http://localhost:20128 +# OMNIROUTE_URL= +# API key to authenticate against the OmniRoute /v1/models endpoint. Falls back +# to OPENCODE_API_KEY when unset. Used by: scripts/ad-hoc/regen-opencode-config.ts. +# OMNIROUTE_KEY= +# OpenCode-style API key (sk-...) for the regenerated opencode.json. Used by: +# scripts/ad-hoc/regen-opencode-config.ts. Falls back to OMNIROUTE_KEY. +# OPENCODE_API_KEY= + diff --git a/.source/source.config.mjs b/.source/source.config.mjs index 1b0644fe1ef5..8405202c5562 100644 --- a/.source/source.config.mjs +++ b/.source/source.config.mjs @@ -4,6 +4,7 @@ var docs = defineDocs({ dir: "docs", docs: { files: [ + "./getting-started/**/*.md", "./architecture/**/*.md", "./guides/**/*.md", "./reference/**/*.md", diff --git a/@omniroute/opencode-plugin/README.md b/@omniroute/opencode-plugin/README.md index 73cad32efd23..f5d52c511d21 100644 --- a/@omniroute/opencode-plugin/README.md +++ b/@omniroute/opencode-plugin/README.md @@ -1,6 +1,20 @@ # @omniroute/opencode-plugin -First-class OpenCode plugin for the [OmniRoute AI Gateway](https://github.com/diegosouzapw/OmniRoute). Pulls a live model catalog from `/v1/models` (including `-low`/`-medium`/`-high`/`-thinking` variants as first-class IDs), aggregates combos via `/api/combos` using a least-common-denominator capability/limit join, sanitizes Gemini tool schemas in flight, and supports multiple side-by-side OmniRoute instances out of the box. +> **Recommended way to use OmniRoute with OpenCode.** Pulls a live model catalog from `/v1/models` (including `-low`/`-medium`/`-high`/`-thinking` variants as first-class IDs), aggregates combos via `/api/combos` using a least-common-denominator capability/limit join, sanitizes Gemini tool schemas in flight, and supports multiple side-by-side OmniRoute instances out of the box. + +## Why this and not `@omniroute/opencode-provider`? + +`@omniroute/opencode-provider` is the legacy config-generator package — it writes a frozen `provider.omniroute` block into `opencode.json` with a **hardcoded list of 8 models** ([`OMNIROUTE_DEFAULT_OPENCODE_MODELS`](https://github.com/diegosouzapw/OmniRoute/blob/main/%40omniroute/opencode-provider/src/index.ts#L48-L56)). It works on the CLI but in the **OpenCode Desktop / Web** builds (Tauri / Electron) the runtime re-runs the model picker and the static block surfaces only a few of those — and they drift behind the live OmniRoute catalog. + +This plugin solves that by: + +- Fetching `/v1/models` and `/api/combos` **at OpenCode startup, in Node.js** — no CORS, no WebView restrictions +- Emitting the provider block **dynamically** in the plugin's `config`/`provider` hook — so `opencode.json` only needs the plugin entry, not a static `provider.omniroute` +- Re-fetching on a configurable TTL (default 5 min), so new models / combo changes in the OmniRoute UI appear without restarting OpenCode +- Computing `limit.context` for combos as `min(member.context_length)` from the live catalog (no more `null` values that cause 4K-token truncation) +- **Auto-pickup of `interleaved` capability** for thinking models (merged via PR #3138) + +**If you only have the legacy `opencode-provider` block in your `opencode.json`, replace it with a single plugin entry.** No other config changes required — the same `auth.json` API key works. ## Install diff --git a/@omniroute/opencode-plugin/package.json b/@omniroute/opencode-plugin/package.json index 22d50af18313..8489bbd9e13b 100644 --- a/@omniroute/opencode-plugin/package.json +++ b/@omniroute/opencode-plugin/package.json @@ -26,7 +26,7 @@ "scripts": { "build": "tsup", "clean": "rm -rf dist", - "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts", + "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts", "prepublishOnly": "npm run clean && npm run build && npm test" }, "keywords": [ diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index c6cc851058e5..4074ebdace4c 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -49,9 +49,30 @@ import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; import type { AuthHook, Config, Plugin, PluginOptions, ProviderHook } from "@opencode-ai/plugin"; import type { Model as ModelV2 } from "@opencode-ai/sdk/v2"; import { z } from "zod"; +import { + logger as _logger, + setLogLevel, + type LogLevel as _LogLevel, +} from "./logger.js"; +import { + PROVIDER_TAG_SEPARATOR as _PROVIDER_TAG_SEPARATOR, + shortProviderLabel as _shortProviderLabel, + normaliseFreeLabel as _normaliseFreeLabel, + formatAutoComboName, + autoComboModelId, + formatFreeBudget, + type AutoVariant, + AUTO_VARIANTS, + AUTO_VARIANT_DESCRIPTIONS, + type FreeModelFreeType, +} from "./naming.js"; /** * Zod schema for plugin options accepted as the second element of the @@ -107,10 +128,42 @@ import { z } from "zod"; * preferred over the chat/inference key. * - `fetchInterceptor` Inject Authorization: Bearer + Content-Type on * every outbound request to baseURL. Default true. + * - `debugLog` Capture every outbound request + response to a + * JSONL file at + * `~/.local/share/opencode/plugins/omniroute-debug-{providerId}.jsonl`. + * Each line: `{ reqId, ts, url, method, reqBody, + * resStatus, resBody, durationMs }`. + * Default false. Opt-in. + * - `apiFormat` Per-provider-prefix API format routing. Model IDs + * whose prefix (the part before `/`) matches an entry + * in `anthropicPrefixes` are served via the Anthropic + * SDK (`@ai-sdk/anthropic`, sends to `/v1/messages` + * with native cache_control, tool_choice, etc.). + * All other models fall back to `openai-compatible`. + * + * Default `anthropicPrefixes`: + * ["cc", "claude", "anthropic", "kiro", "kr"] + * (covers OmniRoute's canonical Anthropic aliases). + * + * Set `anthropicPrefixes: []` to disable and force + * everything through OpenAI-compat. + * + * Example: + * ```json + * "apiFormat": { "anthropicPrefixes": ["cc","claude","anthropic","kiro"] } + * ``` */ +const apiFormatSchema = z + .object({ + anthropicPrefixes: z.array(z.string()).optional(), + }) + .strict() + .optional(); + const featuresSchema = z .object({ combos: z.boolean().optional(), + autoCombos: z.boolean().optional(), enrichment: z.boolean().optional(), compressionMetadata: z.boolean().optional(), geminiSanitization: z.boolean().optional(), @@ -120,6 +173,8 @@ const featuresSchema = z usableOnly: z.boolean().optional(), diskCache: z.boolean().optional(), providerTag: z.boolean().optional(), + debugLog: z.boolean().optional(), + apiFormat: apiFormatSchema, }) .strict(); @@ -148,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 @@ -158,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--; @@ -175,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 @@ -212,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) { @@ -238,6 +363,98 @@ function coercePluginOptions(opts?: PluginOptions): OmniRoutePluginOptions { return parseOmniRoutePluginOptions(opts); } +// ──────────────────────────────────────────────────────────────────────────── +// Per-prefix API format routing (apiFormat feature) +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Default provider-prefix list that triggers the Anthropic SDK format. + * Covers OmniRoute's canonical Anthropic aliases: `cc/`, `claude/`, + * `anthropic/`, plus the user-configured `kiro/` and `kr/` upstream + * connections that proxy Anthropic models. + */ +export const DEFAULT_ANTHROPIC_PREFIXES = [ + "cc", + "claude", + "anthropic", + "kiro", + "kr", +]; + +/** + * Ensure a baseURL ends with `/v1` so the OpenAI-compat SDK constructs + * `/v1/chat/completions` correctly. The Anthropic SDK does NOT want `/v1` + * (it appends `/v1/messages` automatically), so callers should branch on + * format first. + */ +export function ensureV1Suffix(url: string): string { + const trimmed = trimTrailingSlashes(url); + return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`; +} + +/** + * Resolve the API block (id + url + npm package) for a given model id. + * + * Decision matrix: + * - If the model id's prefix (the substring before the first `/`) is in + * `apiFormat.anthropicPrefixes` (or the default list), return the + * Anthropic SDK block: `id: "anthropic"`, `url: baseURL` (no `/v1`), + * `npm: "@ai-sdk/anthropic"`. + * - Otherwise return the OpenAI-compat block: `id: "openai-compatible"`, + * `url: baseURL + "/v1"`, `npm: "@ai-sdk/openai-compatible"`. + * + * Combos span multiple providers. Callers should pass each combo member's + * id through this function and pick the LCD format (lowest common + * denominator that every upstream actually understands). + */ +export function resolveApiBlock( + modelId: string, + baseURL: string, + apiFormat?: { anthropicPrefixes?: string[] } +): { id: string; url: string; npm: string } { + const prefixes = apiFormat?.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES; + const slash = modelId.indexOf("/"); + const prefix = slash === -1 ? modelId : modelId.slice(0, slash); + const isAnthropic = prefixes.includes(prefix); + return isAnthropic + ? { + id: "anthropic", + url: trimTrailingSlashes(baseURL), + npm: "@ai-sdk/anthropic", + } + : { + id: "openai-compatible", + url: ensureV1Suffix(baseURL), + npm: "@ai-sdk/openai-compatible", + }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Free-label normalisation +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Normalise a model display name so free-tier models always carry a + * consistent `[Free] ` prefix instead of a trailing `(Free)` suffix or + * an ad-hoc `free` word anywhere in the name. + * + * "GPT-4.1 (Free)" → "[Free] GPT-4.1" + * "DeepSeek V4 Flash Free" → "[Free] DeepSeek V4 Flash" + * "Claude 4.7 Opus" → "Claude 4.7 Opus" (no change) + * + * Non-matching names pass through untouched. + */ +export function normaliseFreeLabel(name: string): string { + // Remove trailing " (Free)" or " free" (case-insensitive, hyphen-tolerant) + const cleaned = name + .replace(/\s*\(free\)\s*$/i, "") + .replace(/[\s-]+free\s*$/i, "") + .trim(); + const wasFree = cleaned.length < name.trim().length; + if (!wasFree) return name; + return `[Free] ${cleaned}`; +} + /** * 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 @@ -262,14 +479,18 @@ function coercePluginOptions(opts?: PluginOptions): OmniRoutePluginOptions { * 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 // documented and schema-validated but silently ignored. const wantFetchInterceptor = (features ?? {}).fetchInterceptor !== false; const wantGeminiSanitization = (features ?? {}).geminiSanitization !== false; + const wantDebugLog = (features ?? {}).debugLog === true; const hook: AuthHook = { provider: providerId, @@ -304,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 @@ -330,9 +552,16 @@ export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook if (wantGeminiSanitization) { composedFetch = createGeminiSanitizingFetch(composedFetch ?? fetch); } + if (wantDebugLog || debugLogEnabled(providerId)) { + composedFetch = createDebugLoggingFetch( + composedFetch ?? fetch, + providerId, + wantDebugLog + ); + } return composedFetch - ? { apiKey, baseURL: resolvedBaseURL, fetch: composedFetch } - : { apiKey, baseURL: resolvedBaseURL }; + ? { apiKey, baseURL: sdkBaseURL, fetch: composedFetch } + : { apiKey, baseURL: sdkBaseURL }; } return {}; }, @@ -365,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), @@ -446,7 +697,7 @@ export interface OmniRouteRawModelEntry { export type OmniRouteModelsFetcher = ( baseURL: string, apiKey: string, - timeoutMs?: number + timeoutMs?: number, ) => Promise; /** @@ -459,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); @@ -482,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); } } @@ -561,7 +826,7 @@ export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async ( export function mapRawModelToModelV2( raw: OmniRouteRawModelEntry, - ctx: { providerId: string; baseURL: string } + ctx: { providerId: string; baseURL: string; apiFormat?: { anthropicPrefixes?: string[] } } ): ModelV2 { const caps = raw.capabilities ?? {}; const inMods = new Set(raw.input_modalities ?? ["text"]); @@ -575,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), @@ -604,19 +869,18 @@ 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: {}, headers: {}, release_date: raw.release_date ?? "", providerID: ctx.providerId, - api: { - id: "openai-compatible", - url: ctx.baseURL, - npm: "@ai-sdk/openai-compatible", - }, + api: resolveApiBlock(raw.id, ctx.baseURL, ctx.apiFormat), }; } @@ -684,7 +948,7 @@ export interface OmniRouteRawCombo { export type OmniRouteCombosFetcher = ( baseURL: string, apiKey: string, - timeoutMs?: number + timeoutMs?: number, ) => Promise; /** @@ -708,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 @@ -734,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); } } @@ -797,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)); @@ -819,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"), @@ -845,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: { @@ -875,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. @@ -921,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`). */ @@ -929,7 +1373,7 @@ export type OmniRouteEnrichmentMap = Map; export type OmniRouteEnrichmentFetcher = ( baseURL: string, apiKey: string, - timeoutMs?: number + timeoutMs?: number, ) => Promise; /** @@ -953,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); + // ── 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); } - } 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 }); + // ── 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); } - } catch { - // Soft-fail; return whatever names we collected. - } finally { - clearTimeout(priceTimer); + + // ── 3. Free model budgets from /api/free-tier/summary ────────────────── + // Best-effort fetch: populates freeType/monthlyTokens/creditTokens on + // enrichment entries that match. 404 = endpoint doesn't exist — skip. + // Uses the EXISTING /api/free-tier/summary endpoint (no new server code). + const freeAc = new AbortController(); + const freeTimer = setTimeout(() => freeAc.abort(), timeoutMs); + try { + const res = await fetch(`${root}/api/free-tier/summary`, { + method: "GET", + headers, + signal: freeAc.signal, + }); + if (res.ok) { + const body = (await res.json()) as unknown; + // Response shape: { perModel: FreeModelBudget[], ... } + const perModel: unknown[] = + body && + typeof body === "object" && + Array.isArray((body as { perModel?: unknown }).perModel) + ? ((body as { perModel: unknown[] }).perModel as unknown[]) + : Array.isArray(body) + ? (body as unknown[]) + : []; + let matched = 0; + for (const fm of perModel) { + if (!fm || typeof fm !== "object") continue; + const fmObj = fm as Record; + const provider = + typeof fmObj.provider === "string" ? fmObj.provider : ""; + const modelId = + typeof fmObj.modelId === "string" ? fmObj.modelId : ""; + const freeType = + typeof fmObj.freeType === "string" ? fmObj.freeType : ""; + if (!modelId || !freeType) continue; + const monthlyTokens = + typeof fmObj.monthlyTokens === "number" + ? fmObj.monthlyTokens + : undefined; + const creditTokens = + typeof fmObj.creditTokens === "number" + ? fmObj.creditTokens + : undefined; + // Match against enrichment entries: namespaced, bare, and displayName + const displayName = + typeof fmObj.displayName === "string" ? fmObj.displayName : ""; + const candidates = [ + `${provider}/${modelId}`, + modelId, + ...(displayName ? [displayName] : []), + ]; + for (const key of candidates) { + const entry = out.get(key); + if (entry) { + entry.freeType = freeType as FreeModelFreeType; + if (monthlyTokens !== undefined) + entry.monthlyTokens = monthlyTokens; + if (creditTokens !== undefined) entry.creditTokens = creditTokens; + matched++; + break; + } + } + } + _logger.debug( + `free-tier/summary: ${perModel.length} models returned, ${matched} matched enrichment entries`, + ); + } + } catch { + // Soft-fail; free metadata is optional. + } finally { + clearTimeout(freeTimer); + } + + return out; + }; + +// ── Startup diagnostics writer (file-based) ────────────────────────────── +// OC doesn't capture plugin console.warn in its log file. Write diagnostics +// to a file so they're readable after session starts. Capped at 64KB. +async function writeStartupDiagnostics(params: { + providerId: string; + baseURL: string; + modelCount: number; + comboCount: number; + enrichmentSize: number; + autoComboCount: number; + enrichment: OmniRouteEnrichmentMap; + autoCombos: OmniRouteRawAutoCombo[]; +}): Promise { + const { + providerId, + baseURL, + modelCount, + comboCount, + enrichmentSize, + autoComboCount, + enrichment, + autoCombos, + } = params; + const enriched = [...enrichment.entries()]; + const withName = enriched.filter(([, e]) => e.name); + const withPricing = enriched.filter(([, e]) => e.pricing); + const withFree = enriched.filter(([, e]) => e.freeType); + + const lines: string[] = []; + lines.push(`=== startupDebug ${new Date().toISOString()} ===`); + lines.push(`providerId=${providerId} baseURL=${baseURL}`); + lines.push( + `models=${modelCount} combos=${comboCount} enrichment=${enrichmentSize} autoCombos=${autoComboCount}`, + ); + lines.push( + `enrichment: ${withName.length} with name, ${withPricing.length} with pricing, ${withFree.length} free`, + ); + if (withFree.length > 0) { + lines.push(`free models (${withFree.length}):`); + for (const [k, e] of withFree.slice(0, 10)) { + lines.push( + ` ${k} → name=${e.name ?? "(none)"}, freeType=${e.freeType}, monthly=${e.monthlyTokens ?? 0}, credits=${e.creditTokens ?? 0}`, + ); + } + } else { + lines.push( + `NO free models detected. ` + + (enrichmentSize === 0 + ? "Enrichment map is EMPTY." + : `Enrichment has ${enrichmentSize} entries but none have freeType.`), + ); } + const sampleNames = enriched + .filter(([, e]) => e.name) + .slice(0, 5) + .map(([k, e]) => ` ${k} → "${e.name}"`); + if (sampleNames.length > 0) { + lines.push(`sample enriched names:`); + lines.push(sampleNames.join("\n")); + } + if (autoCombos.length > 0) { + lines.push( + `auto combos: ${autoCombos.length} — ${autoCombos.map((ac) => `${ac.id}(${ac.candidateCount ?? "?"}p)`).join(", ")}`, + ); + } + lines.push(`=== end startupDebug ===\n`); - 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 @@ -1096,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 = " - "; - -/** - * 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; +export const PROVIDER_TAG_SEPARATOR = _PROVIDER_TAG_SEPARATOR; -/** - * 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 - * `