diff --git a/changelog.d/fixes/1556-openai-regex-lookaround.md b/changelog.d/fixes/1556-openai-regex-lookaround.md new file mode 100644 index 00000000000..7045d0917dc --- /dev/null +++ b/changelog.d/fixes/1556-openai-regex-lookaround.md @@ -0,0 +1 @@ +- **fix(codex):** strip regex `pattern` lookaround (lookahead/lookbehind) from tool JSON Schemas on the Codex/OpenAI native passthrough path — previously only the translated-request path coerced tool schemas, so a `pattern` like `^(?=.*@).+$` reached OpenAI unmodified and was rejected with `regex lookaround is not supported`. (thanks @evinjohnn) (#7100) diff --git a/changelog.d/fixes/7098-mimo-thinking-model-reasoning-content.md b/changelog.d/fixes/7098-mimo-thinking-model-reasoning-content.md new file mode 100644 index 00000000000..ddd454f2520 --- /dev/null +++ b/changelog.d/fixes/7098-mimo-thinking-model-reasoning-content.md @@ -0,0 +1 @@ +- **fix(sse):** xiaomi-tokenplan `mimo` models (e.g. `mimo-v2.5-pro`) are now recognized as thinking-mode upstreams that require `reasoning_content` echoed back on every assistant turn, fixing a persistent `400 reasoning_content must be passed back` error on multi-turn conversations ([#7098](https://github.com/diegosouzapw/OmniRoute/pull/7098)) — thanks @xxue-z diff --git a/open-sse/executors/codex/tools.ts b/open-sse/executors/codex/tools.ts index 3337000359c..52d01e9d874 100644 --- a/open-sse/executors/codex/tools.ts +++ b/open-sse/executors/codex/tools.ts @@ -1,6 +1,8 @@ // Codex Responses-API tool normalization (hosted-tool passthrough + free-plan gating). // Extracted verbatim from codex.ts. Self-contained (console.debug only). +import { stripUnsupportedRegexPatterns } from "../../translator/helpers/schemaCoercion.ts"; + // Responses-API hosted tool types that OpenAI/Codex executes server-side. // These arrive shaped as `{ type, ...params }` with no `function` object and no `name` — // e.g. Codex CLI injects `{ type: "image_generation", output_format: "png" }` or @@ -133,6 +135,11 @@ export function normalizeCodexTools( ? functionObject.strict : undefined; + // Codex/OpenAI Responses API rejects `pattern` fields using regex lookaround + // (e.g. `^(?=.*@).+$`) with a 400 "regex lookaround is not supported" error. + // Strip those before the schema reaches upstream (9router#1556). + const sanitizedParameters = stripUnsupportedRegexPatterns(parameters); + // Rewrite in-place to Responses format for (const key of Object.keys(tool)) { delete tool[key]; @@ -140,7 +147,7 @@ export function normalizeCodexTools( tool.type = "function"; tool.name = name.slice(0, 128); if (description) tool.description = description; - tool.parameters = parameters; + tool.parameters = sanitizedParameters; if (strict !== undefined) tool.strict = strict; validToolNames.add(name); diff --git a/open-sse/translator/helpers/schemaCoercion.ts b/open-sse/translator/helpers/schemaCoercion.ts index 9be3d930b6e..60ecfa44a79 100644 --- a/open-sse/translator/helpers/schemaCoercion.ts +++ b/open-sse/translator/helpers/schemaCoercion.ts @@ -24,6 +24,18 @@ const NUMERIC_SCHEMA_FIELDS = [ "multipleOf", ] as const; +// Fix (9router#1556): OpenAI/Codex's Responses API rejects JSON Schema `pattern` +// values that use regex lookaround (lookahead/lookbehind) with +// "Invalid JSON schema: regex lookaround is not supported.". IDE/SDK agent +// harnesses commonly emit lookahead patterns (e.g. `^(?=.*@).+$`), so any +// `pattern` field containing `(?=`, `(?!`, `(?<=`, or `(? [key, stripUnsupportedRegexPatterns(value)]) + ); +} + +/** + * Strip regex `pattern` constraints that use lookaround (lookahead/lookbehind), + * which OpenAI/Codex's Responses API rejects outright with a 400 + * ("Invalid JSON schema: regex lookaround is not supported."). Walks the same + * JSON Schema shape as `coerceSchemaNumericFields` (properties, items, + * anyOf/oneOf/allOf, $defs/definitions, etc). See 9router#1556. + */ +export function stripUnsupportedRegexPatterns(schema: unknown): unknown { + if (Array.isArray(schema)) { + return schema.map((entry) => stripUnsupportedRegexPatterns(entry)); + } + if (!isPlainObject(schema)) return schema; + + const result: JsonRecord = { ...schema }; + + if (hasUnsupportedRegexLookaround(result.pattern)) { + delete result.pattern; + } + + for (const field of REGEX_STRIP_OBJECT_MAP_FIELDS) { + if (isPlainObject(result[field])) { + result[field] = stripRegexFromObjectMap(result[field]); + } + } + + for (const field of REGEX_STRIP_ARRAY_MAP_FIELDS) { + if (Array.isArray(result[field])) { + result[field] = (result[field] as unknown[]).map((entry) => + stripUnsupportedRegexPatterns(entry) + ); + } + } + + if (result.items !== undefined) { + result.items = stripUnsupportedRegexPatterns(result.items); + } + if (result.additionalProperties && typeof result.additionalProperties === "object") { + result.additionalProperties = stripUnsupportedRegexPatterns(result.additionalProperties); + } + if (isPlainObject(result.not)) { + result.not = stripUnsupportedRegexPatterns(result.not); + } + + return result; +} + export function sanitizeToolDescription(tool: unknown): unknown { if (!isPlainObject(tool)) return tool; diff --git a/open-sse/utils/reasoningContentInjector.ts b/open-sse/utils/reasoningContentInjector.ts index 8fd9b13bd41..c2e8318be4e 100644 --- a/open-sse/utils/reasoningContentInjector.ts +++ b/open-sse/utils/reasoningContentInjector.ts @@ -1,5 +1,6 @@ /** - * Thinking-mode upstreams (DeepSeek V4 Flash, Kimi, MiniMax, ...) require + * Thinking-mode upstreams (DeepSeek V4 Flash, Kimi, MiniMax, xiaomi-tokenplan + * mimo, ...) require * `reasoning_content` to be echoed back on every assistant message in the * conversation history. Standard OpenAI clients do not preserve that field * across turns, so we inject a non-empty placeholder before forwarding. @@ -26,6 +27,7 @@ const THINKING_MODEL_PATTERNS: RegExp[] = [ /\bkimi\b/i, /\bk2\b/i, // moonshot kimi k2 family alias /\bminimax\b/i, + /\bmimo\b/i, // xiaomi-tokenplan mimo family (e.g. xiaomi-tokenplan/mimo-v2.5-pro) ]; export function isThinkingMessageModel(model: string | undefined | null): boolean { diff --git a/scripts/quality/validate-release-green.mjs b/scripts/quality/validate-release-green.mjs index 13ffdb8ff04..a14b80051d4 100644 --- a/scripts/quality/validate-release-green.mjs +++ b/scripts/quality/validate-release-green.mjs @@ -126,7 +126,14 @@ export function parseEslintJson(out) { /** Pull the cognitive-complexity violation count from the gate's output. */ export function parseCognitiveCount(out) { - const m = String(out || "").match(/(\d+)\s+(?:function\(s\) exceed|violações|violations)/i); + const s = String(out || ""); + // `check:complexity-ratchets` runs ONE shared ESLint walk and prints BOTH ratchets, with the + // cyclomatic "N violações" summary emitted FIRST — so a bare `\d+ violações` regex would grab + // the cyclomatic count. Prefer the unambiguous machine-readable `cognitiveComplexity=N` line + // (mirrors the cyclomatic `complexity=N` parse used for cycCurrent below). + const machine = s.match(/(?:^|\n)cognitiveComplexity=(\d+)/); + if (machine) return Number(machine[1]); + const m = s.match(/(\d+)\s+(?:function\(s\) exceed|violações|violations)/i); return m ? Number(m[1]) : null; } diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 380827f11af..f4419da2825 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -860,12 +860,17 @@ async function buildUnifiedModelsResponseCore( // #6457: some upstream discovery catalogs (e.g. HuggingFace's live // `/v1/models`) return image/diffusion models with no modality info, // so `endpoints` below would default to ["chat"] and misrepresent - // them as chat-capable. Skip any synced model that is already a - // registered image model for this provider — getAllImageModels() - // below adds the correctly-typed `type: "image"` entry instead. + // them as chat-capable. Skip a registered image model only when its + // synced metadata does not explicitly advertise a chat endpoint. + // Multi-capability models may intentionally share an id between the + // chat and image catalogs; getAllImageModels() adds the image entry. + const explicitlySupportsChat = sm.supportedEndpoints?.some( + (endpoint) => endpoint === "chat" || endpoint === "responses" + ); if ( - isRegisteredImageModel(canonicalProviderId, sm.id) || - isRegisteredImageModel(providerId, sm.id) + !explicitlySupportsChat && + (isRegisteredImageModel(canonicalProviderId, sm.id) || + isRegisteredImageModel(providerId, sm.id)) ) { continue; } diff --git a/src/app/api/v1/relay/chat/completions/bifrost/route.ts b/src/app/api/v1/relay/chat/completions/bifrost/route.ts index b0df931b0e0..31b007959f6 100644 --- a/src/app/api/v1/relay/chat/completions/bifrost/route.ts +++ b/src/app/api/v1/relay/chat/completions/bifrost/route.ts @@ -260,6 +260,8 @@ export async function POST(request: Request) { "x-relay-client-ip": clientIp, ...getProviderPluginManifestHeader(new URL(request.url).origin), }; + const requestId = request.headers.get("x-request-id"); + if (requestId) upstreamHeaders["x-request-id"] = requestId; if (BIFROST_API_KEY) { upstreamHeaders["Authorization"] = `Bearer ${BIFROST_API_KEY}`; } diff --git a/src/app/api/v1/relay/chat/completions/route.ts b/src/app/api/v1/relay/chat/completions/route.ts index 8ee4a25a2da..27cf3bf4144 100644 --- a/src/app/api/v1/relay/chat/completions/route.ts +++ b/src/app/api/v1/relay/chat/completions/route.ts @@ -65,6 +65,7 @@ async function forwardToBifrost( body: unknown, token: RelayToken, config: BifrostRoutingConfig, + backend: ReturnType, startTime: number, clientIp: string, userAgent: string | null @@ -77,6 +78,8 @@ async function forwardToBifrost( "x-relay-client-ip": clientIp, ...getProviderPluginManifestHeader(new URL(request.url).origin), }; + const requestId = request.headers.get("x-request-id"); + if (requestId) upstreamHeaders["x-request-id"] = requestId; if (config.apiKey) { upstreamHeaders.Authorization = `Bearer ${config.apiKey}`; } @@ -95,7 +98,6 @@ async function forwardToBifrost( body: JSON.stringify(body), signal: ac.signal, }); - clearTimeout(tid); const headers = new Headers(upstream.headers); headers.set("X-Routed-By", "bifrost"); @@ -107,14 +109,24 @@ async function forwardToBifrost( if (wantsStream && upstream.body) { const stream = finalizeReadableStream(upstream.body, (error) => { + clearTimeout(tid); + const statusCode = timedOut ? 504 : upstream.status; + if (error && backend === "auto") { + recordBifrostFailure( + config.baseUrl, + timedOut + ? `Bifrost sidecar stream timed out after ${config.timeoutMs}ms` + : "bifrost-stream-error" + ); + } recordUsage( token.id, request, startTime, clientIp, userAgent, - error || upstream.status >= 500 ? "error" : "success", - upstream.status + error || statusCode >= 500 ? "error" : "success", + statusCode ); }); @@ -124,6 +136,7 @@ async function forwardToBifrost( }); } + clearTimeout(tid); recordUsage( token.id, request, @@ -313,6 +326,7 @@ export async function POST(request: Request) { parsedBody, token, bifrostConfig, + backend, startTime, clientIp, userAgent diff --git a/tests/unit/api/v1/bifrost-sidecar.test.ts b/tests/unit/api/v1/bifrost-sidecar.test.ts index 286e8bffcb9..8e9f5a3c8f0 100644 --- a/tests/unit/api/v1/bifrost-sidecar.test.ts +++ b/tests/unit/api/v1/bifrost-sidecar.test.ts @@ -79,9 +79,8 @@ test("bifrost route: returns 503 + fallback header when BIFROST_BASE_URL is unse delete process.env.BIFROST_STREAMING_ENABLED; // Dynamic import after env is set so the module reads the empty value. - const { POST } = await import( - "../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts" - ); + const { POST } = + await import("../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts"); const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", { method: "POST", @@ -191,12 +190,14 @@ test("bifrost route: records relay usage after SSE stream completion", async () delete process.env.BIFROST_STREAMING_ENABLED; const relayToken = seedRelayToken(`relay_bifrost_sse_${Date.now()}`); + let forwardedRequestId: string | null = null; - globalThis.fetch = async () => - new Response( + globalThis.fetch = async (_input, init) => { + forwardedRequestId = new Headers(init?.headers).get("x-request-id"); + return new Response( new ReadableStream({ start(controller) { - controller.enqueue(new TextEncoder().encode("data: {\"delta\":\"hi\"}\n\n")); + controller.enqueue(new TextEncoder().encode('data: {"delta":"hi"}\n\n')); controller.close(); }, }), @@ -205,6 +206,7 @@ test("bifrost route: records relay usage after SSE stream completion", async () headers: { "content-type": "text/event-stream" }, } ); + }; const { POST } = await import( `../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts?case=${Date.now()}-${Math.random()}` @@ -227,6 +229,7 @@ test("bifrost route: records relay usage after SSE stream completion", async () const res = await POST(req); assert.equal(res.status, 200); assert.equal(res.headers.get("X-Routed-By"), "bifrost"); + assert.equal(forwardedRequestId, "bifrost-sse-lifecycle-test"); assert.equal(getRelayLogs(relayToken.id, 10).length, 0); assert.match(await res.text(), /delta/); diff --git a/tests/unit/api/v1/relay-routing-backend.test.ts b/tests/unit/api/v1/relay-routing-backend.test.ts index 6e8e0e0aad2..64cca270978 100644 --- a/tests/unit/api/v1/relay-routing-backend.test.ts +++ b/tests/unit/api/v1/relay-routing-backend.test.ts @@ -1,5 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { getBifrostRoutingConfig, getRoutingFallbackHeader, @@ -152,3 +153,26 @@ test("relay routing backend strict bifrost bypasses manifest eligibility", () => { tryBifrost: true } ); }); + +test("automatic relay keeps the Bifrost timeout active until an SSE stream finalizes", () => { + const routeSource = readFileSync( + new URL("../../../../src/app/api/v1/relay/chat/completions/route.ts", import.meta.url), + "utf8" + ); + const forwardToBifrost = routeSource.slice( + routeSource.indexOf("async function forwardToBifrost"), + routeSource.indexOf("export async function OPTIONS") + ); + const streamBranch = forwardToBifrost.slice( + forwardToBifrost.indexOf("if (wantsStream && upstream.body)"), + forwardToBifrost.indexOf("clearTimeout(tid);\n recordUsage(") + ); + + assert.match( + streamBranch, + /finalizeReadableStream\(upstream\.body, \(error\) => \{\s*clearTimeout\(tid\)/ + ); + assert.match(streamBranch, /const statusCode = timedOut \? 504 : upstream\.status/); + assert.match(streamBranch, /error && backend === "auto"/); + assert.match(streamBranch, /recordBifrostFailure\(/); +}); diff --git a/tests/unit/codex-tools-regex-lookaround.test.ts b/tests/unit/codex-tools-regex-lookaround.test.ts new file mode 100644 index 00000000000..4778ec7ac76 --- /dev/null +++ b/tests/unit/codex-tools-regex-lookaround.test.ts @@ -0,0 +1,74 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { normalizeCodexTools } from "../../open-sse/executors/codex/tools.ts"; + +// Port of 9router#1556: OpenAI/Codex Responses API rejects JSON Schema `pattern` +// fields containing regex lookaround (lookahead/lookbehind) with: +// "Invalid JSON schema: regex lookaround is not supported. Found at $.properties.email.pattern." +// Clients (e.g. IDE agent harnesses) commonly emit lookahead patterns such as +// `^(?=.*@).+$` for "must contain an @". These must be stripped before the +// tool schema reaches the Codex/OpenAI Responses API. +test("normalizeCodexTools strips regex lookaround from function tool parameter patterns", () => { + const body: Record = { + tools: [ + { + type: "function", + function: { + name: "send_email", + description: "Send an email", + parameters: { + type: "object", + properties: { + email: { + type: "string", + pattern: "^(?=.*@).+$", + }, + }, + }, + }, + }, + ], + }; + + normalizeCodexTools(body); + + const tools = body.tools as Array>; + const parameters = tools[0].parameters as Record; + const properties = parameters.properties as Record; + const emailSchema = properties.email as Record; + + assert.equal( + emailSchema.pattern, + undefined, + "lookaround pattern must be stripped, not forwarded upstream" + ); +}); + +test("normalizeCodexTools preserves plain (non-lookaround) regex patterns", () => { + const body: Record = { + tools: [ + { + type: "function", + function: { + name: "send_email", + parameters: { + type: "object", + properties: { + zip: { type: "string", pattern: "^[0-9]{5}$" }, + }, + }, + }, + }, + ], + }; + + normalizeCodexTools(body); + + const tools = body.tools as Array>; + const parameters = tools[0].parameters as Record; + const properties = parameters.properties as Record; + const zipSchema = properties.zip as Record; + + assert.equal(zipSchema.pattern, "^[0-9]{5}$"); +}); diff --git a/tests/unit/image-model-not-in-chat-catalog-6457.test.ts b/tests/unit/image-model-not-in-chat-catalog-6457.test.ts index cbd9b2033e3..84187fe2f72 100644 --- a/tests/unit/image-model-not-in-chat-catalog-6457.test.ts +++ b/tests/unit/image-model-not-in-chat-catalog-6457.test.ts @@ -11,10 +11,10 @@ // `type: "image"` by the imageRegistry loop — and catalogDedupe.ts keys on // (id, type, subtype), so the two distinct-`type` entries both survived. // -// Fix: skip a synced model in the chat-catalog loop when it is already a registered -// image model for that exact provider (open-sse/config/imageRegistry.ts -// isRegisteredImageModel()) — the imageRegistry loop still adds the correctly-typed -// `type: "image"` entry. +// Fix: skip an exact-provider registered image model from the chat-catalog loop only +// when synced metadata does not explicitly advertise `chat` or `responses`. The image +// registry loop still adds the correctly typed image entry, while multi-capability +// models keep both entries. import test from "node:test"; import assert from "node:assert/strict"; @@ -38,6 +38,7 @@ async function resetStorage() { } test.beforeEach(async () => { + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); await resetStorage(); }); @@ -46,19 +47,19 @@ test.after(async () => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); -async function seedHuggingFaceConnection() { +async function seedProviderConnection(provider: string) { return providersDb.createProviderConnection({ - provider: "huggingface", + provider, authType: "apikey", - name: `huggingface-${Math.random().toString(16).slice(2, 8)}`, - apiKey: "hf-key", + name: `${provider}-${Math.random().toString(16).slice(2, 8)}`, + apiKey: `${provider}-key`, isActive: true, testStatus: "active", }); } test("#6457 image/diffusion model discovered via live sync is NOT listed as a chat model", async () => { - const connection = await seedHuggingFaceConnection(); + const connection = await seedProviderConnection("huggingface"); // Simulate what HuggingFace's live `/v1/models` discovery persists for an // image/diffusion model: no supportedEndpoints/modality info at all — the exact @@ -100,3 +101,36 @@ test("#6457 image/diffusion model discovered via live sync is NOT listed as a ch assert.equal(entry.type, undefined, "the real chat model must not carry a non-chat type"); } }); + +test("registered image model with explicit chat endpoints keeps both catalog entries", async () => { + const connection = await seedProviderConnection("codex"); + + await modelsDb.replaceSyncedAvailableModelsForConnection("codex", connection.id, [ + { + id: "gpt-5.6-sol", + name: "GPT 5.6 Sol", + supportedEndpoints: ["responses"], + }, + ]); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models?prefix=alias") + ); + assert.equal(response.status, 200); + + const body = (await response.json()) as { + data: Array<{ id: string; type?: string; supported_endpoints?: string[] }>; + }; + const entries = body.data.filter((model) => model.id.endsWith("/gpt-5.6-sol")); + + assert.ok( + entries.some( + (model) => model.type !== "image" && model.supported_endpoints?.includes("responses") + ), + "explicit responses support must keep the synced chat entry" + ); + assert.ok( + entries.some((model) => model.type === "image"), + "the registered image entry must remain available under the same model id" + ); +}); diff --git a/tests/unit/reasoningContentInjector.test.ts b/tests/unit/reasoningContentInjector.test.ts new file mode 100644 index 00000000000..134843e7747 --- /dev/null +++ b/tests/unit/reasoningContentInjector.test.ts @@ -0,0 +1,44 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + isThinkingMessageModel, + injectReasoningContentForThinkingModel, +} from "../../open-sse/utils/reasoningContentInjector.ts"; + +describe("reasoningContentInjector — xiaomi-tokenplan mimo family (9router#1321)", () => { + it("recognizes xiaomi-tokenplan/mimo-v2.5-pro as a thinking-mode model", () => { + assert.equal(isThinkingMessageModel("xiaomi-tokenplan/mimo-v2.5-pro"), true); + }); + + it("recognizes bare mimo model ids as thinking-mode models", () => { + assert.equal(isThinkingMessageModel("mimo-v2.5-pro"), true); + }); + + it("still recognizes the existing thinking-mode families (deepseek/kimi/k2/minimax)", () => { + assert.equal(isThinkingMessageModel("deepseek-v4-flash"), true); + assert.equal(isThinkingMessageModel("kimi-k2"), true); + assert.equal(isThinkingMessageModel("minimax-m2"), true); + }); + + it("does not flag unrelated model ids", () => { + assert.equal(isThinkingMessageModel("gpt-4o"), false); + }); + + it("injects a reasoning_content placeholder for assistant messages when routed to mimo", () => { + const body = { + model: "xiaomi-tokenplan/mimo-v2.5-pro", + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + ], + }; + + // Simulate the executor gate: only inject when the model is a thinking model. + assert.equal(isThinkingMessageModel(body.model), true); + + const result = injectReasoningContentForThinkingModel(body) as typeof body; + const assistantMsg = result.messages[1] as Record; + assert.equal(assistantMsg.reasoning_content, " "); + }); +}); diff --git a/tests/unit/validate-release-green.test.ts b/tests/unit/validate-release-green.test.ts index bf62fd80967..76269752644 100644 --- a/tests/unit/validate-release-green.test.ts +++ b/tests/unit/validate-release-green.test.ts @@ -42,6 +42,20 @@ test("parseCognitiveCount reads the gate's count (en + pt)", () => { assert.equal(parseCognitiveCount("no number"), null); }); +test("parseCognitiveCount ignores the cyclomatic count in the combined ratchets output (#7009)", () => { + // `check:complexity-ratchets` runs ONE shared ESLint walk and prints BOTH ratchets. + // The cyclomatic "N violações" summary is emitted FIRST, so a bare `\\d+ violações` + // regex captured 2056 (cyclomatic) instead of 890 (cognitive) — a phantom drift in + // every pre-flight report. Prefer the unambiguous machine-readable `cognitiveComplexity=N`. + const combined = [ + "complexity=2056", + "cognitiveComplexity=890", + "[complexity] OK — 2056 violações (baseline 2056)", + "[cognitive-complexity] OK — 890 violações (baseline 890)", + ].join("\n"); + assert.equal(parseCognitiveCount(combined), 890); +}); + test("isDrift flags only growth past the committed baseline (down-direction ratchets)", () => { assert.equal(isDrift(3900, 3867), true); // grew → drift assert.equal(isDrift(3867, 3867), false); // equal → ok