mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
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:
@@ -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,
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
271
@omniroute/opencode-plugin/tests/management-read-token.test.ts
Normal file
271
@omniroute/opencode-plugin/tests/management-read-token.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
1
changelog.d/fixes/7884-opencode-management-read-token.md
Normal file
1
changelog.d/fixes/7884-opencode-management-read-token.md
Normal 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)
|
||||
Reference in New Issue
Block a user