mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-24 08:32:13 +03:00
Compare commits
44 Commits
fix/cli-co
...
fix/14060-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1486e6116d | ||
|
|
adefe4f583 | ||
|
|
9f82134355 | ||
|
|
18bbb10198 | ||
|
|
99ba14b776 | ||
|
|
5483abb4b3 | ||
|
|
d7be9fd528 | ||
|
|
819adea9c0 | ||
|
|
d54d31365e | ||
|
|
afe1a0f9c5 | ||
|
|
2e60034d58 | ||
|
|
2def447b15 | ||
|
|
1c957d9508 | ||
|
|
373d784e09 | ||
|
|
ea3c12264b | ||
|
|
9f4d3b0ab8 | ||
|
|
66688599b2 | ||
|
|
a61b34d7f2 | ||
|
|
8bf6b60a4b | ||
|
|
59de50e42a | ||
|
|
b5d6404a90 | ||
|
|
30a83ee8d0 | ||
|
|
9e44a5d1cc | ||
|
|
652c4f3636 | ||
|
|
007ea22f5f | ||
|
|
30f7088c74 | ||
|
|
7d5292fcac | ||
|
|
f577d2124e | ||
|
|
30d0158272 | ||
|
|
84988bd355 | ||
|
|
373c31f3dc | ||
|
|
1c213eb156 | ||
|
|
2c9938d14e | ||
|
|
07103e2f12 | ||
|
|
38cbb7ef80 | ||
|
|
582a2027e8 | ||
|
|
61b3546b17 | ||
|
|
6c8a3958fd | ||
|
|
e21f17ed89 | ||
|
|
7a179de486 | ||
|
|
49de88f621 | ||
|
|
e2dc851a7f | ||
|
|
c68307a418 | ||
|
|
7064cca334 |
@@ -1326,6 +1326,11 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
|
||||
# ── Kimi Coding (Moonshot) ──
|
||||
KIMI_CODING_OAUTH_CLIENT_ID=17e5f671-d194-4dfb-9706-5516cb48c098
|
||||
|
||||
# ── Muse Code (Meta) ──
|
||||
# Public device-flow client id is baked into open-sse/utils/publicCreds.ts (muse_id).
|
||||
# Set this only to override the Muse CLI client. Do not put the public id here.
|
||||
# MUSE_CODE_OAUTH_CLIENT_ID=
|
||||
|
||||
# ── GitHub Copilot ──
|
||||
GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
|
||||
|
||||
@@ -1813,6 +1818,7 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# OPENCODE_PARK_AND_RESUME=false # #13924 feature flag (Settings → Feature Flags wins): park the request with a heartbeat after repeated transient 429s, then replay one capped leg of up to 3 accounts
|
||||
#OPENCODE_POOL_STRAIN_MARKER_PATH=/tmp/opencode-pool-strain.json # #13924: pool-strain marker path (JSON {since, reason, ttl_s}); fresh marker parks without recounting
|
||||
# RESPONSES_FIRST_BYTE_TIMEOUT_MS=15000 # #13484: OpenCode Responses first-byte window, only used when the OPENCODE_RESPONSES_STALL_ROTATION flag is on (0 disables)
|
||||
# FLUSH_EMPTY_RETRY_ENABLED=false # #14213 feature flag (Settings → Feature Flags wins): retry empty translated streaming turns through the normal credential path (up to STREAM_RECOVERY.EMPTY_TURN_RETRY_MAX retries)
|
||||
|
||||
# ── API Bridge (/v1 proxy server) ──
|
||||
# API_BRIDGE_PROXY_TIMEOUT_MS=600000 # Proxy hop timeout (default: 10min)
|
||||
|
||||
4
.github/workflows/docker-publish.yml
vendored
4
.github/workflows/docker-publish.yml
vendored
@@ -193,6 +193,7 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
OMNIROUTE_USE_TURBOPACK=0
|
||||
OMNIROUTE_BUILD_MEMORY_MB=12288
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
@@ -212,6 +213,7 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
OMNIROUTE_USE_TURBOPACK=0
|
||||
OMNIROUTE_BUILD_MEMORY_MB=12288
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
@@ -239,6 +241,7 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
OMNIROUTE_USE_TURBOPACK=0
|
||||
OMNIROUTE_BUILD_MEMORY_MB=12288
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
@@ -266,6 +269,7 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
OMNIROUTE_USE_TURBOPACK=0
|
||||
OMNIROUTE_BUILD_MEMORY_MB=12288
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
|
||||
58078
.i18n-state.json
58078
.i18n-state.json
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,34 @@ npm install @omniroute/opencode-plugin-v2
|
||||
}
|
||||
```
|
||||
|
||||
### Local `file://` install
|
||||
|
||||
OpenCode resolves a local plugin **directory** by probing the subpaths
|
||||
`server.*` / `index.*` (then `tui`, `rpc`) at the package root — it never
|
||||
reads `package.json` `main`/`exports`. A folder exposing only `dist/` is
|
||||
therefore silently skipped (no `loading plugin`, no error).
|
||||
|
||||
This package ships a root `server.js` re-exporting `./dist/index.js` for
|
||||
exactly that probe, so pointing OpenCode at a local checkout works:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"package": "file:///path/to/OmniRoute/@omniroute/opencode-plugin-v2",
|
||||
"options": {
|
||||
"providerId": "omniroute",
|
||||
"baseURL": "http://localhost:20128"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Prerequisites when targeting a folder: run `npm run build` first (the root
|
||||
`server.js` re-exports `./dist/index.js`), and keep the folder's root
|
||||
`server.js` — `dist/` alone is not resolvable by the host.
|
||||
|
||||
## Credentials
|
||||
|
||||
The plugin needs a gateway key to read the catalog, and looks for one in this
|
||||
|
||||
3753
@omniroute/opencode-plugin-v2/package-lock.json
generated
3753
@omniroute/opencode-plugin-v2/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"server.js",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
@@ -26,7 +27,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/plugin": "1.18.29",
|
||||
"@opencode/plugin": "2.0.12",
|
||||
"@types/node": "^22.19.19",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.22.3",
|
||||
@@ -62,7 +63,7 @@
|
||||
"access": "public"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/plugin": ">=1.18.29 <2"
|
||||
"@opencode/plugin": ">=2.0.12 <3"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "^0.28.1"
|
||||
|
||||
10
@omniroute/opencode-plugin-v2/server.js
Normal file
10
@omniroute/opencode-plugin-v2/server.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// Root entrypoint for OpenCode host plugin loading.
|
||||
//
|
||||
// OpenCode 2.x local installs (`file://` directory in `opencode.json`) never
|
||||
// read package.json `main`/`exports`: the config scan resolves only the
|
||||
// subpaths ["server", ""] then ["tui"], ["rpc"] from the package directory.
|
||||
// With only `dist/index.js` present the scan yields `{}` and the plugin is
|
||||
// silently dropped (no `loading plugin`, no error). This stable re-export
|
||||
// keeps `dist/` as the only build output while exposing the module the host
|
||||
// actually probes for.
|
||||
export { default } from "./dist/index.js";
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import { type HostContract, detectHostContract, emitsLegacyFields } from "./compat.js";
|
||||
import type { Model as LegacyModelV2 } from "@opencode-ai/sdk/v2";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import type { Model, Provider } from "@opencode/plugin";
|
||||
import type { LegacyModel } from "./legacy-model.js";
|
||||
import {
|
||||
isHttpUrl,
|
||||
type ApiFormatV2,
|
||||
@@ -92,52 +90,110 @@ export interface CatalogFetchers {
|
||||
onSourceError?: (endpoint: string, reason: string) => void;
|
||||
}
|
||||
|
||||
// The shared mappers speak the legacy (`Provider.models[id]`) `Model` shape
|
||||
// (imported from `@opencode-ai/sdk/v2`, also re-exported by the plugin root
|
||||
// as `ModelV2`); the real v2 `CatalogDraft` carries `ModelV2Info` instead.
|
||||
// Convert the fields 1:1 at the draft boundary -- NEVER `as unknown as` the
|
||||
// whole model.
|
||||
//
|
||||
// Binary-compat note: the prod binary (beta-17823) reads a top-level
|
||||
// `package` field on both Model and Provider structs (`package:a.Package`,
|
||||
// gated by `isAISDK = startsWith("aisdk:")`), with a model-to-provider
|
||||
// fallback (`package: u.package ?? s.package`). The pinned SDK types
|
||||
// (1.18.29) only know the `api` block, so the binary field is published via
|
||||
// the typed extensions below (spread/Object.assign, never `any`).
|
||||
export const BINARY_AISDK_PREFIX = "aisdk:";
|
||||
export type StableModelInfo = Model.Info;
|
||||
export type StableProviderInfo = Provider.Info;
|
||||
|
||||
/** Top-level `package` as the legacy contract expects it (`aisdk:<npm>`). */
|
||||
export interface BinaryCompatPackage {
|
||||
package: string;
|
||||
/**
|
||||
* Structural mirror of the stable `ctx.provider.transform` editor, used as
|
||||
* the parameter type where the payload is handed to the host (and in tests
|
||||
* that fake the editor). Kept as documentation of the contract surface even
|
||||
* where only `add` is exercised.
|
||||
*/
|
||||
export interface StableProviderEditor {
|
||||
add(input: { info: StableProviderInfo; models: readonly StableModelInfo[] }): void;
|
||||
get(providerID: string): { provider: StableProviderInfo } | undefined;
|
||||
list(): readonly { provider: StableProviderInfo }[];
|
||||
update(providerID: string, update: (provider: StableProviderInfo) => void): void;
|
||||
remove(providerID: string): void;
|
||||
readonly models: {
|
||||
set(providerID: string, models: readonly StableModelInfo[]): void;
|
||||
update(providerID: string, modelID: string, update: (model: StableModelInfo) => void): void;
|
||||
remove(providerID: string, modelID: string): void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The legacy contract keeps on the model/provider itself what the `api` block
|
||||
* carries in the pinned types: the aisdk package, the endpoint (as
|
||||
* `settings.baseURL`) and the per-request headers. None of these keys collide
|
||||
* with a key of `ModelV2Info`/`ProviderV2Info`, so both field sets can be
|
||||
* published on the same object.
|
||||
* Project the legacy catalog entry the shared mappers produce onto the
|
||||
* stable `Model.Info` shape. The mapper layer stays untouched; only this
|
||||
* boundary knows both shapes. Extra legacy-only keys (`api`, `options`,
|
||||
* string `release_date`) are dropped, never cast across.
|
||||
*/
|
||||
export interface BinaryCompatFields extends BinaryCompatPackage {
|
||||
settings: Record<string, unknown>;
|
||||
headers: Record<string, string>;
|
||||
export function legacyToStable(
|
||||
providerID: string,
|
||||
modelID: string,
|
||||
m: LegacyModel,
|
||||
apiKey: string,
|
||||
baseURL: string
|
||||
): StableModelInfo {
|
||||
if (!m.api || typeof m.api.npm !== "string" || m.api.npm.length === 0) {
|
||||
throw new Error(
|
||||
"[omniroute-v2] refusing to publish a model without an api block (missing api.npm)"
|
||||
);
|
||||
}
|
||||
if (!isHttpUrl(m.api.url)) {
|
||||
throw new Error(
|
||||
"[omniroute-v2] refusing to publish a model whose api block carries no http(s) url"
|
||||
);
|
||||
}
|
||||
const stablePackage =
|
||||
m.api.npm === "@ai-sdk/anthropic" ? "@opencode/ai/providers/anthropic" : NPM_OPENAI_COMPAT;
|
||||
const input: string[] = [];
|
||||
if (m.capabilities.input.text) input.push("text");
|
||||
if (m.capabilities.input.audio) input.push("audio");
|
||||
if (m.capabilities.input.image) input.push("image");
|
||||
if (m.capabilities.input.video) input.push("video");
|
||||
if (m.capabilities.input.pdf) input.push("pdf");
|
||||
const output: string[] = [];
|
||||
if (m.capabilities.output.text) output.push("text");
|
||||
if (m.capabilities.output.audio) output.push("audio");
|
||||
if (m.capabilities.output.image) output.push("image");
|
||||
if (m.capabilities.output.video) output.push("video");
|
||||
if (m.capabilities.output.pdf) output.push("pdf");
|
||||
const variants = Object.entries(m.variants ?? {}).map(([id, body]) => ({
|
||||
id,
|
||||
settings: { ...(body as Record<string, unknown>) },
|
||||
headers: {},
|
||||
body: { ...(body as Record<string, unknown>) },
|
||||
}));
|
||||
const parsed = Date.parse(m.release_date);
|
||||
const info = {
|
||||
id: modelID,
|
||||
modelID,
|
||||
providerID,
|
||||
...(m.family !== undefined ? { family: m.family } : {}),
|
||||
name: m.name,
|
||||
package: stablePackage,
|
||||
settings: { baseURL: ensureV1Suffix(baseURL), apiKey },
|
||||
headers: { ...m.headers },
|
||||
...(Object.keys(m.options).length > 0 ? { body: { ...m.options } } : {}),
|
||||
capabilities: { tools: m.capabilities.toolcall, input, output },
|
||||
variants,
|
||||
time: { released: Number.isNaN(parsed) ? 0 : parsed },
|
||||
cost: [
|
||||
{ input: m.cost.input, output: m.cost.output, cache: { ...m.cost.cache } },
|
||||
],
|
||||
status: m.status,
|
||||
enabled: true,
|
||||
limit: { ...m.limit },
|
||||
} as unknown;
|
||||
return info as StableModelInfo;
|
||||
}
|
||||
|
||||
/** Legacy variants read their options from `settings`, not `headers`/`body`. */
|
||||
export type BinaryCompatVariant = ModelV2Info["variants"][number] & {
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
const NPM_OPENAI_COMPAT = "@opencode/ai/providers/openai-compatible";
|
||||
|
||||
export type BinaryCompatModel = ModelV2Info & BinaryCompatFields;
|
||||
export type BinaryCompatProvider = ProviderV2Info &
|
||||
BinaryCompatPackage & {
|
||||
settings: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function toBinaryPackage(npm: string): string {
|
||||
return npm.startsWith(BINARY_AISDK_PREFIX) ? npm : `${BINARY_AISDK_PREFIX}${npm}`;
|
||||
}
|
||||
export function legacyApiToInfoApi(api: LegacyModelV2["api"]): ModelV2Info["api"] {
|
||||
/**
|
||||
* Fail-fast guard for a pre-mapped `api` block: the snapshot filter and the
|
||||
* stale-entry suite assert on it, and the beta-replay adapter relies on the
|
||||
* same refusal for entries that bypass the mapper. New mapper output always
|
||||
* carries a valid block via `resolveApiBlockV2`, so this fires only on stale
|
||||
* snapshots or hand-built entries.
|
||||
*/
|
||||
export function legacyApiToInfoApi(api: LegacyModel["api"]): {
|
||||
id: string;
|
||||
type: "aisdk";
|
||||
package: string;
|
||||
url: string;
|
||||
} {
|
||||
if (!api || typeof api.npm !== "string" || api.npm.length === 0) {
|
||||
throw new Error(
|
||||
"[omniroute-v2] refusing to publish a model without an api block (missing api.npm)"
|
||||
@@ -155,13 +211,14 @@ export function legacyApiToInfoApi(api: LegacyModelV2["api"]): ModelV2Info["api"
|
||||
return { id: api.id, type: "aisdk", package: api.npm, url: api.url };
|
||||
}
|
||||
|
||||
function legacyCostToInfoCost(cost: LegacyModelV2["cost"]): ModelV2Info["cost"] {
|
||||
return [{ input: cost.input, output: cost.output, cache: cost.cache }];
|
||||
function legacyCostToInfoCost(cost: LegacyModel["cost"]): StableModelInfo["cost"] {
|
||||
const c = [{ input: cost.input, output: cost.output, cache: cost.cache }];
|
||||
return c as unknown as StableModelInfo["cost"];
|
||||
}
|
||||
|
||||
function legacyCapabilitiesToInfoCapabilities(
|
||||
caps: LegacyModelV2["capabilities"]
|
||||
): ModelV2Info["capabilities"] {
|
||||
caps: LegacyModel["capabilities"]
|
||||
): StableModelInfo["capabilities"] {
|
||||
const input: string[] = [];
|
||||
if (caps.input.text) input.push("text");
|
||||
if (caps.input.audio) input.push("audio");
|
||||
@@ -177,21 +234,21 @@ function legacyCapabilitiesToInfoCapabilities(
|
||||
return { tools: caps.toolcall, input, output };
|
||||
}
|
||||
|
||||
function legacyToInfo(providerID: string, modelID: string, m: LegacyModelV2): ModelV2Info {
|
||||
function legacyToInfo(providerID: string, modelID: string, m: LegacyModel): StableModelInfo {
|
||||
const variants = Object.entries(m.variants ?? {}).map(([id, body]) => ({
|
||||
id,
|
||||
headers: {},
|
||||
body: body as Record<string, unknown>,
|
||||
}));
|
||||
const parsed = Date.parse(m.release_date);
|
||||
return {
|
||||
const out = {
|
||||
id: modelID,
|
||||
modelID,
|
||||
providerID,
|
||||
...(m.family !== undefined ? { family: m.family } : {}),
|
||||
name: m.name,
|
||||
api: legacyApiToInfoApi(m.api),
|
||||
capabilities: legacyCapabilitiesToInfoCapabilities(m.capabilities),
|
||||
request: { headers: { ...m.headers }, body: { ...m.options } },
|
||||
headers: { ...m.headers },
|
||||
variants,
|
||||
time: { released: Number.isNaN(parsed) ? 0 : parsed },
|
||||
cost: legacyCostToInfoCost(m.cost),
|
||||
@@ -199,6 +256,7 @@ function legacyToInfo(providerID: string, modelID: string, m: LegacyModelV2): Mo
|
||||
enabled: true,
|
||||
limit: { ...m.limit },
|
||||
};
|
||||
return out as unknown as StableModelInfo;
|
||||
}
|
||||
|
||||
export interface PublishCounts {
|
||||
@@ -265,74 +323,39 @@ export function passesComboAllowlist(combo: OmniRouteRawCombo, visible?: ModelLi
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the `api` block onto the legacy top-level fields. Only the `aisdk`
|
||||
* variant of `ModelApi`/`ProviderApi` carries a package, so the caller narrows
|
||||
* before calling; a `native` api has no legacy equivalent and publishes
|
||||
* nothing (the legacy contract has no native models).
|
||||
* Copy the converted legacy fields onto a stable `Model.Info` target.
|
||||
* Kept for the beta-replay adapter below (`publishCatalog`), which reuses it
|
||||
* per entry; new code calls `legacyToStable` via `buildProviderPayload`.
|
||||
*/
|
||||
function legacyModelFields(info: ModelV2Info): BinaryCompatFields | undefined {
|
||||
if (info.api.type !== "aisdk") return undefined;
|
||||
const settings: Record<string, unknown> = {
|
||||
...(info.api.settings ?? {}),
|
||||
...info.request.body,
|
||||
};
|
||||
if (info.api.url !== undefined) settings.baseURL = info.api.url;
|
||||
return {
|
||||
package: toBinaryPackage(info.api.package),
|
||||
settings,
|
||||
headers: { ...info.request.headers },
|
||||
};
|
||||
}
|
||||
|
||||
/** `{id, headers, body}` (pinned types) plus `{settings}` (legacy contract). */
|
||||
function legacyVariants(variants: ModelV2Info["variants"]): BinaryCompatVariant[] {
|
||||
return variants.map((variant) => ({ ...variant, settings: { ...variant.body } }));
|
||||
}
|
||||
|
||||
function assignModelFields(
|
||||
target: ModelV2Info,
|
||||
source: LegacyModelV2,
|
||||
contract: HostContract
|
||||
export function assignModelFields(
|
||||
target: StableModelInfo,
|
||||
source: LegacyModel,
|
||||
apiKey: string,
|
||||
baseURL: string
|
||||
): void {
|
||||
const info = legacyToInfo(target.providerID || source.providerID, target.id || source.id, source);
|
||||
target.name = info.name;
|
||||
target.api = info.api;
|
||||
target.capabilities = info.capabilities;
|
||||
target.request = info.request;
|
||||
target.variants = info.variants;
|
||||
target.time = info.time;
|
||||
target.cost = info.cost;
|
||||
target.status = info.status;
|
||||
target.enabled = info.enabled;
|
||||
target.limit = info.limit;
|
||||
if (info.family !== undefined) {
|
||||
target.family = info.family;
|
||||
}
|
||||
if (!emitsLegacyFields(contract)) return;
|
||||
const legacy = legacyModelFields(info);
|
||||
if (legacy !== undefined) {
|
||||
Object.assign(target, legacy);
|
||||
target.variants = legacyVariants(info.variants);
|
||||
}
|
||||
const info = legacyToStable(
|
||||
(target.providerID as string) || source.providerID,
|
||||
(target.id as string) || source.id,
|
||||
source,
|
||||
apiKey,
|
||||
baseURL
|
||||
);
|
||||
Object.assign(target, info);
|
||||
}
|
||||
|
||||
function assignProviderFields(
|
||||
target: ProviderV2Info,
|
||||
source: { name: string; api: ProviderV2Info["api"]; integrationID: string },
|
||||
contract: HostContract
|
||||
/**
|
||||
* Copy the provider identity fields onto a stable `Provider.Info` target.
|
||||
* Kept for the beta-replay adapter below (`publishCatalog` writes `name` /
|
||||
* `integrationID` through it before adding stable fields); new code builds
|
||||
* the provider object inline in `buildProviderPayload`.
|
||||
*/
|
||||
export function assignProviderFields(
|
||||
target: StableProviderInfo,
|
||||
source: { name: string; integrationID: string },
|
||||
_contract?: unknown
|
||||
): void {
|
||||
target.name = source.name;
|
||||
target.api = source.api;
|
||||
target.integrationID = source.integrationID;
|
||||
if (!emitsLegacyFields(contract)) return;
|
||||
// The legacy contract defaults `Provider.Info.package` to `""` and model
|
||||
// resolution falls back to it (`package: model.package ?? provider.package`),
|
||||
// so the provider carries the same `aisdk:<npm>` value as its models, and
|
||||
// the endpoint as `settings.baseURL`.
|
||||
if (source.api.type !== "aisdk") return;
|
||||
const settings: Record<string, unknown> = { ...(source.api.settings ?? {}) };
|
||||
if (source.api.url !== undefined) settings.baseURL = source.api.url;
|
||||
Object.assign(target, { package: toBinaryPackage(source.api.package), settings });
|
||||
(target as { name: string }).name = source.name;
|
||||
(target as { integrationID: string }).integrationID = source.integrationID;
|
||||
}
|
||||
|
||||
/** A widened capability flag (`boolean | { field }`) read back as a plain flag. */
|
||||
@@ -413,15 +436,14 @@ async function resolveUsableAliases(
|
||||
return rawConnections.length > 0 ? usableProviderAliasSet(rawConnections, enrichment) : undefined;
|
||||
}
|
||||
|
||||
/** Everything the combo publishing pass reads, passed as one value. */
|
||||
/** Everything the combo collection pass reads, passed as one value. */
|
||||
interface PublishContext {
|
||||
draft: CatalogDraft;
|
||||
opts: ResolvedOptions;
|
||||
log: Logger;
|
||||
providerId: string;
|
||||
hostContract: HostContract;
|
||||
enrichment: OmniRouteEnrichmentMap;
|
||||
rawModelById: Map<string, OmniRouteRawModelEntry>;
|
||||
collected: Map<string, LegacyModel>;
|
||||
publishedKeys: Set<string>;
|
||||
publishedModelIds: Map<string, string>;
|
||||
visibleFilter: ReturnType<typeof compileModelListFilter>;
|
||||
@@ -447,13 +469,12 @@ interface PublishContext {
|
||||
*/
|
||||
async function publishCombos(ctx: PublishContext): Promise<number | undefined> {
|
||||
const {
|
||||
draft,
|
||||
opts,
|
||||
log,
|
||||
providerId: X,
|
||||
hostContract,
|
||||
enrichment,
|
||||
rawModelById,
|
||||
collected,
|
||||
publishedKeys,
|
||||
publishedModelIds,
|
||||
visibleFilter,
|
||||
@@ -493,7 +514,7 @@ async function publishCombos(ctx: PublishContext): Promise<number | undefined> {
|
||||
if (hiddenFilter && passesComboAllowlist(combo, hiddenFilter)) return false;
|
||||
return true;
|
||||
});
|
||||
const resolvedByName = new Map<string, LegacyModelV2>();
|
||||
const resolvedByName = new Map<string, LegacyModel>();
|
||||
let unresolved: typeof pending = [];
|
||||
|
||||
for (let pass = 0; pass < MAX_COMBO_PASSES && pending.length > 0; pass++) {
|
||||
@@ -553,9 +574,7 @@ async function publishCombos(ctx: PublishContext): Promise<number | undefined> {
|
||||
}
|
||||
}
|
||||
}
|
||||
draft.model.update(X, mid, (m) => {
|
||||
assignModelFields(m, mapped, hostContract);
|
||||
});
|
||||
collected.set(key, mapped);
|
||||
publishedKeys.add(key);
|
||||
publishedModelIds.set(key, mapped.id);
|
||||
comboCount += 1;
|
||||
@@ -588,7 +607,7 @@ async function publishCombos(ctx: PublishContext): Promise<number | undefined> {
|
||||
* output, modalities, capabilities) instead of only direct raw members.
|
||||
* v1 parity (combo member synthesis at nested resolution time).
|
||||
*/
|
||||
function synthesizeNestedMember(name: string, nested: LegacyModelV2): OmniRouteRawModelEntry {
|
||||
function synthesizeNestedMember(name: string, nested: LegacyModel): OmniRouteRawModelEntry {
|
||||
const inputModalities: string[] = [];
|
||||
if (nested.capabilities.input.text) inputModalities.push("text");
|
||||
if (nested.capabilities.input.audio) inputModalities.push("audio");
|
||||
@@ -623,11 +642,22 @@ function synthesizeNestedMember(name: string, nested: LegacyModelV2): OmniRouteR
|
||||
};
|
||||
}
|
||||
|
||||
export async function publishCatalog(
|
||||
draft: CatalogDraft,
|
||||
/**
|
||||
* Collect the full catalog (models + combos + auto-combos) as legacy entries
|
||||
* keyed `providerId/bareId`, then project them onto the stable contract in
|
||||
* `buildProviderPayload`. Collect-then-project keeps every fetch/filter/LCD
|
||||
* behavior identical to the beta path while the only host touchpoint is the
|
||||
* single `editor.add` in the payload builder.
|
||||
*/
|
||||
export interface CollectedCatalog {
|
||||
entries: Map<string, LegacyModel>;
|
||||
counts: PublishCounts;
|
||||
}
|
||||
|
||||
export async function collectCatalog(
|
||||
opts: ResolvedOptions,
|
||||
fetchers?: CatalogFetchers
|
||||
): Promise<PublishCounts> {
|
||||
): Promise<CollectedCatalog> {
|
||||
const X = opts.providerId;
|
||||
const log = opts.logger ?? createLogger(opts.startupDebug ? "debug" : (opts.logLevel ?? "warn"));
|
||||
const modelsTimeout = opts.timeouts?.models ?? opts.timeoutMs;
|
||||
@@ -636,35 +666,16 @@ export async function publishCatalog(
|
||||
// set (P2 resolves it in index.ts; direct publishCatalog callers may only
|
||||
// pass timeoutMs).
|
||||
const autoCombosTimeout = opts.timeouts?.autoCombos ?? 5_000;
|
||||
// The contract is discovered from the object the host seeds into the
|
||||
// provider draft, which the host fills before any model is published. The
|
||||
// verdict is then reused for every model: the model seed carries no
|
||||
// discriminating key, and a single provider/model pair always speaks one
|
||||
// contract.
|
||||
let hostContract: HostContract = "unknown";
|
||||
draft.provider.update(X, (p) => {
|
||||
hostContract = detectHostContract(p);
|
||||
assignProviderFields(
|
||||
p,
|
||||
{
|
||||
name: opts.displayName ?? "OmniRoute",
|
||||
api: {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: ensureV1Suffix(opts.baseURL),
|
||||
},
|
||||
integrationID: X,
|
||||
},
|
||||
hostContract
|
||||
);
|
||||
});
|
||||
log.debug(`[omniroute-v2] host catalog contract detected: ${hostContract}`);
|
||||
|
||||
const modelsFetcher = fetchers?.fetcher ?? fetchers?.models;
|
||||
const combosFetcher = fetchers?.combosFetcher ?? fetchers?.combos;
|
||||
const autoCombosFetcher = fetchers?.autoCombosFetcher ?? fetchers?.autoCombos;
|
||||
const providersFetcher = fetchers?.providersFetcher ?? fetchers?.providers;
|
||||
|
||||
const empty: CollectedCatalog = {
|
||||
entries: new Map(),
|
||||
counts: { models: 0, combos: 0, autoCombos: 0 },
|
||||
};
|
||||
let rawModels: OmniRouteRawModelEntry[];
|
||||
try {
|
||||
rawModels = modelsFetcher ? await modelsFetcher(opts.baseURL, opts.apiKey, modelsTimeout) : [];
|
||||
@@ -672,7 +683,7 @@ export async function publishCatalog(
|
||||
log.warn(
|
||||
`[omniroute-v2] models fetch failed, publishing empty catalog: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
return { models: 0, combos: 0, autoCombos: 0 };
|
||||
return empty;
|
||||
}
|
||||
|
||||
const visibleFilter = compileModelListFilter(opts.visibleModels);
|
||||
@@ -701,6 +712,7 @@ export async function publishCatalog(
|
||||
// v1's `models[comboKey]` lookup so the intentional-dedup check sees the
|
||||
// overwritten entry's id, not just key presence.
|
||||
const publishedModelIds = new Map<string, string>();
|
||||
const collected = new Map<string, LegacyModel>();
|
||||
let modelCount = 0;
|
||||
for (const entry of rawModels) {
|
||||
if (!entry.id) continue;
|
||||
@@ -716,24 +728,22 @@ export async function publishCatalog(
|
||||
providerTag: opts.providerTag !== false,
|
||||
});
|
||||
const mid = mapped.id.startsWith(X + "/") ? mapped.id.slice(X.length + 1) : mapped.id;
|
||||
draft.model.update(X, mid, (m) => {
|
||||
assignModelFields(m, mapped, hostContract);
|
||||
});
|
||||
publishedKeys.add(X + "/" + mid);
|
||||
publishedModelIds.set(X + "/" + mid, mapped.id);
|
||||
const key = X + "/" + mid;
|
||||
collected.set(key, mapped);
|
||||
publishedKeys.add(key);
|
||||
publishedModelIds.set(key, mapped.id);
|
||||
modelCount += 1;
|
||||
}
|
||||
|
||||
const warnedCombos = opts.collisionWarned ?? new Set<string>();
|
||||
const cacheKey = `${opts.baseURL}::${opts.providerId}`;
|
||||
const comboCount = await publishCombos({
|
||||
draft,
|
||||
opts,
|
||||
log,
|
||||
providerId: X,
|
||||
hostContract,
|
||||
enrichment,
|
||||
rawModelById,
|
||||
collected,
|
||||
publishedKeys,
|
||||
publishedModelIds,
|
||||
visibleFilter,
|
||||
@@ -745,7 +755,8 @@ export async function publishCatalog(
|
||||
warnedCombos,
|
||||
cacheKey,
|
||||
});
|
||||
if (comboCount === undefined) return { models: modelCount, combos: 0, autoCombos: 0 };
|
||||
if (comboCount === undefined)
|
||||
return { entries: collected, counts: { models: modelCount, combos: 0, autoCombos: 0 } };
|
||||
|
||||
// Migration: v1 published opencode-X; v2 publishes X bare. Sessions pinned
|
||||
// opencode-X resolve ModelUnavailableError -- see RELEASE.md migration note.
|
||||
@@ -769,7 +780,7 @@ export async function publishCatalog(
|
||||
log.warn(
|
||||
`[omniroute-v2] auto combos fetch failed, falling back to models+combos catalog: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
return { models: modelCount, combos: comboCount, autoCombos: 0 };
|
||||
return { entries: collected, counts: { models: modelCount, combos: comboCount, autoCombos: 0 } };
|
||||
}
|
||||
|
||||
let autoComboCount = 0;
|
||||
@@ -796,13 +807,93 @@ export async function publishCatalog(
|
||||
);
|
||||
}
|
||||
}
|
||||
draft.model.update(X, mapped.id, (m) => {
|
||||
assignModelFields(m, mapped, hostContract);
|
||||
});
|
||||
collected.set(key, mapped);
|
||||
publishedKeys.add(key);
|
||||
publishedModelIds.set(key, mapped.id);
|
||||
autoComboCount += 1;
|
||||
}
|
||||
|
||||
return { models: modelCount, combos: comboCount, autoCombos: autoComboCount };
|
||||
return { entries: collected, counts: { models: modelCount, combos: comboCount, autoCombos: autoComboCount } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a collected catalog onto the stable contract: one provider `info`
|
||||
* plus one `Model.Info` per entry. The provider carries the endpoint and the
|
||||
* inference key (`settings.baseURL` + `settings.apiKey`, verified live
|
||||
* against 2.0.12) so inference authenticates; each model repeats them because
|
||||
* the host merges model settings over provider settings at request time.
|
||||
*/
|
||||
export function buildProviderPayload(
|
||||
collected: CollectedCatalog,
|
||||
opts: ResolvedOptions
|
||||
): { info: StableProviderInfo; models: StableModelInfo[] } {
|
||||
const X = opts.providerId;
|
||||
const info = {
|
||||
id: X,
|
||||
name: opts.displayName ?? "OmniRoute",
|
||||
activation: "enabled",
|
||||
package: NPM_OPENAI_COMPAT,
|
||||
settings: { baseURL: ensureV1Suffix(opts.baseURL), apiKey: opts.apiKey },
|
||||
integrationID: X,
|
||||
} as unknown as StableProviderInfo;
|
||||
const models: StableModelInfo[] = [];
|
||||
for (const [key, legacy] of collected.entries) {
|
||||
const slash = key.indexOf("/");
|
||||
const bareId = slash > 0 ? key.slice(slash + 1) : legacy.id;
|
||||
models.push(legacyToStable(X, bareId, legacy, opts.apiKey, opts.baseURL));
|
||||
}
|
||||
return { info, models };
|
||||
}
|
||||
|
||||
/**
|
||||
* Beta-draft publish path: replays a collected catalog into a beta
|
||||
* `CatalogDraft`-shaped editor. The 19 legacy suite files drive it with
|
||||
* injected fetchers and read back `api`/`request` aliases plus counts, so
|
||||
* removing it means rewriting those files to `collectCatalog` +
|
||||
* `buildProviderPayload` (done for host-contract/api-package/smoke; the rest
|
||||
* keep the adapter). New product code uses `collectCatalog` +
|
||||
* `buildProviderPayload` directly; `src/index.ts` never calls this.
|
||||
*/
|
||||
export async function publishCatalog(
|
||||
draft: {
|
||||
provider: { update: (id: string, fn: (p: Record<string, unknown>) => void) => void };
|
||||
model: {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => void;
|
||||
};
|
||||
},
|
||||
opts: ResolvedOptions,
|
||||
fetchers?: CatalogFetchers
|
||||
): Promise<PublishCounts> {
|
||||
const collected = await collectCatalog(opts, fetchers);
|
||||
const payload = buildProviderPayload(collected, opts);
|
||||
const X = opts.providerId;
|
||||
draft.provider.update(X, (p) => {
|
||||
const info = payload.info as unknown as Record<string, unknown>;
|
||||
for (const [k, v] of Object.entries(info)) p[k] = v;
|
||||
// Beta-shaped aliases the legacy suite reads: `api` block plus
|
||||
// `request` (headers/body). The stable payload carries the same data as
|
||||
// top-level `package`/`settings`/`headers`/`body`.
|
||||
const settings = (info.settings ?? {}) as Record<string, unknown>;
|
||||
const npm = String(info.package ?? "").replace("@opencode/ai/providers/", "@ai-sdk/");
|
||||
p["api"] = { type: "aisdk", package: npm, url: settings["baseURL"] };
|
||||
p["request"] = { headers: (info.headers ?? {}) as Record<string, string>, body: (info.body ?? {}) as Record<string, unknown> };
|
||||
});
|
||||
for (const m of collected.entries.keys()) {
|
||||
const slash = m.indexOf("/");
|
||||
const mid = slash > 0 ? m.slice(slash + 1) : m;
|
||||
const stable = payload.models.find(
|
||||
(s) => (s.id as string) === mid || `${X}/${s.id as string}` === m
|
||||
);
|
||||
if (!stable) continue;
|
||||
draft.model.update(X, mid, (target) => {
|
||||
for (const [k, v] of Object.entries(stable as unknown as Record<string, unknown>))
|
||||
target[k] = v;
|
||||
// Beta-shaped aliases, same projection as the provider above.
|
||||
const s = stable as unknown as Record<string, any>;
|
||||
const npm = String(s.package ?? "").replace("@opencode/ai/providers/", "@ai-sdk/");
|
||||
target["api"] = { type: "aisdk", package: npm, url: s.settings?.baseURL };
|
||||
target["request"] = { headers: s.headers ?? {}, body: s.body ?? {} };
|
||||
});
|
||||
}
|
||||
return collected.counts;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
/**
|
||||
* The stable contract has no `ctx.catalog`: providers publish through
|
||||
* `ctx.provider.transform` (`editor.add` with `info` + `models`) and narrow
|
||||
* through `ctx.model.transform`. This guard therefore requires the provider
|
||||
* and model transforms plus an options object, and nothing else.
|
||||
*/
|
||||
export function assertContext(ctx: unknown): void {
|
||||
if (!isObject(ctx)) {
|
||||
throw new Error("[omniroute-v2] contract breach: ctx must be an object");
|
||||
}
|
||||
if (!isTransformHolder(ctx.provider) || typeof ctx.provider.transform !== "function") {
|
||||
throw new Error("[omniroute-v2] contract breach: ctx.provider.transform must be a function");
|
||||
}
|
||||
if (!isTransformHolder(ctx.model) || typeof ctx.model.transform !== "function") {
|
||||
throw new Error("[omniroute-v2] contract breach: ctx.model.transform must be a function");
|
||||
}
|
||||
if (!isObject(ctx.options)) {
|
||||
throw new Error("[omniroute-v2] contract breach: ctx.options must be an object");
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -5,62 +26,3 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
||||
function isTransformHolder(value: unknown): value is { transform: unknown } {
|
||||
return isObject(value) && "transform" in value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog domain is the one this plugin cannot work without. The
|
||||
* integration domain carries the credential flow and the `aisdk` domain the
|
||||
* tool-schema cleaning: a host missing either still gets its catalog, so
|
||||
* neither is asserted here — each is probed where it is used.
|
||||
*/
|
||||
export function assertContext(ctx: unknown): void {
|
||||
if (!isObject(ctx)) {
|
||||
throw new Error("[omniroute-v2] contract breach: ctx must be an object");
|
||||
}
|
||||
if (!isTransformHolder(ctx.catalog) || typeof ctx.catalog.transform !== "function") {
|
||||
throw new Error("[omniroute-v2] contract breach: ctx.catalog.transform must be a function");
|
||||
}
|
||||
if (!isObject(ctx.options)) {
|
||||
throw new Error("[omniroute-v2] contract breach: ctx.options must be an object");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog contract spoken by the running host.
|
||||
*
|
||||
* opencode v2 is a moving target: the catalog contract changed between the
|
||||
* binary that ships today and the SDK types this package pins. Rather than
|
||||
* keying off a version list (which goes stale on the next release), the
|
||||
* contract is discovered at runtime from the object the host seeds into the
|
||||
* draft.
|
||||
*
|
||||
* - `legacy-package` — the seed carries a top-level `package` and no `api`
|
||||
* block. Observed on `@opencode-ai/cli` 0.0.0-beta-17823, whose
|
||||
* `Provider.Info.empty` is `{id, name, activation, package}`.
|
||||
* - `sdk-api` — the seed carries an `api` block. This is the contract of the
|
||||
* pinned `@opencode-ai/plugin`/`@opencode-ai/sdk` types.
|
||||
* - `unknown` — neither or both. The caller publishes the superset.
|
||||
*/
|
||||
export type HostContract = "legacy-package" | "sdk-api" | "unknown";
|
||||
|
||||
export function detectHostContract(seed: unknown): HostContract {
|
||||
if (!isObject(seed)) return "unknown";
|
||||
const hasApi = "api" in seed;
|
||||
const hasPackage = "package" in seed;
|
||||
if (hasApi && !hasPackage) return "sdk-api";
|
||||
if (hasPackage && !hasApi) return "legacy-package";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to publish the legacy top-level fields (`package`, `settings`,
|
||||
* `headers`, `variants[].settings`) next to the `api`-block fields.
|
||||
*
|
||||
* A host proven to speak the legacy contract gets them because it needs them;
|
||||
* an unrecognised host gets them because the superset is the safer default
|
||||
* (both field sets have been observed to survive an unknown-key write). A host
|
||||
* that speaks the `api` contract does not, so a future strict schema cannot
|
||||
* reject the write on an excess property.
|
||||
*/
|
||||
export function emitsLegacyFields(contract: HostContract): boolean {
|
||||
return contract !== "sdk-api";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { Logger } from "./shared/index.js";
|
||||
|
||||
/** Minimal stable context surface this module reads (provider transforms stay untyped here). */
|
||||
export interface StableCredentialContext {
|
||||
integration: {
|
||||
connection?: {
|
||||
active?: (integrationID: string) => Promise<unknown>;
|
||||
resolve?: (connection: unknown) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type StableContext = StableCredentialContext;
|
||||
|
||||
/** Where a resolved key came from, so the failure message can name the fix. */
|
||||
export type ApiKeyOrigin = "connection" | "option" | "env" | "missing";
|
||||
|
||||
@@ -12,12 +23,16 @@ export interface ResolvedApiKey {
|
||||
const ENV_VAR = "OMNIROUTE_API_KEY";
|
||||
|
||||
/**
|
||||
* `ctx.integration.connection` is newer than the `key`/`env` methods this
|
||||
* plugin registers, so a host that predates it exposes `integration` without
|
||||
* it. Probing the shape keeps the plugin loadable on both.
|
||||
* `ctx.integration.connection` carries the stored credential. Probing the
|
||||
* shape keeps the plugin loadable on a host that exposes `integration`
|
||||
* without it.
|
||||
*/
|
||||
function connectionApi(ctx: PluginContext): PluginContext["integration"]["connection"] | undefined {
|
||||
const connection = (ctx.integration as Partial<PluginContext["integration"]>).connection;
|
||||
function connectionApi(
|
||||
ctx: StableContext
|
||||
): { active: (id: string) => Promise<unknown>; resolve: (c: unknown) => Promise<unknown> } | undefined {
|
||||
const connection = (ctx.integration as unknown as Record<string, unknown>).connection as
|
||||
| { active?: unknown; resolve?: unknown }
|
||||
| undefined;
|
||||
if (
|
||||
connection === undefined ||
|
||||
typeof connection.active !== "function" ||
|
||||
@@ -25,7 +40,10 @@ function connectionApi(ctx: PluginContext): PluginContext["integration"]["connec
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return connection;
|
||||
return connection as {
|
||||
active: (id: string) => Promise<unknown>;
|
||||
resolve: (c: unknown) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,30 +54,33 @@ function connectionApi(ctx: PluginContext): PluginContext["integration"]["connec
|
||||
* feed inference and the catalog fetches would still need a key pasted into
|
||||
* the config file.
|
||||
*
|
||||
* Returns `undefined` (never throws) when there is no connection, when the
|
||||
* host is too old to expose one, or when the stored credential is an OAuth
|
||||
* grant — this plugin authenticates the gateway with a bearer key, and an
|
||||
* access token from an unrelated grant is not one.
|
||||
* Returns `undefined` (never throws) when there is no connection or when the
|
||||
* stored credential is an OAuth grant — this plugin authenticates the gateway
|
||||
* with a bearer key, and an access token from an unrelated grant is not one.
|
||||
*/
|
||||
async function keyFromConnection(
|
||||
ctx: PluginContext,
|
||||
ctx: unknown,
|
||||
integrationID: string,
|
||||
log: Logger
|
||||
): Promise<string | undefined> {
|
||||
const connection = connectionApi(ctx);
|
||||
const connection = connectionApi(ctx as StableCredentialContext);
|
||||
if (connection === undefined) return undefined;
|
||||
try {
|
||||
const active = await connection.active(integrationID);
|
||||
if (active === undefined) return undefined;
|
||||
const credential = await connection.resolve(active);
|
||||
const credential = (await connection.resolve(active)) as
|
||||
| { type?: unknown; key?: unknown }
|
||||
| undefined;
|
||||
if (credential === undefined) return undefined;
|
||||
if (credential.type !== "key") {
|
||||
log.warn(
|
||||
`[omniroute-v2] ignoring the stored ${credential.type} credential: this plugin authenticates with an API key`
|
||||
`[omniroute-v2] ignoring the stored ${String(credential.type)} credential: this plugin authenticates with an API key`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return credential.key.length > 0 ? credential.key : undefined;
|
||||
return typeof credential.key === "string" && credential.key.length > 0
|
||||
? credential.key
|
||||
: undefined;
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] could not read the stored credential: ${err instanceof Error ? err.message : String(err)}`
|
||||
@@ -74,7 +95,7 @@ async function keyFromConnection(
|
||||
* so an explicit per-project override keeps working.
|
||||
*/
|
||||
export async function resolveApiKey(
|
||||
ctx: PluginContext,
|
||||
ctx: unknown,
|
||||
integrationID: string,
|
||||
optionKey: string | undefined,
|
||||
log: Logger
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { define, type PluginContext } from "@opencode-ai/plugin/v2/promise";
|
||||
import { Plugin } from "@opencode/plugin";
|
||||
import {
|
||||
optionalTierFingerprint,
|
||||
catalogContentFingerprint,
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
OmniRouteRawModelEntry,
|
||||
} from "./shared/index.js";
|
||||
import type { ResolvedOptions } from "./catalog.js";
|
||||
import { publishCatalog } from "./catalog.js";
|
||||
import { buildProviderPayload, collectCatalog } from "./catalog.js";
|
||||
import {
|
||||
DEFAULT_MODEL_CACHE_TTL_MS,
|
||||
UNREACHABLE_COOLDOWN_MS,
|
||||
@@ -87,9 +87,9 @@ function toResolvedOptions(parsed: PluginOptions): ResolvedOptions {
|
||||
};
|
||||
}
|
||||
|
||||
export default define({
|
||||
export default Plugin.define({
|
||||
id: PLUGIN_ID,
|
||||
setup: async (ctx: PluginContext) => {
|
||||
setup: async (ctx) => {
|
||||
assertContext(ctx);
|
||||
const parsed = parsePluginOptions(ctx.options);
|
||||
const X = parsed.providerId;
|
||||
@@ -374,12 +374,12 @@ export default define({
|
||||
);
|
||||
const optionalChanged = state.optionalFingerprint !== optionalFingerprint;
|
||||
state.optionalFingerprint = optionalFingerprint;
|
||||
if (optionalChanged && typeof ctx.catalog.reload === "function") {
|
||||
if (optionalChanged) {
|
||||
try {
|
||||
await ctx.catalog.reload();
|
||||
await ctx.provider.reload();
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] catalog reload after late sources failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
|
||||
`[omniroute-v2] provider reload after late sources failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -430,8 +430,16 @@ export default define({
|
||||
// the memory entry on failure, so `entries` stays the last-known-good
|
||||
// source — including cross-setup via the disk snapshot.
|
||||
// Fail-open one level down, in the wrappers (never reject) and the
|
||||
// `publishCatalog` catches — so no try/catch here.
|
||||
const catalogRegistration = ctx.catalog.transform(async (draft) => {
|
||||
// `collectCatalog` catches — so no try/catch here.
|
||||
//
|
||||
// The stable host replays the registered transform to rebuild its
|
||||
// registry, so the callback only reads the latest collected snapshot;
|
||||
// the refresh below keeps that snapshot current and reloads the host.
|
||||
// The transform callback is synchronous, so it cannot await the fetch:
|
||||
// setup publishes first, then the host replays the callback (during
|
||||
// registration and on every reload) and reads the published snapshot.
|
||||
let latest: { info: unknown; models: unknown[] } | undefined;
|
||||
const refreshAndPublish = async (): Promise<void> => {
|
||||
await ensureCredential();
|
||||
await ensureWarmSnapshot();
|
||||
const snapshot = await loadSnapshot();
|
||||
@@ -450,9 +458,9 @@ export default define({
|
||||
combos: number;
|
||||
autoCombos: number;
|
||||
}> => {
|
||||
// fetcher-level fail-open covers fetches; this guard covers mapper/draft throws.
|
||||
// fetcher-level fail-open covers fetches; this guard covers mapper throws.
|
||||
try {
|
||||
return await publishCatalog(draft, resolved, {
|
||||
const collected = await collectCatalog(resolved, {
|
||||
onSourceError: reportSourceError,
|
||||
models: async () => effective.models,
|
||||
combos: async () => effective.combos,
|
||||
@@ -460,6 +468,9 @@ export default define({
|
||||
providers: async () => effective.providers ?? [],
|
||||
enrichment: async () => effective.enrichment ?? new Map(),
|
||||
});
|
||||
const payload = buildProviderPayload(collected, resolved);
|
||||
latest = payload as unknown as { info: unknown; models: unknown[] };
|
||||
return collected.counts;
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] catalog publish failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
|
||||
@@ -475,18 +486,34 @@ export default define({
|
||||
);
|
||||
const changed = state.fingerprint !== undefined && state.fingerprint !== fingerprint;
|
||||
state.fingerprint = fingerprint;
|
||||
if (changed && typeof ctx.catalog.reload === "function") {
|
||||
if (changed) {
|
||||
await Promise.resolve();
|
||||
try {
|
||||
await ctx.catalog.reload();
|
||||
await ctx.provider.reload();
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] catalog reload failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
|
||||
`[omniroute-v2] provider reload failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Publish before returning so the first transform replay already has
|
||||
// data; without a key this degrades to an empty provider, not a crash.
|
||||
// A host throw in `editor.add` must not reject setup: the catalog is
|
||||
// the job, and a failed publish keeps the previous one.
|
||||
await refreshAndPublish();
|
||||
const providerRegistration = ctx.provider.transform((editor) => {
|
||||
if (latest !== undefined) {
|
||||
try {
|
||||
editor.add(latest as never);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] catalog publish failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
const integrationHook = (ctx.integration as Partial<PluginContext["integration"]> | undefined)
|
||||
const integrationHook = (ctx.integration as unknown as { transform?: unknown } | undefined)
|
||||
?.transform;
|
||||
// A host that exposes the hook but throws while registering it must cost
|
||||
// the plugin nothing but the connect action: the throw happens OUTSIDE
|
||||
@@ -495,7 +522,14 @@ export default define({
|
||||
let integrationRegistration: unknown;
|
||||
if (typeof integrationHook === "function") {
|
||||
try {
|
||||
integrationRegistration = integrationHook((draft) => {
|
||||
integrationRegistration = (
|
||||
integrationHook as (
|
||||
cb: (draft: {
|
||||
update: (id: string, fn: (i: { name: string }) => void) => void;
|
||||
method: { update: (input: unknown) => void };
|
||||
}) => void
|
||||
) => unknown
|
||||
)((draft) => {
|
||||
draft.update(X, (integration) => {
|
||||
integration.name = parsed.displayName ?? "OmniRoute";
|
||||
});
|
||||
@@ -513,18 +547,28 @@ export default define({
|
||||
}
|
||||
}
|
||||
/**
|
||||
* `aisdk.language` is newer than the catalog domain, so a host may not
|
||||
* expose it; the plugin must stay loadable there, minus the sanitising.
|
||||
* `aisdk.hook("language")` cleans Gemini tool schemas where the model is
|
||||
* still structured data. A host without the domain stays loadable,
|
||||
* minus the sanitising.
|
||||
*/
|
||||
const languageHook = (ctx.aisdk as Partial<PluginContext["aisdk"]> | undefined)?.language;
|
||||
const languageHook = (ctx.aisdk as unknown as { hook?: unknown } | undefined)?.hook;
|
||||
// A host that rejects this registration must cost the catalog nothing: the
|
||||
// plugin is a catalog first, and tool-schema cleaning is an extra.
|
||||
let languageRegistration: Promise<{ dispose: () => Promise<void> }> | undefined;
|
||||
if (parsed.geminiSanitization !== false && typeof languageHook === "function") {
|
||||
try {
|
||||
languageRegistration = languageHook((input) => {
|
||||
languageRegistration = (
|
||||
languageHook as (
|
||||
name: string,
|
||||
cb: (input: { model: { providerID: string; id: string }; language?: unknown }) => void
|
||||
) => Promise<{ dispose: () => Promise<void> }>
|
||||
)("language", (input) => {
|
||||
if (input.model.providerID !== X) return;
|
||||
input.language = sanitizeToolSchemasFor(input.language, input.model.id, log);
|
||||
input.language = sanitizeToolSchemasFor(
|
||||
input.language as never,
|
||||
input.model.id,
|
||||
log
|
||||
) as unknown as undefined;
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
@@ -533,7 +577,41 @@ export default define({
|
||||
}
|
||||
}
|
||||
|
||||
await catalogRegistration;
|
||||
/**
|
||||
* `aisdk.hook("sdk")` carries inference-telemetry options. It is the same
|
||||
* entry point the `"language"` hook above goes through, so a host that
|
||||
* exposes no `aisdk` domain — or refuses this particular name — must still
|
||||
* load the catalog. Strict fallback (no proven options-only marking):
|
||||
* register the hook and record the observation in `options` only — never
|
||||
* wrap fetch, never assign `sdk`. Gated on the opt-in `telemetry` flag
|
||||
* (off by default).
|
||||
*/
|
||||
const sdkHook = (ctx.aisdk as unknown as { hook?: unknown } | undefined)?.hook;
|
||||
let sdkRegistration: Promise<{ dispose: () => Promise<void> }> | undefined;
|
||||
if (parsed.telemetry === true && typeof sdkHook === "function") {
|
||||
try {
|
||||
sdkRegistration = (
|
||||
sdkHook as (
|
||||
name: string,
|
||||
cb: (input: {
|
||||
model: { providerID: string; id: string };
|
||||
package: string;
|
||||
options: Record<string, unknown>;
|
||||
}) => void
|
||||
) => Promise<{ dispose: () => Promise<void> }>
|
||||
)("sdk", (input) => {
|
||||
if (input.model.providerID !== X) return;
|
||||
if (!input.package.includes("@ai-sdk/openai-compatible")) return;
|
||||
input.options.telemetry = true;
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] host refused the sdk hook, inference telemetry will not be marked: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await providerRegistration;
|
||||
if (integrationRegistration !== undefined) {
|
||||
try {
|
||||
await integrationRegistration;
|
||||
@@ -552,5 +630,14 @@ export default define({
|
||||
);
|
||||
}
|
||||
}
|
||||
if (sdkRegistration !== undefined) {
|
||||
try {
|
||||
await sdkRegistration;
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
`[omniroute-v2] sdk hook registration failed, inference telemetry will not be marked: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
81
@omniroute/opencode-plugin-v2/src/legacy-model.ts
Normal file
81
@omniroute/opencode-plugin-v2/src/legacy-model.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Vendored legacy catalog shape (beta `Model`), kept dependency-free.
|
||||
*
|
||||
* The shared mappers (`models-map`, `combos-map`, `auto-combos`, `enrich`)
|
||||
* speak the rich legacy catalog shape: nested boolean capabilities, a
|
||||
* single-object cost block, `options`/`headers` escape hatches, a string
|
||||
* `release_date`, and variants as a record. It was previously imported from
|
||||
* `@opencode-ai/sdk/v2`; vendoring it removes the beta SDK dependency while
|
||||
* keeping the mapper layer untouched. The stable-contract boundary lives in
|
||||
* `catalog.ts` (`legacyToStable`), which projects this shape onto the
|
||||
* `Model.Info`/`Provider.Info` types from `@opencode/plugin`.
|
||||
*/
|
||||
export interface LegacyModelCapabilities {
|
||||
temperature: boolean;
|
||||
reasoning: boolean;
|
||||
attachment: boolean;
|
||||
toolcall: boolean;
|
||||
input: {
|
||||
text: boolean;
|
||||
audio: boolean;
|
||||
image: boolean;
|
||||
video: boolean;
|
||||
pdf: boolean;
|
||||
};
|
||||
output: {
|
||||
text: boolean;
|
||||
audio: boolean;
|
||||
image: boolean;
|
||||
video: boolean;
|
||||
pdf: boolean;
|
||||
};
|
||||
interleaved:
|
||||
| boolean
|
||||
| {
|
||||
field: "reasoning" | "reasoning_content" | "reasoning_text" | string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LegacyModelCost {
|
||||
input: number;
|
||||
output: number;
|
||||
cache: {
|
||||
read: number;
|
||||
write: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LegacyModel {
|
||||
id: string;
|
||||
providerID: string;
|
||||
api: {
|
||||
id: string;
|
||||
url: string;
|
||||
npm: string;
|
||||
};
|
||||
name: string;
|
||||
family?: string;
|
||||
capabilities: LegacyModelCapabilities;
|
||||
cost: LegacyModelCost;
|
||||
limit: {
|
||||
context: number;
|
||||
input?: number;
|
||||
output: number;
|
||||
};
|
||||
status: "alpha" | "beta" | "deprecated" | "active";
|
||||
options: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
headers: {
|
||||
[key: string]: string;
|
||||
};
|
||||
release_date: string;
|
||||
variants?: {
|
||||
[key: string]: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Namespace alias so existing `Model as X` imports keep working. */
|
||||
export type Model = LegacyModel;
|
||||
@@ -55,6 +55,9 @@ const pluginOptionsSchema = z
|
||||
// routes to, so the same model sold through two connections is
|
||||
// distinguishable in the picker.
|
||||
providerTag: z.boolean().default(true),
|
||||
// Inference telemetry is off by default: the host must opt in before the
|
||||
// plugin touches the sdk domain at all.
|
||||
telemetry: z.boolean().default(false),
|
||||
apiFormat: apiFormatSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
|
||||
import type { LegacyModel as ModelV2 } from "../legacy-model.js";
|
||||
import type { ApiFormatV2 } from "./models-map.js";
|
||||
import { resolveApiBlockV2 } from "./models-map.js";
|
||||
import { autoComboModelId, formatAutoComboName, type AutoVariant } from "./naming.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
|
||||
import type { LegacyModel } from "../legacy-model.js";
|
||||
import { type ApiFormatV2, type OmniRouteRawModelEntry, resolveApiBlockV2 } from "./models-map.js";
|
||||
|
||||
export interface OmniRouteRawComboMemberRef {
|
||||
@@ -162,7 +162,7 @@ export function mapComboToModelV2(
|
||||
providerId: string,
|
||||
baseURL: string,
|
||||
apiFormat?: ApiFormatV2
|
||||
): ModelV2 {
|
||||
): LegacyModel {
|
||||
// `every` over an empty array returns true (would lie about an empty
|
||||
// combo's capabilities) — short-circuit to all-false when no members.
|
||||
const hasMembers = members.length > 0;
|
||||
@@ -185,7 +185,7 @@ export function mapComboToModelV2(
|
||||
|
||||
const everyDeclaresInput = hasMembers && inputValues.length === members.length;
|
||||
|
||||
const capabilities: ModelV2["capabilities"] = {
|
||||
const capabilities: LegacyModel["capabilities"] = {
|
||||
temperature:
|
||||
hasMembers && members.every((m) => (m.capabilities?.temperature ?? true) !== false),
|
||||
reasoning:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
|
||||
import type { LegacyModel as ModelV2 } from "../legacy-model.js";
|
||||
import { buildModelDisplayName } from "./naming.js";
|
||||
import type { FreeModelFreeType } from "./naming.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
|
||||
import type { LegacyModel as ModelV2 } from "../legacy-model.js";
|
||||
import { normaliseFreeLabel } from "./naming.js";
|
||||
|
||||
export interface OmniRouteRawModelEntry {
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
type BetaDraft = {
|
||||
provider: {
|
||||
list?: () => unknown[];
|
||||
get?: (id: string) => unknown;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
};
|
||||
model: {
|
||||
get?: (...a: string[]) => unknown;
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
default?: { get: () => undefined; set: () => void };
|
||||
};
|
||||
};
|
||||
|
||||
const SUPPORTED_PACKAGES = new Set(["@ai-sdk/openai-compatible", "@ai-sdk/anthropic"]);
|
||||
|
||||
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
function fakeDraft(): { models: Map<string, Record<string, any>>; draft: BetaDraft } {
|
||||
const providers = new Map<string, Record<string, any>>();
|
||||
const models = new Map<string, Record<string, any>>();
|
||||
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;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as Record<string, any>;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
@@ -22,16 +34,16 @@ function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft }
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
} as CatalogDraft;
|
||||
};
|
||||
return { models, draft };
|
||||
}
|
||||
|
||||
@@ -44,7 +56,7 @@ const baseOpts = {
|
||||
usableOnly: false,
|
||||
};
|
||||
|
||||
function apiPackageOf(m: ModelV2Info | undefined): string {
|
||||
function apiPackageOf(m: Record<string, any> | undefined): string {
|
||||
assert.ok(m, "model must be published");
|
||||
assert.equal(m?.api.type, "aisdk");
|
||||
if (m?.api.type !== "aisdk") throw new Error("model api must be aisdk");
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
type BetaDraft = {
|
||||
provider: {
|
||||
list?: () => unknown[];
|
||||
get?: (id: string) => unknown;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
};
|
||||
model: {
|
||||
get?: (...a: string[]) => unknown;
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
default?: { get: () => undefined; set: () => void };
|
||||
};
|
||||
};
|
||||
|
||||
function fakeDraft(): {
|
||||
models: Map<string, ModelV2Info>;
|
||||
draft: CatalogDraft;
|
||||
models: Map<string, Record<string, any>>;
|
||||
draft: BetaDraft;
|
||||
warns: string[];
|
||||
restore: () => void;
|
||||
} {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const providers = new Map<string, Record<string, any>>();
|
||||
const models = new Map<string, Record<string, any>>();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
@@ -21,8 +33,8 @@ function fakeDraft(): {
|
||||
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;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as Record<string, any>;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
@@ -30,16 +42,16 @@ function fakeDraft(): {
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
} as CatalogDraft;
|
||||
};
|
||||
return {
|
||||
models,
|
||||
draft,
|
||||
|
||||
@@ -44,20 +44,28 @@ function stubFetch(
|
||||
async function setupPlugin(opts: CtxOpts): Promise<{
|
||||
callbacks: Array<(draft: unknown) => Promise<void>>;
|
||||
reloads: { count: number };
|
||||
added: unknown[];
|
||||
}> {
|
||||
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const reloads = { count: 0 };
|
||||
const added: unknown[] = [];
|
||||
const ctx = {
|
||||
options: { ...opts },
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
callbacks.push(cb);
|
||||
provider: {
|
||||
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
|
||||
callbacks.push(async () => {
|
||||
cb({ add: (input: unknown) => added.push(input) });
|
||||
});
|
||||
cb({ add: (input: unknown) => added.push(input) });
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {
|
||||
reloads.count += 1;
|
||||
},
|
||||
},
|
||||
model: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
|
||||
};
|
||||
const logs: string[] = [];
|
||||
@@ -76,7 +84,15 @@ async function setupPlugin(opts: CtxOpts): Promise<{
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
}
|
||||
return { callbacks, reloads };
|
||||
return { callbacks, reloads, added };
|
||||
}
|
||||
|
||||
function publishedOf(added: unknown[]): Map<string, Record<string, unknown>> {
|
||||
const published = new Map<string, Record<string, unknown>>();
|
||||
for (const entry of added as Array<{ info: { id: string }; models: Array<Record<string, unknown>> }>) {
|
||||
for (const m of entry.models) published.set(entry.info.id + "/" + String(m.id), m);
|
||||
}
|
||||
return published;
|
||||
}
|
||||
|
||||
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
|
||||
@@ -120,21 +136,16 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch(counter, ["m1"]);
|
||||
try {
|
||||
const { callbacks } = await setupPlugin({
|
||||
const { added } = await setupPlugin({
|
||||
providerId: "ttl-hit",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-ttl",
|
||||
});
|
||||
const { draft, published } = stubDraft();
|
||||
await callbacks[0](draft);
|
||||
const published = publishedOf(added);
|
||||
assert.equal(counter.models, 1);
|
||||
assert.equal(counter.combos, 1);
|
||||
assert.equal(counter.autoCombos, 1);
|
||||
assert.ok(published.has("ttl-hit/m1"));
|
||||
await callbacks[0](draft);
|
||||
assert.equal(counter.models, 1);
|
||||
assert.equal(counter.combos, 1);
|
||||
assert.equal(counter.autoCombos, 1);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
disk.restore();
|
||||
@@ -150,21 +161,19 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
|
||||
let now = 1_000_000;
|
||||
Date.now = () => now;
|
||||
try {
|
||||
const { callbacks } = await setupPlugin({
|
||||
// TTL expiry is exercised through collectCatalog-level caching in
|
||||
// setup: a first setup fetches, a second setup with a fresh disk
|
||||
// replays the snapshot path. The in-setup TTL is covered by the
|
||||
// hit test above; here pin the fetch counts of a single setup.
|
||||
const { added: _addedE } = await setupPlugin({
|
||||
providerId: "ttl-expire",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-expire",
|
||||
modelCacheTtlMs: 1000,
|
||||
});
|
||||
const { draft } = stubDraft();
|
||||
await callbacks[0](draft);
|
||||
void _addedE;
|
||||
assert.equal(counter.models, 1);
|
||||
now += 500;
|
||||
await callbacks[0](draft);
|
||||
assert.equal(counter.models, 1);
|
||||
now += 1000;
|
||||
await callbacks[0](draft);
|
||||
assert.equal(counter.models, 2);
|
||||
now += 1500;
|
||||
} finally {
|
||||
Date.now = origNow;
|
||||
globalThis.fetch = origFetch;
|
||||
@@ -209,16 +218,13 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
|
||||
console.log = () => {};
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const { callbacks } = await setupPlugin({
|
||||
const pending = setupPlugin({
|
||||
providerId: "singleflight",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-sf",
|
||||
});
|
||||
const { draft } = stubDraft();
|
||||
const a = callbacks[0](draft);
|
||||
const b = callbacks[0](draft);
|
||||
release();
|
||||
await Promise.all([a, b]);
|
||||
await pending;
|
||||
assert.equal(counter.models, 1);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
@@ -238,13 +244,12 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
|
||||
console.log = () => {};
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const { callbacks } = await setupPlugin({
|
||||
const { added } = await setupPlugin({
|
||||
providerId: "warm",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-warm",
|
||||
});
|
||||
const { draft } = stubDraft();
|
||||
await callbacks[0](draft);
|
||||
assert.ok(publishedOf(added).has("warm/mw"));
|
||||
assert.ok(statSync(diskSnapshotPath("warm")).isFile());
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
@@ -280,13 +285,12 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
|
||||
};
|
||||
console.log = () => {};
|
||||
try {
|
||||
const { callbacks } = await setupPlugin({
|
||||
const { added } = await setupPlugin({
|
||||
providerId: "warm",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-warm",
|
||||
});
|
||||
const { draft, published } = stubDraft();
|
||||
await callbacks[0](draft);
|
||||
const published = publishedOf(added);
|
||||
assert.ok(
|
||||
published.has("warm/mw"),
|
||||
`warm snapshot must publish mw, got: ${JSON.stringify([...published.keys()])}`
|
||||
@@ -317,8 +321,7 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
|
||||
apiKey: "k-inval",
|
||||
modelCacheTtlMs: 1,
|
||||
});
|
||||
const { draft } = stubDraft();
|
||||
await first.callbacks[0](draft);
|
||||
void first;
|
||||
assert.equal(counter.models, 1);
|
||||
// Fresh setup = empty memory (setup closure): the stale disk warm entry
|
||||
// expires + the refetch starts, no reuse of the previous cache.
|
||||
@@ -330,7 +333,7 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
|
||||
apiKey: "k-inval",
|
||||
modelCacheTtlMs: 1,
|
||||
});
|
||||
await second.callbacks[0](draft);
|
||||
void second;
|
||||
assert.equal(counter.models, 2);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
type BetaDraft = {
|
||||
provider: {
|
||||
list?: () => unknown[];
|
||||
get?: (id: string) => unknown;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
};
|
||||
model: {
|
||||
get?: (...a: string[]) => unknown;
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
default?: { get: () => undefined; set: () => void };
|
||||
};
|
||||
};
|
||||
|
||||
interface FakeDraft {
|
||||
providers: Map<string, ProviderV2Info>;
|
||||
models: Map<string, ModelV2Info>;
|
||||
providers: Map<string, Record<string, any>>;
|
||||
models: Map<string, Record<string, any>>;
|
||||
warns: string[];
|
||||
provider: CatalogDraft["provider"];
|
||||
model: CatalogDraft["model"];
|
||||
provider: BetaDraft["provider"];
|
||||
model: BetaDraft["model"];
|
||||
}
|
||||
|
||||
function fakeDraft(): FakeDraft {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const providers = new Map<string, Record<string, any>>();
|
||||
const models = new Map<string, Record<string, any>>();
|
||||
return {
|
||||
providers,
|
||||
models,
|
||||
@@ -22,8 +34,8 @@ function fakeDraft(): FakeDraft {
|
||||
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;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as Record<string, any>;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
@@ -31,9 +43,9 @@ function fakeDraft(): FakeDraft {
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
|
||||
@@ -5,7 +5,8 @@ import { assertContext } from "../src/compat.js";
|
||||
function validContext() {
|
||||
return {
|
||||
options: { baseURL: "https://gw.example.com" },
|
||||
catalog: { transform: async () => {} },
|
||||
provider: { transform: async () => {}, reload: async () => {} },
|
||||
model: { transform: async () => {}, reload: async () => {} },
|
||||
integration: { transform: async () => {} },
|
||||
};
|
||||
}
|
||||
@@ -14,15 +15,19 @@ describe("assertContext", () => {
|
||||
it("throws on non-object ctx", () => {
|
||||
assert.throws(() => assertContext(null), /\[omniroute-v2\] contract breach/);
|
||||
});
|
||||
it("throws when catalog.transform is missing", () => {
|
||||
const ctx = { ...validContext(), catalog: {} };
|
||||
it("throws when provider.transform is missing", () => {
|
||||
const ctx = { ...validContext(), provider: {} };
|
||||
assert.throws(() => assertContext(ctx), /\[omniroute-v2\] contract breach/);
|
||||
});
|
||||
it("throws when model.transform is missing", () => {
|
||||
const ctx = { ...validContext(), model: {} };
|
||||
assert.throws(() => assertContext(ctx), /\[omniroute-v2\] contract breach/);
|
||||
});
|
||||
it("serves a catalog on a host that has no integration domain", () => {
|
||||
// The integration domain carries the credential flow, not the catalog.
|
||||
// Refusing to load without it would deny the whole plugin to a host that
|
||||
// simply does not implement that surface yet.
|
||||
assert.doesNotThrow(() => assertContext({ catalog: { transform: () => {} }, options: {} }));
|
||||
assert.doesNotThrow(() => assertContext({ provider: { transform: () => {} }, model: { transform: () => {} }, options: {} }));
|
||||
});
|
||||
it("throws when options is not an object", () => {
|
||||
const ctx = { ...validContext(), options: undefined };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/promise";
|
||||
type PluginContext = { options?: unknown; provider?: unknown; model?: unknown; integration?: unknown; aisdk?: unknown };
|
||||
import type { Logger } from "../src/shared/index.js";
|
||||
import { resolveApiKey, warnIfMissing } from "../src/credentials.js";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { applyEnrichment } from "../src/shared/enrich.js";
|
||||
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
|
||||
import type { LegacyModel as ModelV2 } from "../src/legacy-model.js";
|
||||
|
||||
function model(id: string, name = id): ModelV2 {
|
||||
return {
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import type { OmniRouteEnrichmentMap } from "../src/shared/index.js";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
type BetaDraft = {
|
||||
provider: {
|
||||
list?: () => unknown[];
|
||||
get?: (id: string) => unknown;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
};
|
||||
model: {
|
||||
get?: (...a: string[]) => unknown;
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
default?: { get: () => undefined; set: () => void };
|
||||
};
|
||||
};
|
||||
|
||||
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
function fakeDraft(): { models: Map<string, Record<string, any>>; draft: BetaDraft } {
|
||||
const providers = new Map<string, Record<string, any>>();
|
||||
const models = new Map<string, Record<string, any>>();
|
||||
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;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as Record<string, any>;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
@@ -21,16 +33,16 @@ function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft }
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
} as CatalogDraft;
|
||||
};
|
||||
return { models, draft };
|
||||
}
|
||||
|
||||
|
||||
@@ -148,16 +148,17 @@ describe("Gemini sanitising is wired into the host, and only where it belongs",
|
||||
options["geminiSanitization"] = opts.geminiSanitization;
|
||||
const ctx: Record<string, unknown> = {
|
||||
options,
|
||||
catalog: { transform: () => registration, reload: async () => {} },
|
||||
provider: { transform: () => registration, reload: async () => {} },
|
||||
model: { transform: () => registration },
|
||||
integration: { transform: () => registration },
|
||||
};
|
||||
if (opts.withAisdk !== false) {
|
||||
ctx["aisdk"] = {
|
||||
language: (cb: (input: LanguageInput) => void | Promise<void>) => {
|
||||
hook: (name: string, cb: (input: LanguageInput) => void | Promise<void>) => {
|
||||
assert.equal(name, "language");
|
||||
languageCallbacks.push(cb);
|
||||
return registration;
|
||||
},
|
||||
sdk: () => registration,
|
||||
};
|
||||
}
|
||||
return { ctx, languageCallbacks };
|
||||
@@ -211,13 +212,13 @@ describe("Gemini sanitising is wired into the host, and only where it belongs",
|
||||
const registration = Promise.resolve({ dispose: async () => {} });
|
||||
const ctx: Record<string, unknown> = {
|
||||
options: { baseURL: "https://gw.example.com", providerId: "omni", apiKey: "k" },
|
||||
catalog: { transform: () => registration, reload: async () => {} },
|
||||
provider: { transform: () => registration, reload: async () => {} },
|
||||
model: { transform: () => registration },
|
||||
integration: { transform: () => registration },
|
||||
aisdk: {
|
||||
language: () => {
|
||||
hook: () => {
|
||||
throw new Error("host says no");
|
||||
},
|
||||
sdk: () => registration,
|
||||
},
|
||||
};
|
||||
// Must not reject: tool-schema cleaning is an extra, the catalog is the job.
|
||||
|
||||
@@ -1,64 +1,6 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import {
|
||||
publishCatalog,
|
||||
type BinaryCompatModel,
|
||||
type BinaryCompatProvider,
|
||||
type BinaryCompatVariant,
|
||||
} from "../src/catalog.js";
|
||||
import { detectHostContract, emitsLegacyFields } from "../src/compat.js";
|
||||
|
||||
/**
|
||||
* A host seed shape. `legacy` mirrors `Provider.Info.empty` as observed on
|
||||
* `@opencode-ai/cli` 0.0.0-beta-17823; `sdk` mirrors the pinned SDK contract;
|
||||
* `bare` is a host that discloses neither.
|
||||
*/
|
||||
type SeedKind = "legacy" | "sdk" | "bare";
|
||||
|
||||
function providerSeed(id: string, kind: SeedKind): ProviderV2Info {
|
||||
if (kind === "legacy") {
|
||||
return { id, name: id, activation: "auto", package: "" } as unknown as ProviderV2Info;
|
||||
}
|
||||
if (kind === "sdk") {
|
||||
return { id, name: id, api: { type: "aisdk", package: "", url: "" } } as ProviderV2Info;
|
||||
}
|
||||
return { id } as ProviderV2Info;
|
||||
}
|
||||
|
||||
function fakeDraft(kind: SeedKind): {
|
||||
draft: CatalogDraft;
|
||||
providers: Map<string, ProviderV2Info>;
|
||||
models: Map<string, ModelV2Info>;
|
||||
} {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const draft = {
|
||||
provider: {
|
||||
list: () => [],
|
||||
get: (id: string) => providers.get(id) as never,
|
||||
update: (id: string, fn: (p: ProviderV2Info) => void) => {
|
||||
const p = providers.get(id) ?? providerSeed(id, kind);
|
||||
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 CatalogDraft;
|
||||
return { draft, providers, models };
|
||||
}
|
||||
import { buildProviderPayload, collectCatalog } from "../src/catalog.js";
|
||||
|
||||
const baseOpts = {
|
||||
providerId: "omniroute",
|
||||
@@ -74,91 +16,49 @@ const rawModel = {
|
||||
capabilities: { effort_tiers: ["low", "high"] },
|
||||
};
|
||||
|
||||
async function publish(kind: SeedKind) {
|
||||
const { draft, providers, models } = fakeDraft(kind);
|
||||
await publishCatalog(draft, baseOpts, {
|
||||
async function publish() {
|
||||
const collected = await collectCatalog(baseOpts, {
|
||||
fetcher: async () => [rawModel],
|
||||
combosFetcher: async () => [],
|
||||
});
|
||||
const provider = providers.get("omniroute");
|
||||
const model = models.get("omniroute/af/chat-latest");
|
||||
assert.ok(provider, "provider must be published");
|
||||
const payload = buildProviderPayload(collected, baseOpts);
|
||||
assert.equal(collected.counts.models, 1);
|
||||
const model = payload.models[0] as unknown as Record<string, any>;
|
||||
assert.ok(model, "model must be published");
|
||||
return { provider: provider as BinaryCompatProvider, model: model as BinaryCompatModel };
|
||||
return { info: payload.info as unknown as Record<string, any>, model };
|
||||
}
|
||||
|
||||
describe("host contract detection", () => {
|
||||
it("reads the contract off the seeded object, not off a version", () => {
|
||||
assert.equal(detectHostContract({ id: "x", package: "" }), "legacy-package");
|
||||
assert.equal(detectHostContract({ id: "x", api: { type: "aisdk" } }), "sdk-api");
|
||||
assert.equal(detectHostContract({ id: "x" }), "unknown");
|
||||
assert.equal(detectHostContract({ id: "x", api: {}, package: "" }), "unknown");
|
||||
assert.equal(detectHostContract(undefined), "unknown");
|
||||
assert.equal(detectHostContract("nope"), "unknown");
|
||||
describe("stable payload", () => {
|
||||
it("publishes one provider info with endpoint plus key", async () => {
|
||||
const { info } = await publish();
|
||||
assert.equal(info.id, "omniroute");
|
||||
assert.equal(info.package, "@opencode/ai/providers/openai-compatible");
|
||||
assert.equal((info.settings as Record<string, unknown>).baseURL, "https://gw.example.com/v1");
|
||||
assert.equal((info.settings as Record<string, unknown>).apiKey, "k");
|
||||
});
|
||||
|
||||
it("publishes the legacy fields for every contract but the sdk one", () => {
|
||||
assert.equal(emitsLegacyFields("legacy-package"), true);
|
||||
assert.equal(emitsLegacyFields("unknown"), true);
|
||||
assert.equal(emitsLegacyFields("sdk-api"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy-package host (cli 0.0.0-beta-17823)", () => {
|
||||
it("publishes package and settings.baseURL on the provider", async () => {
|
||||
const { provider } = await publish("legacy");
|
||||
assert.equal(provider.api.type, "aisdk");
|
||||
assert.equal(provider.package, "aisdk:@ai-sdk/openai-compatible");
|
||||
assert.equal(provider.settings.baseURL, "https://gw.example.com/v1");
|
||||
});
|
||||
|
||||
it("publishes package, settings.baseURL and headers on the model", async () => {
|
||||
const { model } = await publish("legacy");
|
||||
if (model.api.type !== "aisdk") throw new Error("model api must be aisdk");
|
||||
assert.equal(model.package, `aisdk:${model.api.package}`);
|
||||
assert.equal(model.package, "aisdk:@ai-sdk/openai-compatible");
|
||||
assert.equal(model.settings.baseURL, model.api.url);
|
||||
assert.deepEqual(model.headers, model.request.headers);
|
||||
});
|
||||
|
||||
it("publishes each variant in both shapes", async () => {
|
||||
const { model } = await publish("legacy");
|
||||
const variants = model.variants as BinaryCompatVariant[];
|
||||
it("publishes each model with package, endpoint, variants", async () => {
|
||||
const { model } = await publish();
|
||||
assert.equal(model.package, "@opencode/ai/providers/openai-compatible");
|
||||
assert.equal(
|
||||
(model.settings as Record<string, unknown>).baseURL,
|
||||
"https://gw.example.com/v1"
|
||||
);
|
||||
assert.ok(model.headers !== undefined);
|
||||
const variants = model.variants as Array<{
|
||||
id: string;
|
||||
settings: unknown;
|
||||
body: unknown;
|
||||
headers: unknown;
|
||||
}>;
|
||||
assert.deepEqual(
|
||||
variants.map((v) => v.id),
|
||||
["low", "high"]
|
||||
);
|
||||
for (const variant of variants) {
|
||||
assert.deepEqual(variant.settings, { reasoningEffort: variant.id });
|
||||
// The pinned-contract shape stays intact next to the legacy one.
|
||||
assert.deepEqual(variant.body, { reasoningEffort: variant.id });
|
||||
assert.deepEqual(variant.headers, {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("sdk-api host", () => {
|
||||
it("publishes the api block only, with no legacy field", async () => {
|
||||
const { provider, model } = await publish("sdk");
|
||||
assert.equal(provider.api.type, "aisdk");
|
||||
assert.equal("package" in provider, false);
|
||||
assert.equal("settings" in provider, false);
|
||||
assert.equal("package" in model, false);
|
||||
assert.equal("settings" in model, false);
|
||||
assert.equal("headers" in model, false);
|
||||
for (const variant of model.variants) {
|
||||
assert.equal("settings" in variant, false);
|
||||
assert.deepEqual(variant.body, { reasoningEffort: variant.id });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("undisclosed host contract", () => {
|
||||
it("falls back to the superset so an unknown host still routes", async () => {
|
||||
const { provider, model } = await publish("bare");
|
||||
assert.equal(provider.package, "aisdk:@ai-sdk/openai-compatible");
|
||||
assert.equal(model.package, "aisdk:@ai-sdk/openai-compatible");
|
||||
assert.ok(model.settings.baseURL);
|
||||
assert.ok(model.api);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
||||
import plugin from "../src/index.js";
|
||||
|
||||
interface CapturedCall {
|
||||
kind: "catalog" | "integration";
|
||||
kind: "provider" | "integration";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,8 +35,12 @@ async function settle<T>(read: () => T, quietTurns = 3, timeoutMs = 5000): Promi
|
||||
|
||||
interface FakeCtx {
|
||||
options: Record<string, unknown>;
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
|
||||
provider: {
|
||||
transform: (cb: (editor: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
|
||||
reload: () => Promise<void>;
|
||||
};
|
||||
model: {
|
||||
transform: (cb: (editor: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
|
||||
};
|
||||
integration: {
|
||||
transform: (cb: (draft: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
|
||||
@@ -46,9 +50,16 @@ interface FakeCtx {
|
||||
function fakeCtx(options: Record<string, unknown>, seen: CapturedCall[]): FakeCtx {
|
||||
return {
|
||||
options,
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => unknown) => {
|
||||
seen.push({ kind: "catalog" });
|
||||
provider: {
|
||||
transform: (cb: (editor: unknown) => unknown) => {
|
||||
seen.push({ kind: "provider" });
|
||||
assert.equal(typeof cb, "function");
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {},
|
||||
},
|
||||
model: {
|
||||
transform: (cb: (editor: unknown) => unknown) => {
|
||||
assert.equal(typeof cb, "function");
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
@@ -84,7 +95,7 @@ describe("plugin-v2 entrypoint", () => {
|
||||
);
|
||||
assert.deepEqual(
|
||||
seen.map((s) => s.kind),
|
||||
["catalog", "integration"]
|
||||
["provider", "integration"]
|
||||
);
|
||||
const seen2: CapturedCall[] = [];
|
||||
const warns2: string[] = [];
|
||||
@@ -107,29 +118,66 @@ describe("plugin-v2 entrypoint", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("registers transforms synchronously: captures exist without awaiting fetch", async () => {
|
||||
const seen: CapturedCall[] = [];
|
||||
const ctx = fakeCtx({ baseURL: "https://gw.example.com" }, seen);
|
||||
const pending = (plugin as unknown as { setup: (ctx: FakeCtx) => Promise<void> }).setup(ctx);
|
||||
assert.deepEqual(
|
||||
seen.map((s) => s.kind),
|
||||
["catalog", "integration"]
|
||||
);
|
||||
await pending;
|
||||
it("setup publishes the provider payload through editor.add", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/v1/models")) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
json: async () => ({ data: [{ id: "m1" }] }),
|
||||
};
|
||||
}
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
|
||||
}) as typeof fetch;
|
||||
const added: Array<{ info: Record<string, unknown>; models: unknown[] }> = [];
|
||||
const ctx = {
|
||||
options: { baseURL: "https://gw.example.com", providerId: "payload-add", apiKey: "k" },
|
||||
provider: {
|
||||
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
|
||||
cb({
|
||||
add: (input: unknown) => {
|
||||
added.push(input as { info: Record<string, unknown>; models: unknown[] });
|
||||
},
|
||||
});
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {},
|
||||
},
|
||||
model: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
integration: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
};
|
||||
try {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
assert.equal(added.length, 1);
|
||||
assert.equal(added[0]?.info.id, "payload-add");
|
||||
});
|
||||
|
||||
it("declares key plus env methods and no oauth in the integration transform", async () => {
|
||||
const seen: CapturedCall[] = [];
|
||||
const integrationCallbacks: Array<(draft: unknown) => unknown> = [];
|
||||
const catalogCallbacks: Array<(draft: unknown) => unknown> = [];
|
||||
const providerCallbacks: Array<(editor: unknown) => unknown> = [];
|
||||
const ctx: FakeCtx = {
|
||||
options: { baseURL: "https://gw.example.com", providerId: "omniroute" },
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => unknown) => {
|
||||
seen.push({ kind: "catalog" });
|
||||
catalogCallbacks.push(cb);
|
||||
provider: {
|
||||
transform: (cb: (editor: unknown) => unknown) => {
|
||||
seen.push({ kind: "provider" });
|
||||
providerCallbacks.push(cb);
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {},
|
||||
},
|
||||
model: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
integration: {
|
||||
transform: (cb: (draft: unknown) => unknown) => {
|
||||
@@ -205,7 +253,6 @@ describe("plugin-v2 entrypoint", () => {
|
||||
return { ok: true, status: 200, statusText: "OK", json: async () => ({ data: ids }) };
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
let reloads = 0;
|
||||
const ctx = {
|
||||
options: {
|
||||
@@ -214,15 +261,18 @@ describe("plugin-v2 entrypoint", () => {
|
||||
apiKey: "k-lazy",
|
||||
modelCacheTtlMs: 1,
|
||||
},
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
provider: {
|
||||
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
|
||||
cb({ add: () => {} });
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {
|
||||
reloads += 1;
|
||||
},
|
||||
},
|
||||
model: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
integration: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
@@ -237,15 +287,6 @@ describe("plugin-v2 entrypoint", () => {
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
}
|
||||
assert.equal(catalogCallbacks.length, 1);
|
||||
const cb = catalogCallbacks[0] as (draft: unknown) => Promise<void>;
|
||||
const draft = {
|
||||
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (_pid: string, _mid: string, fn: (m: Record<string, unknown>) => void) => fn({}),
|
||||
},
|
||||
};
|
||||
await cb(draft);
|
||||
assert.equal(reloads, 0, "the first publish sets the baseline, it does not reload");
|
||||
assert.equal(modelsCall, 1);
|
||||
// The optional tier lands after that first publish and brings combos and
|
||||
@@ -253,13 +294,6 @@ describe("plugin-v2 entrypoint", () => {
|
||||
// waiting for the next refresh.
|
||||
const afterFirstUpgrade = await settle(() => reloads);
|
||||
assert.ok(afterFirstUpgrade <= 1, `at most one reload for the first upgrade, got ${reloads}`);
|
||||
await cb(draft);
|
||||
assert.equal(reloads, afterFirstUpgrade + 1, "a new model id reloads once");
|
||||
assert.equal(modelsCall, 2);
|
||||
await settle(() => reloads);
|
||||
await cb(draft);
|
||||
assert.equal(reloads, afterFirstUpgrade + 1, "an identical run never reloads");
|
||||
assert.equal(modelsCall, 3);
|
||||
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prevDataDir;
|
||||
} finally {
|
||||
|
||||
@@ -5,8 +5,20 @@ 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";
|
||||
type BetaDraft = {
|
||||
provider: {
|
||||
list?: () => unknown[];
|
||||
get?: (id: string) => unknown;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
};
|
||||
model: {
|
||||
get?: (...a: string[]) => unknown;
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
default?: { get: () => undefined; set: () => void };
|
||||
};
|
||||
};
|
||||
|
||||
const MODELS_URL = "https://gw.example.com/v1/models";
|
||||
const COMBOS_URL = "https://gw.example.com/api/combos";
|
||||
@@ -103,12 +115,17 @@ function setupHarness(options: Record<string, unknown>) {
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const ctx = {
|
||||
options,
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
provider: {
|
||||
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
|
||||
catalogCallbacks.push(async () => {
|
||||
cb({ add: () => {} });
|
||||
});
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
},
|
||||
},
|
||||
model: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
integration: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
@@ -305,14 +322,14 @@ describe("plugin-v2 management token environment source", () => {
|
||||
});
|
||||
|
||||
it("enriches the catalog from the environment token alone", async () => {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const providers = new Map<string, Record<string, any>>();
|
||||
const models = new Map<string, Record<string, any>>();
|
||||
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;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as Record<string, any>;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
@@ -320,16 +337,16 @@ describe("plugin-v2 management token environment source", () => {
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
} as unknown as CatalogDraft;
|
||||
} as unknown as BetaDraft;
|
||||
let seenCombos = "";
|
||||
let seenPricing = "";
|
||||
const res = await withIsolatedEnv("mgmt-env-token", undefined, async () =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import plugin from "../src/index.js";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
import { collectCatalog } from "../src/catalog.js";
|
||||
|
||||
const MODELS_URL = "https://gw.example.com/v1/models";
|
||||
const COMBOS_URL = "https://gw.example.com/api/combos";
|
||||
@@ -24,21 +24,24 @@ function silence() {
|
||||
}
|
||||
|
||||
function setup(options: Record<string, unknown>, reload?: () => Promise<void>) {
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const added: unknown[] = [];
|
||||
const ctx = {
|
||||
options,
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
provider: {
|
||||
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
|
||||
cb({ add: (input: unknown) => added.push(input) });
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
...(reload ? { reload } : {}),
|
||||
},
|
||||
model: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
integration: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
};
|
||||
return { catalogCallbacks, ctx };
|
||||
return { added, ctx };
|
||||
}
|
||||
|
||||
function stubDraft() {
|
||||
@@ -78,16 +81,15 @@ describe("plugin-v2 managementReadToken wiring (F1)", () => {
|
||||
}) as typeof fetch;
|
||||
const guard = silence();
|
||||
try {
|
||||
const { catalogCallbacks, ctx } = setup({
|
||||
const { added, ctx } = setup({
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "omniroute",
|
||||
apiKey: "chat-key",
|
||||
managementReadToken: "mgmt-key",
|
||||
});
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
assert.ok(published.has("omniroute/m1"));
|
||||
const ids = (added as Array<{ models: Array<{ id: string }> }>).flatMap((a) => a.models.map((m) => m.id));
|
||||
assert.ok(ids.includes("m1"));
|
||||
assert.equal(seen.get(COMBOS_URL), "Bearer mgmt-key");
|
||||
assert.equal(seen.get(MODELS_URL), "Bearer chat-key");
|
||||
} finally {
|
||||
@@ -117,14 +119,12 @@ describe("plugin-v2 managementReadToken wiring (F1)", () => {
|
||||
}) as typeof fetch;
|
||||
const guard = silence();
|
||||
try {
|
||||
const { catalogCallbacks, ctx } = setup({
|
||||
const { ctx } = setup({
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "omniroute",
|
||||
apiKey: "chat-key",
|
||||
});
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
assert.equal(seen.get(COMBOS_URL), "Bearer chat-key");
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
@@ -132,16 +132,9 @@ describe("plugin-v2 managementReadToken wiring (F1)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("publishCatalog routes combosFetcher to managementReadToken, models to apiKey", async () => {
|
||||
it("collectCatalog routes combosFetcher to managementReadToken, models to apiKey", async () => {
|
||||
const calls: Array<[string, string]> = [];
|
||||
const draft = {
|
||||
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (_p: string, _m: string, fn: (m: Record<string, unknown>) => void) => fn({}),
|
||||
},
|
||||
};
|
||||
const res = await publishCatalog(
|
||||
draft as never,
|
||||
const collected = await collectCatalog(
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
@@ -162,6 +155,7 @@ describe("plugin-v2 managementReadToken wiring (F1)", () => {
|
||||
},
|
||||
}
|
||||
);
|
||||
const res = collected.counts;
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
assert.deepEqual(calls, [
|
||||
["models", "chat-key"],
|
||||
@@ -201,26 +195,52 @@ describe("plugin-v2 fail-closed models (F2)", () => {
|
||||
}) as typeof fetch;
|
||||
const guard = silence();
|
||||
try {
|
||||
const { catalogCallbacks, ctx } = setup({
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "f2-keep",
|
||||
apiKey: "k-f2",
|
||||
modelCacheTtlMs: 1,
|
||||
});
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const first = stubDraft();
|
||||
await catalogCallbacks[0](first.draft);
|
||||
assert.ok(first.published.has("f2-keep/m1"), "first refresh must publish m1");
|
||||
const firstAdded: unknown[] = [];
|
||||
const firstCtx = {
|
||||
options: {
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "f2-keep",
|
||||
apiKey: "k-f2",
|
||||
modelCacheTtlMs: 1,
|
||||
},
|
||||
provider: {
|
||||
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
|
||||
cb({ add: (input: unknown) => firstAdded.push(input) });
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {},
|
||||
},
|
||||
model: { transform: () => Promise.resolve({ dispose: async () => {} }) },
|
||||
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
|
||||
};
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(firstCtx);
|
||||
const firstIds = (firstAdded as Array<{ models: Array<{ id: string }> }>).flatMap((a) => a.models.map((m) => m.id));
|
||||
assert.ok(firstIds.includes("m1"), "first refresh must publish m1");
|
||||
const { setTimeout: sleep } = await import("node:timers/promises");
|
||||
await sleep(5);
|
||||
const second = stubDraft();
|
||||
await catalogCallbacks[0](second.draft);
|
||||
const secondAdded: unknown[] = [];
|
||||
const secondCtx = {
|
||||
options: {
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "f2-keep",
|
||||
apiKey: "k-f2",
|
||||
modelCacheTtlMs: 1,
|
||||
},
|
||||
provider: {
|
||||
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
|
||||
cb({ add: (input: unknown) => secondAdded.push(input) });
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {},
|
||||
},
|
||||
model: { transform: () => Promise.resolve({ dispose: async () => {} }) },
|
||||
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
|
||||
};
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(secondCtx);
|
||||
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prevDataDir;
|
||||
assert.ok(
|
||||
second.published.has("f2-keep/m1"),
|
||||
"empty models fetch must reuse last-known catalog"
|
||||
);
|
||||
const secondIds = (secondAdded as Array<{ models: Array<{ id: string }> }>).flatMap((a) => a.models.map((m) => m.id));
|
||||
assert.ok(secondIds.includes("m1"), "empty models fetch must reuse last-known catalog");
|
||||
assert.ok(
|
||||
guard.warns.some((w) => w.includes("keeping last-known catalog")),
|
||||
`expected keep-last-known warn, got: ${JSON.stringify(guard.warns)}`
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
type BetaDraft = {
|
||||
provider: {
|
||||
list?: () => unknown[];
|
||||
get?: (id: string) => unknown;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
};
|
||||
model: {
|
||||
get?: (...a: string[]) => unknown;
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
default?: { get: () => undefined; set: () => void };
|
||||
};
|
||||
};
|
||||
|
||||
interface Captured {
|
||||
models: Map<string, ModelV2Info>;
|
||||
draft: CatalogDraft;
|
||||
models: Map<string, Record<string, any>>;
|
||||
draft: BetaDraft;
|
||||
warns: string[];
|
||||
restore: () => void;
|
||||
}
|
||||
|
||||
function fakeDraft(): Captured {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const providers = new Map<string, Record<string, any>>();
|
||||
const models = new Map<string, Record<string, any>>();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
@@ -23,8 +35,8 @@ function fakeDraft(): Captured {
|
||||
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;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as Record<string, any>;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
@@ -32,16 +44,16 @@ function fakeDraft(): Captured {
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
} as CatalogDraft;
|
||||
};
|
||||
return {
|
||||
models,
|
||||
draft,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createReadStream } from "node:fs";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
import {
|
||||
mapComboToModelV2 as sharedMapCombo,
|
||||
@@ -12,6 +10,20 @@ import {
|
||||
type OmniRouteRawCombo,
|
||||
type OmniRouteRawModelEntry,
|
||||
} from "../src/shared/index.js";
|
||||
type BetaDraft = {
|
||||
provider: {
|
||||
list?: () => unknown[];
|
||||
get?: (id: string) => unknown;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
};
|
||||
model: {
|
||||
get?: (...a: string[]) => unknown;
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
default?: { get: () => undefined; set: () => void };
|
||||
};
|
||||
};
|
||||
|
||||
interface Fixture {
|
||||
models: OmniRouteRawModelEntry[];
|
||||
@@ -56,16 +68,16 @@ async function loadV1Parity(): Promise<V1Parity> {
|
||||
type ApiAuth = { type: "api"; key: string };
|
||||
|
||||
function fakeDraft() {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const providers = new Map<string, Record<string, any>>();
|
||||
const models = new Map<string, Record<string, any>>();
|
||||
return {
|
||||
providers,
|
||||
models,
|
||||
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;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as Record<string, any>;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
@@ -73,9 +85,9 @@ function fakeDraft() {
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
@@ -116,7 +128,7 @@ describe("v1-vs-v2 catalog parity", () => {
|
||||
assert.equal(counts.combos, 2);
|
||||
assert.equal(counts.autoCombos, 0);
|
||||
|
||||
// Final converted ModelV2Info shape (legacy→info boundary in
|
||||
// Final converted Record<string, any> shape (legacy→info boundary in
|
||||
// src/catalog.ts assignModelFields): api resolves to the
|
||||
// openai-compatible AISDK block, capabilities fold tool_calling into
|
||||
// tools, cost is zeroed (pricing lives server-side).
|
||||
@@ -221,4 +233,4 @@ describe("v1-vs-v2 catalog parity", () => {
|
||||
});
|
||||
|
||||
void (0 as unknown as ApiAuth);
|
||||
void (0 as unknown as CatalogDraft);
|
||||
void (0 as unknown as BetaDraft);
|
||||
|
||||
@@ -5,11 +5,10 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import plugin from "../src/index.js";
|
||||
|
||||
// Guard around `publishCatalog` in the catalog transform: fetcher-level
|
||||
// fail-open covers fetch rejections, but a mapper throw or a host throw in
|
||||
// `draft.update` would reject the transform callback (unhandled rejection).
|
||||
// The guard must warn + resolve instead.
|
||||
describe("plugin-v2 publish guard (mapper/draft throws)", () => {
|
||||
// Guard around the provider publish: fetcher-level fail-open covers fetch
|
||||
// rejections, but a mapper throw or a host throw in `editor.add` would reject
|
||||
// the setup (unhandled rejection). The guard must warn + resolve instead.
|
||||
describe("plugin-v2 publish guard (mapper/host throws)", () => {
|
||||
function isolateDisk(): () => void {
|
||||
const dir = mkdtempSync(join(tmpdir(), "omniroute-guard-"));
|
||||
const prev = process.env.OPENCODE_DATA_DIR;
|
||||
@@ -19,24 +18,26 @@ describe("plugin-v2 publish guard (mapper/draft throws)", () => {
|
||||
else process.env.OPENCODE_DATA_DIR = prev;
|
||||
};
|
||||
}
|
||||
function setupCtx(): {
|
||||
catalogCallbacks: Array<(draft: unknown) => Promise<void>>;
|
||||
function setupCtx(add: (input: unknown) => void): {
|
||||
ctx: Record<string, unknown>;
|
||||
} {
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const ctx = {
|
||||
options: { baseURL: "https://gw.example.com", providerId: "omniroute", apiKey: "k" },
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
provider: {
|
||||
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
|
||||
cb({ add });
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {},
|
||||
},
|
||||
model: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
integration: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
};
|
||||
return { catalogCallbacks, ctx };
|
||||
return { ctx };
|
||||
}
|
||||
|
||||
function stubFetch(): typeof fetch {
|
||||
@@ -74,25 +75,17 @@ describe("plugin-v2 publish guard (mapper/draft throws)", () => {
|
||||
}
|
||||
}
|
||||
|
||||
it("host throw in draft.model.update: callback resolves + warn, never rejects", async () => {
|
||||
it("host throw in editor.add: setup resolves + warns, never rejects", async () => {
|
||||
const restoreDisk = isolateDisk();
|
||||
const { catalogCallbacks, ctx } = setupCtx();
|
||||
const { ctx } = setupCtx(() => {
|
||||
throw new Error("host boom");
|
||||
});
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch();
|
||||
try {
|
||||
const { warns } = await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
assert.equal(catalogCallbacks.length, 1);
|
||||
const draft = {
|
||||
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: () => {
|
||||
throw new Error("host boom");
|
||||
},
|
||||
},
|
||||
};
|
||||
// MUST resolve — without the guard this rejects with "host boom".
|
||||
await catalogCallbacks[0](draft);
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
});
|
||||
assert.ok(
|
||||
warns.some((w) => w.includes("catalog publish failed") && w.includes("host boom")),
|
||||
@@ -103,35 +96,4 @@ describe("plugin-v2 publish guard (mapper/draft throws)", () => {
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
|
||||
it("host throw in draft.provider.update: callback resolves + warn, never rejects", async () => {
|
||||
const restoreDisk = isolateDisk();
|
||||
const { catalogCallbacks, ctx } = setupCtx();
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch();
|
||||
try {
|
||||
const { warns } = await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const draft = {
|
||||
provider: {
|
||||
update: () => {
|
||||
throw new Error("provider host boom");
|
||||
},
|
||||
},
|
||||
model: {
|
||||
update: (_pid: string, _mid: string, fn: (m: Record<string, unknown>) => void) =>
|
||||
fn({}),
|
||||
},
|
||||
};
|
||||
await catalogCallbacks[0](draft);
|
||||
});
|
||||
assert.ok(
|
||||
warns.some((w) => w.includes("catalog publish failed")),
|
||||
`expected a publish-guard warn, got: ${JSON.stringify(warns)}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
restoreDisk();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,8 @@ import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import plugin from "../src/index.js";
|
||||
|
||||
// RED: reproduces the PROD unhandled rejection — combos 403 must not escape
|
||||
// the catalog transform. Today `loadSnapshot()` awaits
|
||||
// `Promise.all([models, combos])` with no catch, so a 403 combos fetch
|
||||
// rejects the snapshot promise and the rejection propagates out of the
|
||||
// `ctx.catalog.transform` callback (fail-open in `publishCatalog` is
|
||||
// bypassed because injected fetchers return the already-rejected data).
|
||||
// Fail-open refresh: a combos 403/500/abort must not escape setup. Setup
|
||||
// resolves with a models-only provider payload plus a combos warn.
|
||||
describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
|
||||
let diskSeq = 0;
|
||||
async function isolateDisk(): Promise<() => void> {
|
||||
@@ -27,31 +23,33 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
|
||||
combosStatus: number;
|
||||
modelsStatus?: number;
|
||||
reloads: { count: number };
|
||||
added: unknown[];
|
||||
}): {
|
||||
catalogCallbacks: Array<(draft: unknown) => Promise<void>>;
|
||||
ctx: Record<string, unknown>;
|
||||
} {
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const ctx = {
|
||||
options: {
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "fo-" + String(opts.combosStatus) + "-" + String(opts.modelsStatus ?? 200),
|
||||
apiKey: "k-fo-" + String(opts.combosStatus),
|
||||
},
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
provider: {
|
||||
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
|
||||
cb({ add: (input: unknown) => opts.added.push(input) });
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {
|
||||
opts.reloads.count += 1;
|
||||
},
|
||||
},
|
||||
model: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
integration: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
};
|
||||
return { catalogCallbacks, ctx };
|
||||
return { ctx };
|
||||
}
|
||||
|
||||
function stubFetch(opts: { combosStatus: number; modelsStatus?: number }): typeof fetch {
|
||||
@@ -78,24 +76,6 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
function stubDraft(): {
|
||||
draft: unknown;
|
||||
published: Map<string, Record<string, unknown>>;
|
||||
} {
|
||||
const published = new Map<string, Record<string, unknown>>();
|
||||
const draft = {
|
||||
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
|
||||
const entry: Record<string, unknown> = { id: mid, providerID: pid };
|
||||
fn(entry);
|
||||
published.set(pid + "/" + mid, entry);
|
||||
},
|
||||
},
|
||||
};
|
||||
return { draft, published };
|
||||
}
|
||||
|
||||
async function silenceConsole<T>(fn: () => Promise<T>): Promise<{ result: T; warns: string[] }> {
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
@@ -113,23 +93,29 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
|
||||
}
|
||||
}
|
||||
|
||||
it("combos 403: catalog callback resolves (models-only + warn), never rejects", async () => {
|
||||
function modelIds(added: unknown[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of added) {
|
||||
const models = (entry as { models?: Array<{ id?: unknown }> }).models ?? [];
|
||||
for (const m of models) out.push(String(m.id));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
it("combos 403: setup resolves (models-only + warn), never rejects", async () => {
|
||||
const restoreDisk = await isolateDisk();
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 403, reloads });
|
||||
const added: unknown[] = [];
|
||||
const { ctx } = setupCtx({ combosStatus: 403, reloads, added });
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ combosStatus: 403 });
|
||||
try {
|
||||
const { warns } = await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
assert.equal(catalogCallbacks.length, 1);
|
||||
const { draft, published } = stubDraft();
|
||||
// MUST resolve — today it rejects with the 403 error.
|
||||
await catalogCallbacks[0](draft);
|
||||
const key = [...published.keys()].find((k) => k.endsWith("/m1"));
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
assert.ok(
|
||||
key,
|
||||
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
|
||||
modelIds(added).includes("m1"),
|
||||
`models-only fallback must publish m1, got: ${JSON.stringify(modelIds(added))}`
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
@@ -142,21 +128,19 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("combos 500: catalog callback resolves (models-only + warn), never rejects", async () => {
|
||||
it("combos 500: setup resolves (models-only + warn), never rejects", async () => {
|
||||
const restoreDisk = await isolateDisk();
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 500, reloads });
|
||||
const added: unknown[] = [];
|
||||
const { ctx } = setupCtx({ combosStatus: 500, reloads, added });
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ combosStatus: 500 });
|
||||
try {
|
||||
const { warns } = await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
const key = [...published.keys()].find((k) => k.endsWith("/m1"));
|
||||
assert.ok(
|
||||
key,
|
||||
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
|
||||
modelIds(added).includes("m1"),
|
||||
`models-only fallback must publish m1, got: ${JSON.stringify(modelIds(added))}`
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
@@ -169,10 +153,11 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("combos timeout (abort): catalog callback resolves, never rejects", async () => {
|
||||
it("combos timeout (abort): setup resolves, never rejects", async () => {
|
||||
const restoreDisk = await isolateDisk();
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 200, reloads });
|
||||
const added: unknown[] = [];
|
||||
const { ctx } = setupCtx({ combosStatus: 200, reloads, added });
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const href = String(url);
|
||||
@@ -194,12 +179,9 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
|
||||
try {
|
||||
const { warns } = await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
await catalogCallbacks[0](draft);
|
||||
const key = [...published.keys()].find((k) => k.endsWith("/m1"));
|
||||
assert.ok(
|
||||
key,
|
||||
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
|
||||
modelIds(added).includes("m1"),
|
||||
`models-only fallback must publish m1, got: ${JSON.stringify(modelIds(added))}`
|
||||
);
|
||||
});
|
||||
assert.ok(
|
||||
|
||||
69
@omniroute/opencode-plugin-v2/tests/root-entrypoint.test.ts
Normal file
69
@omniroute/opencode-plugin-v2/tests/root-entrypoint.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { PLUGIN_ID } from "../src/options.js";
|
||||
|
||||
// OpenCode 2.0.12 local installs (`file://` directory) never read
|
||||
// package.json `main`/`exports`: the config scan (`dm({directory})`) only
|
||||
// probes the subpaths ["server", ""] then ["tui"], ["rpc"] via resolveModule.
|
||||
// With only `dist/index.js` present the scan yields `{}` and the plugin is
|
||||
// silently dropped — no `loading plugin`, no error. The package root must
|
||||
// therefore expose a `server.*` entrypoint re-exporting the built plugin.
|
||||
const testsDir = dirname(fileURLToPath(import.meta.url));
|
||||
const pkgDir = resolve(testsDir, "..");
|
||||
const serverEntry = join(pkgDir, "server.js");
|
||||
const distEntry = join(pkgDir, "dist", "index.js");
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
describe("root server entrypoint (opencode file:// installs)", () => {
|
||||
it("ships a root server.js re-exporting the built plugin", () => {
|
||||
assert.ok(
|
||||
existsSync(serverEntry),
|
||||
`missing root entrypoint: ${serverEntry} (opencode only probes server.*/index.* at the package root, dist/ alone is invisible)`
|
||||
);
|
||||
const content = readFileSync(serverEntry, "utf8");
|
||||
assert.ok(content.includes("./dist/index.js"), "server.js must re-export ./dist/index.js");
|
||||
assert.ok(content.includes("export"), "server.js must re-export the plugin");
|
||||
});
|
||||
|
||||
it("package.json files ships the root entrypoint", () => {
|
||||
const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8")) as {
|
||||
files?: string[];
|
||||
};
|
||||
assert.ok(
|
||||
Array.isArray(pkg.files) && pkg.files.includes("server.js"),
|
||||
`package.json "files" must include "server.js", got: ${JSON.stringify(pkg.files)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("host-style probe require.resolve(<root>/server) finds the entrypoint", () => {
|
||||
// Emulates the ["server", ""] probe order: "server" must resolve before
|
||||
// the bare-directory fallback (which reads package.json main).
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = require.resolve(join(pkgDir, "server"));
|
||||
} catch {
|
||||
assert.fail(`host probe for "server" found nothing under ${pkgDir}`);
|
||||
}
|
||||
assert.ok(
|
||||
resolved === serverEntry || resolved.endsWith(join("opencode-plugin-v2", "server.js")),
|
||||
`probe must resolve to the root server.js, got: ${resolved}`
|
||||
);
|
||||
});
|
||||
|
||||
it(
|
||||
"root entrypoint exposes the plugin (id + setup)",
|
||||
{ skip: !existsSync(distEntry) ? "dist not built — run npm run build first" : false },
|
||||
async () => {
|
||||
const mod = (await import(pathToFileURL(serverEntry).href)) as {
|
||||
default?: { id?: unknown; setup?: unknown };
|
||||
};
|
||||
assert.ok(mod.default, "server.js must have a default export");
|
||||
assert.equal(mod.default?.id, PLUGIN_ID);
|
||||
assert.equal(typeof mod.default?.setup, "function");
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -3,8 +3,20 @@ import assert from "node:assert/strict";
|
||||
import { mapRawModelToModelV2, resolveApiBlockV2 } from "../src/shared/models-map.js";
|
||||
import { parsePluginOptions } from "../src/options.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";
|
||||
type BetaDraft = {
|
||||
provider: {
|
||||
list?: () => unknown[];
|
||||
get?: (id: string) => unknown;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
};
|
||||
model: {
|
||||
get?: (...a: string[]) => unknown;
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
default?: { get: () => undefined; set: () => void };
|
||||
};
|
||||
};
|
||||
|
||||
const GW = "https://gw.example.com";
|
||||
const PREFIXES = ["cc", "claude", "anthropic", "kiro", "kr"];
|
||||
@@ -100,8 +112,8 @@ describe("deprecated anthropicPrefixes", () => {
|
||||
});
|
||||
|
||||
it("copied v1 config routes anthropic and warns deprecation through publishCatalog", async () => {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
const providers = new Map<string, Record<string, any>>();
|
||||
const models = new Map<string, Record<string, any>>();
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
@@ -116,8 +128,8 @@ describe("deprecated anthropicPrefixes", () => {
|
||||
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;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as Record<string, any>;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
@@ -125,18 +137,18 @@ describe("deprecated anthropicPrefixes", () => {
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
} as CatalogDraft;
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
};
|
||||
const { collectCatalog, buildProviderPayload } = await import("../src/catalog.js");
|
||||
const collected = await collectCatalog(
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: GW,
|
||||
@@ -152,11 +164,18 @@ describe("deprecated anthropicPrefixes", () => {
|
||||
enrichmentFetcher: async () => new Map(),
|
||||
}
|
||||
);
|
||||
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
|
||||
const m = models.get("omniroute/cc/claude-x");
|
||||
assert.deepEqual(collected.counts, { models: 1, combos: 0, autoCombos: 0 });
|
||||
const payload = buildProviderPayload(collected, {
|
||||
providerId: "omniroute",
|
||||
baseURL: GW,
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
});
|
||||
const m = payload.models.find((x) => String((x as unknown as { id: string }).id) === "cc/claude-x") as unknown as Record<string, any>;
|
||||
assert.ok(m);
|
||||
if (m?.api.type !== "aisdk") throw new Error("model api must be aisdk");
|
||||
assert.equal(m?.api.id, "anthropic");
|
||||
assert.equal(m?.package, "@opencode/ai/providers/anthropic");
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft, PluginContext } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import plugin from "../src/index.js";
|
||||
|
||||
describe("v2 contract smoke", () => {
|
||||
describe("stable contract smoke", () => {
|
||||
it("default export has string id and function setup", () => {
|
||||
assert.equal(typeof (plugin as { id: unknown }).id, "string");
|
||||
assert.equal(typeof (plugin as { setup: unknown }).setup, "function");
|
||||
});
|
||||
it("setup registers transforms against a structurally-real ctx", async () => {
|
||||
it("setup registers transforms against a stable ctx", async () => {
|
||||
const seen: string[] = [];
|
||||
const ctx = {
|
||||
options: { baseURL: "https://gw.example.com", providerId: "omniroute" },
|
||||
catalog: {
|
||||
provider: {
|
||||
transform: async () => {
|
||||
seen.push("catalog.transform");
|
||||
seen.push("provider.transform");
|
||||
return { dispose: async () => {} };
|
||||
},
|
||||
reload: async () => {},
|
||||
},
|
||||
model: {
|
||||
transform: async () => {
|
||||
seen.push("model.transform");
|
||||
return { dispose: async () => {} };
|
||||
},
|
||||
reload: async () => {},
|
||||
@@ -28,51 +33,16 @@ describe("v2 contract smoke", () => {
|
||||
reload: async () => {},
|
||||
connection: { active: async () => undefined, resolve: async () => undefined },
|
||||
},
|
||||
agent: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
|
||||
command: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
|
||||
reference: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
|
||||
skill: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
|
||||
aisdk: {
|
||||
sdk: async () => ({ dispose: async () => {} }),
|
||||
language: async () => ({ dispose: async () => {} }),
|
||||
},
|
||||
plugin: { add: async () => {}, remove: async () => {} },
|
||||
} satisfies PluginContext;
|
||||
await (plugin as { setup: (c: PluginContext) => Promise<void> }).setup(ctx);
|
||||
assert.deepEqual(seen, ["catalog.transform", "integration.transform"]);
|
||||
});
|
||||
it("publishCatalog writes into a real CatalogDraft without proxy breakage", async () => {
|
||||
const { publishCatalog } = await import("../src/catalog.js");
|
||||
const written: { provider?: string; models: string[] } = { models: [] };
|
||||
const draft: CatalogDraft = {
|
||||
provider: {
|
||||
list: () => [],
|
||||
get: () => undefined,
|
||||
update: (id: string, fn: (p: ProviderV2Info) => void) => {
|
||||
written.provider = id;
|
||||
const p = {
|
||||
id,
|
||||
name: "",
|
||||
api: { type: "aisdk", package: "" },
|
||||
request: { headers: {}, body: {} },
|
||||
} as ProviderV2Info;
|
||||
fn(p);
|
||||
},
|
||||
remove: () => {},
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (providerID: string, modelID: string, fn: (d: ModelV2Info) => void) => {
|
||||
written.models.push(providerID + "/" + modelID);
|
||||
const d = { id: modelID, providerID } as ModelV2Info;
|
||||
fn(d);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
hook: async () => ({ dispose: async () => {} }),
|
||||
},
|
||||
};
|
||||
const res = await publishCatalog(
|
||||
draft,
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
assert.deepEqual(seen, ["provider.transform", "integration.transform"]);
|
||||
});
|
||||
it("collectCatalog plus buildProviderPayload writes one provider and its models", async () => {
|
||||
const { buildProviderPayload, collectCatalog } = await import("../src/catalog.js");
|
||||
const collected = await collectCatalog(
|
||||
{
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
@@ -86,8 +56,19 @@ describe("v2 contract smoke", () => {
|
||||
combos: async () => [],
|
||||
}
|
||||
);
|
||||
assert.equal(res.models, 1);
|
||||
assert.equal(written.provider, "omniroute");
|
||||
assert.deepEqual(written.models, ["omniroute/m1"]);
|
||||
assert.equal(collected.counts.models, 1);
|
||||
const payload = buildProviderPayload(collected, {
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k",
|
||||
timeoutMs: 1000,
|
||||
modelCacheTtlMs: 300000,
|
||||
usableOnly: false,
|
||||
});
|
||||
assert.equal((payload.info as unknown as { id: string }).id, "omniroute");
|
||||
assert.deepEqual(
|
||||
payload.models.map((m) => String((m as unknown as { id: string }).id)),
|
||||
["m1"]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,26 +26,37 @@ function isolateDisk(): { dir: string; restore: () => void } {
|
||||
}
|
||||
|
||||
function setupCtx(providerId: string): {
|
||||
callbacks: Array<(draft: unknown) => Promise<void>>;
|
||||
added: unknown[];
|
||||
ctx: Record<string, unknown>;
|
||||
} {
|
||||
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const added: unknown[] = [];
|
||||
const ctx = {
|
||||
options: {
|
||||
providerId,
|
||||
baseURL: "https://gw.example.com",
|
||||
apiKey: "k-snapfix",
|
||||
},
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
callbacks.push(cb);
|
||||
provider: {
|
||||
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
|
||||
cb({ add: (input: unknown) => added.push(input) });
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {},
|
||||
},
|
||||
model: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
|
||||
};
|
||||
return { callbacks, ctx };
|
||||
return { added, ctx };
|
||||
}
|
||||
|
||||
function publishedOf(added: unknown[]): Map<string, Record<string, unknown>> {
|
||||
const published = new Map<string, Record<string, unknown>>();
|
||||
for (const entry of added as Array<{ info: { id: string }; models: Array<Record<string, unknown>> }>) {
|
||||
for (const m of entry.models) published.set(entry.info.id + "/" + String(m.id), m);
|
||||
}
|
||||
return published;
|
||||
}
|
||||
|
||||
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
|
||||
@@ -130,11 +141,10 @@ describe("plugin-v2 snapshot stale-entry filter", () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = downFetch();
|
||||
try {
|
||||
const { callbacks, ctx } = setupCtx(providerId);
|
||||
const { added, ctx } = setupCtx(providerId);
|
||||
const { warns } = await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
await callbacks[0](draft);
|
||||
const published = publishedOf(added);
|
||||
assert.ok(
|
||||
published.has(`${providerId}/good-1`),
|
||||
`valid entry must be published, got: ${JSON.stringify([...published.keys()])}`
|
||||
@@ -187,11 +197,10 @@ describe("plugin-v2 snapshot stale-entry filter", () => {
|
||||
};
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const { callbacks, ctx } = setupCtx(providerId);
|
||||
const { added, ctx } = setupCtx(providerId);
|
||||
await silenceConsole(async () => {
|
||||
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
await callbacks[0](draft);
|
||||
const published = publishedOf(added);
|
||||
assert.ok(
|
||||
published.has(`${providerId}/fresh-1`),
|
||||
`fresh fetch must win over unversioned snapshot, got: ${JSON.stringify([...published.keys()])}`
|
||||
|
||||
47
@omniroute/opencode-plugin-v2/tests/stable-contract.test.ts
Normal file
47
@omniroute/opencode-plugin-v2/tests/stable-contract.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import plugin from "../src/index.js";
|
||||
|
||||
/**
|
||||
* RED test for the stable-contract port: setup must register against a
|
||||
* stable ctx (provider/model transforms, no catalog) without throwing the
|
||||
* beta breach. Runs against the built shape: id + setup, graceful without
|
||||
* a key.
|
||||
*/
|
||||
describe("stable contract setup", () => {
|
||||
it("setup works with provider/model transforms and no catalog", async () => {
|
||||
const seen: string[] = [];
|
||||
const ctx = {
|
||||
options: { baseURL: "https://gw.example.com", providerId: "stable-port" },
|
||||
provider: {
|
||||
list: async () => ({ data: [] }),
|
||||
transform: async (cb: (editor: unknown) => void) => {
|
||||
seen.push("provider.transform");
|
||||
assert.equal(typeof cb, "function");
|
||||
return { dispose: async () => {} };
|
||||
},
|
||||
reload: async () => {
|
||||
seen.push("provider.reload");
|
||||
},
|
||||
},
|
||||
model: {
|
||||
list: async () => ({ data: [] }),
|
||||
transform: async (cb: (editor: unknown) => void) => {
|
||||
seen.push("model.transform");
|
||||
assert.equal(typeof cb, "function");
|
||||
return { dispose: async () => {} };
|
||||
},
|
||||
reload: async () => {},
|
||||
},
|
||||
integration: {
|
||||
transform: async () => ({ dispose: async () => {} }),
|
||||
connection: { active: async () => undefined, resolve: async () => undefined },
|
||||
},
|
||||
};
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
assert.ok(
|
||||
seen.includes("provider.transform"),
|
||||
`provider.transform must be registered, got: ${JSON.stringify(seen)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,39 +29,35 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
|
||||
providerId: string,
|
||||
reloads: { count: number }
|
||||
): {
|
||||
catalogCallbacks: Array<(draft: unknown) => Promise<void>>;
|
||||
added: unknown[];
|
||||
ctx: Record<string, unknown>;
|
||||
} {
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const added: unknown[] = [];
|
||||
const ctx = {
|
||||
options: { baseURL: "https://gw.example.com", providerId, apiKey: "k-" + providerId },
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
provider: {
|
||||
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
|
||||
cb({ add: (input: unknown) => added.push(input) });
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
reload: async () => {
|
||||
reloads.count += 1;
|
||||
},
|
||||
},
|
||||
model: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
|
||||
};
|
||||
return { catalogCallbacks, ctx };
|
||||
return { added, ctx };
|
||||
}
|
||||
|
||||
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
|
||||
function publishedOf(added: unknown[]): Map<string, Record<string, unknown>> {
|
||||
const published = new Map<string, Record<string, unknown>>();
|
||||
const draft = {
|
||||
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
|
||||
const entry: Record<string, unknown> = { id: mid, providerID: pid };
|
||||
fn(entry);
|
||||
published.set(pid + "/" + mid, entry);
|
||||
},
|
||||
},
|
||||
};
|
||||
return { draft, published };
|
||||
for (const entry of added as Array<{ info: { id: string }; models: Array<Record<string, unknown>> }>) {
|
||||
for (const m of entry.models) published.set(entry.info.id + "/" + String(m.id), m);
|
||||
}
|
||||
return published;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,12 +124,10 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ autoCombosHangs: true });
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-hang", reloads);
|
||||
const { added, ctx } = setupCtx("staged-hang", reloads);
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
const done = catalogCallbacks[0]!(draft);
|
||||
const done = (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
const raced = await Promise.race([
|
||||
done.then(() => "published" as const),
|
||||
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 1500)),
|
||||
@@ -143,7 +137,7 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
|
||||
"published",
|
||||
"the publish must not wait on a source that never answers"
|
||||
);
|
||||
assert.ok([...published.keys()].some((k) => k.endsWith("/m1")));
|
||||
assert.ok([...publishedOf(added).keys()].some((k) => k.endsWith("/m1")));
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
@@ -156,19 +150,18 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ autoCombosHangs: false, enrichmentDelayMs: 120 });
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-late", reloads);
|
||||
const { added, ctx } = setupCtx("staged-late", reloads);
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
const first = stubDraft();
|
||||
await catalogCallbacks[0]!(first.draft);
|
||||
const early = [...first.published.values()].find((m) => m["id"] === "m1");
|
||||
const early = [...publishedOf(added).values()].find((m) => m["id"] === "m1");
|
||||
assert.ok(early, "models publish before the slow enrichment");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const second = stubDraft();
|
||||
await catalogCallbacks[0]!(second.draft);
|
||||
const late = [...second.published.values()].find((m) => m["id"] === "m1");
|
||||
// Re-setup refreshes the snapshot; the late enrichment lands on reload.
|
||||
added.length = 0;
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
const late = [...publishedOf(added).values()].find((m) => m["id"] === "m1");
|
||||
// The overlay is rendered, not just stored: the provider label the
|
||||
// gateway ships alongside the display name reaches the picker.
|
||||
assert.equal(
|
||||
@@ -188,13 +181,12 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ autoCombosHangs: false });
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-stable", reloads);
|
||||
const { ctx } = setupCtx("staged-stable", reloads);
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const d = stubDraft();
|
||||
await catalogCallbacks[0]!(d.draft);
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
});
|
||||
@@ -243,16 +235,15 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
|
||||
return ok({ data: [{ id: "m1" }] });
|
||||
}) as unknown as typeof fetch;
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-ttl", reloads);
|
||||
const { added, ctx } = setupCtx("staged-ttl", reloads);
|
||||
(ctx["options"] as Record<string, unknown>)["modelCacheTtlMs"] = 1;
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
await catalogCallbacks[0]!(stubDraft().draft);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const second = stubDraft();
|
||||
await catalogCallbacks[0]!(second.draft);
|
||||
const m1 = [...second.published.values()].find((m) => m["id"] === "m1");
|
||||
added.length = 0;
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
const m1 = [...publishedOf(added).values()].find((m) => m["id"] === "m1");
|
||||
assert.equal(
|
||||
m1?.["name"],
|
||||
"Omni - Model One",
|
||||
@@ -273,12 +264,10 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = stubFetch({ autoCombosHangs: false, combosHangs: true });
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-combos-hang", reloads);
|
||||
const { added, ctx } = setupCtx("staged-combos-hang", reloads);
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
const { draft, published } = stubDraft();
|
||||
const done = catalogCallbacks[0]!(draft);
|
||||
const done = (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
const raced = await Promise.race([
|
||||
done.then(() => "published" as const),
|
||||
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 1500)),
|
||||
@@ -288,14 +277,14 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
|
||||
"published",
|
||||
"models must publish without waiting for a hanging /api/combos"
|
||||
);
|
||||
assert.ok([...published.keys()].some((k) => k.endsWith("/m1")));
|
||||
assert.ok([...publishedOf(added).keys()].some((k) => k.endsWith("/m1")));
|
||||
// "staged-combos-hang" contains "combo" as a substring — filter on the
|
||||
// model id suffix instead: no published model id may start with a
|
||||
// combo prefix.
|
||||
assert.equal(
|
||||
[...published.keys()].filter((k) => /\/combo/i.test(k)).length,
|
||||
[...publishedOf(added).keys()].filter((k) => /\/combo/i.test(k)).length,
|
||||
0,
|
||||
`no combos known yet — models-only on the first publish is correct, got ${JSON.stringify([...published.keys()])}`
|
||||
`no combos known yet — models-only on the first publish is correct, got ${JSON.stringify([...publishedOf(added).keys()])}`
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
@@ -342,18 +331,17 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
|
||||
return ok({ data: [{ id: "m1" }] });
|
||||
}) as unknown as typeof fetch;
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-enrich-down", reloads);
|
||||
const { added, ctx } = setupCtx("staged-enrich-down", reloads);
|
||||
(ctx["options"] as Record<string, unknown>)["modelCacheTtlMs"] = 1;
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
await catalogCallbacks[0]!(stubDraft().draft);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
await catalogCallbacks[0]!(stubDraft().draft);
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
const third = stubDraft();
|
||||
await catalogCallbacks[0]!(third.draft);
|
||||
const m1 = [...third.published.values()].find((m) => m["id"] === "m1");
|
||||
added.length = 0;
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
const m1 = [...publishedOf(added).values()].find((m) => m["id"] === "m1");
|
||||
assert.equal(
|
||||
m1?.["name"],
|
||||
"Omni - Model One",
|
||||
@@ -393,23 +381,20 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
|
||||
return ok({ data: [{ id: "m1" }] });
|
||||
}) as unknown as typeof fetch;
|
||||
const reloads = { count: 0 };
|
||||
const { catalogCallbacks, ctx } = setupCtx("staged-unreachable", reloads);
|
||||
const { added: _addedU, ctx } = setupCtx("staged-unreachable", reloads);
|
||||
(ctx["options"] as Record<string, unknown>)["modelCacheTtlMs"] = 1;
|
||||
try {
|
||||
await withSilentConsole(async () => {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
// First transform: gateway healthy, entry stored.
|
||||
await catalogCallbacks[0]!(stubDraft().draft);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
// Gateway goes down only now: the next transform fails totally while
|
||||
// a prior entry exists, arming the cooldown.
|
||||
down = true;
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await catalogCallbacks[0]!(stubDraft().draft);
|
||||
// A fresh setup replays the same failing gateway through a new
|
||||
// closure, so it refetches once and arms its own cooldown; the
|
||||
// count assertion pins that single arming fetch.
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
const afterArming = modelCalls;
|
||||
assert.ok(afterArming >= 2, "the failing transform tries the network once");
|
||||
await catalogCallbacks[0]!(stubDraft().draft);
|
||||
assert.equal(modelCalls, afterArming, "a transform inside the cooldown must not refetch");
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
@@ -427,12 +412,17 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const ctx = {
|
||||
options: { baseURL: "https://gw.example.com", providerId: "staged-integ", apiKey: "k" },
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
provider: {
|
||||
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
|
||||
catalogCallbacks.push(async () => {
|
||||
cb({ add: () => {} });
|
||||
});
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
},
|
||||
},
|
||||
model: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
integration: {
|
||||
transform: () => {
|
||||
throw new Error("host says no");
|
||||
|
||||
133
@omniroute/opencode-plugin-v2/tests/telemetry-sdk-hook.test.ts
Normal file
133
@omniroute/opencode-plugin-v2/tests/telemetry-sdk-hook.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import plugin from "../src/index.js";
|
||||
|
||||
/**
|
||||
* Strict fallback (re-anchored): no `includeUsage` marking is proven anywhere
|
||||
* outside node_modules. The repo-verifiable facts are:
|
||||
* (a) the host mock in `gemini-language.test.ts` mounts a callable `aisdk`
|
||||
* domain the plugin reaches through `hook(name, cb)` — the domain exists
|
||||
* on the host and routes by name;
|
||||
* (b) `@opencode/plugin 2.0.12` is the pinned contract reference
|
||||
* (`package.json`), its `sdk`-event option shape is UNKNOWN here.
|
||||
* Consequence: no options-only marking is proven → strict fallback: register
|
||||
* the hook (domain exists, probed at runtime) and record the observation in
|
||||
* `options` only; the pure telemetry core stays unported and unimported.
|
||||
*
|
||||
* Host shape: the stable `@opencode/plugin` contract publishes the catalog
|
||||
* through `ctx.provider.transform` (`assertContext` requires the provider and
|
||||
* model transforms) and exposes one named `ctx.aisdk.hook(name, cb)` entry
|
||||
* point instead of a callable domain per event. The fake below mirrors that;
|
||||
* the five properties it asserts are unchanged.
|
||||
*/
|
||||
|
||||
interface SdkInput {
|
||||
model: { id: string; providerID: string };
|
||||
package: string;
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function hostCtx(opts: {
|
||||
telemetry?: boolean;
|
||||
withAisdk?: boolean;
|
||||
sdkImpl?: (
|
||||
cb: (input: SdkInput) => void | Promise<void>
|
||||
) => Promise<{ dispose: () => Promise<void> }>;
|
||||
}): {
|
||||
ctx: Record<string, unknown>;
|
||||
sdkCallbacks: Array<(input: SdkInput) => void | Promise<void>>;
|
||||
} {
|
||||
const sdkCallbacks: Array<(input: SdkInput) => void | Promise<void>> = [];
|
||||
const registration = Promise.resolve({ dispose: async () => {} });
|
||||
const options: Record<string, unknown> = {
|
||||
baseURL: "https://gw.example.com",
|
||||
providerId: "omni",
|
||||
apiKey: "k",
|
||||
};
|
||||
if (opts.telemetry !== undefined) options["telemetry"] = opts.telemetry;
|
||||
const ctx: Record<string, unknown> = {
|
||||
options,
|
||||
provider: { transform: () => registration, reload: async () => {} },
|
||||
model: { transform: () => registration },
|
||||
integration: { transform: () => registration },
|
||||
};
|
||||
if (opts.withAisdk !== false) {
|
||||
ctx["aisdk"] = {
|
||||
hook: (name: string, cb: (input: SdkInput) => void | Promise<void>) => {
|
||||
// The stable host routes every aisdk event through one entry point;
|
||||
// "language" is the Gemini sanitiser, only "sdk" is this test's subject.
|
||||
if (name !== "sdk") return registration;
|
||||
if (opts.sdkImpl !== undefined) return opts.sdkImpl(cb);
|
||||
sdkCallbacks.push(cb);
|
||||
return registration;
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ctx, sdkCallbacks };
|
||||
}
|
||||
|
||||
async function setupQuiet(ctx: Record<string, unknown>): Promise<void> {
|
||||
const warn = console.warn;
|
||||
const log = console.log;
|
||||
console.warn = () => {};
|
||||
console.log = () => {};
|
||||
try {
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
console.log = log;
|
||||
}
|
||||
}
|
||||
|
||||
describe("aisdk.sdk telemetry hook (parity, option off by default)", () => {
|
||||
it("an older host without the aisdk domain still loads (catalog only)", async () => {
|
||||
const { ctx } = hostCtx({ telemetry: true, withAisdk: false });
|
||||
await setupQuiet(ctx);
|
||||
});
|
||||
|
||||
it("registers nothing when the option is off (default)", async () => {
|
||||
const { ctx, sdkCallbacks } = hostCtx({});
|
||||
await setupQuiet(ctx);
|
||||
assert.equal(sdkCallbacks.length, 0, "telemetry off must not touch aisdk.sdk");
|
||||
});
|
||||
|
||||
it("registers the hook when the option is on and the domain exists", async () => {
|
||||
const { ctx, sdkCallbacks } = hostCtx({ telemetry: true });
|
||||
await setupQuiet(ctx);
|
||||
assert.equal(sdkCallbacks.length, 1, "telemetry on must register aisdk.sdk");
|
||||
});
|
||||
|
||||
it("ignores models from other providers and marks only its own (options-only, no fetch)", async () => {
|
||||
const { ctx, sdkCallbacks } = hostCtx({ telemetry: true });
|
||||
await setupQuiet(ctx);
|
||||
assert.equal(sdkCallbacks.length, 1);
|
||||
const foreign: SdkInput = {
|
||||
model: { id: "m", providerID: "some-other-provider" },
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: {},
|
||||
};
|
||||
await sdkCallbacks[0]!(foreign);
|
||||
assert.deepEqual(foreign.options, {}, "another provider's options are untouched");
|
||||
const own: SdkInput = {
|
||||
model: { id: "m", providerID: "omni" },
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: {},
|
||||
};
|
||||
await sdkCallbacks[0]!(own);
|
||||
assert.equal(
|
||||
own.options["telemetry"],
|
||||
true,
|
||||
"own models carry an options-only telemetry mark, never a wrapped fetch"
|
||||
);
|
||||
});
|
||||
|
||||
it("a host that refuses the sdk hook still keeps its catalog", async () => {
|
||||
const { ctx } = hostCtx({
|
||||
telemetry: true,
|
||||
sdkImpl: () => {
|
||||
throw new Error("refused");
|
||||
},
|
||||
});
|
||||
await setupQuiet(ctx);
|
||||
});
|
||||
});
|
||||
@@ -2,18 +2,30 @@ import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parsePluginOptions, resolveTimeouts } from "../src/options.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";
|
||||
type BetaDraft = {
|
||||
provider: {
|
||||
list?: () => unknown[];
|
||||
get?: (id: string) => unknown;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
};
|
||||
model: {
|
||||
get?: (...a: string[]) => unknown;
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
default?: { get: () => undefined; set: () => void };
|
||||
};
|
||||
};
|
||||
|
||||
function fakeDraft(): CatalogDraft {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
function fakeDraft(): BetaDraft {
|
||||
const providers = new Map<string, Record<string, any>>();
|
||||
const models = new Map<string, Record<string, any>>();
|
||||
return {
|
||||
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;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as Record<string, any>;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
@@ -21,16 +33,16 @@ function fakeDraft(): CatalogDraft {
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
} as CatalogDraft;
|
||||
};
|
||||
}
|
||||
|
||||
const BER = "https://gw.example.com";
|
||||
@@ -124,7 +136,11 @@ describe("plugin-v2 P2 parity: per-endpoint timeouts", () => {
|
||||
};
|
||||
const ctx = {
|
||||
options,
|
||||
catalog: {
|
||||
provider: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
reload: async () => {},
|
||||
},
|
||||
model: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
integration: {
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
|
||||
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
|
||||
import { publishCatalog } from "../src/catalog.js";
|
||||
import { parsePluginOptions } from "../src/options.js";
|
||||
type BetaDraft = {
|
||||
provider: {
|
||||
list?: () => unknown[];
|
||||
get?: (id: string) => unknown;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
};
|
||||
model: {
|
||||
get?: (...a: string[]) => unknown;
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
|
||||
remove?: () => void;
|
||||
default?: { get: () => undefined; set: () => void };
|
||||
};
|
||||
};
|
||||
|
||||
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
|
||||
const providers = new Map<string, ProviderV2Info>();
|
||||
const models = new Map<string, ModelV2Info>();
|
||||
function fakeDraft(): { models: Map<string, Record<string, any>>; draft: BetaDraft } {
|
||||
const providers = new Map<string, Record<string, any>>();
|
||||
const models = new Map<string, Record<string, any>>();
|
||||
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;
|
||||
update: (id: string, fn: (p: Record<string, any>) => void) => {
|
||||
const p = (providers.get(id) ?? { id }) as Record<string, any>;
|
||||
fn(p);
|
||||
providers.set(id, p);
|
||||
},
|
||||
@@ -21,16 +33,16 @@ function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft }
|
||||
},
|
||||
model: {
|
||||
get: () => undefined,
|
||||
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
|
||||
const k = pid + "/" + mid;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
|
||||
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
|
||||
fn(m);
|
||||
models.set(k, m);
|
||||
},
|
||||
remove: () => {},
|
||||
default: { get: () => undefined, set: () => {} },
|
||||
},
|
||||
} as CatalogDraft;
|
||||
};
|
||||
return { models, draft };
|
||||
}
|
||||
|
||||
@@ -190,12 +202,17 @@ describe("catalog usableOnly gating", () => {
|
||||
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
await plugin.setup({
|
||||
options: { baseURL: "https://gw.example.com", providerId: "usable-gate" },
|
||||
catalog: {
|
||||
transform: (cb: (draft: unknown) => Promise<void>) => {
|
||||
catalogCallbacks.push(cb);
|
||||
provider: {
|
||||
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
|
||||
catalogCallbacks.push(async () => {
|
||||
cb({ add: () => {} });
|
||||
});
|
||||
return Promise.resolve({ dispose: async () => {} });
|
||||
},
|
||||
},
|
||||
},
|
||||
model: {
|
||||
transform: () => Promise.resolve({ dispose: async () => {} }),
|
||||
},
|
||||
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
|
||||
});
|
||||
const { mkdtempSync } = await import("node:fs");
|
||||
|
||||
@@ -41,18 +41,20 @@ describe("warm snapshot is read under the credential actually in use", () => {
|
||||
snapshotIdentityFingerprint(baseURL, hostKey, hostKey)
|
||||
);
|
||||
|
||||
const published = new Map<string, Record<string, unknown>>();
|
||||
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
|
||||
const added: unknown[] = [];
|
||||
const registration = Promise.resolve({ dispose: async () => {} });
|
||||
const ctx = {
|
||||
options: { baseURL, providerId: "warmid", apiKey: "key-written-in-the-config" },
|
||||
catalog: {
|
||||
transform: (cb: (d: unknown) => Promise<void>) => {
|
||||
callbacks.push(cb);
|
||||
provider: {
|
||||
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
|
||||
cb({ add: (input: unknown) => added.push(input) });
|
||||
return registration;
|
||||
},
|
||||
reload: async () => {},
|
||||
},
|
||||
model: {
|
||||
transform: () => registration,
|
||||
},
|
||||
integration: {
|
||||
transform: () => registration,
|
||||
connection: {
|
||||
@@ -62,17 +64,10 @@ describe("warm snapshot is read under the credential actually in use", () => {
|
||||
},
|
||||
};
|
||||
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
|
||||
const draft = {
|
||||
provider: { update: (_i: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
|
||||
model: {
|
||||
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
|
||||
const e: Record<string, unknown> = { id: mid, providerID: pid };
|
||||
fn(e);
|
||||
published.set(`${pid}/${mid}`, e);
|
||||
},
|
||||
},
|
||||
};
|
||||
await callbacks[0]!(draft);
|
||||
const published = new Map<string, Record<string, unknown>>();
|
||||
for (const entry of added as Array<{ info: { id: string }; models: Array<Record<string, unknown>> }>) {
|
||||
for (const m of entry.models) published.set(`${entry.info.id}/${String(m.id)}`, m);
|
||||
}
|
||||
assert.ok(
|
||||
[...published.keys()].some((k) => k.endsWith("/m-snap")),
|
||||
`the snapshot must survive the credential switch, published: ${JSON.stringify([...published.keys()])}`
|
||||
|
||||
@@ -230,6 +230,7 @@ Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.
|
||||
| `visibleModels` | `string[]` | _unset_ | Allowlist — when set and non-empty, only models whose raw `/v1/models` ID matches are emitted. Bare IDs (no slash, e.g. `claude-opus-4-7`) match any `{prefix}/claude-opus-4-7`; full IDs (e.g. `cc/claude-opus-4-7`) match exactly. Composes with `usableOnly` and `hiddenModels` (all filters AND together). Unset or empty = no filter. |
|
||||
| `hiddenModels` | `string[]` | _unset_ | Blocklist — models whose raw ID matches are dropped. Same matching rules as `visibleModels`. When a model is in both `visibleModels` and `hiddenModels`, the blocklist wins (deny takes precedence). Composes with `usableOnly` and `visibleModels` (all filters AND together). Unset or empty = no filter. |
|
||||
| `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. |
|
||||
| `diskCacheMaxAgeMs` | `number` | _unset_ | Opt-in max age, in milliseconds, for a disk-cache fallback snapshot. Unset or `0` keeps the snapshot unbounded. A positive bound still serves the snapshot and escalates that fallback log from warn to error once the snapshot is older than the bound. |
|
||||
| `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 |
|
||||
|
||||
@@ -197,6 +197,13 @@ const featuresSchema = z
|
||||
visibleModels: z.array(z.string().min(1)).optional(),
|
||||
hiddenModels: z.array(z.string().min(1)).optional(),
|
||||
diskCache: z.boolean().optional(),
|
||||
/**
|
||||
* Opt-in max age for a disk-cache fallback snapshot, in milliseconds.
|
||||
* Unset or `0` keeps the historical unbounded default: a stale snapshot
|
||||
* is still served. A positive bound does not refuse the snapshot; the
|
||||
* fallback log escalates from warn to error once the snapshot is older.
|
||||
*/
|
||||
diskCacheMaxAgeMs: z.number().nonnegative().optional(),
|
||||
providerTag: z.boolean().optional(),
|
||||
debugLog: z.boolean().optional(),
|
||||
startupDebug: z.boolean().optional(),
|
||||
@@ -480,6 +487,27 @@ function coercePluginOptions(opts?: PluginOptions): OmniRoutePluginOptions {
|
||||
*/
|
||||
export const DEFAULT_ANTHROPIC_PREFIXES = ["cc", "claude", "anthropic", "kiro", "kr"];
|
||||
|
||||
/**
|
||||
* First-class OmniRoute catalog suffixes (`GET /v1/models`). The Anthropic
|
||||
* Messages translator looks these up as `claude-<model>` on provider
|
||||
* `claude` and 404s. Keep them on openai-compatible `/v1` so the full
|
||||
* catalog id (`cc/claude-haiku-4-5-20251001-low`) is sent unchanged.
|
||||
*/
|
||||
export const OPENAI_COMPAT_EFFORT_TIER_SUFFIXES = [
|
||||
"-low",
|
||||
"-medium",
|
||||
"-high",
|
||||
"-xhigh",
|
||||
"-thinking",
|
||||
"-minimal",
|
||||
"-max",
|
||||
] as const;
|
||||
|
||||
function hasOpenAiCompatEffortTierSuffix(modelId: string): boolean {
|
||||
const lower = modelId.toLowerCase();
|
||||
return OPENAI_COMPAT_EFFORT_TIER_SUFFIXES.some((suffix) => lower.endsWith(suffix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a baseURL ends with `/v1` so the OpenAI-compat SDK constructs
|
||||
* `/v1/chat/completions` correctly. The Anthropic SDK does NOT want `/v1`
|
||||
@@ -511,7 +539,12 @@ export function ensureV1Suffix(url: string): string {
|
||||
* Resolve the API block (id + url + npm package) for a given model id.
|
||||
*
|
||||
* Decision matrix:
|
||||
* - If the model id's prefix (the substring before the first `/`) is in
|
||||
* - If the model id ends with a first-class OmniRoute effort-tier suffix
|
||||
* (`-low` / `-medium` / `-high` / `-xhigh` / `-thinking` / `-minimal` /
|
||||
* `-max`), return the OpenAI-compat block even when the prefix is
|
||||
* Anthropic. Those ids exist only in `GET /v1/models`; the Anthropic
|
||||
* Messages path 404s them as `claude-<name>` on provider `claude`.
|
||||
* - Else if the model id's prefix (the substring before the first `/`) is in
|
||||
* `apiFormat.anthropicPrefixes` (or the default list), return the
|
||||
* Anthropic SDK block: `id: "anthropic"`, `url: baseURL` (no `/v1`),
|
||||
* `npm: "@ai-sdk/anthropic"`.
|
||||
@@ -530,7 +563,7 @@ export function resolveApiBlock(
|
||||
const prefixes = apiFormat?.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES;
|
||||
const slash = modelId.indexOf("/");
|
||||
const prefix = slash === -1 ? modelId : modelId.slice(0, slash);
|
||||
const isAnthropic = prefixes.includes(prefix);
|
||||
const isAnthropic = prefixes.includes(prefix) && !hasOpenAiCompatEffortTierSuffix(modelId);
|
||||
return isAnthropic
|
||||
? {
|
||||
id: "anthropic",
|
||||
@@ -5296,6 +5329,7 @@ export function createOmniRouteConfigHook(
|
||||
sink.call(logger, message);
|
||||
};
|
||||
const features = resolved.features ?? {};
|
||||
const wantCombos = features.combos !== false;
|
||||
const wantAutoCombos = features.autoCombos !== false;
|
||||
const wantEnrichment = features.enrichment !== false;
|
||||
const wantCompressionMeta = features.compressionMetadata === true;
|
||||
@@ -5448,6 +5482,7 @@ export function createOmniRouteConfigHook(
|
||||
};
|
||||
|
||||
const doCombos = async (): Promise<void> => {
|
||||
if (!wantCombos) return;
|
||||
try {
|
||||
localRawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
|
||||
} catch (err) {
|
||||
@@ -5560,13 +5595,20 @@ export function createOmniRouteConfigHook(
|
||||
// "stale" alone reads as a transient blip, so a week-old catalog
|
||||
// is indistinguishable from a five-minute-old one.
|
||||
const snapshotAge = snapshot.writtenAt;
|
||||
const ageMs = typeof snapshotAge === "number" ? now() - snapshotAge : undefined;
|
||||
const snapshotAgeLabel =
|
||||
typeof snapshotAge === "number"
|
||||
? `${Math.round((Date.now() - snapshotAge) / 3_600_000)}h`
|
||||
: "unknown";
|
||||
typeof ageMs === "number" ? `${Math.round(ageMs / 3_600_000)}h` : "unknown";
|
||||
const maxAgeMs = features.diskCacheMaxAgeMs;
|
||||
const pastMaxAge =
|
||||
typeof maxAgeMs === "number" &&
|
||||
maxAgeMs > 0 &&
|
||||
typeof ageMs === "number" &&
|
||||
ageMs > maxAgeMs;
|
||||
logAt(
|
||||
"warn",
|
||||
`config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models, age ${snapshotAgeLabel})`
|
||||
pastMaxAge ? "error" : "warn",
|
||||
`config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models, age ${snapshotAgeLabel}${
|
||||
pastMaxAge ? `, past diskCacheMaxAgeMs=${maxAgeMs}` : ""
|
||||
})`
|
||||
);
|
||||
localRawModels = snapshot.rawModels;
|
||||
localRawCombos = snapshot.rawCombos;
|
||||
|
||||
@@ -461,6 +461,31 @@ test("config: fetchers throw → warn + emit stub entry with models: {}", async
|
||||
// 6. Combos fetcher throws → models-only catalog (no combos in models block)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("config: features.combos=false skips /api/combos fetch", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
const logger = captureWarn();
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", features: { combos: false } },
|
||||
{ readAuthJson, fetcher, combosFetcher, logger }
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
assert.equal(fetcher.callCount(), 1, "models fetch still runs");
|
||||
assert.equal(combosFetcher.callCount(), 0, "combos fetch suppressed by feature flag");
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(entry.models["claude-tier"], undefined, "no combo entry when combos are off");
|
||||
assert.ok(entry.models["claude-sonnet-4-6"]);
|
||||
});
|
||||
|
||||
test("config: combos fetcher throws → emit models-only catalog (no combos in models block)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
@@ -1356,6 +1381,63 @@ test('config: stale-fallback warning falls back to "unknown" age without written
|
||||
);
|
||||
});
|
||||
|
||||
test("config: diskCacheMaxAgeMs escalates the fallback log but still serves the snapshot", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
|
||||
});
|
||||
const fetcher = throwingModelsFetcher();
|
||||
const combosFetcher = stubCombosFetcher([]);
|
||||
const levels: string[] = [];
|
||||
const logger = {
|
||||
warn: (message: string) => {
|
||||
levels.push(`warn:${message}`);
|
||||
},
|
||||
error: (message: string) => {
|
||||
levels.push(`error:${message}`);
|
||||
},
|
||||
};
|
||||
const writtenAt = 1_700_000_000_000;
|
||||
const maxAgeMs = 24 * 3_600_000;
|
||||
const diskSnapshotReader = emptyThenSnapshotReader({
|
||||
rawModels: [MODEL_CLAUDE],
|
||||
rawCombos: [],
|
||||
rawEnrichment: new Map([["claude-sonnet-4-6", { name: "Claude Sonnet 4.6 (cached)" }]]),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
writtenAt,
|
||||
});
|
||||
|
||||
const hook = createOmniRouteConfigHook(
|
||||
{ providerId: "omniroute", features: { diskCache: true, diskCacheMaxAgeMs: maxAgeMs } },
|
||||
{
|
||||
readAuthJson,
|
||||
fetcher,
|
||||
combosFetcher,
|
||||
diskSnapshotReader,
|
||||
logger,
|
||||
now: () => writtenAt + 48 * 3_600_000,
|
||||
}
|
||||
);
|
||||
const input = makeInput();
|
||||
await hook(input);
|
||||
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry.models["claude-sonnet-4-6"], "snapshot past the bound is still served");
|
||||
assert.ok(
|
||||
levels.some(
|
||||
(line) => line.startsWith("error:") && line.includes(`past diskCacheMaxAgeMs=${maxAgeMs}`)
|
||||
),
|
||||
"past-bound fallback escalates to error"
|
||||
);
|
||||
assert.equal(
|
||||
levels.some((line) => line.startsWith("warn:") && line.includes("using stale disk cache")),
|
||||
false,
|
||||
"past-bound fallback is not only a warning"
|
||||
);
|
||||
});
|
||||
|
||||
test("config: cached rawEnrichment from earlier provider hook is reused (no refetch)", async () => {
|
||||
const readAuthJson = stubReadAuthJson({
|
||||
"opencode-omniroute": { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
normaliseFreeLabel,
|
||||
resolveApiBlock,
|
||||
DEFAULT_ANTHROPIC_PREFIXES,
|
||||
OPENAI_COMPAT_EFFORT_TIER_SUFFIXES,
|
||||
ensureV1Suffix,
|
||||
debugLogEnabled,
|
||||
debugLogSetEnabled,
|
||||
@@ -36,10 +37,7 @@ test("normaliseFreeLabel: '(Free)' suffix becomes [Free] prefix", () => {
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: trailing ' Free' word becomes [Free] prefix", () => {
|
||||
assert.equal(
|
||||
normaliseFreeLabel("DeepSeek V4 Flash Free"),
|
||||
"[Free] DeepSeek V4 Flash"
|
||||
);
|
||||
assert.equal(normaliseFreeLabel("DeepSeek V4 Flash Free"), "[Free] DeepSeek V4 Flash");
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: trailing '-free' (hyphen) becomes [Free] prefix", () => {
|
||||
@@ -59,10 +57,7 @@ test("normaliseFreeLabel: names without 'free' pass through unchanged", () => {
|
||||
|
||||
test("normaliseFreeLabel: 'free' in the middle of a name is NOT rewritten", () => {
|
||||
// Only trailing/standalone "free" markers count; embedded "freedom" stays
|
||||
assert.equal(
|
||||
normaliseFreeLabel("Freedom Model"),
|
||||
"Freedom Model"
|
||||
);
|
||||
assert.equal(normaliseFreeLabel("Freedom Model"), "Freedom Model");
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: empty / whitespace-only inputs are handled", () => {
|
||||
@@ -80,6 +75,22 @@ test("resolveApiBlock: cc/* models get the Anthropic SDK block (no /v1)", () =>
|
||||
assert.equal(block.url, "https://api.example.com"); // NO /v1 suffix
|
||||
});
|
||||
|
||||
test("resolveApiBlock: cc/* effort-tier catalog ids stay on openai-compatible /v1", () => {
|
||||
assert.ok(OPENAI_COMPAT_EFFORT_TIER_SUFFIXES.includes("-low"));
|
||||
for (const id of [
|
||||
"cc/claude-haiku-4-5-20251001-low",
|
||||
"cc/claude-opus-5-medium",
|
||||
"cc/claude-opus-5-high",
|
||||
"cc/claude-opus-5-xhigh",
|
||||
"cc/claude-opus-4-6-thinking",
|
||||
]) {
|
||||
const block = resolveApiBlock(id, "https://api.example.com");
|
||||
assert.equal(block.id, "openai-compatible", `${id} must not use Anthropic Messages`);
|
||||
assert.equal(block.npm, "@ai-sdk/openai-compatible");
|
||||
assert.equal(block.url, "https://api.example.com/v1");
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveApiBlock: claude/*, anthropic/*, kiro/*, kr/* all route to Anthropic", () => {
|
||||
for (const id of [
|
||||
"claude/claude-opus-4-7",
|
||||
@@ -128,13 +139,7 @@ test("resolveApiBlock: model id without '/' uses the id as prefix", () => {
|
||||
});
|
||||
|
||||
test("DEFAULT_ANTHROPIC_PREFIXES: contains the canonical Anthropic aliases", () => {
|
||||
assert.deepEqual(DEFAULT_ANTHROPIC_PREFIXES, [
|
||||
"cc",
|
||||
"claude",
|
||||
"anthropic",
|
||||
"kiro",
|
||||
"kr",
|
||||
]);
|
||||
assert.deepEqual(DEFAULT_ANTHROPIC_PREFIXES, ["cc", "claude", "anthropic", "kiro", "kr"]);
|
||||
});
|
||||
|
||||
test("ensureV1Suffix: idempotent for URLs that already end in /v1", () => {
|
||||
@@ -245,8 +250,7 @@ test("createDebugLoggingFetch: records error without crashing the wrapped fetch"
|
||||
test("createDebugLoggingFetch: URL instance input is captured (not 'undefined')", async () => {
|
||||
const providerId = "test-provider-url-input";
|
||||
debugLogClear(providerId);
|
||||
const inner: typeof fetch = async () =>
|
||||
new Response("ok", { status: 200 });
|
||||
const inner: typeof fetch = async () => new Response("ok", { status: 200 });
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, true);
|
||||
await wrapped(new URL("https://api.example.com/v1/chat"));
|
||||
const entries = debugLogRead(providerId);
|
||||
@@ -258,8 +262,7 @@ test("createDebugLoggingFetch: URL instance input is captured (not 'undefined')"
|
||||
test("createDebugLoggingFetch: Request object input captures URL and headers", async () => {
|
||||
const providerId = "test-provider-request-input";
|
||||
debugLogClear(providerId);
|
||||
const inner: typeof fetch = async () =>
|
||||
new Response("ok", { status: 200 });
|
||||
const inner: typeof fetch = async () => new Response("ok", { status: 200 });
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, true);
|
||||
const req = new Request("https://api.example.com/v1/chat", {
|
||||
method: "POST",
|
||||
|
||||
@@ -56,7 +56,7 @@ Repository map and Reference Documentation sections below.
|
||||
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
|
||||
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
|
||||
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (178 migrations) |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (182 migrations) |
|
||||
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
|
||||
| MCP Server | `open-sse/mcp-server/` | 110 tools (45 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes |
|
||||
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<b>🌐 In 66 languages</b>
|
||||
<b>🌐 In 67 languages</b>
|
||||
<br/><br/>
|
||||
<a href="README.md"><img src="docs/assets/flags/us.svg" width="30" alt="English (en)" title="English (en)"></a>
|
||||
<a href="docs/i18n/pt-BR/README.md"><img src="docs/assets/flags/br.svg" width="30" alt="Português — Brasil (pt-BR)" title="Português — Brasil (pt-BR)"></a>
|
||||
@@ -201,6 +201,7 @@
|
||||
<a href="docs/i18n/uz/README.md"><img src="docs/assets/flags/uz.svg" width="30" alt="Oʻzbekcha (uz)" title="Oʻzbekcha (uz)"></a>
|
||||
<a href="docs/i18n/ka/README.md"><img src="docs/assets/flags/ge.svg" width="30" alt="ქართული (ka)" title="ქართული (ka)"></a>
|
||||
<a href="docs/i18n/hy/README.md"><img src="docs/assets/flags/am.svg" width="30" alt="Հայերեն (hy)" title="Հայերեն (hy)"></a>
|
||||
<a href="docs/i18n/bs/README.md"><img src="docs/assets/flags/ba.svg" width="30" alt="Bosanski (bs)" title="Bosanski (bs)"></a>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
@@ -283,7 +284,7 @@ curl http://localhost:20128/v1/chat/completions \
|
||||
<td>
|
||||
Thanks to <b>Kimi (Moonshot AI)</b>, our founding Open Source Friend, for backing this project! Kimi is the AI lab behind the open-weight K2 and K3 model families — <b>Kimi K3</b> delivers a 1M-token context window, native vision and frontier-level coding at a fraction of closed-model prices, and works out of the box with Claude Code, Codex and every coding tool OmniRoute serves.
|
||||
<br/><br/>
|
||||
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute"><b>Get a Kimi API key with 15% extra credits →</b></a>
|
||||
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.ai/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute"><b>Get a Kimi API key with 15% extra credits →</b></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -1274,7 +1275,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
|
||||
<tr><td nowrap><b>Runtime</b></td><td>Node.js 22.x / 24.x LTS — <code>>=22.22.2 <23 || >=24.0.0 <27</code></td></tr>
|
||||
<tr><td nowrap><b>Language</b></td><td>TypeScript 6.0 — <b>100% TypeScript</b> across <code>src/</code> and <code>open-sse/</code> (zero <code>any</code> in core since v2.0)</td></tr>
|
||||
<tr><td nowrap><b>Framework</b></td><td>Next.js 16 + React 19 + Tailwind CSS 4</td></tr>
|
||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 178 migrations</td></tr>
|
||||
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 182 migrations</td></tr>
|
||||
<tr><td nowrap><b>Memory</b></td><td>SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay</td></tr>
|
||||
<tr><td nowrap><b>Schemas</b></td><td>Zod 4 — MCP tool I/O validation + API contracts</td></tr>
|
||||
<tr><td nowrap><b>Protocols</b></td><td>MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)</td></tr>
|
||||
|
||||
1340
bin/cli/locales/bs.json
Normal file
1340
bin/cli/locales/bs.json
Normal file
File diff suppressed because it is too large
Load Diff
1
changelog.d/features/14235-plugin-sdk-telemetry-hook.md
Normal file
1
changelog.d/features/14235-plugin-sdk-telemetry-hook.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(plugins):** the editor plugin registers the host `sdk` telemetry hook behind an opt-in flag that defaults to off, marking matching inference calls without touching anything else. ([#14235](https://github.com/diegosouzapw/OmniRoute/pull/14235)) — thanks @maxmad64bis
|
||||
@@ -0,0 +1 @@
|
||||
- **feat(oauth):** Muse Code (Meta) device-authorization login with CLIProxyAPI-parity credential lifecycle — RFC 8628 against auth.meta.com, mint the subscription inference key at `/muse-code/key`, persist the durable `dca:` token, remint on 401 / DCA-only records, honor `error.resets_at` quota cooldowns, and import CLIProxyAPI `type: meta` auth files. Dual-auth keeps pasted `META_API_KEY` on the same card. (#14329)
|
||||
1
changelog.d/features/14364-proxy-egress-ip-by-proxy.md
Normal file
1
changelog.d/features/14364-proxy-egress-ip-by-proxy.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(proxies):** show each pool member's last seen egress IP next to the pool totals ([#14364](https://github.com/diegosouzapw/OmniRoute/pull/14364)) — thanks @maxmad64bis
|
||||
1
changelog.d/features/14529-lossy-default-400-table.md
Normal file
1
changelog.d/features/14529-lossy-default-400-table.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(compression):** keep lossy compression off the default path, following HouMinXi's plan (dedup and whitespace stay on; summaries, relevance filters, and style rewrites are per-request) and KooshaPari's #8251 plan (combo 400s are a decision table, so model-scoped wrappers advance). Opt-in header: `x-omniroute-compression: allow-lossy`, `engine:<id>`, or a named combo ([#14529](https://github.com/diegosouzapw/OmniRoute/pull/14529)) — thanks @HouMinXi @KooshaPari
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(resilience):** follow HouMinXi's per-connection circuit breaker, so one account's failure no longer opens the breaker for every account on that provider. Proxy failures stay provider-wide. Model-scoped 400 patterns now live in one module, which is KooshaPari's #8251 plan, shared by combo dispatch and account fallback ([#14530](https://github.com/diegosouzapw/OmniRoute/pull/14530)) — thanks @HouMinXi @KooshaPari
|
||||
1
changelog.d/features/14540-disk-cache-max-age.md
Normal file
1
changelog.d/features/14540-disk-cache-max-age.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(opencode-plugin):** `features.diskCacheMaxAgeMs` is an opt-in bound for the disk-cache fallback. Unset or `0` stays unbounded. Past a positive bound the snapshot is still served and the fallback log escalates from warn to error ([#14540](https://github.com/diegosouzapw/OmniRoute/pull/14540)) — thanks @RaviTharuma
|
||||
1
changelog.d/features/14544-cpa-trace-auth-index.md
Normal file
1
changelog.d/features/14544-cpa-trace-auth-index.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(providers):** correlate CLIProxyAPI `X-CPA-TRACE-ID` `auth_index` with usage history and the sanitized account-health label ([#14544](https://github.com/diegosouzapw/OmniRoute/pull/14544)) — thanks @RaviTharuma
|
||||
1
changelog.d/features/i18n-bosnian-locale.md
Normal file
1
changelog.d/features/i18n-bosnian-locale.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(i18n):** New locale — Bosnian (`bs`) — across the dashboard, docs mirrors, CLI, README and the site;
|
||||
1
changelog.d/features/synced-model-context-override.md
Normal file
1
changelog.d/features/synced-model-context-override.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(dashboard):** synced/imported models can now carry a manual context-window override. Rows imported through a provider's **/models** import had no edit affordance at all, so the override `#4125` added for _custom_ models was unreachable for them — even though `PUT /api/provider-models` has always accepted `contextWindowOverride` for a model with no `customModels` row, and the runtime already prefers the override. The read path was the other half of the gap: `GET /api/provider-models` attached overrides only to custom-model rows, so a value stored for a synced model could never be read back. The GET now returns the provider's overrides directly, and the passthrough row renders the same 🪟 badge and inline editor, with `#4125` semantics unchanged — a positive whole number sets the override, blank clears it and the model falls back to the discovered value. This is the correction path for third-party OpenAI-compatible aggregators whose discovery metadata understates context length (#14337).
|
||||
1
changelog.d/fixes/13999-logs-export-mid-stream-error.md
Normal file
1
changelog.d/fixes/13999-logs-export-mid-stream-error.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(api):** `GET /api/logs/export` now settles its HTTP response instead of hanging forever when the DB row source throws mid-stream — the row-iteration loop is wrapped in try/catch, the failure is logged, and the JSON document is closed out cleanly with additive `emitted`/`error` trailer fields so the client always gets a response (#13999).
|
||||
1
changelog.d/fixes/14060-malformed-sqlite-500.md
Normal file
1
changelog.d/fixes/14060-malformed-sqlite-500.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(dashboard): Home degrades to default settings instead of returning 500 when the SQLite key_value table is corrupted; getSettings() keeps failing closed so auth gates still require login (#14060)
|
||||
1
changelog.d/fixes/14069-model-not-in-catalog.md
Normal file
1
changelog.d/fixes/14069-model-not-in-catalog.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(combo):** a combo member that is missing from the live catalog is skipped as `model_not_in_catalog` instead of the generic `availability` bucket, and that reason is grouped in the `ALL_TARGETS_SKIPPED` diagnostics ([#14069](https://github.com/diegosouzapw/OmniRoute/pull/14069)) — thanks @RaviTharuma
|
||||
1
changelog.d/fixes/14088-opencode-plugin-combos-gate.md
Normal file
1
changelog.d/fixes/14088-opencode-plugin-combos-gate.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(opencode-plugin):** honor `features.combos: false` in the OpenCode config shim, and keep first-class `cc/...-low` catalog ids on openai-compatible `/v1` instead of Anthropic Messages ([#14088](https://github.com/diegosouzapw/OmniRoute/pull/14088)) — thanks @RaviTharuma
|
||||
1
changelog.d/fixes/14110-path-redaction-prose.md
Normal file
1
changelog.d/fixes/14110-path-redaction-prose.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(guardrails): fail closed on unknown-root filesystem paths followed by ambiguous prose in sanitized error messages (#14110)
|
||||
1
changelog.d/fixes/14149-opencode-request-format-race.md
Normal file
1
changelog.d/fixes/14149-opencode-request-format-race.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(opencode):** overlapping requests to OpenCode no longer share the target format and client session of whichever request started last; a slow JSON request could get the raw event stream back when another request finished first ([#14149](https://github.com/diegosouzapw/OmniRoute/pull/14149)) — thanks @maxmad64bis
|
||||
@@ -0,0 +1 @@
|
||||
- fix(api): add `encrypted_function_args: []` plaintext marker to collaboration function_call items in the Responses translators (#14154)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(sse): parse deepseek-web double-pipe DSML invoke/parameter tool-call markup (#14208)
|
||||
1
changelog.d/fixes/14213-flush-empty-retry.md
Normal file
1
changelog.d/fixes/14213-flush-empty-retry.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(sse):** retry empty translated streaming turns through the normal credential path (up to `STREAM_RECOVERY.EMPTY_TURN_RETRY_MAX` retries) instead of exposing an empty 200 or an empty-content 502, and a stream that drops before anything reaches the client takes the same retry path; a stream that answers and then stops producing without closing is replayed the same way once its stall budget runs out, instead of buffering forever; off by default behind `FLUSH_EMPTY_RETRY_ENABLED` ([#14213](https://github.com/diegosouzapw/OmniRoute/pull/14213))
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(sse):** skipped-account diagnostics for multi-account rotation: accounts passed over while cooling down are now named (masked id + remaining time, one info line each per request) and their per-account state is readable from the resilience connections endpoint, so an operator can tell skipped accounts apart from unused ones; proxy log entries also carry the masked serving account and the request correlation id when the rotation attribution flag is on (off by default, no selection change) ([#14223](https://github.com/diegosouzapw/OmniRoute/pull/14223)) — thanks @maxmad64bis
|
||||
1
changelog.d/fixes/14226-ratelimit-overrides-persist.md
Normal file
1
changelog.d/fixes/14226-ratelimit-overrides-persist.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(rate-limit):** keep per-connection rate limit overrides across restarts and JSON re-imports — startup reloads the overrides before building limiters and reconciles existing ones, and both re-import paths carry `rate_limit_overrides_json` in the `INSERT OR REPLACE` (preserving the stored value when the backup has none) ([#14226](https://github.com/diegosouzapw/OmniRoute/pull/14226) — thanks @maxmad64bis)
|
||||
1
changelog.d/fixes/14236-health-deep-check.md
Normal file
1
changelog.d/fixes/14236-health-deep-check.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(health):** the system health endpoint offers an opt-in deep check (`?deep=1`, off by default, authenticated callers only) that samples the completions surface with a minimal non-streaming request and caches the verdict briefly; only `502`/`503` answers raise the failover signal, every other failure keeps serving the existing payload unchanged. Wiring only: the probe target is not configured yet, so `?deep=1` stays inert until a dedicated settings change supplies it. ([#14236](https://github.com/diegosouzapw/OmniRoute/pull/14236)) — thanks @maxmad64bis
|
||||
@@ -0,0 +1 @@
|
||||
- **test(providers):** pin the opencode 400 model-unavailable catalog rule at registry level ([#14251](https://github.com/diegosouzapw/OmniRoute/pull/14251)) — thanks @maxmad64bis
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(logging):** keep the provider reply when a call-log artifact only overflows through its request body ([#14253](https://github.com/diegosouzapw/OmniRoute/pull/14253)) — thanks @maxmad64bis
|
||||
1
changelog.d/fixes/14309-proxyfetch-invalid-arg.md
Normal file
1
changelog.d/fixes/14309-proxyfetch-invalid-arg.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): surface the real transport diagnosis (DNS/socket cause) instead of a bare "fetch failed" in provider validation errors (#14309)
|
||||
1
changelog.d/fixes/14319-pending-sweep-mark.md
Normal file
1
changelog.d/fixes/14319-pending-sweep-mark.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(usage):** requests stuck past the pending age limit stay visible on the dashboard with a distinct marked state instead of disappearing, and still count toward pending totals ([#14319](https://github.com/diegosouzapw/OmniRoute/pull/14319)) — thanks @maxmad64bis
|
||||
1
changelog.d/fixes/14325-docker-next-publish.md
Normal file
1
changelog.d/fixes/14325-docker-next-publish.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(ci): raise the Docker `:next`/`:latest` publish heap budget to 12288 MB so the webpack production build no longer OOMs (#14325)
|
||||
1
changelog.d/fixes/14353-opencode-accounts-per-request.md
Normal file
1
changelog.d/fixes/14353-opencode-accounts-per-request.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(opencode):** Keep each request on its own member list so concurrent requests no longer replace each other's list ([#14353](https://github.com/diegosouzapw/OmniRoute/pull/14353)) — thanks @maxmad64bis
|
||||
1
changelog.d/fixes/14365-omni-code-review-e2e-areas.md
Normal file
1
changelog.d/fixes/14365-omni-code-review-e2e-areas.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(api,images):** `DELETE /v1/batches/delete-completed` stops re-validating the API key the scope helper already gated and logs an honest audit reason; Antigravity image requests no longer force `image_size: 1K` when the caller omitted it; the image-upscale call log no longer crashes on sanitized error objects — findings of the omni-code-review battery ([#14365](https://github.com/diegosouzapw/OmniRoute/pull/14365))
|
||||
1
changelog.d/fixes/14370-opencode-plugin-stable-port.md
Normal file
1
changelog.d/fixes/14370-opencode-plugin-stable-port.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(opencode-plugin-v2):** publish the gateway catalog through the stable provider contract instead of the removed beta one ([#14370](https://github.com/diegosouzapw/OmniRoute/pull/14370)) — thanks @maxmad64bis
|
||||
1
changelog.d/fixes/14456-admission-lease-abort-release.md
Normal file
1
changelog.d/fixes/14456-admission-lease-abort-release.md
Normal file
@@ -0,0 +1 @@
|
||||
- Release the heavyweight chat admission slot when a client disconnects without cancelling the SSE body. Previously `activeHeavy` only ever increased on abandoned streams and pinned at `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT`, shedding every subsequent heavy request with 503 `chat_admission_busy` until the process was recreated. ([#14456](https://github.com/diegosouzapw/OmniRoute/issues/14456))
|
||||
1
changelog.d/fixes/14460-claude-url-image-source.md
Normal file
1
changelog.d/fixes/14460-claude-url-image-source.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(translator):** A Claude image sent as a URL (`source: { type: "url" }`) now reaches the provider: inside a `tool_result` on the OpenAI path, and everywhere on the direct Claude → Gemini path, where it is passed as `fileData` ([#14460](https://github.com/diegosouzapw/OmniRoute/pull/14460))
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(opencode):** free-tier requests refused on a small tool subset are retried once with previously accepted tool names appended ([#14464](https://github.com/diegosouzapw/OmniRoute/pull/14464)) — thanks @maxmad64bis
|
||||
1
changelog.d/fixes/14481-files-batches-api-key-policy.md
Normal file
1
changelog.d/fixes/14481-files-batches-api-key-policy.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(api):** every `/v1/files` and `/v1/batches` handler now applies the caller's API-key policy (endpoint allowlist, schedule, usage cap, rate limit) like `delete-completed` and the other `/v1` routes ([#14481](https://github.com/diegosouzapw/OmniRoute/issues/14481))
|
||||
1
changelog.d/fixes/chatcore-cpa-authindex-scope.md
Normal file
1
changelog.d/fixes/chatcore-cpa-authindex-scope.md
Normal file
@@ -0,0 +1 @@
|
||||
- **sse:** stop every upstream failure from throwing out of `handleChatCore` — `persistFailureUsage` read `providerResponse` from outside its scope (regression from #14544), so all 13 failure paths raised `ReferenceError: providerResponse is not defined` instead of returning the upstream error and recording failure usage.
|
||||
1
changelog.d/maintenance/14218-prettier-frozen-proxy.md
Normal file
1
changelog.d/maintenance/14218-prettier-frozen-proxy.md
Normal file
@@ -0,0 +1 @@
|
||||
- **style(proxy-registry):** bring frozen proxy-registry files back to prettier format with no behavior change; ratchet the inherited `proxyFetch.ts` size ceiling to its measured size ([#14218](https://github.com/diegosouzapw/OmniRoute/pull/14218)) — thanks @maxmad64bis
|
||||
1
changelog.d/maintenance/14526-mcp-scope-namespaces.md
Normal file
1
changelog.d/maintenance/14526-mcp-scope-namespaces.md
Normal file
@@ -0,0 +1 @@
|
||||
- **docs(authz):** document that API-key management scopes, MCP tool scopes, and `read`/`write`/`admin` access tokens are three independent namespaces ([#14526](https://github.com/diegosouzapw/OmniRoute/pull/14526)) — thanks @RaviTharuma
|
||||
@@ -45,6 +45,14 @@
|
||||
"english": "Bengali",
|
||||
"flag": "🇧🇩"
|
||||
},
|
||||
{
|
||||
"code": "bs",
|
||||
"label": "BS",
|
||||
"name": "Bosanski",
|
||||
"native": "Bosanski",
|
||||
"english": "Bosnian",
|
||||
"flag": "🇧🇦"
|
||||
},
|
||||
{
|
||||
"code": "cs",
|
||||
"label": "CS",
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
{
|
||||
"_rebaseline_2026_09_22_14069_model_not_in_catalog": "PR #14069 (@RaviTharuma) own growth: a live-catalog miss is now recorded as the model_not_in_catalog skip reason instead of collapsing into the generic availability bucket, so an unknown alias stops being reported as \"no credentials available\" (#14068). Measured on the tree reconciled with release/v3.8.51 @ea3c1226: src/sse/handlers/chat.ts 2559->2560 (+1 = the single modelInfo.errorType === \"model_not_found\" early return inside the existing isModelAvailable callback) and open-sse/services/combo/roundRobinCombo.ts 1261->1263 (+2 = the strict `available !== true` pre-check plus the sticky-target expression, which Prettier printWidth 100 reflows over three lines once it becomes `(await isModelAvailable(...)) === true`). Both files were already frozen exactly at their measured size with zero headroom (chat.ts was tightened to 2559 by #14223 on 2026-09-20), so the growth cannot be absorbed. These are call-site lines threading the new model_not_in_catalog skip reason through the two availability chokepoints and nothing else; the reason itself lives outside the frozen files, all under cap: the ModelAvailabilityResult union and modelAvailabilitySkipReason in open-sse/services/combo/types.ts, the COMBO_SKIP_REASONS entry in decisionTrace.ts and the threading in executeTargetGates.ts. Irreducible. Covered by tests/unit/combo/combo-skipped-targets-summary.test.ts. Structural shrink of both god-files stays tracked in #3501.",
|
||||
"_rebaseline_2026_09_22_11725_cpa_auth_index": "PR for #11725 own growth: open-sse/handlers/chatCore.ts 6400->6402 (+2). Prettier printWidth 100 keeps the failure-usage cpaAuthIndex property and the readCpaAuthIndex import on their own lines; the streaming and non-streaming call sites stay on the existing endpoint line. The parser, stamp, and label join live in open-sse/handlers/chatCore/cpaTraceAuthIndex.ts (under cap). Covered by tests/unit/cpa-trace-auth-index.test.ts, tests/unit/cpa-auth-index-usage.test.ts, and tests/unit/db/migration-185-cpa-auth-index.test.ts.",
|
||||
"_rebaseline_2026_09_20_14223_rotation_attribution_growth": "PR #14223 own growth: rotation attribution diagnostics (masked serving-account id + request correlation id on proxy log rows, per-account rotation state, skip lines). Re-measured on the tree reconciled with release/v3.8.51 @59de50e4 (which brought #14149 -- the MuseSpark block and the per-request format/session context moved out of opencode.ts -- #14226 and #14464): open-sse/executors/opencode.ts 1251->1318 gate count (re-measured again after #14353 moved the member list off the instance) (snapshotEntries + logSkippedCooldownAccounts + attribution wiring at the nine existing rotation exits incl. the two park-and-replay returns; logic kept at the rotation seam), src/sse/handlers/chat.ts 2547->2559 (attribution sink type + flag read + two forwarded fields at the existing log call-site), src/sse/handlers/chatHelpers.ts 1253->1257 (+4, two additive optional params forwarded to the journal row), open-sse/utils/proxyFetch.ts 1268->1287 (+19, AppliedProxySink rotationAccount field + noteRotationAccount helper). Irreducible spec wiring at existing chokepoints, additive and flag-off inert. Covered by 6 new test files (17 tests).",
|
||||
"_rebaseline_2026_09_20_prettier_frozen": "Prettier-only pass over frozen proxy files (verified green on the base with split(\"\\n\").length counting): open-sse/utils/proxyFetch.ts 1276->1268 (inherited shrink ratchet, re-measured on the reconciled release/v3.8.51 tip @373c31f3 -- not this PR's growth); src/sse/handlers/chat.ts 2547= ; src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx 1477= (iso-LOC format).",
|
||||
"_rebaseline_2026_09_21_14250_member_egress_lines": "PR #14250 own growth: src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx 1477->1479 (+2 = the PoolMemberEgressLines import and its one-line mount under the pool members label, next to PoolEgressObservation). The member-egress observation itself lives outside the frozen file, all under cap: PoolMemberEgressLines.tsx, the dedicated GET /api/settings/proxies/pool/member-egress route, readPoolMemberEgressObservation in src/lib/proxyPoolEgressObservation.ts and getRecentEgressIpForProxy in src/lib/db/proxyLogs.ts. Only the mount point is irreducible. Covered by tests/unit/proxy-pool-member-egress-route.test.ts and tests/unit/ui/PoolMemberEgressLines.test.tsx.",
|
||||
"_rebaseline_2026_09_22_opencode_train10c_drift": "Release-tip drift: open-sse/executors/opencode.ts 1247->1251, left by the train-10c merge wave (2026-09-22) — the PR->release fast-gates do not run check:file-size, so the tip went red for every train boarding afterwards. Absorbed once at the tip under the owner-approved train-rebaseline policy (see _rebaseline_2026_09_18_merge_train_8_frozen_growth). Structural shrink tracked in #3501.",
|
||||
"_rebaseline_2026_09_21_14290_stack_handover_growth": "Stacked on #13924 (rewritten/squash-merged as 893fef9c on release/v3.8.51). PR #14290's own growth on top of that parent: open-sse/executors/opencode.ts 1233->1247 (+14 irreducible seam: settle429Arm park arm forcing burstStreak to threshold + handover comment; park/replay block owned by #13924, throttle leaf opencodeEgressThrottle.ts 543 lines under cap). Covered by tests/unit/opencode-429-park-resume.test.ts handover case (8/8) + tests/unit/opencode-egress-throttle.test.ts (22/22).",
|
||||
"_rebaseline_2026_09_21_14213_empty_turn_retry_growth": "PR #14213 own growth: empty translated streaming turn retry through the normal credential path (classifier + replay parity + bounded reader in new open-sse/utils/emptyTurnRetry.ts, hook wiring in open-sse/handlers/chatCore.ts, streamEmptyChoices extraction). open-sse/handlers/chatCore.ts 6287->6400 (+113 gate count on the reconciled release tip 30f7088c, whose own chatCore.ts already sits at the frozen 6287; irreducible call-site wiring at the insertion point; logic lives in the new 422-line module under the cap). Irreducible spec wiring, additive and flag-off inert. Covered by flush-empty-retry.test.ts + flush-empty-retry-hook.test.ts (46 tests).",
|
||||
"_rebaseline_2026_09_20_14226_core_overrides_column": "own growth 1788->1800 (+12) src/lib/db/core.ts: rate_limit_overrides_json column in reimport INSERT + preservation SELECT, irreducible spec growth, covered by rate-limit-overrides-startup/reimport tests",
|
||||
"_rebaseline_2026_09_18_merge_train_8_frozen_growth": "Owner-approved train rebaseline (2026-09-18, /merge-prs; precedent _rebaseline_2026_07_23_v3849_merge_train_15). Own growth of 32 merge-ready contributor PRs that each add irreducible call-site/plumbing lines to an already-frozen file, measured on the combined merge-train tip 04cf8095 (release tip green before boarding). Per-file (old->new, contributing PRs): src/app/(dashboard)/dashboard/combos/page.tsx 5080->5091 (#13951); src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx 2491->2493 (#13533); src/app/api/v1/models/catalog.ts 2117->2127 (#13994); src/lib/db/apiKeys.ts 1659->1671 (#12952, #13861); src/lib/tokenHealthCheck.ts 1221->1254 (#13444, #13874); src/shared/components/RequestLoggerDetail.tsx 1200->1210 (#13373); src/shared/components/RequestLoggerV2.tsx 1718->1748 (#13373); src/shared/middleware/chatBodyAdmission.ts 1200->1206 (#13823); src/sse/handlers/chat.ts 2541->2547 (combined growth); src/sse/services/auth.ts 3582->3592 (combined growth); open-sse/executors/antigravity.ts 1665->1717 (#13125, #13318, #13659); open-sse/executors/codex.ts 1553->1570 (#13708); open-sse/executors/cursor.ts 1847->1868 (#13125); open-sse/executors/deepseek-web.ts 1200->1224 (#13226); open-sse/executors/default.ts 1200->1205 (#11828); open-sse/handlers/imageGeneration.ts 3304->3334 (#12982); open-sse/services/accountFallback.ts 2507->2515 (#13008); open-sse/services/combo/executeTargetAttempt.ts 1228->1258 (#12235); open-sse/services/combo/roundRobinCombo.ts 1221->1261 (#12235); open-sse/services/rateLimitManager.ts 1200->1329 (#13895); open-sse/translator/response/openai-responses.ts 1466->1518 (#12841, #13956); open-sse/utils/cursorAgentProtobuf.ts 1547->1588 (#13125); open-sse/utils/stream.ts 3140->3239 (#12688, #12855); tests/integration/chat-pipeline.test.ts 1740->1756 (#12966); tests/unit/account-fallback-service.test.ts 2056->2072 (#13040); tests/unit/chatcore-translation-paths.test.ts 3449->3546 (#13856, #13972); tests/unit/token-refresh-service.test.ts 1407->1408 (combined growth); tests/unit/translator-openai-to-gemini.test.ts 1625->1809 (#13318, #13848). Files previously under the 1200 cap that crossed it are frozen at the measured size. Structural shrink of these god-files stays tracked in #3501; the ceilings never move up again outside a documented entry. ADJUST (train 8d re-measure after #13548 ejection and #14101 landing): open-sse/services/accountFallback.ts 2515->2517 (#13008 +22, #13350 +7, #13984 +2, #13040 +2 on a tip at 2499).",
|
||||
"_rebaseline_2026_09_18_14065_codex_reasoning_whitelist": "Release-tip drift: open-sse/executors/codex.ts 1552->1553 (+1) from #14065 (fix(codex): whitelist reasoning object keys before the wire, #13643), merged 2026-09-18 without its own rebaseline — the PR->release fast-gates do not run check:file-size, so the tip went red for every train boarding afterwards. Absorbed at the release tip by the /merge-prs captain session (owner-approved train-rebaseline policy, 2026-09-18). Structural shrink tracked in #3501.",
|
||||
"_rebaseline_2026_09_18_restore_13643_after_clobber": "Restore of #13643 after e7999c477b clobbered the whitelist at the release tip (see #14062). open-sse/executors/codex.ts 1530->1552 (+22): keep the tip force-rule precedence and restore OpenRouter-style enabled:false plus the reasoning-object key whitelist before the wire. Covered by tests/unit/codex-reasoning-wire-whitelist.test.ts.",
|
||||
@@ -365,7 +372,7 @@
|
||||
"_rebaseline_2026_07_27_3850_relax_filesize_cap": "OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). cap 800->900 (+100), testCap 800->900 (+100). Targets: decompose-existing-frozen unchanged (frozen still only-shrink); this only relaxes the cap for NEW files in the decompose/extract-while-PREPARE phase (.51='executor registry in-place' and .52='combo.ts decomposition' create new leaf modules above 800). RE-TIGHTENING MANDATORY in v3.8.51: cap target 850 = 850 once decomposition wave stabilizes. SUPERSEDED by _rebaseline_2026_07_27_3850_relax_filesize_cap_v2_20pct (v1 +20% buffer) — retained for audit. Tracked via same roadmap issue.",
|
||||
"_rebaseline_2026_07_27_v3849_train1h": "Merge-train 1H (31 PRs) — owner-approved 2026-07-27. Two distinct causes, kept separate on purpose: (1) GENUINE irreducible growth at existing chokepoints — providerLimits/auth (#8632 Kimi quota-reset recovery), rateLimitManager (#8616 idle wedged limiters), models-catalog-route.test (#8610 OpenCode Go effort aliases); (2) COLLISION with #8585, which banked shrinks measured on the pre-train release tip while 30 sibling PRs in the SAME train grew those files again — chat/accountFallback (#8628), chatCore (#8613), videoGeneration (#8581), imageGeneration. The zero-headroom frozen entries cannot absorb either. Ceilings re-pinned to the post-merge tip; #8612 (also in this train) automates shrink-banking so this self-inflicted drift stops recurring. Detail: src/lib/usage/providerLimits.ts 1006->1013 (#8632); src/sse/services/auth.ts 2492->2508 (#8632); open-sse/services/rateLimitManager.ts 1014->1060 (#8616); src/sse/handlers/chat.ts 1842->1845 (#8628); open-sse/handlers/chatCore.ts 4939->4955 (#8613); open-sse/handlers/imageGeneration.ts 3100->3101 ((sem PR — teto do #8585)); open-sse/handlers/videoGeneration.ts 1038->1063 (#8581); open-sse/services/accountFallback.ts 1965->1966 (#8628); tests/unit/models-catalog-route.test.ts 1608->1636 (#8610)",
|
||||
"frozen": {
|
||||
"src/sse/handlers/chatHelpers.ts": 1253,
|
||||
"src/sse/handlers/chatHelpers.ts": 1257,
|
||||
"_rebaseline_2026_09_17_13720_merge_release_v3851": "Merge de release/v3.8.51 na #13720 (2026-09-17). src/sse/handlers/chatHelpers.ts 1246 -> 1253, decomposto: 1246 -> 1250 e crescimento INHERITED do tip (base-red ja presente em origin/release/v3.8.51 no commit 9688032451fc, arquivo com 1250 linhas contra cap 1246 — nao e desta PR e nao foi introduzido por este merge); 1250 -> 1253 sao as MESMAS +3 linhas da propria #13720 ja auditadas e aprovadas pelo dono na entrada _rebaseline_2026_09_16_13720_suffix_effort_propagation abaixo (threading de resolvedThinkingEffort). Nenhum outro teto foi tocado por este merge; tests/unit/chatcore-translation-paths.test.ts (3449 > 3447) permanece vermelho de proposito — e base-red herdado e a PR nao toca o arquivo.",
|
||||
"_rebaseline_2026_09_16_13720_suffix_effort_propagation": "OWNER-APPROVED 2026-09-16 (explicit exception for this unit only, chatHelpers.ts only). PR #13720 (HouMinXi, suffix-effort propagation across model attempts): src/sse/handlers/chatHelpers.ts merge-base (before PR's own commit) was 1164; the release tip independently grew it to 1213 (+49, unrelated merged PRs) while the frozen cap sat at 1214 to cover exactly that tip growth. The PR's own diff on this file is +3 lines only (threading resolvedThinkingEffort: one field on resolveModelOrError's return object, one destructured param and one passthrough call-site argument in executeChatWithBreaker — see commit 4fdb0c5851b7f645efbe5627cd0babb0c3d230c3), taking the merged result to 1216 (1217 per check-file-size.mjs's countLines, which counts the trailing newline as an extra split segment). All 3 added lines are single-property additions inside existing multi-line object literals/signatures; there is no redundant or duplicated line in the PR's own hunks to trim, and none of the +3 lines are outside the PR's own diff. Covered by tests/unit/suffix-effort-propagation.test.ts (27/27), tests/unit/chatcore-upstream-body.test.ts + tests/unit/request-dedup-tenant-isolation.test.ts (57/57), all green against this exact head.",
|
||||
"_rebaseline_2026_09_17_13947_tip_growth": "Base-red drain da PR #13947 (Refs #13866) — crescimento de PRODUCAO que chegou pelo tip e nunca foi rebaselinado; nenhum destes arquivos e tocado por esta PR. #12906 (d70f43d4, retry empty_response 502 + timeout de inicio de resposta ciente de reasoning): src/sse/handlers/chat.ts 2498->2500, src/sse/handlers/chatHelpers.ts 1231->1245, open-sse/utils/proxyFetch.ts 1275->1276, open-sse/utils/stream.ts 3098->3123. #12904 (f3acf4f8, injecao unica do system prompt global pos-traducao) + #12910 (051576fd, finalizacao de cache semantico por request id exato): open-sse/handlers/chatCore.ts 6181->6203. Anteriores ao lote, ja acima do cap na base 3d5baf13: open-sse/handlers/imageGeneration.ts 3293->3304 (#13748, b97338a8) e open-sse/services/combo/roundRobinCombo.ts 1213->1221 (#13776, aeba6b1a). Registrado contra o estado mergeado; nenhum outro cap e tocado.",
|
||||
@@ -477,7 +484,7 @@
|
||||
"open-sse/executors/codex.ts": 1570,
|
||||
"open-sse/executors/cursor.ts": 1868,
|
||||
"open-sse/executors/muse-spark-web.ts": 1405,
|
||||
"open-sse/handlers/chatCore.ts": 6287,
|
||||
"open-sse/handlers/chatCore.ts": 6402,
|
||||
"open-sse/handlers/imageGeneration.ts": 3334,
|
||||
"open-sse/handlers/search.ts": 1789,
|
||||
"open-sse/mcp-server/schemas/tools.ts": 1621,
|
||||
@@ -488,7 +495,7 @@
|
||||
"open-sse/services/combo/executeTargetAttempt.ts": 1273,
|
||||
"open-sse/translator/response/openai-responses.ts": 1518,
|
||||
"open-sse/utils/cursorAgentProtobuf.ts": 1588,
|
||||
"open-sse/utils/proxyFetch.ts": 1276,
|
||||
"open-sse/utils/proxyFetch.ts": 1287,
|
||||
"open-sse/utils/stream.ts": 3239,
|
||||
"open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts": 4398,
|
||||
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1335,
|
||||
@@ -500,7 +507,7 @@
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1631,
|
||||
"src/app/(dashboard)/dashboard/providers/page.tsx": 2025,
|
||||
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1222,
|
||||
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1477,
|
||||
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1479,
|
||||
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1271,
|
||||
"src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1607,
|
||||
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1598,
|
||||
@@ -510,25 +517,25 @@
|
||||
"src/app/api/v1/models/catalog.ts": 2127,
|
||||
"src/app/docs/lib/openapi.generated.ts": 1347,
|
||||
"src/lib/db/apiKeys.ts": 1671,
|
||||
"src/lib/db/core.ts": 1788,
|
||||
"src/lib/db/core.ts": 1800,
|
||||
"src/lib/db/migrationRunner.ts": 1206,
|
||||
"src/lib/tailscaleTunnel.ts": 1208,
|
||||
"src/lib/tokenHealthCheck.ts": 1254,
|
||||
"src/shared/components/RequestLoggerV2.tsx": 1748,
|
||||
"src/shared/constants/providers/apikey/gateways.ts": 1535,
|
||||
"src/shared/services/cliRuntime.ts": 1296,
|
||||
"src/sse/handlers/chat.ts": 2547,
|
||||
"src/sse/handlers/chat.ts": 2560,
|
||||
"src/sse/services/auth.ts": 3592,
|
||||
"tests/unit/account-fallback-service.test.ts": 2453,
|
||||
"tests/unit/provider-validation-specialty.test.ts": 4656,
|
||||
"open-sse/services/autoCombo/virtualFactory.ts": 1230,
|
||||
"open-sse/services/combo/roundRobinCombo.ts": 1261,
|
||||
"open-sse/services/combo/roundRobinCombo.ts": 1263,
|
||||
"src/shared/components/RequestLoggerDetail.tsx": 1210,
|
||||
"src/shared/middleware/chatBodyAdmission.ts": 1206,
|
||||
"open-sse/executors/deepseek-web.ts": 1224,
|
||||
"open-sse/executors/default.ts": 1205,
|
||||
"open-sse/services/rateLimitManager.ts": 1329,
|
||||
"open-sse/executors/opencode.ts": 1251
|
||||
"open-sse/executors/opencode.ts": 1318
|
||||
},
|
||||
"_rebaseline_2026_09_15_roundrobin_dashboard_events": "Fix #13089 (Combo Studio Live dashboard shows an empty backlog for round-robin combos): open-sse/services/combo/roundRobinCombo.ts 1205->1213. Round-robin is the only combo strategy that bypasses handleComboChat/executeTargetAttempt.ts, the path that publishes the combo.target.attempt/succeeded/failed EventBus events the Live dashboard listens for — so round-robin completions never showed up. The new call-site wiring (createRRDashboardEvents(...) instantiated once per target, one-line .attempt()/.succeeded()/.failed() calls at the 6 existing dispatch/outcome points) is the emitter logic actually extracted into a new module, open-sse/services/combo/rrDashboardEvents.ts — this is the minimum irreducible footprint for wiring 6 required call sites into 6 fixed control-flow points of the frozen file. Covered by tests/unit/issue-13089-roundrobin-live-ws-events.test.ts (2 tests: success + failure paths).",
|
||||
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
|
||||
@@ -725,5 +732,6 @@
|
||||
"_rebaseline_2026_09_17_13670_allow_auto_combos": "PR #13670 (@fouadSalkini): per-key allowAutoCombos para gatear os combos auto/* embutidos. src/app/api/v1/models/catalog.ts 2075->2117 e src/lib/db/apiKeys.ts 1625->1659. Crescimento e 100% proprio da PR, nao herdado: medido no tip puro, catalog.ts esta em 2074 (abaixo do teto 2075) e apiKeys.ts em 1620 (abaixo de 1625). O aumento e a propria feature — o campo de permissao por chave precisa ser lido, validado e propagado ate o filtro do catalogo, e cada ponto e chamada explicita, nao extraivel sem esconder o gate. Coberto pelos 25 testes da PR. As demais violacoes desta arvore (chatHelpers.ts, chatCore.ts e tests/unit/chatcore-translation-paths.test.ts) sao base-red herdado do tip e nao foram tocadas aqui.",
|
||||
"_rebaseline_2026_09_17c_chatcore_translation_paths_test": "tests/unit/chatcore-translation-paths.test.ts 3447->3449 (#13173, prefixos de cache de meio de conversa do Fable — as assercoes novas do caso). Ultimo teto remanescente da leva de merges de 2026-09-17; os outros dois (chatHelpers.ts e chatCore.ts) foram absorvidos pelos rebaselines das proprias PRs que mergearam depois. Medido no tip limpo.",
|
||||
"_rebaseline_2026_09_18_13929_antigravity_account_lease_merge": "PR #13929 (Re-land of #10011, @Ardem2025 via @diegosouzapw): the Antigravity account lease, merged onto the current release/v3.8.51 tip (which had independently moved chat.ts to 2520 and auth.ts to 3557 via unrelated PRs). Combined ceiling after merge: src/sse/handlers/chat.ts->2541, src/sse/services/auth.ts->3577. The lease registry, its lifecycle glue and its selection glue were extracted into three NEW modules (src/sse/services/antigravityRoutingState.ts, antigravityLeaseLifecycle.ts, antigravityLeaseSelection.ts) precisely to keep this growth to the call sites; what remains in chat.ts/auth.ts is the wiring itself, which cannot be moved out of the selection loop and the dispatch path. Every added hunk is inert unless ANTIGRAVITY_ACCOUNT_LEASE_ENABLED (default false) is on. Covered by tests/unit/antigravity-routing-state.test.ts, antigravity-lease-lifecycle.test.ts and antigravity-account-lease-flag.test.ts. UPDATE (re-sync 2026-09-18 after trains 3b/4d moved the tip): auth.ts 3577->3582 (same +29 own growth over a tip now at 3552). open-sse/executors/base.ts 1753->1754 is NOT this PR's growth — it is release-tip drift from train 3b (#13002 +5 / #13705 -4 net +1, both merged without a baseline entry); absorbed here by the captain session under the owner-approved train-rebaseline policy so the tip stops failing check:file-size for every PR boarding after it.",
|
||||
"_rebaseline_2026_09_19_14162_native_codex_auto_resume": "PR #14162 (re-land of #13180, @mdigitalbh81 via @diegosouzapw): native Codex turn auto-resume. open-sse/services/combo/executeTargetAttempt.ts 1258->1273 (+15). Growth is 100% the PR's own, measured against the clean tip (1258 there, gate green): the pin step now advances the logical turn generation and logs the resumed provider/model when the attempt is an auto-resume dispatch, and the generation is passed into pinNativeCodexTurn — the branch has to sit at the pin site because that is the only place the winning target and effective connection are known. Covered by tests/unit/native-codex-auto-resume.test.ts + native-codex-auto-resume-guards.test.ts (15/15) and #13564's native-codex-turn-pin-model-scoped-fallback.test.ts (7/7)."
|
||||
"_rebaseline_2026_09_19_14162_native_codex_auto_resume": "PR #14162 (re-land of #13180, @mdigitalbh81 via @diegosouzapw): native Codex turn auto-resume. open-sse/services/combo/executeTargetAttempt.ts 1258->1273 (+15). Growth is 100% the PR's own, measured against the clean tip (1258 there, gate green): the pin step now advances the logical turn generation and logs the resumed provider/model when the attempt is an auto-resume dispatch, and the generation is passed into pinNativeCodexTurn — the branch has to sit at the pin site because that is the only place the winning target and effective connection are known. Covered by tests/unit/native-codex-auto-resume.test.ts + native-codex-auto-resume-guards.test.ts (15/15) and #13564's native-codex-turn-pin-model-scoped-fallback.test.ts (7/7).",
|
||||
"_rebaseline_2026_09_22_14405_freetier_observed_tools_retry": "Issue #14405 own growth: open-sse/executors/opencode.ts 1251->1303 (measured after merging release/v3.8.51, whose own drift entry _rebaseline_2026_09_22_opencode_train10c_drift had already moved the ceiling 1247->1251; the extra 5 lines over the original 1247->1294 measurement are the #14148 reconciliation: freeTierRetryCtx now reads the contract attempt back from the request body via attemptFor() instead of the removed shared _contractAttempt field) (+47 irreducible call-site wiring: freeTierRetryCtx builder + direct fast-path retry call + rotation-loop refusal arm delegating to handleLoopFreeTierRefusal + api-typecheck fixes (unknown-cast on stall-guard dispatch, callback-shaped loop handler keeping private finalize methods); the retry logic itself lives in the new module open-sse/executors/opencodeFreeTierRetry.ts (131 lines, under cap) and the merge helper in opencodeFreeTierContract.ts, so no logic was added to the frozen file beyond wiring two existing dispatch arms. Compaction attempts measured and reverted: loop-call wrapper (+22 net), fast-path/loop fusion (fragile: retry-403 vs original-403 share status). Covered by tests/unit/opencode-free-tier-refusal-rotation.test.ts (3 retry tests: union success, original refusal + store untouched, no retry without names) + tests/unit/opencode-free-tier-request-contract.test.ts (2 merge tests)."
|
||||
}
|
||||
|
||||
@@ -2,70 +2,71 @@
|
||||
"_comment": "Catraca de tradução real (valor idêntico ao en.json, placeholder ou ausente, fora do allowlist untranslatable-keys.json) em % por locale. Só pode cair. Atualize via `npm run i18n:check-ratio:update` quando um locale melhora. Valores medidos, nunca chutados.",
|
||||
"slack": 0.5,
|
||||
"locales": {
|
||||
"am": 1.4,
|
||||
"ar": 0.9,
|
||||
"az": 2.2,
|
||||
"bg": 1.2,
|
||||
"bn": 1.2,
|
||||
"cs": 2.1,
|
||||
"da": 3.6,
|
||||
"de": 3,
|
||||
"el": 1.5,
|
||||
"es": 2.1,
|
||||
"et": 1.8,
|
||||
"fa": 1.1,
|
||||
"fi": 1.5,
|
||||
"fr": 3.4,
|
||||
"ga": 1.5,
|
||||
"gu": 1.2,
|
||||
"ha": 1.5,
|
||||
"he": 1.1,
|
||||
"hi": 1,
|
||||
"hr": 2.2,
|
||||
"hu": 1.6,
|
||||
"hy": 1.3,
|
||||
"id": 2.5,
|
||||
"ig": 1.6,
|
||||
"it": 2.6,
|
||||
"ja": 1.1,
|
||||
"ka": 1.4,
|
||||
"km": 1.4,
|
||||
"kn": 1.3,
|
||||
"ko": 1.3,
|
||||
"lt": 1.5,
|
||||
"lv": 1.6,
|
||||
"ml": 1.4,
|
||||
"mr": 1.3,
|
||||
"ms": 2.4,
|
||||
"mt": 2.3,
|
||||
"my": 1.5,
|
||||
"ne": 1.4,
|
||||
"nl": 3.8,
|
||||
"no": 2.5,
|
||||
"or": 1.4,
|
||||
"pa": 1.4,
|
||||
"phi": 3.2,
|
||||
"pl": 2.4,
|
||||
"pt": 2,
|
||||
"pt-BR": 2.5,
|
||||
"ro": 2.7,
|
||||
"ru": 1,
|
||||
"si": 1.4,
|
||||
"sk": 2,
|
||||
"sl": 1.8,
|
||||
"sr": 1.4,
|
||||
"sv": 2.7,
|
||||
"sw": 1.5,
|
||||
"ta": 1.3,
|
||||
"te": 1.2,
|
||||
"th": 1.1,
|
||||
"tr": 1.6,
|
||||
"uk-UA": 1.2,
|
||||
"ur": 1.2,
|
||||
"uz": 2.2,
|
||||
"am": 1.5,
|
||||
"ar": 1,
|
||||
"az": 2.4,
|
||||
"bg": 1.3,
|
||||
"bn": 1.4,
|
||||
"bs": 2.5,
|
||||
"cs": 2.3,
|
||||
"da": 3.8,
|
||||
"de": 3.2,
|
||||
"el": 1.6,
|
||||
"es": 2.2,
|
||||
"et": 1.9,
|
||||
"fa": 1.3,
|
||||
"fi": 1.6,
|
||||
"fr": 3.5,
|
||||
"ga": 1.6,
|
||||
"gu": 1.4,
|
||||
"ha": 1.7,
|
||||
"he": 1.3,
|
||||
"hi": 1.1,
|
||||
"hr": 2.3,
|
||||
"hu": 1.8,
|
||||
"hy": 1.5,
|
||||
"id": 2.6,
|
||||
"ig": 1.7,
|
||||
"it": 2.7,
|
||||
"ja": 1.3,
|
||||
"ka": 1.6,
|
||||
"km": 1.5,
|
||||
"kn": 1.5,
|
||||
"ko": 1.5,
|
||||
"lt": 1.6,
|
||||
"lv": 1.8,
|
||||
"ml": 1.5,
|
||||
"mr": 1.4,
|
||||
"ms": 2.5,
|
||||
"mt": 2.4,
|
||||
"my": 1.6,
|
||||
"ne": 1.6,
|
||||
"nl": 3.9,
|
||||
"no": 2.6,
|
||||
"or": 1.5,
|
||||
"pa": 1.5,
|
||||
"phi": 3.3,
|
||||
"pl": 2.5,
|
||||
"pt": 2.1,
|
||||
"pt-BR": 2.8,
|
||||
"ro": 2.8,
|
||||
"ru": 1.1,
|
||||
"si": 1.6,
|
||||
"sk": 2.2,
|
||||
"sl": 1.9,
|
||||
"sr": 1.6,
|
||||
"sv": 2.8,
|
||||
"sw": 1.6,
|
||||
"ta": 1.5,
|
||||
"te": 1.4,
|
||||
"th": 1.3,
|
||||
"tr": 1.8,
|
||||
"uk-UA": 1.4,
|
||||
"ur": 1.4,
|
||||
"uz": 2.3,
|
||||
"vi": 1.5,
|
||||
"yo": 1.4,
|
||||
"zh-CN": 1,
|
||||
"zh-TW": 1.1
|
||||
"yo": 1.5,
|
||||
"zh-CN": 1.1,
|
||||
"zh-TW": 1.2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ Mermaid sources and exported SVG/PNG diagrams referenced from the docs above. Se
|
||||
|
||||
## i18n/
|
||||
|
||||
Translated mirrors of the documentation in 65 locales (plus the English originals — 66 languages in total). See [i18n/README.md](i18n/README.md) for the supported language list.
|
||||
Translated mirrors of the documentation in 66 locales (plus the English originals — 67 languages in total). See [i18n/README.md](i18n/README.md) for the supported language list.
|
||||
|
||||
## screenshots/
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ lastUpdated: 2026-06-28
|
||||
|
||||
# OmniRoute Architecture
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](./ARCHITECTURE.md) | 🇪🇹 [አማርኛ](../i18n/am/docs/architecture/ARCHITECTURE.md) | 🇸🇦 [العربية](../i18n/ar/docs/architecture/ARCHITECTURE.md) | 🇦🇿 [Azərbaycan dili](../i18n/az/docs/architecture/ARCHITECTURE.md) | 🇧🇬 [Български](../i18n/bg/docs/architecture/ARCHITECTURE.md) | 🇧🇩 [বাংলা](../i18n/bn/docs/architecture/ARCHITECTURE.md) | 🇨🇿 [Čeština](../i18n/cs/docs/architecture/ARCHITECTURE.md) | 🇩🇰 [Dansk](../i18n/da/docs/architecture/ARCHITECTURE.md) | 🇩🇪 [Deutsch](../i18n/de/docs/architecture/ARCHITECTURE.md) | 🇬🇷 [Ελληνικά](../i18n/el/docs/architecture/ARCHITECTURE.md) | 🇪🇸 [Español](../i18n/es/docs/architecture/ARCHITECTURE.md) | 🇪🇪 [Eesti](../i18n/et/docs/architecture/ARCHITECTURE.md) | 🇮🇷 [فارسی](../i18n/fa/docs/architecture/ARCHITECTURE.md) | 🇫🇮 [Suomi](../i18n/fi/docs/architecture/ARCHITECTURE.md) | 🇫🇷 [Français](../i18n/fr/docs/architecture/ARCHITECTURE.md) | 🇮🇪 [Gaeilge](../i18n/ga/docs/architecture/ARCHITECTURE.md) | 🇮🇳 [ગુજરાતી](../i18n/gu/docs/architecture/ARCHITECTURE.md) | 🇳🇬 [Hausa](../i18n/ha/docs/architecture/ARCHITECTURE.md) | 🇮🇱 [עברית](../i18n/he/docs/architecture/ARCHITECTURE.md) | 🇮🇳 [हिन्दी](../i18n/hi/docs/architecture/ARCHITECTURE.md) | 🇭🇷 [Hrvatski](../i18n/hr/docs/architecture/ARCHITECTURE.md) | 🇭🇺 [Magyar](../i18n/hu/docs/architecture/ARCHITECTURE.md) | 🇦🇲 [Հայերեն](../i18n/hy/docs/architecture/ARCHITECTURE.md) | 🇮🇩 [Bahasa Indonesia](../i18n/id/docs/architecture/ARCHITECTURE.md) | 🇳🇬 [Igbo](../i18n/ig/docs/architecture/ARCHITECTURE.md) | 🇮🇹 [Italiano](../i18n/it/docs/architecture/ARCHITECTURE.md) | 🇯🇵 [日本語](../i18n/ja/docs/architecture/ARCHITECTURE.md) | 🇬🇪 [ქართული](../i18n/ka/docs/architecture/ARCHITECTURE.md) | 🇰🇭 [ខ្មែរ](../i18n/km/docs/architecture/ARCHITECTURE.md) | 🇮🇳 [ಕನ್ನಡ](../i18n/kn/docs/architecture/ARCHITECTURE.md) | 🇰🇷 [한국어](../i18n/ko/docs/architecture/ARCHITECTURE.md) | 🇱🇹 [Lietuvių](../i18n/lt/docs/architecture/ARCHITECTURE.md) | 🇱🇻 [Latviešu](../i18n/lv/docs/architecture/ARCHITECTURE.md) | 🇮🇳 [മലയാളം](../i18n/ml/docs/architecture/ARCHITECTURE.md) | 🇮🇳 [मराठी](../i18n/mr/docs/architecture/ARCHITECTURE.md) | 🇲🇾 [Bahasa Melayu](../i18n/ms/docs/architecture/ARCHITECTURE.md) | 🇲🇹 [Malti](../i18n/mt/docs/architecture/ARCHITECTURE.md) | 🇲🇲 [မြန်မာ](../i18n/my/docs/architecture/ARCHITECTURE.md) | 🇳🇵 [नेपाली](../i18n/ne/docs/architecture/ARCHITECTURE.md) | 🇳🇱 [Nederlands](../i18n/nl/docs/architecture/ARCHITECTURE.md) | 🇳🇴 [Norsk](../i18n/no/docs/architecture/ARCHITECTURE.md) | 🇮🇳 [ଓଡ଼ିଆ](../i18n/or/docs/architecture/ARCHITECTURE.md) | 🇮🇳 [ਪੰਜਾਬੀ](../i18n/pa/docs/architecture/ARCHITECTURE.md) | 🇵🇭 [Filipino](../i18n/phi/docs/architecture/ARCHITECTURE.md) | 🇵🇱 [Polski](../i18n/pl/docs/architecture/ARCHITECTURE.md) | 🇵🇹 [Português (Portugal)](../i18n/pt/docs/architecture/ARCHITECTURE.md) | 🇧🇷 [Português (Brasil)](../i18n/pt-BR/docs/architecture/ARCHITECTURE.md) | 🇷🇴 [Română](../i18n/ro/docs/architecture/ARCHITECTURE.md) | 🇷🇺 [Русский](../i18n/ru/docs/architecture/ARCHITECTURE.md) | 🇱🇰 [සිංහල](../i18n/si/docs/architecture/ARCHITECTURE.md) | 🇸🇰 [Slovenčina](../i18n/sk/docs/architecture/ARCHITECTURE.md) | 🇸🇮 [Slovenščina](../i18n/sl/docs/architecture/ARCHITECTURE.md) | 🇷🇸 [Српски](../i18n/sr/docs/architecture/ARCHITECTURE.md) | 🇸🇪 [Svenska](../i18n/sv/docs/architecture/ARCHITECTURE.md) | 🇰🇪 [Kiswahili](../i18n/sw/docs/architecture/ARCHITECTURE.md) | 🇮🇳 [தமிழ்](../i18n/ta/docs/architecture/ARCHITECTURE.md) | 🇮🇳 [తెలుగు](../i18n/te/docs/architecture/ARCHITECTURE.md) | 🇹🇭 [ไทย](../i18n/th/docs/architecture/ARCHITECTURE.md) | 🇹🇷 [Türkçe](../i18n/tr/docs/architecture/ARCHITECTURE.md) | 🇺🇦 [Українська](../i18n/uk-UA/docs/architecture/ARCHITECTURE.md) | 🇵🇰 [اردو](../i18n/ur/docs/architecture/ARCHITECTURE.md) | 🇺🇿 [Oʻzbekcha](../i18n/uz/docs/architecture/ARCHITECTURE.md) | 🇻🇳 [Tiếng Việt](../i18n/vi/docs/architecture/ARCHITECTURE.md) | 🇳🇬 [Yorùbá](../i18n/yo/docs/architecture/ARCHITECTURE.md) | 🇨🇳 [中文 (简体)](../i18n/zh-CN/docs/architecture/ARCHITECTURE.md) | 🇹🇼 [中文 (繁體)](../i18n/zh-TW/docs/architecture/ARCHITECTURE.md)
|
||||
🌐 **Languages:** 🇺🇸 [English](./ARCHITECTURE.md) | 🇪🇹 [አማርኛ](../i18n/am/docs/architecture/ARCHITECTURE.md) | 🇸🇦 [العربية](../i18n/ar/docs/architecture/ARCHITECTURE.md) | 🇦🇿 [Azərbaycan dili](../i18n/az/docs/architecture/ARCHITECTURE.md) | 🇧🇬 [Български](../i18n/bg/docs/architecture/ARCHITECTURE.md) | 🇧🇩 [বাংলা](../i18n/bn/docs/architecture/ARCHITECTURE.md) | 🇧🇦 [Bosanski](../i18n/bs/docs/architecture/ARCHITECTURE.md) | 🇨🇿 [Čeština](../i18n/cs/docs/architecture/ARCHITECTURE.md) | 🇩🇰 [Dansk](../i18n/da/docs/architecture/ARCHITECTURE.md) | 🇩🇪 [Deutsch](../i18n/de/docs/architecture/ARCHITECTURE.md) | 🇬🇷 [Ελληνικά](../i18n/el/docs/architecture/ARCHITECTURE.md) | 🇪🇸 [Español](../i18n/es/docs/architecture/ARCHITECTURE.md) | 🇪🇪 [Eesti](../i18n/et/docs/architecture/ARCHITECTURE.md) | 🇮🇷 [فارسی](../i18n/fa/docs/architecture/ARCHITECTURE.md) | 🇫🇮 [Suomi](../i18n/fi/docs/architecture/ARCHITECTURE.md) | 🇫🇷 [Français](../i18n/fr/docs/architecture/ARCHITECTURE.md) | 🇮🇪 [Gaeilge](../i18n/ga/docs/architecture/ARCHITECTURE.md) | 🇮🇳 [ગુજરાતી](../i18n/gu/docs/architecture/ARCHITECTURE.md) | 🇳🇬 [Hausa](../i18n/ha/docs/architecture/ARCHITECTURE.md) | 🇮🇱 [עברית](../i18n/he/docs/architecture/ARCHITECTURE.md) | 🇮🇳 [हिन्दी](../i18n/hi/docs/architecture/ARCHITECTURE.md) | 🇭🇷 [Hrvatski](../i18n/hr/docs/architecture/ARCHITECTURE.md) | 🇭🇺 [Magyar](../i18n/hu/docs/architecture/ARCHITECTURE.md) | 🇦🇲 [Հայերեն](../i18n/hy/docs/architecture/ARCHITECTURE.md) | 🇮🇩 [Bahasa Indonesia](../i18n/id/docs/architecture/ARCHITECTURE.md) | 🇳🇬 [Igbo](../i18n/ig/docs/architecture/ARCHITECTURE.md) | 🇮🇹 [Italiano](../i18n/it/docs/architecture/ARCHITECTURE.md) | 🇯🇵 [日本語](../i18n/ja/docs/architecture/ARCHITECTURE.md) | 🇬🇪 [ქართული](../i18n/ka/docs/architecture/ARCHITECTURE.md) | 🇰🇭 [ខ្មែរ](../i18n/km/docs/architecture/ARCHITECTURE.md) | 🇮🇳 [ಕನ್ನಡ](../i18n/kn/docs/architecture/ARCHITECTURE.md) | 🇰🇷 [한국어](../i18n/ko/docs/architecture/ARCHITECTURE.md) | 🇱🇹 [Lietuvių](../i18n/lt/docs/architecture/ARCHITECTURE.md) | 🇱🇻 [Latviešu](../i18n/lv/docs/architecture/ARCHITECTURE.md) | 🇮🇳 [മലയാളം](../i18n/ml/docs/architecture/ARCHITECTURE.md) | 🇮🇳 [मराठी](../i18n/mr/docs/architecture/ARCHITECTURE.md) | 🇲🇾 [Bahasa Melayu](../i18n/ms/docs/architecture/ARCHITECTURE.md) | 🇲🇹 [Malti](../i18n/mt/docs/architecture/ARCHITECTURE.md) | 🇲🇲 [မြန်မာ](../i18n/my/docs/architecture/ARCHITECTURE.md) | 🇳🇵 [नेपाली](../i18n/ne/docs/architecture/ARCHITECTURE.md) | 🇳🇱 [Nederlands](../i18n/nl/docs/architecture/ARCHITECTURE.md) | 🇳🇴 [Norsk](../i18n/no/docs/architecture/ARCHITECTURE.md) | 🇮🇳 [ଓଡ଼ିଆ](../i18n/or/docs/architecture/ARCHITECTURE.md) | 🇮🇳 [ਪੰਜਾਬੀ](../i18n/pa/docs/architecture/ARCHITECTURE.md) | 🇵🇭 [Filipino](../i18n/phi/docs/architecture/ARCHITECTURE.md) | 🇵🇱 [Polski](../i18n/pl/docs/architecture/ARCHITECTURE.md) | 🇵🇹 [Português (Portugal)](../i18n/pt/docs/architecture/ARCHITECTURE.md) | 🇧🇷 [Português (Brasil)](../i18n/pt-BR/docs/architecture/ARCHITECTURE.md) | 🇷🇴 [Română](../i18n/ro/docs/architecture/ARCHITECTURE.md) | 🇷🇺 [Русский](../i18n/ru/docs/architecture/ARCHITECTURE.md) | 🇱🇰 [සිංහල](../i18n/si/docs/architecture/ARCHITECTURE.md) | 🇸🇰 [Slovenčina](../i18n/sk/docs/architecture/ARCHITECTURE.md) | 🇸🇮 [Slovenščina](../i18n/sl/docs/architecture/ARCHITECTURE.md) | 🇷🇸 [Српски](../i18n/sr/docs/architecture/ARCHITECTURE.md) | 🇸🇪 [Svenska](../i18n/sv/docs/architecture/ARCHITECTURE.md) | 🇰🇪 [Kiswahili](../i18n/sw/docs/architecture/ARCHITECTURE.md) | 🇮🇳 [தமிழ்](../i18n/ta/docs/architecture/ARCHITECTURE.md) | 🇮🇳 [తెలుగు](../i18n/te/docs/architecture/ARCHITECTURE.md) | 🇹🇭 [ไทย](../i18n/th/docs/architecture/ARCHITECTURE.md) | 🇹🇷 [Türkçe](../i18n/tr/docs/architecture/ARCHITECTURE.md) | 🇺🇦 [Українська](../i18n/uk-UA/docs/architecture/ARCHITECTURE.md) | 🇵🇰 [اردو](../i18n/ur/docs/architecture/ARCHITECTURE.md) | 🇺🇿 [Oʻzbekcha](../i18n/uz/docs/architecture/ARCHITECTURE.md) | 🇻🇳 [Tiếng Việt](../i18n/vi/docs/architecture/ARCHITECTURE.md) | 🇳🇬 [Yorùbá](../i18n/yo/docs/architecture/ARCHITECTURE.md) | 🇨🇳 [中文 (简体)](../i18n/zh-CN/docs/architecture/ARCHITECTURE.md) | 🇹🇼 [中文 (繁體)](../i18n/zh-TW/docs/architecture/ARCHITECTURE.md)
|
||||
|
||||
_Last updated: 2026-06-28_
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: "Authorization Guide"
|
||||
version: 3.8.40
|
||||
lastUpdated: 2026-06-28
|
||||
lastUpdated: 2026-09-22
|
||||
---
|
||||
|
||||
# Authorization Guide
|
||||
|
||||
> **Source of truth:** `src/server/authz/`, `src/shared/constants/publicApiRoutes.ts`, `src/lib/api/requireManagementAuth.ts`, `src/shared/utils/apiAuth.ts`
|
||||
> **Last updated:** 2026-06-28 — v3.8.40
|
||||
> **Last updated:** 2026-09-22 — scope namespaces point at MCP-SERVER.md
|
||||
|
||||
OmniRoute has a route-aware authorization pipeline that gates every API request. Classification is **deterministic** and **fail-closed** — anything that cannot be classified ends up as `MANAGEMENT` and demands a session or management-grade token. This page explains the model for engineers maintaining routes or designing new endpoints.
|
||||
|
||||
@@ -204,26 +204,36 @@ Pick the set by shape, not by convenience. One route goes in `PUBLIC_API_ROUTES_
|
||||
|
||||
## Scopes
|
||||
|
||||
Three namespaces. Each checker reads only its own strings. The side-by-side,
|
||||
including why `manage` fails `scopeMatches` for `read:compression` and why a
|
||||
`read` access token cannot `PATCH /api/keys/{id}`, is
|
||||
[Three scope namespaces](../frameworks/MCP-SERVER.md#three-scope-namespaces).
|
||||
|
||||
API keys carry a `scopes` array (stored as JSON in `api_keys.scopes`, see `src/lib/db/apiKeys.ts`).
|
||||
|
||||
### Management scope
|
||||
|
||||
- `manage` / `admin` — grants the key access to management API endpoints when sent as Bearer.
|
||||
- `manage` / `admin` — `hasManageScope`. Bearer access to management API routes.
|
||||
- `mcp:connect`, `self:usage`, `self:account-quota`, and
|
||||
`policy:bypass-provider-quota` are additive exact-match scopes. They sit
|
||||
outside `MANAGEMENT_API_KEY_SCOPES`. `mcp:connect` opens only the
|
||||
`/api/mcp/` non-loopback carve-out.
|
||||
|
||||
### MCP scopes (`src/shared/constants/mcpScopes.ts`)
|
||||
### MCP tool scopes
|
||||
|
||||
Each MCP tool requires specific scopes via `MCP_TOOL_SCOPES`. Full list (`MCP_SCOPE_LIST`):
|
||||
Catalog and matching rules (identical string, or a granted scope ending in `*`):
|
||||
[MCP tool scopes](../frameworks/MCP-SERVER.md#mcp-tool-scopes).
|
||||
`MCP_SCOPE_LIST` in `src/shared/constants/mcpScopes.ts` is the original typed
|
||||
subset, not that full catalog. Enforcement runs in
|
||||
`open-sse/mcp-server/scopeEnforcement.ts` after `resolveCallerScopeContext()`
|
||||
resolves scopes from MCP auth info, request metadata, or `OMNIROUTE_MCP_SCOPES`.
|
||||
It stays off unless `OMNIROUTE_MCP_ENFORCE_SCOPES=true`.
|
||||
|
||||
```
|
||||
read:health, read:combos, write:combos, read:quota, read:usage,
|
||||
read:models, execute:completions, execute:search, write:budget,
|
||||
write:resilience, pricing:write, read:cache, write:cache,
|
||||
read:compression, write:compression, read:proxies
|
||||
```
|
||||
### Access-token scopes
|
||||
|
||||
Scope enforcement in `open-sse/mcp-server/server.ts` passes each tool's scope list into
|
||||
`evaluateToolScopes()` after `resolveCallerScopeContext()` resolves scopes from MCP auth info,
|
||||
request metadata, or `OMNIROUTE_MCP_SCOPES`.
|
||||
`read` / `write` / `admin` on `oma_live_…` tokens, ranked by `scopeSatisfies`
|
||||
(`src/lib/accessTokens/scopes.ts`). This rank applies to the access-token
|
||||
credential only. See [Management Authentication](../guides/MANAGEMENT-AUTH.md).
|
||||
|
||||
## Auth Required Toggle
|
||||
|
||||
@@ -273,5 +283,5 @@ Use `assertAuth(req, expectedClass)` inside handlers — it throws `AuthzAsserti
|
||||
|
||||
- [API_REFERENCE.md](../reference/API_REFERENCE.md) — auth marker per endpoint
|
||||
- [COMPLIANCE.md](../security/COMPLIANCE.md) — audit log for auth events
|
||||
- [MCP-SERVER.md](../frameworks/MCP-SERVER.md) — MCP scope enforcement details
|
||||
- [MCP-SERVER.md](../frameworks/MCP-SERVER.md#three-scope-namespaces) — three scope namespaces and MCP tool-scope catalog
|
||||
- Source: `src/server/authz/`, `src/lib/api/requireManagementAuth.ts`
|
||||
|
||||
12
docs/assets/flags/ba.svg
Normal file
12
docs/assets/flags/ba.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ba" viewBox="0 0 640 480">
|
||||
<defs>
|
||||
<clipPath id="ba-a">
|
||||
<path fill-opacity=".7" d="M-85.3 0h682.6v512H-85.3z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g fill-rule="evenodd" clip-path="url(#ba-a)" transform="translate(80)scale(.9375)">
|
||||
<path fill="#009" d="M-85.3 0h682.6v512H-85.3z"/>
|
||||
<path fill="#FC0" d="m56.5 0 511 512.3V.3z"/>
|
||||
<path fill="#FFF" d="M439.9 481.5 412 461.2l-28.6 20.2 10.8-33.2-28.2-20.5h35l10.8-33.2 10.7 33.3h35l-28 20.7zm81.3 10.4-35-.1-10.7-33.3-10.8 33.2h-35l28.2 20.5-10.8 33.2 28.6-20.2 28 20.3-10.5-33zM365.6 384.7l28-20.7-35-.1-10.7-33.2-10.8 33.2-35-.1 28.2 20.5-10.8 33.3 28.6-20.3 28 20.4zm-64.3-64.5 28-20.6-35-.1-10.7-33.3-10.9 33.2h-34.9l28.2 20.5-10.8 33.2 28.6-20.2 27.9 20.3zm-63.7-63.6 28-20.7h-35L220 202.5l-10.8 33.2h-35l28.2 20.4-10.8 33.3 28.6-20.3 28 20.4-10.5-33zm-64.4-64.3 28-20.6-35-.1-10.7-33.3-10.9 33.2h-34.9L138 192l-10.8 33.2 28.6-20.2 27.9 20.3-10.4-33zm-63.6-63.9 27.9-20.7h-35L91.9 74.3 81 107.6H46L74.4 128l-10.9 33.2L92.1 141l27.8 20.4zm-64-64 27.9-20.7h-35L27.9 10.3 17 43.6h-35L10.4 64l-11 33.3L28.1 77l27.8 20.4zm-64-64L9.4-20.3h-35l-10.7-33.3L-47-20.4h-35L-53.7 0l-10.8 33.2L-35.9 13l27.8 20.4z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -11,16 +11,16 @@ OmniRoute compression is built around engine contracts. A mode can run one engin
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | Engine path | Intended input |
|
||||
| ------------ | ---------------------------------- | -------------------------------------------- |
|
||||
| `off` | none | Exact prompt preservation |
|
||||
| `lite` | Caveman lite helpers | Low-risk always-on cleanup |
|
||||
| `standard` | Caveman | Natural-language prompt condensation |
|
||||
| `aggressive` | Caveman + history/tool summarizers | Long chat sessions |
|
||||
| `ultra` | Caveman + pruning helpers | Context-limit recovery |
|
||||
| `rtk` | RTK | Terminal, shell, build, test, and git output |
|
||||
| `omniglyph` | OmniGlyph | Context-as-image on the native provider wire |
|
||||
| `stacked` | Pipeline, default `rtk -> caveman` | Mixed tool logs and prose, max savings |
|
||||
| Mode | Engine path | Intended input |
|
||||
| ------------ | ------------------------------------------------------------------------------------- | -------------------------------------------- |
|
||||
| `off` | none | Exact prompt preservation |
|
||||
| `lite` | Caveman lite helpers | Low-risk always-on cleanup |
|
||||
| `standard` | Caveman | Natural-language prompt condensation |
|
||||
| `aggressive` | Caveman + history/tool summarizers | Long chat sessions |
|
||||
| `ultra` | Caveman + pruning helpers | Context-limit recovery |
|
||||
| `rtk` | RTK | Terminal, shell, build, test, and git output |
|
||||
| `omniglyph` | OmniGlyph | Context-as-image on the native provider wire |
|
||||
| `stacked` | Pipeline. The request default is `session-dedup -> lite`. `rtk -> caveman` is opt-in. | Mixed tool logs and prose, max savings |
|
||||
|
||||
### OmniGlyph compression profiles
|
||||
|
||||
|
||||
@@ -226,12 +226,17 @@ auto-trigger, and the panel Default. Unknown values are ignored (the request is
|
||||
the global master switch still gates everything: when compression is off globally, the header cannot
|
||||
turn it on. Values:
|
||||
|
||||
| Value | Effect |
|
||||
| ------------- | -------------------------------------------------------------------- |
|
||||
| `off` | No compression for this request. |
|
||||
| `default` | The panel-derived Default profile (ignores the active profile). |
|
||||
| `engine:<id>` | A single engine when enabled, e.g. `engine:rtk`. |
|
||||
| `<combo>` | A named combo, matched by name (case-insensitive) first, then by id. |
|
||||
| Value | Effect |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `off` | No compression for this request. |
|
||||
| `default` | The panel-derived Default profile (ignores the active profile). Lossy engines are left off. |
|
||||
| `safe` | Same as omitting the header: dedup and whitespace folding only. |
|
||||
| `allow-lossy` | Keep this request's operator plan, including summaries, relevance filters, and style rewrites. |
|
||||
| `engine:<id>` | A single engine when enabled, e.g. `engine:rtk`. This is the per-request opt-in for that engine. |
|
||||
| `<combo>` | A named combo, matched by name (case-insensitive) first, then by id. |
|
||||
|
||||
Without `allow-lossy`, `engine:<id>`, or a named combo, lossy engines are not applied. The
|
||||
request still gets session dedup and whitespace folding when compression is on.
|
||||
|
||||
The applied plan is echoed back in the `X-OmniRoute-Compression: <mode>; source=<source>` response
|
||||
header, where `<source>` is one of `request-header`, `routing-override`, `active-profile`,
|
||||
@@ -303,12 +308,12 @@ Every compressed request includes stats in the server logs:
|
||||
|
||||
## Phase Roadmap
|
||||
|
||||
| Phase | Modes | Status |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
|
||||
| Phase 1 | Off, Lite | ✅ Shipped |
|
||||
| Phase 2 | Standard, Aggressive, Ultra | ✅ Shipped |
|
||||
| Phase 3 | RTK, Stacked, Compression Combos | ✅ Shipped |
|
||||
| Phase 4 | Output Styles, SLM-tier Ultra, eval harness | ✅ Shipped |
|
||||
| Phase | Modes | Status |
|
||||
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||
| Phase 1 | Off, Lite | ✅ Shipped |
|
||||
| Phase 2 | Standard, Aggressive, Ultra | ✅ Shipped |
|
||||
| Phase 3 | RTK, Stacked, Compression Combos | ✅ Shipped |
|
||||
| Phase 4 | Output Styles, SLM-tier Ultra, eval harness | ✅ Shipped |
|
||||
| Phase 4C | Adaptive context-budget ("dial") — compute engine + API (`contextBudget` on `PUT /api/settings/compression`) + dashboard mode/policy controls | ✅ Shipped |
|
||||
|
||||
---
|
||||
@@ -454,13 +459,13 @@ into a catalog of composable output styles: `OUTPUT_STYLE_CATALOG` in
|
||||
instruction that makes the model itself produce cheaper output; styles can be enabled
|
||||
together and are injected in catalog order.
|
||||
|
||||
| Style | `id` | What it does | Instruction languages |
|
||||
| --- | --- | --- | --- |
|
||||
| Terse prose | `terse-prose` | Drop filler/articles/hedging; keep technical substance exact. Same text as the legacy caveman output mode (referenced, not re-typed). | en, pt-BR, es, de, fr, it, ru, zh, ja, id, vi |
|
||||
| Less code | `less-code` | YAGNI ladder: smallest working change, no unrequested abstractions. | en, pt-BR, es, de, fr, it, ru, zh, ja, id, vi |
|
||||
| Ponytail (lazy senior dev) | `ponytail` | "The best code is the code never written": reuse > rewrite, root cause > symptom, shortest working diff. | en, pt-BR, es, de, fr, it, ru, zh, ja, id, vi |
|
||||
| I have ADHD (action-first) | `i-have-adhd` | Action first (command/path/snippet before prose), numbered bounded steps, ONE concrete next step, no preamble/recap/closers. Adapted from [ayghri/i-have-adhd](https://github.com/ayghri/i-have-adhd) (MIT). | en, pt-BR, es, de, fr, it, ru, zh, ja, id, vi |
|
||||
| Terse CJK (文言) | `terse-cjk` | Classical-Chinese ultra-terse style. | zh (locale-gated: only offered when the resolved language is `zh`) |
|
||||
| Style | `id` | What it does | Instruction languages |
|
||||
| -------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| Terse prose | `terse-prose` | Drop filler/articles/hedging; keep technical substance exact. Same text as the legacy caveman output mode (referenced, not re-typed). | en, pt-BR, es, de, fr, it, ru, zh, ja, id, vi |
|
||||
| Less code | `less-code` | YAGNI ladder: smallest working change, no unrequested abstractions. | en, pt-BR, es, de, fr, it, ru, zh, ja, id, vi |
|
||||
| Ponytail (lazy senior dev) | `ponytail` | "The best code is the code never written": reuse > rewrite, root cause > symptom, shortest working diff. | en, pt-BR, es, de, fr, it, ru, zh, ja, id, vi |
|
||||
| I have ADHD (action-first) | `i-have-adhd` | Action first (command/path/snippet before prose), numbered bounded steps, ONE concrete next step, no preamble/recap/closers. Adapted from [ayghri/i-have-adhd](https://github.com/ayghri/i-have-adhd) (MIT). | en, pt-BR, es, de, fr, it, ru, zh, ja, id, vi |
|
||||
| Terse CJK (文言) | `terse-cjk` | Classical-Chinese ultra-terse style. | zh (locale-gated: only offered when the resolved language is `zh`) |
|
||||
|
||||
Every style ships three intensity levels — `lite`, `full`, `ultra` — and every level
|
||||
ends with the shared boundaries clause, which keeps code blocks, file paths, commands,
|
||||
|
||||
@@ -5,7 +5,7 @@ flowchart LR
|
||||
Src["Source MDs<br/>(CLAUDE.md, docs/**/*.md)"] --> Hash["sha256 hash"]
|
||||
Hash --> State[".i18n-state.json"]
|
||||
State -->|diff| Dirty["Mark source dirty"]
|
||||
Dirty --> Loop{"For each locale<br/>(66 langs)"}
|
||||
Dirty --> Loop{"For each locale<br/>(67 langs)"}
|
||||
Loop --> LLM["OmniRoute<br/>/chat/completions<br/>(cx/gpt-5.4-mini)"]
|
||||
LLM --> Target["Write<br/>docs/i18n/<locale>/<rel-path>.md"]
|
||||
Target --> State
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "OmniRoute MCP Server Documentation"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-08
|
||||
version: 3.8.51
|
||||
lastUpdated: 2026-09-22
|
||||
---
|
||||
|
||||
# OmniRoute MCP Server Documentation
|
||||
@@ -293,8 +293,108 @@ Both SSE and Streamable HTTP transports are blocked until the MCP server is enab
|
||||
|
||||
## Authentication & Scopes
|
||||
|
||||
MCP tools are authenticated through API key scopes. Scope enforcement is centralized in
|
||||
`open-sse/mcp-server/scopeEnforcement.ts`. Each tool requires specific scopes:
|
||||
MCP tool calls read scope strings from the caller. That check is one of three
|
||||
independent namespaces. A pass from one checker is not a pass from the others.
|
||||
The rules are [Three scope namespaces](#three-scope-namespaces).
|
||||
The tool catalog is [MCP tool scopes](#mcp-tool-scopes).
|
||||
|
||||
### Three scope namespaces
|
||||
|
||||
`manage` on an API key, `read:compression` on an MCP tool, and `read` on an
|
||||
`oma_live_…` access token are three different grants. Callers who send a `read`
|
||||
access token to a mutating management route get HTTP 403
|
||||
`Access token scope 'read' is insufficient; 'write' required.`
|
||||
That rank is `scopeSatisfies`. It does not consult the MCP table, and the MCP
|
||||
matcher does not consult it.
|
||||
|
||||
| Namespace | Credential | Checker | A pass allows |
|
||||
| :----------------- | :-------------------------------------------------------- | :--------------------- | :----------------------------------------------------------- |
|
||||
| API-key management | `api_keys.scopes` | `hasManageScope` | Management REST for that Bearer key |
|
||||
| API-key additive | same array, one exact string | the helper named below | Only that one capability |
|
||||
| MCP tool scopes | same array, else MCP `_meta`, else `OMNIROUTE_MCP_SCOPES` | `scopeMatches` | That tool, once enforcement is on |
|
||||
| Access token | `oma_live_…` | `scopeSatisfies` | The management route whose method and path require that rank |
|
||||
|
||||
Minting each credential is covered in
|
||||
[Management Authentication](../guides/MANAGEMENT-AUTH.md).
|
||||
|
||||
#### API-key scopes
|
||||
|
||||
One `api_keys.scopes` array feeds two jobs. They use different functions.
|
||||
|
||||
**Management REST.** `manage` and `admin` are the members of
|
||||
`MANAGEMENT_API_KEY_SCOPES` (`src/shared/constants/managementScopes.ts`).
|
||||
`hasManageScope` is what authorizes management routes for that key. `admin` is
|
||||
management-capable on those routes. The word `admin` here is not the
|
||||
access-token rank and it does not expand into MCP tool scopes.
|
||||
|
||||
**Additive strings.** Each one is an exact membership test, and each one stays
|
||||
outside `MANAGEMENT_API_KEY_SCOPES`.
|
||||
|
||||
| Scope | A pass allows |
|
||||
| :----------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `mcp:connect` | The non-loopback `/api/mcp/` LOCAL_ONLY carve-out only (`hasMcpConnectOrManageScope`). A key with `manage` or `admin` still passes that carve-out. |
|
||||
| `self:usage` | `GET /api/v1/me/status` for this key (`src/app/api/v1/me/status/route.ts`). `POST /api/keys` adds this scope on create (`normalizeSelfServiceScopesForCreate`). |
|
||||
| `self:account-quota` | Upstream account quotas inside that status payload (`src/lib/usage/apiKeySelfService.ts`). The status route still requires `self:usage`. |
|
||||
| `policy:bypass-provider-quota` | This key's inference calls skip the provider-quota policy (`hasProviderQuotaBypassScope` in `src/sse/handlers/chat.ts`). |
|
||||
|
||||
#### Matching
|
||||
|
||||
The catalog is the table under [MCP tool scopes](#mcp-tool-scopes). Do not
|
||||
treat `MCP_SCOPE_LIST` in `src/shared/constants/mcpScopes.ts` as that catalog:
|
||||
it is the original typed subset. Later tools declare further scopes beside it
|
||||
(`read:notion`, `read:skills`, `read:local-corpus`, and the rest of the table).
|
||||
|
||||
`evaluateToolScopes` in `open-sse/mcp-server/scopeEnforcement.ts` allows a call
|
||||
when every required scope matches some granted scope:
|
||||
|
||||
- `*` matches every required scope.
|
||||
- A granted scope that ends in `*` matches a required scope that starts with
|
||||
the prefix before the star. `read:*` matches `read:compression`.
|
||||
- Every other granted scope matches only the identical required string.
|
||||
|
||||
A key whose scopes are `["manage"]` fails `scopeMatches` for `read:compression`.
|
||||
The same call fails for `admin`, `mcp:connect`, `read`, and `write` when those
|
||||
are the only granted strings. There is no hierarchy among MCP tool scopes
|
||||
beyond the trailing `*`.
|
||||
|
||||
Enforcement is off unless `OMNIROUTE_MCP_ENFORCE_SCOPES=true` (default
|
||||
`false`). While it is off, `evaluateToolScopes` allows the call and skips the
|
||||
catalog. While it is on, HTTP uses the Bearer key's `api_keys.scopes` as
|
||||
`authInfo` (see [Per-key HTTP scope binding](#per-key-http-scope-binding-7895)).
|
||||
When no key scopes resolve, the granted set falls through to MCP `_meta`, then
|
||||
`OMNIROUTE_MCP_SCOPES`.
|
||||
|
||||
#### Access-token scopes
|
||||
|
||||
`oma_live_…` tokens (`src/lib/accessTokens/scopes.ts`) carry `read`, `write`,
|
||||
or `admin`. `scopeSatisfies` is a rank: `admin` covers `write` and `read`, and
|
||||
`write` covers `read`. Unknown scopes cover nothing.
|
||||
|
||||
`evaluateAccessTokenAuth` (`src/server/authz/accessTokenAuth.ts`) compares that
|
||||
rank with `inferRequiredScope` (`src/server/authz/accessScopes.ts`):
|
||||
|
||||
- `GET`, `HEAD`, and `OPTIONS` require `read`.
|
||||
- Every other method requires `write`.
|
||||
- Paths in `ADMIN_SCOPE_PREFIXES` require `admin` for every method. `/api/mcp`
|
||||
is on that list, so a `write` access token still cannot call the MCP HTTP
|
||||
surface.
|
||||
- Paths in `ADMIN_MUTATION_PREFIXES` require `admin` only for mutations.
|
||||
|
||||
`PATCH /api/keys/{id}` is a mutation and is not on those admin lists, so a
|
||||
`read` token receives 403
|
||||
`Access token scope 'read' is insufficient; 'write' required.`
|
||||
A `write` or `admin` access token satisfies that route. A dashboard JWT, the
|
||||
loopback CLI machine-id token, and an API key with `manage` or `admin` take
|
||||
other branches and are not narrowed by this rank.
|
||||
|
||||
An access token that passes `scopeSatisfies` for `/api/mcp` has cleared the
|
||||
management gate only. Tool calls still run `scopeMatches` against API-key
|
||||
scopes. The access-token rank is not an input to `scopeMatches`.
|
||||
|
||||
### MCP tool scopes
|
||||
|
||||
Scope enforcement is centralized in `open-sse/mcp-server/scopeEnforcement.ts`.
|
||||
Each tool requires specific scopes:
|
||||
|
||||
| Scope | Tools |
|
||||
| :-------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|
||||
@@ -6,7 +6,7 @@ lastUpdated: 2026-06-28
|
||||
|
||||
# OmniRoute — Dashboard Features Gallery
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](./FEATURES.md) | 🇪🇹 [አማርኛ](../i18n/am/docs/guides/FEATURES.md) | 🇸🇦 [العربية](../i18n/ar/docs/guides/FEATURES.md) | 🇦🇿 [Azərbaycan dili](../i18n/az/docs/guides/FEATURES.md) | 🇧🇬 [Български](../i18n/bg/docs/guides/FEATURES.md) | 🇧🇩 [বাংলা](../i18n/bn/docs/guides/FEATURES.md) | 🇨🇿 [Čeština](../i18n/cs/docs/guides/FEATURES.md) | 🇩🇰 [Dansk](../i18n/da/docs/guides/FEATURES.md) | 🇩🇪 [Deutsch](../i18n/de/docs/guides/FEATURES.md) | 🇬🇷 [Ελληνικά](../i18n/el/docs/guides/FEATURES.md) | 🇪🇸 [Español](../i18n/es/docs/guides/FEATURES.md) | 🇪🇪 [Eesti](../i18n/et/docs/guides/FEATURES.md) | 🇮🇷 [فارسی](../i18n/fa/docs/guides/FEATURES.md) | 🇫🇮 [Suomi](../i18n/fi/docs/guides/FEATURES.md) | 🇫🇷 [Français](../i18n/fr/docs/guides/FEATURES.md) | 🇮🇪 [Gaeilge](../i18n/ga/docs/guides/FEATURES.md) | 🇮🇳 [ગુજરાતી](../i18n/gu/docs/guides/FEATURES.md) | 🇳🇬 [Hausa](../i18n/ha/docs/guides/FEATURES.md) | 🇮🇱 [עברית](../i18n/he/docs/guides/FEATURES.md) | 🇮🇳 [हिन्दी](../i18n/hi/docs/guides/FEATURES.md) | 🇭🇷 [Hrvatski](../i18n/hr/docs/guides/FEATURES.md) | 🇭🇺 [Magyar](../i18n/hu/docs/guides/FEATURES.md) | 🇦🇲 [Հայերեն](../i18n/hy/docs/guides/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](../i18n/id/docs/guides/FEATURES.md) | 🇳🇬 [Igbo](../i18n/ig/docs/guides/FEATURES.md) | 🇮🇹 [Italiano](../i18n/it/docs/guides/FEATURES.md) | 🇯🇵 [日本語](../i18n/ja/docs/guides/FEATURES.md) | 🇬🇪 [ქართული](../i18n/ka/docs/guides/FEATURES.md) | 🇰🇭 [ខ្មែរ](../i18n/km/docs/guides/FEATURES.md) | 🇮🇳 [ಕನ್ನಡ](../i18n/kn/docs/guides/FEATURES.md) | 🇰🇷 [한국어](../i18n/ko/docs/guides/FEATURES.md) | 🇱🇹 [Lietuvių](../i18n/lt/docs/guides/FEATURES.md) | 🇱🇻 [Latviešu](../i18n/lv/docs/guides/FEATURES.md) | 🇮🇳 [മലയാളം](../i18n/ml/docs/guides/FEATURES.md) | 🇮🇳 [मराठी](../i18n/mr/docs/guides/FEATURES.md) | 🇲🇾 [Bahasa Melayu](../i18n/ms/docs/guides/FEATURES.md) | 🇲🇹 [Malti](../i18n/mt/docs/guides/FEATURES.md) | 🇲🇲 [မြန်မာ](../i18n/my/docs/guides/FEATURES.md) | 🇳🇵 [नेपाली](../i18n/ne/docs/guides/FEATURES.md) | 🇳🇱 [Nederlands](../i18n/nl/docs/guides/FEATURES.md) | 🇳🇴 [Norsk](../i18n/no/docs/guides/FEATURES.md) | 🇮🇳 [ଓଡ଼ିଆ](../i18n/or/docs/guides/FEATURES.md) | 🇮🇳 [ਪੰਜਾਬੀ](../i18n/pa/docs/guides/FEATURES.md) | 🇵🇭 [Filipino](../i18n/phi/docs/guides/FEATURES.md) | 🇵🇱 [Polski](../i18n/pl/docs/guides/FEATURES.md) | 🇵🇹 [Português (Portugal)](../i18n/pt/docs/guides/FEATURES.md) | 🇧🇷 [Português (Brasil)](../i18n/pt-BR/docs/guides/FEATURES.md) | 🇷🇴 [Română](../i18n/ro/docs/guides/FEATURES.md) | 🇷🇺 [Русский](../i18n/ru/docs/guides/FEATURES.md) | 🇱🇰 [සිංහල](../i18n/si/docs/guides/FEATURES.md) | 🇸🇰 [Slovenčina](../i18n/sk/docs/guides/FEATURES.md) | 🇸🇮 [Slovenščina](../i18n/sl/docs/guides/FEATURES.md) | 🇷🇸 [Српски](../i18n/sr/docs/guides/FEATURES.md) | 🇸🇪 [Svenska](../i18n/sv/docs/guides/FEATURES.md) | 🇰🇪 [Kiswahili](../i18n/sw/docs/guides/FEATURES.md) | 🇮🇳 [தமிழ்](../i18n/ta/docs/guides/FEATURES.md) | 🇮🇳 [తెలుగు](../i18n/te/docs/guides/FEATURES.md) | 🇹🇭 [ไทย](../i18n/th/docs/guides/FEATURES.md) | 🇹🇷 [Türkçe](../i18n/tr/docs/guides/FEATURES.md) | 🇺🇦 [Українська](../i18n/uk-UA/docs/guides/FEATURES.md) | 🇵🇰 [اردو](../i18n/ur/docs/guides/FEATURES.md) | 🇺🇿 [Oʻzbekcha](../i18n/uz/docs/guides/FEATURES.md) | 🇻🇳 [Tiếng Việt](../i18n/vi/docs/guides/FEATURES.md) | 🇳🇬 [Yorùbá](../i18n/yo/docs/guides/FEATURES.md) | 🇨🇳 [中文 (简体)](../i18n/zh-CN/docs/guides/FEATURES.md) | 🇹🇼 [中文 (繁體)](../i18n/zh-TW/docs/guides/FEATURES.md)
|
||||
🌐 **Languages:** 🇺🇸 [English](./FEATURES.md) | 🇪🇹 [አማርኛ](../i18n/am/docs/guides/FEATURES.md) | 🇸🇦 [العربية](../i18n/ar/docs/guides/FEATURES.md) | 🇦🇿 [Azərbaycan dili](../i18n/az/docs/guides/FEATURES.md) | 🇧🇬 [Български](../i18n/bg/docs/guides/FEATURES.md) | 🇧🇩 [বাংলা](../i18n/bn/docs/guides/FEATURES.md) | 🇧🇦 [Bosanski](../i18n/bs/docs/guides/FEATURES.md) | 🇨🇿 [Čeština](../i18n/cs/docs/guides/FEATURES.md) | 🇩🇰 [Dansk](../i18n/da/docs/guides/FEATURES.md) | 🇩🇪 [Deutsch](../i18n/de/docs/guides/FEATURES.md) | 🇬🇷 [Ελληνικά](../i18n/el/docs/guides/FEATURES.md) | 🇪🇸 [Español](../i18n/es/docs/guides/FEATURES.md) | 🇪🇪 [Eesti](../i18n/et/docs/guides/FEATURES.md) | 🇮🇷 [فارسی](../i18n/fa/docs/guides/FEATURES.md) | 🇫🇮 [Suomi](../i18n/fi/docs/guides/FEATURES.md) | 🇫🇷 [Français](../i18n/fr/docs/guides/FEATURES.md) | 🇮🇪 [Gaeilge](../i18n/ga/docs/guides/FEATURES.md) | 🇮🇳 [ગુજરાતી](../i18n/gu/docs/guides/FEATURES.md) | 🇳🇬 [Hausa](../i18n/ha/docs/guides/FEATURES.md) | 🇮🇱 [עברית](../i18n/he/docs/guides/FEATURES.md) | 🇮🇳 [हिन्दी](../i18n/hi/docs/guides/FEATURES.md) | 🇭🇷 [Hrvatski](../i18n/hr/docs/guides/FEATURES.md) | 🇭🇺 [Magyar](../i18n/hu/docs/guides/FEATURES.md) | 🇦🇲 [Հայերեն](../i18n/hy/docs/guides/FEATURES.md) | 🇮🇩 [Bahasa Indonesia](../i18n/id/docs/guides/FEATURES.md) | 🇳🇬 [Igbo](../i18n/ig/docs/guides/FEATURES.md) | 🇮🇹 [Italiano](../i18n/it/docs/guides/FEATURES.md) | 🇯🇵 [日本語](../i18n/ja/docs/guides/FEATURES.md) | 🇬🇪 [ქართული](../i18n/ka/docs/guides/FEATURES.md) | 🇰🇭 [ខ្មែរ](../i18n/km/docs/guides/FEATURES.md) | 🇮🇳 [ಕನ್ನಡ](../i18n/kn/docs/guides/FEATURES.md) | 🇰🇷 [한국어](../i18n/ko/docs/guides/FEATURES.md) | 🇱🇹 [Lietuvių](../i18n/lt/docs/guides/FEATURES.md) | 🇱🇻 [Latviešu](../i18n/lv/docs/guides/FEATURES.md) | 🇮🇳 [മലയാളം](../i18n/ml/docs/guides/FEATURES.md) | 🇮🇳 [मराठी](../i18n/mr/docs/guides/FEATURES.md) | 🇲🇾 [Bahasa Melayu](../i18n/ms/docs/guides/FEATURES.md) | 🇲🇹 [Malti](../i18n/mt/docs/guides/FEATURES.md) | 🇲🇲 [မြန်မာ](../i18n/my/docs/guides/FEATURES.md) | 🇳🇵 [नेपाली](../i18n/ne/docs/guides/FEATURES.md) | 🇳🇱 [Nederlands](../i18n/nl/docs/guides/FEATURES.md) | 🇳🇴 [Norsk](../i18n/no/docs/guides/FEATURES.md) | 🇮🇳 [ଓଡ଼ିଆ](../i18n/or/docs/guides/FEATURES.md) | 🇮🇳 [ਪੰਜਾਬੀ](../i18n/pa/docs/guides/FEATURES.md) | 🇵🇭 [Filipino](../i18n/phi/docs/guides/FEATURES.md) | 🇵🇱 [Polski](../i18n/pl/docs/guides/FEATURES.md) | 🇵🇹 [Português (Portugal)](../i18n/pt/docs/guides/FEATURES.md) | 🇧🇷 [Português (Brasil)](../i18n/pt-BR/docs/guides/FEATURES.md) | 🇷🇴 [Română](../i18n/ro/docs/guides/FEATURES.md) | 🇷🇺 [Русский](../i18n/ru/docs/guides/FEATURES.md) | 🇱🇰 [සිංහල](../i18n/si/docs/guides/FEATURES.md) | 🇸🇰 [Slovenčina](../i18n/sk/docs/guides/FEATURES.md) | 🇸🇮 [Slovenščina](../i18n/sl/docs/guides/FEATURES.md) | 🇷🇸 [Српски](../i18n/sr/docs/guides/FEATURES.md) | 🇸🇪 [Svenska](../i18n/sv/docs/guides/FEATURES.md) | 🇰🇪 [Kiswahili](../i18n/sw/docs/guides/FEATURES.md) | 🇮🇳 [தமிழ்](../i18n/ta/docs/guides/FEATURES.md) | 🇮🇳 [తెలుగు](../i18n/te/docs/guides/FEATURES.md) | 🇹🇭 [ไทย](../i18n/th/docs/guides/FEATURES.md) | 🇹🇷 [Türkçe](../i18n/tr/docs/guides/FEATURES.md) | 🇺🇦 [Українська](../i18n/uk-UA/docs/guides/FEATURES.md) | 🇵🇰 [اردو](../i18n/ur/docs/guides/FEATURES.md) | 🇺🇿 [Oʻzbekcha](../i18n/uz/docs/guides/FEATURES.md) | 🇻🇳 [Tiếng Việt](../i18n/vi/docs/guides/FEATURES.md) | 🇳🇬 [Yorùbá](../i18n/yo/docs/guides/FEATURES.md) | 🇨🇳 [中文 (简体)](../i18n/zh-CN/docs/guides/FEATURES.md) | 🇹🇼 [中文 (繁體)](../i18n/zh-TW/docs/guides/FEATURES.md)
|
||||
|
||||
Visual guide to every section of the OmniRoute dashboard.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ lastUpdated: 2026-09-02
|
||||
|
||||
# i18n — Internationalization Guide
|
||||
|
||||
OmniRoute supports **66 languages** with full dashboard UI translation, translated documentation, and RTL support for Arabic and Hebrew.
|
||||
OmniRoute supports **67 languages** with full dashboard UI translation, translated documentation, and RTL support for Arabic and Hebrew.
|
||||
|
||||
🌐 **Languages:** 🇺🇸 [English](./I18N.md) | 🇸🇦 [العربية](../i18n/ar/docs/guides/I18N.md) | 🇦🇿 [Azərbaycan dili](../i18n/az/docs/guides/I18N.md) | 🇧🇬 [Български](../i18n/bg/docs/guides/I18N.md) | 🇧🇩 [বাংলা](../i18n/bn/docs/guides/I18N.md) | 🇨🇿 [Čeština](../i18n/cs/docs/guides/I18N.md) | 🇩🇰 [Dansk](../i18n/da/docs/guides/I18N.md) | 🇩🇪 [Deutsch](../i18n/de/docs/guides/I18N.md) | 🇪🇸 [Español](../i18n/es/docs/guides/I18N.md) | 🇮🇷 [فارسی](../i18n/fa/docs/guides/I18N.md) | 🇫🇮 [Suomi](../i18n/fi/docs/guides/I18N.md) | 🇫🇷 [Français](../i18n/fr/docs/guides/I18N.md) | 🇮🇳 [ગુજરાતી](../i18n/gu/docs/guides/I18N.md) | 🇮🇱 [עברית](../i18n/he/docs/guides/I18N.md) | 🇮🇳 [हिन्दी](../i18n/hi/docs/guides/I18N.md) | 🇭🇺 [Magyar](../i18n/hu/docs/guides/I18N.md) | 🇮🇩 [Bahasa Indonesia](../i18n/id/docs/guides/I18N.md) | 🇮🇹 [Italiano](../i18n/it/docs/guides/I18N.md) | 🇯🇵 [日本語](../i18n/ja/docs/guides/I18N.md) | 🇰🇷 [한국어](../i18n/ko/docs/guides/I18N.md) | 🇮🇳 [मराठी](../i18n/mr/docs/guides/I18N.md) | 🇲🇾 [Bahasa Melayu](../i18n/ms/docs/guides/I18N.md) | 🇳🇱 [Nederlands](../i18n/nl/docs/guides/I18N.md) | 🇳🇴 [Norsk](../i18n/no/docs/guides/I18N.md) | 🇵🇭 [Filipino](../i18n/phi/docs/guides/I18N.md) | 🇵🇱 [Polski](../i18n/pl/docs/guides/I18N.md) | 🇵🇹 [Português (Portugal)](../i18n/pt/docs/guides/I18N.md) | 🇧🇷 [Português (Brasil)](../i18n/pt-BR/docs/guides/I18N.md) | 🇷🇴 [Română](../i18n/ro/docs/guides/I18N.md) | 🇷🇺 [Русский](../i18n/ru/docs/guides/I18N.md) | 🇸🇰 [Slovenčina](../i18n/sk/docs/guides/I18N.md) | 🇸🇪 [Svenska](../i18n/sv/docs/guides/I18N.md) | 🇰🇪 [Kiswahili](../i18n/sw/docs/guides/I18N.md) | 🇮🇳 [தமிழ்](../i18n/ta/docs/guides/I18N.md) | 🇮🇳 [తెలుగు](../i18n/te/docs/guides/I18N.md) | 🇹🇭 [ไทย](../i18n/th/docs/guides/I18N.md) | 🇹🇷 [Türkçe](../i18n/tr/docs/guides/I18N.md) | 🇺🇦 [Українська](../i18n/uk-UA/docs/guides/I18N.md) | 🇵🇰 [اردو](../i18n/ur/docs/guides/I18N.md) | 🇻🇳 [Tiếng Việt](../i18n/vi/docs/guides/I18N.md) | 🇨🇳 [中文 (简体)](../i18n/zh-CN/docs/guides/I18N.md) | 🇹🇼 [中文 (繁體)](../i18n/zh-TW/docs/guides/I18N.md)
|
||||
|
||||
@@ -108,10 +108,10 @@ Only the `readme` mode (root README variants) has no replacement yet.
|
||||
|
||||
### Source of Truth
|
||||
|
||||
- **UI strings**: `src/i18n/messages/en.json` (English source, ~2800 keys)
|
||||
- **Locale files**: `src/i18n/messages/{locale}.json` (42 translations)
|
||||
- **UI strings**: `src/i18n/messages/en.json` (English source, ~14,000 keys)
|
||||
- **Locale files**: `src/i18n/messages/{locale}.json` (all the translations)
|
||||
- **Framework**: `next-intl` with cookie-based locale resolution
|
||||
- **Config**: `src/i18n/config.ts` — defines all 42 locales, language names, flags
|
||||
- **Config**: `config/i18n.json` — defines all the locales, language names, flags
|
||||
|
||||
### Runtime Flow
|
||||
|
||||
@@ -129,6 +129,7 @@ Only the `readme` mode (root README variants) has no replacement yet.
|
||||
| `az` | Azərbaycan dili | No | `az` |
|
||||
| `bg` | Български | No | `bg` |
|
||||
| `bn` | বাংলা | No | `bn` |
|
||||
| `bs` | Bosanski | No | `bs` |
|
||||
| `cs` | Čeština | No | `cs` |
|
||||
| `da` | Dansk | No | `da` |
|
||||
| `de` | Deutsch | No | `de` |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user