diff --git a/@omniroute/opencode-plugin-v2/README.md b/@omniroute/opencode-plugin-v2/README.md index 873290a9d1..00b9cd4ad9 100644 --- a/@omniroute/opencode-plugin-v2/README.md +++ b/@omniroute/opencode-plugin-v2/README.md @@ -56,8 +56,14 @@ explicitly: } ``` +The token can also come from the `OMNIROUTE_MANAGEMENT_API_KEY` environment +variable (the option wins when both are set). Resolution order: +`managementReadToken` option, then `OMNIROUTE_MANAGEMENT_API_KEY`, then the +`apiKey` fallback. + Left unset, `managementReadToken` falls back to `apiKey` for backwards -compatibility. When a gateway rejects that fallback, the catalog still +compatibility, and the plugin warns once at startup that the fallback is +active. When a gateway rejects that fallback, the catalog still publishes — but with raw model ids instead of display names, no canonical alias dedupe, no pricing and no combos. The plugin warns once per endpoint when this happens, naming the endpoint and the consequence, so the degraded @@ -65,25 +71,25 @@ catalog is never a mystery. ## Options -| Key | Default | Notes | -| -------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| `providerId` | `"omniroute"` | Provider id and integration id; models publish under `/…` | -| `baseURL` | required | OmniRoute gateway root (no `/v1` suffix needed) | -| `apiKey` | connected credential, then `OMNIROUTE_API_KEY` | Chat key for `/v1/*` — see [Credentials](#credentials) | -| `managementReadToken` | falls back to `apiKey` | Management key for `/api/*` (combos, providers, enrichment) — usually **not** the same key | -| `displayName` | `"OmniRoute"` | Provider display name | -| `timeoutMs` | `10000` | Per-endpoint fetch timeout (auto-combos use 5s) | -| `modelCacheTtlMs` | `300000` | Catalog cache TTL; disk snapshot warms cold starts | -| `timeouts` | per-endpoint override | `{ models, combos, autoCombos, enrichment }` in ms; falls back to `timeoutMs` | -| `enrichment` | `true` | Fetch names + pricing (`/api/pricing*`, `/api/free-tier/summary`) | -| `providerTag` | `true` | Prefix a display name with the upstream provider it routes to | -| `geminiSanitization` | `true` | Strip `$schema`/`additionalProperties` from tool schemas sent to Gemini models (`$ref` tools are forwarded untouched) | -| `usableOnly` | `false` | Filter to healthy provisioned providers (`/api/providers`) | -| `visibleModels` / `hiddenModels` | `[]` | Exact-or-suffix allowlists, deny wins | -| `apiFormat.allowAnthropic` | `false` | Route allowlisted ids to the Anthropic API block | -| `apiFormat.anthropicModels` | `[]` | Full model ids routed to Anthropic | -| `apiFormat.anthropicPrefixes` | v1 defaults | Deprecated, warns once — prefer `anthropicModels` | -| `logLevel` / `startupDebug` | `warn` / `false` | Logger verbosity | +| Key | Default | Notes | +| -------------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `providerId` | `"omniroute"` | Provider id and integration id; models publish under `/…` | +| `baseURL` | required | OmniRoute gateway root (no `/v1` suffix needed) | +| `apiKey` | connected credential, then `OMNIROUTE_API_KEY` | Chat key for `/v1/*` — see [Credentials](#credentials) | +| `managementReadToken` | option, then `OMNIROUTE_MANAGEMENT_API_KEY`, then `apiKey` | Management key for `/api/*` (combos, providers, enrichment) — usually **not** the same key | +| `displayName` | `"OmniRoute"` | Provider display name | +| `timeoutMs` | `10000` | Per-endpoint fetch timeout (auto-combos use 5s) | +| `modelCacheTtlMs` | `300000` | Catalog cache TTL; disk snapshot warms cold starts | +| `timeouts` | per-endpoint override | `{ models, combos, autoCombos, enrichment }` in ms; falls back to `timeoutMs` | +| `enrichment` | `true` | Fetch names + pricing (`/api/pricing*`, `/api/free-tier/summary`) | +| `providerTag` | `true` | Prefix a display name with the upstream provider it routes to | +| `geminiSanitization` | `true` | Strip `$schema`/`additionalProperties` from tool schemas sent to Gemini models (`$ref` tools are forwarded untouched) | +| `usableOnly` | `false` | Filter to healthy provisioned providers (`/api/providers`) | +| `visibleModels` / `hiddenModels` | `[]` | Exact-or-suffix allowlists, deny wins | +| `apiFormat.allowAnthropic` | `false` | Route allowlisted ids to the Anthropic API block | +| `apiFormat.anthropicModels` | `[]` | Full model ids routed to Anthropic | +| `apiFormat.anthropicPrefixes` | v1 defaults | Deprecated, warns once — prefer `anthropicModels` | +| `logLevel` / `startupDebug` | `warn` / `false` | Logger verbosity | ## Tool calling on Gemini models diff --git a/@omniroute/opencode-plugin-v2/src/index.ts b/@omniroute/opencode-plugin-v2/src/index.ts index f6c0471baf..44f0e8eefd 100644 --- a/@omniroute/opencode-plugin-v2/src/index.ts +++ b/@omniroute/opencode-plugin-v2/src/index.ts @@ -31,7 +31,14 @@ import { assertContext } from "./compat.js"; import { type ApiKeyOrigin, resolveApiKey, warnIfMissing } from "./credentials.js"; import { createSourceErrorReporter } from "./enrichment-report.js"; import { sanitizeToolSchemasFor } from "./gemini-language.js"; -import { PLUGIN_ID, parsePluginOptions, resolveTimeouts, type PluginOptions } from "./options.js"; +import { + MANAGEMENT_TOKEN_ENV_VAR, + PLUGIN_ID, + parsePluginOptions, + resolveManagementReadToken, + resolveTimeouts, + type PluginOptions, +} from "./options.js"; /** * A fetch result that says whether it succeeded. Returning a bare `[]` on @@ -61,7 +68,7 @@ function toResolvedOptions(parsed: PluginOptions): ResolvedOptions { providerId: parsed.providerId, baseURL: parsed.baseURL, apiKey: parsed.apiKey ?? process.env.OMNIROUTE_API_KEY ?? "", - managementReadToken: parsed.managementReadToken, + managementReadToken: resolveManagementReadToken(parsed.managementReadToken), timeoutMs: parsed.timeoutMs, timeouts: parsed.timeouts, logLevel: parsed.logLevel, @@ -93,6 +100,16 @@ export default define({ resolved.logLevel = parsed.logLevel; resolved.startupDebug = parsed.startupDebug; log.info(`[omniroute-v2] init providerId=${X}`); + // The inference key stands in below when no management token is set, and + // gateways usually reject that stand-in with 401/403. Say so once here, + // before any fetch, instead of letting the refusal surface per endpoint. + if (resolved.managementReadToken === undefined) { + log.warn( + `[omniroute-v2] no management token configured: management endpoints (/api/*) will reuse the inference key, ` + + `which gateways usually reject with 401/403. Set "managementReadToken" in the plugin options ` + + `or export ${MANAGEMENT_TOKEN_ENV_VAR}.` + ); + } // v1 parity port: in-memory TTL + disk snapshot. The memory key // `baseURL::sha256(creds)` isolates credential tuples (prod vs diff --git a/@omniroute/opencode-plugin-v2/src/options.ts b/@omniroute/opencode-plugin-v2/src/options.ts index 9f9f23041c..b0f5743697 100644 --- a/@omniroute/opencode-plugin-v2/src/options.ts +++ b/@omniroute/opencode-plugin-v2/src/options.ts @@ -61,6 +61,21 @@ const pluginOptionsSchema = z export type PluginOptions = z.infer; +/** Environment source for the management token (option wins over this). */ +export const MANAGEMENT_TOKEN_ENV_VAR = "OMNIROUTE_MANAGEMENT_API_KEY"; + +/** + * Resolve the management token: a non-empty option wins, then a non-empty + * environment value, else absent. Empty counts as absent on both inputs, the + * same rule the inference key follows; no trimming, the token is opaque. + */ +export function resolveManagementReadToken(optionValue: string | undefined): string | undefined { + if (optionValue !== undefined && optionValue.length > 0) return optionValue; + const fromEnv = process.env[MANAGEMENT_TOKEN_ENV_VAR]; + if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv; + return undefined; +} + /** Per-endpoint timeout defaults (v1 parity). `timeoutMs` is the global fallback. */ export const DEFAULT_TIMEOUT_MS = 10_000 as const; /** Auto-combos keep the v1 5s budget; the field is resolved now for the P3 port. */ diff --git a/@omniroute/opencode-plugin-v2/tests/management-token-env.test.ts b/@omniroute/opencode-plugin-v2/tests/management-token-env.test.ts new file mode 100644 index 0000000000..d8e4171d66 --- /dev/null +++ b/@omniroute/opencode-plugin-v2/tests/management-token-env.test.ts @@ -0,0 +1,371 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import plugin from "../src/index.js"; +import { publishCatalog } from "../src/catalog.js"; +import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise"; +import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types"; + +const MODELS_URL = "https://gw.example.com/v1/models"; +const COMBOS_URL = "https://gw.example.com/api/combos"; +const PRICING_MODELS_URL = "https://gw.example.com/api/pricing/models"; + +const MGMT_ENV_VAR = "OMNIROUTE_MANAGEMENT_API_KEY"; +const INFERENCE_ENV_VAR = "OMNIROUTE_API_KEY"; + +function okJson(body: unknown) { + return { ok: true, status: 200, statusText: "OK", json: async () => body }; +} + +interface Harness { + seen: Map; + warns: string[]; + restore: () => void; +} + +function installHarness(combos: unknown[]): Harness { + const seen = new Map(); + const warns: string[] = []; + const origFetch = globalThis.fetch; + const origWarn = console.warn; + const origLog = console.log; + const origError = console.error; + console.warn = (...args: unknown[]) => { + warns.push(String(args[0])); + }; + console.log = () => {}; + console.error = (...args: unknown[]) => { + warns.push(String(args[0])); + }; + globalThis.fetch = (async (url: unknown, init?: { headers?: Record }) => { + const href = String(url); + seen.set(href, String(init?.headers?.Authorization ?? "")); + if (href.includes("/api/combos/auto")) return okJson({ combos: [] }); + if (href.includes("/api/pricing/models")) { + return okJson({ + providers: { + demo: { + id: "demo", + name: "Demo", + models: [{ id: "team-combo", name: "Team Combo" }], + }, + }, + }); + } + if (href.includes("/api/pricing")) return okJson({}); + if (href.includes("/api/free-tier/summary")) return okJson({ perModel: [] }); + if (href.includes("/api/combos")) return okJson({ combos }); + return okJson({ data: [{ id: "m1" }] }); + }) as typeof fetch; + return { + seen, + warns, + restore() { + globalThis.fetch = origFetch; + console.warn = origWarn; + console.log = origLog; + console.error = origError; + }, + }; +} + +async function withIsolatedEnv( + mgmt: string | undefined, + inference: string | undefined, + fn: () => Promise +): Promise { + const prevMgmt = process.env[MGMT_ENV_VAR]; + const prevInference = process.env[INFERENCE_ENV_VAR]; + // Like tests/management-token.test.ts:176-180: a fresh OPENCODE_DATA_DIR + // per case keeps the real disk snapshot out of the run, so a filtered 'it' + // never gets a warm snapshot served without fetch. + const prevDataDir = process.env.OPENCODE_DATA_DIR; + process.env.OPENCODE_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-mgmt-env-")); + if (mgmt === undefined) delete process.env[MGMT_ENV_VAR]; + else process.env[MGMT_ENV_VAR] = mgmt; + if (inference === undefined) delete process.env[INFERENCE_ENV_VAR]; + else process.env[INFERENCE_ENV_VAR] = inference; + try { + return await fn(); + } finally { + if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR; + else process.env.OPENCODE_DATA_DIR = prevDataDir; + if (prevMgmt === undefined) delete process.env[MGMT_ENV_VAR]; + else process.env[MGMT_ENV_VAR] = prevMgmt; + if (prevInference === undefined) delete process.env[INFERENCE_ENV_VAR]; + else process.env[INFERENCE_ENV_VAR] = prevInference; + } +} + +function setupHarness(options: Record) { + const catalogCallbacks: Array<(draft: unknown) => Promise> = []; + const ctx = { + options, + catalog: { + transform: (cb: (draft: unknown) => Promise) => { + catalogCallbacks.push(cb); + return Promise.resolve({ dispose: async () => {} }); + }, + }, + integration: { + transform: () => Promise.resolve({ dispose: async () => {} }), + }, + }; + return { catalogCallbacks, ctx }; +} + +function stubDraft() { + const published = new Map>(); + const draft = { + provider: { update: (_id: string, fn: (p: Record) => void) => fn({}) }, + model: { + update: (pid: string, mid: string, fn: (m: Record) => void) => { + const key = pid + "/" + mid; + let entry = published.get(key); + if (entry === undefined) { + entry = { id: mid, providerID: pid }; + published.set(key, entry); + } + fn(entry); + }, + }, + }; + return { draft, published }; +} + +function fallbackWarns(warns: string[]): string[] { + return warns.filter((w) => w.includes("managementReadToken")); +} + +async function runSetup(ctx: unknown): Promise { + await (plugin as unknown as { setup: (ctx: unknown) => Promise }).setup(ctx); +} + +describe("plugin-v2 management token environment source", () => { + it("uses the managementReadToken option for /api/* while models keep apiKey", async () => { + await withIsolatedEnv(undefined, undefined, async () => { + const h = installHarness([]); + try { + const { catalogCallbacks, ctx } = setupHarness({ + baseURL: "https://gw.example.com", + providerId: "omniroute", + apiKey: "chat-key", + managementReadToken: "mgmt-option-token", + }); + await runSetup(ctx); + assert.deepEqual(fallbackWarns(h.warns), []); + const { draft } = stubDraft(); + await catalogCallbacks[0](draft); + assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-option-token"); + assert.equal(h.seen.get(MODELS_URL), "Bearer chat-key"); + } finally { + h.restore(); + } + }); + }); + + it("reads the management token from the environment when the option is absent", async () => { + await withIsolatedEnv("mgmt-env-token", undefined, async () => { + const h = installHarness([]); + try { + const { catalogCallbacks, ctx } = setupHarness({ + baseURL: "https://gw.example.com", + providerId: "omniroute", + apiKey: "chat-key", + }); + await runSetup(ctx); + assert.deepEqual(fallbackWarns(h.warns), []); + const { draft } = stubDraft(); + await catalogCallbacks[0](draft); + assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-env-token"); + assert.equal(h.seen.get(MODELS_URL), "Bearer chat-key"); + } finally { + h.restore(); + } + }); + }); + + it("prefers the option over the environment", async () => { + await withIsolatedEnv("mgmt-env-token", undefined, async () => { + const h = installHarness([]); + try { + const { catalogCallbacks, ctx } = setupHarness({ + baseURL: "https://gw.example.com", + providerId: "omniroute", + apiKey: "chat-key", + managementReadToken: "mgmt-option-token", + }); + await runSetup(ctx); + assert.deepEqual(fallbackWarns(h.warns), []); + const { draft } = stubDraft(); + await catalogCallbacks[0](draft); + assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-option-token"); + } finally { + h.restore(); + } + }); + }); + + it("falls back to the inference key with a single early warning when neither is set", async () => { + await withIsolatedEnv(undefined, undefined, async () => { + const h = installHarness([]); + try { + const { catalogCallbacks, ctx } = setupHarness({ + baseURL: "https://gw.example.com", + providerId: "omniroute", + apiKey: "chat-key", + }); + await runSetup(ctx); + const atSetup = fallbackWarns(h.warns); + assert.equal( + atSetup.length, + 1, + `expected exactly one early fallback warning, got: ${JSON.stringify(h.warns)}` + ); + assert.match(atSetup[0] ?? "", /managementReadToken/); + assert.match(atSetup[0] ?? "", new RegExp(MGMT_ENV_VAR)); + assert.ok(!(atSetup[0] ?? "").includes("chat-key"), "warning must not leak the key"); + const { draft } = stubDraft(); + await catalogCallbacks[0](draft); + assert.equal(h.seen.get(COMBOS_URL), "Bearer chat-key"); + assert.equal( + fallbackWarns(h.warns).length, + 1, + "the fallback warning stays a single setup-time notice" + ); + } finally { + h.restore(); + } + }); + }); + + it("treats an empty option as absent so the environment wins", async () => { + await withIsolatedEnv("mgmt-env-token", undefined, async () => { + const h = installHarness([]); + try { + const { catalogCallbacks, ctx } = setupHarness({ + baseURL: "https://gw.example.com", + providerId: "omniroute", + apiKey: "chat-key", + managementReadToken: "", + }); + await runSetup(ctx); + assert.deepEqual(fallbackWarns(h.warns), []); + const { draft } = stubDraft(); + await catalogCallbacks[0](draft); + assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-env-token"); + } finally { + h.restore(); + } + }); + }); + + it("treats an empty environment value as absent so the option wins", async () => { + await withIsolatedEnv("", undefined, async () => { + const h = installHarness([]); + try { + const { catalogCallbacks, ctx } = setupHarness({ + baseURL: "https://gw.example.com", + providerId: "omniroute", + apiKey: "chat-key", + managementReadToken: "mgmt-option-token", + }); + await runSetup(ctx); + assert.deepEqual(fallbackWarns(h.warns), []); + const { draft } = stubDraft(); + await catalogCallbacks[0](draft); + assert.equal(h.seen.get(COMBOS_URL), "Bearer mgmt-option-token"); + } finally { + h.restore(); + } + }); + }); + + it("falls back with a warning when both the option and the environment are empty", async () => { + await withIsolatedEnv("", undefined, async () => { + const h = installHarness([]); + try { + const { catalogCallbacks, ctx } = setupHarness({ + baseURL: "https://gw.example.com", + providerId: "omniroute", + apiKey: "chat-key", + managementReadToken: "", + }); + await runSetup(ctx); + assert.equal(fallbackWarns(h.warns).length, 1); + const { draft } = stubDraft(); + await catalogCallbacks[0](draft); + assert.equal(h.seen.get(COMBOS_URL), "Bearer chat-key"); + } finally { + h.restore(); + } + }); + }); + + it("enriches the catalog from the environment token alone", async () => { + const providers = new Map(); + const models = new Map(); + const draft = { + provider: { + list: () => [], + get: (id: string) => providers.get(id) as never, + update: (id: string, fn: (p: ProviderV2Info) => void) => { + const p = (providers.get(id) ?? { id }) as ProviderV2Info; + fn(p); + providers.set(id, p); + }, + remove: () => {}, + }, + model: { + get: () => undefined, + update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => { + const k = pid + "/" + mid; + const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info; + fn(m); + models.set(k, m); + }, + remove: () => {}, + default: { get: () => undefined, set: () => {} }, + }, + } as unknown as CatalogDraft; + let seenCombos = ""; + let seenPricing = ""; + const res = await withIsolatedEnv("mgmt-env-token", undefined, async () => + publishCatalog( + draft, + { + providerId: "omniroute", + baseURL: "https://gw.example.com", + apiKey: "chat-key", + managementReadToken: process.env[MGMT_ENV_VAR], + timeoutMs: 1000, + modelCacheTtlMs: 300000, + usableOnly: false, + }, + { + fetcher: async () => [{ id: "m1" }], + combosFetcher: async (_base, token) => { + seenCombos = token; + return [{ id: "team-combo", models: [{ kind: "model", model: "m1" }] }]; + }, + enrichmentFetcher: async (_base, token) => { + seenPricing = token; + // The process env is the source under test: the resolver output + // flows in through the option above, so report success only when + // the flow under test actually carried it. + if (token !== "mgmt-env-token") return new Map(); + return new Map([["team-combo", { name: "Team Combo" }]]); + }, + } + ) + ); + assert.deepEqual(res, { models: 1, combos: 1, autoCombos: 0 }); + assert.equal(seenCombos, "mgmt-env-token"); + assert.equal(seenPricing, "mgmt-env-token"); + const entry = models.get("omniroute/team-combo"); + assert.ok(entry, "expected the combo entry in the published catalog"); + assert.equal(entry?.name, "Team Combo"); + }); +}); diff --git a/changelog.d/fixes/13613-management-token-env.md b/changelog.d/fixes/13613-management-token-env.md new file mode 100644 index 0000000000..10496cd792 --- /dev/null +++ b/changelog.d/fixes/13613-management-token-env.md @@ -0,0 +1 @@ +- **fix(opencode):** the v2 plugin reads the management token from OMNIROUTE_MANAGEMENT_API_KEY (plugin option wins) and warns once at startup when management calls fall back to the inference key ([#13613](https://github.com/diegosouzapw/OmniRoute/pull/13613)) — thanks @maxmad64bis