From a49ac1755d5db5407b01c91b3e0b65fe3e91f72b Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:20:56 -0300 Subject: [PATCH 01/11] fix(docs): document Turbopack build memory tradeoff for RAM-constrained machines (#6409) (#6885) --- changelog.d/fixes/6409-6409-build-ram.md | 1 + docs/reference/ENVIRONMENT.md | 2 +- scripts/build/build-next-isolated.mjs | 4 ++ .../probe-6409-turbopack-memory-doc.test.ts | 46 +++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/6409-6409-build-ram.md create mode 100644 tests/unit/probe-6409-turbopack-memory-doc.test.ts diff --git a/changelog.d/fixes/6409-6409-build-ram.md b/changelog.d/fixes/6409-6409-build-ram.md new file mode 100644 index 0000000000..4baeb797a6 --- /dev/null +++ b/changelog.d/fixes/6409-6409-build-ram.md @@ -0,0 +1 @@ +- fix(docs): document Turbopack build memory tradeoff and `OMNIROUTE_USE_TURBOPACK=0` webpack fallback for RAM-constrained machines (#6409) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 6bad9d7969..15c310dbc5 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -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. | diff --git a/scripts/build/build-next-isolated.mjs b/scripts/build/build-next-isolated.mjs index 09dc26bb89..463190d430 100644 --- a/scripts/build/build-next-isolated.mjs +++ b/scripts/build/build-next-isolated.mjs @@ -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 diff --git a/tests/unit/probe-6409-turbopack-memory-doc.test.ts b/tests/unit/probe-6409-turbopack-memory-doc.test.ts new file mode 100644 index 0000000000..592cd17d31 --- /dev/null +++ b/tests/unit/probe-6409-turbopack-memory-doc.test.ts @@ -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" + ); +}); From afbd9361a7b32cab28bc8336c4ea20326fba5c47 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:21:14 -0300 Subject: [PATCH 02/11] fix(routing): recognize Kimi token-limit 400 as context overflow for combo fallback (#6637) (#6893) combo.ts's isContextOverflow400() guard required the literal word 'context' in the 400 error body before letting a combo fall through to the next target. Kimi's exact wording ('Your request exceeded model token limit: 262144 (requested: 308458)') never says 'context', so the guard misclassified it as a body-specific error and halted the whole combo instead of trying the next (larger-context) target. accountFallback.ts's CONTEXT_OVERFLOW_PATTERNS already recognized this wording one layer below (via checkFallbackError -> shouldFallback), so the two independently-maintained classifiers disagreed and the stricter one won. Export CONTEXT_OVERFLOW_PATTERNS from accountFallback.ts and reuse it inside combo.ts's isContextOverflow400() so both layers share a single source of truth. Regression test: tests/unit/repro-6637-kimi-token-limit.test.ts (RED on unfixed code -> GREEN after the fix). Existing #4519 guard tests (tests/unit/combo-param-validation-fallback-4519.test.ts) still pass, including the negative case that a genuinely body-specific 400 is NOT misclassified as overflow. --- .../fixes/6637-6637-combo-kimi-fallback.md | 1 + open-sse/services/accountFallback.ts | 5 ++- open-sse/services/combo.ts | 8 +++- .../unit/repro-6637-kimi-token-limit.test.ts | 41 +++++++++++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 changelog.d/fixes/6637-6637-combo-kimi-fallback.md create mode 100644 tests/unit/repro-6637-kimi-token-limit.test.ts diff --git a/changelog.d/fixes/6637-6637-combo-kimi-fallback.md b/changelog.d/fixes/6637-6637-combo-kimi-fallback.md new file mode 100644 index 0000000000..be7cd2ed0b --- /dev/null +++ b/changelog.d/fixes/6637-6637-combo-kimi-fallback.md @@ -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) diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 5a1e1888de..275ecbab41 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -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, diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 0beb8bfb79..69cec49793 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -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 */ diff --git a/tests/unit/repro-6637-kimi-token-limit.test.ts b/tests/unit/repro-6637-kimi-token-limit.test.ts new file mode 100644 index 0000000000..b27bc35595 --- /dev/null +++ b/tests/unit/repro-6637-kimi-token-limit.test.ts @@ -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).` + ); +}); From 69e47cdec50c91fcdc489c957e70a430a8f8c6b5 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:21:22 -0300 Subject: [PATCH 03/11] fix(providers): honor a provider-level proxy assigned to no-auth providers (#6272) (#6895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No-auth providers (mimocode, opencode, ...) are always dispatched with a single hardcoded connectionId ("noauth" — SYNTHETIC_NOAUTH_CONNECTION_ID in src/sse/services/auth.ts). No provider_connections row ever has id="noauth", so resolveProxyForConnection() in src/lib/db/settings.ts could never populate connectionRecord for them, and its provider-level proxy lookup (Steps 6/8) only runs when connectionRecord is present. A proxy assigned via Settings -> Providers -> mimocode was therefore silently ignored, reproducing the reporter's "same thing happen when i set the proxy directly in the provider menu" symptom. Adds a best-effort fallback (src/lib/db/settings/noAuthProxyFallback.ts): when connectionRecord could not be resolved, scan the known no-auth provider ids for a configured provider-level proxy (registry first, then legacy) before falling through to the global/direct steps. Regression test: tests/unit/proxy-noauth-provider-6272.test.ts (RED on unfixed code — resolved to level=direct/proxy=null; GREEN after the fix). --- changelog.d/fixes/6272-6272-mimo-proxy.md | 1 + src/lib/db/settings.ts | 14 ++++ src/lib/db/settings/noAuthProxyFallback.ts | 65 +++++++++++++++++++ tests/unit/proxy-noauth-provider-6272.test.ts | 51 +++++++++++++++ 4 files changed, 131 insertions(+) create mode 100644 changelog.d/fixes/6272-6272-mimo-proxy.md create mode 100644 src/lib/db/settings/noAuthProxyFallback.ts create mode 100644 tests/unit/proxy-noauth-provider-6272.test.ts diff --git a/changelog.d/fixes/6272-6272-mimo-proxy.md b/changelog.d/fixes/6272-6272-mimo-proxy.md new file mode 100644 index 0000000000..37b11868be --- /dev/null +++ b/changelog.d/fixes/6272-6272-mimo-proxy.md @@ -0,0 +1 @@ +- fix(providers): honor a provider-level proxy assigned to no-auth providers like MiMoCode Free (#6272) diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 12a8dae6c3..ef29caea54 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -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) { diff --git a/src/lib/db/settings/noAuthProxyFallback.ts b/src/lib/db/settings/noAuthProxyFallback.ts new file mode 100644 index 0000000000..14fcabd1ec --- /dev/null +++ b/src/lib/db/settings/noAuthProxyFallback.ts @@ -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 | 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 { + 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 { + for (const providerId of Object.keys(NOAUTH_PROVIDERS)) { + const resolved = await resolveOneNoAuthProviderProxy(providerId, legacyProviders); + if (resolved) return resolved; + } + return null; +} diff --git a/tests/unit/proxy-noauth-provider-6272.test.ts b/tests/unit/proxy-noauth-provider-6272.test.ts new file mode 100644 index 0000000000..9df7217a44 --- /dev/null +++ b/tests/unit/proxy-noauth-provider-6272.test.ts @@ -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"); +}); From ac81235609097daccd185eccb57e12346c337bc3 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:21:30 -0300 Subject: [PATCH 04/11] fix(dashboard): surface Claude extraUsage credits in quota card (#6806) (#6896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enterprise-tier Claude accounts (default_raven_enterprise) don't get five_hour/seven_day utilization windows from Anthropic's OAuth usage endpoint — only an extra_usage credit-billing block. parseClaude() only read data.quotas, so quotas stayed {} and the dashboard showed "No quota data" even when extraUsage showed the account 100% exhausted. parseClaude() now folds an enabled extraUsage block into a credits-style quota row (mirroring parseCodex's bankedResetCredits pattern), both when quotas is empty and when it's already populated. --- .../fixes/6806-6806-claude-quota-data.md | 1 + .../components/ProviderLimits/quotaParsing.ts | 29 +++++- .../quota-parsing-claude-extra-usage.test.ts | 98 +++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/6806-6806-claude-quota-data.md create mode 100644 tests/unit/quota-parsing-claude-extra-usage.test.ts diff --git a/changelog.d/fixes/6806-6806-claude-quota-data.md b/changelog.d/fixes/6806-6806-claude-quota-data.md new file mode 100644 index 0000000000..bbc9f76803 --- /dev/null +++ b/changelog.d/fixes/6806-6806-claude-quota-data.md @@ -0,0 +1 @@ +- fix(dashboard): surface Claude extraUsage credits in quota card when quotas is empty (#6806) diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts index d1d29eeb1a..0530404c05 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts @@ -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) { diff --git a/tests/unit/quota-parsing-claude-extra-usage.test.ts b/tests/unit/quota-parsing-claude-extra-usage.test.ts new file mode 100644 index 0000000000..0eba6a6110 --- /dev/null +++ b/tests/unit/quota-parsing-claude-extra-usage.test.ts @@ -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); +}); From ec553dd9c027aae922c97fbdab558ca6cbbee219 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:21:37 -0300 Subject: [PATCH 05/11] fix(db): share sql.js preinit across callers, fix named-param bind (#6628, #6802) (#6899) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - preInitSqlJs() now memoizes an in-flight Promise (not just the resolved adapter) per filePath, so concurrent BATCH/STARTUP/HealthCheck/ ProviderLimitsSync callers at boot share one full-file read+WASM decode instead of each independently reloading the whole database — the thundering-herd amplifier of the OOM condition #6632 already partly fixed, left un-implemented by the reporter's own proposed fix (#6628). - sqljsAdapter's run/get/all now unwrap a lone named-parameter object (e.g. .all({ isActive: 1 }) for "WHERE is_active = @isActive", the same call shape getProviderConnections() already uses against better-sqlite3) before calling sql.js's stmt.bind(), expanding it to the @/:/$ sigil variants sql.js's own named-bind path requires. Previously the object was wrapped into an array and sql.js took the positional-bind path, throwing "Wrong API use : tried to bind a value of an unknown type ([object Object])." whenever the sql.js WASM fallback driver was active — exactly the error #6802 reported (misattributed to better-sqlite3). Regression tests added to tests/unit/db-adapters/driverFactory.test.ts and tests/unit/db-adapters/sqljsAdapter.test.ts, both proven RED against the prior code and GREEN after the fix. --- changelog.d/fixes/6628-6628-sqljs-adapter.md | 2 + src/lib/db/adapters/driverFactory.ts | 39 ++++++++- src/lib/db/adapters/sqljsAdapter.ts | 60 +++++++++++++- src/types/sqljs.d.ts | 2 +- tests/unit/db-adapters/driverFactory.test.ts | 50 ++++++++++++ tests/unit/db-adapters/sqljsAdapter.test.ts | 86 ++++++++++++++++++++ 6 files changed, 231 insertions(+), 8 deletions(-) create mode 100644 changelog.d/fixes/6628-6628-sqljs-adapter.md diff --git a/changelog.d/fixes/6628-6628-sqljs-adapter.md b/changelog.d/fixes/6628-6628-sqljs-adapter.md new file mode 100644 index 0000000000..55d6a3ce9a --- /dev/null +++ b/changelog.d/fixes/6628-6628-sqljs-adapter.md @@ -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) diff --git a/src/lib/db/adapters/driverFactory.ts b/src/lib/db/adapters/driverFactory.ts index df0c9e0856..574abfc0f4 100644 --- a/src/lib/db/adapters/driverFactory.ts +++ b/src/lib/db/adapters/driverFactory.ts @@ -10,6 +10,7 @@ const _require = createRequire(import.meta.url); declare global { var __omnirouteSqlJsAdapters: Map | undefined; + var __omnirouteSqlJsInitPromises: Map> | undefined; } function getSqlJsCache(): Map { @@ -19,6 +20,20 @@ function getSqlJsCache(): Map { 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> { + 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 { 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. */ diff --git a/src/lib/db/adapters/sqljsAdapter.ts b/src/lib/db/adapters/sqljsAdapter.ts index 0d2ea317db..181481ab1a 100644 --- a/src/lib/db/adapters/sqljsAdapter.ts +++ b/src/lib/db/adapters/sqljsAdapter.ts @@ -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): Record { + const expanded: Record = {}; + 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 | 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) + : params; +} + async function loadSqlJs(): Promise { 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): void; step(): boolean; getAsObject(): Record; free(): void; diff --git a/tests/unit/db-adapters/driverFactory.test.ts b/tests/unit/db-adapters/driverFactory.test.ts index d38fe3a846..2716abfe86 100644 --- a/tests/unit/db-adapters/driverFactory.test.ts +++ b/tests/unit/db-adapters/driverFactory.test.ts @@ -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) => { + 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"); diff --git a/tests/unit/db-adapters/sqljsAdapter.test.ts b/tests/unit/db-adapters/sqljsAdapter.test.ts index 27512c6143..51f53d5f7f 100644 --- a/tests/unit/db-adapters/sqljsAdapter.test.ts +++ b/tests/unit/db-adapters/sqljsAdapter.test.ts @@ -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(); + }); + }); }); From 9c1db94c749431bf15a9d195bba23a31eb4cb908 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:21:45 -0300 Subject: [PATCH 06/11] fix(plugin): split OC-gate provider id from OmniRoute-facing routing id (#6859) (#6900) resolveOmniRoutePluginOptions() auto-prefixes providerId with "opencode-" (commit 75b52e286) so OpenCode 1.17.8+'s native-adapter gate accepts it as a registered provider id. That prefixed value was being reused for the OmniRoute-server-facing identifiers too: mapRawModelToModelV2's id/providerID, mapComboToModelV2's providerID, and the dynamic provider hook's combo catalog keys. OmniRoute's server has no "opencode-" provider alias, so every dispatched model failed credential lookup with "No credentials for opencode-omniroute" / "No active credentials for provider: opencode-omniroute". Add a second, unprefixed omnirouteProviderId field and thread it through the four dynamic-hook call sites that emit server-facing identifiers, while leaving the OC-gate-prefixed providerId in place for AuthHook.provider, provider registration (hook.id), and the static-catalog path (which OC strips before dispatch, per the existing static-block comments). --- @omniroute/opencode-plugin/package.json | 2 +- @omniroute/opencode-plugin/src/index.ts | 46 +++++++-- .../opencode-plugin/tests/combos.test.ts | 40 ++++---- .../opencode-plugin/tests/features.test.ts | 7 +- .../tests/provider-id-routing.test.ts | 99 +++++++++++++++++++ .../opencode-plugin/tests/provider.test.ts | 15 ++- changelog.d/fixes/6859-plugin-provider-id.md | 1 + 7 files changed, 176 insertions(+), 34 deletions(-) create mode 100644 @omniroute/opencode-plugin/tests/provider-id-routing.test.ts create mode 100644 changelog.d/fixes/6859-plugin-provider-id.md diff --git a/@omniroute/opencode-plugin/package.json b/@omniroute/opencode-plugin/package.json index 491de78c16..717c101001 100644 --- a/@omniroute/opencode-plugin/package.json +++ b/@omniroute/opencode-plugin/package.json @@ -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": [ diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index d69383b087..cb154fb513 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -238,9 +238,26 @@ function trimLeadingDashes(value: string): string { */ export function resolveOmniRoutePluginOptions( opts?: OmniRoutePluginOptions -): Required> & - Pick { +): Required< + Pick +> & { + /** + * #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.` 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-", so a prefixed id there is unrecoverable and credential + * lookup fails with "No credentials for opencode-". + */ + omnirouteProviderId: string; +} & Pick { 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: {}, }; diff --git a/@omniroute/opencode-plugin/tests/combos.test.ts b/@omniroute/opencode-plugin/tests/combos.test.ts index 04102c0055..ff209a9a67 100644 --- a/@omniroute/opencode-plugin/tests/combos.test.ts +++ b/@omniroute/opencode-plugin/tests/combos.test.ts @@ -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, diff --git a/@omniroute/opencode-plugin/tests/features.test.ts b/@omniroute/opencode-plugin/tests/features.test.ts index 73ee37a72b..929a097390 100644 --- a/@omniroute/opencode-plugin/tests/features.test.ts +++ b/@omniroute/opencode-plugin/tests/features.test.ts @@ -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, diff --git a/@omniroute/opencode-plugin/tests/provider-id-routing.test.ts b/@omniroute/opencode-plugin/tests/provider-id-routing.test.ts new file mode 100644 index 0000000000..eb01aac703 --- /dev/null +++ b/@omniroute/opencode-plugin/tests/provider-id-routing.test.ts @@ -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 `/` 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" + ); +}); diff --git a/@omniroute/opencode-plugin/tests/provider.test.ts b/@omniroute/opencode-plugin/tests/provider.test.ts index e4849205ac..20012ddb12 100644 --- a/@omniroute/opencode-plugin/tests/provider.test.ts +++ b/@omniroute/opencode-plugin/tests/provider.test.ts @@ -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"); diff --git a/changelog.d/fixes/6859-plugin-provider-id.md b/changelog.d/fixes/6859-plugin-provider-id.md new file mode 100644 index 0000000000..9343798009 --- /dev/null +++ b/changelog.d/fixes/6859-plugin-provider-id.md @@ -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-` provider alias, so every dispatched model failed credential lookup with "No credentials for opencode-omniroute" (#6859). From c089ca9d1af363207ec3a4344d8b90c2609c0528 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:21:52 -0300 Subject: [PATCH 07/11] fix(compression): surface silently-dropped stacked-pipeline steps and fix inflation-guard no-op misfire (#6479, #6480, #6491) (#6901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related root causes in the stacked compression pipeline: - #6479/#6491: a dispatched step whose engine legitimately finds nothing eligible (session-dedup with no repeated blocks, ccr below its min-chars threshold) returns `{ stats: null }`. `mergeStackStep()` silently dropped that step from `engineBreakdown` with zero trace — no warning, no error. Now records a `": skipped (no eligible content)"` validation warning for any null-stats step, covering every engine that follows this convention (session-dedup, ccr, headroom, relevance, llm, llmlingua, ionizer, readLifecycle), not just the two reported. - #6480: `finalizeStackedResult` ran the aggregate `guardPipelineInflation` check unconditionally, even when the loop-level `compressed` flag stayed false (no step ever advanced `currentBody`). Since tokens are trivially equal when nothing ran, the guard mislabeled a genuine no-op as `fallbackApplied: true` with a misleading "reverted to original" warning. Extracted the guard into `applyStackedInflationGuard()` in `pipelineGuards.ts` (keeps `strategySelector.ts` under its frozen line budget) and gated it on `compressed === true`. Also fixes `compression-pipeline-inflation-guard.test.ts`'s wire test, which passed a bare engine-id string to the pipeline; `normalizePipelineStep()` only recognizes a fixed set of built-in string aliases and silently downgrades any other string to `{ engine: "caveman" }`, so the test's custom inflating engine was never actually exercised. Passing a step object restores the test's original intent. New regression tests: tests/unit/compression/repro-6479-6491-null-stats-silent-drop.test.ts, tests/unit/compression/repro-6480-noop-guard-misfire.test.ts. --- .../fixes/6479-6479-compression-pipeline.md | 1 + .../services/compression/pipelineGuards.ts | 44 ++++++++ .../services/compression/stackedStepCore.ts | 16 ++- .../services/compression/strategySelector.ts | 30 +---- ...mpression-pipeline-inflation-guard.test.ts | 6 +- ...o-6479-6491-null-stats-silent-drop.test.ts | 103 ++++++++++++++++++ .../repro-6480-noop-guard-misfire.test.ts | 39 +++++++ 7 files changed, 212 insertions(+), 27 deletions(-) create mode 100644 changelog.d/fixes/6479-6479-compression-pipeline.md create mode 100644 tests/unit/compression/repro-6479-6491-null-stats-silent-drop.test.ts create mode 100644 tests/unit/compression/repro-6480-noop-guard-misfire.test.ts diff --git a/changelog.d/fixes/6479-6479-compression-pipeline.md b/changelog.d/fixes/6479-6479-compression-pipeline.md new file mode 100644 index 0000000000..dba6094066 --- /dev/null +++ b/changelog.d/fixes/6479-6479-compression-pipeline.md @@ -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) diff --git a/open-sse/services/compression/pipelineGuards.ts b/open-sse/services/compression/pipelineGuards.ts index 07a712e306..2977ff4aec 100644 --- a/open-sse/services/compression/pipelineGuards.ts +++ b/open-sse/services/compression/pipelineGuards.ts @@ -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; @@ -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, + currentBody: Record, + 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 }; +} diff --git a/open-sse/services/compression/stackedStepCore.ts b/open-sse/services/compression/stackedStepCore.ts index f82413d994..5c9cdb0db9 100644 --- a/open-sse/services/compression/stackedStepCore.ts +++ b/open-sse/services/compression/stackedStepCore.ts @@ -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)); diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts index f0eafd8fcf..1beb167e7f 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -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) ── diff --git a/tests/unit/compression-pipeline-inflation-guard.test.ts b/tests/unit/compression-pipeline-inflation-guard.test.ts index 07150a13d9..359f5fef3c 100644 --- a/tests/unit/compression-pipeline-inflation-guard.test.ts +++ b/tests/unit/compression-pipeline-inflation-guard.test.ts @@ -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); diff --git a/tests/unit/compression/repro-6479-6491-null-stats-silent-drop.test.ts b/tests/unit/compression/repro-6479-6491-null-stats-silent-drop.test.ts new file mode 100644 index 0000000000..32bb4315e5 --- /dev/null +++ b/tests/unit/compression/repro-6479-6491-null-stats-silent-drop.test.ts @@ -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)}`); + }); +}); diff --git a/tests/unit/compression/repro-6480-noop-guard-misfire.test.ts b/tests/unit/compression/repro-6480-noop-guard-misfire.test.ts new file mode 100644 index 0000000000..586d7f0af5 --- /dev/null +++ b/tests/unit/compression/repro-6480-noop-guard-misfire.test.ts @@ -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" + ); +}); From 5c0a0d8db9f9d92f4b3500945e7a98d4fdd29476 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:22:00 -0300 Subject: [PATCH 08/11] fix(mcp): de-duplicate TOTAL_MCP_TOOL_COUNT by tool name (#6854) (#6902) TOTAL_MCP_TOOL_COUNT in open-sse/mcp-server/server.ts summed collection sizes additively, double-counting tools registered in more than one collection. The agent-skills trio (omniroute_agent_skills_list/get/coverage) is intentionally defined in both MCP_TOOLS (schemas/tools.ts) and agentSkillTools (tools/agentSkillTools.ts), inflating the reported count from 96 unique tools to 99. Replace the additive sum with countUniqueMcpTools() (new open-sse/mcp-server/toolCount.ts), which unions all collection tool names into a Set before counting, so any future overlap self-corrects instead of double-counting. Regression test: tests/unit/mcp-tool-count-dedup-6854.test.ts --- changelog.d/fixes/6854-6854-mcp-toolcount.md | 1 + open-sse/mcp-server/server.ts | 24 +++--- open-sse/mcp-server/toolCount.ts | 36 +++++++++ tests/unit/mcp-tool-count-dedup-6854.test.ts | 83 ++++++++++++++++++++ 4 files changed, 133 insertions(+), 11 deletions(-) create mode 100644 changelog.d/fixes/6854-6854-mcp-toolcount.md create mode 100644 open-sse/mcp-server/toolCount.ts create mode 100644 tests/unit/mcp-tool-count-dedup-6854.test.ts diff --git a/changelog.d/fixes/6854-6854-mcp-toolcount.md b/changelog.d/fixes/6854-6854-mcp-toolcount.md new file mode 100644 index 0000000000..1e0ac6b1fe --- /dev/null +++ b/changelog.d/fixes/6854-6854-mcp-toolcount.md @@ -0,0 +1 @@ +- fix(mcp): de-duplicate `TOTAL_MCP_TOOL_COUNT` by tool name instead of double-counting collections (#6854) diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index a9428ba310..4d8b7be2de 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -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; diff --git a/open-sse/mcp-server/toolCount.ts b/open-sse/mcp-server/toolCount.ts new file mode 100644 index 0000000000..068adac6b6 --- /dev/null +++ b/open-sse/mcp-server/toolCount.ts @@ -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>; + +function collectionNames(collection: ToolCollection): string[] { + const items: NamedTool[] = Array.isArray(collection) + ? collection + : Object.values(collection as Record); + return items.map((item) => item.name); +} + +export function countUniqueMcpTools( + collectionsByLabel: Readonly> +): number { + const uniqueNames = new Set(); + for (const collection of Object.values(collectionsByLabel)) { + for (const name of collectionNames(collection)) { + uniqueNames.add(name); + } + } + return uniqueNames.size; +} diff --git a/tests/unit/mcp-tool-count-dedup-6854.test.ts b/tests/unit/mcp-tool-count-dedup-6854.test.ts new file mode 100644 index 0000000000..f578a761cb --- /dev/null +++ b/tests/unit/mcp-tool-count-dedup-6854.test.ts @@ -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[] { + 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); + 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, + skillTools: skillTools as unknown as Record, + agentSkillTools: agentSkillTools as unknown as Record, + githubSkillTools: githubSkillTools as unknown as Record, + poolTools: poolTools as unknown as Record, + 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(); + 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" + ); +}); From 6dff715ba663f9493a9bfc29efa5ebb1ba1c5cfd Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:22:08 -0300 Subject: [PATCH 09/11] fix(sse): apply cliproxyapiModelMapping at CLIProxyAPI dispatch time (#6876) (#6903) --- .../fixes/6876-6876-cliproxy-modelmap.md | 1 + .../handlers/chatCore/cliproxyModelMapping.ts | 69 ++++++++++ .../handlers/chatCore/comboContextCache.ts | 20 ++- open-sse/handlers/chatCore/executorProxy.ts | 15 +- ...cliproxyapi-model-mapping-dispatch.test.ts | 128 ++++++++++++++++++ 5 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 changelog.d/fixes/6876-6876-cliproxy-modelmap.md create mode 100644 open-sse/handlers/chatCore/cliproxyModelMapping.ts create mode 100644 tests/unit/cliproxyapi-model-mapping-dispatch.test.ts diff --git a/changelog.d/fixes/6876-6876-cliproxy-modelmap.md b/changelog.d/fixes/6876-6876-cliproxy-modelmap.md new file mode 100644 index 0000000000..d28d68467b --- /dev/null +++ b/changelog.d/fixes/6876-6876-cliproxy-modelmap.md @@ -0,0 +1 @@ +- fix(sse): apply cliproxyapiModelMapping at CLIProxyAPI dispatch time (#6876) diff --git a/open-sse/handlers/chatCore/cliproxyModelMapping.ts b/open-sse/handlers/chatCore/cliproxyModelMapping.ts new file mode 100644 index 0000000000..99914e2f01 --- /dev/null +++ b/open-sse/handlers/chatCore/cliproxyModelMapping.ts @@ -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; + [key: string]: unknown; +}; + +export type CliproxyapiModelMapping = Record | null | undefined; + +function resolveMappedModel(model: string, mapping: CliproxyapiModelMapping): string | null { + if (!mapping || typeof mapping !== "object") return null; + const mapped = (mapping as Record)[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), 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( + 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; +} diff --git a/open-sse/handlers/chatCore/comboContextCache.ts b/open-sse/handlers/chatCore/comboContextCache.ts index fc7df58fef..da2c3d3c15 100644 --- a/open-sse/handlers/chatCore/comboContextCache.ts +++ b/open-sse/handlers/chatCore/comboContextCache.ts @@ -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(); +type UpstreamProxyConfigCacheEntry = { + mode: string; + enabled: boolean; + cliproxyapiModelMapping: Record | null; + ts: number; +}; + +const _proxyConfigCache = new Map(); 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; } diff --git a/open-sse/handlers/chatCore/executorProxy.ts b/open-sse/handlers/chatCore/executorProxy.ts index 4f0ea2a784..870c1bf00d 100644 --- a/open-sse/handlers/chatCore/executorProxy.ts +++ b/open-sse/handlers/chatCore/executorProxy.ts @@ -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]; diff --git a/tests/unit/cliproxyapi-model-mapping-dispatch.test.ts b/tests/unit/cliproxyapi-model-mapping-dispatch.test.ts new file mode 100644 index 0000000000..1952999118 --- /dev/null +++ b/tests/unit/cliproxyapi-model-mapping-dispatch.test.ts @@ -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 }; + +async function captureFetchBody(fn: () => Promise): Promise> { + let capturedBody: Record | 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; +} + +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"); + }); +}); From adb1fc5b2760e920740c9806f12d594c1b17eb96 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:22:15 -0300 Subject: [PATCH 10/11] fix(providers): honor max_token capability override in reasoning buffer clamp (#6524) (#6904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getExplicitModelOutputCap() (the clamp ceiling used by resolveReasoningBufferedMaxTokens) only ever read the unvalidated synced limit_output / registry / static-spec chain — it ignored the operator-settable max_token capability override that getResolvedModelCapabilities() already consulted. When a provider's synced catalog row reports a wrong limit_output (e.g. ollama-cloud/deepseek-v4-flash: limit_output=1048576, same as limit_context, while the real upstream cap is 65536), the reasoning-buffer clamp trusted the bad number and inflated max_tokens 64000 -> 96000, which upstream rejected with "exceeds model's maximum output tokens (65536)". The override table (model_capability_overrides, "max_token" key, /api/model-capability-overrides) is the existing, already-shipped remediation path for exactly this class of bad catalog data, but reasoningTokenBuffer.ts had no way to benefit from it. Extracted the override lookup into a shared getMaxTokenCapabilityOverride() helper and made getExplicitModelOutputCap() consult it first, so both read paths now agree. --- .../fixes/6524-6524-reasoning-buffer-clamp.md | 1 + src/lib/modelCapabilities.ts | 31 +++++- tests/unit/repro-6524.test.ts | 103 ++++++++++++++++++ 3 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fixes/6524-6524-reasoning-buffer-clamp.md create mode 100644 tests/unit/repro-6524.test.ts diff --git a/changelog.d/fixes/6524-6524-reasoning-buffer-clamp.md b/changelog.d/fixes/6524-6524-reasoning-buffer-clamp.md new file mode 100644 index 0000000000..9f551de90c --- /dev/null +++ b/changelog.d/fixes/6524-6524-reasoning-buffer-clamp.md @@ -0,0 +1 @@ +- fix(providers): honor the `max_token` capability override in the reasoning-token-buffer output cap (#6524) diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 75715f31ca..e3e6052f9d 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -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, diff --git a/tests/unit/repro-6524.test.ts b/tests/unit/repro-6524.test.ts new file mode 100644 index 0000000000..b71a72c98a --- /dev/null +++ b/tests/unit/repro-6524.test.ts @@ -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 = {}) { + 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"); + } +}); From 0a358c02abbd781dba998b9b7d5db471894a085e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:22:22 -0300 Subject: [PATCH 11/11] fix(api): merge id-only tool_call continuation deltas in stream summary (#6276) (#6905) --- changelog.d/fixes/6276-toolcall-args-logs.md | 1 + open-sse/utils/streamPayloadCollector.ts | 20 ++- tests/unit/stream-payload-collector.test.ts | 140 +++++++++++++++++++ 3 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 changelog.d/fixes/6276-toolcall-args-logs.md diff --git a/changelog.d/fixes/6276-toolcall-args-logs.md b/changelog.d/fixes/6276-toolcall-args-logs.md new file mode 100644 index 0000000000..90b6bde61b --- /dev/null +++ b/changelog.d/fixes/6276-toolcall-args-logs.md @@ -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) diff --git a/open-sse/utils/streamPayloadCollector.ts b/open-sse/utils/streamPayloadCollector.ts index 26e9f4418e..63cb901179 100644 --- a/open-sse/utils/streamPayloadCollector.ts +++ b/open-sse/utils/streamPayloadCollector.ts @@ -129,13 +129,29 @@ function buildOpenAISummary(events: StructuredSSEEvent[], fallbackModel?: string function: { name: string; arguments: string }; }; const toolCalls = new Map(); + // 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(); 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}`; }; diff --git a/tests/unit/stream-payload-collector.test.ts b/tests/unit/stream-payload-collector.test.ts index 1cb9e58b85..669edcdd1e 100644 --- a/tests/unit/stream-payload-collector.test.ts +++ b/tests/unit/stream-payload-collector.test.ts @@ -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, 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"}'); +});