fix(opencode-plugin): support separate management read token (#7885)

* fix(opencode-plugin): separate management read token

* fix(opencode-plugin): scope inference auth and snapshots

* fix(opencode-plugin): scope auth to base path

* docs(changelog): format opencode management-read-token fragment as a bullet

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Ravi Tharuma
2026-07-20 23:48:35 +02:00
committed by GitHub
parent 00677044be
commit 161de7de4a
7 changed files with 486 additions and 74 deletions

View File

@@ -165,7 +165,7 @@ npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prepro
| Dynamic `/v1/models` | Pulls live catalog (455+ entries on prod) on each refresh, TTL-cached | `provider.models` |
| Variants pass-through | `-low`/`-medium`/`-high`/`-thinking` ship as first-class IDs from OmniRoute (no client synthesis) | `provider.models` |
| Combo LCD aggregation | Combos appear with intersected capabilities + min context/output across members | `provider.models` + `config` |
| `combo/<slug>` namespace + `Combo: ` prefix | Combos surface under `combo/claude-primary` (not the upstream UUID) and the picker shows `Combo: claude-primary` so they stand apart from raw provider/model pairs | both hooks |
| `combo/<slug>` namespace + `Combo:` prefix | Combos surface under `combo/claude-primary` (not the upstream UUID) and the picker shows `Combo: claude-primary` so they stand apart from raw provider/model pairs | both hooks |
| Nice names + cost | `/api/pricing/models` display names AND `/api/pricing` per-million-token cost overlaid onto the live catalog | both hooks |
| Canonical-twin dedup + alias-fallback | `/v1/models` exposes the same upstream model under both short alias (`cc/claude-opus-4-7`) and canonical name (`claude/claude-opus-4-7`); the plugin drops the canonical twin when an alias twin exists (no duplicate rows in the picker) and reverse-maps canonical → alias to pick up enrichment for short aliases (`dg/nova-3 → Deepgram - Nova 3`) that `/api/pricing/models` only indexes by canonical | both hooks |
| Compression pipeline tags | Combo names get tagged with their compression pipeline (e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`) when `features.compressionMetadata: true`. Intensity tokens render as a traffic-light emoji: 🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra | both hooks |
@@ -179,30 +179,33 @@ npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prepro
## Plugin options
| Option | Type | Default | Description |
| --------------- | -------- | ------------------------------------------ | ---------------------------------------------------------- |
| `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries |
| `displayName` | `string` | `"OmniRoute"` or `OmniRoute (<id>)` | Label in the OC UI |
| `modelCacheTtl` | `number` | `300000` (5 min) | `/v1/models` TTL in ms |
| `baseURL` | `string` | resolved from `auth.json` after `/connect` | Override OmniRoute base URL |
| `features` | `object` | see below | Feature toggles (all opt-in/out, defaults preserve v0.1.0) |
| Option | Type | Default | Description |
| --------------------- | -------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries |
| `displayName` | `string` | `"OmniRoute"` or `OmniRoute (<id>)` | Label in the OC UI |
| `modelCacheTtl` | `number` | `300000` (5 min) | `/v1/models` TTL in ms |
| `baseURL` | `string` | resolved from `auth.json` after `/connect` | Override OmniRoute base URL |
| `managementReadToken` | `string` | falls back to `apiKey` | Optional read-only token for management catalog GETs; `/v1` inference stays on the connected `apiKey` |
| `features` | `object` | see below | Feature toggles (all opt-in/out, defaults preserve v0.1.0) |
For least-privilege deployments, set top-level `managementReadToken` to a read-only management token. It is sent only to catalog reads (`/api/combos`, `/api/combos/auto`, `/api/pricing/models`, `/api/pricing`, `/api/context/combos`, and `/api/providers`). Inference requests under `/v1`, including chat, continue to use the `apiKey` stored by OpenCode. `features.mcpToken` remains independent. If `managementReadToken` is omitted, catalog reads retain the previous `apiKey` behavior.
### `features` block
Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.json` files do not need to change.
| Feature | Type | Default | What it does |
| --------------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `combos` | `boolean` | `true` | Discover `/api/combos` and surface them as pseudo-models with LCD capabilities. Combos are keyed under the `combo/<slug>` namespace and labelled `Combo: <name>` in the model picker so they're distinguishable from raw provider/model pairs. |
| `enrichment` | `boolean` | `true` | Pull display names from `/api/pricing/models` AND per-million-token pricing (`input`, `output`, `cached``cacheRead`, `cache_creation``cacheWrite`) from `/api/pricing`, then overlay both onto the live catalog (so the UI shows `Claude 4.7 Opus` with `cost.input: 5`, `cost.output: 25` instead of raw IDs and zeroed cost). |
| `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`. Intensity tokens render as traffic-light emoji (🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra) so the picker advertises "how compressed" each combo is at a glance. |
| `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 → Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 → Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[<alias>].name` verbatim when ≤8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models``GHM`, `Gemini``GEMINI`). Idempotent. Combos intentionally skipped (the `Combo: ` prefix already conveys multi-upstream). |
| `usableOnly` | `boolean` | `false` | Read `/api/providers` and filter the catalog to providers that have at least one connection with `isActive: true` AND `testStatus: 'active'`. Subtract-filter semantics: providers unknown to BOTH the pricing-models catalog AND the connection table pass through (so synthetic prefixes like `agentrouter/*` survive). On fetch failure the filter is disabled for the refresh — never hides the whole catalog. |
| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write — never blocks publishing. |
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.<providerId>` remote entry into the OC config pointing at `<baseURL>/api/mcp/stream` with the resolved Bearer token |
| `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset |
| `fetchInterceptor` | `boolean` | `true` | Inject `Authorization: Bearer` + default `Content-Type` on every outbound request targeting `baseURL` (suffix-spoof guarded) |
| Feature | Type | Default | What it does |
| --------------------- | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `combos` | `boolean` | `true` | Discover `/api/combos` and surface them as pseudo-models with LCD capabilities. Combos are keyed under the `combo/<slug>` namespace and labelled `Combo: <name>` in the model picker so they're distinguishable from raw provider/model pairs. |
| `enrichment` | `boolean` | `true` | Pull display names from `/api/pricing/models` AND per-million-token pricing (`input`, `output`, `cached``cacheRead`, `cache_creation``cacheWrite`) from `/api/pricing`, then overlay both onto the live catalog (so the UI shows `Claude 4.7 Opus` with `cost.input: 5`, `cost.output: 25` instead of raw IDs and zeroed cost). |
| `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`. Intensity tokens render as traffic-light emoji (🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra) so the picker advertises "how compressed" each combo is at a glance. |
| `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 → Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 → Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[<alias>].name` verbatim when ≤8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models``GHM`, `Gemini``GEMINI`). Idempotent. Combos intentionally skipped (the `Combo:` prefix already conveys multi-upstream). |
| `usableOnly` | `boolean` | `false` | Read `/api/providers` and filter the catalog to providers that have at least one connection with `isActive: true` AND `testStatus: 'active'`. Subtract-filter semantics: providers unknown to BOTH the pricing-models catalog AND the connection table pass through (so synthetic prefixes like `agentrouter/*` survive). On fetch failure the filter is disabled for the refresh — never hides the whole catalog. |
| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write — never blocks publishing. |
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.<providerId>` remote entry into the OC config pointing at `<baseURL>/api/mcp/stream` with the resolved Bearer token |
| `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset |
| `fetchInterceptor` | `boolean` | `true` | Inject `Authorization: Bearer` + default `Content-Type` on every outbound request targeting `baseURL` (suffix-spoof guarded) |
#### Example — enrichment + compression tags + MCP auto-emit
@@ -214,6 +217,7 @@ Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.
{
"providerId": "omniroute",
"baseURL": "https://or.example.com",
"managementReadToken": "<read-only-management-token>",
"features": {
"combos": true,
"enrichment": true,

View File

@@ -23,7 +23,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 tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.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 tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts",
"prepublishOnly": "npm run clean && npm run build && npm test"
},
"keywords": [

View File

@@ -91,6 +91,10 @@ import {
* - `baseURL` Override base URL for this OmniRoute instance. When
* absent, the loader falls back to a credential-attached
* baseURL set by `/connect`.
* - `managementReadToken` Optional read-only management-plane bearer token.
* Used only for catalog GETs under `/api/*`; `/v1/*`
* inference continues to use the connected `apiKey`.
* Falls back to `apiKey` when unset.
*/
/**
* Optional feature toggles. Every field is opt-in/out per call; defaults
@@ -122,8 +126,9 @@ import {
* provider's API key (from auth.json) when unset.
* Useful when a narrower-scoped MCP-only key is
* preferred over the chat/inference key.
* - `fetchInterceptor` Inject Authorization: Bearer + Content-Type on
* every outbound request to baseURL. Default true.
* - `fetchInterceptor` Inject Authorization: Bearer + Content-Type only
* on same-origin `/v1/chat/completions` and
* `/v1/models` requests. Default true.
* - `debugLog` Capture every outbound request + response to a
* JSONL file at
* `~/.local/share/opencode/plugins/omniroute-debug-{providerId}.jsonl`.
@@ -186,6 +191,7 @@ const optionsSchema = z
displayName: z.string().min(1).optional(),
modelCacheTtl: z.number().positive().optional(),
baseURL: z.string().url().optional(),
managementReadToken: z.string().min(1).optional(),
features: featuresSchema.optional(),
})
.strict();
@@ -253,7 +259,7 @@ export function resolveOmniRoutePluginOptions(opts?: OmniRoutePluginOptions): Re
* lookup fails with "No credentials for opencode-<x>".
*/
omnirouteProviderId: string;
} & Pick<OmniRoutePluginOptions, "baseURL" | "features"> {
} & Pick<OmniRoutePluginOptions, "baseURL" | "managementReadToken" | "features"> {
const rawProviderId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY;
const omnirouteProviderId = trimLeadingOpencodePrefix(rawProviderId);
// OC 1.17.8+ native-adapter gate rejects providerID not in
@@ -277,6 +283,7 @@ export function resolveOmniRoutePluginOptions(opts?: OmniRoutePluginOptions): Re
displayName,
modelCacheTtl,
baseURL: opts?.baseURL,
managementReadToken: opts?.managementReadToken,
features: opts?.features,
};
}
@@ -2348,12 +2355,14 @@ export function buildComboKey(
}
/**
* Internal cache key: `${baseURL}::sha256(apiKey)`. We hash the apiKey so
* the key is safe to log / inspect via debugger without leaking the secret.
* Different (baseURL, apiKey) tuples MUST keep independent cache entries:
* a single OC user may register prod + preprod OmniRoute side-by-side with
* distinct keys, and serving one's catalog from the other's cache would be
* a correctness bug, not just a privacy one.
* Internal cache key: `${baseURL}::sha256(credentialId)`. The credential id
* combines the inference key and effective management-read token so catalog
* results fetched under different permissions never share an entry. We hash
* it so the cache key is safe to inspect without leaking either secret.
* Different credential tuples MUST keep independent cache entries: a single
* OC user may register prod + preprod OmniRoute side-by-side with distinct
* keys, and serving one's catalog from the other's cache would be a
* correctness bug, not just a privacy one.
*/
// codeql[js/insufficient-password-hash]: the input here is an API-key
// identifier we use solely to derive an in-memory cache lookup key — it is
@@ -2504,6 +2513,9 @@ export function createOmniRouteProviderHook(
return {};
}
const apiKey = (auth as { key: string }).key;
// Management-plane catalog reads may use a narrower read-only token.
// Backward compatibility: when unset, retain the historical apiKey use.
const managementReadToken = resolved.managementReadToken ?? apiKey;
// baseURL resolution: plugin opts first, then credential-attached
// baseURL (auth backends sometimes stash it next to the key), then the
@@ -2533,7 +2545,7 @@ export function createOmniRouteProviderHook(
return {};
}
const cacheKey = modelsCacheKey(baseURL, apiKey);
const cacheKey = modelsCacheKey(baseURL, `${apiKey}\0${managementReadToken}`);
const t = now();
const cached = cache.get(cacheKey);
@@ -2565,7 +2577,7 @@ export function createOmniRouteProviderHook(
rawCombos = [];
if (wantCombos) {
try {
rawCombos = await combosFetcher(baseURL, apiKey, 10_000);
rawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
console.warn(
"[omniroute-plugin] combos fetch failed, falling back to models-only catalog",
@@ -2580,7 +2592,7 @@ export function createOmniRouteProviderHook(
rawAutoCombos = [];
if (wantAutoCombos) {
try {
rawAutoCombos = await autoCombosFetcher(baseURL, apiKey, 5_000);
rawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000);
} catch {
// Already handled inside the default fetcher — this catch
// is belt-and-suspenders for injected stubs.
@@ -2592,7 +2604,7 @@ export function createOmniRouteProviderHook(
rawEnrichment = new Map();
if (wantEnrichment) {
try {
rawEnrichment = await enrichmentFetcher(baseURL, apiKey, 10_000);
rawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
console.warn(
"[omniroute-plugin] enrichment fetch failed, falling back to raw ids",
@@ -2606,7 +2618,11 @@ export function createOmniRouteProviderHook(
rawCompressionCombos = [];
if (wantCompressionMeta) {
try {
rawCompressionCombos = await compressionMetaFetcher(baseURL, apiKey, 10_000);
rawCompressionCombos = await compressionMetaFetcher(
baseURL,
managementReadToken,
10_000
);
} catch (err) {
console.warn("[omniroute-plugin] compression-metadata fetch failed", err);
}
@@ -2620,7 +2636,7 @@ export function createOmniRouteProviderHook(
rawConnections = [];
if (wantUsableOnly) {
try {
rawConnections = await providersFetcher(baseURL, apiKey, 10_000);
rawConnections = await providersFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
console.warn(
"[omniroute-plugin] /api/providers fetch failed; usableOnly filter disabled for this refresh",
@@ -3008,17 +3024,18 @@ export function createOmniRouteProviderHook(
}
// ────────────────────────────────────────────────────────────────────────────
// Fetch interceptor (T-04) — Bearer + Content-Type injection on outbound
// provider requests targeting the configured OmniRoute baseURL
// Fetch interceptor (T-04) — Bearer + Content-Type injection on intended
// same-origin OmniRoute inference requests only
// ────────────────────────────────────────────────────────────────────────────
/**
* Build a `fetch`-compatible interceptor that injects `Authorization: Bearer`
* (and a default `Content-Type`) onto outbound requests targeting the given
* `baseURL`. Requests to any other host pass through untouched — the apiKey
* is treated as a secret bound to the configured OmniRoute instance and
* MUST NOT leak to third-party endpoints (a vector AI-SDKs occasionally
* exercise when a tool call rewrites the URL mid-flight).
* (and a default `Content-Type`) only onto same-origin requests targeting
* `<base>/chat/completions` or `<base>/models`, where `<base>` is the
* configured baseURL path normalized to end in `/v1`. Management, MCP, and
* unrelated inference paths pass through untouched. The apiKey is treated as
* a secret bound to both the configured OmniRoute origin and these intended
* inference endpoints, and MUST NOT leak elsewhere.
*
* Ported from Alph4d0g's `opencode-omniroute-auth@1.2.1` `createFetchInterceptor`
* (their `dist/src/plugin.js:477-516`) with these intentional deviations:
@@ -3045,17 +3062,35 @@ export function createOmniRouteFetchInterceptor(config: {
apiKey: string;
baseURL: string;
}): typeof fetch {
const trimmed = trimTrailingSlashes(config.baseURL);
// Use `<base>/` for prefix matching to prevent suffix-spoof attacks
// (e.g. baseURL `https://or.example.com/v1` should NOT match
// `https://or.example.com/v1-attacker.evil/...`).
const prefix = `${trimmed}/`;
let baseOrigin: string | undefined;
const inferencePaths = new Set<string>();
try {
const baseUrl = new URL(config.baseURL);
baseOrigin = baseUrl.origin;
const basePath = ensureV1Suffix(baseUrl.pathname);
inferencePaths.add(`${basePath}/chat/completions`);
inferencePaths.add(`${basePath}/models`);
} catch {
// Credential-attached base URLs are not schema-validated. A malformed
// value must disable injection rather than broaden the credential scope.
}
return async (input, init = {}) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const targetsOmniRoute = url === trimmed || url.startsWith(prefix);
if (!targetsOmniRoute) {
let requestUrl: URL | undefined;
try {
requestUrl = new URL(url);
} catch {
// Native fetch will report malformed/relative URLs as usual. We only
// decline to attach credentials before forwarding the original input.
}
const normalizedPath = requestUrl ? trimTrailingSlashes(requestUrl.pathname) || "/" : undefined;
const targetsInference =
requestUrl?.origin === baseOrigin &&
normalizedPath !== undefined &&
inferencePaths.has(normalizedPath);
if (!targetsInference) {
return fetch(input, init);
}
@@ -3997,7 +4032,9 @@ type AuthJsonShape = Record<string, AuthJsonApiEntry | { type?: string; [k: stri
/** Disk snapshot envelope. Versioned for forward-compat. */
interface OmniRouteDiskSnapshot {
v: 1;
v: 2;
/** Opaque identity for normalized baseURL + both effective credentials. */
identityFingerprint: string;
rawModels: OmniRouteRawModelEntry[];
rawCombos: OmniRouteRawCombo[];
rawAutoCombos?: OmniRouteRawAutoCombo[];
@@ -4017,22 +4054,53 @@ export function diskSnapshotPath(providerId: string): string {
export type OmniRouteDiskSnapshotWriter = (
providerId: string,
entry: Omit<OmniRouteFetchCacheEntry, "expiresAt">
entry: Omit<OmniRouteFetchCacheEntry, "expiresAt">,
identityFingerprint: string
) => Promise<void>;
export type OmniRouteDiskSnapshotReader = (
providerId: string
providerId: string,
identityFingerprint: string
) => Promise<Omit<OmniRouteFetchCacheEntry, "expiresAt"> | undefined>;
/**
* Bind a snapshot to the endpoint and effective credential tuple without
* persisting any raw token. This opaque value is only stored and compared;
* it is never logged or included in generated provider configuration.
*/
function diskSnapshotIdentityFingerprint(
baseURL: string,
apiKey: string,
managementReadToken: string
): string {
let normalizedBaseURL: string;
try {
const parsed = new URL(baseURL);
parsed.hash = "";
parsed.pathname = trimTrailingSlashes(parsed.pathname) || "/";
normalizedBaseURL = parsed.toString();
} catch {
normalizedBaseURL = trimTrailingSlashes(baseURL);
}
return createHash("sha256")
.update(JSON.stringify([normalizedBaseURL, apiKey, managementReadToken]))
.digest("hex");
}
/** Best-effort disk write. Soft-fails on any I/O error (no exception thrown). */
export const defaultDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async (providerId, entry) => {
export const defaultDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async (
providerId,
entry,
identityFingerprint
) => {
try {
const file = diskSnapshotPath(providerId);
// Restrict perms to the owner: the snapshot lives alongside auth.json
// (0o600) and embeds provider topology + masked connection records.
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
const snapshot: OmniRouteDiskSnapshot = {
v: 1,
v: 2,
identityFingerprint,
rawModels: entry.rawModels,
rawCombos: entry.rawCombos,
rawAutoCombos: entry.rawAutoCombos,
@@ -4051,12 +4119,22 @@ export const defaultDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async (pro
};
/** Best-effort disk read. Returns `undefined` when missing/corrupt/unreadable. */
export const defaultDiskSnapshotReader: OmniRouteDiskSnapshotReader = async (providerId) => {
export const defaultDiskSnapshotReader: OmniRouteDiskSnapshotReader = async (
providerId,
identityFingerprint
) => {
try {
const file = diskSnapshotPath(providerId);
const body = await readFile(file, "utf8");
const parsed = JSON.parse(body) as Partial<OmniRouteDiskSnapshot>;
if (!parsed || parsed.v !== 1) return undefined;
if (
!parsed ||
parsed.v !== 2 ||
typeof parsed.identityFingerprint !== "string" ||
parsed.identityFingerprint !== identityFingerprint
) {
return undefined;
}
return {
rawModels: Array.isArray(parsed.rawModels) ? parsed.rawModels : [],
rawCombos: Array.isArray(parsed.rawCombos) ? parsed.rawCombos : [],
@@ -4480,6 +4558,9 @@ export function createOmniRouteConfigHook(
);
return;
}
// Management-plane catalog reads may use a narrower read-only token.
// Backward compatibility: when unset, retain the historical apiKey use.
const managementReadToken = resolved.managementReadToken ?? apiKey;
// baseURL resolution: opts.baseURL wins, then auth.json's stored baseURL.
// No silent localhost default — a misconfigured plugin should surface a
@@ -4496,7 +4577,12 @@ export function createOmniRouteConfigHook(
// Try the shared cache first. On OC ≥1.14.49 the provider hook may have
// populated it moments earlier; on OC ≤1.14.48 only this hook runs but
// the cache still works (single producer + consumer through one Map).
const cacheKey = modelsCacheKey(baseURL, apiKey);
const cacheKey = modelsCacheKey(baseURL, `${apiKey}\0${managementReadToken}`);
const snapshotFingerprint = diskSnapshotIdentityFingerprint(
baseURL,
apiKey,
managementReadToken
);
const t = now();
const cached = cache.get(cacheKey);
@@ -4537,7 +4623,7 @@ export function createOmniRouteConfigHook(
rawCombos = [];
try {
rawCombos = await combosFetcher(baseURL, apiKey, 10_000);
rawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/combos fetch failed; publishing models-only static catalog",
@@ -4548,7 +4634,7 @@ export function createOmniRouteConfigHook(
rawAutoCombos = [];
if (wantAutoCombos) {
try {
rawAutoCombos = await autoCombosFetcher(baseURL, apiKey, 5_000);
rawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000);
} catch {
// Already handled inside the default fetcher
}
@@ -4564,7 +4650,7 @@ export function createOmniRouteConfigHook(
rawEnrichment = new Map();
if (wantEnrichment) {
try {
rawEnrichment = await enrichmentFetcher(baseURL, apiKey, 10_000);
rawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/pricing/models fetch failed; publishing raw-id static catalog",
@@ -4579,7 +4665,7 @@ export function createOmniRouteConfigHook(
rawCompressionCombos = [];
if (wantCompressionMeta) {
try {
rawCompressionCombos = await compressionMetaFetcher(baseURL, apiKey, 10_000);
rawCompressionCombos = await compressionMetaFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/context/combos fetch failed; publishing combos without compression suffix",
@@ -4595,7 +4681,7 @@ export function createOmniRouteConfigHook(
rawConnections = [];
if (wantUsableOnly) {
try {
rawConnections = await providersFetcher(baseURL, apiKey, 10_000);
rawConnections = await providersFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/providers fetch failed; usableOnly filter disabled for this refresh",
@@ -4611,7 +4697,7 @@ export function createOmniRouteConfigHook(
// a healthy refresh; staleness is bounded only by how recently the
// user was online.
if (modelsFetchThrew && wantDiskCache) {
const snapshot = await diskSnapshotReader(resolved.providerId);
const snapshot = await diskSnapshotReader(resolved.providerId, snapshotFingerprint);
if (snapshot && snapshot.rawModels.length > 0) {
logger.warn(
`[omniroute-plugin] config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)`
@@ -4656,14 +4742,18 @@ export function createOmniRouteConfigHook(
// Best-effort; soft-fail keeps us moving when the data dir isn't
// writable (e.g. read-only container).
if (modelsFetchOk && wantDiskCache) {
await diskSnapshotWriter(resolved.providerId, {
rawModels,
rawCombos,
rawAutoCombos,
rawEnrichment,
rawCompressionCombos,
rawConnections,
});
await diskSnapshotWriter(
resolved.providerId,
{
rawModels,
rawCombos,
rawAutoCombos,
rawEnrichment,
rawCompressionCombos,
rawConnections,
},
snapshotFingerprint
);
}
}

View File

@@ -40,7 +40,7 @@ test("defaultDiskSnapshotWriter writes an owner-only (no group/other) snapshot",
process.env.OPENCODE_DATA_DIR = tmp;
try {
await defaultDiskSnapshotWriter("perm-test", makeEntry());
await defaultDiskSnapshotWriter("perm-test", makeEntry(), "test-snapshot-identity");
const file = diskSnapshotPath("perm-test");
assert.ok(fs.existsSync(file), "snapshot file should be written");

View File

@@ -50,6 +50,52 @@ test("createOmniRouteFetchInterceptor: targets baseURL → Authorization header
}
});
test("createOmniRouteFetchInterceptor: path-prefixed baseURL scopes auth to its normalized inference paths", async () => {
const { calls, restore } = installFetchRecorder();
try {
const prefixedBase = "https://or.example.com/tenant-a/v1";
const f = createOmniRouteFetchInterceptor({
apiKey: KEY,
baseURL: `${prefixedBase}///`,
});
const streamingBody = '{"stream":true}';
await f(`${prefixedBase}/chat/completions?trace=1`, {
method: "POST",
body: streamingBody,
headers: { Accept: "text/event-stream" },
});
await f(`${prefixedBase}/models/?refresh=1`);
await f("https://or.example.com/v1/chat/completions", { method: "POST", body: "{}" });
await f("https://or.example.com/v1/models");
await f("https://or.example.com/tenant-b/v1/chat/completions", {
method: "POST",
body: "{}",
});
await f(`${prefixedBase}/chat/completions/batch`, { method: "POST", body: "{}" });
const headers = calls.map(({ init }) => new Headers(init?.headers));
assert.equal(headers[0]?.get("Authorization"), `Bearer ${KEY}`);
assert.equal(headers[1]?.get("Authorization"), `Bearer ${KEY}`);
for (const index of [2, 3, 4, 5]) {
assert.equal(headers[index]?.get("Authorization"), null);
}
assert.equal(calls[0]?.input, `${prefixedBase}/chat/completions?trace=1`);
assert.equal(calls[0]?.init?.body, streamingBody);
assert.equal(headers[0]?.get("Accept"), "text/event-stream");
const suffixingInterceptor = createOmniRouteFetchInterceptor({
apiKey: KEY,
baseURL: "https://or.example.com/tenant-a/",
});
await suffixingInterceptor(`${prefixedBase}/models`);
const suffixedHeaders = new Headers(calls[6]?.init?.headers);
assert.equal(suffixedHeaders.get("Authorization"), `Bearer ${KEY}`);
} finally {
restore();
}
});
test("createOmniRouteFetchInterceptor: targets baseURL → Authorization OVERRIDES caller-supplied Bearer", async () => {
const { calls, restore } = installFetchRecorder();
try {
@@ -259,7 +305,7 @@ test("loader integration: wired interceptor actually injects Bearer when invoked
{} as never
);
const wiredFetch = (result as { fetch: typeof fetch }).fetch;
await wiredFetch(`${BASE}/v1/models`, {});
await wiredFetch(`${BASE}/models`, {});
assert.equal(calls.length, 1);
const sentHeaders = new Headers((calls[0]!.init as RequestInit).headers);
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);

View File

@@ -0,0 +1,271 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
createOmniRouteAuthHook,
createOmniRouteConfigHook,
createOmniRouteProviderHook,
parseOmniRoutePluginOptions,
type OmniRouteCompressionMetaFetcher,
type OmniRouteEnrichmentFetcher,
type OmniRouteProvidersFetcher,
type OmniRouteRawModelEntry,
} from "../src/index.js";
const BASE_URL = "https://or.example.com/v1";
const API_KEY = "sk-inference-only";
const MANAGEMENT_READ_TOKEN = "sk-management-read-only";
const RAW_MODELS: OmniRouteRawModelEntry[] = [
{
id: "openai/gpt-test",
context_length: 16_000,
max_output_tokens: 4_000,
capabilities: {
tool_calling: true,
reasoning: false,
vision: false,
thinking: false,
temperature: true,
},
input_modalities: ["text"],
output_modalities: ["text"],
},
];
function apiAuth(key: string) {
return { type: "api" as const, key };
}
test("options: managementReadToken is accepted and preserved", () => {
const parsed = parseOmniRoutePluginOptions({ managementReadToken: MANAGEMENT_READ_TOKEN });
assert.equal(parsed.managementReadToken, MANAGEMENT_READ_TOKEN);
});
test("provider hook: management GET fetchers use managementReadToken while /v1 uses apiKey", async () => {
const calls: Array<[string, string]> = [];
const enrichmentFetcher: OmniRouteEnrichmentFetcher = async (_baseURL, token) => {
calls.push(["pricing", token]);
return new Map();
};
const compressionMetaFetcher: OmniRouteCompressionMetaFetcher = async (_baseURL, token) => {
calls.push(["context", token]);
return [];
};
const providersFetcher: OmniRouteProvidersFetcher = async (_baseURL, token) => {
calls.push(["providers", token]);
return [];
};
const hook = createOmniRouteProviderHook(
{
baseURL: BASE_URL,
managementReadToken: MANAGEMENT_READ_TOKEN,
features: { compressionMetadata: true, usableOnly: true },
},
{
fetcher: async (_baseURL, token) => {
calls.push(["models", token]);
return RAW_MODELS;
},
combosFetcher: async (_baseURL, token) => {
calls.push(["combos", token]);
return [];
},
autoCombosFetcher: async (_baseURL, token) => {
calls.push(["auto-combos", token]);
return [];
},
enrichmentFetcher,
compressionMetaFetcher,
providersFetcher,
}
);
await hook.models!({} as never, { auth: apiAuth(API_KEY) as never });
assert.deepEqual(calls, [
["models", API_KEY],
["combos", MANAGEMENT_READ_TOKEN],
["auto-combos", MANAGEMENT_READ_TOKEN],
["pricing", MANAGEMENT_READ_TOKEN],
["context", MANAGEMENT_READ_TOKEN],
["providers", MANAGEMENT_READ_TOKEN],
]);
});
test("provider hook: absent managementReadToken preserves apiKey fallback", async () => {
const calls: Array<[string, string]> = [];
const hook = createOmniRouteProviderHook(
{ baseURL: BASE_URL, features: { enrichment: false, autoCombos: false } },
{
fetcher: async (_baseURL, token) => {
calls.push(["models", token]);
return RAW_MODELS;
},
combosFetcher: async (_baseURL, token) => {
calls.push(["combos", token]);
return [];
},
}
);
await hook.models!({} as never, { auth: apiAuth(API_KEY) as never });
assert.deepEqual(calls, [
["models", API_KEY],
["combos", API_KEY],
]);
});
test("config hook: managementReadToken stays out of provider inference and MCP config", async () => {
const calls: Array<[string, string]> = [];
const hook = createOmniRouteConfigHook(
{
baseURL: BASE_URL,
managementReadToken: MANAGEMENT_READ_TOKEN,
features: { enrichment: false, autoCombos: false, diskCache: false, mcpAutoEmit: true },
},
{
readAuthJson: async () => ({
"opencode-omniroute": { type: "api" as const, key: API_KEY },
}),
fetcher: async (_baseURL, token) => {
calls.push(["models", token]);
return RAW_MODELS;
},
combosFetcher: async (_baseURL, token) => {
calls.push(["combos", token]);
return [];
},
logger: { warn: () => {} },
}
);
const input: { provider?: Record<string, any>; mcp?: Record<string, any> } = {};
await hook(input as never);
assert.deepEqual(calls, [
["models", API_KEY],
["combos", MANAGEMENT_READ_TOKEN],
]);
assert.equal(input.provider?.["opencode-omniroute"]?.options?.apiKey, API_KEY);
assert.equal(
input.mcp?.["opencode-omniroute"]?.headers?.Authorization,
`Bearer ${API_KEY}`,
"mcpAutoEmit remains independent of managementReadToken"
);
});
test("auth fetch: only intended same-origin inference paths receive apiKey", async () => {
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ input, init });
return new Response("ok");
}) as typeof fetch;
try {
const hook = createOmniRouteAuthHook({
baseURL: `${BASE_URL}/`,
managementReadToken: MANAGEMENT_READ_TOKEN,
});
const loaded = await hook.loader!(async () => apiAuth(API_KEY) as never, {} as never);
const interceptedFetch = (loaded as { fetch: typeof fetch }).fetch;
const streamingBody = '{"stream":true}';
await interceptedFetch(`${BASE_URL}/chat/completions?trace=1`, {
method: "POST",
body: streamingBody,
headers: { Accept: "text/event-stream" },
});
await interceptedFetch(`${BASE_URL}/models/?refresh=1`);
await interceptedFetch("https://or.example.com/api/combos");
await interceptedFetch("https://or.example.com/api/mcp/stream");
await interceptedFetch("https://or.example.com/v1/embeddings");
await interceptedFetch("https://third-party.example/v1/chat/completions", {
method: "POST",
body: "{}",
});
const headers = calls.map(({ init }) => new Headers(init?.headers));
assert.equal(headers[0]?.get("Authorization"), `Bearer ${API_KEY}`);
assert.equal(headers[1]?.get("Authorization"), `Bearer ${API_KEY}`);
for (const index of [2, 3, 4, 5]) {
assert.equal(headers[index]?.get("Authorization"), null);
}
assert.equal(calls[0]?.input, `${BASE_URL}/chat/completions?trace=1`);
assert.equal(calls[0]?.init?.body, streamingBody);
assert.equal(headers[0]?.get("Accept"), "text/event-stream");
assert.equal(
calls.some(({ init }) =>
[...new Headers(init?.headers).values()].some((value) =>
value.includes(MANAGEMENT_READ_TOKEN)
)
),
false,
"managementReadToken must never enter inference fetch headers"
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("disk cache: snapshot written under management token A is rejected under token B", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-token-snapshot-"));
const previousDataDir = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = tmp;
try {
const commonDeps = {
readAuthJson: async () => ({
"opencode-omniroute": {
type: "api" as const,
key: API_KEY,
baseURL: BASE_URL,
},
}),
combosFetcher: async () => [],
logger: { warn: () => {} },
};
const features = {
enrichment: false,
autoCombos: false,
diskCache: true,
} as const;
const tokenAHook = createOmniRouteConfigHook(
{ managementReadToken: "token-A", features },
{
...commonDeps,
fetcher: async () => RAW_MODELS,
}
);
await tokenAHook({} as never);
const tokenBHook = createOmniRouteConfigHook(
{ managementReadToken: "token-B", features },
{
...commonDeps,
fetcher: async () => {
throw new Error("offline");
},
}
);
const input: { provider?: Record<string, { models: Record<string, unknown> }> } = {};
await tokenBHook(input as never);
assert.deepEqual(
input.provider?.["opencode-omniroute"]?.models,
{},
"catalog from token A must not hydrate after switching to token B"
);
} finally {
if (previousDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = previousDataDir;
fs.rmSync(tmp, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1 @@
- **fix(opencode-plugin):** the official `@omniroute/opencode-plugin` now accepts an optional `managementReadToken` for management-plane catalog GETs while keeping inference requests on the connected `apiKey` — existing configurations continue to fall back to `apiKey`, and MCP token selection remains independent. (thanks @RaviTharuma)