Merge branch 'release/v3.8.47' into fix/6803-flaky-timing-tests

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-11 11:24:02 -03:00
committed by GitHub
50 changed files with 1630 additions and 98 deletions

View File

@@ -23,7 +23,7 @@
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts",
"prepublishOnly": "npm run clean && npm run build && npm test"
},
"keywords": [

View File

@@ -238,9 +238,26 @@ function trimLeadingDashes(value: string): string {
*/
export function resolveOmniRoutePluginOptions(
opts?: OmniRoutePluginOptions
): Required<Pick<OmniRoutePluginOptions, "providerId" | "displayName" | "modelCacheTtl">> &
Pick<OmniRoutePluginOptions, "baseURL" | "features"> {
): Required<
Pick<OmniRoutePluginOptions, "providerId" | "displayName" | "modelCacheTtl">
> & {
/**
* #6859: the UNPREFIXED provider id ("omniroute", "omniroute-preprod", …).
* `providerId` above is auto-prefixed with "opencode-" ONLY to satisfy OC
* 1.17.8+'s native-adapter gate ({openai, anthropic, opencode*}) — that
* prefixed value is OC-internal and must be used ONLY for AuthHook.provider
* and provider-registration keys (the OC config-hook top-level
* `provider.<id>` block). `omnirouteProviderId` MUST be used everywhere an
* identifier reaches or represents something OmniRoute's own server parses
* (model `id` prefix, `ModelV2.providerID`, combo catalog keys in the
* dynamic provider hook) — OmniRoute's `parseModel()` has no alias for
* "opencode-<x>", so a prefixed id there is unrecoverable and credential
* lookup fails with "No credentials for opencode-<x>".
*/
omnirouteProviderId: string;
} & Pick<OmniRoutePluginOptions, "baseURL" | "features"> {
const rawProviderId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY;
const omnirouteProviderId = trimLeadingOpencodePrefix(rawProviderId);
// OC 1.17.8+ native-adapter gate rejects providerID not in
// {openai, anthropic, opencode*}. Silently prefix so existing
// configs (providerId: "omniroute") keep working.
@@ -258,6 +275,7 @@ export function resolveOmniRoutePluginOptions(
: DEFAULT_MODEL_CACHE_TTL_MS;
return {
providerId,
omnirouteProviderId,
displayName,
modelCacheTtl,
baseURL: opts?.baseURL,
@@ -265,6 +283,18 @@ export function resolveOmniRoutePluginOptions(
};
}
/**
* Strip a leading "opencode-" prefix (added only for the OC native-adapter
* gate — see `resolveOmniRoutePluginOptions`) so the returned id is safe to
* embed in anything OmniRoute's own server parses. A user-supplied
* `providerId: "opencode-omniroute"` (already prefixed) resolves to the same
* unprefixed "omniroute" as the default, matching `providerId`'s own
* idempotent-prefix handling above.
*/
function trimLeadingOpencodePrefix(rawProviderId: string): string {
return rawProviderId.startsWith("opencode-") ? rawProviderId.slice("opencode-".length) : rawProviderId;
}
/**
* Strict parse of raw plugin options (as received from opencode.json or a
* direct factory call) into the validated `OmniRoutePluginOptions` shape.
@@ -2661,7 +2691,8 @@ export function createOmniRouteProviderHook(
if (canonicalDedup.has(entry.id)) continue;
if (usable && !isUsableRawModelId(entry.id, usable, rawEnrichment)) continue;
const model = mapRawModelToModelV2(entry, {
providerId: resolved.providerId,
// #6859: server-facing id — NOT the OC-gate-prefixed `resolved.providerId`.
providerId: resolved.omnirouteProviderId,
baseURL,
apiFormat: resolved.features?.apiFormat,
});
@@ -2826,7 +2857,8 @@ export function createOmniRouteProviderHook(
const mapped = mapComboToModelV2(
combo,
memberEntries,
resolved.providerId,
// #6859: server-facing id — NOT the OC-gate-prefixed `resolved.providerId`.
resolved.omnirouteProviderId,
baseURL,
features.apiFormat
);
@@ -2845,7 +2877,8 @@ export function createOmniRouteProviderHook(
}
}
const comboKey = buildComboKey(combo, usedComboKeys, resolved.providerId);
// #6859: server-facing key — NOT the OC-gate-prefixed `resolved.providerId`.
const comboKey = buildComboKey(combo, usedComboKeys, resolved.omnirouteProviderId);
// Collision policy: combos win. Warn ONCE per (cacheKey, comboKey)
// when overwriting a same-key raw model so the operator can spot
@@ -2947,7 +2980,8 @@ export function createOmniRouteProviderHook(
},
status: "active",
release_date: "",
providerID: resolved.providerId,
// #6859: server-facing id — NOT the OC-gate-prefixed `resolved.providerId`.
providerID: resolved.omnirouteProviderId,
options: {},
headers: {},
};

View File

@@ -447,14 +447,14 @@ test("models() returns combo entries merged into the map", async () => {
// 3 raw models + 1 combo = 4 entries
assert.equal(Object.keys(out).length, 4);
assert.ok(out["opencode-omniroute/claude-primary"]);
assert.ok(out["opencode-omniroute/claude-secondary"]);
assert.ok(out["opencode-omniroute/gemini-3-flash"]);
assert.ok(out["opencode-omniroute/claude-tier"]);
assert.ok(out["omniroute/claude-primary"]);
assert.ok(out["omniroute/claude-secondary"]);
assert.ok(out["omniroute/gemini-3-flash"]);
assert.ok(out["omniroute/claude-tier"]);
const combo = out["opencode-omniroute/claude-tier"];
const combo = out["omniroute/claude-tier"];
assert.equal(combo.name, "Claude Tier");
assert.equal(combo.providerID, "opencode-omniroute");
assert.equal(combo.providerID, "omniroute");
// LCD over claude-primary (200k, reasoning) + claude-secondary (100k, no reasoning)
assert.equal(combo.limit.context, 100_000);
assert.equal(combo.capabilities.reasoning, false);
@@ -478,11 +478,11 @@ test("models(): combo with unknown member ids degrades to all-false LCD posture"
{ fetcher: modelsFetcher, combosFetcher }
);
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
assert.ok(out["opencode-omniroute/phantom-combo"]);
assert.ok(out["omniroute/phantom-combo"]);
// With zero resolvable members, LCD = all-false (defensive posture).
assert.equal(out["opencode-omniroute/phantom-combo"].capabilities.toolcall, false);
assert.equal(out["opencode-omniroute/phantom-combo"].capabilities.reasoning, false);
assert.equal(out["opencode-omniroute/phantom-combo"].limit.context, 0);
assert.equal(out["omniroute/phantom-combo"].capabilities.toolcall, false);
assert.equal(out["omniroute/phantom-combo"].capabilities.reasoning, false);
assert.equal(out["omniroute/phantom-combo"].limit.context, 0);
});
test("models(): hidden combos are excluded from the map", async () => {
@@ -505,8 +505,8 @@ test("models(): hidden combos are excluded from the map", async () => {
{ fetcher: modelsFetcher, combosFetcher }
);
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
assert.ok(out["opencode-omniroute/visible"]);
assert.ok(!out["opencode-omniroute/hidden"], "hidden combo must be omitted");
assert.ok(out["omniroute/visible"]);
assert.ok(!out["omniroute/hidden"], "hidden combo must be omitted");
});
test("models(): combo name exactly matches raw model id → raw deleted, raw deleted, no warn", async () => {
@@ -530,8 +530,8 @@ test("models(): combo name exactly matches raw model id → raw deleted, raw del
});
// Raw model replaced by combo of the same key; combo now lives at the bare slug.
assert.ok(out["opencode-omniroute/claude-primary"], "combo surfaces under prefixed key");
assert.equal(out["opencode-omniroute/claude-primary"].name, "claude-primary");
assert.ok(out["omniroute/claude-primary"], "combo surfaces under prefixed key");
assert.equal(out["omniroute/claude-primary"].name, "claude-primary");
// No collision warning fires — dedup makes keys disjoint.
const collisionWarns = warnings.filter((w) => {
@@ -565,8 +565,8 @@ test("models(): two combos with same slug → second gets disambiguator suffix",
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
// First combo gets the bare slug; second gets disambiguated.
assert.ok(out["opencode-omniroute/claude"], "first combo at prefixed slug");
assert.ok(out["opencode-omniroute/claude-uuid"], "second combo disambiguated by id prefix");
assert.ok(out["omniroute/claude"], "first combo at prefixed slug");
assert.ok(out["omniroute/claude-uuid"], "second combo disambiguated by id prefix");
});
test("models(): combos fetch fails → falls back to models-only, warn emitted, no throw", async () => {
@@ -583,8 +583,8 @@ test("models(): combos fetch fails → falls back to models-only, warn emitted,
// Catalog includes the models but NOT any combo entries.
assert.equal(Object.keys(out).length, 2);
assert.ok(out["opencode-omniroute/claude-primary"]);
assert.ok(out["opencode-omniroute/claude-secondary"]);
assert.ok(out["omniroute/claude-primary"]);
assert.ok(out["omniroute/claude-secondary"]);
// Soft-fail warning surfaced.
const softFail = warnings.find((w) => {
@@ -609,7 +609,7 @@ test("models(): combos cached + reused within TTL (one combo fetch per TTL windo
const second = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
assert.equal(combosFetcher.callCount(), 1, "combos fetched only once within TTL");
assert.equal(modelsFetcher.callCount(), 1, "models fetched only once within TTL");
assert.ok(second["opencode-omniroute/claude-tier"]);
assert.ok(second["omniroute/claude-tier"]);
});
test("models(): combos refetched after TTL expiry (same key as models)", async () => {
@@ -701,7 +701,7 @@ test("models(): nested combo-ref context is the min of nested + raw members", as
{ fetcher: modelsFetcher, combosFetcher }
);
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
const masterLight = out["opencode-omniroute/master-light"];
const masterLight = out["omniroute/master-light"];
assert.ok(masterLight, "MASTER-LIGHT entry must exist");
assert.equal(
masterLight.limit.context,

View File

@@ -376,7 +376,8 @@ test("provider hook: enrichment fetcher called when features.enrichment !== fals
);
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
assert.equal(called, 1, "enrichment fetcher called once");
const m = out["opencode-omniroute/claude-sonnet-4-6"];
// #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId.
const m = out["omniroute/claude-sonnet-4-6"];
assert.equal(m.name, "Claude Sonnet 4.6", "enrichment name overlay applied");
assert.equal(m.cost.input, 3, "enrichment pricing applied");
assert.equal(m.cost.output, 15);
@@ -402,7 +403,7 @@ test("provider hook: enrichment fetcher NOT called when features.enrichment:fals
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
assert.equal(called, 0, "enrichment fetcher NOT called when gated off");
assert.equal(
out["opencode-omniroute/claude-sonnet-4-6"].name,
out["omniroute/claude-sonnet-4-6"].name,
"claude-sonnet-4-6",
"raw id preserved"
);
@@ -463,7 +464,7 @@ test("provider hook: compression metadata fetcher called when opted in", async (
);
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
assert.equal(called, 1, "compression metadata fetcher called");
const combo = out["opencode-omniroute/claude-primary"];
const combo = out["omniroute/claude-primary"];
assert.ok(combo, "combo entry present");
assert.match(
combo.name,

View File

@@ -0,0 +1,99 @@
/**
* Regression test for #6859.
*
* `resolveOmniRoutePluginOptions()` auto-prefixes `providerId` with
* `"opencode-"` (commit 75b52e286) so OpenCode 1.17.8+'s native-adapter gate
* accepts it as an OC-registered provider id. That prefixed value must stay
* OC-internal (AuthHook.provider / provider registration keys) — it must
* NEVER leak into the identifiers OmniRoute's own server parses to resolve
* credentials (`mapRawModelToModelV2`'s `id`/`providerID`,
* `mapComboToModelV2`'s `providerID`, and the dynamic-hook catalog keys).
*
* OmniRoute's server-side `parseModel()` (open-sse/services/model.ts) splits
* a dispatched model string on `/` to recover the provider name and look up
* credentials. If the plugin embeds the OC-gate-prefixed id in that string,
* the server looks up credentials for a provider named "opencode-omniroute"
* (which never exists in `src/shared/constants/providers.ts`) instead of
* "omniroute" — producing the exact "No credentials for opencode-omniroute" /
* "No active credentials for provider: opencode-omniroute" errors reported
* in #6859.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
createOmniRouteProviderHook,
mapRawModelToModelV2,
resolveOmniRoutePluginOptions,
} from "../src/index.js";
/**
* Minimal stand-in for OmniRoute's own `parseModel()` (open-sse/services/
* model.ts), which splits a dispatched `<providerID>/<modelID>` string on the
* FIRST "/" to recover the provider name used for credential lookup. Kept
* local (rather than cross-importing the real module) so this package's
* self-contained test suite (`cd @omniroute/opencode-plugin && npm test`)
* doesn't depend on the root repo's `@/*` path-alias resolution.
*/
function splitProviderFromDispatchedModel(modelStr: string): string {
const idx = modelStr.indexOf("/");
return idx === -1 ? modelStr : modelStr.slice(0, idx);
}
const apiAuth = (key: string) => ({ type: "api" as const, key });
test("#6859: server-facing model id/providerID must resolve to the unprefixed provider name", () => {
const resolved = resolveOmniRoutePluginOptions();
// The OC-gate-compatible id stays prefixed — it is legitimate for
// AuthHook.provider / provider registration.
assert.equal(resolved.providerId, "opencode-omniroute");
// A second, unprefixed id must be exposed for anything that reaches
// OmniRoute's own server (model id prefix, ModelV2.providerID, combo keys).
assert.equal(
resolved.omnirouteProviderId,
"omniroute",
"resolveOmniRoutePluginOptions() must expose an unprefixed omnirouteProviderId"
);
// A bare raw /v1/models entry (no existing "/" in its id — the common
// case for OmniRoute's catalog) mapped with the server-facing id.
const model = mapRawModelToModelV2(
{ id: "claude-opus-4-7" },
{ providerId: resolved.omnirouteProviderId, baseURL: "http://localhost:20128" }
);
assert.equal(model.providerID, "omniroute");
assert.equal(model.id, "omniroute/claude-opus-4-7");
// OpenCode dispatches back to OmniRoute using `providerID/modelKey`
// (matches the issue's own repro: `-m opencode-omniroute/oc/big-pickle`).
const dispatchedModelString = `${model.providerID}/claude-opus-4-7`;
const parsedProvider = splitProviderFromDispatchedModel(dispatchedModelString);
assert.equal(
parsedProvider,
"omniroute",
`server-side provider split resolved '${parsedProvider}', expected 'omniroute' — ` +
`credentials lookup would fail for an OC-gate-prefixed provider id`
);
});
test("#6859: createOmniRouteProviderHook end-to-end — catalog keys/providerID never carry the OC-gate prefix", async () => {
const hook = createOmniRouteProviderHook(
{ baseURL: "https://or.example.com/v1" },
{
fetcher: async () => [{ id: "claude-opus-4-7" }],
combosFetcher: async () => [],
}
);
const out = await hook.models!({} as never, { auth: apiAuth("sk-test") as never });
const model = out["omniroute/claude-opus-4-7"];
assert.ok(model, "catalog keyed under the unprefixed provider name");
assert.equal(model.providerID, "omniroute");
assert.ok(
!model.providerID.startsWith("opencode-"),
"the OC-gate prefix must never leak into ModelV2.providerID"
);
});

View File

@@ -101,7 +101,10 @@ test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it
assert.equal(fetcher.callCount(), 1);
assert.deepEqual(fetcher.callsBy()[0], ["https://or.example.com/v1", "sk-abc"]);
assert.equal(Object.keys(out).length, 3);
assert.ok(out["opencode-omniroute/claude-primary"]);
// #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId
// ("omniroute"), not the OC-gate-prefixed hook.id ("opencode-omniroute") —
// that prefix must never leak into anything OmniRoute's server parses.
assert.ok(out["omniroute/claude-primary"]);
});
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
@@ -152,13 +155,17 @@ test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
{ fetcher, combosFetcher: async () => [] }
);
const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never });
const claude = out["opencode-omniroute/claude-primary"];
// #6859: dynamic-hook catalog keys/ids/providerID use the unprefixed
// omnirouteProviderId ("omniroute") — the OC-gate prefix ("opencode-")
// must stay OC-internal (hook.id / AuthHook.provider) and never leak into
// anything OmniRoute's own server parses for credential lookup.
const claude = out["omniroute/claude-primary"];
assert.ok(claude, "claude-primary present");
// `mapRawModelToModelV2` stamps the provider prefix on the id so OC's
// static-catalog reader resolves `(providerID, modelID)` from the key.
assert.equal(claude.id, "opencode-omniroute/claude-primary");
assert.equal(claude.id, "omniroute/claude-primary");
assert.equal(claude.name, "claude-primary");
assert.equal(claude.providerID, "opencode-omniroute");
assert.equal(claude.providerID, "omniroute");
assert.equal(claude.api.id, "openai-compatible");
assert.equal(claude.api.url, "https://or.example.com/v1");
assert.equal(claude.api.npm, "@ai-sdk/openai-compatible");

View File

@@ -0,0 +1 @@
- fix(providers): honor a provider-level proxy assigned to no-auth providers like MiMoCode Free (#6272)

View File

@@ -0,0 +1 @@
- fix(api): merge tool_call continuation deltas that carry only `id` (no `index`) so tool-call arguments are no longer split/lost in request/response logs (#6276)

View File

@@ -0,0 +1 @@
- fix(docs): document Turbopack build memory tradeoff and `OMNIROUTE_USE_TURBOPACK=0` webpack fallback for RAM-constrained machines (#6409)

View File

@@ -0,0 +1 @@
- fix(compression): surface silently-dropped stacked-pipeline steps (session-dedup, ccr) and stop the aggregate inflation guard from misfiring on a genuine no-op (#6479, #6480, #6491)

View File

@@ -0,0 +1 @@
- fix(providers): honor the `max_token` capability override in the reasoning-token-buffer output cap (#6524)

View File

@@ -0,0 +1,2 @@
- fix(db): share one in-flight sql.js load across concurrent `preInitSqlJs()` callers to stop the boot-time thundering-herd re-decode of the whole database file (#6628)
- fix(db): unwrap lone named-parameter objects before `sql.js` `stmt.bind()` so `@`/`:`/`$`-style named placeholders bind correctly instead of throwing "Wrong API use" (#6802)

View File

@@ -0,0 +1 @@
- fix(routing): recognize Kimi-style "exceeded model token limit" 400 as context overflow so combo fallback continues to the next target (#6637)

View File

@@ -0,0 +1 @@
- fix(dashboard): surface Claude extraUsage credits in quota card when quotas is empty (#6806)

View File

@@ -0,0 +1 @@
- fix(mcp): de-duplicate `TOTAL_MCP_TOOL_COUNT` by tool name instead of double-counting collections (#6854)

View File

@@ -0,0 +1 @@
- **fix(plugin):** the `@omniroute/opencode-plugin` dynamic provider hook stopped embedding its OC-1.17.8+-gate-compatible `opencode-`-prefixed provider id into model routing fields (`ModelV2.id`/`providerID`, combo catalog keys) — OmniRoute's server has no `opencode-<x>` provider alias, so every dispatched model failed credential lookup with "No credentials for opencode-omniroute" (#6859).

View File

@@ -0,0 +1 @@
- fix(sse): apply cliproxyapiModelMapping at CLIProxyAPI dispatch time (#6876)

View File

@@ -133,7 +133,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| `OMNIROUTE_ENABLE_LIVE_WS` | `true` | `src/server/ws/liveServer.ts` and `scripts/start-ws-server.mjs` | Set to `0` or `false` to disable the real-time WebSocket server (enabled by default, loopback-bound). CI/harness toggle that disables the standalone live WebSocket helper script. |
| `RELAY_IP_PER_MINUTE` | `30` | `src/app/api/v1/relay/chat/completions/route.ts` | Per-(token, IP) relay rate limit, requests/minute. In-memory, per instance. `0` or negative disables the IP-dimension gate (per-token DB limit still applies). |
| `NODE_ENV` | `production` | Next.js core | Controls logging verbosity, caching, error detail exposure, and Next.js optimizations. |
| `OMNIROUTE_USE_TURBOPACK` | `1` (Turbopack — code default) | `package.json` / Next.js 16 | Turbopack is the default bundler for `npm run dev` and `npm run build` (2-3× faster builds, benchmarked). Set to `0` to fall back to webpack on Windows or when running into native binding / bundler-compat incompatibilities. |
| `OMNIROUTE_USE_TURBOPACK` | `1` (Turbopack — code default) | `package.json` / Next.js 16 | Turbopack is the default bundler for `npm run dev` and `npm run build` (2-3× faster builds, benchmarked). Set to `0` to fall back to webpack on Windows, when running into native binding / bundler-compat incompatibilities, **or on RAM-constrained machines** — Turbopack production builds on this Next.js version line (16.2.x) are known upstream to peak far higher in memory than webpack on large module graphs (Next 16.3's Turbopack memory-eviction fix is not yet stable); webpack fallback peaks much lower. See #6409. |
| `OMNIROUTE_SKIP_DB_HEALTHCHECK` | _(unset)_ | `src/lib/db/core.ts` / `src/lib/db/healthCheck.ts` | Set to `1` to skip the SQLite integrity health check on startup. Useful for faster boot on large databases. |
| `CREDENTIAL_HEALTH_CHECK_INTERVAL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/scheduler.ts` | Interval (ms) for the background credential health check scheduler. Minimum: 10000 (10s). |
| `CREDENTIAL_HEALTH_CACHE_TTL` | `300000` | `open-sse/config/constants.ts` / `src/lib/credentialHealth/cache.ts` | TTL (ms) for cached credential health status. |

View File

@@ -0,0 +1,69 @@
/**
* CLIProxyAPI model-mapping application (#6876).
*
* The dashboard persists `cliproxyapiModelMapping` per provider
* (`upstream_proxy_config.cliproxyapi_model_mapping`) but nothing at
* request-dispatch time ever consulted it — the configured alias was
* silently dropped and the original model was forwarded verbatim to
* CLIProxyAPI. This module applies the mapping exactly once, at the
* executor boundary, so it only affects requests that actually reach
* CLIProxyAPI (the `cliproxyapi` passthrough leg and the CLIProxyAPI retry
* leg of `fallback` mode) and never the native leg of `fallback` mode.
*/
type ExecutorInput = {
model: string;
body: unknown;
[key: string]: unknown;
};
type ExecutorLike = {
execute: (input: ExecutorInput) => Promise<unknown>;
[key: string]: unknown;
};
export type CliproxyapiModelMapping = Record<string, unknown> | null | undefined;
function resolveMappedModel(model: string, mapping: CliproxyapiModelMapping): string | null {
if (!mapping || typeof mapping !== "object") return null;
const mapped = (mapping as Record<string, unknown>)[model];
return typeof mapped === "string" && mapped.trim() ? mapped : null;
}
/**
* Rewrites `input.model` (and `input.body.model` when body is a plain
* object) to the mapped model, if one is configured for `input.model`.
* Returns the original input unchanged when no mapping applies.
*/
export function applyCliproxyapiModelMapping(
input: ExecutorInput,
mapping: CliproxyapiModelMapping
): ExecutorInput {
const mappedModel = resolveMappedModel(input.model, mapping);
if (!mappedModel) return input;
const body =
input.body && typeof input.body === "object" && !Array.isArray(input.body)
? { ...(input.body as Record<string, unknown>), model: mappedModel }
: input.body;
return { ...input, model: mappedModel, body };
}
/**
* Wraps an executor so every `execute()` call has the CLIProxyAPI model
* mapping applied first. Returns the executor unchanged when no mapping is
* configured (empty/absent mapping is a no-op, matching prior behavior).
*/
export function wrapExecutorWithCliproxyapiModelMapping<T extends ExecutorLike>(
executor: T,
mapping: CliproxyapiModelMapping
): T {
if (!mapping || typeof mapping !== "object" || Object.keys(mapping).length === 0) {
return executor;
}
const wrapped = Object.create(executor) as T;
wrapped.execute = (input: ExecutorInput) =>
executor.execute(applyCliproxyapiModelMapping(input, mapping));
return wrapped;
}

View File

@@ -4,7 +4,14 @@ import { getUpstreamProxyConfig } from "@/lib/localDb";
* Module-level cache for upstream proxy config (shared across all requests).
* 10s TTL prevents per-request DB lookups while staying fresh enough for setting changes.
*/
const _proxyConfigCache = new Map<string, { mode: string; enabled: boolean; ts: number }>();
type UpstreamProxyConfigCacheEntry = {
mode: string;
enabled: boolean;
cliproxyapiModelMapping: Record<string, unknown> | null;
ts: number;
};
const _proxyConfigCache = new Map<string, UpstreamProxyConfigCacheEntry>();
const PROXY_CONFIG_CACHE_TTL = 10_000;
/**
@@ -55,9 +62,14 @@ export async function getUpstreamProxyConfigCached(providerId: string) {
const cached = _proxyConfigCache.get(providerId);
if (cached && Date.now() - cached.ts < PROXY_CONFIG_CACHE_TTL) return cached;
const cfg = await getUpstreamProxyConfig(providerId).catch(() => null);
const result = cfg
? { mode: cfg.mode, enabled: cfg.enabled, ts: Date.now() }
: { mode: "native" as const, enabled: false, ts: Date.now() };
const result: UpstreamProxyConfigCacheEntry = cfg
? {
mode: cfg.mode,
enabled: cfg.enabled,
cliproxyapiModelMapping: cfg.cliproxyapiModelMapping ?? null,
ts: Date.now(),
}
: { mode: "native" as const, enabled: false, cliproxyapiModelMapping: null, ts: Date.now() };
_proxyConfigCache.set(providerId, result);
return result;
}

View File

@@ -13,6 +13,7 @@ import { getExecutor } from "../../executors/index.ts";
import { isCliproxyapiDeepModeEnabled } from "../../executors/cliproxyapi.ts";
import { getCachedSettings } from "@/lib/db/readCache";
import { getUpstreamProxyConfigCached } from "./comboContextCache.ts";
import { wrapExecutorWithCliproxyapiModelMapping } from "./cliproxyModelMapping.ts";
type LoggerLike =
| {
@@ -47,12 +48,20 @@ export async function resolveExecutorWithProxy(
if (cfg.mode === "cliproxyapi") {
log?.info?.("UPSTREAM_PROXY", `${prov} routed through CLIProxyAPI (passthrough)`);
return getExecutor("cliproxyapi");
return wrapExecutorWithCliproxyapiModelMapping(
getExecutor("cliproxyapi"),
cfg.cliproxyapiModelMapping
);
}
// mode === "fallback": try native first, retry via CLIProxyAPI on specific failures
// mode === "fallback": try native first, retry via CLIProxyAPI on specific failures.
// The model mapping applies only to the CLIProxyAPI retry leg (proxyExec) — the
// native leg must keep seeing the original, unmapped model.
const nativeExec = getExecutor(prov);
const proxyExec = getExecutor("cliproxyapi");
const proxyExec = wrapExecutorWithCliproxyapiModelMapping(
getExecutor("cliproxyapi"),
cfg.cliproxyapiModelMapping
);
// Read custom fallback codes from settings. Default: 5xx + 429 + network errors.
let fallbackCodes: number[] = [429, 500, 502, 503, 504];

View File

@@ -37,6 +37,7 @@ import {
oneproxyStatsInput,
} from "./schemas/tools.ts";
import { startMcpHeartbeat } from "./runtimeHeartbeat.ts";
import { countUniqueMcpTools } from "./toolCount.ts";
import { z } from "zod";
import { closeAuditDb, logToolCall } from "./audit.ts";
import {
@@ -98,17 +99,18 @@ const MCP_ALLOWED_SCOPES = new Set(
.map((s) => s.trim())
.filter(Boolean)
);
const TOTAL_MCP_TOOL_COUNT =
MCP_TOOLS.length +
Object.keys(memoryTools).length +
Object.keys(skillTools).length +
Object.keys(agentSkillTools).length +
Object.keys(githubSkillTools).length +
Object.keys(poolTools).length +
gamificationTools.length +
pluginTools.length +
notionTools.length +
obsidianTools.length;
const TOTAL_MCP_TOOL_COUNT = countUniqueMcpTools({
MCP_TOOLS,
memoryTools,
skillTools,
agentSkillTools,
githubSkillTools,
poolTools,
gamificationTools,
pluginTools,
notionTools,
obsidianTools,
});
type JsonRecord = Record<string, unknown>;

View File

@@ -0,0 +1,36 @@
/**
* countUniqueMcpTools — de-duplicated MCP tool count.
*
* The various tool collections registered by the MCP server (array-shaped, e.g.
* `MCP_TOOLS`, and record-shaped, e.g. `memoryTools`) are not guaranteed disjoint by
* tool `name` — some tools (e.g. the agent-skills trio) are intentionally defined in
* both an array collection and a record collection for registration purposes. Summing
* `collection.length` / `Object.keys(collection).length` across all sources therefore
* double-counts any name that appears in more than one source.
*
* This helper unions every collection's tool names into a `Set` and returns the size
* of that set, so the reported tool count always reflects distinct, user-visible tool
* names regardless of how many internal collections a given tool happens to appear in.
*/
type NamedTool = { name: string };
type ToolCollection = readonly NamedTool[] | Readonly<Record<string, NamedTool>>;
function collectionNames(collection: ToolCollection): string[] {
const items: NamedTool[] = Array.isArray(collection)
? collection
: Object.values(collection as Record<string, NamedTool>);
return items.map((item) => item.name);
}
export function countUniqueMcpTools(
collectionsByLabel: Readonly<Record<string, ToolCollection>>
): number {
const uniqueNames = new Set<string>();
for (const collection of Object.values(collectionsByLabel)) {
for (const name of collectionNames(collection)) {
uniqueNames.add(name);
}
}
return uniqueNames.size;
}

View File

@@ -189,7 +189,10 @@ export const OAUTH_INVALID_TOKEN_SIGNALS = [
// Context overflow patterns — the prompt exceeds the model's maximum context length.
// Different providers phrase this differently. Used to decide whether a 400 error
// should trigger combo fallback (a different model may have a larger context window).
const CONTEXT_OVERFLOW_PATTERNS = [
// Exported so combo.ts's isContextOverflow400() guard (open-sse/services/combo.ts)
// can reuse this single source of truth instead of maintaining its own,
// independently-drifting pattern list (see issue #6637).
export const CONTEXT_OVERFLOW_PATTERNS = [
/\binput is too long\b/i,
/\binput too long\b/i,
/\bcontext.*(too long|exceeded|overflow|limit)/i,

View File

@@ -8,6 +8,7 @@
import {
checkFallbackError,
classifyLockoutReason,
CONTEXT_OVERFLOW_PATTERNS,
decayModelFailureCount,
formatRetryAfter,
getModelLockoutInfo,
@@ -663,7 +664,12 @@ export function isContextOverflow400(errorText) {
return (
/\bcontext.*(?:length_exceeded|too long|overflow|exceeded|window|limit)\b/i.test(errorText) ||
/exceeds.*context/i.test(errorText) ||
/your input exceeds/i.test(errorText)
/your input exceeds/i.test(errorText) ||
// Reuse accountFallback.ts's CONTEXT_OVERFLOW_PATTERNS (single source of truth)
// so wording like Kimi's "exceeded model token limit" — which never says the
// literal word "context" — is still recognized as an overflow/fallback-worthy
// 400 instead of halting the whole combo (issue #6637).
CONTEXT_OVERFLOW_PATTERNS.some((p) => p.test(errorText))
);
}
/** @param {string} errorText */

View File

@@ -6,6 +6,8 @@
* default-off; the inflation guard here is an honest DEFAULT-ON check on the FINAL output.
*/
import type { CompressionResult, CompressionStats } from "./types.ts";
export interface PipelineInflationInput {
/** The verbatim request body before any engine ran. */
originalBody: Record<string, unknown>;
@@ -43,3 +45,45 @@ export function guardPipelineInflation(input: PipelineInflationInput): PipelineI
}
return { body: input.compressedBody, inflated: false };
}
/**
* Applies the aggregate inflation guard to a finalized stacked-pipeline `stats` object, honoring
* the `compressed` loop-level flag (#6480). If the fully-stacked body did not actually shrink
* (its token count is >= the original), discards it and returns the verbatim original — safe by
* construction, since the original request body is always a valid payload.
*
* Only meaningful when some step actually advanced `currentBody` (`compressed === true`). When
* no step in the pipeline ever produced/advanced a candidate (e.g. a single no-op engine on an
* out-of-charter payload), `currentBody` is still reference-identical to `originalBody`, so
* tokens are trivially equal — running the guard in that case would mislabel a genuine no-op as
* a "reverted" fallback (`fallbackApplied: true` + a misleading warning) even though nothing was
* ever computed to revert.
*/
export function applyStackedInflationGuard(
originalBody: Record<string, unknown>,
currentBody: Record<string, unknown>,
compressed: boolean,
stats: CompressionStats
): CompressionResult {
if (!compressed) return { body: currentBody, compressed, stats };
const inflation = guardPipelineInflation({
originalBody,
compressedBody: currentBody,
originalTokens: stats.originalTokens,
compressedTokens: stats.compressedTokens,
});
if (!inflation.inflated) return { body: currentBody, compressed, stats };
const inflatedTokens = stats.compressedTokens;
const warnings = new Set(stats.validationWarnings ?? []);
warnings.add(
`pipeline-inflation-guard: stacked output (${inflatedTokens} tok) did not shrink input ` +
`(${stats.originalTokens} tok); reverted to original`
);
stats.validationWarnings = Array.from(warnings);
stats.fallbackApplied = true;
stats.compressedTokens = stats.originalTokens;
stats.savingsPercent = 0;
return { body: inflation.body, compressed: false, stats };
}

View File

@@ -62,13 +62,27 @@ export function decideStep(
return { advance: true };
}
/**
* A dispatched step whose engine found nothing eligible (e.g. session-dedup with no repeated
* blocks, ccr below its min-chars threshold) returns `stats: null` instead of throwing or
* advancing. Left unrecorded, that step vanishes from the pipeline's telemetry with zero trace —
* no `engineBreakdown` entry, no warning, no error (#6479, #6491). Surface it as a validation
* warning so operators can tell "engine ran but had nothing to do" apart from "engine never ran".
*/
function recordNullStatsStep(acc: StackAccumulator, engineId: string): void {
acc.validationWarnings.add(`${engineId}: skipped (no eligible content)`);
}
/** Folds one engine result into the accumulator (telemetry + breakdown entry). */
export function mergeStackStep(
acc: StackAccumulator,
engineId: string,
result: CompressionResult
): void {
if (!result.stats) return;
if (!result.stats) {
recordNullStatsStep(acc, engineId);
return;
}
result.stats.techniquesUsed.forEach((technique) => acc.techniques.add(technique));
result.stats.rulesApplied?.forEach((rule) => acc.rules.add(rule));
result.stats.rtkRawOutputPointers?.forEach((pointer) => acc.rtkRawOutputPointers.push(pointer));

View File

@@ -13,7 +13,7 @@ import { cavemanCompress } from "./caveman.ts";
import { compressAggressive } from "./aggressive.ts";
import { ultraCompress, ultraCompressHeuristic } from "./ultra.ts";
import { createCompressionStats } from "./stats.ts";
import { guardPipelineInflation } from "./pipelineGuards.ts";
import { applyStackedInflationGuard } from "./pipelineGuards.ts";
import {
resolvePipelineBreakerConfig,
canRunEngine,
@@ -762,30 +762,10 @@ function finalizeStackedResult(
});
}
// T02 / H1: honest aggregate inflation guard. If the fully-stacked body did not actually shrink
// (its token count is >= the original), discard it and return the verbatim original — safe by
// construction, since the original request body is always a valid payload.
const inflation = guardPipelineInflation({
originalBody,
compressedBody: currentBody,
originalTokens: stats.originalTokens,
compressedTokens: stats.compressedTokens,
});
if (inflation.inflated) {
const inflatedTokens = stats.compressedTokens;
const warnings = new Set(stats.validationWarnings ?? []);
warnings.add(
`pipeline-inflation-guard: stacked output (${inflatedTokens} tok) did not shrink input ` +
`(${stats.originalTokens} tok); reverted to original`
);
stats.validationWarnings = Array.from(warnings);
stats.fallbackApplied = true;
stats.compressedTokens = stats.originalTokens;
stats.savingsPercent = 0;
return { body: inflation.body, compressed: false, stats };
}
return { body: currentBody, compressed, stats };
// T02 / H1 / #6480: honest aggregate inflation guard, gated on the loop-level `compressed`
// flag so a pipeline where nothing ever advanced isn't mislabeled as a reverted fallback.
// See `applyStackedInflationGuard` in `pipelineGuards.ts` for the full rationale.
return applyStackedInflationGuard(originalBody, currentBody, compressed, stats);
}
// ── Shared per-step helpers (used by the sync + async stacked loops; keep them in lockstep) ──

View File

@@ -129,13 +129,29 @@ function buildOpenAISummary(events: StructuredSSEEvent[], fallbackModel?: string
function: { name: string; arguments: string };
};
const toolCalls = new Map<string, ToolCall>();
// Aliases every `idx:N` key we've seen to the `id:X` it was first observed with (and
// vice versa), so a later delta chunk that only carries one of the two dimensions
// (e.g. a continuation chunk with `id` but no `index` — a known quirk of some
// OpenAI-compatible proxies) still resolves to the SAME accumulator entry instead of
// splitting one logical tool call into two (#6276).
const keyAliases = new Map<string, string>();
let unknownToolCallSeq = 0;
let finishReason = "stop";
let usage: JsonRecord | null = null;
const getToolCallKey = (toolCall: JsonRecord) => {
if (Number.isInteger(toolCall.index)) return `idx:${toolCall.index}`;
if (toolCall.id) return `id:${toolCall.id}`;
const idKey = typeof toolCall.id === "string" && toolCall.id ? `id:${toolCall.id}` : null;
const idxKey = Number.isInteger(toolCall.index) ? `idx:${toolCall.index}` : null;
const resolvedKey = (idKey && keyAliases.get(idKey)) || (idxKey && keyAliases.get(idxKey));
const key = resolvedKey || idKey || idxKey;
if (key) {
if (idKey) keyAliases.set(idKey, key);
if (idxKey) keyAliases.set(idxKey, key);
return key;
}
unknownToolCallSeq += 1;
return `seq:${unknownToolCallSeq}`;
};

View File

@@ -129,6 +129,10 @@ export function resolveNextBuildEnv(baseEnv = process.env) {
// this only in the Docker builder stage (ENV NODE_OPTIONS); the local/native path
// was left unprotected. Respect an existing --max-old-space-size (Docker already
// sets one — don't clobber/duplicate) and let OMNIROUTE_BUILD_MEMORY_MB override.
// NOTE (#6409): --max-old-space-size only bounds V8's JS heap — it does NOT bound
// Turbopack's native (Rust, off-V8-heap) memory, which is the default bundler as of
// #6283. On memory-constrained machines, set OMNIROUTE_USE_TURBOPACK=0 (webpack
// fallback) instead of raising this heap value; see docs/reference/ENVIRONMENT.md.
if (!/--max-old-space-size/.test(env.NODE_OPTIONS || "")) {
// Default 8 GB (was 4 GB): the clean module graph peaks ~3.9 GB during the webpack
// production pass, which brushed the old 4 GB ceiling on a borderline OOM. 8 GB gives

View File

@@ -158,12 +158,39 @@ function parseCodex(data: any) {
return quotas;
}
function buildClaudeExtraUsageQuota(extraUsage: any) {
const monthlyLimit = Number(extraUsage?.monthly_limit ?? 0);
const usedCredits = Number(extraUsage?.used_credits ?? 0);
const utilization = Number(extraUsage?.utilization ?? 0);
const remainingPercentage = Number.isFinite(utilization)
? Math.max(0, 100 - utilization)
: undefined;
const remaining = Number.isFinite(monthlyLimit) ? Math.max(0, monthlyLimit - usedCredits) : 0;
return buildCreditsQuota("extra_usage", remaining, remainingPercentage ?? 100, {
used: Number.isFinite(usedCredits) ? usedCredits : 0,
total: Number.isFinite(monthlyLimit) ? monthlyLimit : 0,
currency: extraUsage?.currency,
});
}
// #6806: some Claude plans (e.g. "default_raven_enterprise") return no
// five_hour/seven_day utilization windows at all — only a credit-billing
// extraUsage block — so quotas can be {} while extraUsage still holds real,
// actionable usage data. Fold it in instead of falling back to "No quota data".
function parseClaude(data: any) {
if (data?.message)
return [{ name: "error", used: 0, total: 0, resetAt: null, message: data.message }];
return quotaEntries(data).map(([name, quota]) =>
const quotas = quotaEntries(data).map(([name, quota]) =>
normalizeQuotaEntry(name, quota, { isPercentageOnly: true })
);
if (data?.extraUsage?.is_enabled) {
quotas.push(buildClaudeExtraUsageQuota(data.extraUsage));
}
return quotas;
}
function parseDeepseekQuota(quotaKey: string, quota: any) {

View File

@@ -10,6 +10,7 @@ const _require = createRequire(import.meta.url);
declare global {
var __omnirouteSqlJsAdapters: Map<string, SqliteAdapter> | undefined;
var __omnirouteSqlJsInitPromises: Map<string, Promise<SqliteAdapter>> | undefined;
}
function getSqlJsCache(): Map<string, SqliteAdapter> {
@@ -19,6 +20,20 @@ function getSqlJsCache(): Map<string, SqliteAdapter> {
return globalThis.__omnirouteSqlJsAdapters;
}
/**
* Cache das Promises de inicialização EM VOO (não resolvidas ainda), por filePath.
* Separado de getSqlJsCache() (que só guarda o adapter já resolvido) para que
* chamadores concorrentes (BATCH/STARTUP/HealthCheck/ProviderLimitsSync no boot)
* compartilhem UMA única leitura+decode do arquivo em vez de cada um chamar
* fs.readFileSync + WASM decode independentemente (#6628 — thundering herd).
*/
function getSqlJsPendingCache(): Map<string, Promise<SqliteAdapter>> {
if (!globalThis.__omnirouteSqlJsInitPromises) {
globalThis.__omnirouteSqlJsInitPromises = new Map();
}
return globalThis.__omnirouteSqlJsInitPromises;
}
/** Tenta abrir com better-sqlite3 e node:sqlite sincronamente. Retorna null se ambos falharem. */
export function tryOpenSync(
filePath: string,
@@ -75,10 +90,26 @@ export async function preInitSqlJs(filePath: string): Promise<SqliteAdapter> {
cache.delete(filePath);
}
const { createSqlJsAdapter } = await import("./sqljsAdapter");
const adapter = await createSqlJsAdapter(filePath);
cache.set(filePath, adapter);
return adapter;
// Share one in-flight load across concurrent callers for the same filePath
// (#6628): without this, each of BATCH/STARTUP/HealthCheck/ProviderLimitsSync
// independently fs.readFileSync + WASM-decode the same (possibly 300+MB) file
// at boot, multiplying peak memory pressure by the number of racing callers.
const pending = getSqlJsPendingCache();
const inflight = pending.get(filePath);
if (inflight) return inflight;
const initPromise = (async () => {
const { createSqlJsAdapter } = await import("./sqljsAdapter");
const adapter = await createSqlJsAdapter(filePath);
cache.set(filePath, adapter);
return adapter;
})();
pending.set(filePath, initPromise);
try {
return await initPromise;
} finally {
pending.delete(filePath);
}
}
/** Retorna adapter sql.js pré-inicializado ou null se ainda não inicializado. */

View File

@@ -31,6 +31,57 @@ function resolveSqlJsWasmPath(): string {
return candidatePaths[0];
}
/**
* better-sqlite3's named-parameter convention lets callers bind with the bare
* property name (e.g. `{ isActive: 1 }` for a SQL placeholder written as
* `@isActive`, `:isActive`, or `$isActive` — better-sqlite3 strips the sigil
* internally). sql.js's own named-bind path (`sqlite3_bind_parameter_index`)
* requires the FULL name INCLUDING the sigil, and silently no-ops (does not
* throw) for a key it can't resolve. Expand each bare key to all three
* sigil-prefixed variants so sql.js matches whichever sigil the SQL actually
* used, while passing through any key the caller already prefixed unchanged.
*/
function withNamedParamPrefixes(obj: Record<string, unknown>): Record<string, unknown> {
const expanded: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (/^[:@$]/.test(key)) {
expanded[key] = value;
continue;
}
expanded[`@${key}`] = value;
expanded[`:${key}`] = value;
expanded[`$${key}`] = value;
}
return expanded;
}
/**
* sql.js's own `stmt.bind()` dispatches on shape: an Array means positional
* bind (each element -> bind index N), a plain object means named-parameter
* bind. Callers here always pass their rest-args as an array, so a caller
* doing `.all({ isActive: 1 })` for a named placeholder (mirrors
* better-sqlite3's spread-args named-bind convention, see
* betterSqliteAdapter.ts) ends up handing sql.js `[{isActive:1}]` — an ARRAY
* containing the object — which sql.js treats as a single positional value
* and rejects with "Wrong API use : tried to bind a value of an unknown
* type (...)." (#6802). Unwrap a lone plain-object param back to the object
* itself (sigil-expanded) so sql.js takes its named-bind path instead.
*/
function toBindValue(params: unknown[]): unknown[] | Record<string, unknown> | undefined {
if (!params.length) return undefined;
const [first] = params;
const isLoneNamedParamsObject =
params.length === 1 &&
first !== null &&
typeof first === "object" &&
!Array.isArray(first) &&
!Buffer.isBuffer(first) &&
!(first instanceof Uint8Array);
return isLoneNamedParamsObject
? withNamedParamPrefixes(first as Record<string, unknown>)
: params;
}
async function loadSqlJs(): Promise<typeof _sqlJsLib> {
if (_sqlJsLib) return _sqlJsLib;
const initSqlJs = ((await import("sql.js")) as { default: (typeof import("sql.js"))["default"] })
@@ -103,7 +154,8 @@ export async function createSqlJsAdapter(filePath: string): Promise<SqliteAdapte
run(...params: unknown[]): RunResult {
const stmt = db.prepare(sql);
try {
if (params.length) stmt.bind(params as unknown[]);
const bindValue = toBindValue(params);
if (bindValue !== undefined) stmt.bind(bindValue);
stmt.step();
const changes = db.getRowsModified();
const lastRows = db.exec("SELECT last_insert_rowid() as id");
@@ -117,7 +169,8 @@ export async function createSqlJsAdapter(filePath: string): Promise<SqliteAdapte
get(...params: unknown[]): unknown {
const stmt = db.prepare(sql);
try {
if (params.length) stmt.bind(params as unknown[]);
const bindValue = toBindValue(params);
if (bindValue !== undefined) stmt.bind(bindValue);
if (stmt.step()) return stmt.getAsObject();
return undefined;
} finally {
@@ -127,7 +180,8 @@ export async function createSqlJsAdapter(filePath: string): Promise<SqliteAdapte
all(...params: unknown[]): unknown[] {
const stmt = db.prepare(sql);
try {
if (params.length) stmt.bind(params as unknown[]);
const bindValue = toBindValue(params);
if (bindValue !== undefined) stmt.bind(bindValue);
const rows: unknown[] = [];
while (stmt.step()) rows.push(stmt.getAsObject());
return rows;

View File

@@ -11,6 +11,7 @@ import { getComboModelProvider as getComboEntryProvider } from "@/lib/combos/ste
import { requestBodyLimitMbFromEnv } from "@/shared/constants/bodySize";
import { DEFAULT_RESPONSES_PREVIOUS_RESPONSE_ID_MODE } from "@/shared/constants/responsesPreviousResponseId";
import { type JsonRecord, toRecord } from "./settings/shared";
import { resolveNoAuthSharedProviderProxy } from "./settings/noAuthProxyFallback";
type ProxyValue = JsonRecord | string | null;
type ProxyResolutionResult = {
@@ -561,6 +562,19 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?:
}
}
// Step 8.5 (#6272): no-auth providers (mimocode, opencode, ...) share a single
// synthetic connectionId that never matches a `provider_connections` row, so
// `connectionRecord` above is null and Steps 5-8 (which require it) never run for
// them — a provider-level proxy assigned to a no-auth provider was silently
// ignored. Best-effort fallback: scan the known no-auth provider ids directly.
if (!connectionRecord) {
const noAuthFallback = await resolveNoAuthSharedProviderProxy(config.providers);
if (noAuthFallback) {
cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, noAuthFallback);
return noAuthFallback;
}
}
// Step 9: Global registry
const registryGlobal = await resolveProxyForScopeFromRegistry("global");
if (registryGlobal?.proxy) {

View File

@@ -0,0 +1,65 @@
/**
* db/settings/noAuthProxyFallback.ts
*
* #6272 — no-auth providers (mimocode, opencode, ...) are dispatched with a single
* hardcoded, provider-agnostic connectionId ("noauth" — SYNTHETIC_NOAUTH_CONNECTION_ID
* in src/sse/services/auth.ts). No `provider_connections` row ever has id="noauth", so
* `resolveProxyForConnection()` in ../settings.ts can never populate `connectionRecord`
* for these providers, and its provider-level proxy lookup (Steps 6/8) only runs when
* `connectionRecord` is present — a proxy assigned via Settings -> Providers -> mimocode
* (or any other no-auth provider) was therefore silently unreachable.
*
* This is a best-effort fallback invoked only when `connectionRecord` could not be
* found: it scans the known no-auth provider ids for a configured provider-level
* proxy (registry first, then legacy `proxyConfig.providers`) and returns the first
* match. It intentionally does not try to disambiguate which no-auth provider issued
* the request (the shared connectionId carries no such information) — in practice at
* most one no-auth provider has a provider-level proxy assigned at a time.
*/
import { NOAUTH_PROVIDERS } from "@/shared/constants/providers";
import { resolveProxyForScopeFromRegistry } from "../proxies";
import type { JsonRecord } from "./shared";
type ProxyValue = JsonRecord | string | null;
export type NoAuthProxyResolutionResult = {
proxy: ProxyValue;
level: string;
levelId: string | null;
source?: string;
};
type LegacyProviderProxyMap = Record<string, ProxyValue> | undefined | null;
// Mirrors settings.ts's withFamilyDefault: legacy proxyConfig entries predate the
// IPv6-only `family` directive, so default it to "auto" when missing.
function withFamilyDefault(value: ProxyValue): ProxyValue {
if (value && typeof value === "object" && !Array.isArray(value)) {
const record = value as JsonRecord;
if (typeof record.family === "string") return record;
return { ...record, family: "auto" };
}
return value;
}
async function resolveOneNoAuthProviderProxy(
providerId: string,
legacyProviders: LegacyProviderProxyMap
): Promise<NoAuthProxyResolutionResult | null> {
const registryProvider = await resolveProxyForScopeFromRegistry("provider", providerId);
if (registryProvider?.proxy) return registryProvider as NoAuthProxyResolutionResult;
const legacyProxy = legacyProviders?.[providerId];
if (legacyProxy) {
return { proxy: withFamilyDefault(legacyProxy), level: "provider", levelId: providerId };
}
return null;
}
export async function resolveNoAuthSharedProviderProxy(
legacyProviders: LegacyProviderProxyMap
): Promise<NoAuthProxyResolutionResult | null> {
for (const providerId of Object.keys(NOAUTH_PROVIDERS)) {
const resolved = await resolveOneNoAuthProviderProxy(providerId, legacyProviders);
if (resolved) return resolved;
}
return null;
}

View File

@@ -341,8 +341,33 @@ function resolveVisionCapability(
return null;
}
/**
* Issue #6524: an operator-set `max_token` capability override (see
* `src/lib/db/modelCapabilityOverrides.ts`) is the manual escape hatch for a
* wrong/stale synced `limit_output` value (e.g. a provider's models.dev catalog
* row reporting `limit_output` equal to `limit_context`). It already won over the
* synced value in `getResolvedModelCapabilities().maxOutputTokens` — this helper
* makes `getExplicitModelOutputCap()` (used by the reasoning-token-buffer clamp)
* consult the same override so both read paths agree.
*/
function getMaxTokenCapabilityOverride(resolved: {
provider: string | null;
model: string | null;
rawModel: string | null;
}): number | null {
return (
getModelCapabilityOverride(resolved.provider, resolved.model, "max_token") ??
(resolved.rawModel && resolved.rawModel !== resolved.model
? getModelCapabilityOverride(resolved.provider, resolved.rawModel, "max_token")
: null)
);
}
export function getExplicitModelOutputCap(input: CapabilityInput): number | null {
const resolved = resolveCapabilityInput(input);
const maxTokenOverride = getMaxTokenCapabilityOverride(resolved);
if (maxTokenOverride !== null) return maxTokenOverride;
const synced = getSyncedCapabilityForResolved(
resolved.provider,
resolved.model,
@@ -402,11 +427,7 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo
spec?.contextWindow ??
null;
const maxTokenOverride =
getModelCapabilityOverride(resolved.provider, resolved.model, "max_token") ??
(resolved.rawModel && resolved.rawModel !== resolved.model
? getModelCapabilityOverride(resolved.provider, resolved.rawModel, "max_token")
: null);
const maxTokenOverride = getMaxTokenCapabilityOverride(resolved);
return {
provider: resolved.provider,

View File

@@ -1,6 +1,6 @@
declare module "sql.js" {
export interface SqlJsStatement {
bind(values: unknown[]): void;
bind(values: unknown[] | Record<string, unknown>): void;
step(): boolean;
getAsObject(): Record<string, unknown>;
free(): void;

View File

@@ -0,0 +1,128 @@
/**
* Regression tests for #6876 — `cliproxyapiModelMapping` was persisted by the
* dashboard (`upstream_proxy_config.cliproxyapi_model_mapping`) but never
* consulted at request-dispatch time: the mapped model never made it onto the
* outbound CLIProxyAPI wire request.
*
* All tests exercise REAL production functions end-to-end:
* - upsertUpstreamProxyConfig (src/lib/db/upstreamProxy.ts)
* - resolveExecutorWithProxy (open-sse/handlers/chatCore/executorProxy.ts)
* - CliproxyapiExecutor.execute (open-sse/executors/cliproxyapi.ts)
* `globalThis.fetch` is stubbed only to capture the outbound wire body.
*/
import { describe, it, before, after, afterEach } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-6876-model-mapping-"));
process.env.DATA_DIR = testDataDir;
const coreDb = await import("../../src/lib/db/core.ts");
const upstreamProxyDb = await import("../../src/lib/db/upstreamProxy.ts");
const { resolveExecutorWithProxy } =
await import("../../open-sse/handlers/chatCore/executorProxy.ts");
const { clearUpstreamProxyConfigCache } =
await import("../../open-sse/handlers/chatCore/comboContextCache.ts");
before(async () => {
await coreDb.ensureDbInitialized();
});
afterEach(() => {
clearUpstreamProxyConfigCache();
});
after(() => {
coreDb.resetDbInstance();
if (fs.existsSync(testDataDir)) fs.rmSync(testDataDir, { recursive: true, force: true });
});
type ExecuteInput = {
model: string;
body: unknown;
stream: boolean;
credentials: unknown;
};
type ExecutorLike = { execute: (input: ExecuteInput) => Promise<unknown> };
async function captureFetchBody(fn: () => Promise<unknown>): Promise<Record<string, unknown>> {
let capturedBody: Record<string, unknown> | null = null;
const originalFetch = globalThis.fetch;
// @ts-expect-error test stub
globalThis.fetch = async (_url: string, init: RequestInit) => {
capturedBody = JSON.parse(init.body as string);
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
await fn();
} finally {
globalThis.fetch = originalFetch;
}
assert.ok(capturedBody, "executor should have issued a fetch call");
return capturedBody as Record<string, unknown>;
}
describe("#6876 — cliproxyapiModelMapping applied at dispatch", () => {
it("forwards the MAPPED model to CLIProxyAPI in cliproxyapi (passthrough) mode", async () => {
await upstreamProxyDb.upsertUpstreamProxyConfig({
providerId: "anthropic-mapped-passthrough",
mode: "cliproxyapi",
enabled: true,
cliproxyapiModelMapping: { "claude-3-opus": "claude-3-opus-mapped" },
});
const executor = await resolveExecutorWithProxy(
"anthropic-mapped-passthrough",
undefined,
null
);
assert.equal(
(executor as { provider?: string }).provider,
"cliproxyapi",
"sanity: provider should route through the cliproxyapi executor"
);
const capturedBody = await captureFetchBody(() =>
(executor as ExecutorLike).execute({
model: "claude-3-opus",
body: { model: "claude-3-opus", messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "test-key" },
})
);
assert.equal(
capturedBody.model,
"claude-3-opus-mapped",
`expected mapped model "claude-3-opus-mapped" to be forwarded upstream, got unmapped "${capturedBody.model}"`
);
});
it("does NOT remap the model when no mapping is configured (no regression)", async () => {
await upstreamProxyDb.upsertUpstreamProxyConfig({
providerId: "anthropic-no-mapping",
mode: "cliproxyapi",
enabled: true,
});
const executor = await resolveExecutorWithProxy("anthropic-no-mapping", undefined, null);
const capturedBody = await captureFetchBody(() =>
(executor as ExecutorLike).execute({
model: "claude-3-opus",
body: { model: "claude-3-opus", messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "test-key" },
})
);
assert.equal(capturedBody.model, "claude-3-opus", "unmapped model must pass through unchanged");
});
});

View File

@@ -113,7 +113,11 @@ test("applyStackedCompression reverts to the original body when the pipeline inf
const body = {
messages: [{ role: "user", content: "hello world this is a short message" }],
};
const result = applyStackedCompression(body, [INFLATE_ID]);
// Pass a step object, not a bare string: `normalizePipelineStep()` only recognizes a fixed
// set of built-in bare-string aliases ("standard"/"rtk"/"lite"/"aggressive"/"ultra") and
// silently falls back to `{ engine: "caveman" }` for any other string — a bare custom-engine
// id here would silently run caveman instead of the registered `inflatingEngine`.
const result = applyStackedCompression(body, [{ engine: INFLATE_ID }]);
// The inflating engine produced a bigger body, so the aggregate guard discarded it.
assert.equal(result.compressed, false);

View File

@@ -0,0 +1,103 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { applyStackedCompression } from "../../../open-sse/services/compression/strategySelector.ts";
/**
* Regression coverage for #6479 and #6491: a dispatched stacked-pipeline step whose engine
* legitimately finds nothing eligible (`session-dedup` with no repeated blocks, `ccr` below its
* min-chars threshold) returns `{ stats: null }`. Before the fix, `mergeStackStep()` in
* `stackedStepCore.ts` silently dropped that step from the accumulator: no `engineBreakdown`
* entry, no `validationWarnings`, no `validationErrors` — zero trace the engine ever ran.
*
* Both issues report the exact same symptom for two different registered/known engines, so both
* are covered here against the shared fix (a `validationWarnings` entry recorded whenever a step
* returns `stats: null`).
*/
describe("#6479/#6491 — null-stats step no longer silently dropped from the pipeline", () => {
it("session-dedup with nothing to dedupe is explained in engineBreakdown or validationWarnings", () => {
// Small markdown table, short rows — well under session-dedup's 80-char/3-line
// suffix-block threshold, so session-dedup legitimately finds nothing to dedupe.
const body = {
messages: [
{
role: "user",
content:
"| a | b |\n|---|---|\n| 1 | 2 |\n| 1 | 2 |\n| 1 | 2 |\n| 1 | 2 |\n| 1 | 2 |\n| 1 | 2 |",
},
],
};
const pipeline = [{ engine: "session-dedup" }, { engine: "rtk" }, { engine: "caveman" }];
const result = applyStackedCompression(body, pipeline);
const engines = result.stats?.engineBreakdown?.map((e) => e.engine) ?? [];
const warnings = result.stats?.validationWarnings ?? [];
const errors = result.stats?.validationErrors ?? [];
assert.ok(
engines.includes("session-dedup") ||
warnings.some((w) => w.includes("session-dedup")) ||
errors.some((w) => w.includes("session-dedup")),
`session-dedup missing from engineBreakdown (${JSON.stringify(engines)}) with no ` +
`explanation in validationWarnings/validationErrors — matches issue #6479's report`
);
// Specifically: the shared no-op reason must be present.
assert.ok(
warnings.some((w) => w === "session-dedup: skipped (no eligible content)") ||
engines.includes("session-dedup"),
`expected an explicit skip reason for session-dedup, got warnings=${JSON.stringify(warnings)}`
);
});
it("ccr with no duplicate-eligible block (>=600 chars) is explained, not silently dropped", () => {
const body = {
messages: [
{
role: "tool",
content: Array.from({ length: 8 }, () => "same noisy tool output line").join("\n"),
},
{
role: "user",
content:
"Please provide a detailed explanation of the authentication configuration and how it works",
},
],
};
const pipeline = [{ engine: "ccr" }];
const result = applyStackedCompression(body, pipeline);
const engines = result.stats?.engineBreakdown?.map((e) => e.engine) ?? [];
const warnings = result.stats?.validationWarnings ?? [];
const errors = result.stats?.validationErrors ?? [];
assert.ok(
engines.includes("ccr") ||
warnings.some((w) => w.includes("ccr")) ||
errors.some((w) => w.includes("ccr")),
`ccr missing from engineBreakdown (${JSON.stringify(engines)}) with no explanation in ` +
`validationWarnings/validationErrors — matches issue #6491's report`
);
assert.ok(
warnings.some((w) => w === "ccr: skipped (no eligible content)") || engines.includes("ccr"),
`expected an explicit skip reason for ccr, got warnings=${JSON.stringify(warnings)}`
);
});
it("control: ccr with a large duplicate-eligible block (>=600 chars) still runs and advances", () => {
const bigBody = {
messages: [
{
role: "tool",
content: Array.from({ length: 40 }, () => "same noisy tool output line").join("\n"),
},
{ role: "user", content: "please explain" },
],
};
const result = applyStackedCompression(bigBody, [{ engine: "ccr" }]);
const engines = result.stats?.engineBreakdown?.map((e) => e.engine) ?? [];
assert.ok(engines.includes("ccr"), `expected 'ccr' in engineBreakdown (control), got ${JSON.stringify(engines)}`);
});
});

View File

@@ -0,0 +1,39 @@
import test from "node:test";
import assert from "node:assert/strict";
import { applyStackedCompression } from "../../../open-sse/services/compression/strategySelector.ts";
/**
* Regression coverage for #6480: `finalizeStackedResult` in `strategySelector.ts` used to run
* the aggregate `guardPipelineInflation` check unconditionally, even when the loop-level
* `compressed` flag was `false` (i.e. no engine in the pipeline ever produced/advanced a
* candidate). Since `compressedTokens === originalTokens` trivially holds when nothing ran, the
* guard mislabeled a genuine no-op as `fallbackApplied: true` with a misleading
* "pipeline-inflation-guard ... reverted to original" warning, even though nothing was ever
* computed to revert.
*/
test("#6480: session-dedup no-op on out-of-charter single message does not fire the pipeline-inflation-guard", () => {
const body = {
messages: [
{
role: "user",
content:
"This is a single message with no prior session history and no internally " +
"repeated content whatsoever, so the session-dedup engine has nothing to do.",
},
],
};
const result = applyStackedCompression(body, [{ engine: "session-dedup" }]);
assert.equal(
result.stats?.fallbackApplied,
undefined,
"expected no fallbackApplied flag on a trivial no-op pipeline (engine never advanced)"
);
assert.equal(
(result.stats?.validationWarnings ?? []).some((w) => w.includes("pipeline-inflation-guard")),
false,
"expected no misleading pipeline-inflation-guard warning when nothing was ever compressed"
);
});

View File

@@ -85,6 +85,56 @@ describe("driverFactory", () => {
adapter.close();
});
// #6628 (remaining gap): concurrent preInitSqlJs() calls for the same
// filePath must share ONE in-flight load instead of each caller
// independently fs.readFileSync + WASM-decoding the whole file — the
// thundering-herd amplifier of the OOM condition #6632 already partly
// fixed (restore-cycle-breaker + OOM early-abort), left un-implemented by
// the reporter's own proposed promise-sharing fix.
test("preInitSqlJs shares one in-flight load across concurrent callers", async (t) => {
const os = await import("node:os");
const path = await import("node:path");
// The dynamic-import namespace object is read-only; grab the mutable CJS
// `.default` (== module.exports) so readFileSync can be monkeypatched.
const fsNs = await import("node:fs");
const fs = fsNs.default;
const tmpFile = path.join(os.tmpdir(), `sqljs_race_${Date.now()}.sqlite`);
fs.writeFileSync(tmpFile, Buffer.alloc(1024 * 1024, 1));
t.after(() => {
try {
fs.unlinkSync(tmpFile);
} catch {}
});
let readCountForTarget = 0;
const originalReadFileSync = fs.readFileSync;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fs as any).readFileSync = (...args: Parameters<typeof fs.readFileSync>) => {
if (args[0] === tmpFile) readCountForTarget += 1;
return originalReadFileSync(...args);
};
t.after(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fs as any).readFileSync = originalReadFileSync;
});
const [a, b, c] = await Promise.all([
preInitSqlJs(tmpFile),
preInitSqlJs(tmpFile),
preInitSqlJs(tmpFile),
]);
assert.equal(
readCountForTarget,
1,
`expected exactly 1 shared full-file read for 3 concurrent preInitSqlJs() calls, got ${readCountForTarget}`
);
assert.equal(a, b, "concurrent callers must resolve to the SAME adapter instance");
assert.equal(b, c, "concurrent callers must resolve to the SAME adapter instance");
a.close();
});
test("cross-driver: escreve com adapter sync, relê com sql.js", async (t) => {
const os = await import("node:os");
const path = await import("node:path");

View File

@@ -89,4 +89,90 @@ describe("sqljsAdapter", () => {
assert.equal(row.name, "test-value");
reader.close();
});
// #6802: named-parameter object binds ("... WHERE is_active = @isActive"
// called as .all({ isActive: 1 })) must work the same way they do against
// better-sqlite3, instead of throwing sql.js's own
// "Wrong API use : tried to bind a value of an unknown type (...)." error.
describe("named-parameter object bind (#6802)", () => {
test("all() with a single named-params object mirrors getProviderConnections", async () => {
const adapter = await createSqlJsAdapter(":memory:");
adapter.exec(
"CREATE TABLE provider_connections (id INTEGER PRIMARY KEY, provider TEXT, is_active INTEGER)"
);
adapter
.prepare("INSERT INTO provider_connections (provider, is_active) VALUES (?, ?)")
.run("glm", 1);
adapter
.prepare("INSERT INTO provider_connections (provider, is_active) VALUES (?, ?)")
.run("openai", 0);
const sql =
"SELECT * FROM provider_connections WHERE is_active = @isActive ORDER BY id ASC";
const rows = adapter.prepare(sql).all({ isActive: 1 }) as Array<{ provider: string }>;
assert.equal(rows.length, 1, "expected exactly 1 active provider connection");
assert.equal(rows[0].provider, "glm");
adapter.close();
});
test("get() with a single named-params object resolves the row", async () => {
const adapter = await createSqlJsAdapter(":memory:");
adapter.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)");
adapter.prepare("INSERT INTO t (val) VALUES (?)").run("named-get");
const row = adapter.prepare("SELECT val FROM t WHERE id = @id").get({ id: 1 }) as {
val: string;
};
assert.equal(row.val, "named-get");
adapter.close();
});
test("run() with a single named-params object binds correctly", async () => {
const adapter = await createSqlJsAdapter(":memory:");
adapter.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)");
const result = adapter
.prepare("INSERT INTO t (val) VALUES (@val)")
.run({ val: "named-run" });
assert.equal(result.changes, 1);
const row = adapter
.prepare("SELECT val FROM t WHERE id = ?")
.get(result.lastInsertRowid) as { val: string };
assert.equal(row.val, "named-run");
adapter.close();
});
test("supports :name and $name sigils in addition to @name", async () => {
const adapter = await createSqlJsAdapter(":memory:");
adapter.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)");
adapter.prepare("INSERT INTO t (val) VALUES (?)").run("colon-sigil");
adapter.prepare("INSERT INTO t (val) VALUES (?)").run("dollar-sigil");
const colonRow = adapter.prepare("SELECT val FROM t WHERE val = :val").get({
val: "colon-sigil",
}) as { val: string };
assert.equal(colonRow.val, "colon-sigil");
const dollarRow = adapter.prepare("SELECT val FROM t WHERE val = $val").get({
val: "dollar-sigil",
}) as { val: string };
assert.equal(dollarRow.val, "dollar-sigil");
adapter.close();
});
test("existing positional-array binding still works unchanged", async () => {
const adapter = await createSqlJsAdapter(":memory:");
adapter.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, a TEXT, b TEXT)");
const result = adapter.prepare("INSERT INTO t (a, b) VALUES (?, ?)").run("x", "y");
assert.equal(result.changes, 1);
const row = adapter
.prepare("SELECT a, b FROM t WHERE id = ?")
.get(result.lastInsertRowid) as { a: string; b: string };
assert.equal(row.a, "x");
assert.equal(row.b, "y");
adapter.close();
});
});
});

View File

@@ -0,0 +1,83 @@
import test from "node:test";
import assert from "node:assert/strict";
// Regression for #6854: TOTAL_MCP_TOOL_COUNT (open-sse/mcp-server/server.ts) was a
// plain additive sum across all registered tool collections. Three tools
// (omniroute_agent_skills_list/get/coverage) are intentionally defined in BOTH
// MCP_TOOLS (open-sse/mcp-server/schemas/tools.ts) and agentSkillTools
// (open-sse/mcp-server/tools/agentSkillTools.ts), so the additive sum reported 99
// while only 96 distinct tool names actually exist. countUniqueMcpTools
// (open-sse/mcp-server/toolCount.ts) fixes this by unioning tool names into a Set
// before counting, so a tool present in multiple collections is only counted once.
const { countUniqueMcpTools } = await import("../../open-sse/mcp-server/toolCount.ts");
const { MCP_TOOLS } = await import("../../open-sse/mcp-server/schemas/tools.ts");
const { memoryTools } = await import("../../open-sse/mcp-server/tools/memoryTools.ts");
const { skillTools } = await import("../../open-sse/mcp-server/tools/skillTools.ts");
const { agentSkillTools } = await import("../../open-sse/mcp-server/tools/agentSkillTools.ts");
const { githubSkillTools } = await import("../../open-sse/mcp-server/tools/githubSkillTools.ts");
const { poolTools } = await import("../../open-sse/mcp-server/tools/poolTools.ts");
const { gamificationTools } = await import("../../open-sse/mcp-server/tools/gamificationTools.ts");
const { pluginTools } = await import("../../open-sse/mcp-server/tools/pluginTools.ts");
const { notionTools } = await import("../../open-sse/mcp-server/tools/notionTools.ts");
const { obsidianTools } = await import("../../open-sse/mcp-server/tools/obsidianTools.ts");
type NamedTool = { name: string };
function namesOf(collection: readonly NamedTool[] | Record<string, NamedTool>): string[] {
return Array.isArray(collection)
? collection.map((t) => t.name)
: Object.values(collection).map((t) => t.name);
}
test("#6854: countUniqueMcpTools de-duplicates tools registered in multiple collections", () => {
// The agent-skills trio is intentionally present in both MCP_TOOLS and agentSkillTools.
const mcpToolsNames = namesOf(MCP_TOOLS as unknown as NamedTool[]);
const agentSkillNames = namesOf(agentSkillTools as unknown as Record<string, NamedTool>);
const overlap = mcpToolsNames.filter((n) => agentSkillNames.includes(n));
assert.ok(
overlap.length > 0,
"expected MCP_TOOLS and agentSkillTools to still share the agent-skills tool names " +
"(if this fails because the overlap was removed instead, this test's premise no " +
"longer applies and it should be revisited)"
);
const collections = {
MCP_TOOLS: MCP_TOOLS as unknown as NamedTool[],
memoryTools: memoryTools as unknown as Record<string, NamedTool>,
skillTools: skillTools as unknown as Record<string, NamedTool>,
agentSkillTools: agentSkillTools as unknown as Record<string, NamedTool>,
githubSkillTools: githubSkillTools as unknown as Record<string, NamedTool>,
poolTools: poolTools as unknown as Record<string, NamedTool>,
gamificationTools: gamificationTools as unknown as NamedTool[],
pluginTools: pluginTools as unknown as NamedTool[],
notionTools: notionTools as unknown as NamedTool[],
obsidianTools: obsidianTools as unknown as NamedTool[],
};
const total = countUniqueMcpTools(collections);
// Independently compute the "true" unique count by unioning every collection's
// tool names into a Set — this must equal countUniqueMcpTools's own result AND
// must be strictly less than the naive additive sum whenever there is overlap.
const uniqueNames = new Set<string>();
for (const collection of Object.values(collections)) {
for (const name of namesOf(collection)) uniqueNames.add(name);
}
const naiveAdditiveSum = Object.values(collections).reduce(
(sum, collection) => sum + namesOf(collection).length,
0
);
assert.equal(total, uniqueNames.size, "countUniqueMcpTools must equal the unique-name count");
assert.equal(
total,
naiveAdditiveSum - overlap.length,
"unique count must be exactly the additive sum minus the double-counted overlap"
);
assert.ok(
total < naiveAdditiveSum,
"unique count must be strictly less than the naive additive sum given a known overlap"
);
});

View File

@@ -0,0 +1,46 @@
/**
* Probe for #6409 — "npm run build requires >14 GB RAM".
*
* Root cause: PR #6283 (merged 2026-07-05, one day before this issue was filed)
* made Turbopack the default bundler for `npm run build` (previously opt-in via
* OMNIROUTE_USE_TURBOPACK=1). OmniRoute pins `next@^16.2.6` (resolved 16.2.9),
* a version line where Turbopack *production* builds are known upstream to use
* dramatically more memory than webpack on large module graphs (Vercel reported
* ~21.5 GB on their own dashboard app before the memory-eviction fix landed in
* Next 16.3 — not yet stable on npm as of this triage, only canary/preview).
* OmniRoute's own module graph is large (open-sse workspace, thousands of
* modules), matching the class of app that hits this.
*
* A working escape hatch already exists (`OMNIROUTE_USE_TURBOPACK=0` reverts to
* webpack), but docs/reference/ENVIRONMENT.md documents it only as a fix for
* "native binding / bundler-compat" issues on Windows — it says nothing about
* memory, so a RAM-constrained contributor building from source (exactly this
* reporter's scenario) has no signal to reach for it before their build balloons
* past 14 GB.
*
* This probe proves the informational gap: the ENVIRONMENT.md row for
* OMNIROUTE_USE_TURBOPACK does not mention memory/RAM at all.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
test("#6409 ENVIRONMENT.md documents the memory tradeoff of the Turbopack build default", () => {
const envDocPath = path.join(repoRoot, "docs/reference/ENVIRONMENT.md");
const doc = fs.readFileSync(envDocPath, "utf-8");
const lines = doc.split("\n");
const row = lines.find((l) => l.includes("OMNIROUTE_USE_TURBOPACK"));
assert.ok(row, "ENVIRONMENT.md must document OMNIROUTE_USE_TURBOPACK");
const mentionsMemory = /\b(memory|RAM|GB)\b/i.test(row ?? "");
assert.ok(
mentionsMemory,
"OMNIROUTE_USE_TURBOPACK row must mention the memory/RAM tradeoff so contributors on " +
"memory-constrained machines know the webpack fallback (=0) exists BEFORE `npm run build` " +
"balloons past 14 GB (#6409), not just the Windows/native-binding-compat reason currently documented"
);
});

View File

@@ -0,0 +1,51 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-6272-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
delete process.env.INITIAL_PASSWORD;
const core = await import("../../src/lib/db/core.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD;
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
});
test("#6272: resolveProxyForConnection('noauth', ...) honors a provider-level proxy assigned to 'mimocode'", async () => {
core.getDbInstance();
const proxy = { type: "http", host: "127.0.0.1", port: 8888 };
// Reporter's second symptom: "same thing happen when i set the proxy directly
// in the provider menu" -> assign a provider-scoped proxy to the mimocode
// provider id, the way Settings -> Providers -> mimocode would persist it.
await settingsDb.setProxyForLevel("provider", "mimocode", proxy);
const resolved = await settingsDb.resolveProxyForConnection("noauth", undefined);
assert.equal(
resolved?.proxy?.host,
"127.0.0.1",
`expected the mimocode provider-level proxy to be honored, got level=${resolved?.level} proxy=${JSON.stringify(resolved?.proxy)}`
);
assert.equal(resolved?.level, "provider");
assert.equal(resolved?.levelId, "mimocode");
});
test("control: resolveProxyForConnection('noauth', ...) still honors the GLOBAL proxy when no no-auth provider proxy is set", async () => {
core.getDbInstance();
await settingsDb.deleteProxyForLevel("provider", "mimocode");
const proxy = { type: "http", host: "10.0.0.1", port: 9999 };
await settingsDb.setProxyForLevel("global", null, proxy);
const resolved = await settingsDb.resolveProxyForConnection("noauth", undefined);
assert.equal(resolved?.proxy?.host, "10.0.0.1");
assert.equal(resolved?.level, "global");
});

View File

@@ -0,0 +1,98 @@
import test from "node:test";
import assert from "node:assert/strict";
import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx";
interface QuotaRow {
name: string;
isCredits?: boolean;
remainingPercentage?: number;
}
// Reproduces issue #6806: Claude Code "raven_enterprise" plan usage response has an
// empty `quotas: {}` (no five_hour/seven_day utilization fields returned by Anthropic
// for this plan) but a fully populated, 100%-exhausted `extraUsage` block. The UI must
// not fall back to "No quota data" when extraUsage has real, usable data.
test("#6806: parseQuotaData surfaces Claude extraUsage credits when quotas object is empty", () => {
const data = {
plan: "default_raven_enterprise",
quotas: {},
extraUsage: {
is_enabled: true,
monthly_limit: 120000,
used_credits: 120015,
utilization: 100,
currency: "USD",
decimal_places: 2,
disabled_reason: null,
daily: null,
weekly: null,
},
bootstrap: {
account_uuid: "redacted",
account_email: "redacted",
organization_uuid: "redacted",
organization_name: "redacted",
organization_type: "claude_enterprise",
organization_rate_limit_tier: "default_raven_enterprise",
},
};
const parsed = parseQuotaData("claude", data);
assert.ok(
parsed.length > 0,
`expected at least one quota row derived from extraUsage, got empty array: ${JSON.stringify(parsed)}`
);
const creditRow = parsed.find((row: QuotaRow) => row.isCredits);
assert.ok(creditRow, "expected a credits-style quota row derived from extraUsage");
assert.equal(creditRow.remainingPercentage, 0, "utilization 100% means 0% remaining");
});
test("#6806: parseQuotaData still surfaces extraUsage credits when quotas is also populated", () => {
const data = {
plan: "pro",
quotas: {
"session (5h)": { used: 10, total: 100, remainingPercentage: 90, resetAt: null },
},
extraUsage: {
is_enabled: true,
monthly_limit: 5000,
used_credits: 1000,
utilization: 20,
currency: "USD",
decimal_places: 2,
disabled_reason: null,
daily: null,
weekly: null,
},
};
const parsed = parseQuotaData("claude", data);
assert.equal(parsed.length, 2, `expected session quota + credits row, got: ${JSON.stringify(parsed)}`);
const creditRow = parsed.find((row: QuotaRow) => row.isCredits);
assert.ok(creditRow, "expected a credits-style quota row derived from extraUsage");
assert.equal(creditRow.remainingPercentage, 80, "utilization 20% means 80% remaining");
});
test("#6806: parseQuotaData does not add a credits row when extraUsage is disabled", () => {
const data = {
plan: "pro",
quotas: {
"session (5h)": { used: 10, total: 100, remainingPercentage: 90, resetAt: null },
},
extraUsage: {
is_enabled: false,
monthly_limit: 5000,
used_credits: 0,
utilization: 0,
},
};
const parsed = parseQuotaData("claude", data);
assert.equal(parsed.length, 1, `expected only the session quota, got: ${JSON.stringify(parsed)}`);
assert.equal(parsed.some((row: QuotaRow) => row.isCredits), false);
});

View File

@@ -0,0 +1,103 @@
/**
* Regression test for issue #6524.
*
* Reporter observed: for `ollama-cloud/deepseek-v4-flash`, a synced capability row
* with `limit_output=1048576` (wrongly equal to `limit_context`, while the real
* upstream output cap is `65536`) caused the reasoning-token-buffer heuristic to
* expand `max_tokens` 64000 -> 96000, which upstream rejected with
* "exceeds model's maximum output tokens (65536)".
*
* Root cause (confirmed by reading `resolveReasoningBufferedMaxTokens` and its
* `getExplicitModelOutputCap` clamp source): the clamp math itself is correct, but
* `getExplicitModelOutputCap()` only ever read the unvalidated synced
* `limit_output` (or registry/static fallbacks) — it ignored the operator-settable
* `max_token` capability override (`src/lib/db/modelCapabilityOverrides.ts`,
* `/api/model-capability-overrides`) that `getResolvedModelCapabilities()` already
* consulted. That inconsistency meant an operator manually correcting a bad synced
* output cap (the existing, already-shipped remediation path for wrong catalog
* data) had no effect on the reasoning buffer, which kept inflating past the real
* cap regardless.
*
* Fix: `getExplicitModelOutputCap()` now checks the same `max_token` override
* before falling back to synced/registry/static data, via a helper shared with
* `getResolvedModelCapabilities()`.
*/
import assert from "node:assert/strict";
import test from "node:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-repro-6524-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const { saveModelsDevCapabilities, clearModelsDevCapabilities } = await import(
"../../src/lib/modelsDevSync.ts"
);
const { setModelCapabilityOverride, removeModelCapabilityOverride } = await import(
"../../src/lib/db/modelCapabilityOverrides.ts"
);
const { resolveReasoningBufferedMaxTokens } = await import(
"../../open-sse/services/reasoningTokenBuffer.ts"
);
const PROVIDER = "ollama-cloud";
const MODEL = "deepseek-v4-flash";
const TARGET = `${PROVIDER}/${MODEL}`;
const REAL_UPSTREAM_OUTPUT_CAP = 65536; // per reporter's boundary test table
function capabilityEntry(limitContext: unknown, overrides: Record<string, unknown> = {}) {
return {
reasoning: false,
tool_call: true,
attachment: false,
temperature: true,
structured_output: true,
limit: { context: limitContext, output: limitContext },
...overrides,
};
}
test.before(() => {
clearModelsDevCapabilities();
// Mirrors the exact production row from the issue: limit_context and limit_output
// both wrongly synced to 1048576 for ollama-cloud/deepseek-v4-flash, while the real
// upstream output cap (per the reporter's boundary test) is 65536.
saveModelsDevCapabilities({
[PROVIDER]: {
[MODEL]: capabilityEntry(1048576, { reasoning: true, limit_output: 1048576 }),
},
});
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("#6524: with only the (wrong) synced catalog data, the buffer still inflates past the real cap", () => {
// Documents the known, out-of-scope limitation: nothing in our codebase can
// psychically know the real upstream cap before an operator (or a future
// self-healing mechanism) supplies a correction. This is the reported symptom's
// starting state, not something this fix promises to eliminate on first contact.
const result = resolveReasoningBufferedMaxTokens(TARGET, 64000);
assert.equal(result, 96000);
});
test("#6524: an operator-set max_token override now clamps the reasoning buffer to the real cap", () => {
assert.ok(
setModelCapabilityOverride(TARGET, "max_token", REAL_UPSTREAM_OUTPUT_CAP),
"expected the max_token override to be written"
);
try {
const result = resolveReasoningBufferedMaxTokens(TARGET, 64000);
assert.ok(
result === null || result <= REAL_UPSTREAM_OUTPUT_CAP,
`expected max_tokens to stay <= ${REAL_UPSTREAM_OUTPUT_CAP}, got ${result} ` +
`(reproduces reported 64000 -> 96000 inflation, upstream then 400s)`
);
} finally {
removeModelCapabilityOverride(TARGET, "max_token");
}
});

View File

@@ -0,0 +1,41 @@
// Repro probe for issue #6637: combo stops fallback after Kimi total token-limit 400.
//
// Observed provider response (Kimi, verbatim from the issue report):
// "Invalid request: Your request exceeded model token limit: 262144 (requested: 308458)"
//
// combo.ts's #2101 guard (handleComboChat) treats a 400 as a combo-halting
// "body-specific" error UNLESS isContextOverflow400() or isParamValidation400()
// recognizes it as a context/param overflow that should fall through to the next
// combo target. Kimi's exact wording ("... exceeded model token limit ...") does
// NOT contain the literal word "context", so isContextOverflow400() misses it even
// though accountFallback.ts's OWN CONTEXT_OVERFLOW_PATTERNS (used one layer below,
// to decide fallbackResult.shouldFallback) explicitly matches `/\btoken limit\b/i`
// and `/\bmax.*token/i`. The two classifiers disagree, and the stricter one wins,
// so the combo halts instead of trying the next (larger-context) target.
import assert from "node:assert/strict";
import test from "node:test";
import { isContextOverflow400, isParamValidation400 } from "../../open-sse/services/combo.ts";
const KIMI_ERROR_TEXT =
"Invalid request: Your request exceeded model token limit: 262144 (requested: 308458)";
test("#6637: Kimi's 'exceeded model token limit' 400 must be classified as context/token overflow (not body-specific)", () => {
// This is the exact predicate combo.ts checks before deciding to abort the
// whole combo instead of falling through to the next target (combo.ts ~L2038-2049):
// if (status === 400 && fallbackResult.shouldFallback &&
// !isContextOverflow400(errorText) && !isParamValidation400(errorText) && ...)
// -> "stopping combo"
//
// For the fallback to proceed to the next target, at least one of these must be true.
const isRecognizedAsOverflow = isContextOverflow400(KIMI_ERROR_TEXT) || isParamValidation400(KIMI_ERROR_TEXT);
assert.equal(
isRecognizedAsOverflow,
true,
`Expected Kimi's "exceeded model token limit" 400 to be classified as context/token ` +
`overflow so combo fallback continues to the next target, but neither ` +
`isContextOverflow400() nor isParamValidation400() matched it. This causes ` +
`handleComboChat's #2101 guard to treat it as a body-specific error and halt the ` +
`whole combo (bug #6637).`
);
});

View File

@@ -75,3 +75,143 @@ test("createStructuredSSECollector collector has expected methods", () => {
const keys = Object.keys(c);
assert.ok(keys.length > 0);
});
// #6276 — tool_call arguments lost in request/response logs when a continuation
// delta omits `index` (some OpenAI-compatible proxies only send `index` on the
// FIRST tool_call delta chunk, then only `id` on subsequent chunks).
type ToolCallSummary = {
choices: Array<{
message: {
tool_calls: Array<{ function: { name: string; arguments: string } }>;
};
}>;
};
function toolCallEvent(delta: Record<string, unknown>, finishReason?: string) {
return {
index: 0,
data: {
id: "chatcmpl-1",
object: "chat.completion.chunk",
created: 1,
model: "deepseek-v4-flash-free",
choices: [{ index: 0, delta, ...(finishReason ? { finish_reason: finishReason } : {}) }],
},
};
}
test("buildStreamSummaryFromEvents merges tool_call deltas when every chunk carries `index` (happy path)", () => {
const events = [
toolCallEvent({
role: "assistant",
tool_calls: [
{ index: 0, id: "call_a", type: "function", function: { name: "Bash", arguments: "" } },
],
}),
toolCallEvent({
tool_calls: [{ index: 0, id: "call_a", type: "function", function: { arguments: '{"x":1}' } }],
}),
toolCallEvent({}, "tool_calls"),
];
const summary = collector.buildStreamSummaryFromEvents(
events,
"openai",
"deepseek-v4-flash-free"
) as ToolCallSummary;
const toolCalls = summary.choices[0].message.tool_calls;
assert.equal(toolCalls.length, 1);
assert.equal(toolCalls[0].function.name, "Bash");
assert.equal(toolCalls[0].function.arguments, '{"x":1}');
});
test("buildStreamSummaryFromEvents merges a continuation delta that carries only `id` (no `index`) into the initiating tool_call (#6276)", () => {
const events = [
toolCallEvent({
role: "assistant",
tool_calls: [
{
index: 0,
id: "call_00_xasdOvEWoeldzXAqFPQP2849",
type: "function",
function: { name: "Bash", arguments: "" },
},
],
}),
// Continuation chunk omits `index`, carries only `id` + arguments fragment.
toolCallEvent({
tool_calls: [
{
id: "call_00_xasdOvEWoeldzXAqFPQP2849",
type: "function",
function: { arguments: '{"command": "date' },
},
],
}),
toolCallEvent({
tool_calls: [
{
id: "call_00_xasdOvEWoeldzXAqFPQP2849",
type: "function",
function: { arguments: '"}' },
},
],
}),
toolCallEvent({}, "tool_calls"),
];
const summary = collector.buildStreamSummaryFromEvents(
events,
"openai",
"deepseek-v4-flash-free"
) as ToolCallSummary;
const toolCalls = summary.choices[0].message.tool_calls;
assert.equal(
toolCalls.length,
1,
`expected 1 tool_call, got ${toolCalls.length}: ${JSON.stringify(toolCalls)}`
);
assert.equal(toolCalls[0].function.name, "Bash");
assert.equal(toolCalls[0].function.arguments, '{"command": "date"}');
});
test("buildStreamSummaryFromEvents keeps two genuinely different interleaved tool_calls separate", () => {
const events = [
toolCallEvent({
role: "assistant",
tool_calls: [
{ index: 0, id: "call_a", type: "function", function: { name: "Bash", arguments: "" } },
{ index: 1, id: "call_b", type: "function", function: { name: "Read", arguments: "" } },
],
}),
toolCallEvent({
tool_calls: [
{ index: 0, id: "call_a", type: "function", function: { arguments: '{"cmd":"a"' } },
{ index: 1, id: "call_b", type: "function", function: { arguments: '{"path":"b"' } },
],
}),
toolCallEvent({
tool_calls: [
{ index: 0, id: "call_a", type: "function", function: { arguments: "}" } },
{ index: 1, id: "call_b", type: "function", function: { arguments: "}" } },
],
}),
toolCallEvent({}, "tool_calls"),
];
const summary = collector.buildStreamSummaryFromEvents(
events,
"openai",
"deepseek-v4-flash-free"
) as ToolCallSummary;
const toolCalls = summary.choices[0].message.tool_calls;
assert.equal(toolCalls.length, 2);
assert.equal(toolCalls[0].function.name, "Bash");
assert.equal(toolCalls[0].function.arguments, '{"cmd":"a"}');
assert.equal(toolCalls[1].function.name, "Read");
assert.equal(toolCalls[1].function.arguments, '{"path":"b"}');
});