mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-16 20:22:21 +03:00
Merge branch 'release/v3.8.47' into fix/6803-flaky-timing-tests
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
99
@omniroute/opencode-plugin/tests/provider-id-routing.test.ts
Normal file
99
@omniroute/opencode-plugin/tests/provider-id-routing.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
1
changelog.d/fixes/6272-6272-mimo-proxy.md
Normal file
1
changelog.d/fixes/6272-6272-mimo-proxy.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): honor a provider-level proxy assigned to no-auth providers like MiMoCode Free (#6272)
|
||||
1
changelog.d/fixes/6276-toolcall-args-logs.md
Normal file
1
changelog.d/fixes/6276-toolcall-args-logs.md
Normal 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)
|
||||
1
changelog.d/fixes/6409-6409-build-ram.md
Normal file
1
changelog.d/fixes/6409-6409-build-ram.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(docs): document Turbopack build memory tradeoff and `OMNIROUTE_USE_TURBOPACK=0` webpack fallback for RAM-constrained machines (#6409)
|
||||
1
changelog.d/fixes/6479-6479-compression-pipeline.md
Normal file
1
changelog.d/fixes/6479-6479-compression-pipeline.md
Normal 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)
|
||||
1
changelog.d/fixes/6524-6524-reasoning-buffer-clamp.md
Normal file
1
changelog.d/fixes/6524-6524-reasoning-buffer-clamp.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): honor the `max_token` capability override in the reasoning-token-buffer output cap (#6524)
|
||||
2
changelog.d/fixes/6628-6628-sqljs-adapter.md
Normal file
2
changelog.d/fixes/6628-6628-sqljs-adapter.md
Normal 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)
|
||||
1
changelog.d/fixes/6637-6637-combo-kimi-fallback.md
Normal file
1
changelog.d/fixes/6637-6637-combo-kimi-fallback.md
Normal 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)
|
||||
1
changelog.d/fixes/6806-6806-claude-quota-data.md
Normal file
1
changelog.d/fixes/6806-6806-claude-quota-data.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(dashboard): surface Claude extraUsage credits in quota card when quotas is empty (#6806)
|
||||
1
changelog.d/fixes/6854-6854-mcp-toolcount.md
Normal file
1
changelog.d/fixes/6854-6854-mcp-toolcount.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(mcp): de-duplicate `TOTAL_MCP_TOOL_COUNT` by tool name instead of double-counting collections (#6854)
|
||||
1
changelog.d/fixes/6859-plugin-provider-id.md
Normal file
1
changelog.d/fixes/6859-plugin-provider-id.md
Normal 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).
|
||||
1
changelog.d/fixes/6876-6876-cliproxy-modelmap.md
Normal file
1
changelog.d/fixes/6876-6876-cliproxy-modelmap.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): apply cliproxyapiModelMapping at CLIProxyAPI dispatch time (#6876)
|
||||
@@ -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. |
|
||||
|
||||
69
open-sse/handlers/chatCore/cliproxyModelMapping.ts
Normal file
69
open-sse/handlers/chatCore/cliproxyModelMapping.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
36
open-sse/mcp-server/toolCount.ts
Normal file
36
open-sse/mcp-server/toolCount.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
65
src/lib/db/settings/noAuthProxyFallback.ts
Normal file
65
src/lib/db/settings/noAuthProxyFallback.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
2
src/types/sqljs.d.ts
vendored
2
src/types/sqljs.d.ts
vendored
@@ -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;
|
||||
|
||||
128
tests/unit/cliproxyapi-model-mapping-dispatch.test.ts
Normal file
128
tests/unit/cliproxyapi-model-mapping-dispatch.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}`);
|
||||
});
|
||||
});
|
||||
39
tests/unit/compression/repro-6480-noop-guard-misfire.test.ts
Normal file
39
tests/unit/compression/repro-6480-noop-guard-misfire.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
83
tests/unit/mcp-tool-count-dedup-6854.test.ts
Normal file
83
tests/unit/mcp-tool-count-dedup-6854.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
46
tests/unit/probe-6409-turbopack-memory-doc.test.ts
Normal file
46
tests/unit/probe-6409-turbopack-memory-doc.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
51
tests/unit/proxy-noauth-provider-6272.test.ts
Normal file
51
tests/unit/proxy-noauth-provider-6272.test.ts
Normal 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");
|
||||
});
|
||||
98
tests/unit/quota-parsing-claude-extra-usage.test.ts
Normal file
98
tests/unit/quota-parsing-claude-extra-usage.test.ts
Normal 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);
|
||||
});
|
||||
103
tests/unit/repro-6524.test.ts
Normal file
103
tests/unit/repro-6524.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
41
tests/unit/repro-6637-kimi-token-limit.test.ts
Normal file
41
tests/unit/repro-6637-kimi-token-limit.test.ts
Normal 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).`
|
||||
);
|
||||
});
|
||||
@@ -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"}');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user