From ec09949e6d2f2894915ea37eca3ee50a31a54c5b Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 4 Aug 2026 03:35:39 -0300 Subject: [PATCH 001/100] feat(providers): expose full NanoGPT endpoint surface (#9322) --- .../features/9322-nanogpt-endpoint-surface.md | 1 + open-sse/config/audioRegistry.ts | 22 +++ open-sse/config/embeddingRegistry.ts | 19 +++ .../providers/registry/nanogpt/index.ts | 1 + open-sse/config/videoRegistry.ts | 9 ++ tests/unit/nanogpt-endpoint-surface.test.ts | 131 ++++++++++++++++++ 6 files changed, 183 insertions(+) create mode 100644 changelog.d/features/9322-nanogpt-endpoint-surface.md create mode 100644 tests/unit/nanogpt-endpoint-surface.test.ts diff --git a/changelog.d/features/9322-nanogpt-endpoint-surface.md b/changelog.d/features/9322-nanogpt-endpoint-surface.md new file mode 100644 index 0000000000..485b3cf6c6 --- /dev/null +++ b/changelog.d/features/9322-nanogpt-endpoint-surface.md @@ -0,0 +1 @@ +- **feat(providers):** expanded the NanoGPT (`nano-gpt.com`) upstream provider from chat-only to the full OpenAI-compatible endpoint surface: audio transcriptions (`/api/v1/audio/transcriptions`), audio speech (`/api/v1/audio/speech`), video generation (`/api/v1/video/generations`), embeddings (`/v1/embeddings`), and the Responses API (`responsesBaseUrl` → `/api/v1/responses`) ([#9322](https://github.com/diegosouzapw/OmniRoute/issues/9322)) diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index 0419622f36..b856d0b765 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -226,6 +226,17 @@ export const AUDIO_TRANSCRIPTION_PROVIDERS: Record = { format: "speechmatics", models: [{ id: "enhanced", name: "Enhanced" }], }, + + nanogpt: { + id: "nanogpt", + baseUrl: "https://nano-gpt.com/api/v1/audio/transcriptions", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "whisper-1", name: "Whisper 1" }, + { id: "gpt-4o-transcription", name: "GPT-4o Transcription" }, + ], + }, }; /** @@ -540,6 +551,17 @@ export const AUDIO_SPEECH_PROVIDERS: Record = { { id: "mimo-v2.5-tts-voiceclone", name: "MiMo V2.5 Voice Clone" }, ], }, + + nanogpt: { + id: "nanogpt", + baseUrl: "https://nano-gpt.com/api/v1/audio/speech", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "tts-1-hd", name: "TTS 1 HD" }, + { id: "tts-1", name: "TTS 1" }, + ], + }, }; /** diff --git a/open-sse/config/embeddingRegistry.ts b/open-sse/config/embeddingRegistry.ts index 4882b5373a..a83d263622 100644 --- a/open-sse/config/embeddingRegistry.ts +++ b/open-sse/config/embeddingRegistry.ts @@ -394,6 +394,25 @@ export const EMBEDDING_PROVIDERS: Record = { }, ], }, + + nanogpt: { + id: "nanogpt", + baseUrl: "https://nano-gpt.com/v1/embeddings", + authType: "apikey", + authHeader: "bearer", + models: [ + { + id: "text-embedding-3-small", + name: "Text Embedding 3 Small", + dimensions: 1536, + }, + { + id: "text-embedding-3-large", + name: "Text Embedding 3 Large", + dimensions: 3072, + }, + ], + }, }; const EMBEDDING_PROVIDER_ALIASES: Record = { diff --git a/open-sse/config/providers/registry/nanogpt/index.ts b/open-sse/config/providers/registry/nanogpt/index.ts index 9bd165deee..39ff5aa5c6 100644 --- a/open-sse/config/providers/registry/nanogpt/index.ts +++ b/open-sse/config/providers/registry/nanogpt/index.ts @@ -7,6 +7,7 @@ export const nanogptProvider: RegistryEntry = { format: "openai", executor: "default", baseUrl: "https://nano-gpt.com/api/v1/chat/completions", + responsesBaseUrl: "https://nano-gpt.com/api/v1/responses", authType: "apikey", authHeader: "bearer", models: CHAT_OPENAI_COMPAT_MODELS.nanogpt, diff --git a/open-sse/config/videoRegistry.ts b/open-sse/config/videoRegistry.ts index 260f1bf480..d3c6ae27ca 100644 --- a/open-sse/config/videoRegistry.ts +++ b/open-sse/config/videoRegistry.ts @@ -348,6 +348,15 @@ export const VIDEO_PROVIDERS: Record = { { id: "runway-gen4-turbo", name: "Firefly Runway Gen-4 Video" }, ], }, + + nanogpt: { + id: "nanogpt", + baseUrl: "https://nano-gpt.com/api/v1/video/generations", + authType: "apikey", + authHeader: "bearer", + format: "openai", + models: [{ id: "default", name: "NanoGPT Video" }], + }, }; /** diff --git a/tests/unit/nanogpt-endpoint-surface.test.ts b/tests/unit/nanogpt-endpoint-surface.test.ts new file mode 100644 index 0000000000..a34afab113 --- /dev/null +++ b/tests/unit/nanogpt-endpoint-surface.test.ts @@ -0,0 +1,131 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + AUDIO_TRANSCRIPTION_PROVIDERS, + AUDIO_SPEECH_PROVIDERS, + getTranscriptionProvider, + getSpeechProvider, +} from "../../open-sse/config/audioRegistry.ts"; +import { VIDEO_PROVIDERS, getVideoProvider } from "../../open-sse/config/videoRegistry.ts"; +import { + EMBEDDING_PROVIDERS, + getEmbeddingProvider, +} from "../../open-sse/config/embeddingRegistry.ts"; +import { REGISTRY } from "../../open-sse/config/providerRegistry.ts"; + +describe("nanogpt endpoint surface (#9322)", () => { + describe("audio transcription", () => { + it("is registered in AUDIO_TRANSCRIPTION_PROVIDERS", () => { + assert.ok( + AUDIO_TRANSCRIPTION_PROVIDERS.nanogpt, + "nanogpt should be in AUDIO_TRANSCRIPTION_PROVIDERS" + ); + }); + + it("resolves nanogpt transcription config", () => { + const p = getTranscriptionProvider("nanogpt"); + assert.ok(p, "getTranscriptionProvider should resolve nanogpt"); + assert.equal(p.id, "nanogpt"); + assert.equal(p.baseUrl, "https://nano-gpt.com/api/v1/audio/transcriptions"); + assert.equal(p.authType, "apikey"); + assert.equal(p.authHeader, "bearer"); + }); + + it("has at least one transcription model", () => { + const p = getTranscriptionProvider("nanogpt"); + assert.ok(p.models.length >= 1, "Expected >= 1 transcription model"); + }); + }); + + describe("audio speech", () => { + it("is registered in AUDIO_SPEECH_PROVIDERS", () => { + assert.ok( + AUDIO_SPEECH_PROVIDERS.nanogpt, + "nanogpt should be in AUDIO_SPEECH_PROVIDERS" + ); + }); + + it("resolves nanogpt speech config", () => { + const p = getSpeechProvider("nanogpt"); + assert.ok(p, "getSpeechProvider should resolve nanogpt"); + assert.equal(p.id, "nanogpt"); + assert.equal(p.baseUrl, "https://nano-gpt.com/api/v1/audio/speech"); + assert.equal(p.authType, "apikey"); + assert.equal(p.authHeader, "bearer"); + }); + + it("has at least one speech model", () => { + const p = getSpeechProvider("nanogpt"); + assert.ok(p.models.length >= 1, "Expected >= 1 speech model"); + }); + }); + + describe("video generation", () => { + it("is registered in VIDEO_PROVIDERS", () => { + assert.ok( + VIDEO_PROVIDERS.nanogpt, + "nanogpt should be in VIDEO_PROVIDERS" + ); + }); + + it("resolves nanogpt video config", () => { + const p = getVideoProvider("nanogpt"); + assert.ok(p, "getVideoProvider should resolve nanogpt"); + assert.equal(p.id, "nanogpt"); + assert.equal(p.baseUrl, "https://nano-gpt.com/api/v1/video/generations"); + assert.equal(p.authType, "apikey"); + assert.equal(p.authHeader, "bearer"); + }); + + it("has at least one video model", () => { + const p = getVideoProvider("nanogpt"); + assert.ok(p.models.length >= 1, "Expected >= 1 video model"); + }); + }); + + describe("embeddings", () => { + it("is registered in EMBEDDING_PROVIDERS", () => { + assert.ok( + EMBEDDING_PROVIDERS.nanogpt, + "nanogpt should be in EMBEDDING_PROVIDERS" + ); + }); + + it("resolves nanogpt embedding config", () => { + const p = getEmbeddingProvider("nanogpt"); + assert.ok(p, "getEmbeddingProvider should resolve nanogpt"); + assert.equal(p.id, "nanogpt"); + assert.equal(p.baseUrl, "https://nano-gpt.com/v1/embeddings"); + assert.equal(p.authType, "apikey"); + assert.equal(p.authHeader, "bearer"); + }); + + it("has at least one embedding model", () => { + const p = getEmbeddingProvider("nanogpt"); + assert.ok(p.models.length >= 1, "Expected >= 1 embedding model"); + }); + }); + + describe("registry entry", () => { + it("has a registry entry with the canonical identity", () => { + const entry = REGISTRY.nanogpt; + assert.ok(entry, "REGISTRY.nanogpt should be defined"); + assert.equal(entry.id, "nanogpt"); + assert.equal(entry.format, "openai"); + assert.equal(entry.executor, "default"); + }); + + it("has responsesBaseUrl for Responses API", () => { + const entry = REGISTRY.nanogpt; + assert.ok(entry, "REGISTRY.nanogpt should be defined"); + assert.ok( + entry.responsesBaseUrl, + "nanogpt registry entry should have responsesBaseUrl" + ); + assert.equal( + entry.responsesBaseUrl, + "https://nano-gpt.com/api/v1/responses" + ); + }); + }); +}); From 848fca7eb023e5f5945804279fdc4cce831b72ff Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 4 Aug 2026 03:49:35 -0300 Subject: [PATCH 002/100] feat(gemini): recursive schema type:object + empty choices interceptor (#9268) --- _tasks | 1 + ...ini-schema-recursive-type-empty-choices.md | 1 + config/quality/file-size-baseline.json | 5 +- open-sse/translator/helpers/geminiHelper.ts | 33 ++++ open-sse/utils/stream.ts | 26 ++++ open-sse/utils/streamEmptyChoices.ts | 116 ++++++++++++++ .../unit/gemini-schema-recursive-type.test.ts | 142 ++++++++++++++++++ .../stream-empty-choices-interceptor.test.ts | 131 ++++++++++++++++ 8 files changed, 453 insertions(+), 2 deletions(-) create mode 120000 _tasks create mode 100644 changelog.d/features/9268-gemini-schema-recursive-type-empty-choices.md create mode 100644 open-sse/utils/streamEmptyChoices.ts create mode 100644 tests/unit/gemini-schema-recursive-type.test.ts create mode 100644 tests/unit/stream-empty-choices-interceptor.test.ts diff --git a/_tasks b/_tasks new file mode 120000 index 0000000000..c17ee3177f --- /dev/null +++ b/_tasks @@ -0,0 +1 @@ +/home/diegosouzapw/dev/proxys/OmniRoute/_tasks \ No newline at end of file diff --git a/changelog.d/features/9268-gemini-schema-recursive-type-empty-choices.md b/changelog.d/features/9268-gemini-schema-recursive-type-empty-choices.md new file mode 100644 index 0000000000..8b38913bae --- /dev/null +++ b/changelog.d/features/9268-gemini-schema-recursive-type-empty-choices.md @@ -0,0 +1 @@ +- **feat(gemini):** recursive type:object injection in schema normalizer + empty choices interceptor for streaming (#9268) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 925ce4fb74..0660bb3386 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -365,7 +365,7 @@ "open-sse/services/rateLimitManager.ts": 1060, "open-sse/translator/response/openai-responses.ts": 1174, "open-sse/utils/cursorAgentProtobuf.ts": 1505, - "open-sse/utils/stream.ts": 2889, + "open-sse/utils/stream.ts": 2915, "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1381, "src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1031, "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3117, @@ -414,5 +414,6 @@ "_rebaseline_2026_07_28_8861_xiaomi_token_plan": "PR #8861 (feat/xiaomi-token-plan-protocol-selector) own growth: EditConnectionModal.tsx 1283->1316 (+33 = the per-connection API-protocol selector field) and open-sse/executors/base.ts 1540->1562 (+22 = alternate-format resolution at the existing buildUrl/headers chokepoint). Both are irreducible wiring at existing call sites.", "_rebaseline_2026_07_28_8863_firefly_detail_level": "PR #8863 (fix/adobe-firefly-gpt-detail-level-max) own growth: adobeFireflyClient.ts 2317->2322 (+5 = gpt-image detailLevel defaulting to maximal at the existing payload-build site). Covered by tests/unit/adobe-firefly.test.ts.", "_rebaseline_2026_07_29_8281_home_quickstart_prefetch": "Release v3.8.49 base-red fix (no PR — captain sweep): src/app/(dashboard)/dashboard/HomePageClient.tsx 1377->1381 (+4). #8292 added prefetch={false} to the sidebar but left /home's five quick-start Links prefetching, so first paint still fired 12 speculative RSC requests — caught by navigation.spec.ts only after the e2e helper bug (APP_ROUTE_PATTERN missing /home) was repaired in the same cycle. Growth is the five prefetch attributes; it was offset first by extracting the repeated className literals (INLINE_LINK x4, DOCS_LINK x1), which collapsed five wrapped blocks back to one line each — a naive fix measured 1391. Guard: tests/unit/sidebar-prefetch-policy-8281.test.ts.", - "_rebaseline_2026_08_02_v3850_agentrouter_responses": "Release v3.8.50 AgentRouter/Codex compatibility reconciliation. open-sse/executors/base.ts 1562->1578: #9190 wires AgentRouter's selected Claude/OpenAI/Responses protocol through the existing executor URL, auth, identity-header and fingerprint chokepoints; the reusable alternate resolver remains outside base.ts. open-sse/utils/stream.ts 2887->2889: #9213 evaluates Responses ID and usage normalization independently so response.completed always receives finite usage.total_tokens instead of short-circuiting after an ID rewrite. tests/unit/chatcore-translation-paths.test.ts 2769->2776: #9191 updates the existing Claude-Code bridge assertions for the dynamic AgentRouter wire image. PR #9224 offsets its own chatCore growth by extracting the AgentRouter protocol decisions into chatCore/agentRouterProtocol.ts, leaving chatCore below its frozen ceiling. Covered by agentrouter executor/chatCore protocol tests, chatcore translation-path tests, and responses-commentary-passthrough tests." + "_rebaseline_2026_08_02_v3850_agentrouter_responses": "Release v3.8.50 AgentRouter/Codex compatibility reconciliation. open-sse/executors/base.ts 1562->1578: #9190 wires AgentRouter's selected Claude/OpenAI/Responses protocol through the existing executor URL, auth, identity-header and fingerprint chokepoints; the reusable alternate resolver remains outside base.ts. open-sse/utils/stream.ts 2887->2889: #9213 evaluates Responses ID and usage normalization independently so response.completed always receives finite usage.total_tokens instead of short-circuiting after an ID rewrite. tests/unit/chatcore-translation-paths.test.ts 2769->2776: #9191 updates the existing Claude-Code bridge assertions for the dynamic AgentRouter wire image. PR #9224 offsets its own chatCore growth by extracting the AgentRouter protocol decisions into chatCore/agentRouterProtocol.ts, leaving chatCore below its frozen ceiling. Covered by agentrouter executor/chatCore protocol tests, chatcore translation-path tests, and responses-commentary-passthrough tests.", + "_rebaseline_2026_08_04_9268_gemini_schema_empty_choices": "Feature #9268 own growth: open-sse/utils/stream.ts 2889->2915 (+26 = irreducible call-site wiring for the empty-choices interceptor). The translate-mode flush now rejects a stream that completed without forwarding any valuable chunk (all-empty `choices: []`, no content/tool_calls/finish_reason) as a retryable 502 \"empty content\" instead of a clean empty 200 — the missing streaming counterpart of chatCore.ts's non-streaming isEmptyContentResponse. All rejection logic lives in the NEW leaf module open-sse/utils/streamEmptyChoices.ts ( retryable 502 (#9268) + let forwardedValuableChunk = false; + // Track content length for usage estimation (both modes) let totalContentLength = 0; // Passthrough: accumulate content and reasoning separately for call log response body @@ -1036,6 +1040,7 @@ export function createSSEStream(options: StreamOptions = {}) { const output = formatSSE(itemSanitized, sourceFormat); clientPayloadCollector.push(itemSanitized); reqLogger?.appendConvertedChunk?.(output); + forwardedValuableChunk = true; controller.enqueue(encoder.encode(output)); }; @@ -2651,6 +2656,27 @@ export function createSSEStream(options: StreamOptions = {}) { return; } + // #9268: reject a translate-mode stream that forwarded no valuable chunk + // (all-empty `choices: []`) instead of completing with an empty 200. + if ( + mode === STREAM_MODE.TRANSLATE && + rejectEmptyChoicesStream({ + forwardedValuableChunk, + hasValidUsage: hasValidUsage(state?.usage), + providerPayloadCollector, + clientPayloadCollector, + targetFormat, + model, + usage: state?.usage, + onFailure, + onComplete, + clearPendingRequestFromStream, + }) + ) { + controller.error(markPendingRequestCleared(buildEmptyChoicesStreamError())); + return; + } + // Flush remaining events (only once at stream end) const flushed = translateResponse(targetFormat, sourceFormat, null, state); diff --git a/open-sse/utils/streamEmptyChoices.ts b/open-sse/utils/streamEmptyChoices.ts new file mode 100644 index 0000000000..20d2ec50e6 --- /dev/null +++ b/open-sse/utils/streamEmptyChoices.ts @@ -0,0 +1,116 @@ +/** + * Empty-stream rejection for the SSE transform (#9268). + * + * A streaming provider can complete a turn having forwarded nothing usable — + * every chunk carried an empty `choices: []` (no content, no tool_calls, no + * finish_reason, e.g. a Gemini turn where the model emitted nothing). The SSE + * transform drops those chunks silently, so without a guard the stream would + * terminate with a clean empty 200, which clients treat as a valid empty turn + * and retry to their cap with no error to stop on. + * + * The transform is the only place that knows a chunk was actually forwarded, so + * `createSSEStream` threads a `forwardedValuableChunk` boolean and the + * flush-time callbacks. All rejection logic lives here so the frozen + * `open-sse/utils/stream.ts` only carries the minimal call-site wiring. + * + * Mirrors the non-streaming `isEmptyContentResponse` behavior in + * `open-sse/handlers/chatCore.ts` (empty content → retryable 502), and the + * #8649 disconnect-aware wrapper's "Provider returned empty content" outcome. + */ +import { buildErrorBody } from "./error.ts"; +import { buildStreamSummaryFromEvents } from "./streamPayloadCollector.ts"; + +type StructuredSSECollectorLike = { + getEvents: () => unknown[]; + build: (summary?: unknown, opts?: { includeEvents?: boolean }) => unknown; +}; + +type EmptyChoicesRejectContext = { + /** True when any chunk with content/tool_calls/finish_reason was forwarded. */ + forwardedValuableChunk: boolean; + /** Valid usage accumulated on the stream state (usage-only streams are fine). */ + hasValidUsage: boolean; + /** Provider-side event collector (for the onComplete providerPayload summary). */ + providerPayloadCollector: StructuredSSECollectorLike; + /** Client-side payload collector (for the onComplete clientPayload). */ + clientPayloadCollector: StructuredSSECollectorLike; + targetFormat?: string; + model?: string | null; + usage?: unknown; + onFailure?: ((payload: { + status: number; + message: string; + code?: string; + type?: string; + }) => boolean | void | Promise) | null; + onComplete?: ((payload: { + status: number; + usage: unknown; + responseBody?: unknown; + providerPayload?: unknown; + clientPayload?: unknown; + error?: string | null; + errorCode?: string | null; + }) => void) | null; + clearPendingRequestFromStream?: () => void; +}; + +/** + * Returns `true` when the empty-stream condition was detected and the caller + * must abort the stream (controller.error + early return); `false` when the + * stream legitimately forwarded content/usage and should complete normally. + */ +export function rejectEmptyChoicesStream(ctx: EmptyChoicesRejectContext): boolean { + if (ctx.forwardedValuableChunk || ctx.hasValidUsage) return false; + + const error = new Error( + "Provider returned empty content — stream forwarded no valuable chunks" + ) as Error & { statusCode: number; code: string }; + error.statusCode = 502; + error.code = "empty_content"; + + if (ctx.onFailure) { + try { + ctx.onFailure({ status: 502, message: error.message, code: "empty_content" }); + } catch { + // best-effort — must never break the stream error path + } + } + + const errorBody = buildErrorBody(502, error.message); + if (ctx.onComplete) { + try { + ctx.onComplete({ + status: 502, + usage: ctx.usage, + responseBody: errorBody, + error: error.message, + errorCode: "empty_content", + providerPayload: ctx.providerPayloadCollector.build( + buildStreamSummaryFromEvents( + ctx.providerPayloadCollector.getEvents(), + ctx.targetFormat, + ctx.model + ), + { includeEvents: false } + ), + clientPayload: ctx.clientPayloadCollector.build(errorBody, { includeEvents: false }), + }); + } catch { + // best-effort + } + } + + ctx.clearPendingRequestFromStream?.(); + return true; +} + +/** The retryable error the caller should surface via controller.error. */ +export function buildEmptyChoicesStreamError(): Error & { statusCode: number; code: string } { + const error = new Error( + "Provider returned empty content — stream forwarded no valuable chunks" + ) as Error & { statusCode: number; code: string }; + error.statusCode = 502; + error.code = "empty_content"; + return error; +} diff --git a/tests/unit/gemini-schema-recursive-type.test.ts b/tests/unit/gemini-schema-recursive-type.test.ts new file mode 100644 index 0000000000..43a1182dc8 --- /dev/null +++ b/tests/unit/gemini-schema-recursive-type.test.ts @@ -0,0 +1,142 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { cleanJSONSchemaForAntigravity } = await import( + "../../open-sse/translator/helpers/geminiHelper.ts" +); + +test("#9268 injects type:object on nested properties without type", () => { + const input = { + type: "object", + properties: { + name: { type: "string" }, + address: { + // nested node with properties but NO type — should get type:object + properties: { + street: { type: "string" }, + city: { type: "string" }, + }, + }, + }, + required: ["name"], + }; + + const result = cleanJSONSchemaForAntigravity(input) as Record; + const props = result.properties as Record; + const address = props.address as Record; + + assert.equal(address.type, "object", "nested object with properties must get type:object"); +}); + +test("#9268 injects type:object on nested items array schemas", () => { + const input = { + type: "object", + properties: { + items: { + type: "array", + items: { + // array items schema with properties but NO type + properties: { + id: { type: "integer" }, + label: { type: "string" }, + }, + }, + }, + }, + }; + + const result = cleanJSONSchemaForAntigravity(input) as Record; + const props = result.properties as Record; + const items = props.items as Record; + const inner = items.items as Record; + + assert.equal(inner.type, "object", "array items schema with properties must inject type:object"); +}); + +test("#9268 injects type:object on deeply nested schemas (3+ levels)", () => { + const input = { + type: "object", + properties: { + level1: { + properties: { + level2: { + properties: { + level3: { + properties: { + value: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }; + + const result = cleanJSONSchemaForAntigravity(input) as Record; + const l1 = (result.properties as Record).level1 as Record; + const l2 = (l1.properties as Record).level2 as Record; + const l3 = (l2.properties as Record).level3 as Record; + + assert.equal(l1.type, "object", "level1 must have type:object"); + assert.equal(l2.type, "object", "level2 must have type:object"); + assert.equal(l3.type, "object", "level3 must have type:object"); +}); + +test("#9268 schema already typed is not double-injected", () => { + const input = { + type: "object", + properties: { + nested: { + type: "object", + properties: { + x: { type: "string" }, + }, + }, + }, + }; + + const result = cleanJSONSchemaForAntigravity(input) as Record; + const nested = (result.properties as Record).nested as Record; + + assert.equal(nested.type, "object", "already-typed nested must keep its type"); + // Ensure properties is not clobbered + const nestedProps = nested.properties as Record; + assert.ok(nestedProps, "nested properties must be preserved"); + assert.ok("x" in nestedProps, "nested property 'x' must exist"); +}); + +test("#9268 node with required but no properties still gets type:object", () => { + // Edge case: a node that has `required` but no `type` and no `properties` + // should still get type:object injection (Gemini needs it). + const input = { + type: "object", + properties: { + ref: { + // has required but no type nor properties (e.g. an incomplete $ref stub) + required: ["id"], + }, + }, + }; + + const result = cleanJSONSchemaForAntigravity(input) as Record; + const ref = (result.properties as Record).ref as Record; + + assert.equal(ref.type, "object", "node with required but no type must get type:object"); +}); + +test("#9268 null/undefined fields do not crash the normalizer", () => { + const input = { + type: "object", + properties: { + a: null, + b: undefined, + // @ts-expect-error - testing runtime resilience + c: { properties: null }, + }, + }; + + assert.doesNotThrow(() => { + cleanJSONSchemaForAntigravity(input); + }, "null/undefined fields must not crash the normalizer"); +}); diff --git a/tests/unit/stream-empty-choices-interceptor.test.ts b/tests/unit/stream-empty-choices-interceptor.test.ts new file mode 100644 index 0000000000..0023bdc3e7 --- /dev/null +++ b/tests/unit/stream-empty-choices-interceptor.test.ts @@ -0,0 +1,131 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { createSSETransformStreamWithLogger } = await import( + "../../open-sse/utils/stream.ts" +); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + +async function drainTransform( + transformStream: TransformStream, + frames: string[] +): Promise<{ output: string; errored: boolean }> { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const upstream = new ReadableStream({ + start(controller) { + for (const frame of frames) controller.enqueue(encoder.encode(frame)); + controller.close(); + }, + }); + + const reader = upstream.pipeThrough(transformStream).getReader(); + const parts: string[] = []; + let errored = false; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) parts.push(decoder.decode(value)); + } + } catch { + errored = true; + } + return { output: parts.join(""), errored }; +} + +function geminiContentChunk(text: string): string { + return `data: ${JSON.stringify({ + candidates: [{ content: { parts: [{ text }] } }], + })}\n\n`; +} + +function geminiFinishChunk(): string { + return `data: ${JSON.stringify({ candidates: [{ finishReason: "STOP" }] })}\n\n`; +} + +function emptyChoicesChunk(id = "1"): string { + return `data: ${JSON.stringify({ + id: `chatcmpl-${id}`, + object: "chat.completion.chunk", + model: "gemini-test", + choices: [], + })}\n\n`; +} + +test("#9268 an all-empty-choices stream is rejected as a retryable error", async () => { + const transform = createSSETransformStreamWithLogger( + FORMATS.GEMINI, + FORMATS.OPENAI, + "gemini-test", + null, + null, + "gemini-model", + "conn-1", + { messages: [{ role: "user", content: "hi" }] }, + null, + null, + null + ); + + const { output, errored } = await drainTransform(transform, [ + emptyChoicesChunk("1"), + emptyChoicesChunk("2"), + ]); + + // The translate-mode flush now errors the stream when no valuable chunk was + // forwarded, so the client must NOT see a clean empty 200 with just [DONE]. + assert.ok( + errored || !output.includes("[DONE]"), + "an all-empty stream must not complete cleanly with a [DONE] terminator" + ); +}); + +test("#9268 a stream with real content passes through unchanged", async () => { + const transform = createSSETransformStreamWithLogger( + FORMATS.GEMINI, + FORMATS.OPENAI, + "gemini-test", + null, + null, + "gemini-model", + "conn-2", + { messages: [{ role: "user", content: "hi" }] }, + null, + null, + null + ); + + const { output, errored } = await drainTransform(transform, [ + geminiContentChunk("hello"), + geminiFinishChunk(), + ]); + + assert.ok(output.includes("hello"), "content must be forwarded"); + assert.equal(errored, false, "a healthy stream must not error"); +}); + +test("#9268 empty choices after real content still passes through (mid-stream usage-only)", async () => { + const transform = createSSETransformStreamWithLogger( + FORMATS.GEMINI, + FORMATS.OPENAI, + "gemini-test", + null, + null, + "gemini-model", + "conn-3", + { messages: [{ role: "user", content: "hi" }] }, + null, + null, + null + ); + + const { output, errored } = await drainTransform(transform, [ + geminiContentChunk("real output"), + emptyChoicesChunk("1"), + geminiFinishChunk(), + ]); + + assert.ok(output.includes("real output"), "content must be forwarded"); + assert.equal(errored, false, "a stream with content then empty usage chunk must not error"); +}); From b263905984ecc47c03bb08bdbedd755b11ea1632 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 4 Aug 2026 03:49:51 -0300 Subject: [PATCH 003/100] chore: remove _tasks symlink from tracking --- _tasks | 1 - 1 file changed, 1 deletion(-) delete mode 120000 _tasks diff --git a/_tasks b/_tasks deleted file mode 120000 index c17ee3177f..0000000000 --- a/_tasks +++ /dev/null @@ -1 +0,0 @@ -/home/diegosouzapw/dev/proxys/OmniRoute/_tasks \ No newline at end of file From ee94b0378de949b27ee2e406bb03bc3e0043b2e7 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 4 Aug 2026 08:51:41 -0300 Subject: [PATCH 004/100] feat(core): add Layer A capability filter at router (#5696) --- .../5696-layer-a-capability-filter.md | 1 + config/quality/file-size-baseline.json | 2 +- open-sse/handlers/chatCore.ts | 13 +- src/i18n/messages/en.json | 7 +- src/i18n/messages/pt-BR.json | 8 +- .../capabilities/capabilityFilter.ts | 211 ++++++++++++++ .../constants/featureFlagDefinitions.ts | 12 + tests/unit/capability-filter.test.ts | 269 ++++++++++++++++++ 8 files changed, 519 insertions(+), 4 deletions(-) create mode 100644 changelog.d/features/5696-layer-a-capability-filter.md create mode 100644 src/shared/constants/capabilities/capabilityFilter.ts create mode 100644 tests/unit/capability-filter.test.ts diff --git a/changelog.d/features/5696-layer-a-capability-filter.md b/changelog.d/features/5696-layer-a-capability-filter.md new file mode 100644 index 0000000000..37d04132e3 --- /dev/null +++ b/changelog.d/features/5696-layer-a-capability-filter.md @@ -0,0 +1 @@ +- **feat(core):** add Layer A capability filter at router (#5696) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 925ce4fb74..e0be3f9fcc 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -349,7 +349,7 @@ "open-sse/executors/deepseek-web.ts": 1148, "open-sse/executors/grok-web.ts": 1044, "open-sse/executors/muse-spark-web.ts": 1405, - "open-sse/handlers/chatCore.ts": 5020, + "open-sse/handlers/chatCore.ts": 5029, "open-sse/handlers/imageGeneration.ts": 3101, "open-sse/handlers/responseSanitizer.ts": 1115, "open-sse/handlers/search.ts": 1536, diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 941a5e9b45..2f9a890c5f 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -134,6 +134,8 @@ import { getResolvedModelCapabilities, getExplicitModelOutputCap, } from "@/lib/modelCapabilities.ts"; +import { checkRequestCapabilityFit, deriveRequestCapabilityRequirements, buildCapabilityMismatchMessage } from "@/shared/constants/capabilities/capabilityFilter.ts"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags.ts"; import { toPositiveInteger } from "../services/reasoningTokenBuffer.ts"; import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts"; import { @@ -2613,7 +2615,16 @@ export async function handleChatCore({ } } // === /Quota Share enforcement PRE-hook === - + if (isFeatureFlagEnabled("CAPABILITY_FILTER_ENABLED")) { + const fit = checkRequestCapabilityFit(getResolvedModelCapabilities({ provider, model: effectiveModel }), + deriveRequestCapabilityRequirements(body as Record), provider); + if (!fit.compatible) { + const msg = buildCapabilityMismatchMessage(fit.terminalReason!, provider, effectiveModel); + log?.warn?.("CAPABILITY", msg); + trackPendingRequest(model, provider, connectionId, false); + return createErrorResult(400, msg, null, fit.terminalReason, "invalid_request_error"); + } + } // Get executor for this provider (with optional upstream proxy routing) const executor = await resolveExecutorWithProxy(provider); const getExecutionCredentials = () => diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 62d6c4719b..5bf384a75a 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -938,6 +938,7 @@ "featureFlagOmnirouteEmergencyFallbackDescription": "Route budget-exhausted requests to the emergency free fallback provider/model.", "featureFlagArenaEloSyncEnabledDescription": "Enable periodic Arena AI leaderboard ELO sync for model intelligence rankings.", "featureFlagExposeCcDiscoveryAliasesDescription": "Advertise claude/<provider>/<model> mirror ids on /v1/models so Claude Code gateway model discovery lists non-Claude models. Warning: doubles catalog entries for all clients when enabled globally.", + "featureFlagCapabilityFilterEnabledDescription": "Reject requests before dispatch when the target model lacks required capabilities (vision, tools, structured output, context window). Protects direct single-provider requests that bypass the combo-layer compatibility filter.", "sidebar": { "home": "Home", "dashboard": "Dashboard", @@ -12208,5 +12209,9 @@ "partnerLinkNote": "Partner link", "dismissAriaLabel": "Dismiss" }, - "featureFlagExposeFunctionalGatewayMirrorsDescription": "Advertise / mirror ids on /v1/models for models whose canonical owner has no active credential but a passthrough gateway with an active credential routes them. Warning: adds catalog entries for all clients when enabled globally." + "featureFlagExposeFunctionalGatewayMirrorsDescription": "Advertise / mirror ids on /v1/models for models whose canonical owner has no active credential but a passthrough gateway with an active credential routes them. Warning: adds catalog entries for all clients when enabled globally.", + "capabilityFilter.visionMismatch": "Provider does not support vision for this image request", + "capabilityFilter.toolsMismatch": "Provider does not support tool calling", + "capabilityFilter.structuredOutputMismatch": "Provider does not support structured output", + "capabilityFilter.contextWindowMismatch": "Request exceeds provider context window" } diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index bf9ba43dae..8fe7ccc63b 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -938,6 +938,7 @@ "featureFlagOmnirouteEmergencyFallbackDescription": "Encaminhar solicitações com orçamento esgotado para o provedor/modelo de fallback gratuito de emergência.", "featureFlagArenaEloSyncEnabledDescription": "Habilitar sincronização periódica de ELO da tabela de classificação do Arena AI para rankings de inteligência de modelos.", "featureFlagExposeCcDiscoveryAliasesDescription": "Divulgar ids espelho claude/<provider>/<model> em /v1/models para que a descoberta de modelos do gateway Claude Code liste modelos não-Claude. Atenção: duplica as entradas do catálogo para todos os clientes quando ativado globalmente.", + "featureFlagCapabilityFilterEnabledDescription": "Rejeitar requisicoes antes do despacho quando o modelo alvo nao possui as capacidades necessarias (visao, ferramentas, saida estruturada, janela de contexto). Protege requisicoes diretas que ignoram o filtro de compatibilidade do combo.", "sidebar": { "home": "Início", "dashboard": "Painel", @@ -12208,5 +12209,10 @@ "partnerLinkNote": "Link de parceiro", "dismissAriaLabel": "Descartar" }, - "featureFlagExposeFunctionalGatewayMirrorsDescription": "__MISSING__:Advertise / mirror ids on /v1/models for models whose canonical owner has no active credential but a passthrough gateway with an active credential routes them. Warning: adds catalog entries for all clients when enabled globally." + "featureFlagExposeFunctionalGatewayMirrorsDescription": "__MISSING__:Advertise / mirror ids on /v1/models for models whose canonical owner has no active credential but a passthrough gateway with an active credential routes them. Warning: adds catalog entries for all clients when enabled globally.", + "featureFlagCapabilityFilterEnabledDescription": "Rejeitar requisições antes do despacho quando o modelo alvo nao possui as capacidades necessarias (visao, ferramentas, saída estruturada, janela de contexto). Protege requisições diretas que ignoram o filtro de compatibilidade do combo.", + "capabilityFilter.visionMismatch": "O provedor nao suporta visao para esta requisicao de imagem", + "capabilityFilter.toolsMismatch": "O provedor nao suporta chamada de ferramentas", + "capabilityFilter.structuredOutputMismatch": "O provedor nao suporta saida estruturada", + "capabilityFilter.contextWindowMismatch": "A requisicao excede a janela de contexto do provedor" } diff --git a/src/shared/constants/capabilities/capabilityFilter.ts b/src/shared/constants/capabilities/capabilityFilter.ts new file mode 100644 index 0000000000..b0c0de8091 --- /dev/null +++ b/src/shared/constants/capabilities/capabilityFilter.ts @@ -0,0 +1,211 @@ +/** + * Layer A capability filter — shared, provider-agnostic module. + * + * Validates that a provider+model can satisfy the request's capability + * requirements (tools, vision, structured output, context window) BEFORE + * dispatch to the executor. Returns an early 400 if not, rather than + * letting the request fail downstream or produce garbage (e.g. a text-only + * model receiving image_url content and answering "image not provided"). + * + * The logic mirrors what `filterTargetsByRequestCompatibility` in + * comboStructure.ts already does for combo-routed requests, but this + * module lives at the router layer (Layer A) so it also protects direct + * single-provider requests (`model:"openai/gpt-4o-mini"` without a combo). + * + * #5696 + */ + +import { getResolvedModelCapabilities } from "@/lib/modelCapabilities"; +import { evaluateContextLimit } from "@omniroute/open-sse/services/combo/contextOverrideGate"; +import { hasEstimableContent } from "@omniroute/open-sse/services/combo/knownContextOverflow"; +import { isRecord } from "@omniroute/open-sse/services/combo/comboData"; +import { providerSupportsEmulatedToolCalling } from "@omniroute/open-sse/services/combo/comboStructure"; +import { estimateTokens } from "@omniroute/open-sse/services/contextManager"; + +// ── Types ───────────────────────────────────────────────────────────────── + +export type CapabilityFailure = "tools" | "vision" | "structured_output" | "context_window"; + +export interface RequestCapabilityRequirements { + requiresTools: boolean; + requiresVision: boolean; + requiresStructuredOutput: boolean; + requiredContextTokens: number; + toolCount: number; +} + +export interface CapabilityFilterResult { + compatible: boolean; + failures: CapabilityFailure[]; + terminalReason?: string; +} + +// ── Pure helpers (mirror the unexported helpers in comboStructure.ts) ────── + +function requestRequiresTools(body: Record): boolean { + if (Array.isArray(body.tools) && body.tools.length > 0) return true; + if (Array.isArray(body.functions) && body.functions.length > 0) return true; + return false; +} + +function requestRequiresStructuredOutput(body: Record): boolean { + const responseFormat = isRecord(body.response_format) ? body.response_format : null; + const type = typeof responseFormat?.type === "string" ? responseFormat.type : null; + return type === "json_object" || type === "json_schema"; +} + +function estimateRequestInputTokens(body: Record): number { + const estimatePayload: Record = {}; + for (const key of ["messages", "input", "tools", "functions", "response_format"]) { + if (hasEstimableContent(body[key])) estimatePayload[key] = body[key]; + } + return Object.keys(estimatePayload).length > 0 ? estimateTokens(estimatePayload) : 0; +} + +function getPositiveTokenCount(value: unknown): number { + const count = Number(value); + return Number.isFinite(count) && count > 0 ? Math.ceil(count) : 0; +} + +function isMediaTypeImage(value: Record): boolean { + const source = isRecord(value.source) ? value.source : null; + const mediaType = typeof source?.media_type === "string" ? source.media_type.toLowerCase() : ""; + return mediaType.startsWith("image/"); +} + +function valueContainsImagePart(value: unknown, depth = 0): boolean { + if (depth > 8 || value === null || value === undefined) return false; + if (typeof value === "string") return value.startsWith("data:image/"); + if (Array.isArray(value)) return value.some((entry) => valueContainsImagePart(entry, depth + 1)); + if (!isRecord(value)) return false; + + if (valueContainsImageType(value)) return true; + if (isMediaTypeImage(value)) return true; + + return Object.values(value).some((entry) => valueContainsImagePart(entry, depth + 1)); +} + +function isContextOverflow( + capabilities: { maxInputTokens: number | null; contextWindow: number | null }, + requirements: { requiredContextTokens: number } +): boolean { + return evaluateContextLimit( + { maxInputTokens: capabilities.maxInputTokens, contextWindow: capabilities.contextWindow }, + { estimatedInputTokens: requirements.requiredContextTokens, requiredContextTokens: requirements.requiredContextTokens } + ) === false; +} + +function valueContainsImageType(value: Record): boolean { + const type = typeof value.type === "string" ? value.type.toLowerCase() : null; + if (type === "image" || type === "image_url" || type === "input_image") return true; + if ("image_url" in value || "input_image" in value) return true; + return false; +} + +// ── Public API ───────────────────────────────────────────────────────────── + +/** + * Derive capability requirements from a request body. + * Mirrors `deriveRequestCompatibilityRequirements` in comboStructure.ts. + */ +export function deriveRequestCapabilityRequirements( + body: Record +): RequestCapabilityRequirements { + const estimatedInputTokens = estimateRequestInputTokens(body); + const requestedOutputTokens = Math.max( + getPositiveTokenCount(body.max_tokens), + getPositiveTokenCount(body.max_completion_tokens) + ); + return { + requiresTools: requestRequiresTools(body), + requiresVision: valueContainsImagePart(body.messages) || valueContainsImagePart(body.input), + requiresStructuredOutput: requestRequiresStructuredOutput(body), + requiredContextTokens: estimatedInputTokens + requestedOutputTokens, + toolCount: Array.isArray(body.tools) ? body.tools.length : 0, + }; +} + +/** + * Build a human-readable error message for a capability mismatch. + * Mirrors the i18n keys: capabilityFilter.visionMismatch / toolsMismatch / etc. + */ +export function buildCapabilityMismatchMessage( + terminalReason: string, + provider: string | null, + model: string | null +): string { + const msgs: Record = { + vision: `Provider '${provider}' does not support vision for this image request`, + tools: `Provider '${provider}' does not support tool calling`, + structured_output: `Provider '${provider}' does not support structured output`, + context_window: `Request exceeds the context window for ${provider}/${model}`, + }; + return msgs[terminalReason] || `Provider '${provider}' does not support the required capabilities`; +} + +/** + * Check whether a model's capabilities satisfy the request requirements. + * + * @param capabilities - Resolved model capabilities (from getResolvedModelCapabilities) + * @param requirements - Request capability requirements + * @param provider - Provider id or alias (needed for emulated-tool-calling bypass) + * @returns CapabilityFilterResult with compatibility verdict and failure details + */ +function collectCapabilityFailures( + capabilities: Record, + requirements: RequestCapabilityRequirements, + provider?: string | null +): CapabilityFailure[] { + const failures: CapabilityFailure[] = []; + const caps = capabilities as { + supportsTools: boolean | null; + toolCalling: boolean; + supportsVision: boolean | null; + structuredOutput: boolean | null; + contextWindow: number | null; + maxInputTokens: number | null; + maxOutputTokens: number | null; + }; + + if (requirements.requiresTools && (caps.supportsTools === false || !caps.toolCalling) + && !providerSupportsEmulatedToolCalling(provider)) { + failures.push("tools"); + } + if (requirements.requiresVision && caps.supportsVision !== true) { + failures.push("vision"); + } + if (requirements.requiresStructuredOutput && caps.structuredOutput === false) { + failures.push("structured_output"); + } + if (requirements.requiredContextTokens > 0 && isContextOverflow(caps, requirements)) { + failures.push("context_window"); + } + return failures; +} + +function primaryFailure(failures: CapabilityFailure[]): CapabilityFailure { + if (failures.includes("vision")) return "vision"; + if (failures.includes("tools")) return "tools"; + if (failures.includes("structured_output")) return "structured_output"; + return "context_window"; +} + +export function checkRequestCapabilityFit( + capabilities: { + supportsTools: boolean | null; + toolCalling: boolean; + supportsVision: boolean | null; + structuredOutput: boolean | null; + contextWindow: number | null; + maxInputTokens: number | null; + maxOutputTokens: number | null; + }, + requirements: RequestCapabilityRequirements, + provider?: string | null +): CapabilityFilterResult { + const failures = collectCapabilityFailures(capabilities as Record, requirements, provider); + if (failures.length === 0) { + return { compatible: true, failures: [] }; + } + return { compatible: false, failures, terminalReason: primaryFailure(failures) }; +} \ No newline at end of file diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index d08fc7e329..28f3432c81 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -233,6 +233,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: true, warningLevel: "info", }, + { + key: "CAPABILITY_FILTER_ENABLED", + label: "Capability Filter", + description: + "Reject requests before dispatch when the target model lacks required capabilities (vision, tools, structured output, context window). Protects direct single-provider requests that bypass the combo-layer compatibility filter.", + descriptionI18nKey: "featureFlagCapabilityFilterEnabledDescription", + category: "policies", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "caution", + }, // ──────────────── Runtime (15) ──────────────── { diff --git a/tests/unit/capability-filter.test.ts b/tests/unit/capability-filter.test.ts new file mode 100644 index 0000000000..5bcab12d76 --- /dev/null +++ b/tests/unit/capability-filter.test.ts @@ -0,0 +1,269 @@ +/** + * #5696 — Layer A capability filter unit tests. + * + * Tests the pure `checkRequestCapabilityFit` function and the + * `deriveRequestCapabilityRequirements` helper. The chatCore integration + * gate is tested via the feature flag assertion below. + * + * Note: `getResolvedModelCapabilities` requires a database connection, so + * the full integration path (capabilities → filter → error response) is + * tested by verifying the filter function's behavior with mock capabilities. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + checkRequestCapabilityFit, + deriveRequestCapabilityRequirements, + type RequestCapabilityRequirements, + type CapabilityFilterResult, +} from "../../src/shared/constants/capabilities/capabilityFilter.ts"; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +/** Minimal capabilities shape for filter testing. */ +function caps(overrides: Partial<{ + supportsTools: boolean | null; + toolCalling: boolean; + supportsVision: boolean | null; + structuredOutput: boolean | null; + contextWindow: number | null; + maxInputTokens: number | null; + maxOutputTokens: number | null; +}> = {}) { + return { + supportsTools: overrides.supportsTools ?? null, + toolCalling: overrides.toolCalling ?? true, + supportsVision: overrides.supportsVision ?? null, + structuredOutput: overrides.structuredOutput ?? null, + contextWindow: overrides.contextWindow ?? null, + maxInputTokens: overrides.maxInputTokens ?? null, + maxOutputTokens: overrides.maxOutputTokens ?? null, + }; +} + +function req(overrides: Partial = {}): RequestCapabilityRequirements { + return { + requiresTools: false, + requiresVision: false, + requiresStructuredOutput: false, + requiredContextTokens: 0, + toolCount: 0, + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +test("checkRequestCapabilityFit: compatible when no requirements", () => { + const result = checkRequestCapabilityFit(caps(), req()); + assert.equal(result.compatible, true); + assert.deepEqual(result.failures, []); +}); + +test("checkRequestCapabilityFit: vision failure when model lacks vision", () => { + const result = checkRequestCapabilityFit( + caps({ supportsVision: false }), + req({ requiresVision: true }) + ); + assert.equal(result.compatible, false); + assert.deepEqual(result.failures, ["vision"]); + assert.equal(result.terminalReason, "vision"); +}); + +test("checkRequestCapabilityFit: vision failure when model vision is unknown (null)", () => { + const result = checkRequestCapabilityFit( + caps({ supportsVision: null }), + req({ requiresVision: true }) + ); + assert.equal(result.compatible, false); + assert.deepEqual(result.failures, ["vision"]); + assert.equal(result.terminalReason, "vision"); +}); + +test("checkRequestCapabilityFit: vision OK when model supports vision", () => { + const result = checkRequestCapabilityFit( + caps({ supportsVision: true }), + req({ requiresVision: true }) + ); + assert.equal(result.compatible, true); + assert.deepEqual(result.failures, []); +}); + +test("checkRequestCapabilityFit: tools failure when model has no tool support", () => { + const result = checkRequestCapabilityFit( + caps({ supportsTools: false, toolCalling: false }), + req({ requiresTools: true }), + "openai" + ); + assert.equal(result.compatible, false); + assert.deepEqual(result.failures, ["tools"]); + assert.equal(result.terminalReason, "tools"); +}); + +test("checkRequestCapabilityFit: tools OK when model supports tools", () => { + const result = checkRequestCapabilityFit( + caps({ supportsTools: true, toolCalling: true }), + req({ requiresTools: true }), + "openai" + ); + assert.equal(result.compatible, true); + assert.deepEqual(result.failures, []); +}); + +test("checkRequestCapabilityFit: tools bypassed for emulated-tool provider", () => { + // chatgpt-web has toolCalling: "emulated" in the provider registry, + // so the filter must not reject it even when capabilities report false. + const result = checkRequestCapabilityFit( + caps({ supportsTools: false, toolCalling: false }), + req({ requiresTools: true }), + "chatgpt-web" + ); + assert.equal(result.compatible, true); + assert.deepEqual(result.failures, []); +}); + +test("checkRequestCapabilityFit: structured output failure when model does not support", () => { + const result = checkRequestCapabilityFit( + caps({ structuredOutput: false }), + req({ requiresStructuredOutput: true }) + ); + assert.equal(result.compatible, false); + assert.deepEqual(result.failures, ["structured_output"]); + assert.equal(result.terminalReason, "structured_output"); +}); + +test("checkRequestCapabilityFit: structured output OK when model supports", () => { + const result = checkRequestCapabilityFit( + caps({ structuredOutput: true }), + req({ requiresStructuredOutput: true }) + ); + assert.equal(result.compatible, true); + assert.deepEqual(result.failures, []); +}); + +test("checkRequestCapabilityFit: context window failure when tokens exceed window", () => { + const result = checkRequestCapabilityFit( + caps({ contextWindow: 1000, maxInputTokens: 1000 }), + req({ requiredContextTokens: 2000 }) + ); + assert.equal(result.compatible, false); + assert.deepEqual(result.failures, ["context_window"]); + assert.equal(result.terminalReason, "context_window"); +}); + +test("checkRequestCapabilityFit: context window OK when tokens fit", () => { + const result = checkRequestCapabilityFit( + caps({ contextWindow: 10000, maxInputTokens: 10000 }), + req({ requiredContextTokens: 2000 }) + ); + assert.equal(result.compatible, true); + assert.deepEqual(result.failures, []); +}); + +test("checkRequestCapabilityFit: multiple failures reported", () => { + const result = checkRequestCapabilityFit( + caps({ supportsVision: false, supportsTools: false, toolCalling: false }), + req({ requiresVision: true, requiresTools: true }), + "openai" + ); + assert.equal(result.compatible, false); + // vision is checked first, so it's the terminalReason + assert.ok(result.failures.length >= 1); + assert.ok(result.failures.includes("vision")); +}); + +test("checkRequestCapabilityFit: context window returns null (unknown) when no window data", () => { + // When contextWindow and maxInputTokens are both null, evaluateContextLimit + // returns null, which means compatible (no data to judge). + const result = checkRequestCapabilityFit( + caps({ contextWindow: null, maxInputTokens: null }), + req({ requiredContextTokens: 2000 }) + ); + assert.equal(result.compatible, true); + assert.deepEqual(result.failures, []); +}); + +test("deriveRequestCapabilityRequirements: no requirements from empty body", () => { + const requirements = deriveRequestCapabilityRequirements({}); + assert.equal(requirements.requiresTools, false); + assert.equal(requirements.requiresVision, false); + assert.equal(requirements.requiresStructuredOutput, false); + assert.equal(requirements.requiredContextTokens, 0); + assert.equal(requirements.toolCount, 0); +}); + +test("deriveRequestCapabilityRequirements: detects tools from body", () => { + const requirements = deriveRequestCapabilityRequirements({ + tools: [{ type: "function", function: { name: "test" } }], + }); + assert.equal(requirements.requiresTools, true); + assert.equal(requirements.toolCount, 1); +}); + +test("deriveRequestCapabilityRequirements: detects vision from image_url", () => { + const requirements = deriveRequestCapabilityRequirements({ + messages: [ + { role: "user", content: [{ type: "image_url", image_url: { url: "https://example.com/img.jpg" } }] }, + ], + }); + assert.equal(requirements.requiresVision, true); +}); + +test("deriveRequestCapabilityRequirements: detects structured output from response_format", () => { + const requirements = deriveRequestCapabilityRequirements({ + response_format: { type: "json_object" }, + }); + assert.equal(requirements.requiresStructuredOutput, true); +}); + +test("deriveRequestCapabilityRequirements: detects json_schema structured output", () => { + const requirements = deriveRequestCapabilityRequirements({ + response_format: { type: "json_schema", json_schema: { name: "test", schema: {} } }, + }); + assert.equal(requirements.requiresStructuredOutput, true); +}); + +test("feature flag CAPABILITY_FILTER_ENABLED defaults to false", () => { + // This test verifies the feature flag definition ensures the gate is + // opt-in. The default value must be "false" per the plan. + import("../../src/shared/constants/featureFlagDefinitions.ts").then( + ({ FEATURE_FLAG_DEFINITIONS }) => { + const flag = FEATURE_FLAG_DEFINITIONS.find( + (d) => d.key === "CAPABILITY_FILTER_ENABLED" + ); + assert.ok(flag, "CAPABILITY_FILTER_ENABLED flag must be defined"); + assert.equal(flag.defaultValue, "false"); + assert.equal(flag.type, "boolean"); + assert.equal(flag.category, "policies"); + } + ); +}); + +test("error responses use buildErrorBody and do not leak stack traces", () => { + // Verify that capability mismatch errors route through buildErrorBody + // (createErrorResult) and never contain stack traces. + import("../../open-sse/utils/error.ts").then(({ createErrorResult }) => { + const result = createErrorResult( + 400, + "Provider 'test' does not support vision for this image request", + null, + "vision", + "invalid_request_error" + ); + assert.equal(result.status, 400); + assert.equal(result.error, "Provider 'test' does not support vision for this image request"); + assert.equal(result.errorType, "invalid_request_error"); + assert.equal(result.errorCode, "vision"); + + // Parse the response body and assert no stack leak + result.response.text().then((text) => { + const body = JSON.parse(text); + assert.ok(body.error.message, "error message must exist"); + assert.equal(body.error.message.includes("at /"), false, "must not leak stack traces"); + assert.equal(body.error.code, "vision"); + assert.equal(body.error.type, "invalid_request_error"); + }); + }); +}); \ No newline at end of file From 02dd5e723e8e0563a9a55873c46b21eb101d3f8b Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 5 Aug 2026 02:39:54 -0300 Subject: [PATCH 005/100] feat(ci): add windows-latest leg to test-bun-sqlite job (#8468) Add a windows-latest matrix leg to the test-bun-sqlite CI job with continue-on-error: true for advisory Windows+Bun coverage. Update CLAUDE.md Bun section to note the advisory Windows leg. --- .github/workflows/ci.yml | 16 +++++++++++++++- CLAUDE.md | 2 +- .../features/8468-bun-windows-ci-coverage.md | 1 + 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 changelog.d/features/8468-bun-windows-ci-coverage.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 542f44b973..9ce454b0af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -811,7 +811,12 @@ jobs: test-bun-sqlite: name: Bun SQLite Compatibility - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + fail-fast: false + runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.os == 'windows-latest' }} timeout-minutes: 10 needs: changes if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }} @@ -824,6 +829,15 @@ jobs: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - uses: ./.github/actions/npm-ci-retry + - name: Install Bun (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + powershell -c "iwr bun.sh/install.ps1 -useb | iex" + echo "$env:USERPROFILE\.bun\bin" | Out-File -FilePath $env:GITHUB_PATH -Append + - name: Install Bun (non-Windows) + if: runner.os != 'Windows' + run: npm install -g bun - run: npm run test:bun:db test-vitest: diff --git a/CLAUDE.md b/CLAUDE.md index 170bbb08d0..11375f5350 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -491,7 +491,7 @@ list` shows worktrees you didn't create, leave them alone. End every session wit ## Environment - **Runtime**: Node.js ≥22.0.0 <23 || ≥24.0.0 <27, ES Modules. This is the **only supported** runtime for the published `omniroute` CLI, the server, and the test suites (`node:test` + vitest) — `engines.node` is authoritative and end users never need Bun. A **best-effort `bun:sqlite` compatibility path** exists so a global Bun install (`bun install -g omniroute`) can start without `better-sqlite3` (driver adapter + Bun-aware process spawning); it is **not** a supported runtime — no support guarantees — and every Bun-specific runtime change MUST preserve the Node driver/fallback chain and ship a Bun test (`test:bun:db`) or an explicit reason why the path is Node-only. -- **Bun (build/dev script runner + compatibility smoke only)**: Bun `1.3.14` is pinned as an **exact devDependency** (provisioned through the existing `npm ci` via the lockfile's `@oven/bun-*` platform binaries — no `setup-bun`/ad-hoc install). It is used **only** to execute a small, allow-listed set of TypeScript **gate/generator scripts** (replacing `node --import tsx` for startup speed): the CI checks `check:provider-consistency`, `check:compression-budget`, `check:known-symbols`, and the non-CI `gen:provider-reference`, `bench:compression` — plus the focused `test:bun:db` compatibility smoke suite for the best-effort `bun:sqlite` path. **Do NOT** widen Bun to `npm install`, the build (`build:cli*`), `check:pack-artifact`, the supported published runtime, or the main test runners — those stay on Node. Any new Bun-invoking gate/generator script must be validated byte-identical against its `node --import tsx` output first. After pulling the lockfile change, run `npm install` so `bun` resolves locally (a stale `node_modules` will fail those scripts with `bun: not found`). +- **Bun (build/dev script runner + compatibility smoke only)**: Bun `1.3.14` is pinned as an **exact devDependency** (provisioned through the existing `npm ci` via the lockfile's `@oven/bun-*` platform binaries — no `setup-bun`/ad-hoc install). It is used **only** to execute a small, allow-listed set of TypeScript **gate/generator scripts** (replacing `node --import tsx` for startup speed): the CI checks `check:provider-consistency`, `check:compression-budget`, `check:known-symbols`, and the non-CI `gen:provider-reference`, `bench:compression` — plus the focused `test:bun:db` compatibility smoke suite for the best-effort `bun:sqlite` path. **Do NOT** widen Bun to `npm install`, the build (`build:cli*`), `check:pack-artifact`, the supported published runtime, or the main test runners — those stay on Node. Any new Bun-invoking gate/generator script must be validated byte-identical against its `node --import tsx` output first. After pulling the lockfile change, run `npm install` so `bun` resolves locally (a stale `node_modules` will fail those scripts with `bun: not found`). The `test-bun-sqlite` CI job includes a `windows-latest` matrix leg with `continue-on-error: true` for advisory Windows+Bun coverage (#8468). - **TypeScript**: 6.0+, target ES2022, module esnext, resolution bundler - **Path aliases**: `@/*` → `src/`, `@omniroute/open-sse` → `open-sse/`, `@omniroute/open-sse/*` → `open-sse/*` - **Default port**: 20128 (API + dashboard on same port) diff --git a/changelog.d/features/8468-bun-windows-ci-coverage.md b/changelog.d/features/8468-bun-windows-ci-coverage.md new file mode 100644 index 0000000000..48c4f100cc --- /dev/null +++ b/changelog.d/features/8468-bun-windows-ci-coverage.md @@ -0,0 +1 @@ +- feat(ci): add windows-latest leg to test-bun-sqlite job (#8468) From 4dbbaeb746942de541533e3a5a549da354cbf9bb Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 5 Aug 2026 19:55:14 -0300 Subject: [PATCH 006/100] test(mutation): register capability-filter.test.ts in stryker tap.testFiles The mutation test-coverage drift gate (check:mutation-test-coverage --strict) failed because tests/unit/capability-filter.test.ts covers open-sse/utils/error.ts (a mutated module) but was missing from stryker.conf.json tap.testFiles. --- stryker.conf.json | 1 + 1 file changed, 1 insertion(+) diff --git a/stryker.conf.json b/stryker.conf.json index 191104f480..28ee700191 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -84,6 +84,7 @@ "tests/unit/bug-7940-gemini-retrydelay.test.ts", "tests/unit/build/check-circular-deps.test.ts", "tests/unit/cache-sweeps.test.ts", + "tests/unit/capability-filter.test.ts", "tests/unit/chat-adaptive-admission-binding.test.ts", "tests/unit/cc-bridge-openai-image-7777.test.ts", "tests/unit/cc-compatible-provider.test.ts", From 034db3c3dd976ed1f4e065dc3d2fbdada88d84be Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Thu, 6 Aug 2026 10:27:10 -0400 Subject: [PATCH 007/100] fix(quality): clears two release/v3.8.50 base-red gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unblocks Merge integrity and Docs Gates for every PR against release/v3.8.50, not just this branch: - changelog.d/features/9415-newapi-sub2api-aggregator-balance.md had a non-standard YAML frontmatter header that no other fragment in the tree uses. check-changelog-integrity.mjs reads a fragment's first non-blank line to validate it starts with a markdown bullet; the frontmatter's leading `---` made that check fail regardless of the actual bullet content further down. Removed the frontmatter and reformatted the body to match the documented changelog.d/README.md bullet convention. - docs/ops/VM_DEPLOYMENT_GUIDE.md documented OMNIROUTE_MAX_POOL_SIZE and OMNIROUTE_DB_POOL_SIZE as tunable env vars, but neither is read anywhere in the codebase (confirmed via full-repo grep) — this repo uses SQLite, which has no connection-pool concept these vars could plausibly control. check:fabricated-docs --strict correctly flags fabricated env-var claims; removed the bullet rather than implementing a feature to match invented documentation. --- .../features/9415-newapi-sub2api-aggregator-balance.md | 7 +------ docs/ops/VM_DEPLOYMENT_GUIDE.md | 1 - 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/changelog.d/features/9415-newapi-sub2api-aggregator-balance.md b/changelog.d/features/9415-newapi-sub2api-aggregator-balance.md index 421c33b198..e2317e3cb8 100644 --- a/changelog.d/features/9415-newapi-sub2api-aggregator-balance.md +++ b/changelog.d/features/9415-newapi-sub2api-aggregator-balance.md @@ -1,6 +1 @@ ---- -kind: feature -ref: "#9415" ---- - -New-API / One-API / Sub2API aggregator balance detection for compatible nodes. When a compatible provider node has the "Aggregator Gateway" toggle enabled, OmniRoute will query the aggregator's `/api/user/self` endpoint to detect the account balance. The dashboard shows the balance badge and quota-preflight routing skips exhausted accounts. The feature is gated by the `NEWAPI_AGGREGATOR_BALANCE` feature flag (default: off). A custom `quotaPerUnit` override is supported for aggregators that use a different rate than the default 500000 units/$1. +- **feat(sse):** New-API/One-API/Sub2API aggregator balance detection for compatible provider nodes — when the "Aggregator Gateway" toggle is enabled, OmniRoute queries the aggregator's `/api/user/self` endpoint to detect the account balance; the dashboard shows a balance badge and quota-preflight routing skips exhausted accounts. Gated by the `NEWAPI_AGGREGATOR_BALANCE` feature flag (default: off), with a custom `quotaPerUnit` override for aggregators that use a different rate than the default 500000 units/$1 ([#9415](https://github.com/diegosouzapw/OmniRoute/issues/9415)) diff --git a/docs/ops/VM_DEPLOYMENT_GUIDE.md b/docs/ops/VM_DEPLOYMENT_GUIDE.md index 3a885626b0..69e3ac4209 100644 --- a/docs/ops/VM_DEPLOYMENT_GUIDE.md +++ b/docs/ops/VM_DEPLOYMENT_GUIDE.md @@ -429,6 +429,5 @@ For deployments on small VPS instances (1 GB RAM or less): - **Disable background services** — set `OMNIROUTE_DISABLE_BACKGROUND_SERVICES=1` to skip scheduler, MCP server, and periodic maintenance tasks. See `docs/reference/ENVIRONMENT.md`. - **Use SQLite WAL mode** — enabled by default, reduces peak memory during concurrent reads. -- **Limit connection concurrency** — reduce `OMNIROUTE_MAX_POOL_SIZE` and `OMNIROUTE_DB_POOL_SIZE` in your environment. - **Avoid `next build` on the VPS** — build locally and deploy the standalone output (`.next/standalone/`). - **Monitor with `top` / `free -m`** — OmniRoute typically uses 200-400 MB RSS at idle on a 1 GB VM. From f1fda940477ee05ec7cf8ff8c36b0b306123bec9 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Thu, 6 Aug 2026 11:39:08 -0400 Subject: [PATCH 008/100] fix(i18n): completes Vietnamese parity, fixes empty migration query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more release/v3.8.50 base-red items, both surfaced while chasing CI failures on unrelated PRs: - vi.json was missing 8 keys that #9539 (NewAPI/Sub2API aggregator balance) added to en.json without a matching i18n:sync-ui run — pt-BR.json already had all 8, only Vietnamese drifted. Added translations for the 6 provider-settings strings, the feature-flag description, and the quota tooltip; verified against tests/unit/i18n-vi-completeness.test.ts (parity, placeholder preservation, ICU parse — all 5 assertions pass). - src/lib/db/migrations/120_interception_rules.sql was pure comments documenting a no-schema-change key_value namespace, with no executable SQL statement — the migration runner logged "FAILED: 120_interception_rules — Query contained no valid SQL statement" on every fresh DB init. 118_provider_param_filters.sql (same pattern, two migrations earlier) already ends with a bare `SELECT 1;` no-op for exactly this reason; 120 was just missing it. Verified directly against better-sqlite3 that the file now executes without error. --- src/i18n/messages/vi.json | 8 ++++++++ src/lib/db/migrations/120_interception_rules.sql | 1 + 2 files changed, 9 insertions(+) diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index f6333012d9..de7cbfc75d 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -5475,6 +5475,13 @@ "newApiUserIdLabel": "ID người dùng New-API", "newApiUserIdPlaceholder": "vd. 12345", "newApiUserIdHint": "Giá trị tiêu đề New-Api-User của AgentRouter, dùng cùng với khóa API console để lấy số dư hạn mức.", + "newApiAggregatorToggleLabel": "Cổng tổng hợp", + "newApiAggregatorToggleHint": "Bật tính năng phát hiện số dư cho các node tổng hợp New-API / One-API / Sub2API. Bảng điều khiển sẽ hiển thị huy hiệu số dư và định tuyến kiểm tra hạn mức trước sẽ bỏ qua các tài khoản đã hết hạn mức.", + "newApiAggregatorConsoleApiKeyHint": "Token truy cập hệ thống cho endpoint /api/user/self của bộ tổng hợp. Không phải là khóa API định tuyến.", + "newApiAggregatorUserIdHint": "Giá trị tiêu đề New-Api-User dùng để lấy số dư hạn mức của người dùng bộ tổng hợp.", + "newApiAggregatorQuotaPerUnitLabel": "Hạn mức trên mỗi đơn vị", + "newApiAggregatorQuotaPerUnitHint": "Số đơn vị tín dụng New-API trên mỗi $1 (mặc định: 500000). Ghi đè nếu bộ tổng hợp của bạn sử dụng tỷ lệ khác.", + "featureFlagNewApiAggregatorBalanceDescription": "Bật tính năng phát hiện số dư cho các node tương thích với bộ tổng hợp New-API / One-API / Sub2API", "cpaModeDisabledTitle": "Chế độ tương thích CLIProxyAPI đã bị tắt", "cpaModeEnabledTitle": "Chế độ tương thích CLIProxyAPI đã được bật", "customUserAgentHint": "Gợi ý User Agent tùy chỉnh", @@ -5590,6 +5597,7 @@ "tagGroupPlaceholder": "Nhập nhóm thẻ...", "testModel": "Kiểm tra mô hình", "testingModel": "Đang kiểm tra mô hình", + "modelTestQuotaTooltip": "Đã hết hạn mức — sẽ được đặt lại vào ngày mai hoặc cần nạp thêm", "toggleOffShort": "Tắt", "toggleOnShort": "Bật", "tokenExpiredBadge": "Nhãn token đã hết hạn", diff --git a/src/lib/db/migrations/120_interception_rules.sql b/src/lib/db/migrations/120_interception_rules.sql index d042a5f035..7e7e1593be 100644 --- a/src/lib/db/migrations/120_interception_rules.sql +++ b/src/lib/db/migrations/120_interception_rules.sql @@ -14,3 +14,4 @@ -- falls back to the existing native web-search-bypass defaults in webSearchFallback.ts). -- -- See: src/lib/db/interceptionRules.ts +SELECT 1; From 3ea174d5316895a15fbdb04c3c7d8c255807f32a Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Thu, 6 Aug 2026 13:08:34 -0400 Subject: [PATCH 009/100] fix(types): clears 6 pre-existing release/v3.8.50 typecheck errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typecheck:core is its own blocking CI job (quality.yml), separate from Docs Gates/Merge integrity. Confirmed pre-existing and unrelated to any current work by branching this worktree directly from upstream/release/v3.8.50 with no other merges applied. - accountSemaphore.ts: isBypassed() already excludes null/<=0 maxConcurrency before ensureGate() is called, but a boolean- returning helper isn't a type predicate TS can narrow through. Added a targeted `as number` at the one call site, with a comment explaining why it's safe. - combo/comboStructure.ts: two module-scope `const HARD_COMPAT_REASONS` declarations with different values — a genuine "can't redeclare" compile error, not a narrowing gap. The first (4-item set including "output_tokens") had zero usages between its own declaration and the second; the second (3-item set, matching the CompatFilterOptions doc comment exactly) is what hasHardCapabilityFailure/ describeCapabilityFilterExhaustion/the third call site all actually use. Removed the dead first declaration. - combo/comboStructure.ts + combo/fusionPanel.ts: both accessed `.prompt`/`.model` on a `ComboModelStep | ComboProviderWildcardStep` union after only excluding `combo-ref`, but `ComboProviderWildcardStep` has neither field — a real latent bug (fusionPanel would have pushed `undefined` into a fusion panel for a wildcard step). Narrowed to `step.kind === "model"` in comboStructure, and switched to the already-existing `getComboModelString()` helper in fusionPanel (which correctly resolves to null for unsupported step kinds, mirroring how combo-ref is already skipped there). Verified directly via a standalone script exercising both branches (wildcard vs. model step). - combo/quotaStrategies.ts: imported `preferAntigravityConnectionsWithStoredProject` from a module that never existed (`../antigravityProjectPersistence.ts`, distinct from the real `antigravityProjectPersist.ts`) — the function itself was referenced nowhere else in the codebase. Wrote the missing implementation: prefers Antigravity connections with a discovered `projectId` for reset-aware routing, failing open to the full list when none have one yet (per the file's own "Exclude... from reset-aware pool" changelog note, softened to a preference — strict exclusion would empty the pool entirely for a fleet of freshly-added accounts). Verified directly via a standalone script. - compression/engines/ccr/index.ts: `enforceGlobalBudget(owner, bytes)` was called with only `bytes` at one of its two call sites, missing the `owner` argument the other call site (and the function's own doc comment on preferring the calling principal's LRU eviction) already uses correctly. Added the missing `entry.principalId` argument. - firecrawlQuotaFetcher.ts: `fetchFirecrawlQuota` was annotated to return `Promise` but every return path constructs a `FirecrawlQuota` (QuotaInfo extended with remainingCredits/planCredits/ extraCreditsInferred/overPlan) — the type the file already defines and the type `parseFirecrawlCreditUsage` already correctly returns. Widened the annotation to match; `FirecrawlQuota extends QuotaInfo` so this stays compatible with the `QuotaFetcher` contract. npm run typecheck:core and npm run check:dashboard-typecheck both pass cleanly. A subset of DB-backed tests in this area also fail, but 100% attributably to an already-tracked, unrelated migration version collision (134 -> [ccr_blocks, proxy_logs_egress_ip], see _tasks/features-v3.8.4/9route/POST-MERGE-AUDIT.md) — confirmed by every failure's stack trace bottoming out at that exact error, not at anything touched here. --- open-sse/services/accountSemaphore.ts | 4 ++- .../services/antigravityProjectPersistence.ts | 35 +++++++++++++++++++ open-sse/services/combo/comboStructure.ts | 4 +-- open-sse/services/combo/fusionPanel.ts | 8 +++-- open-sse/services/combo/quotaStrategies.ts | 4 +-- .../services/compression/engines/ccr/index.ts | 5 ++- open-sse/services/firecrawlQuotaFetcher.ts | 2 +- 7 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 open-sse/services/antigravityProjectPersistence.ts diff --git a/open-sse/services/accountSemaphore.ts b/open-sse/services/accountSemaphore.ts index ddb629e12e..ec0f06f090 100644 --- a/open-sse/services/accountSemaphore.ts +++ b/open-sse/services/accountSemaphore.ts @@ -200,7 +200,9 @@ export function acquire( return Promise.reject(makeAbortError(signal)); } - const gate = ensureGate(semaphoreKey, maxConcurrency); + // isBypassed() above already excluded null/<=0 — ensureGate requires a plain + // number, but a boolean-returning helper isn't a type predicate TS can narrow on. + const gate = ensureGate(semaphoreKey, maxConcurrency as number); clearCleanupTimer(gate); if (gate.running < gate.maxConcurrency && !isBlocked(gate)) { diff --git a/open-sse/services/antigravityProjectPersistence.ts b/open-sse/services/antigravityProjectPersistence.ts new file mode 100644 index 0000000000..066179a890 --- /dev/null +++ b/open-sse/services/antigravityProjectPersistence.ts @@ -0,0 +1,35 @@ +/** + * Prefer Antigravity connections with a discovered/stored `projectId` for + * reset-aware quota routing (#7719 follow-up). + * + * Antigravity's Code Assist API is scoped per-project — a connection whose + * `projectId` was never discovered (no `loadCodeAssist` round-trip has + * completed yet, see antigravityProjectPersist.ts) cannot serve a request + * reliably. Preferring connections that already have one avoids routing + * reset-aware traffic to an account that will just re-trigger discovery. + * + * This is a preference, not a hard requirement: if none of the candidate + * connections have a stored projectId yet (e.g. a freshly added account), + * excluding all of them would empty the reset-aware pool entirely, which is + * worse than routing to an undiscovered connection. Fail open to the full + * list in that case. + */ + +function hasStoredProjectId(connection: Record): boolean { + if (typeof connection.projectId === "string" && connection.projectId.trim().length > 0) { + return true; + } + const providerSpecificData = connection.providerSpecificData; + if (providerSpecificData && typeof providerSpecificData === "object") { + const nested = (providerSpecificData as Record).projectId; + if (typeof nested === "string" && nested.trim().length > 0) return true; + } + return false; +} + +export function preferAntigravityConnectionsWithStoredProject< + T extends Record, +>(connections: T[]): T[] { + const withStoredProject = connections.filter(hasStoredProjectId); + return withStoredProject.length > 0 ? withStoredProject : connections; +} diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index 58a8b99538..5bb4bbaaf3 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -137,7 +137,7 @@ function normalizeRuntimeStep( : {}), weight, label, - prompt: step.prompt || null, + prompt: step.kind === "model" ? step.prompt || null : null, } satisfies ResolvedComboTarget; } @@ -533,8 +533,6 @@ function hasKnownCompatibleContextLimit( return evaluateContextLimit(capabilities, requirements, target.modelStr) === true; } -const HARD_COMPAT_REASONS = new Set(["tools", "vision", "structured_output", "output_tokens"]); - /** * #8332: vision is a hard requirement, not a soft preference — a target whose vision * support is not confirmed can never succeed on an image_url request. Callers diff --git a/open-sse/services/combo/fusionPanel.ts b/open-sse/services/combo/fusionPanel.ts index 6397c5120c..20540d5850 100644 --- a/open-sse/services/combo/fusionPanel.ts +++ b/open-sse/services/combo/fusionPanel.ts @@ -10,7 +10,7 @@ * literal `auto/*` string panel member already behaves via the single- * dispatch safety net in src/sse/handlers/chat.ts. */ -import { normalizeComboStep } from "../../../src/lib/combos/steps.ts"; +import { getComboModelString, normalizeComboStep } from "../../../src/lib/combos/steps.ts"; import { executeComboRefUnit } from "./runtimeUnits.ts"; import type { ComboCollectionLike, @@ -51,7 +51,11 @@ export function extractFusionPanelSpec( panel.push(step.comboName); return; } - panel.push(step.model); + // Provider-wildcard steps have no concrete model to dispatch — fusion is a + // fixed-size panel of literal models/combo-refs, not a wildcard-expanding + // strategy (see file header). Skip rather than push an undefined model. + const modelStr = getComboModelString(step); + if (modelStr) panel.push(modelStr); }); return { panel, comboRefUnits }; } diff --git a/open-sse/services/combo/quotaStrategies.ts b/open-sse/services/combo/quotaStrategies.ts index 2e117f74fe..822ee57409 100644 --- a/open-sse/services/combo/quotaStrategies.ts +++ b/open-sse/services/combo/quotaStrategies.ts @@ -89,9 +89,7 @@ async function getQuotaAwareConnectionsForTarget( ? (connections as Array>) : []; if (provider === "antigravity" || provider === "agy") { - activeConnections = preferAntigravityConnectionsWithStoredProject( - activeConnections - ) as Array>; + activeConnections = preferAntigravityConnectionsWithStoredProject(activeConnections); } if ( !resetAwareConnectionCache.has(provider) && diff --git a/open-sse/services/compression/engines/ccr/index.ts b/open-sse/services/compression/engines/ccr/index.ts index e854666182..6d8d1e2f03 100644 --- a/open-sse/services/compression/engines/ccr/index.ts +++ b/open-sse/services/compression/engines/ccr/index.ts @@ -292,7 +292,10 @@ function rehydrateEntry(hash: string, principalId: string, now: number): CcrEntr // Re-admit through the same budgets a fresh store would face. If the block no longer // fits, it stays on disk and is served straight from the row instead of being cached. - if (enforcePrincipalBudget(entry.principalId, entry.bytes) && enforceGlobalBudget(entry.bytes)) { + if ( + enforcePrincipalBudget(entry.principalId, entry.bytes) && + enforceGlobalBudget(entry.principalId, entry.bytes) + ) { const key = buildStoreKey(hash, principalId === ANON ? undefined : principalId); ccrStore.set(key, entry); ccrTotalBytes += entry.bytes; diff --git a/open-sse/services/firecrawlQuotaFetcher.ts b/open-sse/services/firecrawlQuotaFetcher.ts index 9f0784fa06..92a8bb0a87 100644 --- a/open-sse/services/firecrawlQuotaFetcher.ts +++ b/open-sse/services/firecrawlQuotaFetcher.ts @@ -120,7 +120,7 @@ export function getFirecrawlBaseUrl(connection?: Record): strin export async function fetchFirecrawlQuota( connectionId: string, connection?: Record -): Promise { +): Promise { const cached = quotaCache.get(connectionId); if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { return cached.quota; From 3b411c7da7c1081b01a22ad772a87e11da1a978b Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Thu, 6 Aug 2026 14:08:39 -0400 Subject: [PATCH 010/100] ci: re-trigger checks after transient runner shutdown From 9233a9483cab1d0cd3ecbd7b1584bb18d9de4a97 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 6 Aug 2026 18:58:51 -0300 Subject: [PATCH 011/100] fix(deps): bump transitive deps for 6 Dependabot + remaining audit vulns on main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same overrides as #9464 (ip-address, hono, fast-uri, socket.io-parser, undici) applied directly to main. Also covers brace-expansion (scoped), js-yaml v4 copies, and mermaid. npm audit: 6→0 vulnerabilities. Closes Dependabot #161-#166. --- package-lock.json | 391 ++++++---------------------------------------- package.json | 44 ++++-- 2 files changed, 87 insertions(+), 348 deletions(-) diff --git a/package-lock.json b/package-lock.json index 72985a4780..71c0d85d8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -103,7 +103,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/better-sqlite3": "^7.6.13", - "@types/bun": "*", + "@types/bun": "latest", "@types/node": "^26.1.0", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", @@ -461,9 +461,9 @@ } }, "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -3074,9 +3074,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -5894,29 +5894,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@npmcli/arborist/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@npmcli/arborist/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@npmcli/arborist/node_modules/lru-cache": { "version": "11.5.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", @@ -6110,29 +6087,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@npmcli/map-workspaces/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@npmcli/map-workspaces/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@npmcli/map-workspaces/node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -10088,29 +10042,6 @@ "node": ">=20.0.0" } }, - "node_modules/@stryker-mutator/core/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@stryker-mutator/core/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@stryker-mutator/core/node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -11302,29 +11233,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@tufjs/models/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@tufjs/models/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@tufjs/models/node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -12133,29 +12041,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -12825,9 +12710,9 @@ } }, "node_modules/@yarnpkg/parsers/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -14156,9 +14041,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -18512,29 +18397,6 @@ "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" } }, - "node_modules/eslint-plugin-sonarjs/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/eslint-plugin-sonarjs/node_modules/globals": { "version": "17.7.0", "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", @@ -19181,9 +19043,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -20281,29 +20143,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/glob/node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -21116,9 +20955,9 @@ "license": "MIT" }, "node_modules/hono": { - "version": "4.12.31", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", - "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", + "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -21807,29 +21646,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/ignore-walk/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/ignore-walk/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/ignore-walk/node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -22703,9 +22519,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -23805,9 +23621,9 @@ } }, "node_modules/jsdom/node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { @@ -24081,29 +23897,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/junit-to-ctrf/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/junit-to-ctrf/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/junit-to-ctrf/node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -24684,17 +24477,6 @@ "node": ">= 14" } }, - "node_modules/libxmljs2/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/libxmljs2/node_modules/cacache": { "version": "19.0.1", "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", @@ -25480,9 +25262,9 @@ } }, "node_modules/lockfile-lint/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -26289,9 +26071,9 @@ } }, "node_modules/mermaid": { - "version": "11.16.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz", - "integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==", + "version": "11.16.1", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.1.tgz", + "integrity": "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.2", @@ -28272,9 +28054,9 @@ } }, "node_modules/node-gyp/node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "dev": true, "license": "MIT", "engines": { @@ -30588,29 +30370,6 @@ "sharp": "^0.34.5" } }, - "node_modules/promptfoo/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/promptfoo/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/promptfoo/node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -30866,9 +30625,9 @@ } }, "node_modules/promptfoo/node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { @@ -30911,9 +30670,9 @@ "license": "ISC" }, "node_modules/protobufjs": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", - "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", "devOptional": true, "hasInstallScript": true, "license": "BSD-3-Clause", @@ -32265,9 +32024,9 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -33290,9 +33049,9 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", - "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", "dev": true, "license": "MIT", "dependencies": { @@ -34341,9 +34100,9 @@ } }, "node_modules/tar": { - "version": "7.5.20", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", - "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "devOptional": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -34434,29 +34193,6 @@ "node": "20 || >=22" } }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/test-exclude/node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -34987,29 +34723,6 @@ "typescript": "2 || 3 || 4 || 5" } }, - "node_modules/type-coverage-core/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/type-coverage-core/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/type-coverage-core/node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -36737,9 +36450,9 @@ } }, "node_modules/xmlbuilder2/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index ad54771371..a9904548e0 100644 --- a/package.json +++ b/package.json @@ -399,25 +399,25 @@ "fast-xml-parser": "^5.10.1", "sharp": "^0.35.0", "postcss": "^8.5.18", - "ip-address": "10.2.0", + "ip-address": "^10.3.1", "qs": "^6.15.2", "uuid": "^14.0.0", "form-data": "^4.0.6", "vite": "^8.0.16", - "protobufjs": "^7.6.3", + "protobufjs": "^7.6.5", "@babel/core": "^7.29.6", - "hono": "^4.12.27", + "hono": "^4.12.34", "@hono/node-server": "^2.0.5", - "fast-uri": "^3.1.3", + "fast-uri": "^3.1.5", "body-parser": "^2.3.0", "@yarnpkg/parsers": { - "js-yaml": "^4.2.0" + "js-yaml": "^4.3.1" }, "jsdom": { - "undici": "^7.28.0" + "undici": "^7.29.0" }, "node-gyp": { - "undici": "^6.27.0" + "undici": "^6.28.0" }, "concurrently": { "shell-quote": "^1.9.0" @@ -425,9 +425,35 @@ "adm-zip": "^0.6.0", "promptfoo": { "js-yaml": "^5.2.2", - "@apidevtools/json-schema-ref-parser": { - "js-yaml": "^4.2.0" + "undici": "^7.29.0" + }, + "socket.io-parser": "^4.2.7", + "tar": "^7.5.21", + "brace-expansion": "^5.0.9", + "minimatch": { + "brace-expansion": "^1.1.18" + }, + "libxmljs2": { + "minimatch": { + "brace-expansion": "^2.1.4" } + }, + "rimraf": { + "minimatch": { + "brace-expansion": "^2.1.4" + } + }, + "@apidevtools/json-schema-ref-parser": { + "js-yaml": "^4.3.1" + }, + "@eslint/eslintrc": { + "js-yaml": "^4.3.1" + }, + "lockfile-lint": { + "js-yaml": "^4.3.1" + }, + "xmlbuilder2": { + "js-yaml": "^4.3.1" } } } From cf7e4148c5ef6968425f9abe93ebe41c2d289701 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 7 Aug 2026 10:56:52 -0400 Subject: [PATCH 012/100] ci: re-trigger checks after GitHub Actions incident (2026-08-07, resolved) From 7a0515038b2aa3061d5b7e0ddfa1c5d85c3b3cb0 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 7 Aug 2026 15:05:03 -0400 Subject: [PATCH 013/100] ci: re-trigger checks (previous push event was dropped) From 038035f9373da019ada9aaa2ae8e9aa869a1cba5 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 7 Aug 2026 15:50:53 -0400 Subject: [PATCH 014/100] fix(sse): update stale ALL_ACCOUNTS_INACTIVE test assertions to ALL_TARGETS_SKIPPED Two combo-routing-engine.test.ts cases assert the pre-dispatch-skip scenario (isModelAvailable always false, zero dispatch attempts) returns ALL_ACCOUNTS_INACTIVE. Production code already distinguishes this case via the recordedAttempts === 0 branch and returns the more precise ALL_TARGETS_SKIPPED -- the tests were never updated when that branch shipped upstream, so they fail on a clean release/v3.8.50 checkout independent of this PR's changes. --- tests/unit/combo-routing-engine.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts index 42cf4d03ef..a13cbb1d7b 100644 --- a/tests/unit/combo-routing-engine.test.ts +++ b/tests/unit/combo-routing-engine.test.ts @@ -2318,7 +2318,11 @@ test("handleComboChat returns a 503 when every model is unavailable before execu const payload = (await result.json()) as any; assert.equal(result.status, 503); - assert.equal(payload.error.code, "ALL_ACCOUNTS_INACTIVE"); + // isModelAvailable always false means every target is skipped by the + // pre-dispatch filter with zero dispatch attempts — the more precise + // ALL_TARGETS_SKIPPED classification, not ALL_ACCOUNTS_INACTIVE (which + // implies targets were attempted and their accounts found inactive). + assert.equal(payload.error.code, "ALL_TARGETS_SKIPPED"); }); test("handleComboChat treats provider circuit breaker responses as ordinary target failures", async () => { @@ -2847,7 +2851,10 @@ test("handleComboChat round-robin resolves nested combos and returns inactive wh const payload = (await result.json()) as any; assert.equal(result.status, 503); - assert.equal(payload.error.code, "ALL_ACCOUNTS_INACTIVE"); + // isModelAvailable always false means every nested target is skipped by the + // pre-dispatch filter with zero dispatch attempts — ALL_TARGETS_SKIPPED, + // not ALL_ACCOUNTS_INACTIVE (see the analogous priority-strategy test above). + assert.equal(payload.error.code, "ALL_TARGETS_SKIPPED"); }); test("handleComboChat round-robin treats provider circuit breaker responses as ordinary target failures", async () => { From 58ab721fe2615ba4f7819c0f6393f8154278554d Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Fri, 7 Aug 2026 16:11:08 -0400 Subject: [PATCH 015/100] fix(sse): update second stale ALL_ACCOUNTS_INACTIVE assertion (T24) Same pre-existing upstream test-drift as 038035f93: t23-t24-fallback-resilience.test.ts's T24 case asserts the pre-dispatch-skip scenario returns ALL_ACCOUNTS_INACTIVE, but production code returns the more precise ALL_TARGETS_SKIPPED when recordedAttempts === 0. Caught by this PR's own fresh CI run after the dirty-mergeable-state fix. --- tests/unit/t23-t24-fallback-resilience.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/t23-t24-fallback-resilience.test.ts b/tests/unit/t23-t24-fallback-resilience.test.ts index 6682581e69..22f9c5d7da 100644 --- a/tests/unit/t23-t24-fallback-resilience.test.ts +++ b/tests/unit/t23-t24-fallback-resilience.test.ts @@ -148,7 +148,11 @@ test("T24: all inactive accounts return 503 service_unavailable (not 406)", asyn assert.equal(result.status, 503); const body = (await result.json()) as any; - assert.equal(body.error?.code, "ALL_ACCOUNTS_INACTIVE"); + // isModelAvailable always false means every target is skipped by the + // pre-dispatch filter with zero dispatch attempts — the more precise + // ALL_TARGETS_SKIPPED classification, not ALL_ACCOUNTS_INACTIVE (which + // implies targets were attempted and their accounts found inactive). + assert.equal(body.error?.code, "ALL_TARGETS_SKIPPED"); }); test("combo falls through 400s and reaches the next model", async () => { From 390690dd0abe672ac8436d4d9598ae0d883e7bc9 Mon Sep 17 00:00:00 2001 From: benzntech Date: Sat, 8 Aug 2026 08:19:41 +0530 Subject: [PATCH 016/100] fix(logging): make stream-chunk capture and request-shape logging opt-in Flip two heavy/noisy defaults to reduce resource load and log volume: - CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS now defaults to false. Stream chunks are the largest call-log artifact; capturing them on every request by default is what grows ~/.omniroute/call_logs by hundreds of MB in days. Operators can re-enable with =true. - OMNIROUTE_LOG_REQUEST_SHAPE now logs only when explicitly set to "1" (was: enabled unless set to "0"). Large-body diagnostics are debug tooling, not default behavior. Docs (.env.example + ENVIRONMENT.md) updated to match the new defaults. --- .env.example | 4 ++-- docs/reference/ENVIRONMENT.md | 4 ++-- src/app/api/v1/chat/completions/route.ts | 8 +++++--- src/lib/logEnv.ts | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 2ba4099968..f389dc7fb2 100644 --- a/.env.example +++ b/.env.example @@ -1345,7 +1345,7 @@ APP_LOG_TO_FILE=true # Whether call log pipeline capture stores stream chunks when enabled in settings. # Only applies when call_log_pipeline_enabled=true. -# Default: true +# Default: false (opt-in — saves disk: stream chunks are the biggest call-log artifact) # CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS=true # Maximum call log artifact size for pipeline captures, in KB. @@ -1784,7 +1784,7 @@ APP_LOG_TO_FILE=true # Log request shape (content-type + content-length) for large chat payloads. # Used by: src/app/api/v1/chat/completions/route.ts. Set to "0" to silence. -# Default: enabled. +# Default: disabled (opt-in). # OMNIROUTE_LOG_REQUEST_SHAPE=1 # Write raw (untruncated) request/response JSON in call log artifacts. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index f1099872bd..925a1aede7 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -716,7 +716,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | `CALL_LOG_MAX_ENTRIES` | `10000` | Max call log entries in the in-memory buffer. | | `CALL_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `call_logs` SQLite table before pruning. | | `MAX_PENDING_REQUEST_AGE_MS` | `3600000` (1 hour) | Max age for orphaned active request log entries before in-memory cleanup. | -| `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS` | `true` | Store stream chunks in pipeline artifacts when `call_log_pipeline_enabled=true`. | +| `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS` | `false` | Store stream chunks in pipeline artifacts when `call_log_pipeline_enabled=true`. Opt-in (`true`) — off by default to save disk. | | `CALL_LOG_PIPELINE_MAX_SIZE_KB` | `512` | Max pipeline call log artifact size in KB when `call_log_pipeline_enabled=true`. | | `PROXY_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `proxy_logs` SQLite table before pruning. | | `APP_LOG_ROTATION_CHECK_INTERVAL_MS` | `60000` (1 min) | How often `src/lib/logRotation.ts` re-checks the active log file size. | @@ -946,7 +946,7 @@ changing them requires a code edit, not an env var: | `CURSOR_AGENT_CLI_VERSION` | _(detect / pin)_ | `open-sse/utils/cursorAgentCliVersion.ts` | Agent CLI build id (`YYYY.MM.DD-`) for `x-cursor-client-version: cli-…` on Agent Run. | | `CURSOR_DATA_DIR` | _(probed)_ | `open-sse/utils/cursorAgentCliVersion.ts` | Override Cursor Agent CLI data dir (`…/versions/`); same var the official agent uses. | | `CURSOR_TOKEN` | _(unset)_ | `scripts/ad-hoc/cursor-tap.cjs` | Direct Cursor bearer token used by developer tooling. | -| `OMNIROUTE_LOG_REQUEST_SHAPE` | enabled (`!== "0"`) | `src/app/api/v1/chat/completions/route.ts` | Log content-type/length markers for large chat payloads. Set `"0"` to silence. | +| `OMNIROUTE_LOG_REQUEST_SHAPE` | disabled (opt-in via `"1"`) | `src/app/api/v1/chat/completions/route.ts` | Log content-type/length markers for large chat payloads when `"1"` is set. Off by default to reduce log noise. | | `DEBUG_RESPONSES_SSE_TO_JSON` | _(unset)_ | `open-sse/handlers/responseTranslator.ts` | Set `true` to log Responses API SSE→JSON translation details. | | `NEXT_PUBLIC_OMNIROUTE_E2E_MODE` | _(unset)_ | E2E test harness | Set `true` to enable E2E test mode (relaxed auth, test hooks). | diff --git a/src/app/api/v1/chat/completions/route.ts b/src/app/api/v1/chat/completions/route.ts index 77eed06416..84ae14522f 100644 --- a/src/app/api/v1/chat/completions/route.ts +++ b/src/app/api/v1/chat/completions/route.ts @@ -108,8 +108,8 @@ export async function POST(request) { try { // One-line marker for diagnosing 413 / Server-Action interceptions. // Logs only when Content-Length is present so debug noise stays low for - // typical chat payloads. Toggle off via OMNIROUTE_LOG_REQUEST_SHAPE=0. - if (process.env.OMNIROUTE_LOG_REQUEST_SHAPE !== "0") { + // typical chat payloads. Opt-in via OMNIROUTE_LOG_REQUEST_SHAPE=1. + if (process.env.OMNIROUTE_LOG_REQUEST_SHAPE === "1") { const ct = request.headers.get("content-type") ?? ""; const cl = request.headers.get("content-length"); if (cl && Number(cl) > 256 * 1024) { @@ -135,7 +135,9 @@ export async function POST(request) { if (!shapeCheck.success) { const issue = shapeCheck.error.issues[0]; const field = issue?.path?.length ? issue.path.join(".") : "body"; - return finishAdmission(errorResponse(400, `${field}: ${issue?.message ?? "Invalid request"}`)); + return finishAdmission( + errorResponse(400, `${field}: ${issue?.message ?? "Invalid request"}`) + ); } } diff --git a/src/lib/logEnv.ts b/src/lib/logEnv.ts index 8486628b4e..eb40bda66a 100644 --- a/src/lib/logEnv.ts +++ b/src/lib/logEnv.ts @@ -116,7 +116,7 @@ export function getCallLogsTableMaxRows(): number { } export function getCallLogPipelineCaptureStreamChunks(): boolean { - return parseBoolean(process.env.CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS, true); + return parseBoolean(process.env.CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS, false); } export function getCallLogPipelineMaxSizeBytes(): number { From 788d56fa07f9187358be375c30dc7a566a952b1b Mon Sep 17 00:00:00 2001 From: benzntech Date: Sat, 8 Aug 2026 18:48:51 +0530 Subject: [PATCH 017/100] docs(providers): add ChatGPT Web session credential guide Add docs/providers/CHATGPT_WEB.md covering how to obtain and update chatgpt-web session credentials via the Cookie Editor extension: - extension option settings (export format, HttpOnly, domain filter) - verifying __Secure-next-auth.session-token in a live network request - adding/updating credentials in the dashboard + bulk/session-pool APIs - contributing changes back via a PR Fill the previously _(verify)_ ChatGPT Web row in WEB-COOKIE-GUIDE.md. --- docs/getting-started/WEB-COOKIE-GUIDE.md | 20 ++-- docs/providers/CHATGPT_WEB.md | 143 +++++++++++++++++++++++ 2 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 docs/providers/CHATGPT_WEB.md diff --git a/docs/getting-started/WEB-COOKIE-GUIDE.md b/docs/getting-started/WEB-COOKIE-GUIDE.md index 9b44cc184e..0a44b5fa6b 100644 --- a/docs/getting-started/WEB-COOKIE-GUIDE.md +++ b/docs/getting-started/WEB-COOKIE-GUIDE.md @@ -18,7 +18,7 @@ Unlike API-key providers, Web Cookie providers authenticate using the credential Many authentication issues are caused by copying cookies from the wrong place. -## Do NOT copy from Cookie Storage +## Do NOT copy from Cookie Storage Most browsers expose stored cookies through: @@ -36,7 +36,7 @@ Although these cookies look correct, they may be: Using these values may cause authentication failures even if they appear valid. -## Copy from a Live Request +## Copy from a Live Request Instead, use the cookies from a successful request: @@ -80,14 +80,14 @@ The exact credentials required depend on the provider. Different websites store authentication differently. Some require only cookies, while others may require additional headers or tokens. -| Provider | Credential Format | Provider Guide | -|----------|-------------------|----------------| -| Claude Web | Full Cookie request header | `docs/providers/CLAUDE_WEB.md` | -| ChatGPT Web | _(verify)_ | | -| Gemini Web | _(verify)_ | | -| Copilot Web | _(verify)_ | | -| Grok Web | _(verify)_ | | -| ... | ... | ... | +| Provider | Credential Format | Provider Guide | +| ----------- | -------------------------------------------------------------- | ------------------------------- | +| Claude Web | Full Cookie request header | `docs/providers/CLAUDE_WEB.md` | +| ChatGPT Web | Full Cookie header or `__Secure-next-auth.session-token` value | `docs/providers/CHATGPT_WEB.md` | +| Gemini Web | _(verify)_ | | +| Copilot Web | _(verify)_ | | +| Grok Web | _(verify)_ | | +| ... | ... | ... | > Update this table as new Web Cookie providers are added or existing providers change their authentication requirements. diff --git a/docs/providers/CHATGPT_WEB.md b/docs/providers/CHATGPT_WEB.md new file mode 100644 index 0000000000..b95c7f12cf --- /dev/null +++ b/docs/providers/CHATGPT_WEB.md @@ -0,0 +1,143 @@ +--- +title: "Providers — ChatGPT Web (session credentials via Cookie Editor)" +version: 3.8.50 +lastUpdated: 2026-08-08 +--- + +# Providers — ChatGPT Web (Plus/Pro session credentials) + +`chatgpt-web` (alias `cgpt-web`, display name **ChatGPT Web (Plus/Pro)**) sends OpenAI-format chat requests through an authenticated `chatgpt.com` browser session. It authenticates with the `__Secure-next-auth.session-token` cookie — **no API key required**. + +> **New to Web Cookie providers?** +> +> Read **`docs/getting-started/WEB-COOKIE-GUIDE.md`** for the general setup process, limitations, and troubleshooting before following this provider-specific guide. + +--- + +## 1. What credential does OmniRoute need? + +Defined in `src/shared/constants/providers/web-cookie.ts` + `src/shared/providers/webSessionCredentials.ts`: + +| Field | Value | +| -------------------------- | ----------------------------------------------------------------------------- | +| Provider id | `chatgpt-web` | +| Credential name | `__Secure-next-auth.session-token` | +| Accepts full Cookie header | ✅ yes | +| Accepted storage keys | `cookie`, `sessionToken`, `session-token`, `__Secure-next-auth.session-token` | + +Two paste formats both work: + +- **Bare value** — just the token contents: `eyJhbGciOi...` +- **Full Cookie header** — `__Secure-next-auth.session-token=eyJhbGciOi...; cf_clearance=...` (preferred — carries rotation/anti-bot cookies the executor needs) + +--- + +## 2. Get the cookie fast with Cookie Editor + +> **Why Cookie Editor instead of DevTools?** The repo's general guide insists you copy from a **live network request**, not cookie storage. Cookie Editor gives you both in one click: it can read the _live_ cookies for the domain (including `HttpOnly` ones DevTools hides) and export them as a ready-made Cookie header — no manual request-hunting, no stale-storage pitfalls. + +### 2.1 Install and pin + +1. Install **[Cookie-Editor](https://chrome.google.com/webstore/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm)** (Moustachauve) in Chrome/Edge, or the Firefox equivalent. +2. Pin it to the toolbar (right-click icon → Pin). + +### 2.2 Update the extension's option settings (one-time) + +Click the Cookie Editor icon → the **⚙️ Options** tab, then set: + +| Option | Set to | Why | +| ----------------------------------- | ------------------- | --------------------------------------------------------------------------------------- | +| **Export format** | `Cookie header` | Produces a paste-ready `name=value; name=value` string — exactly what OmniRoute accepts | +| **Show / include HttpOnly cookies** | ON | `__Secure-next-auth.session-token` is HttpOnly; hidden by default in some skins | +| **Show expired cookies** | OFF | Keeps the list clean — expired tokens are useless | +| **Domain filter behavior** | Active tab's domain | Auto-scopes to `chatgpt.com` when you open the popup there | + +### 2.3 Copy the credential + +1. Go to **https://chatgpt.com** and make sure you're **signed in with the Plus/Pro account** you want OmniRoute to use. +2. Open a conversation and send at least one message (forces the session token to be live/refreshed). +3. Click the **Cookie Editor** icon → the popup shows only `chatgpt.com` cookies. +4. Find `__Secure-next-auth.session-token`. If it's split into chunks (`__Secure-next-auth.session-token.0`, `.1`, …), select **all** of them — OmniRoute's `nextAuthCookie.ts` merges rotated chunk families. +5. Click **Export → Copy** (the icon in the header). With `Export format = Cookie header` this copies the full header in one click. + +> **If the token is missing:** you're signed out, or the account has no active Plus/Pro subscription (chatgpt-web is a `subscriptionRisk: true` provider — free accounts won't authenticate). + +--- + +## 3. Verify the required data (before pasting) + +The repo's `WEB-COOKIE-GUIDE.md` mandates a live-request check. Do it once per session: + +1. With chatgpt.com open, press **F12** → **Network** tab. +2. Refresh the page, then send a chat message. +3. Click the conversation request (e.g. `/backend-api/conversation` or the SSE stream) → **Headers** → **Request Headers** → **Cookie**. +4. Confirm it contains `__Secure-next-auth.session-token=...` — **not** just `cf_clearance` or `__cf_bm`. + +The value you copied in step 2.3 must match what the live request sends. If they differ, re-copy from Cookie Editor. + +--- + +## 4. Add / update the credential in OmniRoute + +### Dashboard (typical user path) + +1. Open the OmniRoute dashboard → **Providers** → **Add Provider**. +2. Search **ChatGPT Web (Plus/Pro)** (id `chatgpt-web`). +3. Paste the copied cookie header into the credential field. +4. Click **Test Connection**. +5. Save. + +> **Validation caveat:** per `WEB-COOKIE-GUIDE.md`, Test Connection only checks the format — it doesn't guarantee upstream auth (tracked as Issue #7857). If requests later 401, re-copy from a fresh live session (sessions rotate; the executor auto-merges `Set-Cookie` rotations, but expired tokens need a manual refresh). + +### Bulk / session pools (many accounts) + +For multiple ChatGPT sessions, use the bulk web-session import or session-pool endpoints: + +- `POST /api/providers/bulk-web-session` — import many cookie credentials at once +- `GET /api/session-pools` + `/api/session-pools/[provider]` — pool rotation across accounts + +Each credential blob must carry the `__Secure-next-auth.session-token` value under one of the accepted storage keys (`cookie`, `sessionToken`, `session-token`, or the cookie's exact name). + +### Renewing when the session expires + +Web sessions expire on sign-out or server-side rotation. Re-run steps 2.3 + 4 whenever requests start failing with 401/403. There is no refresh token — the cookie **is** the credential. + +--- + +## 5. Contributing: update docs / constants and open a PR + +If you changed the credential contract (new storage key, new cookie name, changed hint) or are filling the docs gap, contribute it: + +1. Update `src/shared/providers/webSessionCredentials.ts` (credential name / placeholder / storage keys) or `src/shared/constants/providers/web-cookie.ts` (`authHint`). +2. Update this guide (`docs/providers/CHATGPT_WEB.md`) and the provider table in `docs/getting-started/WEB-COOKIE-GUIDE.md` (currently shows `_(verify)_` for ChatGPT Web). +3. Update `.env.example` + `docs/reference/ENVIRONMENT.md` if you touched env vars, then run: + ```bash + node scripts/check/check-env-doc-sync.mjs # must pass + ``` +4. Run the provider/unit tests: + ```bash + npm run test:unit + # targeted: tests/unit/chatgpt-web.test.ts (stealth path) + ``` +5. Commit with a Conventional Commit message (no `Co-Authored-By`), push to your fork, and open the PR against `main` (or `release/v3.8.49` per CONTRIBUTING.md): + ```bash + git checkout -b docs/chatgpt-web-cookie-guide + git add docs/providers/CHATGPT_WEB.md docs/getting-started/WEB-COOKIE-GUIDE.md + git commit -m "docs(providers): add ChatGPT Web cookie credential guide" + git push -u origin docs/chatgpt-web-cookie-guide + gh pr create --base main --head docs/chatgpt-web-cookie-guide --title "docs(providers): ChatGPT Web session credential guide" + ``` + +> ⚠️ **Never commit a real cookie value.** All examples above are placeholders. If a test fixture needs a token, use a fake `eyJhbGciOi...` string. + +--- + +## Troubleshooting + +| Symptom | Likely cause | Fix | +| -------------------------------- | -------------------------------------------- | --------------------------------------------------------- | +| Cookie not in Cookie Editor | Signed out / not HttpOnly-visible | Sign in; enable HttpOnly display in options | +| Token missing from live request | Free account, or request isn't authenticated | Use a Plus/Pro account; send a chat message first | +| 401 after Test Connection passed | Expired/rotated session (Issue #7857) | Re-copy from a fresh live request | +| Chunked token fails | Only one chunk pasted | Select all `__Secure-next-auth.session-token.*` chunks | +| 403 from a different machine | Cloudflare-pinned session | Copy + use from the same browser profile/IP as the cookie | From eb817932e47a00f1a411feb61a4b9c86999c620a Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 8 Aug 2026 09:22:02 -0400 Subject: [PATCH 018/100] fix(quality): rebaseline combo-routing-engine.test.ts own-comment growth The ALL_ACCOUNTS_INACTIVE->ALL_TARGETS_SKIPPED fix (58ab721fe) added explanatory comments (+7 lines), pushing the file past its frozen 3457 cap. CI's PR-mode check:file-size caught it; local check-file-size.mjs was not re-run after that specific commit. --- config/quality/file-size-baseline.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index fe16cc8abc..9d496b81f6 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_08_08_9619_own_comment_growth": "PR #9619's own follow-up commit (58ab721fe/15b9cb194): tests/unit/combo-routing-engine.test.ts 3457->3464 (+7) is this PR's own growth — explanatory comment blocks added alongside the ALL_ACCOUNTS_INACTIVE->ALL_TARGETS_SKIPPED stale-assertion fix (matching the identical fix applied to #9173/#9006/#8909 the same day; upstream's own test was never updated when the recordedAttempts===0 pre-dispatch-skip branch shipped). Caught by CI's PR-mode check:file-size (--base-ref) after the fix commit; missed locally because check-file-size.mjs was not re-run after that specific edit.", "_rebaseline_2026_08_07_9619_reconcile_onto_tip": "PR #9619 (fix/basered-changelog-integrity-fabricated-docs) rebase-onto-tip reconciliation. 10 files + 1 test file grew via already-merged release/v3.8.50 PRs since this branch's creation, none touched by this PR's own diff: open-sse/mcp-server/server.ts 1411->1444, open-sse/services/accountFallback.ts 1972->1978, src/app/(dashboard)/dashboard/combos/page.tsx 4647->4703, src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx 1316->1324, src/app/api/providers/[id]/models/route.ts 2250->2304, src/app/api/v1/models/catalog.ts 1549->1556, src/lib/tokenHealthCheck.ts 1021->1053, src/lib/db/core.ts 1637->1639, src/sse/handlers/chat.ts 1877->1878, tests/unit/translator-openai-to-gemini.test.ts 1619->1622. open-sse/mcp-server/schemas/tools.ts 1505->1553 is new growth not previously tracked. Same root cause as every other entry in this chain: fast-gates PR->release does not run check:file-size. No offending branch left to fix.", "_rebaseline_2026_08_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.", "_rebaseline_2026_07_24_8470_hyperagent_sticky_thread": "PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) own growth: open-sse/executors/hyperagent.ts 936->1025 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 937->1026, +89, crosses the 1000 cap). Fixes a real bug where a reverse-conversion proxy (text-Intent/JSON to Claude Code native tool_calls) rewrites assistant messages between agentic tool-loop turns, breaking HyperAgent’s conversation-prefix fingerprint and cold-starting the thread mid tool-loop. Adds Anthropic tool_use/tool_result flattening to extractMessageText() plus a new rootUserFingerprint()/root-key lookup tier in resolveHyperAgentThreadBinding()/storeHyperAgentThreadAfterTurn() so the thread stays sticky across the tool loop. Cohesive additions inside the existing single-file executor; not extractable without splitting the executor mid-request-flow. Covered by tests/unit/executor-hyperagent.test.ts (19/19, +5 new cases for tool_result/tool_use flattening + root-key stickiness). Pre-merge review flagged a cross-conversation root-key collision risk (tracked in the PR’s own mandatory pre-merge checklist, not yet addressed) — unrelated to this file-size ratchet, tracked separately by /fix-prs.", @@ -362,7 +363,7 @@ "tests/unit/cc-compatible-provider.test.ts": 1217, "tests/unit/chatcore-translation-paths.test.ts": 2876, "tests/unit/chatgpt-web.test.ts": 3148, - "tests/unit/combo-routing-engine.test.ts": 3457, + "tests/unit/combo-routing-engine.test.ts": 3464, "tests/unit/db-migration-runner.test.ts": 1499, "tests/unit/deepseek-web.test.ts": 1092, "tests/unit/executor-codex.test.ts": 1339, From 5e4a684bada411c639e9e40af9e5db643b8a30dc Mon Sep 17 00:00:00 2001 From: benzntech Date: Sat, 8 Aug 2026 19:03:56 +0530 Subject: [PATCH 019/100] docs(providers): use canonical chromewebstore URL for Cookie Editor install link --- docs/providers/CHATGPT_WEB.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/providers/CHATGPT_WEB.md b/docs/providers/CHATGPT_WEB.md index b95c7f12cf..0918844478 100644 --- a/docs/providers/CHATGPT_WEB.md +++ b/docs/providers/CHATGPT_WEB.md @@ -38,7 +38,7 @@ Two paste formats both work: ### 2.1 Install and pin -1. Install **[Cookie-Editor](https://chrome.google.com/webstore/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm)** (Moustachauve) in Chrome/Edge, or the Firefox equivalent. +1. Install **[Cookie-Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm)** (Moustachauve) in Chrome/Edge, or the Firefox equivalent. 2. Pin it to the toolbar (right-click icon → Pin). ### 2.2 Update the extension's option settings (one-time) From b294c76719e39709002506044b2144844f48bb45 Mon Sep 17 00:00:00 2001 From: benzntech Date: Sat, 8 Aug 2026 19:04:07 +0530 Subject: [PATCH 020/100] feat(providers): add Cookie Editor fast-path to web session credential guide The 'How to get the session credential' instructions in the provider add-connection modal only described the manual DevTools flow. Add a fast-path step using the Cookie Editor extension (export as Cookie header, select all numbered session-token chunks) and demote the DevTools walkthrough to the manual alternative. New i18n keys (webSessionGuideStep2Fast, webSessionGuideStep3Manual) ship in en.json; other locales fall back to English until translated. --- .../[id]/components/WebSessionCredentialGuide.tsx | 10 +++++----- src/i18n/messages/en.json | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/WebSessionCredentialGuide.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/WebSessionCredentialGuide.tsx index e3743acdfb..e104188844 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/WebSessionCredentialGuide.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/WebSessionCredentialGuide.tsx @@ -111,16 +111,16 @@ export default function WebSessionCredentialGuide({
  • {providerText( t, - "webSessionGuideStep2", - "Open the browser developer tools and inspect a request made by the web app." + "webSessionGuideStep2Fast", + "Fast path: install the Cookie Editor extension (chromewebstore.google.com → Cookie Editor), open it on the {provider} tab, find {credential} (select all numbered chunks if split), and click Export → Copy with the export format set to “Cookie header”.", + { provider: providerName, credential: requirement.credentialName } )}
  • {providerText( t, - "webSessionGuideStep3", - "Copy the required credential from the provider's own domain. For cookies, copy only the Cookie header value and omit Cookie:.", - { credential: requirement.credentialName } + "webSessionGuideStep3Manual", + "Manual path: open the browser developer tools (F12 → Network), refresh the page, open an authenticated request, and copy the Cookie header value from Request Headers — omit the Cookie: prefix." )}
  • diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 3d9bb9bc8e..3d10557f7a 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5984,7 +5984,9 @@ "webTokenRequiredCredential": "Required token: {credential}", "webSessionGuideStep1": "Sign in to {provider} in your browser.", "webSessionGuideStep2": "Open the browser developer tools and inspect a request made by the web app.", + "webSessionGuideStep2Fast": "Fast path: install the Cookie Editor extension (chromewebstore.google.com → Cookie Editor), open it on the {provider} tab, find {credential} (select all numbered chunks if split), and click Export → Copy with the export format set to “Cookie header”.", "webSessionGuideStep3": "Copy the required credential from the provider's own domain. For cookies, copy only the Cookie header value and omit Cookie:.", + "webSessionGuideStep3Manual": "Manual path: open the browser developer tools (F12 → Network), refresh the page, open an authenticated request, and copy the Cookie header value from Request Headers — omit the Cookie: prefix.", "webSessionGuideStep4": "Paste it here and check the connection. If it stops working, sign in again and replace it with a fresh value.", "webSessionSecurityHint": "Treat this like a password: it may access your signed-in web account until it expires or is revoked.", "webNoAuthGuideTitle": "No credential required", From 2c8093f73f42e3a1f8fb25d79b16a633bd192025 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Sat, 8 Aug 2026 09:59:34 -0400 Subject: [PATCH 021/100] chore(tests): drop explanatory comments on ALL_TARGETS_SKIPPED assertions Kept the assertion value fix (ALL_ACCOUNTS_INACTIVE -> ALL_TARGETS_SKIPPED); the comments were unnecessary. Reverts the file-size baseline bump these comments caused (combo-routing-engine.test.ts back to its original 3457). --- config/quality/file-size-baseline.json | 3 +-- tests/unit/combo-routing-engine.test.ts | 7 ------- tests/unit/t23-t24-fallback-resilience.test.ts | 4 ---- 3 files changed, 1 insertion(+), 13 deletions(-) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index cc1a124172..1a11742dbb 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,4 @@ { - "_rebaseline_2026_08_08_9619_own_comment_growth": "PR #9619's own follow-up commit (58ab721fe/15b9cb194): tests/unit/combo-routing-engine.test.ts 3457->3464 (+7) is this PR's own growth — explanatory comment blocks added alongside the ALL_ACCOUNTS_INACTIVE->ALL_TARGETS_SKIPPED stale-assertion fix (matching the identical fix applied to #9173/#9006/#8909 the same day; upstream's own test was never updated when the recordedAttempts===0 pre-dispatch-skip branch shipped). Caught by CI's PR-mode check:file-size (--base-ref) after the fix commit; missed locally because check-file-size.mjs was not re-run after that specific edit.", "_rebaseline_2026_08_07_9619_reconcile_onto_tip": "PR #9619 (fix/basered-changelog-integrity-fabricated-docs) rebase-onto-tip reconciliation. 10 files + 1 test file grew via already-merged release/v3.8.50 PRs since this branch's creation, none touched by this PR's own diff: open-sse/mcp-server/server.ts 1411->1444, open-sse/services/accountFallback.ts 1972->1978, src/app/(dashboard)/dashboard/combos/page.tsx 4647->4703, src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx 1316->1324, src/app/api/providers/[id]/models/route.ts 2250->2304, src/app/api/v1/models/catalog.ts 1549->1556, src/lib/tokenHealthCheck.ts 1021->1053, src/lib/db/core.ts 1637->1639, src/sse/handlers/chat.ts 1877->1878, tests/unit/translator-openai-to-gemini.test.ts 1619->1622. open-sse/mcp-server/schemas/tools.ts 1505->1553 is new growth not previously tracked. Same root cause as every other entry in this chain: fast-gates PR->release does not run check:file-size. No offending branch left to fix.", "_rebaseline_2026_08_08_v3850_base_drift_batch_9757": "Base drift on release/v3.8.50, not own growth: the 08-06..08-08 merge batches grew 12 already-frozen (or newly-landed) files without carrying their rebaselines — the dedicated rebaseline PR #9616 was closed as 'superseded' but its file-size entries never actually reached the base, and later merges (#8894 combos page, #9539 EditConnectionModal, #8895 models route, #9294/#9293 catalog, #9541 db/core, #8970 tokenHealthCheck, #8925 mcp schemas+server, #8890 accountFallback, #9467 chat.ts, #8931 openai-to-kiro, ProxyRegistryManager) kept growing them. All 12 values re-measured on THIS branch's tree (= pure tip + this PR's 1-line chat.ts fix, which adds zero lines). This PR's own source changes (chat.ts identifier restore, stream.ts format carve-out) do not grow any frozen file past these values.", "_rebaseline_2026_08_08_migration_135_collision": "fix(db): resolve migration version 135 numbering collision — #9449's 135_connection_runtime_state.sql and #8908's 135_migrate_model_capability_max_token.sql both claimed version 135 (#9449 branched before #8908 merged and never got renumbered before landing on release/v3.8.50), which threw 'Migration version collision detected' the moment ANY code touched the database — a fresh install/deploy from this tip cannot even boot. Renumbered the later-landing file to 140 (next free slot) and added the matching isSchemaAlreadyApplied('140') retroactive guard, matching the established pattern already used for the prior 135/136 -> 137/138 renumber in the same file. Own growth: src/lib/db/migrationRunner.ts 1084->1094 (+10, the new case block) — irreducible, matches the existing per-case guard pattern exactly. Covered by tests/unit/migration-135-numbering-collision.test.ts (2/2), confirmed failing (reproducing the exact live crash) against the pre-fix colliding filenames, passing after.", @@ -311,7 +310,7 @@ "tests/unit/cc-compatible-provider.test.ts": 1217, "tests/unit/chatcore-translation-paths.test.ts": 2876, "tests/unit/chatgpt-web.test.ts": 3148, - "tests/unit/combo-routing-engine.test.ts": 3464, + "tests/unit/combo-routing-engine.test.ts": 3457, "tests/unit/db-migration-runner.test.ts": 1499, "tests/unit/deepseek-web.test.ts": 1092, "tests/unit/executor-codex.test.ts": 1339, diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts index a13cbb1d7b..e3f71e8052 100644 --- a/tests/unit/combo-routing-engine.test.ts +++ b/tests/unit/combo-routing-engine.test.ts @@ -2318,10 +2318,6 @@ test("handleComboChat returns a 503 when every model is unavailable before execu const payload = (await result.json()) as any; assert.equal(result.status, 503); - // isModelAvailable always false means every target is skipped by the - // pre-dispatch filter with zero dispatch attempts — the more precise - // ALL_TARGETS_SKIPPED classification, not ALL_ACCOUNTS_INACTIVE (which - // implies targets were attempted and their accounts found inactive). assert.equal(payload.error.code, "ALL_TARGETS_SKIPPED"); }); @@ -2851,9 +2847,6 @@ test("handleComboChat round-robin resolves nested combos and returns inactive wh const payload = (await result.json()) as any; assert.equal(result.status, 503); - // isModelAvailable always false means every nested target is skipped by the - // pre-dispatch filter with zero dispatch attempts — ALL_TARGETS_SKIPPED, - // not ALL_ACCOUNTS_INACTIVE (see the analogous priority-strategy test above). assert.equal(payload.error.code, "ALL_TARGETS_SKIPPED"); }); diff --git a/tests/unit/t23-t24-fallback-resilience.test.ts b/tests/unit/t23-t24-fallback-resilience.test.ts index 22f9c5d7da..f0f51d30a2 100644 --- a/tests/unit/t23-t24-fallback-resilience.test.ts +++ b/tests/unit/t23-t24-fallback-resilience.test.ts @@ -148,10 +148,6 @@ test("T24: all inactive accounts return 503 service_unavailable (not 406)", asyn assert.equal(result.status, 503); const body = (await result.json()) as any; - // isModelAvailable always false means every target is skipped by the - // pre-dispatch filter with zero dispatch attempts — the more precise - // ALL_TARGETS_SKIPPED classification, not ALL_ACCOUNTS_INACTIVE (which - // implies targets were attempted and their accounts found inactive). assert.equal(body.error?.code, "ALL_TARGETS_SKIPPED"); }); From d11b99f6ccb7cc147b39dc30940429cca4ba83c8 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:47:07 -0300 Subject: [PATCH 022/100] cherry-pick(pr-9834): fix(cursor): SelectedImage blobIdWithData + JPEG soft-cap prep (#9840) * fix(cursor): hydrate SelectedImage via blobIdWithData + JPEG soft-cap Cursor vision expects SelectedImage.blob_id_with_data (field 9) backed by the session blobStore, and large clipboard PNGs need JPEG soft-cap prep rather than a hard 1 MiB reject before encode. * docs(changelog): add fragment for Cursor SelectedImage blobIdWithData fix * refactor(cursor): split image protobuf encoding Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- .../9834-cursor-selected-image-blobid.md | 1 + open-sse/utils/cursorAgentProtobuf.ts | 86 +--- .../cursorAgentProtobuf/imageEncoding.ts | 80 ++++ open-sse/utils/cursorImages.ts | 449 ++++++++++++++++-- package-lock.json | 4 +- package.json | 1 + tests/unit/cursor-image-input.test.ts | 222 ++++++--- 7 files changed, 673 insertions(+), 170 deletions(-) create mode 100644 changelog.d/fixes/9834-cursor-selected-image-blobid.md create mode 100644 open-sse/utils/cursorAgentProtobuf/imageEncoding.ts diff --git a/changelog.d/fixes/9834-cursor-selected-image-blobid.md b/changelog.d/fixes/9834-cursor-selected-image-blobid.md new file mode 100644 index 0000000000..6ad655af98 --- /dev/null +++ b/changelog.d/fixes/9834-cursor-selected-image-blobid.md @@ -0,0 +1 @@ +- **fix(cursor):** SelectedImage uses `blobIdWithData` + session blobStore, with JPEG soft-cap prep via sharp ([#9834](https://github.com/diegosouzapw/OmniRoute/pull/9834)) — thanks @yansigit diff --git a/open-sse/utils/cursorAgentProtobuf.ts b/open-sse/utils/cursorAgentProtobuf.ts index 106c29ecba..a38085ae9e 100644 --- a/open-sse/utils/cursorAgentProtobuf.ts +++ b/open-sse/utils/cursorAgentProtobuf.ts @@ -19,6 +19,11 @@ import zlib from "node:zlib"; import crypto from "node:crypto"; import { decodeNativeTodoWriteCompletion } from "./cursorAgentProtobuf/nativeTodoWrite.ts"; +import { + cursorImageAttachmentPath, + encodeSelectedImageBody, + type EncodedImage, +} from "./cursorAgentProtobuf/imageEncoding.ts"; import { WT_VARINT, WT_LEN, @@ -63,25 +68,8 @@ const UM_MESSAGE_ID = 2; // UserMessage.message_id const UM_SELECTED_CONTEXT = 3; // UserMessage.selected_context (empty placeholder required) const UM_MODE = 4; // UserMessage.mode (cursor-agent sends 1) -// ─── Vision input (image) field numbers ──────────────────────────────────── -// Pinned from cursor-agent's agent.v1 protobuf descriptor (bundle version -// 2026.06.02-8c11d9f, cross-checked against composer-api's older-endpoint -// encoder for shape). Images attach to the current UserMessage through its -// selected_context (field 3): UserMessage.selected_context is a SelectedContext -// whose `selected_images` (field 1) is a repeated SelectedImage. Each -// SelectedImage carries the raw bytes inline in its `data_or_blob_id` oneof -// (the `data` case, field 8) — cursor-agent's CLI instead sends a local file -// `path`, which a proxy cannot use, so we inline the bytes like composer-api. const SC_SELECTED_IMAGES = 1; // SelectedContext.selected_images [repeated SelectedImage] -const SI_UUID = 2; // SelectedImage.uuid -const SI_DIMENSION = 4; // SelectedImage.dimension (SelectedImage.Dimension) -const SI_MIME_TYPE = 7; // SelectedImage.mime_type -const SI_DATA = 8; // SelectedImage.data (oneof data_or_blob_id) — inline image bytes - -const DIM_WIDTH = 1; // SelectedImage.Dimension.width (int32) -const DIM_HEIGHT = 2; // SelectedImage.Dimension.height (int32) - const RM_MODEL_ID = 1; // RequestedModel.model_id const RM_PARAMETERS = 3; // RequestedModel.parameters [repeated] @@ -413,58 +401,15 @@ export type AgentRunInput = { // which the executor's processFrame replies to with the stored bytes. systemPrompt?: string; blobStore?: Map; - // Vision input: images attached to the current user turn. Encoded inline as - // SelectedContext.selected_images[] (see encodeSelectedImageBody). Empty / - // undefined keeps the request byte-identical to the text-only path. + // Vision input: images attached to the current user turn. Encoded as + // SelectedContext.selected_images[] via blobIdWithData (see + // encodeSelectedImageBody). Empty / undefined keeps the request + // byte-identical to the text-only path. images?: EncodedImage[]; }; -/** - * A resolved image ready to embed in a cursor request. `data` is the raw - * decoded image bytes (already SSRF-checked / size-capped by the executor's - * resolveCursorImages helper). `mimeType` (e.g. "image/png") helps cursor - * decode the inline bytes; `width`/`height` populate the optional Dimension - * sub-message when cheaply known; `uuid` is a stable per-image id. - */ -export type EncodedImage = { - data: Buffer; - mimeType?: string; - width?: number; - height?: number; - uuid: string; -}; - -/** - * Encode the body of a SelectedImage message (no outer field tag — the caller - * wraps it via encodeMessage(SC_SELECTED_IMAGES, [body])). Sets the inline - * `data` oneof case plus uuid, optional dimension, and mime_type. Fields are - * written in ascending field-number order (canonical protobuf layout). - */ -export function encodeSelectedImageBody(img: EncodedImage): Buffer { - const parts: Buffer[] = [encodeString(SI_UUID, img.uuid)]; - if ( - typeof img.width === "number" && - typeof img.height === "number" && - Number.isFinite(img.width) && - Number.isFinite(img.height) && - img.width > 0 && - img.height > 0 - ) { - parts.push( - encodeMessage(SI_DIMENSION, [ - encodeUInt32Field(DIM_WIDTH, Math.floor(img.width)), - encodeUInt32Field(DIM_HEIGHT, Math.floor(img.height)), - ]) - ); - } - if (img.mimeType) { - parts.push(encodeString(SI_MIME_TYPE, img.mimeType)); - } - // data_or_blob_id oneof = data (inline bytes) — field 8, written last to - // keep ascending field order. - parts.push(encodeBytes(SI_DATA, img.data)); - return Buffer.concat(parts); -} +export { cursorImageAttachmentPath, encodeSelectedImageBody }; +export type { EncodedImage }; /** * Convert OpenAI tool definitions to cursor McpToolDefinition bodies. Used @@ -493,12 +438,15 @@ export function encodeAgentRunRequest(input: AgentRunInput): Buffer { // UserMessage { text, message_id, selected_context, mode=1 }. // selected_context is normally an empty placeholder (required by the server // even when empty — see below), but when the turn carries vision input we - // populate its selected_images[] with the inline-encoded images. The - // empty-images path produces byte-identical output to the text-only request. + // populate its selected_images[] with blobIdWithData-encoded images (and + // store the bytes in blobStore for getBlob). The empty-images path produces + // byte-identical output to the text-only request. const selectedContextParts: Buffer[] = []; if (input.images && input.images.length > 0) { for (const img of input.images) { - selectedContextParts.push(encodeMessage(SC_SELECTED_IMAGES, [encodeSelectedImageBody(img)])); + selectedContextParts.push( + encodeMessage(SC_SELECTED_IMAGES, [encodeSelectedImageBody(img, input.blobStore)]) + ); } } // The empty selected_context placeholder and mode=1 match cursor-agent's diff --git a/open-sse/utils/cursorAgentProtobuf/imageEncoding.ts b/open-sse/utils/cursorAgentProtobuf/imageEncoding.ts new file mode 100644 index 0000000000..efb7fc6376 --- /dev/null +++ b/open-sse/utils/cursorAgentProtobuf/imageEncoding.ts @@ -0,0 +1,80 @@ +import crypto from "node:crypto"; +import { + encodeBytes, + encodeMessage, + encodeString, + encodeUInt32Field, +} from "./wire.ts"; + +const SI_UUID = 2; +const SI_PATH = 3; +const SI_DIMENSION = 4; +const SI_MIME_TYPE = 7; +const SI_BLOB_ID_WITH_DATA = 9; + +const SIBD_BLOB_ID = 1; +const SIBD_DATA = 2; + +const DIM_WIDTH = 1; +const DIM_HEIGHT = 2; + +export type EncodedImage = { + data: Buffer; + mimeType?: string; + width?: number; + height?: number; + uuid: string; +}; + +export function cursorImageAttachmentPath(uuid: string, mimeType?: string): string { + const normalized = (mimeType || "").toLowerCase(); + const ext = + normalized === "image/jpeg" || normalized === "image/jpg" + ? "jpg" + : normalized === "image/gif" + ? "gif" + : normalized === "image/webp" + ? "webp" + : "png"; + return `attachment-${uuid}.${ext}`; +} + +export function encodeSelectedImageBody( + img: EncodedImage, + blobStore?: Map +): Buffer { + const blobId = crypto.createHash("sha256").update(img.data).digest(); + if (blobStore) { + blobStore.set(blobId.toString("hex"), img.data); + } + + const parts: Buffer[] = [ + encodeString(SI_UUID, img.uuid), + encodeString(SI_PATH, cursorImageAttachmentPath(img.uuid, img.mimeType)), + ]; + if ( + typeof img.width === "number" && + typeof img.height === "number" && + Number.isFinite(img.width) && + Number.isFinite(img.height) && + img.width > 0 && + img.height > 0 + ) { + parts.push( + encodeMessage(SI_DIMENSION, [ + encodeUInt32Field(DIM_WIDTH, Math.floor(img.width)), + encodeUInt32Field(DIM_HEIGHT, Math.floor(img.height)), + ]) + ); + } + if (img.mimeType) { + parts.push(encodeString(SI_MIME_TYPE, img.mimeType)); + } + parts.push( + encodeMessage(SI_BLOB_ID_WITH_DATA, [ + encodeBytes(SIBD_BLOB_ID, blobId), + encodeBytes(SIBD_DATA, img.data), + ]) + ); + return Buffer.concat(parts); +} diff --git a/open-sse/utils/cursorImages.ts b/open-sse/utils/cursorImages.ts index 29e669f57e..bd29dcabf5 100644 --- a/open-sse/utils/cursorImages.ts +++ b/open-sse/utils/cursorImages.ts @@ -2,8 +2,8 @@ * Image resolution + security for Cursor vision input. * * Turns OpenAI `image_url` parts (base64 `data:` URIs or remote `http(s)` - * URLs) into decoded bytes ready to inline into a cursor SelectedImage - * (see ../utils/cursorAgentProtobuf.ts::encodeSelectedImageBody). + * URLs) into decoded, JPEG-prepped bytes ready for SelectedImage + * `blobIdWithData` encoding (see cursorAgentProtobuf.ts). * * Security (OmniRoute hard rules): * - SSRF: remote fetches go through the repo's canonical outbound guard @@ -12,9 +12,9 @@ * cloud-metadata hostnames. Client-supplied image URLs are always held to * the strict public-only policy (never gated by the private-URL toggle that * admin-configured provider URLs use). - * - Size cap: each image must decode to <= 1 MiB (matches composer-api). - * Enforced both before base64 decode (cheap pre-check) and while streaming - * a remote body (so a hostile server can't stream gigabytes). + * - Size caps: inbound decode/fetch is bounded (16 MiB) so large clipboard + * PNGs can shrink via JPEG soft-cap prep; the final wire image must be + * <= 1 MiB. Soft target is ~100 KiB JPEG for reliable Cursor hydration. * - Content type: data URIs and URL responses must be `image/*`. * - Errors throw `CursorImageError` with a clean, path-free message; the * executor routes it through the sanitized 400 path (hard rule #12). @@ -23,6 +23,7 @@ import crypto from "node:crypto"; import dns from "node:dns"; import { isIP } from "node:net"; +import sharp from "sharp"; import { parseAndValidatePublicUrl, isPrivateHost, @@ -30,14 +31,47 @@ import { } from "@/shared/network/outboundUrlGuard"; import type { EncodedImage } from "./cursorAgentProtobuf.ts"; -// 1 MiB per image — matches composer-api's MAX_CURSOR_IMAGE_BYTES. Large -// enough for a typical screenshot, small enough to bound request size and -// memory. +/** Final per-image byte cap after prep (composer-api / wire bound). */ export const MAX_CURSOR_IMAGE_BYTES = 1024 * 1024; -// Upper bound on the number of images per request. Each image triggers (at -// most) one remote fetch, so an unbounded count is a DoS vector; 12 is well -// above any realistic vision prompt. +/** + * Inbound decode/fetch bomb ceiling before JPEG prep. Large clipboard PNGs may + * exceed {@link MAX_CURSOR_IMAGE_BYTES} raw but shrink under the wire cap after + * re-encode. + */ +export const MAX_CURSOR_IMAGE_DECODE_BYTES = 16 * 1024 * 1024; + +/** + * Soft target for Cursor vision hydration. Prefer JPEG at or under this size. + */ +export const CURSOR_VISION_SOFT_MAX_BYTES = 100 * 1024; + +/** Soft target when the client requests `detail: original` or `high`. */ +export const CURSOR_VISION_SOFT_MAX_BYTES_HIGH = 256 * 1024; + +/** Longest edge after Cursor vision prep. */ +export const CURSOR_VISION_MAX_EDGE = 2000; + +/** Decode bomb: reject images whose sniffed longest edge exceeds this. */ +export const MAX_CURSOR_IMAGE_DECODE_EDGE = 8192; + +/** Decode bomb: reject images whose sniffed pixel count exceeds this. */ +export const MAX_CURSOR_IMAGE_PIXELS = 25_000_000; + +const CURSOR_VISION_JPEG_QUALITIES_DEFAULT = [85, 70, 55, 40] as const; +const CURSOR_VISION_JPEG_QUALITIES_HIGH = [90, 80, 65, 50] as const; +const CURSOR_VISION_SOFT_MIN_EDGE = 256; +const CURSOR_VISION_SOFT_SHRINK = 0.85; + +const CURSOR_VISION_PASSTHROUGH_MIME = new Set([ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", +]); + +/** Upper bound on images attached to one Cursor turn. */ export const MAX_CURSOR_IMAGES = 12; // Wall-clock cap for a single remote image fetch. A malformed env value @@ -64,6 +98,25 @@ export class CursorImageError extends Error { } } +function estimatedBase64DecodedBytes(payload: string): number { + return Math.floor((payload.length * 3) / 4); +} + +function isHighDetail(detail: string | undefined): boolean { + const normalized = (detail || "").toLowerCase(); + return normalized === "high" || normalized === "original"; +} + +function softMaxBytesForDetail(detail: string | undefined): number { + return isHighDetail(detail) ? CURSOR_VISION_SOFT_MAX_BYTES_HIGH : CURSOR_VISION_SOFT_MAX_BYTES; +} + +function jpegQualitiesForDetail(detail: string | undefined): readonly number[] { + return isHighDetail(detail) + ? CURSOR_VISION_JPEG_QUALITIES_HIGH + : CURSOR_VISION_JPEG_QUALITIES_DEFAULT; +} + function decodeDataUrl(url: string): { data: Buffer; mimeType: string } { // data:[][;base64], const comma = url.indexOf(","); @@ -86,16 +139,21 @@ function decodeDataUrl(url: string): { data: Buffer; mimeType: string } { // Reject on the raw payload length BEFORE the regex/normalize pass, so an // arbitrarily large data URL can't burn CPU on the whitespace strip. Base64 - // expands ~4:3, so 2x the byte cap is a safe upper bound on the encoded text. - if (payload.length > MAX_CURSOR_IMAGE_BYTES * 2) { - throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry."); + // expands ~4:3, so 2x the decode ceiling is a safe upper bound on the text. + if (payload.length > MAX_CURSOR_IMAGE_DECODE_BYTES * 2) { + throw new CursorImageError("Image input is too large to process safely."); } const normalized = payload.replace(/\s/g, ""); - // Cheap pre-check: 4 base64 chars -> 3 bytes. Reject obviously oversized - // payloads before allocating the decode buffer. - if (Math.floor((normalized.length * 3) / 4) > MAX_CURSOR_IMAGE_BYTES) { - throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry."); + if (normalized.length === 0) { + throw new CursorImageError("Image data URL contains invalid base64 data."); + } + // Reject lenient Buffer.from acceptances (wrong alphabet, bad padding). + if (normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(normalized)) { + throw new CursorImageError("Image data URL contains invalid base64 data."); + } + if (estimatedBase64DecodedBytes(normalized) > MAX_CURSOR_IMAGE_DECODE_BYTES) { + throw new CursorImageError("Image input is too large to process safely."); } let data: Buffer; @@ -104,11 +162,16 @@ function decodeDataUrl(url: string): { data: Buffer; mimeType: string } { } catch { throw new CursorImageError("Image data URL contains invalid base64 data."); } - // Buffer.from(base64) silently drops invalid trailing chars; guard against a - // payload that decoded to nothing despite being non-empty. - if (normalized.length > 0 && data.length === 0) { + if (data.length === 0) { throw new CursorImageError("Image data URL contains invalid base64 data."); } + // Round-trip guard: Node can silently drop trailing garbage. + if (data.toString("base64").replace(/=+$/, "") !== normalized.replace(/=+$/, "")) { + throw new CursorImageError("Image data URL contains invalid base64 data."); + } + if (data.length > MAX_CURSOR_IMAGE_DECODE_BYTES) { + throw new CursorImageError("Image input is too large to process safely."); + } return { data, mimeType }; } @@ -216,10 +279,10 @@ async function fetchImageBytes(url: string): Promise<{ data: Buffer; mimeType: s // Reject early on an oversized Content-Length, then still cap during read // (the header is advisory / may be absent). const declaredLen = Number(response.headers.get("content-length") || "0"); - if (Number.isFinite(declaredLen) && declaredLen > MAX_CURSOR_IMAGE_BYTES) { - throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry."); + if (Number.isFinite(declaredLen) && declaredLen > MAX_CURSOR_IMAGE_DECODE_BYTES) { + throw new CursorImageError("Image input is too large to process safely."); } - const data = await readCapped(response, MAX_CURSOR_IMAGE_BYTES); + const data = await readCapped(response, MAX_CURSOR_IMAGE_DECODE_BYTES); return { data, mimeType }; } finally { clearTimeout(timer); @@ -249,7 +312,7 @@ async function readCapped(response: Response, cap: number): Promise { const pushCapped = (chunk: Uint8Array) => { total += chunk.byteLength; if (total > cap) { - throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry."); + throw new CursorImageError("Image input is too large to process safely."); } chunks.push(Buffer.from(chunk)); }; @@ -284,22 +347,311 @@ async function readCapped(response: Response, cap: number): Promise { // Last resort: buffer then cap-check (only exotic non-stream bodies). const buf = Buffer.from(await response.arrayBuffer()); if (buf.length > cap) { - throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry."); + throw new CursorImageError("Image input is too large to process safely."); } return buf; } +/** Magic-byte format sniff (independent of declared MIME). */ +export function sniffCursorImageFormat( + data: Uint8Array +): "png" | "jpeg" | "gif" | "webp" | undefined { + if ( + data.byteLength >= 8 && + data[0] === 0x89 && + data[1] === 0x50 && + data[2] === 0x4e && + data[3] === 0x47 && + data[4] === 0x0d && + data[5] === 0x0a && + data[6] === 0x1a && + data[7] === 0x0a + ) { + return "png"; + } + if ( + data.byteLength >= 6 && + data[0] === 0x47 && + data[1] === 0x49 && + data[2] === 0x46 && + data[3] === 0x38 + ) { + return "gif"; + } + if (data.byteLength >= 4 && data[0] === 0xff && data[1] === 0xd8) return "jpeg"; + if ( + data.byteLength >= 12 && + data[0] === 0x52 && + data[1] === 0x49 && + data[2] === 0x46 && + data[3] === 0x46 && + data[8] === 0x57 && + data[9] === 0x45 && + data[10] === 0x42 && + data[11] === 0x50 + ) { + return "webp"; + } + return undefined; +} + +/** + * Sniff PNG/JPEG/GIF/WebP dimensions from raw bytes when the header is present. + * Best-effort only — unknown formats return undefined (dimension is optional). + */ +export function sniffCursorImageDimensions( + data: Uint8Array +): { width: number; height: number } | undefined { + // PNG: signature + IHDR chunk (width/height at bytes 16..23) + if ( + data.byteLength >= 24 && + data[0] === 0x89 && + data[1] === 0x50 && + data[2] === 0x4e && + data[3] === 0x47 && + data[4] === 0x0d && + data[5] === 0x0a && + data[6] === 0x1a && + data[7] === 0x0a + ) { + const width = ((data[16]! << 24) | (data[17]! << 16) | (data[18]! << 8) | data[19]!) >>> 0; + const height = ((data[20]! << 24) | (data[21]! << 16) | (data[22]! << 8) | data[23]!) >>> 0; + if (width > 0 && height > 0) return { width, height }; + } + // GIF: "GIF8" + width/height as little-endian u16 at bytes 6..9 + if ( + data.byteLength >= 10 && + data[0] === 0x47 && + data[1] === 0x49 && + data[2] === 0x46 && + data[3] === 0x38 + ) { + const width = data[6]! | (data[7]! << 8); + const height = data[8]! | (data[9]! << 8); + if (width > 0 && height > 0) return { width, height }; + } + // WebP: RIFF....WEBP + VP8X / VP8 / VP8L + if ( + data.byteLength >= 30 && + data[0] === 0x52 && + data[1] === 0x49 && + data[2] === 0x46 && + data[3] === 0x46 && + data[8] === 0x57 && + data[9] === 0x45 && + data[10] === 0x42 && + data[11] === 0x50 + ) { + const fourcc = String.fromCharCode(data[12]!, data[13]!, data[14]!, data[15]!); + if (fourcc === "VP8X") { + const width = 1 + (data[24]! | (data[25]! << 8) | (data[26]! << 16)); + const height = 1 + (data[27]! | (data[28]! << 8) | (data[29]! << 16)); + if (width > 0 && height > 0) return { width, height }; + } else if (fourcc === "VP8 ") { + if (data[23] === 0x9d && data[24] === 0x01 && data[25] === 0x2a) { + const width = (data[26]! | (data[27]! << 8)) & 0x3fff; + const height = (data[28]! | (data[29]! << 8)) & 0x3fff; + if (width > 0 && height > 0) return { width, height }; + } + } else if (fourcc === "VP8L" && data[20] === 0x2f) { + const raw = data[21]! | (data[22]! << 8) | (data[23]! << 16) | (data[24]! << 24); + const width = (raw & 0x3fff) + 1; + const height = ((raw >> 14) & 0x3fff) + 1; + if (width > 0 && height > 0) return { width, height }; + } + } + // JPEG: scan for SOF0/SOF2 marker with dimensions + if (data.byteLength >= 4 && data[0] === 0xff && data[1] === 0xd8) { + let offset = 2; + while (offset + 8 < data.byteLength) { + if (data[offset] !== 0xff) break; + const marker = data[offset + 1]!; + // Standalone markers (TEM, RSTn, SOI, EOI) carry no length payload. + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) { + offset += 2; + continue; + } + const length = (data[offset + 2]! << 8) | data[offset + 3]!; + if (marker === 0xc0 || marker === 0xc2) { + const height = (data[offset + 5]! << 8) | data[offset + 6]!; + const width = (data[offset + 7]! << 8) | data[offset + 8]!; + if (width > 0 && height > 0) return { width, height }; + break; + } + if (length < 2) break; + offset += 2 + length; + } + } + return undefined; +} + +type PreparedImage = { + data: Buffer; + mimeType: string; + width?: number; + height?: number; +}; + +/** + * Re-encode toward a JPEG under the soft vision cap when sharp can decode the + * payload. Fail-closed with CursorImageError on unsupported MIME, decode bombs, + * or undecodable bytes. After the quality ladder, edges shrink iteratively + * until the soft byte cap is met (or the min edge floor is hit). + */ +export async function prepareCursorImageForWire(input: { + data: Buffer; + mimeType: string; + detail?: string; +}): Promise { + const mime = input.mimeType.toLowerCase(); + const softMax = softMaxBytesForDetail(input.detail); + const qualities = jpegQualitiesForDetail(input.detail); + const lowestQuality = qualities[qualities.length - 1]!; + + if (!CURSOR_VISION_PASSTHROUGH_MIME.has(mime)) { + throw new CursorImageError("Image input type is unsupported."); + } + + const format = sniffCursorImageFormat(input.data); + const sniffed = sniffCursorImageDimensions(input.data); + if (sniffed) { + const edge = Math.max(sniffed.width, sniffed.height); + const pixels = sniffed.width * sniffed.height; + if (edge > MAX_CURSOR_IMAGE_DECODE_EDGE || pixels > MAX_CURSOR_IMAGE_PIXELS) { + throw new CursorImageError("Image input dimensions are too large."); + } + } + + // Soft-cap skip: already soft-capped JPEG that has a real SOF (not SOI-only). + const declaredJpeg = mime === "image/jpeg" || mime === "image/jpg"; + const alreadySmallJpeg = + declaredJpeg && format === "jpeg" && sniffed !== undefined && input.data.byteLength <= softMax; + if (alreadySmallJpeg) { + return { + data: input.data, + mimeType: "image/jpeg", + width: sniffed!.width, + height: sniffed!.height, + }; + } + + try { + // Force a full decode before accepting passthrough / encode. + await sharp(input.data, { failOn: "error" }).resize(1, 1).jpeg({ quality: 1 }).toBuffer(); + + // Passthrough only when declared MIME matches actual JPEG magic. + if (declaredJpeg && format === "jpeg" && input.data.byteLength <= softMax) { + const dims = sniffed ?? (await sharp(input.data).metadata()); + const width = typeof dims.width === "number" ? dims.width : undefined; + const height = typeof dims.height === "number" ? dims.height : undefined; + return { + data: input.data, + mimeType: "image/jpeg", + ...(width && height && width > 0 && height > 0 ? { width, height } : {}), + }; + } + + const meta = await sharp(input.data).metadata(); + const width = typeof meta.width === "number" ? meta.width : 0; + const height = typeof meta.height === "number" ? meta.height : 0; + if (width > 0 && height > 0) { + const edge = Math.max(width, height); + if (edge > MAX_CURSOR_IMAGE_DECODE_EDGE || width * height > MAX_CURSOR_IMAGE_PIXELS) { + throw new CursorImageError("Image input dimensions are too large."); + } + } + + let targetW = width; + let targetH = height; + if (width > 0 && height > 0 && Math.max(width, height) > CURSOR_VISION_MAX_EDGE) { + const scale = CURSOR_VISION_MAX_EDGE / Math.max(width, height); + targetW = Math.max(1, Math.round(width * scale)); + targetH = Math.max(1, Math.round(height * scale)); + } + + const encodeAt = async (w: number, h: number, quality: number): Promise => { + let pipeline = sharp(input.data, { failOn: "error" }); + if (w > 0 && h > 0 && (w !== width || h !== height)) { + pipeline = pipeline.resize(w, h); + } + return pipeline.jpeg({ quality, mozjpeg: true }).toBuffer(); + }; + + let best: Buffer | undefined; + for (const quality of qualities) { + const encoded = await encodeAt(targetW, targetH, quality); + if (!best || encoded.byteLength < best.byteLength) best = encoded; + if (encoded.byteLength <= softMax) { + const outDims = sniffCursorImageDimensions(encoded); + return { + data: encoded, + mimeType: "image/jpeg", + ...(outDims ?? (targetW > 0 && targetH > 0 ? { width: targetW, height: targetH } : {})), + }; + } + } + + while ( + best && + best.byteLength > softMax && + targetW > 0 && + targetH > 0 && + Math.max(targetW, targetH) > CURSOR_VISION_SOFT_MIN_EDGE + ) { + const nextW = Math.max(1, Math.round(targetW * CURSOR_VISION_SOFT_SHRINK)); + const nextH = Math.max(1, Math.round(targetH * CURSOR_VISION_SOFT_SHRINK)); + if (Math.max(nextW, nextH) < CURSOR_VISION_SOFT_MIN_EDGE) { + const scale = CURSOR_VISION_SOFT_MIN_EDGE / Math.max(targetW, targetH); + targetW = Math.max(1, Math.round(targetW * scale)); + targetH = Math.max(1, Math.round(targetH * scale)); + } else { + targetW = nextW; + targetH = nextH; + } + const encoded = await encodeAt(targetW, targetH, lowestQuality); + if (!best || encoded.byteLength < best.byteLength) best = encoded; + if (encoded.byteLength <= softMax) { + const outDims = sniffCursorImageDimensions(encoded); + return { + data: encoded, + mimeType: "image/jpeg", + ...(outDims ?? { width: targetW, height: targetH }), + }; + } + if (Math.max(targetW, targetH) <= CURSOR_VISION_SOFT_MIN_EDGE) break; + } + + if (best) { + const outDims = sniffCursorImageDimensions(best); + return { + data: best, + mimeType: "image/jpeg", + ...(outDims ?? (targetW > 0 && targetH > 0 ? { width: targetW, height: targetH } : {})), + }; + } + + if (declaredJpeg && format !== "jpeg") { + throw new CursorImageError("Image input is not a valid JPEG."); + } + throw new CursorImageError("Image input could not be prepared for Cursor vision."); + } catch (err) { + if (err instanceof CursorImageError) throw err; + throw new CursorImageError("Image input is undecodable or unsupported."); + } +} + /** * Resolve OpenAI `image_url` URLs (data: or http(s):) into EncodedImage[] - * ready to inline into a cursor request. Each image gets a stable random uuid. - * Throws CursorImageError (clean message, sanitizable) on any invalid / - * oversized / blocked input. + * ready for SelectedImage blobIdWithData encoding. Each image gets a stable + * random uuid. Throws CursorImageError (clean message, sanitizable) on any + * invalid / oversized / blocked / undecodable input. */ -export async function resolveCursorImages(imageUrls: string[]): Promise { +export async function resolveCursorImages( + imageUrls: string[], + options?: { detail?: string } +): Promise { if (imageUrls.length > MAX_CURSOR_IMAGES) { - throw new CursorImageError( - `Too many images in one request (max ${MAX_CURSOR_IMAGES}).` - ); + throw new CursorImageError(`Too many images in one request (max ${MAX_CURSOR_IMAGES}).`); } const out: EncodedImage[] = []; for (const url of imageUrls) { @@ -314,10 +666,27 @@ export async function resolveCursorImages(imageUrls: string[]): Promise MAX_CURSOR_IMAGE_BYTES) { + if (data.length > MAX_CURSOR_IMAGE_DECODE_BYTES) { + throw new CursorImageError("Image input is too large to process safely."); + } + + const prepared = await prepareCursorImageForWire({ + data, + mimeType, + detail: options?.detail, + }); + if (prepared.data.length > MAX_CURSOR_IMAGE_BYTES) { throw new CursorImageError("Image input is too large (max 1 MiB). Resize and retry."); } - out.push({ data, mimeType, uuid: crypto.randomUUID() }); + + out.push({ + data: prepared.data, + mimeType: prepared.mimeType, + uuid: crypto.randomUUID(), + ...(typeof prepared.width === "number" && typeof prepared.height === "number" + ? { width: prepared.width, height: prepared.height } + : {}), + }); } return out; } @@ -327,17 +696,11 @@ export async function resolveCursorImages(imageUrls: string[]): Promise=18" } @@ -32778,7 +32778,6 @@ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", @@ -32828,7 +32827,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index b1430ec223..bcc3b19c4e 100644 --- a/package.json +++ b/package.json @@ -311,6 +311,7 @@ "recharts": "^3.8.1", "safe-regex": "^2.1.1", "selfsigned": "^5.5.0", + "sharp": "^0.35.3", "smol-toml": "1.7.1", "socks": "^2.8.7", "sql.js": "^1.14.1", diff --git a/tests/unit/cursor-image-input.test.ts b/tests/unit/cursor-image-input.test.ts index 1e16035c8f..6aa62aa775 100644 --- a/tests/unit/cursor-image-input.test.ts +++ b/tests/unit/cursor-image-input.test.ts @@ -1,18 +1,25 @@ import test from "node:test"; import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import dns from "node:dns"; +import sharp from "sharp"; import { encodeSelectedImageBody, encodeAgentRunRequest, type EncodedImage, } from "../../open-sse/utils/cursorAgentProtobuf"; -import dns from "node:dns"; import { resolveCursorImages, extractImageUrls, assertResolvedAddressesPublic, + prepareCursorImageForWire, + sniffCursorImageDimensions, + sniffCursorImageFormat, CursorImageError, MAX_CURSOR_IMAGE_BYTES, + MAX_CURSOR_IMAGE_DECODE_BYTES, MAX_CURSOR_IMAGES, + CURSOR_VISION_SOFT_MAX_BYTES, } from "../../open-sse/utils/cursorImages"; import { CursorExecutor } from "../../open-sse/executors/cursor"; @@ -20,12 +27,36 @@ import { CursorExecutor } from "../../open-sse/executors/cursor"; // example hostnames) pass the DNS-rebinding gate. const PUBLIC_IP = [{ address: "93.184.216.34", family: 4 }]; +/** Tiny valid 1x1 PNG (red pixel). */ +const TINY_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64" +); + +async function makeTinyJpeg(): Promise { + return sharp({ + create: { width: 8, height: 8, channels: 3, background: { r: 20, g: 40, b: 60 } }, + }) + .jpeg({ quality: 80 }) + .toBuffer(); +} + +async function makeLargePng(edge = 1200): Promise { + // Uncompressed-ish PNG well over the soft cap but under the decode ceiling. + return sharp({ + create: { + width: edge, + height: edge, + channels: 3, + background: { r: 180, g: 90, b: 30 }, + }, + }) + .png({ compressionLevel: 0 }) + .toBuffer(); +} + // ─── Minimal protobuf field walker (test-only) ────────────────────────────── -// Mirrors the production decoder enough to assert field layout without exposing -// the internal decodeFields helper. -type WalkField = - | { fn: number; wt: 0; varint: bigint } - | { fn: number; wt: 2; bytes: Buffer }; +type WalkField = { fn: number; wt: 0; varint: bigint } | { fn: number; wt: 2; bytes: Buffer }; function walk(buf: Buffer): WalkField[] { const out: WalkField[] = []; @@ -68,9 +99,6 @@ const lenBytes = (fields: WalkField[], fn: number): Buffer => { return Buffer.from((f as { bytes: Buffer }).bytes); }; -// Navigate AgentClientMessage(1) -> AgentRunRequest -> action(2) -> -// ConversationAction -> user_message_action(1) -> UserMessageAction -> -// user_message(1) -> UserMessage. function navUserMessage(req: Buffer): WalkField[] { const acm = walk(req); const arr = walk(lenBytes(acm, 1)); @@ -79,34 +107,56 @@ function navUserMessage(req: Buffer): WalkField[] { return walk(lenBytes(uma, 1)); } +function decodeBlobIdWithData(fields: WalkField[]): { blobId: Buffer; data: Buffer } { + assert.equal(find(fields, 8), undefined, "legacy field 8 (data) must be absent"); + const nested = walk(lenBytes(fields, 9)); + return { + blobId: lenBytes(nested, 1), + data: lenBytes(nested, 2), + }; +} + // ─── encodeSelectedImageBody field layout ─────────────────────────────────── -test("encodeSelectedImageBody emits uuid(2), dimension(4), mime_type(7), data(8)", () => { +test("encodeSelectedImageBody emits uuid(2), path(3), dimension(4), mime_type(7), blobIdWithData(9)", () => { const data = Buffer.from([1, 2, 3, 4, 5]); - const body = encodeSelectedImageBody({ - data, - mimeType: "image/png", - width: 10, - height: 20, - uuid: "abc-123", - }); + const blobStore = new Map(); + const body = encodeSelectedImageBody( + { + data, + mimeType: "image/png", + width: 10, + height: 20, + uuid: "abc-123", + }, + blobStore + ); const fields = walk(body); assert.equal(lenBytes(fields, 2).toString("utf8"), "abc-123"); // uuid + assert.equal(lenBytes(fields, 3).toString("utf8"), "attachment-abc-123.png"); // path const dim = walk(lenBytes(fields, 4)); // dimension submessage assert.equal(Number((find(dim, 1) as { varint: bigint }).varint), 10); // width assert.equal(Number((find(dim, 2) as { varint: bigint }).varint), 20); // height assert.equal(lenBytes(fields, 7).toString("utf8"), "image/png"); // mime_type - assert.deepEqual(lenBytes(fields, 8), data); // inline data (oneof case) + + const expectedBlobId = crypto.createHash("sha256").update(data).digest(); + const { blobId, data: nestedData } = decodeBlobIdWithData(fields); + assert.deepEqual(blobId, expectedBlobId); + assert.deepEqual(nestedData, data); + assert.deepEqual(blobStore.get(expectedBlobId.toString("hex")), data); }); test("encodeSelectedImageBody omits dimension/mime_type when not provided", () => { - const body = encodeSelectedImageBody({ data: Buffer.from([9]), uuid: "u" }); + const data = Buffer.from([9]); + const body = encodeSelectedImageBody({ data, uuid: "u" }); const fields = walk(body); assert.equal(find(fields, 4), undefined, "no dimension"); assert.equal(find(fields, 7), undefined, "no mime_type"); assert.ok(find(fields, 2), "uuid present"); - assert.deepEqual(lenBytes(fields, 8), Buffer.from([9]), "data present"); + assert.ok(find(fields, 3), "path present"); + const { data: nestedData } = decodeBlobIdWithData(fields); + assert.deepEqual(nestedData, data); }); test("encodeSelectedImageBody omits dimension when width/height are invalid", () => { @@ -134,7 +184,6 @@ test("no-image request is byte-identical to images:undefined and images:[]", () assert.ok(plain.equals(undef), "images:undefined matches no images"); assert.ok(plain.equals(empty), "images:[] matches no images"); - // And selected_context (field 3) is present but empty in the no-image case. const um = navUserMessage(plain); const sc = find(um, 3); assert.ok(sc && sc.wt === 2, "selected_context present"); @@ -143,17 +192,19 @@ test("no-image request is byte-identical to images:undefined and images:[]", () // ─── Images attach under UserMessage.selected_context.selected_images ──────── -test("images attach as selected_context.selected_images[] with inline data", () => { +test("images attach as selected_context.selected_images[] with blobIdWithData", () => { const imgs: EncodedImage[] = [ { data: Buffer.from([0xaa, 0xbb]), mimeType: "image/png", uuid: "u1" }, { data: Buffer.from([0xcc]), mimeType: "image/jpeg", uuid: "u2" }, ]; + const blobStore = new Map(); const req = encodeAgentRunRequest({ modelId: "gpt-5.2", userText: "what colors?", conversationId: "c", messageId: "m", images: imgs, + blobStore, }); const um = navUserMessage(req); const sc = walk(lenBytes(um, 3)); // SelectedContext @@ -163,12 +214,14 @@ test("images attach as selected_context.selected_images[] with inline data", () const first = walk(Buffer.from((selectedImages[0] as { bytes: Buffer }).bytes)); assert.equal(lenBytes(first, 2).toString("utf8"), "u1"); assert.equal(lenBytes(first, 7).toString("utf8"), "image/png"); - assert.deepEqual(lenBytes(first, 8), Buffer.from([0xaa, 0xbb])); + const firstNested = decodeBlobIdWithData(first); + assert.deepEqual(firstNested.data, Buffer.from([0xaa, 0xbb])); + assert.deepEqual(blobStore.get(firstNested.blobId.toString("hex")), Buffer.from([0xaa, 0xbb])); const second = walk(Buffer.from((selectedImages[1] as { bytes: Buffer }).bytes)); - assert.deepEqual(lenBytes(second, 8), Buffer.from([0xcc])); + const secondNested = decodeBlobIdWithData(second); + assert.deepEqual(secondNested.data, Buffer.from([0xcc])); - // UserMessage.text (field 1) still carries the prompt text alongside images. assert.equal(lenBytes(um, 1).toString("utf8"), "what colors?"); }); @@ -178,11 +231,11 @@ test("extractImageUrls pulls urls from object and string image_url parts", () => assert.deepEqual( extractImageUrls([ { type: "text", text: "hi" }, - { type: "image_url", image_url: { url: "data:image/png;base64,AA" } }, + { type: "image_url", image_url: { url: "data:image/png;base64,AA==" } }, { type: "image_url", image_url: "https://x.test/y.png" }, { type: "image_url", image_url: { detail: "high" } }, // no url -> ignored ]), - ["data:image/png;base64,AA", "https://x.test/y.png"] + ["data:image/png;base64,AA==", "https://x.test/y.png"] ); assert.deepEqual(extractImageUrls("plain string content"), []); assert.deepEqual(extractImageUrls(null), []); @@ -191,12 +244,14 @@ test("extractImageUrls pulls urls from object and string image_url parts", () => // ─── resolveCursorImages: happy path ──────────────────────────────────────── test("resolveCursorImages decodes a valid base64 data URI", async () => { - const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); - const out = await resolveCursorImages([`data:image/png;base64,${png.toString("base64")}`]); + const out = await resolveCursorImages([`data:image/png;base64,${TINY_PNG.toString("base64")}`]); assert.equal(out.length, 1); - assert.deepEqual(out[0].data, png); - assert.equal(out[0].mimeType, "image/png"); + assert.equal(out[0].mimeType, "image/jpeg"); // soft-cap prep re-encodes to JPEG + assert.ok(out[0].data.length > 0); + assert.ok(out[0].data.length <= CURSOR_VISION_SOFT_MAX_BYTES); assert.ok(out[0].uuid && out[0].uuid.length > 0); + assert.equal(out[0].width, 1); + assert.equal(out[0].height, 1); }); // ─── resolveCursorImages: rejections (all CursorImageError, all sanitized) ─── @@ -215,6 +270,15 @@ test("resolveCursorImages rejects invalid base64", async () => { ); }); +test("resolveCursorImages rejects base64 with trailing garbage (strict round-trip)", async () => { + // Buffer.from would silently drop the trailing "!!!!" — we must reject. + const padded = `${TINY_PNG.toString("base64")}!!!!`; + await assert.rejects( + () => resolveCursorImages([`data:image/png;base64,${padded}`]), + (e) => e instanceof CursorImageError + ); +}); + test("resolveCursorImages rejects a non-base64 data URI", async () => { await assert.rejects( () => resolveCursorImages(["data:image/png,not-base64-payload"]), @@ -222,8 +286,8 @@ test("resolveCursorImages rejects a non-base64 data URI", async () => { ); }); -test("resolveCursorImages rejects an oversized image (>1 MiB)", async () => { - const big = Buffer.alloc(MAX_CURSOR_IMAGE_BYTES + 16).toString("base64"); +test("resolveCursorImages rejects an oversized image over the decode ceiling", async () => { + const big = Buffer.alloc(MAX_CURSOR_IMAGE_DECODE_BYTES + 16).toString("base64"); await assert.rejects( () => resolveCursorImages([`data:image/png;base64,${big}`]), (e) => e instanceof CursorImageError @@ -248,7 +312,7 @@ test("resolveCursorImages blocks SSRF targets (localhost, link-local, file://)", }); test("resolveCursorImages rejects too many images", async () => { - const one = "data:image/png;base64,AAAA"; + const one = `data:image/png;base64,${TINY_PNG.toString("base64")}`; await assert.rejects( () => resolveCursorImages(Array.from({ length: MAX_CURSOR_IMAGES + 1 }, () => one)), (e) => e instanceof CursorImageError @@ -256,26 +320,27 @@ test("resolveCursorImages rejects too many images", async () => { }); test("resolveCursorImages accepts an uppercase DATA: scheme (RFC 2397 case-insensitive)", async () => { - const png = Buffer.from([137, 80, 78, 71]); - const out = await resolveCursorImages([`DATA:image/png;base64,${png.toString("base64")}`]); + const out = await resolveCursorImages([`DATA:image/png;base64,${TINY_PNG.toString("base64")}`]); assert.equal(out.length, 1); - assert.deepEqual(out[0].data, png); - assert.equal(out[0].mimeType, "image/png"); + assert.equal(out[0].mimeType, "image/jpeg"); + assert.ok(out[0].data.length > 0); }); test("assertResolvedAddressesPublic blocks private/metadata IPs, allows public", () => { for (const ip of ["127.0.0.1", "10.0.0.1", "169.254.169.254", "192.168.1.1", "::1", "fd00::1"]) { - assert.throws(() => assertResolvedAddressesPublic([ip]), CursorImageError, `should block ${ip}`); + assert.throws( + () => assertResolvedAddressesPublic([ip]), + CursorImageError, + `should block ${ip}` + ); } assert.doesNotThrow(() => assertResolvedAddressesPublic(["93.184.216.34", "1.1.1.1"])); - // A single private answer among public ones still blocks (DNS-rebinding). assert.throws(() => assertResolvedAddressesPublic(["8.8.8.8", "127.0.0.1"]), CursorImageError); }); test("resolveCursorImages blocks DNS rebinding (public host resolving to a private IP)", async (t) => { t.mock.method(dns.promises, "lookup", async () => [{ address: "127.0.0.1", family: 4 }]); const realFetch = globalThis.fetch; - // fetch should never be reached — the DNS gate blocks first. globalThis.fetch = async () => { throw new Error("fetch must not run for a rebinding host"); }; @@ -290,9 +355,6 @@ test("resolveCursorImages blocks DNS rebinding (public host resolving to a priva }); test("resolveCursorImages re-validates redirects: a 30x to a private host is blocked (SSRF)", async (t) => { - // fetch() follows redirects by default; the resolver uses redirect:"manual" - // and re-validates each hop. A public URL that 302s to 127.0.0.1 must be - // blocked, not followed. t.mock.method(dns.promises, "lookup", async () => PUBLIC_IP); const realFetch = globalThis.fetch; globalThis.fetch = async () => @@ -309,7 +371,6 @@ test("resolveCursorImages re-validates redirects: a 30x to a private host is blo test("resolveCursorImages follows a redirect to another public host and reads the image", async (t) => { t.mock.method(dns.promises, "lookup", async () => PUBLIC_IP); - const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); const realFetch = globalThis.fetch; let call = 0; globalThis.fetch = async () => { @@ -320,7 +381,7 @@ test("resolveCursorImages follows a redirect to another public host and reads th headers: { location: "https://cdn.public.example/a.png" }, }); } - return new Response(new Uint8Array(png), { + return new Response(new Uint8Array(TINY_PNG), { status: 200, headers: { "content-type": "image/png" }, }); @@ -328,8 +389,9 @@ test("resolveCursorImages follows a redirect to another public host and reads th try { const out = await resolveCursorImages(["https://public.example/a.png"]); assert.equal(out.length, 1); - assert.deepEqual(out[0].data, png); - assert.equal(out[0].mimeType, "image/png"); + assert.equal(out[0].mimeType, "image/jpeg"); + assert.ok(out[0].data.length > 0); + assert.ok(out[0].data.length <= MAX_CURSOR_IMAGE_BYTES); } finally { globalThis.fetch = realFetch; } @@ -353,13 +415,67 @@ test("resolveCursorImages rejects an over-long redirect chain", async (t) => { } }); +// ─── JPEG soft-cap / sniff regressions ────────────────────────────────────── + +test("prepareCursorImageForWire re-encodes a large PNG under the soft cap", async () => { + const large = await makeLargePng(1400); + assert.ok(large.length > CURSOR_VISION_SOFT_MAX_BYTES, "fixture must exceed soft cap"); + assert.ok(large.length < MAX_CURSOR_IMAGE_DECODE_BYTES, "fixture under decode ceiling"); + const prepared = await prepareCursorImageForWire({ + data: large, + mimeType: "image/png", + }); + assert.equal(prepared.mimeType, "image/jpeg"); + assert.ok(prepared.data.length <= CURSOR_VISION_SOFT_MAX_BYTES); + assert.ok(prepared.data.length <= MAX_CURSOR_IMAGE_BYTES); + assert.equal(sniffCursorImageFormat(prepared.data), "jpeg"); + const dims = sniffCursorImageDimensions(prepared.data); + assert.ok(dims && dims.width > 0 && dims.height > 0); +}); + +test("prepareCursorImageForWire does not passthrough mislabeled PNG-as-JPEG", async () => { + // Declared JPEG but bytes are PNG — must re-encode (or fail), never SOI-less passthrough. + const prepared = await prepareCursorImageForWire({ + data: TINY_PNG, + mimeType: "image/jpeg", + }); + assert.equal(prepared.mimeType, "image/jpeg"); + assert.equal(sniffCursorImageFormat(prepared.data), "jpeg"); + assert.ok(sniffCursorImageDimensions(prepared.data), "JPEG must have a real SOF"); +}); + +test("prepareCursorImageForWire skips re-encode for small real JPEG with SOF", async () => { + const jpeg = await makeTinyJpeg(); + assert.ok(jpeg.length <= CURSOR_VISION_SOFT_MAX_BYTES); + assert.equal(sniffCursorImageFormat(jpeg), "jpeg"); + assert.ok(sniffCursorImageDimensions(jpeg), "fixture must have SOF dims"); + const prepared = await prepareCursorImageForWire({ + data: jpeg, + mimeType: "image/jpeg", + }); + assert.deepEqual(prepared.data, jpeg); + assert.equal(prepared.mimeType, "image/jpeg"); +}); + +test("sniffCursorImageDimensions reads PNG IHDR", () => { + const dims = sniffCursorImageDimensions(TINY_PNG); + assert.deepEqual(dims, { width: 1, height: 1 }); +}); + +test("resolveCursorImages soft-caps a large PNG under the wire budget", async () => { + const large = await makeLargePng(1400); + const out = await resolveCursorImages([`data:image/png;base64,${large.toString("base64")}`]); + assert.equal(out.length, 1); + assert.equal(out[0].mimeType, "image/jpeg"); + assert.ok(out[0].data.length <= CURSOR_VISION_SOFT_MAX_BYTES); + assert.ok(out[0].data.length <= MAX_CURSOR_IMAGE_BYTES); +}); + // ─── Executor-level error body (response path, hard rule #12) ─────────────── test("executor returns a sanitized 400 for an oversized image", async () => { - // buildRequest throws CursorImageError before any network/session/DB work, - // so this stays fully offline (no token needed). const exec = new CursorExecutor(); - const big = Buffer.alloc(MAX_CURSOR_IMAGE_BYTES + 16).toString("base64"); + const big = Buffer.alloc(MAX_CURSOR_IMAGE_DECODE_BYTES + 16).toString("base64"); const result = await exec.execute({ model: "gpt-5.2", body: { @@ -383,7 +499,6 @@ test("executor returns a sanitized 400 for an oversized image", async () => { const body = await result.response.json(); assert.ok(body.error, "error envelope present"); assert.match(body.error.message, /too large/i); - // No stack-trace / source-path leakage in the response body (hard rule #12). assert.ok(!body.error.message.includes("at /"), "no stack frame in error body"); assert.ok(!/\/(root|home|usr)\//.test(body.error.message), "no absolute path in error body"); }); @@ -415,9 +530,6 @@ test("executor returns a sanitized 400 for an SSRF-blocked image URL", async () }); test("CursorImageError messages never leak stack traces or paths", async () => { - // Every rejection message must be a clean human string (no "at /" frames, - // no absolute paths) so the executor's sanitized 400 body stays clean - // (hard rule #12). const triggers = [ "data:text/plain;base64,aGVsbG8=", "data:image/png;base64,@@@@", From 2d49f1c743c429c3aa06fe2de02fe2e6b762bb05 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:47:17 -0300 Subject: [PATCH 023/100] maint: follow-up cherry-pick fix-in-place #9833 (conflict-resolved fallback) (#9899) * fix(nvidia): keep 410 failures model-scoped * test: register NVIDIA 410 regression for mutation coverage * chore: preserve Stryker config formatting * fix(auth): reduce file size Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Zartharas <1402357+Zartharas@users.noreply.github.com> --- src/sse/services/auth.ts | 69 +------- stryker.conf.json | 1 + tests/unit/nvidia-410-model-scope.test.ts | 196 ++++++++++++++++++++++ 3 files changed, 205 insertions(+), 61 deletions(-) create mode 100644 tests/unit/nvidia-410-model-scope.test.ts diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index b20d3d5d2e..3243a0316a 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -83,7 +83,6 @@ import { getResource404Bypass } from "./requestResourceHealth"; import * as log from "../utils/logger"; import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck"; import { readHeaderValue, type AuthRequestHeaders } from "./headerReader.ts"; - type JsonRecord = Record; interface RecoverableConnectionState { connectionId: string; @@ -94,7 +93,6 @@ interface RecoverableConnectionState { lastErrorType?: string | null; lastErrorSource?: string | null; } - interface CredentialSelectionOptions { allowSuppressedConnections?: boolean; allowRateLimitedConnections?: boolean; @@ -104,14 +102,12 @@ interface CredentialSelectionOptions { sessionKey?: string | null; sessionAffinityTtlMs?: number | null; } - interface CooldownInspectionState { connection: ProviderConnectionView; connectionCooldownMs: number | null; codexScopeCooldownMs: number | null; retryableModelCooldownMs: number | null; } - const MIN_QUOTA_THRESHOLD_PERCENT = 1; const MAX_QUOTA_THRESHOLD_PERCENT = 100; const NON_RETRYABLE_MODEL_LOCKOUT_REASONS = new Set(["not_found", "not_found_local"]); @@ -119,25 +115,20 @@ const NON_RETRYABLE_MODEL_LOCKOUT_REASONS = new Set(["not_found", "not_found_loc // this base. Real upstream Retry-After hints still win — they flow through // `exactCooldownMs` (usedUpstreamRetryHint), not this base. (#5222) const ANTIGRAVITY_FAMILY_INFERRED_BASE_COOLDOWN_MS = 30_000; - function asRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } - function toStringOrNull(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } - function toNullableNumber(value: unknown): number | null { if (value === null || value === undefined) return null; const parsed = toNumber(value, Number.NaN); return Number.isFinite(parsed) ? parsed : null; } - function toBooleanOrDefault(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } - function normalizeSessionKey(value: unknown, prefix: string): string | null { if (typeof value !== "string" || value.trim().length === 0) return null; const trimmed = value.trim(); @@ -146,7 +137,6 @@ function normalizeSessionKey(value: unknown, prefix: string): string | null { } return `${prefix}:sha256:${createHash("sha256").update(trimmed).digest("hex")}`; } - function extractTextForSessionHash(value: unknown): string | null { if (typeof value === "string") return value; if (Array.isArray(value)) { @@ -164,7 +154,6 @@ function extractTextForSessionHash(value: unknown): string | null { if (value && typeof value === "object") return JSON.stringify(value); return null; } - function getFirstInputText(body: unknown): string | null { const record = asRecord(body); if (record.input !== undefined) { @@ -189,7 +178,6 @@ function getFirstInputText(body: unknown): string | null { return null; } - export function extractSessionAffinityKey( body: unknown, headers?: Headers | { get?: (name: string) => string | null } | null @@ -216,7 +204,6 @@ export function extractSessionAffinityKey( if (!inputText || inputText.trim().length === 0) return null; return `input:sha256:${createHash("sha256").update(inputText.slice(0, 4096)).digest("hex")}`; } - function getCodexLimitPolicy(providerSpecificData: JsonRecord): { use5h: boolean; useWeekly: boolean; @@ -227,13 +214,11 @@ function getCodexLimitPolicy(providerSpecificData: JsonRecord): { useWeekly: toBooleanOrDefault(policy.useWeekly, true), }; } - interface QuotaLimitPolicy { enabled: boolean; thresholdPercent: number; windows: string[]; } - interface QuotaCacheView { quotas?: Record< string, @@ -243,7 +228,6 @@ interface QuotaCacheView { } >; } - function normalizeQuotaThreshold( value: unknown, fallback = DEFAULT_QUOTA_THRESHOLD_PERCENT @@ -251,17 +235,14 @@ function normalizeQuotaThreshold( const parsed = toNumber(value, fallback); return Math.min(MAX_QUOTA_THRESHOLD_PERCENT, Math.max(MIN_QUOTA_THRESHOLD_PERCENT, parsed)); } - function normalizeWindowName(windowName: unknown): string | null { if (typeof windowName !== "string") return null; const normalized = windowName.trim().toLowerCase(); return normalized.length > 0 ? normalized : null; } - function uniqueWindows(windows: string[]): string[] { return [...new Set(windows)]; } - function normalizeCodexWindowName(windowName: unknown): string | null { if (typeof windowName !== "string") return null; const normalized = windowName.trim().toLowerCase(); @@ -273,7 +254,6 @@ function normalizeCodexWindowName(windowName: unknown): string | null { } return toCodexBaseQuotaWindowName(normalized); } - function applyCodexWindowPolicy(rawWindows: string[], providerSpecificData: JsonRecord): string[] { const codexPolicy = getCodexLimitPolicy(providerSpecificData); const normalizedRaw = rawWindows.map(normalizeCodexWindowName).filter(Boolean) as string[]; @@ -291,7 +271,6 @@ function applyCodexWindowPolicy(rawWindows: string[], providerSpecificData: Json return uniqueWindows(windows); } - function getCodexScopeRateLimitedUntil( providerSpecificData: JsonRecord, model: string | null @@ -302,7 +281,6 @@ function getCodexScopeRateLimitedUntil( const value = scopeMap[scope]; return typeof value === "string" && value.trim().length > 0 ? value : null; } - function isCodexScopeUnavailable( connection: ProviderConnectionView, model: string | null @@ -311,7 +289,6 @@ function isCodexScopeUnavailable( if (!until) return false; return new Date(until).getTime() > Date.now(); } - function getEarliestCodexScopeRateLimitedUntil( connections: ProviderConnectionView[], model: string | null @@ -332,11 +309,9 @@ function getEarliestCodexScopeRateLimitedUntil( return earliest; } - function normalizeStatus(value: string | null): string { return (value || "").trim().toLowerCase(); } - function isTerminalConnectionStatus(connection: ProviderConnectionView): boolean { const status = normalizeStatus(connection.testStatus); return status === "credits_exhausted" || status === "banned" || status === "expired"; @@ -354,7 +329,6 @@ function isRecoverableCookieAuth401( resolveProviderId(provider) in WEB_COOKIE_PROVIDERS ); } - function resolveTerminalConnectionStatus( status: number, result: { permanent?: boolean; creditsExhausted?: boolean }, @@ -381,7 +355,6 @@ function resolveTerminalConnectionStatus( } return null; } - export function resolveQuotaLimitPolicy( provider: string, providerSpecificData: JsonRecord @@ -407,7 +380,6 @@ export function resolveQuotaLimitPolicy( windows, }; } - export function evaluateQuotaLimitPolicy( provider: string, connection: ProviderConnectionView, @@ -440,7 +412,6 @@ export function evaluateQuotaLimitPolicy( resetAt: getEarliestFutureDate(resetCandidates), }; } - function parseFutureDateMs(value: string | null): number | null { if (!value) return null; // Tolerate numeric-epoch strings (e.g. "1781696905131.0") as well as ISO @@ -449,7 +420,6 @@ function parseFutureDateMs(value: string | null): number | null { if (!Number.isFinite(ms) || ms <= Date.now()) return null; return ms; } - function getEarliestFutureDate(candidates: Array): string | null { return ( candidates @@ -461,31 +431,26 @@ function getEarliestFutureDate(candidates: Array): string | null .sort((a, b) => (a.ms as number) - (b.ms as number))[0]?.raw || null ); } - function getCachedQuotaResetAt(connectionId: string): string | null { const entry = getQuotaCache(connectionId); if (!entry?.quotas) return null; return getEarliestFutureDate(Object.values(entry.quotas).map((quota) => quota.resetAt)); } - function isRetryableModelLockoutReason(reason: unknown): boolean { return typeof reason === "string" && reason.length > 0 ? !NON_RETRYABLE_MODEL_LOCKOUT_REASONS.has(reason) : false; } - function pushClampedPercentage(percentages: number[], value: number): void { if (Number.isFinite(value)) { percentages.push(Math.max(0, Math.min(100, value))); } } - function isResetAtInPast(resetAt: string | null): boolean { if (!resetAt) return false; const resetMs = new Date(resetAt).getTime(); return Number.isFinite(resetMs) && resetMs <= Date.now(); } - function collectPolicyQuotaHeadroomPercentages( provider: string, connection: ProviderConnectionView, @@ -508,7 +473,6 @@ function collectPolicyQuotaHeadroomPercentages( return percentages; } - function collectCachedQuotaHeadroomPercentages( provider: string, connection: ProviderConnectionView, @@ -528,7 +492,6 @@ function collectCachedQuotaHeadroomPercentages( return percentages; } - function getConnectionQuotaHeadroomPercent( provider: string, connection: ProviderConnectionView, @@ -548,7 +511,6 @@ function getConnectionQuotaHeadroomPercent( return percentages.length > 0 ? Math.min(...percentages) : null; } - function getConnectionErrorPenalty(connection: ProviderConnectionView): number { const errorType = normalizeStatus(connection.lastErrorType); const errorSource = normalizeStatus(connection.lastErrorSource); @@ -572,7 +534,6 @@ function getConnectionErrorPenalty(connection: ProviderConnectionView): number { return penalty; } - function getConnectionRecencyPenalty(connection: ProviderConnectionView): number { if (!connection.lastUsedAt) return 0; const ageMs = Date.now() - new Date(connection.lastUsedAt).getTime(); @@ -582,7 +543,6 @@ function getConnectionRecencyPenalty(connection: ProviderConnectionView): number if (ageMs < 5 * 60_000) return 1; return 0; } - function getP2CConnectionScore( provider: string, connection: ProviderConnectionView, @@ -628,7 +588,6 @@ function getP2CConnectionScore( return { score, quotaHeadroomPercent }; } - function compareP2CConnections( provider: string, a: ProviderConnectionView, @@ -662,12 +621,10 @@ function compareP2CConnections( * exclude it (#3061), otherwise it gets re-selected forever. */ const SYNTHETIC_NOAUTH_CONNECTION_ID = "noauth"; - type AnonymousFallbackProviderDefinition = { anonymousFallback?: boolean; noAuth?: boolean; }; - function buildSyntheticNoAuthCredentials(providerSpecificData: JsonRecord = {}): { apiKey: null; accessToken: null; @@ -756,7 +713,6 @@ async function loadNoAuthProviderSpecificData(providerId: string): Promise, @@ -790,7 +745,6 @@ async function maybeSyntheticNoAuthFallback( const providerSpecificData = await loadNoAuthProviderSpecificData(providerId); return buildSyntheticNoAuthCredentials(providerSpecificData); } - function normalizeExcludedConnectionIds( excludeConnectionId: string | null, extraExcludedConnectionIds: string[] | null | undefined @@ -811,7 +765,6 @@ function normalizeExcludedConnectionIds( return normalized; } - function formatConnectionPrefixesForLog(ids: Iterable, max = 6): string { const prefixes = Array.from(ids) .filter((id) => typeof id === "string" && id.length > 0) @@ -819,7 +772,6 @@ function formatConnectionPrefixesForLog(ids: Iterable, max = 6): string .map((id) => `${id.slice(0, 8)}...`); return prefixes.length > 0 ? prefixes.join(",") : "none"; } - function buildQuotaPreflightRateLimitedResult( provider: string, blockedByPreflight: Array<{ @@ -850,12 +802,10 @@ function buildQuotaPreflightRateLimitedResult( lastErrorCode: 429, }; } - function quotaPreflightUnavailableUntil(resetAt?: string | null): string { const resetMs = parseFutureDateMs(resetAt ?? null); return new Date(resetMs ?? Date.now() + 5 * 60 * 1000).toISOString(); } - async function markQuotaPreflightAccountUnavailable( provider: string, connectionId: string, @@ -884,14 +834,12 @@ async function markQuotaPreflightAccountUnavailable( // Provider-scoped mutexes prevent race conditions during account selection without // serializing unrelated providers behind a single global lock. const selectionMutexes = new Map>(); - function getSelectionMutexKey(provider: string, options: CredentialSelectionOptions): string { return [ resolveProviderId(provider) || provider, options.forcedConnectionId ? `forced:${options.forcedConnectionId}` : "pool", ].join(":"); } - function createSelectionLock(key: string) { const currentMutex = selectionMutexes.get(key) ?? Promise.resolve(); let resolveMutex: (() => void) | undefined; @@ -923,7 +871,6 @@ export { fisherYatesShuffle, getNextFromDeckSync as getNextFromDeck }; // Re-export readHeaderValue and AuthRequestHeaders from headerReader.ts for // backwards compat with existing imports (e.g. googApiKeyAuth.ts). export { readHeaderValue, type AuthRequestHeaders } from "./headerReader.ts"; - const PROVIDER_SEARCH_PAIRS: string[][] = [ ["nvidia", "nvidia_nim"], ["kimi-coding", "kimi-coding-apikey"], @@ -1703,7 +1650,6 @@ export async function getProviderCredentials( selectionLock.release(); } } - export async function getProviderCredentialsWithQuotaPreflight( provider: string, excludeConnectionId: string | null = null, @@ -2005,16 +1951,17 @@ export async function markAccountUnavailable( const disableCooling = connProviderSpecificData.disableCooling === true; const isPerModelQuotaProvider = hasPerModelQuota(provider, model, connectionPassthroughModels); + const isNvidiaModelGone = provider === "nvidia" && status === 410; const modelLockoutOptions = { maxCooldownMs: effectiveProviderProfile?.maxCooldownMs }; if ( isPerModelQuotaProvider && provider && provider !== "codex" && model && - (status === 404 || status === 429 || status >= 500) + (status === 404 || isNvidiaModelGone || status === 429 || status >= 500) ) { const reason = - status === 404 + status === 404 || isNvidiaModelGone ? "not_found" : status === 429 && fallbackResult.reason === RateLimitReason.QUOTA_EXHAUSTED ? "quota_exhausted" @@ -2046,7 +1993,10 @@ export async function markAccountUnavailable( ? "model" : getQuotaScopeLabelForProvider(provider, model); const antigravityFamilyInferredBaseCooldownMs = - !usesExactAntigravityLock && provider === "antigravity" && quotaScope === "family" && status === 429 + !usesExactAntigravityLock && + provider === "antigravity" && + quotaScope === "family" && + status === 429 ? ANTIGRAVITY_FAMILY_INFERRED_BASE_COOLDOWN_MS : null; const lockout = recordModelLockoutFailure( @@ -2055,7 +2005,7 @@ export async function markAccountUnavailable( model, reason, status, - status === 404 + status === 404 || isNvidiaModelGone ? (effectiveProviderProfile?.baseCooldownMs ?? COOLDOWN_MS.notFoundLocal) : (antigravityFamilyInferredBaseCooldownMs ?? fallbackResult.baseCooldownMs ?? @@ -2352,7 +2302,6 @@ export interface RecoveredStateExpectation { lastErrorAt: string | null; rateLimitedUntil: string | null; } - export async function clearRecoveredProviderState( credentials: Partial | null, expectedState?: RecoveredStateExpectation @@ -2373,12 +2322,10 @@ export async function clearRecoveredProviderState( await clearAccountError(credentials.connectionId, credentials); return { applied: true }; } - type AuthRequestLike = { headers?: AuthRequestHeaders | null; url?: string | null; }; - function readNonEmptyUrlToken(request: AuthRequestLike): string | null { if (typeof request?.url !== "string" || request.url.trim().length === 0) return null; diff --git a/stryker.conf.json b/stryker.conf.json index 0b177926cd..7c52c54726 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -247,6 +247,7 @@ "tests/unit/no-memory-header.test.ts", "tests/unit/noauth-autocombo-lockout-7623.test.ts", "tests/unit/non-streaming-sse-terminal-typescan-4459.test.ts", + "tests/unit/nvidia-410-model-scope.test.ts", "tests/unit/nvidia-passthrough-models-6773.test.ts", "tests/unit/nvidia-quota-phase1.test.ts", "tests/unit/oauth-providers-config.test.ts", diff --git a/tests/unit/nvidia-410-model-scope.test.ts b/tests/unit/nvidia-410-model-scope.test.ts new file mode 100644 index 0000000000..13fbaa6ef6 --- /dev/null +++ b/tests/unit/nvidia-410-model-scope.test.ts @@ -0,0 +1,196 @@ +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-nvidia-410-model-scope-")); + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "nvidia-410-model-scope-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const auth = await import("../../src/sse/services/auth.ts"); +const fallback = await import("../../open-sse/services/accountFallback.ts"); + +const DEAD_MODEL = "deepseek-ai/deepseek-v4-pro"; +const HEALTHY_MODEL = "z-ai/glm-5.2"; + +const GONE_BODY = JSON.stringify({ + type: "about:blank", + title: "Gone", + status: 410, + detail: + "The model 'deepseek-ai/deepseek-v4-pro' has reached its end of life " + + "and is no longer available.", +}); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function seedNvidiaConnection() { + return providersDb.createProviderConnection({ + provider: "nvidia", + authType: "apikey", + name: "nvidia-410-model-scope", + apiKey: "sk-nvidia-410-model-scope", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("NVIDIA 410 Gone stays model-scoped and leaves the connection usable", async () => { + const connection = await seedNvidiaConnection(); + + assert.equal( + fallback.hasPerModelQuota("nvidia", DEAD_MODEL), + true, + "NVIDIA must use per-model failure scoping" + ); + + const result = await auth.markAccountUnavailable( + connection.id, + 410, + GONE_BODY, + "nvidia", + DEAD_MODEL + ); + + assert.equal(result.shouldFallback, true); + + const after = await providersDb.getProviderConnectionById(connection.id); + + assert.equal( + after?.rateLimitedUntil ?? null, + null, + "410 for one retired NVIDIA model must not apply a connection-wide cooldown" + ); + + assert.equal( + after?.testStatus, + "active", + "410 for one retired NVIDIA model must leave the NVIDIA connection active" + ); + + assert.equal( + fallback.isModelLocked("nvidia", connection.id, DEAD_MODEL), + true, + "the retired model itself should be locked" + ); + + assert.equal( + fallback.isModelLocked("nvidia", connection.id, HEALTHY_MODEL), + false, + "a healthy sibling NVIDIA model must remain unlocked" + ); + + const healthyCredentials = await auth.getProviderCredentials("nvidia", null, null, HEALTHY_MODEL); + + assert.equal( + healthyCredentials?.connectionId, + connection.id, + "the same NVIDIA connection must remain selectable for healthy sibling models" + ); +}); + +test("non-per-model provider keeps 410 connection-scoped", async () => { + assert.equal( + fallback.hasPerModelQuota("openai", DEAD_MODEL), + false, + "plain OpenAI API-key connections are not per-model quota providers" + ); + + const connection = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "openai-410-connection-scope", + apiKey: "sk-openai-410-connection-scope", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + + const result = await auth.markAccountUnavailable( + connection.id, + 410, + "Gone", + "openai", + DEAD_MODEL + ); + + assert.equal(result.shouldFallback, true); + + const after = await providersDb.getProviderConnectionById(connection.id); + + assert.ok( + after?.rateLimitedUntil, + "non-per-model providers should retain the existing connection-level 410 behavior" + ); + + assert.equal( + after?.testStatus, + "unavailable", + "410 model scoping must not be applied globally to every provider" + ); +}); + +test("other per-model providers retain existing 410 connection scope", async () => { + assert.equal( + fallback.hasPerModelQuota("gemini", DEAD_MODEL), + true, + "Gemini provides a non-NVIDIA per-model control case" + ); + + const connection = await providersDb.createProviderConnection({ + provider: "gemini", + authType: "apikey", + name: "gemini-410-control", + apiKey: "sk-gemini-410-control", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + + const result = await auth.markAccountUnavailable( + connection.id, + 410, + "Gone", + "gemini", + DEAD_MODEL + ); + + assert.equal(result.shouldFallback, true); + + const after = await providersDb.getProviderConnectionById(connection.id); + + assert.ok( + after?.rateLimitedUntil, + "410 must remain connection-scoped for per-model providers without an explicit 410 contract" + ); + + assert.equal( + after?.testStatus, + "unavailable", + "the NVIDIA-specific 410 fix must not change other provider semantics" + ); + + assert.equal( + fallback.isModelLocked("gemini", connection.id, DEAD_MODEL), + false, + "a generic per-model provider must not inherit NVIDIA's 410 model lock" + ); +}); From bbe5c78f4d06cf713dd3f13c547e419d20065017 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:47:28 -0300 Subject: [PATCH 024/100] cherry-pick(pr-9828): fix(executors): strip redundant oneOf matching sibling enum (#9841) * fix(executors): strip redundant oneOf matching sibling enum The Codex private Responses endpoint intermittently returns a 502 upstream_empty_response for tool parameters that combine oneOf:[{const,...}] with a sibling enum containing the same value set. When the const and enum sets match exactly, oneOf adds no constraint beyond enum. Add stripRedundantOneOfConstEnum to normalizeCodexTools to remove only this semantically redundant form. The schema-aware recursive walker requires non-empty, unique string const branches containing annotations only, string enum values, and an exact set match. It preserves bare oneOf[const], narrowing or non-matching sets, type-discriminated oneOf, empty oneOf, non-string values, and anyOf/allOf. Run the normalization after stripUnsupportedRegexPatterns and before assigning tool.parameters. Add focused regression coverage for matching, non-matching, nested, immutable, and Chat-to-Responses cases. * docs(changelog): update PR number in changelog fragment --------- Co-authored-by: Vasily Larin --- .../fixes/9828-codex-redundant-oneof-enum.md | 1 + open-sse/executors/codex/tools.ts | 112 ++++++- .../codex-tools-redundant-oneof-enum.test.ts | 280 ++++++++++++++++++ 3 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/9828-codex-redundant-oneof-enum.md create mode 100644 tests/unit/codex-tools-redundant-oneof-enum.test.ts diff --git a/changelog.d/fixes/9828-codex-redundant-oneof-enum.md b/changelog.d/fixes/9828-codex-redundant-oneof-enum.md new file mode 100644 index 0000000000..b0d7d6b135 --- /dev/null +++ b/changelog.d/fixes/9828-codex-redundant-oneof-enum.md @@ -0,0 +1 @@ +- **fix(executors):** prevent intermittent Codex `upstream_empty_response` errors for tool schemas that combine `oneOf` const branches with a matching sibling `enum` by removing only the semantically redundant `oneOf`; bare, narrowing, non-matching, and type-discriminated `oneOf` schemas remain unchanged. ([#9828](https://github.com/diegosouzapw/OmniRoute/pull/9828)) diff --git a/open-sse/executors/codex/tools.ts b/open-sse/executors/codex/tools.ts index 52d01e9d87..0547432f2b 100644 --- a/open-sse/executors/codex/tools.ts +++ b/open-sse/executors/codex/tools.ts @@ -30,6 +30,114 @@ export function isCodexFreePlan(providerSpecificData: unknown): boolean { return typeof plan === "string" && plan.trim().toLowerCase() === "free"; } +type JsonRecord = Record; + +const REDUNDANT_ONEOF_OBJECT_MAP_FIELDS = [ + "properties", + "patternProperties", + "$defs", + "definitions", +] as const; + +const REDUNDANT_ONEOF_ARRAY_SCHEMA_FIELDS = ["prefixItems", "oneOf", "anyOf", "allOf"] as const; + +const REDUNDANT_ONEOF_SINGLE_SCHEMA_FIELDS = [ + "items", + "additionalProperties", + "not", + "if", + "then", + "else", +] as const; + +const REDUNDANT_ONEOF_ANNOTATION_KEYS = new Set(["const", "description", "title", "$comment"]); + +/** + * Remove a redundant `oneOf` when it is fully covered by a sibling `enum`. + * + * The Codex private Responses endpoint (`chatgpt.com/backend-api/codex/responses`) + * intermittently returns a 502 `upstream_empty_response` when a tool parameter + * carries the JSON-Schema pattern `oneOf: [{const, ...annotations}]` together + * with a sibling `enum` whose value set exactly matches the `const` set. In that + * case `oneOf` adds no constraint beyond `enum`, so dropping it is semantically + * safe and eliminates the trigger. + * + * Only the exact-match redundant case is stripped. Bare `oneOf[const]` without + * a sibling `enum`, narrowing const sets, non-matching enums, type-discriminated + * `oneOf`, and `anyOf`/`allOf` are all preserved. + */ +export function stripRedundantOneOfConstEnum(schema: unknown): unknown { + if (Array.isArray(schema)) { + return schema.map((entry) => stripRedundantOneOfConstEnum(entry)); + } + if (!isPlainObject(schema)) return schema; + + const result: JsonRecord = { ...schema }; + + maybeStripRedundantOneOf(result); + + for (const field of REDUNDANT_ONEOF_OBJECT_MAP_FIELDS) { + const map = result[field]; + if (isPlainObject(map)) { + result[field] = Object.fromEntries( + Object.entries(map).map(([key, value]) => [key, stripRedundantOneOfConstEnum(value)]) + ); + } + } + + for (const field of REDUNDANT_ONEOF_ARRAY_SCHEMA_FIELDS) { + if (Array.isArray(result[field])) { + result[field] = (result[field] as unknown[]).map((entry) => + stripRedundantOneOfConstEnum(entry) + ); + } + } + + for (const field of REDUNDANT_ONEOF_SINGLE_SCHEMA_FIELDS) { + if (result[field] !== undefined) { + result[field] = stripRedundantOneOfConstEnum(result[field]); + } + } + + return result; +} + +function maybeStripRedundantOneOf(node: JsonRecord): void { + const branches = node.oneOf; + if (!Array.isArray(branches) || branches.length === 0) return; + + const enumValues = Array.isArray(node.enum) ? node.enum : null; + if (!enumValues || enumValues.length === 0) return; + + // Every branch must be {const, ...annotations only}. + const constValues: unknown[] = []; + for (const branch of branches) { + if (!isPlainObject(branch)) return; + const branchKeys = Object.keys(branch); + if (!branchKeys.includes("const")) return; + if (!branchKeys.every((key) => REDUNDANT_ONEOF_ANNOTATION_KEYS.has(key))) return; + constValues.push((branch as JsonRecord).const); + } + + // Restrict to string consts and string enums (confirmed production shape). + if (!constValues.every((value) => typeof value === "string")) return; + if (!enumValues.every((value) => typeof value === "string")) return; + + // All const values must be unique. + if (new Set(constValues).size !== constValues.length) return; + + // The const set must exactly match the enum set. + const enumSet = new Set(enumValues); + if (enumSet.size !== constValues.length) return; + if (!constValues.every((value) => enumSet.has(value))) return; + + delete node.oneOf; +} + +function isPlainObject(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + export function normalizeCodexTools( body: Record, options?: { dropImageGeneration?: boolean; preserveCustomTools?: boolean } @@ -138,7 +246,9 @@ export function normalizeCodexTools( // 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); + const sanitizedParameters = stripRedundantOneOfConstEnum( + stripUnsupportedRegexPatterns(parameters) + ); // Rewrite in-place to Responses format for (const key of Object.keys(tool)) { diff --git a/tests/unit/codex-tools-redundant-oneof-enum.test.ts b/tests/unit/codex-tools-redundant-oneof-enum.test.ts new file mode 100644 index 0000000000..d29ba38342 --- /dev/null +++ b/tests/unit/codex-tools-redundant-oneof-enum.test.ts @@ -0,0 +1,280 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + normalizeCodexTools, + stripRedundantOneOfConstEnum, +} from "../../open-sse/executors/codex/tools.ts"; + +type JsonRecord = Record; + +const PRODUCTION_ACTION_VALUES = [ + "read_file", + "write_file", + "list_files", + "search_files", + "run_command", + "create_directory", + "delete_file", + "move_file", + "copy_file", + "rename_file", + "open_terminal", + "close_terminal", + "get_status", +] as const; + +function productionParameters(): JsonRecord { + return { + type: "object", + description: "OpenChamber action parameters", + properties: { + action: { + type: "string", + description: "Action to perform", + enum: [...PRODUCTION_ACTION_VALUES], + oneOf: PRODUCTION_ACTION_VALUES.map((value) => ({ + const: value, + description: `Action ${value}`, + })), + }, + }, + required: ["action"], + }; +} + +function chatTool(parameters: JsonRecord): JsonRecord { + return { + type: "function", + function: { name: "test_tool", parameters }, + }; +} + +test("production openchamber shape is normalized", () => { + const tool = chatTool(productionParameters()); + normalizeCodexTools({ tools: [tool] }); + + const parameters = tool.parameters as JsonRecord; + const properties = parameters.properties as JsonRecord; + const action = properties.action as JsonRecord; + assert.equal(action.oneOf, undefined); + assert.deepEqual(action.enum, [...PRODUCTION_ACTION_VALUES]); + assert.equal(parameters.type, "object"); + assert.equal(parameters.description, "OpenChamber action parameters"); + assert.deepEqual(parameters.required, ["action"]); +}); + +test("bare oneOf[const] without sibling enum is preserved", () => { + const tool = chatTool({ oneOf: [{ const: "x" }, { const: "y" }] }); + normalizeCodexTools({ tools: [tool] }); + + const oneOf = (tool.parameters as JsonRecord).oneOf as unknown[]; + assert.equal(oneOf.length, 2); +}); + +test("non-matching enum is preserved", () => { + const tool = chatTool({ + enum: ["a", "b", "c"], + oneOf: [{ const: "a" }, { const: "b" }], + }); + normalizeCodexTools({ tools: [tool] }); + + assert.deepEqual((tool.parameters as JsonRecord).oneOf, [{ const: "a" }, { const: "b" }]); +}); + +test("partially overlapping enum is preserved", () => { + const tool = chatTool({ + enum: ["a", "b", "c"], + oneOf: [{ const: "a" }, { const: "d" }], + }); + normalizeCodexTools({ tools: [tool] }); + + assert.deepEqual((tool.parameters as JsonRecord).oneOf, [{ const: "a" }, { const: "d" }]); +}); + +test("enum with extra value is preserved", () => { + const tool = chatTool({ enum: ["a", "b"], oneOf: [{ const: "a" }] }); + normalizeCodexTools({ tools: [tool] }); + + assert.deepEqual((tool.parameters as JsonRecord).oneOf, [{ const: "a" }]); +}); + +test("duplicate const branches are preserved", () => { + const tool = chatTool({ + enum: ["a", "b"], + oneOf: [{ const: "a" }, { const: "a" }], + }); + normalizeCodexTools({ tools: [tool] }); + + assert.deepEqual((tool.parameters as JsonRecord).oneOf, [{ const: "a" }, { const: "a" }]); +}); + +test("branch with validation keyword is preserved", () => { + const tool = chatTool({ + enum: ["a", "b"], + oneOf: [{ const: "a", type: "string" }, { const: "b" }], + }); + normalizeCodexTools({ tools: [tool] }); + + assert.deepEqual((tool.parameters as JsonRecord).oneOf, [ + { const: "a", type: "string" }, + { const: "b" }, + ]); +}); + +test("type-discriminated oneOf is preserved", () => { + const tool = chatTool({ oneOf: [{ type: "string" }, { type: "number" }] }); + normalizeCodexTools({ tools: [tool] }); + + assert.deepEqual((tool.parameters as JsonRecord).oneOf, [{ type: "string" }, { type: "number" }]); +}); + +test("empty oneOf is preserved", () => { + const tool = chatTool({ enum: ["a"], oneOf: [] }); + normalizeCodexTools({ tools: [tool] }); + + assert.deepEqual((tool.parameters as JsonRecord).oneOf, []); +}); + +test("single-branch exact match is stripped", () => { + const tool = chatTool({ enum: ["x"], oneOf: [{ const: "x" }] }); + normalizeCodexTools({ tools: [tool] }); + + const parameters = tool.parameters as JsonRecord; + assert.equal(parameters.oneOf, undefined); + assert.deepEqual(parameters.enum, ["x"]); +}); + +test("non-string const is preserved", () => { + const tool = chatTool({ enum: [1, 2], oneOf: [{ const: 1 }, { const: 2 }] }); + normalizeCodexTools({ tools: [tool] }); + + assert.deepEqual((tool.parameters as JsonRecord).oneOf, [{ const: 1 }, { const: 2 }]); +}); + +test("non-string enum is preserved", () => { + const tool = chatTool({ enum: [{ a: 1 }], oneOf: [{ const: "x" }] }); + normalizeCodexTools({ tools: [tool] }); + + assert.deepEqual((tool.parameters as JsonRecord).oneOf, [{ const: "x" }]); +}); + +test("anyOf is preserved while the walker strips its inner oneOf", () => { + const tool = chatTool({ + anyOf: [{ enum: ["a", "b"], oneOf: [{ const: "a" }, { const: "b" }] }], + }); + normalizeCodexTools({ tools: [tool] }); + + const parameters = tool.parameters as JsonRecord; + const anyOf = parameters.anyOf as JsonRecord[]; + assert.equal(anyOf.length, 1); + assert.equal(anyOf[0].oneOf, undefined); +}); + +test("allOf is preserved while the walker strips its inner oneOf", () => { + const tool = chatTool({ allOf: [{ enum: ["a"], oneOf: [{ const: "a" }] }] }); + normalizeCodexTools({ tools: [tool] }); + + const parameters = tool.parameters as JsonRecord; + const allOf = parameters.allOf as JsonRecord[]; + assert.equal(allOf.length, 1); + assert.equal(allOf[0].oneOf, undefined); +}); + +test("nested oneOf in properties is stripped", () => { + const tool = chatTool({ + properties: { + action: { + enum: ["a", "b"], + oneOf: [ + { const: "a", description: "A" }, + { const: "b", description: "B" }, + ], + }, + }, + }); + normalizeCodexTools({ tools: [tool] }); + + const properties = (tool.parameters as JsonRecord).properties as JsonRecord; + const action = properties.action as JsonRecord; + assert.equal(action.oneOf, undefined); + assert.deepEqual(action.enum, ["a", "b"]); +}); + +test("nested oneOf in items is stripped", () => { + const tool = chatTool({ items: { enum: ["a", "b"], oneOf: [{ const: "a" }, { const: "b" }] } }); + normalizeCodexTools({ tools: [tool] }); + + const items = (tool.parameters as JsonRecord).items as JsonRecord; + assert.equal(items.oneOf, undefined); +}); + +test("nested oneOf in additionalProperties is stripped", () => { + const tool = chatTool({ + additionalProperties: { + enum: ["p", "q"], + oneOf: [{ const: "p" }, { const: "q" }], + }, + }); + normalizeCodexTools({ tools: [tool] }); + + const additionalProperties = (tool.parameters as JsonRecord).additionalProperties as JsonRecord; + assert.equal(additionalProperties.oneOf, undefined); +}); + +test("nested oneOf in $defs is stripped", () => { + const tool = chatTool({ + $defs: { D: { enum: ["d1", "d2"], oneOf: [{ const: "d1" }, { const: "d2" }] } }, + }); + normalizeCodexTools({ tools: [tool] }); + + const defs = (tool.parameters as JsonRecord).$defs as JsonRecord; + const definition = defs.D as JsonRecord; + assert.equal(definition.oneOf, undefined); +}); + +test("nested oneOf in patternProperties is stripped", () => { + const tool = chatTool({ + patternProperties: { "^x$": { enum: ["a"], oneOf: [{ const: "a" }] } }, + }); + normalizeCodexTools({ tools: [tool] }); + + const patternProperties = (tool.parameters as JsonRecord).patternProperties as JsonRecord; + const pattern = patternProperties["^x$"] as JsonRecord; + assert.equal(pattern.oneOf, undefined); +}); + +test("stripping is idempotent", () => { + const first = stripRedundantOneOfConstEnum(productionParameters()); + const second = stripRedundantOneOfConstEnum(first); + + assert.deepEqual(second, first); +}); + +test("stripping is immutable", () => { + const original = productionParameters(); + const before = structuredClone(original); + const result = stripRedundantOneOfConstEnum(original) as JsonRecord; + + const properties = original.properties as JsonRecord; + const action = properties.action as JsonRecord; + assert.deepEqual(original, before); + assert.ok(Array.isArray(action.oneOf)); + assert.notStrictEqual(result, original); +}); + +test("Chat wrapper is flattened to the flat Responses form", () => { + const tool = chatTool({ + properties: { + action: { enum: ["a", "b"], oneOf: [{ const: "a" }, { const: "b" }] }, + }, + }); + normalizeCodexTools({ tools: [tool] }); + + const parameters = tool.parameters as JsonRecord; + const properties = parameters.properties as JsonRecord; + const action = properties.action as JsonRecord; + assert.equal(action.oneOf, undefined); + assert.equal(tool.function, undefined); + assert.equal(tool.name, "test_tool"); +}); From 714a36cf99ed247dc6aab48b3ec0396bd41e0e65 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:47:37 -0300 Subject: [PATCH 025/100] cherry-pick(pr-9826): fix(executors): preserve Command Code usage in Responses streams (#9842) * fix(executors): preserve Command Code usage in Responses streams * fix(executors): add Command Code usage changelog fragment --------- Co-authored-by: MrShitFox --- .../9826-command-code-responses-usage.md | 1 + open-sse/executors/commandCode.ts | 129 ++++++++++-- open-sse/transformer/responsesTransformer.ts | 103 +++++++++- tests/unit/command-code-executor.test.ts | 189 ++++++++++++++++++ tests/unit/responses-transformer.test.ts | 12 +- 5 files changed, 412 insertions(+), 22 deletions(-) create mode 100644 changelog.d/fixes/9826-command-code-responses-usage.md diff --git a/changelog.d/fixes/9826-command-code-responses-usage.md b/changelog.d/fixes/9826-command-code-responses-usage.md new file mode 100644 index 0000000000..45d8dad254 --- /dev/null +++ b/changelog.d/fixes/9826-command-code-responses-usage.md @@ -0,0 +1 @@ +- **fix(executors):** preserve Command Code usage in `/v1/responses` streams so Codex clients receive real input, output, cache, and reasoning token counts ([#9826](https://github.com/diegosouzapw/OmniRoute/pull/9826)) — thanks @MrShitFox diff --git a/open-sse/executors/commandCode.ts b/open-sse/executors/commandCode.ts index cc2642de5c..08576fe051 100644 --- a/open-sse/executors/commandCode.ts +++ b/open-sse/executors/commandCode.ts @@ -420,7 +420,61 @@ type AggregateState = { usage: JsonRecord | null; }; +function firstRecord(record: JsonRecord, keys: readonly string[]): JsonRecord { + for (const key of keys) { + const value = record[key]; + if (isRecord(value)) return value; + } + return {}; +} + +function firstNumber(record: JsonRecord, keys: readonly string[]): number | undefined { + for (const key of keys) { + const value = numberValue(record[key]); + if (value !== undefined) return value; + } + return undefined; +} + +/** Keep earlier finish-step usage when the terminal finish event omits it. */ +function mergeCommandCodeUsage(previous: JsonRecord | null, next: unknown): JsonRecord | null { + if (!isRecord(next)) return previous; + + const merged: JsonRecord = { ...(previous || {}), ...next }; + for (const key of [ + "inputTokenDetails", + "input_token_details", + "input_tokens_details", + "prompt_tokens_details", + "outputTokenDetails", + "output_token_details", + "output_tokens_details", + "completion_tokens_details", + "reasoningTokenDetails", + "reasoning_token_details", + ]) { + const before = isRecord(previous?.[key]) ? previous[key] : {}; + const after = isRecord(next[key]) ? next[key] : {}; + if (Object.keys(before).length > 0 || Object.keys(after).length > 0) { + merged[key] = { ...before, ...after }; + } + } + return merged; +} + +function rememberCommandCodeUsage(state: AggregateState, event: JsonRecord): void { + const usage = + event.type === "finish-step" + ? (event.usage ?? event.totalUsage) + : (event.totalUsage ?? event.usage); + state.usage = mergeCommandCodeUsage(state.usage, usage); +} + function applyEventToAggregate(event: JsonRecord, state: AggregateState): void { + // Some Command Code protocol revisions attach usage to the terminal payload + // without preserving the event type. Capture it before event-specific handling. + rememberCommandCodeUsage(state, event); + switch (event.type) { case "text-delta": state.content += stringValue(event.text) || ""; @@ -440,9 +494,10 @@ function applyEventToAggregate(event: JsonRecord, state: AggregateState): void { }); break; } + case "finish-step": + break; case "finish": state.finishReason = mapFinishReason(event.finishReason); - state.usage = isRecord(event.totalUsage) ? event.totalUsage : null; break; } } @@ -460,30 +515,72 @@ function applyEventToAggregateOrThrow(event: JsonRecord, state: AggregateState): function usageFromCommandCode(usage: JsonRecord | null) { if (!usage) return undefined; - const details = isRecord(usage.inputTokenDetails) ? usage.inputTokenDetails : {}; - const cacheRead = numberValue(details.cacheReadTokens) || 0; - const noCache = numberValue(details.noCacheTokens) || 0; + const inputDetails = firstRecord(usage, [ + "inputTokenDetails", + "input_token_details", + "input_tokens_details", + "prompt_tokens_details", + ]); + const outputDetails = firstRecord(usage, [ + "outputTokenDetails", + "output_token_details", + "output_tokens_details", + "completion_tokens_details", + ]); + const reasoningDetails = firstRecord(usage, [ + "reasoningTokenDetails", + "reasoning_token_details", + "reasoning_tokens_details", + ]); + const cacheRead = + firstNumber(usage, [ + "cachedInputTokens", + "cached_input_tokens", + "cacheReadInputTokens", + "cache_read_input_tokens", + "cacheReadTokens", + "cache_read_tokens", + "cached_tokens", + ]) ?? + firstNumber(inputDetails, [ + "cachedTokens", + "cached_tokens", + "cacheReadTokens", + "cache_read_tokens", + ]); + const noCache = firstNumber(inputDetails, ["noCacheTokens", "no_cache_tokens"]); // Command Code's totalUsage.inputTokens is the FULL prompt total and already // includes the cached portion (noCacheTokens + cacheReadTokens = inputTokens), // so we must NOT add cacheRead back — that would double-count. There is no // cache-write field in the upstream payload, so cache creation stays unset. - const inputTokens = numberValue(usage.inputTokens) || 0; - const prompt = inputTokens; - const completion = numberValue(usage.outputTokens) || 0; + const prompt = + firstNumber(usage, ["inputTokens", "input_tokens", "promptTokens", "prompt_tokens"]) ?? + (noCache ?? 0) + (cacheRead ?? 0); + const reasoning = + firstNumber(usage, ["reasoningTokens", "reasoning_tokens"]) ?? + firstNumber(outputDetails, ["reasoningTokens", "reasoning_tokens"]) ?? + firstNumber(reasoningDetails, ["reasoningTokens", "reasoning_tokens"]); + const textOutput = firstNumber(outputDetails, ["textTokens", "text_tokens"]); + const completion = + firstNumber(usage, [ + "outputTokens", + "output_tokens", + "completionTokens", + "completion_tokens", + ]) ?? (textOutput ?? 0) + (reasoning ?? 0); + const total = firstNumber(usage, ["totalTokens", "total_tokens"]) ?? prompt + completion; const result: JsonRecord = { prompt_tokens: prompt, + prompt_tokens_details: { cached_tokens: cacheRead ?? 0 }, completion_tokens: completion, - total_tokens: prompt + completion, + completion_tokens_details: { reasoning_tokens: reasoning ?? 0 }, + total_tokens: total, }; // Surface the cache breakdown as informational fields so logUsage prints // `| cache_read=X | no_cache=Y` and appendRequestLog persists them. These are // NOT added to prompt_tokens (already included) — metering stays accurate. - if (cacheRead > 0) result.cache_read_input_tokens = cacheRead; - if (noCache > 0) result.no_cache_tokens = noCache; - // Keep reasoning_token_details (reasoningTokens) when present so stream.ts's - // extractUsage can surface it as reasoning_tokens. - const reasoningDetails = isRecord(usage.reasoningTokenDetails) ? usage.reasoningTokenDetails : {}; - const reasoning = numberValue(reasoningDetails.reasoningTokens); + if (cacheRead !== undefined && cacheRead > 0) result.cache_read_input_tokens = cacheRead; + if (noCache !== undefined && noCache > 0) result.no_cache_tokens = noCache; if (reasoning !== undefined && reasoning > 0) result.reasoning_tokens = reasoning; return result; } @@ -523,6 +620,7 @@ function createStreamResponse( const emitEvent = (event: unknown) => { if (!isRecord(event) || closed) return; + rememberCommandCodeUsage(state, event); if (!sentRole) { sentRole = true; controller.enqueue(sse(chatCompletionChunk(id, model, { role: "assistant" }))); @@ -562,9 +660,10 @@ function createStreamResponse( } case "reasoning-end": break; + case "finish-step": + break; case "finish": { state.finishReason = mapFinishReason(event.finishReason); - state.usage = isRecord(event.totalUsage) ? event.totalUsage : null; controller.enqueue(sse(chatCompletionChunk(id, model, {}, state.finishReason))); // Emit a standards-compliant usage-only chunk (choices: []) before // [DONE] when upstream reported usage. stream.ts's extractUsage diff --git a/open-sse/transformer/responsesTransformer.ts b/open-sse/transformer/responsesTransformer.ts index dca61a31fa..e98c4d98db 100644 --- a/open-sse/transformer/responsesTransformer.ts +++ b/open-sse/transformer/responsesTransformer.ts @@ -37,6 +37,102 @@ async function getPath() { return _path || null; } +type UsageRecord = Record; + +function usageRecord(value: unknown): UsageRecord { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as UsageRecord) + : {}; +} + +function usageNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function usageDetails(record: UsageRecord, ...keys: string[]): UsageRecord { + for (const key of keys) { + const value = usageRecord(record[key]); + if (Object.keys(value).length > 0) return value; + } + return {}; +} + +/** Normalize Chat Completions and Responses usage into the Responses API shape. */ +function normalizeResponsesUsage(previous: unknown, raw: unknown): UsageRecord | null { + const source = usageRecord(raw); + if (Object.keys(source).length === 0) return usageRecord(previous); + + const before = usageRecord(previous); + const beforeInputDetails = usageDetails(before, "input_tokens_details", "prompt_tokens_details"); + const beforeOutputDetails = usageDetails( + before, + "output_tokens_details", + "completion_tokens_details" + ); + const inputDetails = usageDetails( + source, + "input_tokens_details", + "prompt_tokens_details", + "inputTokenDetails", + "input_token_details" + ); + const outputDetails = usageDetails( + source, + "output_tokens_details", + "completion_tokens_details", + "outputTokenDetails", + "output_token_details", + "reasoningTokenDetails", + "reasoning_token_details" + ); + + const inputTokens = + usageNumber(source.input_tokens) ?? + usageNumber(source.prompt_tokens) ?? + usageNumber(source.inputTokens) ?? + usageNumber(source.promptTokens) ?? + usageNumber(before.input_tokens) ?? + usageNumber(before.prompt_tokens) ?? + 0; + const cachedTokens = + usageNumber(source.cache_read_input_tokens) ?? + usageNumber(source.cached_input_tokens) ?? + usageNumber(source.cachedInputTokens) ?? + usageNumber(source.cached_tokens) ?? + usageNumber(inputDetails.cached_tokens) ?? + usageNumber(inputDetails.cachedTokens) ?? + usageNumber(inputDetails.cacheReadTokens) ?? + usageNumber(beforeInputDetails.cached_tokens) ?? + 0; + const outputTokens = + usageNumber(source.output_tokens) ?? + usageNumber(source.completion_tokens) ?? + usageNumber(source.outputTokens) ?? + usageNumber(source.completionTokens) ?? + usageNumber(before.output_tokens) ?? + usageNumber(before.completion_tokens) ?? + 0; + const reasoningTokens = + usageNumber(source.reasoning_tokens) ?? + usageNumber(source.reasoningTokens) ?? + usageNumber(outputDetails.reasoning_tokens) ?? + usageNumber(outputDetails.reasoningTokens) ?? + usageNumber(beforeOutputDetails.reasoning_tokens) ?? + 0; + const totalTokens = + usageNumber(source.total_tokens) ?? + usageNumber(source.totalTokens) ?? + inputTokens + outputTokens; + + return { + input_tokens: inputTokens, + input_tokens_details: { cached_tokens: cachedTokens }, + output_tokens: outputTokens, + output_tokens_details: { reasoning_tokens: reasoningTokens }, + total_tokens: totalTokens, + }; +} + // Create log directory for responses (Node.js only) export function createResponsesLogger(model, logsDir = null) { // Skip logging in worker environment (no fs) @@ -477,10 +573,11 @@ export function createResponsesApiTransformStream( continue; } + if (parsed.usage) { + state.usage = normalizeResponsesUsage(state.usage, parsed.usage); + } + if (!parsed.choices?.length) { - if (parsed.usage) { - state.usage = parsed.usage; - } // #6906: trailing usage-only chunk after finish_reason already deferred // completion — send it now with the usage just captured above. if (state.awaitingTrailingUsage && !state.completedSent) { diff --git a/tests/unit/command-code-executor.test.ts b/tests/unit/command-code-executor.test.ts index 3fd1dacac2..1dcef8e77b 100644 --- a/tests/unit/command-code-executor.test.ts +++ b/tests/unit/command-code-executor.test.ts @@ -11,10 +11,18 @@ const { REGISTRY, getRegistryEntry } = await import("../../open-sse/config/provi const { CommandCodeExecutor, COMMAND_CODE_VERSION } = await import("../../open-sse/executors/commandCode.ts"); const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts"); +const { createResponsesApiTransformStream } = + await import("../../open-sse/transformer/responsesTransformer.ts"); const core = await import("../../src/lib/db/core.ts"); const originalFetch = globalThis.fetch; +type JsonRecord = Record; +type ResponsesEvent = { + event: string; + data: { response: JsonRecord & { usage?: unknown; output?: JsonRecord[] } }; +}; + const PINNED_COMMAND_CODE_MODELS = [ "claude-opus-4-7", "claude-opus-4-6", @@ -60,6 +68,27 @@ function parseSsePayloads(sse: string) { .map((line) => JSON.parse(line)); } +async function responsesFromChatSse(sse: string): Promise { + const input = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(sse)); + controller.close(); + }, + }); + const transformed = await new Response( + input.pipeThrough(createResponsesApiTransformStream(null, 60_000)) + ).text(); + + return transformed + .split("\n\n") + .map((part) => { + const event = part.match(/^event:\s*(.+)$/m)?.[1]; + const data = part.match(/^data:\s*(.+)$/m)?.[1]; + return event && data ? ({ event, data: JSON.parse(data) } as ResponsesEvent) : null; + }) + .filter((entry): entry is ResponsesEvent => entry !== null); +} + test.afterEach(() => { globalThis.fetch = originalFetch; }); @@ -270,7 +299,9 @@ test("Command Code data: SSE lines aggregate into non-stream ChatCompletion JSON assert.equal(json.choices[0].finish_reason, "length"); assert.deepEqual(json.usage, { prompt_tokens: 3, + prompt_tokens_details: { cached_tokens: 2 }, completion_tokens: 5, + completion_tokens_details: { reasoning_tokens: 0 }, total_tokens: 8, cache_read_input_tokens: 2, }); @@ -466,7 +497,9 @@ test("Command Code stream emits a usage-only chunk with actual tokens before [DO assert.ok(usageChunk, "expected a usage-only chunk (choices: []) in the stream"); assert.deepEqual(usageChunk.usage, { prompt_tokens: 10, + prompt_tokens_details: { cached_tokens: 4 }, completion_tokens: 6, + completion_tokens_details: { reasoning_tokens: 1 }, total_tokens: 16, cache_read_input_tokens: 4, reasoning_tokens: 1, @@ -506,9 +539,165 @@ test("Command Code non-stream usage keeps inputTokens as prompt_tokens and repor const json = await response.json(); assert.deepEqual(json.usage, { prompt_tokens: 5, + prompt_tokens_details: { cached_tokens: 3 }, completion_tokens: 2, + completion_tokens_details: { reasoning_tokens: 0 }, total_tokens: 7, cache_read_input_tokens: 3, no_cache_tokens: 2, }); }); + +test("Command Code preserves finish-step usage through a finish without totalUsage", async () => { + globalThis.fetch = async () => + commandCodeStream([ + { type: "text-delta", text: "Hi" }, + { + type: "finish-step", + usage: { + inputTokens: 7308, + inputTokenDetails: { noCacheTokens: 27, cacheReadTokens: 7281 }, + outputTokens: 177, + outputTokenDetails: { textTokens: 12, reasoningTokens: 165 }, + totalTokens: 7485, + }, + }, + { type: "finish", finishReason: "stop", totalUsage: null }, + ]); + + const { response } = await getExecutor("command-code").execute({ + model: "gpt-5.4-mini", + stream: true, + credentials: { apiKey: "cc_test_key" }, + body: { messages: [{ role: "user", content: "Hi" }] }, + }); + const sse = await response.text(); + const chunks = parseSsePayloads(sse); + const usageChunk = chunks.find( + (chunk) => Array.isArray(chunk.choices) && chunk.choices.length === 0 + ); + + assert.deepEqual(usageChunk?.usage, { + prompt_tokens: 7308, + prompt_tokens_details: { cached_tokens: 7281 }, + completion_tokens: 177, + completion_tokens_details: { reasoning_tokens: 165 }, + total_tokens: 7485, + cache_read_input_tokens: 7281, + no_cache_tokens: 27, + reasoning_tokens: 165, + }); + + const completed = (await responsesFromChatSse(sse)).find( + (event) => event.event === "response.completed" + ); + assert.deepEqual(completed?.data.response.usage, { + input_tokens: 7308, + input_tokens_details: { cached_tokens: 7281 }, + output_tokens: 177, + output_tokens_details: { reasoning_tokens: 165 }, + total_tokens: 7485, + }); +}); + +test("Command Code accepts OpenAI-style usage aliases with absent optional details", async () => { + globalThis.fetch = async () => + commandCodeStream([ + { + type: "finish-step", + usage: { + prompt_tokens: 11, + prompt_tokens_details: { cached_tokens: 4 }, + completion_tokens: 5, + completion_tokens_details: { reasoning_tokens: 2 }, + total_tokens: 16, + }, + }, + { type: "finish", finishReason: "stop" }, + ]); + + const { response } = await getExecutor("command-code").execute({ + model: "gpt-5.4-mini", + stream: true, + credentials: { apiKey: "cc_test_key" }, + body: { messages: [{ role: "user", content: "Hi" }] }, + }); + const sse = await response.text(); + const usageChunk = parseSsePayloads(sse).find( + (chunk) => Array.isArray(chunk.choices) && chunk.choices.length === 0 + ); + assert.deepEqual(usageChunk?.usage, { + prompt_tokens: 11, + prompt_tokens_details: { cached_tokens: 4 }, + completion_tokens: 5, + completion_tokens_details: { reasoning_tokens: 2 }, + total_tokens: 16, + cache_read_input_tokens: 4, + reasoning_tokens: 2, + }); + + globalThis.fetch = async () => + commandCodeStream([ + { type: "finish-step", usage: { inputTokens: 4, outputTokens: 3, totalTokens: 7 } }, + { type: "finish", finishReason: "stop", totalUsage: null }, + ]); + const fallback = await getExecutor("command-code").execute({ + model: "gpt-5.4-mini", + stream: true, + credentials: { apiKey: "cc_test_key" }, + body: { messages: [{ role: "user", content: "Hi" }] }, + }); + const fallbackSse = await fallback.response.text(); + const completed = (await responsesFromChatSse(fallbackSse)).find( + (event) => event.event === "response.completed" + ); + assert.deepEqual(completed?.data.response.usage, { + input_tokens: 4, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 3, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 7, + }); +}); + +test("Command Code preserves tool-call streaming while finalizing finish-step usage", async () => { + globalThis.fetch = async () => + commandCodeStream([ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "lookup", + input: { query: "hello" }, + }, + { type: "finish-step", usage: { inputTokens: 3, outputTokens: 2, totalTokens: 5 } }, + { type: "finish", finishReason: "tool-calls" }, + ]); + + const { response } = await getExecutor("command-code").execute({ + model: "gpt-5.4-mini", + stream: true, + credentials: { apiKey: "cc_test_key" }, + body: { messages: [{ role: "user", content: "Hi" }] }, + }); + const sse = await response.text(); + assert.ok( + parseSsePayloads(sse).some( + (chunk) => chunk.choices?.[0]?.delta?.tool_calls?.[0]?.id === "call_1" + ) + ); + + const completed = (await responsesFromChatSse(sse)).find( + (event) => event.event === "response.completed" + ); + assert.equal( + completed?.data.response.output?.some((item) => item.type === "function_call"), + true + ); + assert.deepEqual(completed?.data.response.usage, { + input_tokens: 3, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 2, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 5, + }); +}); diff --git a/tests/unit/responses-transformer.test.ts b/tests/unit/responses-transformer.test.ts index 6ea4bd6bfe..f8b1c5a0b1 100644 --- a/tests/unit/responses-transformer.test.ts +++ b/tests/unit/responses-transformer.test.ts @@ -76,8 +76,10 @@ test("createResponsesApiTransformStream converts plain chat deltas into Response assert.ok(types.includes("response.output_text.done")); assert.equal(completed.output[0].content[0].text, "Hello"); assert.deepEqual(completed.usage, { - prompt_tokens: 1, - completion_tokens: 2, + input_tokens: 1, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 2, + output_tokens_details: { reasoning_tokens: 0 }, total_tokens: 3, }); assert.equal(doneMarker.data, "[DONE]"); @@ -374,8 +376,10 @@ test("createResponsesApiTransformStream ignores malformed events and preserves u assert.equal(completed.id, "resp_chatcmpl_edge"); assert.equal(completed.output[0].content[0].text, "ok"); assert.deepEqual(completed.usage, { - prompt_tokens: 2, - completion_tokens: 1, + input_tokens: 2, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 1, + output_tokens_details: { reasoning_tokens: 0 }, total_tokens: 3, }); }); From 0f5699165bd26c42371e7c6b2abf72799485014a Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:47:45 -0300 Subject: [PATCH 026/100] fix(providers): remove retired NVIDIA NIM catalog entries (#9898) Co-authored-by: Zartharas <1402357+Zartharas@users.noreply.github.com> --- open-sse/config/freeModelCatalog.data.ts | 2 - .../config/nvidiaHostedModels.snapshot.json | 2 - .../config/providers/registry/nvidia/index.ts | 2 - tests/unit/nvidia-eol-catalog.test.ts | 54 +++++++++++++++++++ 4 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 tests/unit/nvidia-eol-catalog.test.ts diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index 42ec705f8c..aba8cc030b 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -311,7 +311,6 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "nscale", modelId: "openai/gpt-oss-20b", displayName: "openai/gpt-oss-20b", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" }, { provider: "nscale", modelId: "meta-llama/Llama-4-Scout-17B-16E-Instruct", displayName: "meta-llama/Llama-4-Scout-17B-16E-Instruct", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" }, { provider: "nscale", modelId: "meta-llama/Llama-3.3-70B-Instruct", displayName: "meta-llama/Llama-3.3-70B-Instruct", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" }, - { provider: "nvidia", modelId: "z-ai/glm-5.1", displayName: "GLM 5.1", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, { provider: "nvidia", modelId: "z-ai/glm-5.2", displayName: "GLM 5.2", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, { provider: "nvidia", modelId: "minimaxai/minimax-m2.7", displayName: "MiniMax M2.7", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, { provider: "nvidia", modelId: "google/gemma-4-31b-it", displayName: "Gemma 4 31B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, @@ -321,7 +320,6 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "nvidia", modelId: "qwen/qwen3.5-397b-a17b", displayName: "Qwen3.5-397B-A17B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, { provider: "nvidia", modelId: "qwen/qwen3.5-122b-a10b", displayName: "Qwen3.5-122B-A10B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, { provider: "nvidia", modelId: "stepfun-ai/step-3.5-flash", displayName: "Step 3.5 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "deepseek-ai/deepseek-v4-pro", displayName: "DeepSeek V4 Pro", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, { provider: "nvidia", modelId: "openai/gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, { provider: "nvidia", modelId: "openai/gpt-oss-20b", displayName: "GPT OSS 20B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, { provider: "nvidia", modelId: "nvidia/nemotron-3-super-120b-a12b", displayName: "Nemotron 3 Super 120B A12B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, diff --git a/open-sse/config/nvidiaHostedModels.snapshot.json b/open-sse/config/nvidiaHostedModels.snapshot.json index 29bb66e0ad..60d2f2e76d 100644 --- a/open-sse/config/nvidiaHostedModels.snapshot.json +++ b/open-sse/config/nvidiaHostedModels.snapshot.json @@ -1,5 +1,4 @@ [ - "deepseek-ai/deepseek-v4-pro", "google/gemma-4-31b-it", "minimaxai/minimax-m2.7", "mistralai/devstral-2-123b-instruct-2512", @@ -13,6 +12,5 @@ "qwen/qwen3.5-397b-a17b", "stepfun-ai/step-3.5-flash", "thinkingmachines/inkling", - "z-ai/glm-5.1", "z-ai/glm-5.2" ] diff --git a/open-sse/config/providers/registry/nvidia/index.ts b/open-sse/config/providers/registry/nvidia/index.ts index c6e349fce1..3603700d30 100644 --- a/open-sse/config/providers/registry/nvidia/index.ts +++ b/open-sse/config/providers/registry/nvidia/index.ts @@ -32,8 +32,6 @@ export const nvidiaProvider: RegistryEntry = { { id: "qwen/qwen3.5-122b-a10b", name: "Qwen3.5-122B-A10B" }, { id: "stepfun-ai/step-3.5-flash", name: "Step 3.5 Flash" }, { id: "stepfun-ai/step-3.7-flash", name: "Step 3.7 Flash" }, - { id: "deepseek-ai/deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, - { id: "deepseek-ai/deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true }, // Sweep 2026-06-19: verified present in the live NVIDIA NIM /v1/models catalog. { id: "moonshotai/kimi-k2.6", name: "Kimi K2.6" }, { id: "openai/gpt-oss-120b", name: "GPT OSS 120B", toolCalling: false }, diff --git a/tests/unit/nvidia-eol-catalog.test.ts b/tests/unit/nvidia-eol-catalog.test.ts new file mode 100644 index 0000000000..ea8708d19e --- /dev/null +++ b/tests/unit/nvidia-eol-catalog.test.ts @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { FREE_MODEL_BUDGETS } from "../../open-sse/config/freeModelCatalog.data.ts"; +import reviewedLiveIds from "../../open-sse/config/nvidiaHostedModels.snapshot.json" with { type: "json" }; +import { nvidiaProvider } from "../../open-sse/config/providers/registry/nvidia/index.ts"; + +const registryIds = new Set(nvidiaProvider.models.map((model) => model.id)); + +const documentedFreeIds = new Set( + FREE_MODEL_BUDGETS.filter((model) => model.provider === "nvidia").map((model) => model.modelId) +); + +const reviewedIds = new Set(reviewedLiveIds); + +test("NVIDIA registry excludes retired DeepSeek V4 models", () => { + assert.ok( + !registryIds.has("deepseek-ai/deepseek-v4-pro"), + "retired deepseek-ai/deepseek-v4-pro must not be advertised" + ); + + assert.ok( + !registryIds.has("deepseek-ai/deepseek-v4-flash"), + "retired deepseek-ai/deepseek-v4-flash must not be advertised" + ); +}); + +test("NVIDIA static lifecycle metadata excludes known EOL models", () => { + for (const modelId of ["z-ai/glm-5.1", "deepseek-ai/deepseek-v4-pro"]) { + assert.ok( + !reviewedIds.has(modelId), + `${modelId} must not remain in the reviewed NVIDIA hosted-model snapshot` + ); + + assert.ok( + !documentedFreeIds.has(modelId), + `${modelId} must not remain in the NVIDIA free-model catalog` + ); + } +}); + +test("NVIDIA cleanup preserves the healthy GLM replacement", () => { + assert.ok(registryIds.has("z-ai/glm-5.2"), "z-ai/glm-5.2 must remain in the NVIDIA registry"); + + assert.ok( + reviewedIds.has("z-ai/glm-5.2"), + "z-ai/glm-5.2 must remain in the reviewed NVIDIA hosted-model snapshot" + ); + + assert.ok( + documentedFreeIds.has("z-ai/glm-5.2"), + "z-ai/glm-5.2 must remain in the NVIDIA free-model catalog" + ); +}); From 05940f4c7fdab9149190d96cee5382e61be7d6b2 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:50:09 -0300 Subject: [PATCH 027/100] fix(responses-api): tool call after a text message collided on the same output_index (#9843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live incident (2026-08-08): an OpenClaw agent sent a short preamble line ("Kör nu, på riktigt — apply_patch på vibe-scriptet:") followed by an apply_patch tool call in the same turn. The client only spoke the preamble and never executed the patch, even though OmniRoute's own recorded responseBody had a complete, valid tool_calls entry. Root cause: emitToolCall/closeToolCall computed a tool call's output_index as `reasoningIndex + 1 + tcIdx`, assuming reasoningIndex + 1 was free for the first tool call (tcIdx=0). But a text message emitted in the same turn ALSO claims reasoningIndex + 1 (or index 0 with no reasoning) — so a turn with reasoning + text content + a tool call collided the tool call's added/delta/done events onto the same output_index as the just-closed message. A client that tracks response items by output_index (as expected for the Responses API) sees the tool call events land on an index it already marked complete and can silently drop them. Fix: track whether a message item was actually emitted at that index (state.msgItemAdded) and, if so, tool calls start one slot after it. Extracted a shared toolCallOutputIndexBase() helper so emitToolCall and closeToolCall can no longer compute this independently and drift apart. Confirmed via the live call log artifact (id 1786223153235-770a1c): response.output_item.done for the text message and response.output_item.added for the tool call both carried output_index=1 in the raw SSE stream, 1.84s apart, exactly matching the reported symptom. Co-authored-by: Markus Hartung --- config/quality/file-size-baseline.json | 7 +- .../translator/response/openai-responses.ts | 21 +++-- .../translator-resp-openai-responses.test.ts | 89 +++++++++++++++++++ 3 files changed, 108 insertions(+), 9 deletions(-) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 19b2a3e9c4..081460c833 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -364,7 +364,7 @@ "open-sse/services/combo.ts": 3648, "open-sse/services/compression/strategySelector.ts": 1060, "open-sse/services/rateLimitManager.ts": 1167, - "open-sse/translator/response/openai-responses.ts": 1215, + "open-sse/translator/response/openai-responses.ts": 1224, "open-sse/utils/cursorAgentProtobuf.ts": 1505, "open-sse/utils/stream.ts": 2889, "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1388, @@ -401,7 +401,7 @@ "src/shared/components/RequestLoggerV2.tsx": 1629, "src/shared/components/analytics/charts.tsx": 1035, "src/shared/services/cliRuntime.ts": 1122, - "src/sse/handlers/chat.ts": 1918, + "src/sse/handlers/chat.ts": 1904, "src/sse/services/auth.ts": 2520, "tests/unit/account-fallback-service.test.ts": 1572, "tests/unit/provider-validation-specialty.test.ts": 2985, @@ -561,5 +561,6 @@ "open-sse/translator/request/openai-to-kiro.ts": "1057", "open-sse/utils/sseHeartbeat.ts": "142", "_rebaseline_2026_08_04_9305_sse_comments": "#9305 fix: broadened sseCommentsEnabled()", - "_rebaseline_2026_08_09_v3850_release_close": "Release v3.8.50 close reconciliation on e0ce95c592: src/sse/handlers/chat.ts 1904->1918 is the irreducible request-pipeline wiring from #9759 that invokes the Modality Bridge guardrail without moving its implementation into the handler; covered by the 17 Vision Bridge canaries plus the PR-1 focused suite. open-sse/translator/response/openai-responses.ts 1204->1215 is #9168's Responses tool-call argument delta buffering/normalization at the existing translator state-machine chokepoint; covered by its dedicated translator regression tests. Both values are measured by check:file-size (split-newline semantics), and the gate remains frozen at the new exact sizes." + "_rebaseline_2026_08_09_v3850_release_close": "Release v3.8.50 close reconciliation on e0ce95c592: src/sse/handlers/chat.ts 1904->1918 is the irreducible request-pipeline wiring from #9759 that invokes the Modality Bridge guardrail without moving its implementation into the handler; covered by the 17 Vision Bridge canaries plus the PR-1 focused suite. open-sse/translator/response/openai-responses.ts 1204->1215 is #9168's Responses tool-call argument delta buffering/normalization at the existing translator state-machine chokepoint; covered by its dedicated translator regression tests. Both values are measured by check:file-size (split-newline semantics), and the gate remains frozen at the new exact sizes.", + "_rebaseline_2026_08_08_toolcall_message_index_collision": "fix(responses-api): tool call after a text message collided on the same output_index. own growth: open-sse/translator/response/openai-responses.ts 1204->1224 (+20, extracted toolCallOutputIndexBase() shared helper so emitToolCall/closeToolCall can no longer compute a tool call's output_index independently and collide with a text message emitted in the same turn). Live incident (2026-08-08, OpenClaw agent): a client that tracks response items by output_index saw the tool call's added/delta/done events land on an index it had already marked complete (the just-closed text message), and silently dropped them — the agent spoke its preamble and never executed the tool call, even though OmniRoute's own recorded responseBody had a complete, valid tool_calls entry. Covered by the new regression test in tests/unit/translator-resp-openai-responses.test.ts reproducing the exact live scenario." } diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index d459350d35..35571814e7 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -451,11 +451,22 @@ function closeMessage(state, emit, idx) { } } +// Tool calls sit after reasoning (if any) AND after a text message (if one was +// actually emitted this turn) — a model commonly emits a short preamble before +// calling a tool (e.g. "Kör nu, på riktigt — apply_patch..."), and that message +// claims the same reasoningIndex+1 slot the old per-call math (`reasoningIndex +// + 1 + tcIdx`) assumed was free for tcIdx=0. Not accounting for the message +// item collided the tool call's added/delta/done events onto the same +// output_index as the just-closed message, which a client keying per-item +// state by output_index can silently drop (live incident 2026-08-08). +function toolCallOutputIndexBase(state) { + const msgIdx = state.reasoningId ? normalizeOutputIndex(state.reasoningIndex) + 1 : 0; + return state.msgItemAdded[msgIdx] ? msgIdx + 1 : msgIdx; +} + function emitToolCall(state, emit, tc) { const tcIdx = tc.index ?? 0; - const outputIndex = state.reasoningId - ? normalizeOutputIndex(state.reasoningIndex) + 1 + normalizeOutputIndex(tcIdx) - : normalizeOutputIndex(tcIdx); + const outputIndex = toolCallOutputIndexBase(state) + normalizeOutputIndex(tcIdx); const newCallId = tc.id; const funcName = tc.function?.name; @@ -536,9 +547,7 @@ function emitToolCall(state, emit, tc) { function closeToolCall(state, emit, idx, recordAsCompleted = true) { const callId = state.funcCallIds[idx]; if (callId && !state.funcItemDone[idx]) { - const normalizedIndex = state.reasoningId - ? normalizeOutputIndex(state.reasoningIndex) + 1 + normalizeOutputIndex(idx) - : normalizeOutputIndex(idx); + const normalizedIndex = toolCallOutputIndexBase(state) + normalizeOutputIndex(idx); const args = state.funcArgsBuf[idx] || "{}"; const toolName = state.funcNames[idx] || ""; const isCustomTool = diff --git a/tests/unit/translator-resp-openai-responses.test.ts b/tests/unit/translator-resp-openai-responses.test.ts index 3eeef460b8..6a2aee49ae 100644 --- a/tests/unit/translator-resp-openai-responses.test.ts +++ b/tests/unit/translator-resp-openai-responses.test.ts @@ -726,3 +726,92 @@ test("OpenAI -> Responses: parallel tool calls with mixed content survive transl const outputFcs = completed.data.response.output.filter((item) => item.type === "function_call"); assert.equal(outputFcs.length, 2, "completed output should have both function_calls"); }); + +// Live incident (2026-08-08): an OpenClaw agent ("Ping") sent a preamble line +// ("Kör nu, på riktigt — apply_patch på vibe-scriptet:") followed by an +// apply_patch tool call in the same turn, with reasoning ahead of both. The +// text message and the tool call both computed to output_index=1 — the tool +// call's own index math (`reasoningIndex + 1 + tcIdx`) never accounted for +// the message item also claiming `reasoningIndex + 1`, so a completed +// message and a freshly-added tool call collided on the same output_index. +// A client that tracks response items by output_index (as Responses-API +// clients are expected to) sees the tool call's added/delta/done events land +// on an index it already marked complete, and can silently drop or ignore +// them — exactly the observed symptom: the agent spoke the preamble and +// never executed the patch. +test("OpenAI -> Responses: a text message and a following tool call in the same turn get distinct output_index values", () => { + const events = collectEvents([ + { + id: "chatcmpl-1", + model: "big-pickle", + choices: [ + { index: 0, delta: { reasoning_content: "thinking about the patch" }, finish_reason: null }, + ], + }, + { + id: "chatcmpl-1", + model: "big-pickle", + choices: [ + { + index: 0, + delta: { content: "Kör nu, på riktigt — apply_patch på vibe-scriptet:" }, + finish_reason: null, + }, + ], + }, + { + id: "chatcmpl-1", + model: "big-pickle", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_apply_patch", + type: "function", + function: { name: "apply_patch", arguments: '{"input":"*** Begin Patch ***"}' }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + }, + null, + ]); + + const itemDoneEvents = events.filter((e) => e.event === "response.output_item.done"); + const messageDone = itemDoneEvents.find((e) => e.data.item?.type === "message"); + const toolCallDone = itemDoneEvents.find( + (e) => e.data.item?.type === "function_call" || e.data.item?.type === "custom_tool_call" + ); + assert.ok(messageDone, "message output_item.done should be present"); + assert.ok(toolCallDone, "tool call output_item.done should be present"); + assert.notEqual( + messageDone.data.output_index, + toolCallDone.data.output_index, + "message and tool call must not collide on the same output_index" + ); + + // The tool call's own added/delta events (what a streaming client actually + // keys its per-item state on) must also use the tool call's real index, + // not the message's. + const toolCallAdded = events.find( + (e) => + e.event === "response.output_item.added" && + (e.data.item?.type === "function_call" || e.data.item?.type === "custom_tool_call") + ); + assert.ok(toolCallAdded, "tool call output_item.added should be present"); + assert.equal(toolCallAdded.data.output_index, toolCallDone.data.output_index); + assert.notEqual(toolCallAdded.data.output_index, messageDone.data.output_index); + + const completed = events.find((e) => e.event === "response.completed"); + const outputTypes = completed.data.response.output.map((item) => item.type); + assert.ok(outputTypes.includes("message"), "completed output must include the message"); + assert.ok( + outputTypes.includes("function_call") || outputTypes.includes("custom_tool_call"), + "completed output must include the tool call" + ); +}); From 79b8c8351c24721bcb86ea8fd3e03c9fb3c9b31c Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:50:17 -0300 Subject: [PATCH 028/100] fix(command-code): include tool call arguments (#9897) Co-authored-by: Choti Wongbussakorn <126886556+Chewji9875@users.noreply.github.com> --- open-sse/executors/commandCode.ts | 21 ++++- tests/unit/executor-command-code.test.ts | 101 +++++++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/open-sse/executors/commandCode.ts b/open-sse/executors/commandCode.ts index 08576fe051..251981fa78 100644 --- a/open-sse/executors/commandCode.ts +++ b/open-sse/executors/commandCode.ts @@ -48,6 +48,21 @@ function recordOrEmpty(value: unknown): JsonRecord { return {}; } +/** + * Build the `arguments` field for an assistant tool-call part that Command + * Code's /alpha/generate schema REQUIRES (rejects a missing field with + * `missing required field 'arguments'`). Valid source values round-trip: + * - object arguments -> JSON string of the object + * - string arguments -> the string as-is (already valid JSON) + * - missing / empty / invalid JSON -> "{}" (a valid empty-object string) + */ +function toolCallArgumentsString(value: unknown): string { + const parsed = recordOrEmpty(value); + if (isRecord(value)) return JSON.stringify(parsed); + if (typeof value === "string" && value.trim()) return value; + return JSON.stringify(parsed); +} + function normalizeContentText(content: unknown): string { if (typeof content === "string") return content; return asRecordArray(content) @@ -244,11 +259,15 @@ function convertMessages( const id = stringValue(call.id) || ""; if (!id || !pairedToolCallIds.has(id)) continue; const fn = isRecord(call.function) ? call.function : {}; + const parsedInput = recordOrEmpty(fn.arguments); parts.push({ type: "tool-call", toolCallId: id, toolName: stringValue(fn.name) || "", - input: recordOrEmpty(fn.arguments), + input: parsedInput, + // /alpha/generate requires this field on assistant tool-call parts; + // a missing one is rejected with `missing required field 'arguments'`. + arguments: toolCallArgumentsString(fn.arguments), }); } diff --git a/tests/unit/executor-command-code.test.ts b/tests/unit/executor-command-code.test.ts index d4c2761d56..8fb9984a04 100644 --- a/tests/unit/executor-command-code.test.ts +++ b/tests/unit/executor-command-code.test.ts @@ -56,4 +56,105 @@ describe("CommandCodeExecutor", () => { // Network error is expected in test environment } }); + + it("assistant tool-call conversion always emits a valid required arguments field (#regression input[N] missing required field arguments)", async () => { + const calls: Array<{ url: string; init: RequestInit; body: unknown }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ + url: String(url), + init: init || {}, + body: JSON.parse(String((init as RequestInit | undefined)?.body)), + }); + return new Response("", { status: 200 }); + }) as typeof fetch; + + const executor = new mod.CommandCodeExecutor(); + const pairedId = "call_paired"; + const body = { + messages: [ + { role: "user", content: "hi" }, + { + role: "assistant", + content: "", + tool_calls: [ + // Missing arguments entirely -> must still get a valid arguments field + { id: "call_missing", type: "function", function: { name: "lookup" } }, + // Empty string arguments -> "{}" + { + id: "call_empty", + type: "function", + function: { name: "lookup", arguments: "" }, + }, + // Valid object arguments -> round-trips as JSON string + { + id: pairedId, + type: "function", + function: { name: "lookup", arguments: { q: "docs" } }, + }, + // Valid string arguments -> preserved as-is + { + id: "call_string", + type: "function", + function: { name: "lookup", arguments: '{"q":"string"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_missing", content: "r1" }, + { role: "tool", tool_call_id: "call_empty", content: "r2" }, + { role: "tool", tool_call_id: pairedId, content: "r3" }, + { role: "tool", tool_call_id: "call_string", content: "r4" }, + ], + }; + + try { + await executor.execute({ + model: "test", + body, + stream: false, + credentials: { apiKey: "fake-key" }, + signal: null, + }); + assert.fail("Expected fetch to reject (no real network)"); + } catch { + // Fetch rejection is expected; inspect the captured body + } finally { + globalThis.fetch = originalFetch; + } + + assert.equal(calls.length, 1, "exactly one upstream call"); + const sentBody = calls[0].body as { + params: { messages: Array<{ role: string; content: unknown }> }; + }; + const assistant = sentBody.params.messages.find((m) => m.role === "assistant"); + assert.ok(assistant, "assistant turn present"); + const parts = assistant.content as Array>; + const toolCalls = parts.filter((p) => p.type === "tool-call"); + assert.equal(toolCalls.length, 4, "all four paired tool calls converted"); + + for (const call of toolCalls) { + assert.equal( + typeof call.arguments, + "string", + `tool-call ${String(call.toolCallId)} must carry a string arguments field` + ); + const parsed = JSON.parse(call.arguments as string); + assert.equal(typeof parsed, "object"); + assert.ok(!Array.isArray(parsed), "arguments must parse to a JSON object"); + } + + const byId = new Map(toolCalls.map((c) => [String(c.toolCallId), c])); + assert.equal(byId.get("call_missing").arguments, "{}", "missing arguments -> empty object"); + assert.equal(byId.get("call_empty").arguments, "{}", "empty string arguments -> empty object"); + assert.equal( + byId.get(pairedId).arguments, + '{"q":"docs"}', + "object arguments round-trip as JSON string" + ); + assert.equal( + byId.get("call_string").arguments, + '{"q":"string"}', + "valid string arguments preserved as-is" + ); + }); }); From 580162548adef9b5f88c4f7c52a6601857d0de65 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:50:27 -0300 Subject: [PATCH 029/100] cherry-pick(pr-9818): feat: generic OpenAI-compatible video custom provider (#9844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: generic OpenAI-compatible video custom provider Adds a generic OpenAI-compatible video generation path so users can add custom video providers (base URL + API key) without per-provider code. Changes: - open-sse/handlers/videoGeneration/openai.ts (new): generic handler with resolveVideoEndpoint, fetchVideoEndpoint, handleOpenAIVideoGeneration - open-sse/handlers/videoGeneration.ts: added resolveVideoBaseUrl(), dispatch for 'openai-video' format before 'vertex-veo', synthetic config for custom providers, fallback for resolvedProvider - src/app/api/v1/videos/generations/route.ts: scans custom models for supportedEndpoints.includes('videos'), resolves credentials via getProviderCredentialsWithQuotaPreflight, passes resolvedProvider - src/shared/validation/schemas/provider.ts: added 'videos' to supportedEndpoints enum - tests/unit/video-generation-handler.test.ts: handler-level test for custom provider - tests/unit/video-custom-provider-route.test.ts (new): route-level tests covering custom provider with/without videos endpoint, unknown provider All verification: - typecheck:core passes - 17 video tests pass (3 new route tests + 1 new handler test) - no regressions in image generation tests * test(video): drop duplicated test.after cleanup in custom-provider route test * feat(video): declarative job presets + dispatcher, route, and handler test coverage (#9818) - job.ts: presets (agnes-video-job, muapi-video-job) with submit→poll→done executor - videoGeneration.ts: generationConfig.preset dispatch branch + mediaGenerationRoute pass-through - provider-models/route.ts + models.ts: generationConfig persisted on addCustomModel - provider schema: generationConfig optional field - tests: resolvedProvider bare-model + job preset happy/failed/unknown paths - docs/video-preset-generation.md * fix(video): restore dashscope + novita handler imports dropped in refactor * refactor(video): extract Runway helpers Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: oyi77 --- docs/video-preset-generation.md | 113 +++++ open-sse/handlers/videoGeneration.ts | 300 ++++++------- open-sse/handlers/videoGeneration/job.ts | 418 ++++++++++++++++++ open-sse/handlers/videoGeneration/openai.ts | 156 +++++++ .../handlers/videoGeneration/runwayHelpers.ts | 125 ++++++ src/app/api/provider-models/route.ts | 11 +- src/app/api/v1/videos/generations/route.ts | 50 ++- src/lib/db/models.ts | 27 +- src/shared/validation/schemas/provider.ts | 12 + .../unit/video-custom-provider-route.test.ts | 351 +++++++++++++++ tests/unit/video-generation-handler.test.ts | 105 +++++ 11 files changed, 1514 insertions(+), 154 deletions(-) create mode 100644 docs/video-preset-generation.md create mode 100644 open-sse/handlers/videoGeneration/job.ts create mode 100644 open-sse/handlers/videoGeneration/openai.ts create mode 100644 open-sse/handlers/videoGeneration/runwayHelpers.ts create mode 100644 tests/unit/video-custom-provider-route.test.ts diff --git a/docs/video-preset-generation.md b/docs/video-preset-generation.md new file mode 100644 index 0000000000..163fa8856e --- /dev/null +++ b/docs/video-preset-generation.md @@ -0,0 +1,113 @@ +# Video Generation Through Preset Jobs + +Custom provider nodes whose `/videos` surface is an **async submit → poll → fetch-result API** (instead of a synchronous generation endpoint) can be wired into the `/api/v1/videos/generations` route without any new provider code. The model row carries a `generationConfig.preset`, and the dispatcher routes the request through a single job executor that is configured entirely by declarative preset data. + +## How dispatch works + +1. The route parses `model` as `provider/model` and resolves the provider node's credentials (`POST /api/v1/videos/generations`). +2. `handleVideoGeneration` (in `open-sse/handlers/videoGeneration.ts`) checks whether the provider is a **custom provider node** (no entry in the static video registry). +3. For custom nodes it reads the custom model row via `getCustomModelVideoPreset(provider, model)`: + - The model row has `generationConfig.preset` set (e.g. `"agnes-video-job"`) → dispatch through the **job executor** (`open-sse/handlers/videoGeneration/job.ts`). + - The preset name does not match any known preset → **502** `Unknown video job preset: ` (server-side misconfiguration). + - No preset configured → fall back to the generic OpenAI-compatible sync handler, mirroring the images route. +4. The job executor runs the preset pipeline: **submit** the job, **poll** for terminal status, **read** the finished video URL, and return the standard OpenAI-compatible response shape. + +The executor is one handler family; every provider-specific detail (paths, auth, body shape, status/result fields, poll cadence) is data in the preset definition. + +## Response contract + +Both the sync and job paths return the same shape: + +```json +{ + "created": 1234567890, + "data": [{ "url": "https://…", "format": "mp4" }] +} +``` + +This is the shape the media-generation consumer reads (`data.data[0].url`), so preset-job providers are drop-in replacements for sync providers. + +## Presets + +Presets live in `open-sse/handlers/videoGeneration/job.ts` (`VIDEO_JOB_PRESETS`). Each preset declares: + +| Field | Meaning | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `authHeaderName` / `authScheme` | `x-api-key` with `raw` value (Agnes, muapi) or `Authorization` with `Bearer` prefix (Sora). Missing credentials → request goes out without an auth header. | +| `baseUrlFallback` | Default base URL. Overridden by the provider connection's `providerSpecificData.baseUrl` (or top-level `baseUrl`), which wins when set. | +| `submit.path` / `submit.buildBody` | Where and how the job is submitted. `{model}` in the path is substituted with the encoded model id; the body is built from `model`/`prompt`/`duration` plus pass-through of every other request field. | +| `taskIdPath` | Dot path into the submit response identifying the job (e.g. `task_id`, `request_id`, `id`). Missing job id → **502**. | +| `poll.pathTemplate` | Poll URL template; `{taskId}` is substituted. | +| `statusPath` / `statusDone` / `statusFailed` | Where the job status lives and which values are terminal. | +| `resultPath` | Dot path into the poll response holding the finished video URL: a string, a string array, or an array of `{ url }` objects are all accepted. Completed job with no readable URL → **502**. | +| `maxPolls` / `pollIntervalMs` | Poll budget (default 60 polls × 2000 ms). Exhausted → **504** `Video job timed out`. | + +### `agnes-video-job` — Agnes Video V2.0 + +- Auth: `x-api-key: ` (raw). +- Base URL fallback: `https://apihub.agnes-ai.com`. +- Submit: `POST /v1/videos` with `{ model, prompt, ...extras }` — image, mode, `num_frames`, `frame_rate` and other provider knobs pass through untouched. +- Job id: `task_id` from the submit response. +- Poll: `GET /v1/videos/{taskId}`; status at `status` (`completed` / `failed`). +- Result: `metadata.url` — the completed video URL is returned as JSON metadata, not a binary body. + +### `muapi-video-job` — muapi.ai + +- Auth: `x-api-key: ` (raw). +- Base URL fallback: `https://api.muapi.ai`. +- Submit: `POST /api/v1/{model}` with `{ prompt, duration?, ...extras }`. +- Job id: `request_id` from the submit response. +- Poll: `GET /api/v1/predictions/{taskId}/result`; status at `status` (`completed` / `failed`). +- Result: `outputs` — an array of video URLs. + +### `sora-job` — OpenAI Sora + +- Auth: `Authorization: Bearer `. +- Base URL fallback: `https://api.openai.com`. +- Submit: `POST /v1/videos` with `{ model, prompt, seconds?, ...extras }`. `seconds` is a **string** enum (`"4" | "8" | "12"`) in the Sora API, so a numeric `duration` is stringified; size mapping is intentionally not forced. +- Job id: `id` from the submit response. +- Poll: `GET /v1/videos/{taskId}`; status at `status` (`completed` / `failed`). +- Result: `data` — an array whose entries are either a URL string or `{ url: "…" }`. + +## Setup + +1. **Register the provider node** as an OpenAI-compatible custom provider (`providerSpecificData.baseUrl` optional — the preset's `baseUrlFallback` is used when absent). +2. **Register a custom model** tagged with the `videos` endpoint and a `generationConfig`: + + ```json + { + "id": "super-video-v1", + "name": "Super Video v1", + "source": "manual", + "apiFormat": "chat-completions", + "supportedEndpoints": ["videos"], + "generationConfig": { "preset": "agnes-video-job" } + } + ``` + + `addCustomModel` (in `src/lib/db/models.ts`) accepts `generationConfig?: { preset: string }` as its final parameter and persists it on the model row; `updateCustomModel` forwards it the same way. The provider-models API accepts `generationConfig` on create and update. + +3. **Call the route** as usual: + + ```bash + curl -X POST http://localhost:8787/api/v1/videos/generations \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $API_KEY" \ + -d '{ + "model": "my-custom-provider/super-video-v1", + "prompt": "a cat playing piano", + "duration": 5 + }' + ``` + +## Troubleshooting + +| Symptom | Cause | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| `400 Unknown video provider: …` | Non-custom provider not in the static registry; preset jobs only apply to custom provider nodes. | +| `502 Unknown video job preset: …` | `generationConfig.preset` does not match any preset in `VIDEO_JOB_PRESETS`. Fix the model row. | +| `502 Video provider did not return a job id (…)` | Submit succeeded but the response had no readable value at `taskIdPath`. | +| `502 Video job failed (…)` / `Video job completed but no result URL found (…)` | Poll reached a terminal `statusFailed` state, or `resultPath` held no readable URL. | +| `504 Video job timed out after 60 polls (…)` | Job never reached a terminal status within the poll budget. | +| Upstream 4xx/5xx passthrough | `fetchJson` returns the upstream status when the submit/poll request itself is not OK. | +| Requests go out without auth | No `apiKey`/`accessToken` on the provider connection; the executor sends `Content-Type` only. | diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts index 0b26f7d673..1d2fe21e1c 100644 --- a/open-sse/handlers/videoGeneration.ts +++ b/open-sse/handlers/videoGeneration.ts @@ -4,7 +4,7 @@ * Handles POST /v1/videos/generations requests. Proxies to upstream video * generation providers (ComfyUI AnimateDiff/SVD, SD WebUI AnimateDiff, and * more — see the per-format handlers below). Response format (OpenAI-like): - * { "created": 1234567890, "data": [{ "b64_json": "...", "format": "mp4" }] } + * { "created": 1234567890, "data": [{ "url": "https://…", "format": "mp4" }] } */ import { getVideoProvider, parseVideoModel } from "../config/videoRegistry.ts"; @@ -18,6 +18,16 @@ import { handleNovitaVideoGeneration } from "./videoGeneration/novitaHandler.ts" import { handleXaiVideoGeneration } from "./videoGeneration/xaiGrokImagineHandler.ts"; import { handleSegmindVideoGeneration } from "./videoGeneration/providers/segmind.ts"; import { handleAdobeFireflyVideoGeneration } from "./videoGeneration/adobeFireflyHandler.ts"; +import { handleOpenAIVideoGeneration } from "./videoGeneration/openai.ts"; +import { getVideoJobPreset, handleVideoJobGeneration } from "./videoGeneration/job.ts"; +import { + extractRunwayFailureMessage, + normalizeRunwayVideoResult, + resolvePositiveInteger, + resolveRunwayDuration, + resolveRunwayPromptImage, + resolveRunwayRatio, +} from "./videoGeneration/runwayHelpers.ts"; import { getExecutor } from "../executors/index.ts"; import { getKieTaskId, isJsonObject, parseKieResultJson } from "../utils/kieTask.ts"; import { @@ -33,13 +43,94 @@ import { resolveComfyUiBaseUrl, } from "../utils/comfyuiClient.ts"; import { saveCallLog } from "@/lib/usageDb"; +import { getAllCustomModels } from "@/lib/db/models"; import { sanitizeErrorMessage } from "../utils/error.ts"; +import { + FetchTimeoutError, + fetchWithTimeout, + getConfiguredTimeout, +} from "@/shared/utils/fetchTimeout"; + +/** + * Resolve the base URL for OpenAI-compatible video generation endpoints. + * Prefers providerSpecificData.baseUrl (from custom node config), falls back to + * top-level credentials.baseUrl, then to the provided fallback. + */ +export function resolveVideoBaseUrl( + credentials: + { baseUrl?: unknown; providerSpecificData?: { baseUrl?: unknown } | null } | null | undefined, + fallback: string +): string { + const psd = credentials?.providerSpecificData; + const psdBaseUrl = + psd && typeof psd === "object" && typeof psd.baseUrl === "string" && psd.baseUrl.trim() + ? psd.baseUrl.trim() + : null; + const topLevelBaseUrl = + typeof credentials?.baseUrl === "string" && credentials.baseUrl.trim() + ? credentials.baseUrl.trim() + : null; + const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl; + + if (!nodeBaseUrl) return fallback; + + // Trim trailing slashes + let normalized = nodeBaseUrl; + while (normalized.endsWith("/")) normalized = normalized.slice(0, -1); + if (normalized.endsWith("/videos/generations")) return normalized; + const stripped = normalized.replace(/\/videos\/generations$/, ""); + return `${stripped}/videos/generations`; +} + +/** + * Read generationConfig.preset from the custom model row for the given + * provider/model id. Returns null when the model has no preset configured (or + * the registry is unreadable), so callers can fall back to the sync path. + */ +async function getCustomModelVideoPreset( + providerId: string, + modelId: string +): Promise { + try { + const customModelsMap = (await getAllCustomModels()) as Record< + string, + Array> + >; + const models = customModelsMap[providerId]; + if (!Array.isArray(models)) return null; + for (const model of models) { + if (!model || typeof model !== "object" || model.id !== modelId) continue; + const generationConfig = model.generationConfig; + if ( + generationConfig && + typeof generationConfig === "object" && + typeof (generationConfig as Record).preset === "string" + ) { + return (generationConfig as Record).preset as string; + } + return null; + } + return null; + } catch { + return null; + } +} /** * Handle video generation request */ -export async function handleVideoGeneration({ body, credentials, log }) { - const { provider, model } = parseVideoModel(body.model); + +/** + * Handle video generation request + */ +export async function handleVideoGeneration({ body, credentials, log, resolvedProvider = null }) { + let { provider, model } = parseVideoModel(body.model); + if (resolvedProvider) { + provider = resolvedProvider; + model = body.model.startsWith(provider + "/") + ? body.model.slice(provider.length + 1) + : body.model; + } if (!provider) { return { @@ -51,11 +142,59 @@ export async function handleVideoGeneration({ body, credentials, log }) { const providerConfig = getVideoProvider(provider); if (!providerConfig) { - return { - success: false, - status: 400, - error: `Unknown video provider: ${provider}`, + if (!resolvedProvider) { + return { + success: false, + status: 400, + error: `Unknown video provider: ${provider}`, + }; + } + // Custom provider node. When the custom model row carries a + // generationConfig.preset (e.g. "agnes-video-job"), dispatch through the + // submit → poll job pipeline; otherwise mirror the images route and use the + // generic OpenAI-compatible handler with a synthetic config. + const presetName = await getCustomModelVideoPreset(provider, model); + if (presetName !== null) { + if (!getVideoJobPreset(presetName)) { + return { + success: false, + status: 502, + error: `Unknown video job preset: ${presetName}`, + }; + } + if (log) + log.info("VIDEO", `Custom model ${provider}/${model} — using job preset ${presetName}`); + return handleVideoJobGeneration({ + model, + presetName, + body, + credentials, + log, + }); + } + if (log) + log.info("VIDEO", `Custom model ${provider}/${model} — using OpenAI-compatible handler`); + const syntheticConfig = { + id: provider, + baseUrl: resolveVideoBaseUrl( + credentials, + "http://generative.language.googleapis.com/v1beta/openai/videos/generations" + ), + authType: "apikey", + authHeader: "bearer", + format: "openai-video", }; + return handleOpenAIVideoGeneration({ + model, + body, + credentials, + provider, + providerConfig: syntheticConfig, + log, + }); + } + if (providerConfig.format === "openai-video") { + return handleOpenAIVideoGeneration({ model, provider, providerConfig, body, credentials, log }); } if (providerConfig.format === "vertex-veo") { @@ -158,7 +297,10 @@ export async function handleVideoGeneration({ body, credentials, log }) { log, }); } - + if (resolvedProvider) { + // Custom provider with no matching built-in format — use OpenAI-compatible fallback + return handleOpenAIVideoGeneration({ model, provider, providerConfig, body, credentials, log }); + } return { success: false, status: 400, @@ -832,148 +974,6 @@ const RUNWAY_TERMINAL_FAILURE_STATUSES = new Set([ "DELETED", ]); -function resolveRunwayPromptImage(body) { - const directCandidates = [ - body.promptImage, - body.prompt_image, - body.image, - body.image_url, - body.imageUrl, - body.provider_options?.promptImage, - body.provider_options?.prompt_image, - ]; - - for (const candidate of directCandidates) { - if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); - if (candidate && typeof candidate === "object") return candidate; - if (Array.isArray(candidate) && candidate.length > 0) return candidate; - } - - const arrayCandidates = [ - body.imageUrls, - body.image_urls, - body.provider_options?.imageUrls, - body.provider_options?.image_urls, - ]; - for (const candidate of arrayCandidates) { - if (Array.isArray(candidate) && candidate.length > 0) return candidate; - } - - return null; -} - -function resolveRunwayRatio(body) { - const aspectRatio = typeof body.aspect_ratio === "string" ? body.aspect_ratio : body.aspectRatio; - if (aspectRatio === "1280:720" || aspectRatio === "720:1280") return aspectRatio; - if (aspectRatio === "16:9") return "1280:720"; - if (aspectRatio === "9:16") return "720:1280"; - - const size = typeof body.size === "string" ? body.size : ""; - const [widthRaw, heightRaw] = size.split("x"); - const width = Number(widthRaw); - const height = Number(heightRaw); - if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { - return width >= height ? "1280:720" : "720:1280"; - } - - return "1280:720"; -} - -function resolveRunwayDuration(body) { - if (Number.isFinite(body.duration)) { - return clampRunwayDuration(body.duration); - } - - if (Number.isFinite(body.frames) && Number.isFinite(body.fps) && Number(body.fps) > 0) { - return clampRunwayDuration(Number(body.frames) / Number(body.fps)); - } - - return 5; -} - -function clampRunwayDuration(value) { - const duration = Math.round(Number(value)); - if (!Number.isFinite(duration)) return 5; - return Math.min(10, Math.max(2, duration)); -} - -function resolvePositiveInteger(value, fallback) { - const numeric = Number(value); - if (!Number.isFinite(numeric) || numeric <= 0) return fallback; - return Math.floor(numeric); -} - -function extractRunwayOutputUrls(task) { - const rawOutput = Array.isArray(task?.output) - ? task.output - : Array.isArray(task?.result) - ? task.result - : []; - - return rawOutput - .map((entry) => { - if (typeof entry === "string") return entry; - if (!entry || typeof entry !== "object") return null; - return entry.url || entry.uri || entry.videoUrl || entry.video_url || null; - }) - .filter((value) => typeof value === "string" && value.length > 0); -} - -function extractRunwayFailureMessage(task) { - const directCandidates = [ - task?.failure, - task?.failureReason, - task?.error, - task?.errorMessage, - task?.message, - ]; - for (const candidate of directCandidates) { - if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); - } - - if (task?.failure && typeof task.failure === "object") { - const nestedCandidates = [ - task.failure.message, - task.failure.reason, - task.failure.error, - task.failure.code, - ]; - for (const candidate of nestedCandidates) { - if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); - } - } - - return null; -} - -async function normalizeRunwayVideoResult(task, body) { - const urls = extractRunwayOutputUrls(task); - if (urls.length === 0) { - throw new Error( - `Runway task completed without output URLs: ${JSON.stringify(task).slice(0, 400)}` - ); - } - - if (body.response_format === "url") { - return urls.map((url) => ({ url, format: "mp4" })); - } - - const videos = []; - for (const url of urls) { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Runway output fetch failed (${response.status})`); - } - const arrayBuffer = await response.arrayBuffer(); - videos.push({ - b64_json: Buffer.from(arrayBuffer).toString("base64"), - format: "mp4", - }); - } - - return videos; -} - async function handleHaiperVideoGeneration({ model, provider, diff --git a/open-sse/handlers/videoGeneration/job.ts b/open-sse/handlers/videoGeneration/job.ts new file mode 100644 index 0000000000..ef87124188 --- /dev/null +++ b/open-sse/handlers/videoGeneration/job.ts @@ -0,0 +1,418 @@ +/** + * Async job/poll video generation for custom OpenAI-compatible provider nodes + * whose /videos surface is a submit → poll → fetch-result API (e.g. Agnes + * Video V2.0, muapi.ai, OpenAI Sora). Presets are declarative data — the + * handler here is one family; everything else is per-preset config. + * + * Response shape stays OpenAI-like: { created, data: [{ url, format: "mp4" }] } so the + * /v1/videos/generations route returns the same contract as the synchronous + * path. + */ + +import { + fetchWithTimeout, + FetchTimeoutError, + getConfiguredTimeout, +} from "@/shared/utils/fetchTimeout"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { sleep } from "../../utils/sleep.ts"; + +interface LogLike { + info?: (tag: string, msg: string, meta?: unknown) => void; + warn?: (tag: string, msg: string, meta?: unknown) => void; + error?: (tag: string, msg: string, meta?: unknown) => void; +} + +interface CredentialsLike { + providerSpecificData?: { baseUrl?: unknown } | null; + baseUrl?: unknown; + apiKey?: unknown; + accessToken?: unknown; +} + +/** Dot-path reader restricted to plain objects/arrays (no prototypes). */ +function readPath(value: unknown, path: string): unknown { + if (!path) return value; + let current: unknown = value; + for (const segment of path.split(".")) { + if (current === null || current === undefined) return undefined; + if (typeof current !== "object") return undefined; + if (Array.isArray(current)) { + const index = Number(segment); + if (!Number.isInteger(index) || index < 0 || index >= current.length) return undefined; + current = current[index]; + continue; + } + if (!Object.prototype.hasOwnProperty.call(current, segment)) return undefined; + current = (current as Record)[segment]; + } + return current; +} + +/** Non-empty string from a dot path, or null. */ +function readStringPath(value: unknown, path: string): string | null { + const found = readPath(value, path); + return typeof found === "string" && found.trim() ? found : null; +} + +function isDoneStatus( + status: unknown, + done: string[], + failed: string[] +): "done" | "failed" | "pending" { + if (typeof status !== "string") return "pending"; + if (failed.includes(status)) return "failed"; + if (done.includes(status)) return "done"; + return "pending"; +} + +export type VideoJobPreset = { + id: string; + displayName: string; + /** auth header name plus value scheme */ + authHeaderName: "x-api-key" | "Authorization"; + authScheme: "bearer" | "raw"; + baseUrlFallback: string; + submit: { + method: "POST"; + /** may contain {model} — substituted before POST */ + path: string; + buildBody: (params: { + model?: string; + prompt?: string; + duration?: number; + extras: Record; + }) => Record; + }; + /** dot path into the submit response identifying the job */ + taskIdPath: string; + poll: { + /** contains {taskId} */ + pathTemplate: string; + }; + statusPath: string; + statusDone: string[]; + statusFailed: string[]; + /** dot path into the poll response holding the finished video URL/array */ + resultPath: string; + maxPolls: number; + pollIntervalMs: number; +}; + +// #9820: declarative presets for the shipping async job/poll video providers. +const VIDEO_JOB_PRESETS: Record = { + "agnes-video-job": { + id: "agnes-video-job", + displayName: "Agnes Video V2.0", + authHeaderName: "x-api-key", + authScheme: "raw", + // Real default, matching the Agnes Video V2.0 reference: POST /v1/videos with + // x-api-key auth; GET /v1/videos/{task_id} returns status/progress/metadata. + baseUrlFallback: "https://apihub.agnes-ai.com", + submit: { + method: "POST", + path: "/v1/videos", + buildBody: ({ model, prompt, extras }) => ({ + model, + prompt, + // passthrough of image/mode/num_frames/frame_rate/… — the generic + // route body uses .catchall, so provider-specific knobs survive. + ...extras, + }), + }, + taskIdPath: "task_id", + poll: { pathTemplate: "/v1/videos/{taskId}" }, + statusPath: "status", + statusDone: ["completed"], + statusFailed: ["failed"], + resultPath: "metadata.url", + maxPolls: 60, + pollIntervalMs: 2000, + }, + "muapi-video-job": { + id: "muapi-video-job", + displayName: "muapi.ai", + authHeaderName: "x-api-key", + authScheme: "raw", + // muapi.ai video/audio surface is Replicate-style: POST /api/v1/{model} + // returns { request_id }; poll GET /api/v1/predictions/{id}/result. + baseUrlFallback: "https://api.muapi.ai", + submit: { + method: "POST", + path: "/api/v1/{model}", + buildBody: (params) => { + const { prompt, duration, extras } = params; + return { + prompt, + ...(typeof duration === "number" ? { duration } : {}), + ...extras, + }; + }, + }, + taskIdPath: "request_id", + poll: { pathTemplate: "/api/v1/predictions/{taskId}/result" }, + statusPath: "status", + statusDone: ["completed"], + statusFailed: ["failed"], + resultPath: "outputs", + maxPolls: 60, + pollIntervalMs: 2000, + }, + "sora-job": { + id: "sora-job", + displayName: "OpenAI Sora", + authHeaderName: "Authorization", + authScheme: "bearer", + baseUrlFallback: "https://api.openai.com", + submit: { + method: "POST", + path: "/v1/videos", + buildBody: (params) => { + const { model, prompt, duration, extras } = params; + // seconds is a STRING enum ("4"|"8"|"12") in the Sora API; absolute + // size mapping is intentionally not forced here. + return { + model, + prompt, + ...(typeof duration === "number" ? { seconds: String(duration) } : {}), + ...extras, + }; + }, + }, + taskIdPath: "id", + poll: { pathTemplate: "/v1/videos/{taskId}" }, + statusPath: "status", + statusDone: ["completed"], + statusFailed: ["failed"], + resultPath: "data", + maxPolls: 60, + pollIntervalMs: 2000, + }, +}; + +/** Resolve a configured job preset; null when the preset is unknown/none. */ +export function getVideoJobPreset(presetName: unknown): VideoJobPreset | null { + if (typeof presetName !== "string") return null; + const preset = VIDEO_JOB_PRESETS[presetName]; + return preset ?? null; +} + +/** + * Handle a video-generation job via the submit→poll preset pipeline. + * Returns the same shape as the sync handlers: { success, data?: …, status?, error? }. + */ +export async function handleVideoJobGeneration({ + model, + presetName, + body, + credentials, + log, + maxPolls: maxPollsOverride, + pollIntervalMs: pollIntervalOverride, +}: { + model: string; + presetName: string; + body: Record; + credentials?: unknown; + log?: { + info?: (tag: string, msg: string, meta?: unknown) => void; + error?: (tag: string, msg: string) => void; + }; + maxPolls?: number; + pollIntervalMs?: number; +}) { + const preset = getVideoJobPreset(presetName); + if (!preset) { + return { + success: false, + status: 400, + error: `Unknown video job preset: ${presetName}`, + }; + } + + const baseUrl = resolveJobBaseUrl(credentials, preset.baseUrlFallback); + log?.info?.("VIDEO", `Job preset ${presetName} submitting ${model}`); + log?.info?.("VIDEO", JSON.stringify({ baseUrl })); + + const bodyForPreset = preset.submit.buildBody({ + model: model, + prompt: typeof body.prompt === "string" ? body.prompt : undefined, + duration: typeof body.duration === "number" ? body.duration : undefined, + // passthrough of the remainder — the API keeps catchall extras + extras: Object.fromEntries( + Object.entries(body ?? {}).filter( + ([key]) => key !== "model" && key !== "prompt" && key !== "duration" + ) + ), + }); + + const submitPath = preset.submit.path.replace("{model}", encodeURIComponent(model)); + const submitUrl = `${baseUrl}${submitPath}`; // baseUrl never ends with "/" + const submitResult = await fetchJson(submitUrl, { + method: preset.submit.method, + headers: buildJobHeaders(preset, credentials), + body: JSON.stringify(bodyForPreset), + log, + }); + if (!submitResult.ok) { + return { success: false, status: submitResult.status, error: submitResult.error }; + } + + const taskId = readStringPath(submitResult.data, preset.taskIdPath); + if (!taskId) { + return { + success: false, + status: 502, + error: `Video provider did not return a job id (${presetName})`, + }; + } + + // Poll loop. + const maxPolls = maxPollsOverride ?? preset.maxPolls; + const pollInterval = pollIntervalOverride ?? preset.pollIntervalMs; + + for (let attempt = 1; attempt <= maxPolls; attempt += 1) { + await sleep(pollInterval); + const pollUrl = `${baseUrl}${preset.poll.pathTemplate.replace("{taskId}", encodeURIComponent(taskId))}`; + const pollResult = await fetchJson(pollUrl, { + method: "GET", + headers: buildJobHeaders(preset, credentials), + log, + }); + if (!pollResult.ok) { + return { success: false, status: pollResult.status, error: pollResult.error }; + } + + const status = readPath(pollResult.data, preset.statusPath); + const jobState = isDoneStatus(status, preset.statusDone, preset.statusFailed); + if (jobState === "done") { + const url = readResultUrl(pollResult.data, preset.resultPath); + if (!url) { + return { + success: false, + status: 502, + error: `Video job completed but no result URL found (${presetName})`, + }; + } + log?.info?.("VIDEO", `Job completed after ${attempt} poll(s)`); + return { + success: true, + data: { + created: Math.floor(Date.now() / 1000), + data: [{ url, format: "mp4" }], + }, + }; + } + if (jobState === "failed") { + return { + success: false, + status: 502, + error: `Video job failed (${presetName})`, + }; + } + } + + return { + success: false, + status: 504, + error: `Video job timed out after ${maxPolls} polls (${presetName})`, + }; +} + +function buildJobHeaders(preset: VideoJobPreset, credentials?: unknown): Record { + const creds = credentials as CredentialsLike | null | undefined; + const apiKey = + typeof creds?.apiKey === "string" && creds.apiKey + ? creds.apiKey + : typeof creds?.accessToken === "string" && creds.accessToken + ? creds.accessToken + : ""; + const headers: Record = { "Content-Type": "application/json" }; + if (!apiKey) return headers; + if (preset.authScheme === "raw") { + headers[preset.authHeaderName] = apiKey; + } else { + headers[preset.authHeaderName] = `Bearer ${apiKey}`; + } + return headers; +} + +function resolveJobBaseUrl(credentials: unknown, fallback: string): string { + const creds = credentials as CredentialsLike | null | undefined; + const psdBaseUrl = + creds?.providerSpecificData?.baseUrl != null && + typeof creds.providerSpecificData.baseUrl === "string" && + creds.providerSpecificData.baseUrl.trim() + ? (creds.providerSpecificData.baseUrl as string).trim() + : null; + const topLevelBaseUrl = + creds?.baseUrl != null && typeof creds.baseUrl === "string" && creds.baseUrl.trim() + ? (creds.baseUrl as string).trim() + : null; + const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl; + if (!nodeBaseUrl) return fallback.replace(/\/+$/, ""); + let normalized = nodeBaseUrl; + while (normalized.endsWith("/")) normalized = normalized.slice(0, -1); + return normalized; +} + +async function fetchJson( + url: string, + { + method, + headers, + body, + log, + }: { + method: string; + headers: Record; + body?: string; + log?: LogLike; + } +): Promise<{ ok: true; data: unknown } | { ok: false; status: number; error: string }> { + try { + const response = await fetchWithTimeout(url, { + method, + headers, + ...(body !== undefined ? { body } : {}), + timeoutMs: getConfiguredTimeout(), + }); + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("VIDEO", `Upstream ${response.status} for ${url}: ${errorText.slice(0, 200)}`); + return { ok: false, status: response.status, error: errorText }; + } + const data = await response.json(); + return { ok: true, data }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + const isTimeout = + err instanceof FetchTimeoutError || (err instanceof Error && err.name === "AbortError"); + log?.error?.( + "VIDEO", + `${isTimeout ? "Timeout" : "Request error"} for ${url}: ${sanitizeErrorMessage(message)}` + ); + return { + ok: false, + status: isTimeout ? 504 : 502, + error: `Video provider error: ${sanitizeErrorMessage(message)}`, + }; + } +} + +function readResultUrl(data: unknown, resultPath: string): string | null { + const found = readPath(data, resultPath); + if (typeof found === "string" && found.trim()) return found.trim(); + if (Array.isArray(found)) { + const first = found[0]; + // muapi-style: resultPath "outputs" resolves to ["https://…"]. + if (typeof first === "string" && first.trim()) return first.trim(); + // sora-style: resultPath "data" resolves to [{ url: "https://…" }]. + if (first && typeof first === "object" && !Array.isArray(first)) { + const urlEntry = (first as Record).url; + if (typeof urlEntry === "string" && urlEntry.trim()) return urlEntry.trim(); + } + return null; + } + return null; +} diff --git a/open-sse/handlers/videoGeneration/openai.ts b/open-sse/handlers/videoGeneration/openai.ts new file mode 100644 index 0000000000..b53ae51fea --- /dev/null +++ b/open-sse/handlers/videoGeneration/openai.ts @@ -0,0 +1,156 @@ +import { + fetchWithTimeout, + FetchTimeoutError, + getConfiguredTimeout, +} from "@/shared/utils/fetchTimeout"; +import { saveCallLog } from "@/lib/usageDb"; +import { sanitizeErrorMessage } from "../../utils/error.ts"; + +interface LogLike { + info?: (tag: string, msg: string, meta?: unknown) => void; + error?: (tag: string, msg: string) => void; +} + +interface CredentialsLike { + providerSpecificData?: { baseUrl?: unknown } | null; + baseUrl?: unknown; + apiKey?: unknown; + accessToken?: unknown; +} + +/** + * Resolve the video generation endpoint URL from credentials and fallback. + * Handles baseUrl from providerSpecificData or top-level credentials. + */ +function resolveVideoEndpoint(credentials: unknown, fallback: string): string { + const creds = credentials as CredentialsLike | null | undefined; + const psdBaseUrl = + creds?.providerSpecificData?.baseUrl != null && + typeof creds.providerSpecificData.baseUrl === "string" && + creds.providerSpecificData.baseUrl.trim() + ? creds.providerSpecificData.baseUrl.trim() + : null; + const topLevelBaseUrl = + creds?.baseUrl != null && typeof creds.baseUrl === "string" && creds.baseUrl.trim() + ? creds.baseUrl.trim() + : null; + const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl; + let n = nodeBaseUrl; + while (n.endsWith("/")) n = n.slice(0, -1); + if (n.endsWith("/videos/generations")) return n; + return `${n}/videos/generations`; +} + +/** + * Fetch the video generation endpoint with timeout and error handling. + */ +async function fetchVideoEndpoint( + url: string, + { headers, body, log }: { headers: Record; body: string; log?: LogLike } +) { + try { + const response = await fetchWithTimeout(url, { + method: "POST", + headers, + body, + timeoutMs: getConfiguredTimeout(), + }); + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("VIDEO", `Upstream ${response.status} for ${url}: ${errorText}`); + return { success: false, status: response.status, error: errorText }; + } + const data = await response.json(); + return { + success: true, + data: { created: data.created || Math.floor(Date.now() / 1000), data: data.data || [] }, + }; + } catch (err) { + const message = err?.message; + const isTimeout = err instanceof FetchTimeoutError || err?.name === "AbortError"; + log?.error?.( + "VIDEO", + `${isTimeout ? "Timeout" : "Request error"} for ${url}: ${sanitizeErrorMessage(message || err)}` + ); + return { + success: false, + status: isTimeout ? 504 : 502, + error: `Video provider error: ${sanitizeErrorMessage(message || err)}`, + }; + } +} + +/** + * Handle OpenAI-compatible video generation. + * This handler is dispatched for custom providers with format "openai-video". + */ +export async function handleOpenAIVideoGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, +}: { + model: string; + provider: string; + providerConfig: { baseUrl: string; authHeader: string }; + body: unknown; + credentials: unknown; + log?: LogLike; +}) { + const startTime = Date.now(); + const creds = credentials as CredentialsLike | null | undefined; + const apiToken = creds?.apiKey || creds?.accessToken; + const endpoint = resolveVideoEndpoint(credentials, providerConfig.baseUrl); + const headers = { + "Content-Type": "application/json", + ...(providerConfig.authHeader === "x-api-key" + ? { "x-api-key": String(apiToken) } + : { Authorization: `Bearer ${apiToken}` }), + }; + const bodyObj = body as Record; + const upstreamBody = { + model, + prompt: (bodyObj.prompt ?? "") as string, + ...(typeof bodyObj.duration === "number" && { duration: bodyObj.duration }), + }; + const logRequestBody = { + model: bodyObj.model, + prompt: + typeof bodyObj.prompt === "string" + ? bodyObj.prompt.slice(0, 200) + : String(bodyObj.prompt ?? ""), + duration: bodyObj.duration, + }; + log?.info?.("VIDEO", `OpenAI-compatible video generation: ${provider}/${model} -> ${endpoint}`, { + body: logRequestBody, + }); + + const fetchResult = await fetchVideoEndpoint(endpoint, { + headers, + body: JSON.stringify(upstreamBody), + log, + }); + + if (!fetchResult.success) { + return { success: false, status: fetchResult.status, error: fetchResult.error }; + } + + // Save call log for billing/tracking + await saveCallLog({ + provider, + model: String(bodyObj.model), + endpoint: "video", + status: fetchResult.status, + durationMs: Date.now() - startTime, + tokensIn: 0, + tokensOut: 0, + requestId: null, + }); + + return { + success: true, + data: fetchResult.data, + }; +} diff --git a/open-sse/handlers/videoGeneration/runwayHelpers.ts b/open-sse/handlers/videoGeneration/runwayHelpers.ts new file mode 100644 index 0000000000..94917a55ad --- /dev/null +++ b/open-sse/handlers/videoGeneration/runwayHelpers.ts @@ -0,0 +1,125 @@ +export function resolveRunwayPromptImage(body) { + const directCandidates = [ + body.promptImage, + body.prompt_image, + body.image, + body.image_url, + body.imageUrl, + body.provider_options?.promptImage, + body.provider_options?.prompt_image, + ]; + + for (const candidate of directCandidates) { + if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); + if (candidate && typeof candidate === "object") return candidate; + if (Array.isArray(candidate) && candidate.length > 0) return candidate; + } + + const arrayCandidates = [ + body.imageUrls, + body.image_urls, + body.provider_options?.imageUrls, + body.provider_options?.image_urls, + ]; + for (const candidate of arrayCandidates) { + if (Array.isArray(candidate) && candidate.length > 0) return candidate; + } + + return null; +} + +export function resolveRunwayRatio(body) { + const aspectRatio = typeof body.aspect_ratio === "string" ? body.aspect_ratio : body.aspectRatio; + if (aspectRatio === "1280:720" || aspectRatio === "720:1280") return aspectRatio; + if (aspectRatio === "16:9") return "1280:720"; + if (aspectRatio === "9:16") return "720:1280"; + + const size = typeof body.size === "string" ? body.size : ""; + const [widthRaw, heightRaw] = size.split("x"); + const width = Number(widthRaw); + const height = Number(heightRaw); + if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { + return width >= height ? "1280:720" : "720:1280"; + } + + return "1280:720"; +} + +export function resolveRunwayDuration(body) { + if (Number.isFinite(body.duration)) return clampRunwayDuration(body.duration); + if (Number.isFinite(body.frames) && Number.isFinite(body.fps) && Number(body.fps) > 0) { + return clampRunwayDuration(Number(body.frames) / Number(body.fps)); + } + return 5; +} + +function clampRunwayDuration(value) { + const duration = Math.round(Number(value)); + if (!Number.isFinite(duration)) return 5; + return Math.min(10, Math.max(2, duration)); +} + +export function resolvePositiveInteger(value, fallback) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) return fallback; + return Math.floor(numeric); +} + +function extractRunwayOutputUrls(task) { + const rawOutput = Array.isArray(task?.output) + ? task.output + : Array.isArray(task?.result) + ? task.result + : []; + return rawOutput + .map((entry) => { + if (typeof entry === "string") return entry; + if (!entry || typeof entry !== "object") return null; + return entry.url || entry.uri || entry.videoUrl || entry.video_url || null; + }) + .filter((value) => typeof value === "string" && value.length > 0); +} + +export function extractRunwayFailureMessage(task) { + const directCandidates = [ + task?.failure, + task?.failureReason, + task?.error, + task?.errorMessage, + task?.message, + ]; + for (const candidate of directCandidates) { + if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); + } + if (task?.failure && typeof task.failure === "object") { + const nestedCandidates = [ + task.failure.message, + task.failure.reason, + task.failure.error, + task.failure.code, + ]; + for (const candidate of nestedCandidates) { + if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); + } + } + return null; +} + +export async function normalizeRunwayVideoResult(task, body) { + const urls = extractRunwayOutputUrls(task); + if (urls.length === 0) { + throw new Error( + `Runway task completed without output URLs: ${JSON.stringify(task).slice(0, 400)}` + ); + } + if (body.response_format === "url") return urls.map((url) => ({ url, format: "mp4" })); + + const videos = []; + for (const url of urls) { + const response = await fetch(url); + if (!response.ok) throw new Error(`Runway output fetch failed (${response.status})`); + const arrayBuffer = await response.arrayBuffer(); + videos.push({ b64_json: Buffer.from(arrayBuffer).toString("base64"), format: "mp4" }); + } + return videos; +} diff --git a/src/app/api/provider-models/route.ts b/src/app/api/provider-models/route.ts index 08e8c9b507..8e5f2e2183 100644 --- a/src/app/api/provider-models/route.ts +++ b/src/app/api/provider-models/route.ts @@ -146,6 +146,8 @@ export async function POST(request) { max_output_tokens: maxOutputTokens, // #1904: manual vision-capability override set in the add-model form. supportsVision, + // #9820: optional video-generation job preset (job/poll path). + generationConfig, } = validation.data; const model = await addCustomModel( @@ -160,7 +162,8 @@ export async function POST(request) { ...(maxInputTokens != null ? { inputTokenLimit: maxInputTokens } : {}), ...(maxOutputTokens != null ? { outputTokenLimit: maxOutputTokens } : {}), }, - typeof supportsVision === "boolean" ? supportsVision : undefined + typeof supportsVision === "boolean" ? supportsVision : undefined, + generationConfig ); return Response.json({ model }); } catch (error) { @@ -213,6 +216,7 @@ export async function PUT(request) { compatByProtocol, contextWindowOverride, supportsVision, + generationConfig, } = validation.data; const raw = rawBody as Record; @@ -227,6 +231,11 @@ export async function PUT(request) { if ("upstreamHeaders" in raw) updates.upstreamHeaders = upstreamHeaders; // #1904: manual vision-capability override — null clears back to heuristic. if ("supportsVision" in raw) updates.supportsVision = supportsVision; + // #9820: video-generation job preset — schema is non-nullable optional, so + // presence implies a well-formed { preset } object; null is rejected by Zod. + if ("generationConfig" in raw && generationConfig !== undefined) { + updates.generationConfig = generationConfig; + } if ("compatByProtocol" in raw && compatByProtocol !== undefined) { updates.compatByProtocol = compatByProtocol; } diff --git a/src/app/api/v1/videos/generations/route.ts b/src/app/api/v1/videos/generations/route.ts index 7a3cfb4481..ef32fe0818 100644 --- a/src/app/api/v1/videos/generations/route.ts +++ b/src/app/api/v1/videos/generations/route.ts @@ -1,6 +1,7 @@ import { handleVideoGeneration } from "@omniroute/open-sse/handlers/videoGeneration.ts"; import { resolveVideoCredentialProvider } from "@omniroute/open-sse/handlers/videoGeneration/googleFlow.ts"; import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; +import { getAllCustomModels } from "@/lib/db/models"; import { getProviderCredentialsWithQuotaPreflight, clearRecoveredProviderState, @@ -88,7 +89,31 @@ async function postHandler(request, context) { if (policy.rejection) return policy.rejection; // Parse model to get provider - const { provider } = parsedModel; + let { provider, model: requestedModel } = parsedModel; + let isCustomModel = false; + if (!provider) { + // Custom OpenAI-compatible provider nodes (mirrors images route): scan the + // dynamic model registry for a matching `${nodeId}/${modelId}` entry. + try { + const customModelsMap = (await getAllCustomModels()) as Record; + for (const [providerId, models] of Object.entries(customModelsMap)) { + if (!Array.isArray(models)) continue; + for (const model of models) { + if (!model?.id || !Array.isArray(model.supportedEndpoints)) continue; + if (!model.supportedEndpoints.includes("videos")) continue; + const fullId = `${providerId}/${model.id}`; + if (fullId === body.model) { + provider = providerId; + requestedModel = model.id; + isCustomModel = true; + break; + } + } + } + } catch { + // registry read failure — fall through to invalid-model error below + } + } if (!provider) { return errorResponse( HTTP_STATUS.BAD_REQUEST, @@ -116,11 +141,32 @@ async function postHandler(request, context) { if (isAllRateLimitedCredentials(credentials)) { return rateLimitedProviderResponse(provider, credentials); } + } else if (isCustomModel) { + credentials = await getProviderCredentialsWithQuotaPreflight( + provider, + null, + null, + requestedModel + ); + if (!credentials) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No credentials for custom video provider: ${provider}` + ); + } + if (isAllRateLimitedCredentials(credentials)) { + return rateLimitedProviderResponse(provider, credentials); + } } else if (providerConfig?.authType === "none") { credentials = await resolveLocalOverrideCredentials(provider); } - const result: MediaGenerationResultLike = await handleVideoGeneration({ body, credentials, log }); + const result: MediaGenerationResultLike = await handleVideoGeneration({ + body, + credentials, + log, + ...(isCustomModel && { resolvedProvider: provider }), + }); if (isMediaGenerationFailure(result)) { return failedMediaGenerationResponse(result, "Video generation provider error"); diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 0f6499d737..3de90d8ee6 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -109,7 +109,11 @@ export async function addCustomModel( tokenLimits: { inputTokenLimit?: number; outputTokenLimit?: number } = {}, // #1904: optional manual vision-capability override for the "add custom model" // form — read back by getCustomVisionCapabilityFields() in the /v1/models catalog. - supportsVision?: boolean + supportsVision?: boolean, + // #9820: optional video-generation job preset (e.g. "agnes-video-job") for + // custom OpenAI-compatible video models. Persisted on the model row; the + // /v1/videos/generations handler reads it back to pick the job/poll path. + generationConfig?: { preset: string } ) { const db = getDbInstance(); const row = db @@ -135,6 +139,7 @@ export async function addCustomModel( ? { outputTokenLimit: tokenLimits.outputTokenLimit } : {}), ...(typeof supportsVision === "boolean" ? { supportsVision } : {}), + ...(generationConfig && generationConfig.preset ? { generationConfig } : {}), }; models.push(model); db.prepare( @@ -161,6 +166,7 @@ export async function replaceCustomModels( description?: string; supportsThinking?: boolean; targetFormat?: string; + generationConfig?: { preset?: string }; }>, { allowEmpty = false }: { allowEmpty?: boolean } = {} ) { @@ -196,6 +202,13 @@ export async function replaceCustomModels( : (prev as any)?.targetFormat ? { targetFormat: (prev as any).targetFormat } : {}), + // #9820: preserve a video job preset across auto-sync (new value wins, + // else prev — so sync overwrites don't drop a job-config model). + ...(m.generationConfig?.preset + ? { generationConfig: { preset: m.generationConfig.preset } } + : (prev as any)?.generationConfig?.preset + ? { generationConfig: { preset: (prev as any).generationConfig.preset } } + : {}), // Preserve metadata from provider API (or previous sync) ...(m.inputTokenLimit != null ? { inputTokenLimit: m.inputTokenLimit } @@ -722,6 +735,18 @@ export async function updateCustomModel( } } + // #9820: optional video-generation job preset. Mirrors the upstreamHeaders + // pattern: `null`/`undefined` clears a previously set preset; a well-formed + // object replaces it verbatim. + if (Object.prototype.hasOwnProperty.call(updates, "generationConfig")) { + const gc = updates.generationConfig; + if (gc === null || gc === undefined) { + delete next.generationConfig; + } else if (typeof gc === "object" && !Array.isArray(gc)) { + next.generationConfig = gc; + } + } + models[index] = next; db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'customModels' AND key = ?").run( diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index c7daf83b1e..bd70583f22 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -249,6 +249,7 @@ export const providerModelMutationSchema = z.object({ "audio-transcriptions", "audio-speech", "images-generations", + "videos", ]) ) .default(["chat"]), @@ -281,6 +282,17 @@ export const providerModelMutationSchema = z.object({ compatByProtocol: z .partialRecord(z.enum(["openai", "openai-responses", "claude"]), modelCompatPerProtocolSchema) .optional(), + // #9820: optional async video-generation job preset for a custom + // OpenAI-compatible provider whose /videos surface is a submit→poll API + // (agnes-video-job, muapi-video-job, sora-job). Persisted on the custom model + // row; the /v1/videos/generations handler branches on it between the + // synchronous OpenAI-compatible path and the job/poll path. `"openai-video"` + // is a legacy no-op value that keeps the sync handler selected. + generationConfig: z + .object({ + preset: z.enum(["agnes-video-job", "muapi-video-job", "sora-job", "openai-video"]), + }) + .optional(), }); export const updateModelAliasesSchema = z.object({ diff --git a/tests/unit/video-custom-provider-route.test.ts b/tests/unit/video-custom-provider-route.test.ts new file mode 100644 index 0000000000..57f0960ada --- /dev/null +++ b/tests/unit/video-custom-provider-route.test.ts @@ -0,0 +1,351 @@ +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-video-custom-route-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "video-custom-route-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const videoRoute = await import("../../src/app/api/v1/videos/generations/route.ts"); + +const originalFetch = globalThis.fetch; +const originalSetTimeout = globalThis.setTimeout; + +function createResponse(body: BodyInit | null, init?: ResponseInit & { setCookies?: string[] }) { + const response = new Response(body, init); + if (init?.setCookies) { + const cookies = init.setCookies.map((c) => c).join("; "); + response.headers.set("set-cookie", cookies); + } + return response; +} + +function immediateButSafeTimeout( + callback: (...args: unknown[]) => void, + ms?: number, + ...args: unknown[] +) { + if (ms === 20_000 || ms === 5_000) { + return originalSetTimeout(callback as TimerHandler, 0, ...args); + } + return originalSetTimeout(callback as TimerHandler, ms, ...args); +} + +test.afterEach(() => { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; +}); + +test.after(() => { + core.closeDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("video route uses OpenAI-compatible handler for custom provider with videos endpoint", async () => { + globalThis.setTimeout = immediateButSafeTimeout as typeof setTimeout; + + // Seed a custom model tagged with "videos" endpoint + await modelsDb.addCustomModel( + "custom-video-provider", + "super-video-v1", + "Super Video v1", + "manual", + "chat-completions", + ["videos"] + ); + + // Create a provider connection with the custom base URL + await providersDb.createProviderConnection({ + provider: "custom-video-provider", + authType: "apikey", + apiKey: "custom-key", + providerSpecificData: { baseUrl: "https://custom.example.com/v1/videos/generations" }, + }); + + let captured: { url: string; body: unknown; headers: unknown } | null = null; + + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const stringUrl = String(url); + const requestBody = init?.body ? JSON.parse(String(init.body)) : {}; + + captured = { + url: stringUrl, + body: requestBody, + headers: init?.headers, + }; + + // Return a valid OpenAI-like video generation response + return createResponse( + JSON.stringify({ + created: Math.floor(Date.now() / 1000), + data: [{ url: "https://custom.example.com/generated.mp4", format: "mp4" }], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }) as typeof fetch; + + const response = await videoRoute.POST( + new Request("http://localhost/api/v1/videos/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "custom-video-provider/super-video-v1", + prompt: "a cat playing piano", + duration: 5, + }), + }) + ); + + const payload = (await response.json()) as { + data: Array<{ b64_json?: string; url?: string; format?: string }>; + }; + + assert.equal(response.status, 200); + assert.equal(payload.data.length, 1); + assert.equal(payload.data[0].url, "https://custom.example.com/generated.mp4"); + assert.equal(payload.data[0].format, "mp4"); + + // Verify the upstream call went to the custom provider's base URL + assert.ok(captured, "fetch should have been called"); + assert.equal(captured!.url, "https://custom.example.com/v1/videos/generations"); + assert.equal(captured!.headers.Authorization, "Bearer custom-key"); + assert.deepEqual(captured!.body, { + model: "super-video-v1", + prompt: "a cat playing piano", + duration: 5, + }); +}); + +test("video route returns 400 for custom provider without videos endpoint", async () => { + globalThis.setTimeout = immediateButSafeTimeout as typeof setTimeout; + + // Seed a custom model WITHOUT "videos" endpoint + await modelsDb.addCustomModel( + "custom-no-video-provider", + "text-only-model", + "Text Only Model", + "manual", + "chat-completions", + ["chat", "embeddings"] + ); + + const response = await videoRoute.POST( + new Request("http://localhost/api/v1/videos/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "custom-no-video-provider/text-only-model", + prompt: "this should fail", + }), + }) + ); + + assert.equal(response.status, 400); + const payload = await response.json(); + assert.match(payload.error.message, /Invalid video model/); +}); + +test("video route returns 400 for unknown custom provider", async () => { + globalThis.setTimeout = immediateButSafeTimeout as typeof setTimeout; + + const response = await videoRoute.POST( + new Request("http://localhost/api/v1/videos/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "unknown-provider/unknown-model", + prompt: "this should fail", + }), + }) + ); + + assert.equal(response.status, 400); + const payload = await response.json(); + assert.match(payload.error.message, /Invalid video model/); +}); + +test("video route dispatches submit→poll job flow for custom model with agnes-video-job preset", async () => { + globalThis.setTimeout = immediateButSafeTimeout as typeof setTimeout; + + await modelsDb.addCustomModel( + "custom-job-provider", + "job-video-v1", + "Job Video v1", + "manual", + "chat-completions", + ["videos"], + undefined, + {}, + undefined, + { preset: "agnes-video-job" } + ); + + await providersDb.createProviderConnection({ + provider: "custom-job-provider", + authType: "apikey", + apiKey: "custom-key", + providerSpecificData: { baseUrl: "https://custom.example.com" }, + }); + + const calls: Array<{ + url: string; + method: string; + body: unknown; + headers: Record; + }> = []; + + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const stringUrl = String(url); + const method = init?.method || "GET"; + const requestBody = init?.body ? JSON.parse(String(init.body)) : {}; + const headers = (init?.headers || {}) as Record; + + calls.push({ url: stringUrl, method, body: requestBody, headers }); + + if (stringUrl === "https://custom.example.com/v1/videos") { + return createResponse(JSON.stringify({ task_id: "task-123" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (stringUrl === "https://custom.example.com/v1/videos/task-123") { + return createResponse( + JSON.stringify({ + status: "completed", + metadata: { url: "https://custom.example.com/job-out.mp4" }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + return createResponse(JSON.stringify({ error: "unexpected fetch" }), { status: 500 }); + }) as typeof fetch; + + const response = await videoRoute.POST( + new Request("http://localhost/api/v1/videos/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "custom-job-provider/job-video-v1", + prompt: "a cat playing piano", + }), + }) + ); + + const payload = (await response.json()) as { + created: number; + data: Array<{ url?: string; format?: string }>; + }; + + assert.equal(response.status, 200); + assert.equal(payload.data.length, 1); + assert.equal(payload.data[0].url, "https://custom.example.com/job-out.mp4"); + assert.equal(payload.data[0].format, "mp4"); + assert.ok(payload.created > 0); + + assert.equal(calls.length, 2); + assert.equal(calls[0].method, "POST"); + assert.equal(calls[0].url, "https://custom.example.com/v1/videos"); + assert.equal(calls[0].headers["x-api-key"], "custom-key"); + assert.deepEqual(calls[0].body, { + model: "job-video-v1", + prompt: "a cat playing piano", + }); + assert.equal(calls[1].method, "GET"); + assert.equal(calls[1].url, "https://custom.example.com/v1/videos/task-123"); +}); + +test("video route returns 502 when job preset reports failed status", async () => { + globalThis.setTimeout = immediateButSafeTimeout as typeof setTimeout; + + await modelsDb.addCustomModel( + "custom-job-provider-fail", + "job-video-fail-v1", + "Job Video Fail v1", + "manual", + "chat-completions", + ["videos"], + undefined, + {}, + undefined, + { preset: "agnes-video-job" } + ); + + await providersDb.createProviderConnection({ + provider: "custom-job-provider-fail", + authType: "apikey", + apiKey: "custom-fail-key", + providerSpecificData: { baseUrl: "https://custom.example.com" }, + }); + + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + if (String(url).endsWith("/v1/videos")) { + return createResponse(JSON.stringify({ task_id: "task-fail" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return createResponse(JSON.stringify({ status: "failed" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const response = await videoRoute.POST( + new Request("http://localhost/api/v1/videos/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "custom-job-provider-fail/job-video-fail-v1", + prompt: "this should fail", + }), + }) + ); + + assert.equal(response.status, 502); + const payload = (await response.json()) as { error: { message?: string } }; + assert.equal(payload.error?.message, "Video job failed (agnes-video-job)"); +}); + +test("video route returns 502 for unknown generationConfig preset", async () => { + globalThis.setTimeout = immediateButSafeTimeout as typeof setTimeout; + + await modelsDb.addCustomModel( + "custom-job-provider-bad", + "job-video-bad-v1", + "Job Video Bad v1", + "manual", + "chat-completions", + ["videos"], + undefined, + {}, + undefined, + { preset: "no-such-preset" } + ); + + await providersDb.createProviderConnection({ + provider: "custom-job-provider-bad", + authType: "apikey", + apiKey: "custom-bad-key", + providerSpecificData: { baseUrl: "https://custom.example.com" }, + }); + + const response = await videoRoute.POST( + new Request("http://localhost/api/v1/videos/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "custom-job-provider-bad/job-video-bad-v1", + prompt: "bad preset", + }), + }) + ); + + assert.equal(response.status, 502); + const payload = (await response.json()) as { error: { message?: string } }; + assert.equal(payload.error.message, "Unknown video job preset: no-such-preset"); +}); diff --git a/tests/unit/video-generation-handler.test.ts b/tests/unit/video-generation-handler.test.ts index 32cf4a35b4..281576866d 100644 --- a/tests/unit/video-generation-handler.test.ts +++ b/tests/unit/video-generation-handler.test.ts @@ -581,3 +581,108 @@ test("handleVideoGeneration rejects Runway models that require promptImage", asy assert.equal(result.status, 400); assert.match(result.error, /requires promptImage/i); }); +test("handleVideoGeneration uses OpenAI-compatible handler for resolved custom video providers", async () => { + const originalFetch = globalThis.fetch; + let captured; + + globalThis.fetch = async (url, options = {}) => { + captured = { + url: String(url), + body: JSON.parse(String(options.body || "{}")), + headers: options.headers, + }; + + return new Response( + JSON.stringify({ + created: Math.floor(Date.now() / 1000), + data: [{ url: "https://custom.example.com/video.mp4", format: "mp4" }], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const result = await handleVideoGeneration({ + body: { + model: "custom-provider/super-video", + prompt: "a cat playing piano", + duration: 5, + }, + credentials: { + apiKey: "custom-video-key", + providerSpecificData: { + baseUrl: "https://custom.example.com/v1/videos/generations", + }, + }, + resolvedProvider: "custom-provider", + log: null, + }); + + assert.equal(result.success, true); + assert.equal(captured.url, "https://custom.example.com/v1/videos/generations"); + assert.equal(captured.headers.Authorization, "Bearer custom-video-key"); + assert.deepEqual(captured.body, { + model: "super-video", + prompt: "a cat playing piano", + duration: 5, + }); + assert.deepEqual(result.data.data, [ + { url: "https://custom.example.com/video.mp4", format: "mp4" }, + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleVideoGeneration honors resolvedProvider for bare (prefix-less) custom video models", async () => { + const originalFetch = globalThis.fetch; + let captured; + + globalThis.fetch = async (url, options = {}) => { + captured = { + url: String(url), + body: JSON.parse(String(options.body || "{}")), + headers: options.headers, + }; + + return new Response( + JSON.stringify({ + created: Math.floor(Date.now() / 1000), + data: [{ url: "https://custom.example.com/bare.mp4", format: "mp4" }], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + + try { + const result = await handleVideoGeneration({ + body: { + model: "super-video", + prompt: "a cat playing piano", + duration: 5, + }, + credentials: { + apiKey: "custom-video-key", + providerSpecificData: { + baseUrl: "https://custom.example.com/v1/videos/generations", + }, + }, + resolvedProvider: "custom-provider", + log: null, + }); + + assert.equal(result.success, true); + assert.equal(captured.url, "https://custom.example.com/v1/videos/generations"); + assert.equal(captured.headers.Authorization, "Bearer custom-video-key"); + assert.deepEqual(captured.body, { + model: "super-video", + prompt: "a cat playing piano", + duration: 5, + }); + assert.deepEqual(result.data.data, [ + { url: "https://custom.example.com/bare.mp4", format: "mp4" }, + ]); + } finally { + globalThis.fetch = originalFetch; + } +}); From 247f2606cda807fdf8e628956ce9b9889a47d9d7 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:50:37 -0300 Subject: [PATCH 030/100] fix(admission): queue heavyweight chat requests before 503 busy (#9845) Agent clients (OpenCode, Claude Code, Cursor) fan out heavy sub-requests that land on the admission gate together. With the single heavyweight slot, concurrent heavy requests were rejected immediately with a retryable 503; clients burn their retry budget in seconds and the agent dies mid-task. Heavy requests now wait up to OMNIROUTE_CHAT_ADMISSION_QUEUE_MS (default 5000ms) for a slot before the 503, served FIFO; 0 restores the legacy immediate-reject behaviour. Applied to both the byte-based path (admitChatRequest) and the structure-based path (admitChatStructure, now async). Co-authored-by: herjarsa --- docs/guides/TROUBLESHOOTING.md | 13 +- docs/reference/ENVIRONMENT.md | 3 +- src/app/api/v1/chat/completions/route.ts | 9 +- src/shared/middleware/chatBodyAdmission.ts | 72 +++++++- tests/unit/chat-body-admission.test.ts | 205 +++++++++++++++++++-- 5 files changed, 270 insertions(+), 32 deletions(-) diff --git a/docs/guides/TROUBLESHOOTING.md b/docs/guides/TROUBLESHOOTING.md index 4e928e04fc..8c629422a6 100644 --- a/docs/guides/TROUBLESHOOTING.md +++ b/docs/guides/TROUBLESHOOTING.md @@ -523,6 +523,12 @@ exhausts its bounds of `10,000` visited nodes or depth `12`. Each process uses a process-local guard to reserve limited heavyweight capacity before retaining and parsing a large request body. A heavyweight lease remains held for the lifetime of an SSE response. + +When capacity is busy, a heavyweight request first waits up to +`OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` (default `5000`, `0` disables the wait) for a slot to free up +before answering the retryable `503`. The bounded wait exists so agent-style clients +(OpenCode, Claude Code, Cursor) that fan out heavy sub-requests concurrently serialize the burst +instead of burning their whole retry budget on immediate rejections and dying mid-task. Current heavyweight lease occupancy is not surfaced in the dashboard. Settings → Resilience → Request Queue → Concurrent Requests does not control this; that setting governs a separate provider request-queue mechanism. @@ -530,13 +536,18 @@ governs a separate provider request-queue mechanism. **Fix:** 1. Retry first. Clients should honor `Retry-After` and use backoff rather than immediately - repeating the request. + repeating the request. Note that with the default `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS=5000` + a heavy request already waited up to 5 seconds before the `503`, so a client retry loop should + back off beyond that instead of hammering. 2. If normal deployment traffic repeatedly exhausts the guard, you can cautiously raise `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` from its default of `1`. Increase it one step at a time, restart OmniRoute after each change, and observe memory headroom under representative load. Every additional heavyweight request can increase concurrent V8 heap use and container or host OOM risk. No value is safe for every deployment; validate the setting against your own traffic and memory limits rather than assuming that `2` is universally safe. +3. Prefer widening the wait (`OMNIROUTE_CHAT_ADMISSION_QUEUE_MS`) over raising the in-flight + limit when bursts are short: waiting costs latency, while an extra concurrent heavyweight + request costs heap residency for the whole request lifetime. See the [environment-variable reference](../reference/ENVIRONMENT.md#4-security--authentication) for the authoritative admission settings. Loosening the heavyweight classification thresholds diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 161b9f1252..3fad2a7ef2 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -190,7 +190,8 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `MAX_BODY_SIZE_BYTES` | `10485760` (10 MB) | `src/shared/middleware/bodySizeGuard.ts` | Maximum allowed request body size. Rejects payloads exceeding this limit. | | `OMNIROUTE_CHAT_LARGE_BODY_BYTES` | `262144` (256 KB) | `src/shared/middleware/chatBodyAdmission.ts` | Actual request bodies at or above this threshold require an atomic process-local heavyweight admission lease before JSON parsing. | | `OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES` | `52428800` (50 MB) | `src/shared/middleware/chatBodyAdmission.ts` | Chat-route hard cap enforced against bytes read during bounded ingestion, including requests with missing, invalid, or dishonest `Content-Length`; excess receives `413`. | -| `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` | `1` | `src/shared/middleware/chatBodyAdmission.ts` | Maximum heavyweight chat requests admitted concurrently in one process. When capacity is unavailable, OmniRoute returns retryable `503` with `Retry-After`. | +| `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` | `1` | `src/shared/middleware/chatBodyAdmission.ts` | Maximum heavyweight chat requests admitted concurrently in one process. When capacity is unavailable, OmniRoute waits up to `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` for a slot, then returns retryable `503` with `Retry-After`. | +| `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` | `5000` | `src/shared/middleware/chatBodyAdmission.ts` | How long a heavyweight chat request waits for an admission slot before the retryable `503`. A bounded wait serializes agent bursts (OpenCode, Claude Code, Cursor sub-requests) that would otherwise burn their client retry budget on immediate rejections; `0` restores the legacy immediate-reject behaviour. | | `OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT` | `200` | `src/shared/middleware/chatBodyAdmission.ts` | Message count that classifies a chat request as heavyweight even when its body is below the byte threshold. | | `OMNIROUTE_CHAT_HEAVY_TOOL_COUNT` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Tool count that classifies a chat request as heavyweight even when its body is below the byte threshold. | | `OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS` | `32000` | `src/shared/middleware/chatBodyAdmission.ts` | Conservative string-size token estimate that classifies a request as heavyweight; this is an admission-cost proxy, not provider billing tokenization. | diff --git a/src/app/api/v1/chat/completions/route.ts b/src/app/api/v1/chat/completions/route.ts index 4c8f16331a..e61ec10603 100644 --- a/src/app/api/v1/chat/completions/route.ts +++ b/src/app/api/v1/chat/completions/route.ts @@ -17,6 +17,7 @@ import { resolveKeepaliveThreshold } from "@omniroute/open-sse/utils/keepaliveTh import { admitChatRequest, admitChatStructure, + CHAT_ADMISSION_QUEUE_MAX_MS, releaseChatAdmissionAfterHandler, releaseChatAdmissionWhenDone, } from "@/shared/middleware/chatBodyAdmission"; @@ -99,7 +100,9 @@ export async function POST(request) { // Reserve heavyweight capacity atomically and ingest the body with a hard byte bound // BEFORE JSON parsing. Missing or dishonest Content-Length values cannot bypass // the actual-byte limit. Capacity exhaustion is retryable rather than process-fatal. - const admissionResult = await admitChatRequest(request); + const admissionResult = await admitChatRequest(request, { + queueMs: CHAT_ADMISSION_QUEUE_MAX_MS, + }); if (admissionResult.admit === false) return admissionResult.response; const admission = admissionResult; request = admission.request; @@ -142,7 +145,9 @@ export async function POST(request) { } } - const structuralAdmission = admitChatStructure(parsedBody, admission.lease); + const structuralAdmission = await admitChatStructure(parsedBody, admission.lease, { + queueMs: CHAT_ADMISSION_QUEUE_MAX_MS, + }); if (structuralAdmission.admit === false) { admission.lease?.release(); return finishAdmission(structuralAdmission.response); diff --git a/src/shared/middleware/chatBodyAdmission.ts b/src/shared/middleware/chatBodyAdmission.ts index 5219ddf776..4dfdf36bd7 100644 --- a/src/shared/middleware/chatBodyAdmission.ts +++ b/src/shared/middleware/chatBodyAdmission.ts @@ -15,6 +15,11 @@ function parsePositiveInt(value: string | undefined, fallback: number): number { return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; } +function parseNonNegativeInt(value: string | undefined, fallback: number): number { + const parsed = Number.parseInt(String(value), 10); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback; +} + export const CHAT_LARGE_BODY_BYTES = parsePositiveInt( process.env.OMNIROUTE_CHAT_LARGE_BODY_BYTES, 256 * 1024 @@ -30,6 +35,18 @@ const CHAT_MAX_HEAVY_IN_FLIGHT = parsePositiveInt( 1 ); +/** + * How long a heavy request waits for heavyweight capacity before giving up with a + * retryable 503. Agent loops (OpenCode, Claude Code, Cursor…) fan out sub-requests + * that routinely land on the admission gate together; an immediate 503 makes the + * client burn its retry budget in seconds and the agent dies mid-task. A short + * bounded wait serializes the burst instead. `0` (legacy) rejects immediately. + */ +export const CHAT_ADMISSION_QUEUE_MAX_MS = parseNonNegativeInt( + process.env.OMNIROUTE_CHAT_ADMISSION_QUEUE_MS, + 5000 +); + export const CHAT_HEAVY_MESSAGE_COUNT = parsePositiveInt( process.env.OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT, 200 @@ -71,10 +88,13 @@ export interface ChatAdmissionLease { /** * Process-local heavyweight reservation. The capacity check and increment execute in one * synchronous JavaScript turn, making acquisition atomic within an OmniRoute process. - * Queueing is intentionally separate: unavailable capacity is a retryable 503. + * Unavailable capacity is a bounded wait (see `acquireHeavyWithin`) and only then a + * retryable 503, so short agent bursts serialize instead of killing the client's + * retry budget. */ export class ChatAdmissionController { #activeHeavy = 0; + #waiters: Array<() => void> = []; constructor(readonly maxHeavyInFlight = 1) { if (!Number.isSafeInteger(maxHeavyInFlight) || maxHeavyInFlight < 1) { @@ -98,9 +118,40 @@ export class ChatAdmissionController { if (released) return; released = true; this.#activeHeavy = Math.max(0, this.#activeHeavy - 1); + this.#waiters.shift()?.(); }, }; } + + /** + * Wait up to `timeoutMs` for heavyweight capacity, retrying atomically on each + * release. Resolves `null` when the deadline expires with no capacity freed, in + * which case the caller answers the retryable 503. `timeoutMs <= 0` is the + * legacy immediate-reject path. Waiters are served FIFO. + */ + async acquireHeavyWithin(timeoutMs: number): Promise { + const deadline = Date.now() + Math.max(0, Math.floor(timeoutMs)); + for (;;) { + const lease = this.tryAcquireHeavy(); + if (lease) return lease; + const remaining = deadline - Date.now(); + if (remaining <= 0) return null; + let resolver: (() => void) | null = null; + const released = new Promise((resolve) => { + resolver = () => resolve(); + this.#waiters.push(resolver); + }); + const timedOut = await Promise.race([ + released.then(() => false), + new Promise((resolve) => setTimeout(() => resolve(true), remaining)), + ]); + if (resolver) { + const index = this.#waiters.indexOf(resolver); + if (index >= 0) this.#waiters.splice(index, 1); + } + if (timedOut) return null; + } + } } const defaultAdmissionController = new ChatAdmissionController(CHAT_MAX_HEAVY_IN_FLIGHT); @@ -208,7 +259,7 @@ function estimateStructureTokens(value: unknown, limit: number): TokenEstimate { return { tokens, exhausted: stack.length > 0 && tokens < limit }; } -export function admitChatStructure( +export async function admitChatStructure( body: unknown, lease: ChatAdmissionLease | null, options: { @@ -217,8 +268,9 @@ export function admitChatStructure( heavyMessages?: number; heavyTools?: number; heavyTokens?: number; + queueMs?: number; } = {} -): ChatStructureAdmission { +): Promise { if (!body || typeof body !== "object" || Array.isArray(body)) return { admit: true, lease }; const record = body as Record; @@ -249,7 +301,9 @@ export function admitChatStructure( estimatedTokens >= heavyTokens; if (!heavy || lease) return { admit: true, lease }; - const acquired = (options.controller ?? defaultAdmissionController).tryAcquireHeavy(); + const acquired = await (options.controller ?? defaultAdmissionController).acquireHeavyWithin( + options.queueMs ?? 0 + ); return acquired ? { admit: true, lease: acquired } : { admit: false, response: structuralRejectionResponse(503, maxMessages) }; @@ -361,11 +415,13 @@ export async function admitChatRequest( controller?: ChatAdmissionController; largeBodyBytes?: number; hardMaxBytes?: number; + queueMs?: number; } = {} ): Promise { const controller = options.controller ?? defaultAdmissionController; const largeBodyBytes = options.largeBodyBytes ?? CHAT_LARGE_BODY_BYTES; const hardMaxBytes = options.hardMaxBytes ?? CHAT_HARD_MAX_BODY_BYTES; + const queueMs = options.queueMs ?? 0; const internalBypass = isInternalAdmissionBypass(request); const contentLength = parseContentLength(request.headers.get("content-length")); @@ -411,15 +467,15 @@ export async function admitChatRequest( } let lease: ChatAdmissionLease | null = null; - const reserve = (): boolean => { + const reserve = async (): Promise => { if (lease) return true; - lease = controller.tryAcquireHeavy(); + lease = await controller.acquireHeavyWithin(queueMs); return lease !== null; }; // A known-large declaration can reserve before ingestion. Unknown lengths are boundedly // sniffed below; this avoids consuming scarce heavyweight capacity for small chunked bodies. - if (contentLength !== null && contentLength >= largeBodyBytes && !reserve()) { + if (contentLength !== null && contentLength >= largeBodyBytes && !(await reserve())) { return { admit: false, response: rejectionResponse(503, hardMaxBytes) }; } @@ -438,7 +494,7 @@ export async function admitChatRequest( lease?.release(); return { admit: false, response: rejectionResponse(413, hardMaxBytes) }; } - if (totalBytes >= largeBodyBytes && !reserve()) { + if (totalBytes >= largeBodyBytes && !(await reserve())) { await reader.cancel("chat admission capacity unavailable").catch(() => undefined); return { admit: false, response: rejectionResponse(503, hardMaxBytes) }; } diff --git a/tests/unit/chat-body-admission.test.ts b/tests/unit/chat-body-admission.test.ts index e17156a8e3..5ecb5111b6 100644 --- a/tests/unit/chat-body-admission.test.ts +++ b/tests/unit/chat-body-admission.test.ts @@ -60,7 +60,7 @@ test("small known body is admitted without consuming heavyweight capacity", asyn test("a byte-light request above the message threshold acquires heavyweight capacity", async () => { const controller = new ChatAdmissionController(1); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: [ { role: "user", content: "one" }, @@ -82,7 +82,7 @@ test("a byte-light request above the tool threshold is rejected when heavy capac const occupied = controller.tryAcquireHeavy(); assert.ok(occupied); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: [], tools: [{ type: "function" }, { type: "function" }] }, null, { controller, maxMessages: 10, heavyMessages: 10, heavyTools: 2, heavyTokens: 10_000 } @@ -98,7 +98,7 @@ test("a byte-light request above the tool threshold is rejected when heavy capac test("an opt-in history cap still returns the structured compact-required 413", async () => { const controller = new ChatAdmissionController(1); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: Array.from({ length: 3 }, () => ({ role: "user", content: "x" })) }, null, { controller, maxMessages: 2, heavyMessages: 1, heavyTools: 10, heavyTokens: 10_000 } @@ -120,7 +120,7 @@ test("no history cap is enforced by default; long conversations are admitted", a assert.equal(CHAT_HARD_MAX_MESSAGES, 0, "the shipped default must not cap history"); const controller = new ChatAdmissionController(1); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: Array.from({ length: 5_000 }, () => ({ role: "user", content: "x" })) }, null, { controller, heavyMessages: 200, heavyTools: 64, heavyTokens: 32_000 } @@ -137,7 +137,7 @@ test("an uncapped oversized conversation still yields to occupied heavyweight ca const occupied = controller.tryAcquireHeavy(); assert.ok(occupied); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: Array.from({ length: 5_000 }, () => ({ role: "user", content: "x" })) }, null, { controller, maxMessages: 0, heavyMessages: 200, heavyTools: 64, heavyTokens: 32_000 } @@ -153,9 +153,9 @@ test("an uncapped oversized conversation still yields to occupied heavyweight ca occupied.release(); }); -test("maxMessages: 0 explicitly disables the history cap", () => { +test("maxMessages: 0 explicitly disables the history cap", async () => { const controller = new ChatAdmissionController(1); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: Array.from({ length: 3 }, () => ({ role: "user", content: "x" })) }, null, { controller, maxMessages: 0, heavyMessages: 1, heavyTools: 10, heavyTokens: 10_000 } @@ -165,9 +165,9 @@ test("maxMessages: 0 explicitly disables the history cap", () => { if (result.admit) result.lease?.release(); }); -test("a conservative token estimate classifies string messages and tool schemas as heavy", () => { +test("a conservative token estimate classifies string messages and tool schemas as heavy", async () => { const controller = new ChatAdmissionController(1); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: [{ role: "user", content: "abcdefgh" }], tools: [{ type: "function", function: { name: "tool", description: "abcdefgh" } }], @@ -181,9 +181,9 @@ test("a conservative token estimate classifies string messages and tool schemas if (result.admit) result.lease?.release(); }); -test("exhausting the bounded structural inspection is conservatively heavyweight", () => { +test("exhausting the bounded structural inspection is conservatively heavyweight", async () => { const controller = new ChatAdmissionController(1); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: [ { @@ -201,12 +201,12 @@ test("exhausting the bounded structural inspection is conservatively heavyweight if (result.admit) result.lease?.release(); }); -test("tool-schema property names contribute to the conservative token estimate", () => { +test("tool-schema property names contribute to the conservative token estimate", async () => { const controller = new ChatAdmissionController(1); const properties = Object.fromEntries( Array.from({ length: 5 }, (_, index) => [`${index}${"k".repeat(99)}`, {}]) ); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: [], tools: [{ function: { parameters: { properties } } }] }, null, { controller, maxMessages: 10, heavyMessages: 10, heavyTools: 10, heavyTokens: 100 } @@ -217,9 +217,9 @@ test("tool-schema property names contribute to the conservative token estimate", if (result.admit) result.lease?.release(); }); -test("non-ASCII strings use a conservative UTF-8 token estimate", () => { +test("non-ASCII strings use a conservative UTF-8 token estimate", async () => { const controller = new ChatAdmissionController(1); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: [{ role: "user", content: "漢".repeat(100) }] }, null, { controller, maxMessages: 10, heavyMessages: 10, heavyTools: 10, heavyTokens: 100 } @@ -230,10 +230,10 @@ test("non-ASCII strings use a conservative UTF-8 token estimate", () => { if (result.admit) result.lease?.release(); }); -test("wide objects exhaust bounded inspection without materializing all property values", () => { +test("wide objects exhaust bounded inspection without materializing all property values", async () => { const controller = new ChatAdmissionController(1); const wide = Object.fromEntries(Array.from({ length: 10_001 }, (_, index) => [`k${index}`, 0])); - const result = admitChatStructure({ messages: [{ role: "user", content: wide }] }, null, { + const result = await admitChatStructure({ messages: [{ role: "user", content: wide }] }, null, { controller, maxMessages: 10, heavyMessages: 10, @@ -246,12 +246,12 @@ test("wide objects exhaust bounded inspection without materializing all property if (result.admit) result.lease?.release(); }); -test("an existing byte-heavy lease is reused for structure-heavy admission", () => { +test("an existing byte-heavy lease is reused for structure-heavy admission", async () => { const controller = new ChatAdmissionController(1); const lease = controller.tryAcquireHeavy(); assert.ok(lease); - const result = admitChatStructure( + const result = await admitChatStructure( { messages: [ { role: "user", content: "one" }, @@ -637,7 +637,7 @@ test("bypass describe call passes the structural stage while the parent holds th // Structural stage (the route's admitChatStructure(parsedBody, admission.lease)): // the sentinel lease must prevent the heavy body from re-acquiring → no 503. - const structural = admitChatStructure(JSON.parse(body), admission.lease, { controller }); + const structural = await admitChatStructure(JSON.parse(body), admission.lease, { controller }); assert.equal(structural.admit, true); assert.equal(controller.activeHeavy, 1); @@ -813,3 +813,168 @@ test("sk_omniroute sentinel is rejected once an env key is configured (REQUIRE_A restore(); } }); + +test("a heavy structural request waits for capacity instead of failing immediately", async () => { + const controller = new ChatAdmissionController(1); + const held = controller.tryAcquireHeavy(); + assert.ok(held); + + const pending = admitChatStructure( + { + messages: [ + { role: "user", content: "one" }, + { role: "user", content: "two" }, + ], + }, + null, + { + controller, + maxMessages: 10, + heavyMessages: 2, + heavyTools: 10, + heavyTokens: 10_000, + queueMs: 500, + } + ); + + // Capacity is still busy: the request must not have resolved (admit/reject) yet. + let settled = false; + void pending.then(() => { + settled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(settled, false, "must wait while capacity is busy"); + + held.release(); + const result = await pending; + assert.equal(result.admit, true); + if (result.admit) { + assert.equal(controller.activeHeavy, 1, "waiting request acquires the freed lease"); + result.lease?.release(); + } + assert.equal(controller.activeHeavy, 0); +}); + +test("waiting for admission times out into a retryable 503", async () => { + const controller = new ChatAdmissionController(1); + const held = controller.tryAcquireHeavy(); + assert.ok(held); + + const started = Date.now(); + const result = await admitChatStructure( + { + messages: [ + { role: "user", content: "one" }, + { role: "user", content: "two" }, + ], + }, + null, + { + controller, + maxMessages: 10, + heavyMessages: 2, + heavyTools: 10, + heavyTokens: 10_000, + queueMs: 50, + } + ); + + assert.equal(result.admit, false); + if (!result.admit) { + assert.equal(result.response.status, 503); + assert.equal(result.response.headers.get("retry-after"), "1"); + assert.equal((await result.response.json()).error.code, "chat_admission_busy"); + } + assert.ok(Date.now() - started >= 40, "must wait for the queue deadline before rejecting"); + assert.equal(controller.activeHeavy, 1, "the holder keeps its lease"); + held.release(); + assert.equal(controller.activeHeavy, 0); +}); + +test("byte-heavy admission waits for capacity when queueMs is set", async () => { + const controller = new ChatAdmissionController(1); + const body = JSON.stringify({ messages: [{ role: "user", content: "x".repeat(40) }] }); + const options = { controller, largeBodyBytes: 32, hardMaxBytes: 1024, queueMs: 500 }; + + const first = await admitChatRequest(chatRequest(body), options); + assert.equal(first.admit, true); + if (!first.admit) return; + + const second = admitChatRequest(chatRequest(body), options); + let secondSettled = false; + void second.then(() => { + secondSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal( + secondSettled, + false, + "second heavy request must queue while the first holds capacity" + ); + + first.lease?.release(); + const secondResult = await second; + assert.equal(secondResult.admit, true, "second request acquires capacity after release"); + if (secondResult.admit) secondResult.lease?.release(); + assert.equal(controller.activeHeavy, 0); +}); + +test("expired admission queue keeps the legacy immediate 503 behaviour", async () => { + const controller = new ChatAdmissionController(1); + const held = controller.tryAcquireHeavy(); + assert.ok(held); + + const result = await admitChatStructure( + { + messages: [ + { role: "user", content: "one" }, + { role: "user", content: "two" }, + ], + }, + null, + { + controller, + maxMessages: 10, + heavyMessages: 2, + heavyTools: 10, + heavyTokens: 10_000, + queueMs: 0, + } + ); + + assert.equal(result.admit, false); + if (!result.admit) assert.equal(result.response.status, 503); + held.release(); +}); + +test("admission waiters are served FIFO as capacity frees", async () => { + const controller = new ChatAdmissionController(1); + const held = controller.tryAcquireHeavy(); + assert.ok(held); + + const body = { + messages: [ + { role: "user", content: "one" }, + { role: "user", content: "two" }, + ], + }; + const options = { + controller, + maxMessages: 10, + heavyMessages: 2, + heavyTools: 10, + heavyTokens: 10_000, + queueMs: 500, + }; + const first = admitChatStructure(body, null, options); + const second = admitChatStructure(body, null, options); + + held.release(); + const firstResult = await first; + assert.equal(firstResult.admit, true); + if (firstResult.admit) firstResult.lease?.release(); + const secondResult = await second; + assert.equal(secondResult.admit, true); + if (secondResult.admit) secondResult.lease?.release(); + assert.equal(controller.activeHeavy, 0); +}); From 3d590c310bdae8c28706e9409073872aa199f354 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:50:49 -0300 Subject: [PATCH 031/100] fix(ci): repair release lint test regressions (#9896) Co-authored-by: Alex Jordan <60003097+alex-jordan547@users.noreply.github.com> --- tests/unit/repro-7754.test.ts | 20 +++++------ tests/unit/repro-8542.test.ts | 43 ++++++++++++++--------- tests/unit/triage-bugs-2026-08-02.test.ts | 3 ++ 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/tests/unit/repro-7754.test.ts b/tests/unit/repro-7754.test.ts index d997040442..4be47e0158 100644 --- a/tests/unit/repro-7754.test.ts +++ b/tests/unit/repro-7754.test.ts @@ -13,10 +13,10 @@ test("#7754 auto/best-free never leaks the combo name as a model", async () => { // The combo id is the modelStr by design (routing resolves it back), but the // models array must never contain it as a target model. const leak = models.filter( - (m) => - (m.id || "") === "auto/best-free" || - (m.model || "") === "auto/best-free" || - (m.modelStr || "") === "auto/best-free" + (model) => + model.id === "auto/best-free" || + model.model === "auto/best-free" || + ("modelStr" in model && model.modelStr === "auto/best-free") ); assert.equal(leak.length, 0, `combo name leaked as a target model: ${JSON.stringify(leak)}`); }); @@ -24,14 +24,14 @@ test("#7754 auto/best-free never leaks the combo name as a model", async () => { test("#7754 every auto/best-free model carries a concrete provider/model", async () => { const combo = await createBuiltinAutoCombo("auto/best-free", "best-free"); const models = combo.models || []; - for (const m of models) { + for (const model of models) { assert.ok( - m.model && m.model !== "auto/best-free", - `model missing concrete id: ${JSON.stringify(m)}` + model.model && model.model !== "auto/best-free", + `model missing concrete id: ${JSON.stringify(model)}` ); assert.ok( - m.providerId && m.providerId !== "auto", - `model missing concrete provider: ${JSON.stringify(m)}` + model.providerId && model.providerId !== "auto", + `model missing concrete provider: ${JSON.stringify(model)}` ); } }); @@ -47,7 +47,7 @@ test("#7754 empty free-tier pool degrades with a clear 503, not a name leak", as assert.equal(combo.candidatePool?.length || 0, 0); } else { // Non-empty pool must not leak. - const leak = models.filter((m) => (m.model || "") === "auto/best-free"); + const leak = models.filter((model) => model.model === "auto/best-free"); assert.equal(leak.length, 0); } }); diff --git a/tests/unit/repro-8542.test.ts b/tests/unit/repro-8542.test.ts index 62189d36b2..c8ab64d43e 100644 --- a/tests/unit/repro-8542.test.ts +++ b/tests/unit/repro-8542.test.ts @@ -9,18 +9,16 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, "../.."); const WORKFLOW = resolve(repoRoot, ".github/workflows/quality.yml"); -interface WorkflowStep { - name?: string; - run?: string; - "continue-on-error"?: boolean; +type UnknownRecord = Record; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); } -interface WorkflowDocument { - jobs?: Record; -} - -function loadWorkflow(): WorkflowDocument { - return parse(readFileSync(WORKFLOW, "utf8")) as WorkflowDocument; +function loadWorkflow(): UnknownRecord { + const workflow: unknown = parse(readFileSync(WORKFLOW, "utf8")); + if (!isRecord(workflow)) throw new TypeError("quality workflow must be a YAML mapping"); + return workflow; } function invokesGate(run: string): boolean { @@ -31,29 +29,40 @@ function invokesGate(run: string): boolean { /npm run \\"check/.test(run) ); } -function stepCanFail(step: WorkflowStep): boolean { +function stepCanFail(step: UnknownRecord): boolean { return step?.["continue-on-error"] !== true; } test("repro #8542: fast-gates must not fail-fast into a later gate", () => { const wf = loadWorkflow(); - const job = wf.jobs?.["fast-gates"]; + const jobs = isRecord(wf.jobs) ? wf.jobs : {}; + const job = isRecord(jobs["fast-gates"]) ? jobs["fast-gates"] : null; assert.ok(job, "fast-gates job must exist"); - const steps: WorkflowStep[] = job.steps ?? []; + const steps = Array.isArray(job.steps) ? job.steps.filter(isRecord) : []; assert.ok(steps.length >= 5, `fast-gates must have >=5 steps, got ${steps.length}`); - const gateSteps = steps.map((s, i) => ({ s, i })).filter(({ s }) => invokesGate(s?.run ?? "")); + const gateSteps = steps + .map((step, index) => ({ step, index })) + .filter(({ step }) => invokesGate(typeof step.run === "string" ? step.run : "")); assert.ok(gateSteps.length >= 1, `expected >=1 gate step, got ${gateSteps.length}`); const maskedPairs: string[] = []; for (let a = 0; a < gateSteps.length; a++) { const stepA = gateSteps[a]; - if (!stepCanFail(stepA.s)) continue; + if (!stepCanFail(stepA.step)) continue; for (let b = a + 1; b < gateSteps.length; b++) { const stepB = gateSteps[b]; + const stepAName = + typeof stepA.step.name === "string" + ? stepA.step.name + : String(stepA.step.run).split("\n")[0].slice(0, 40); + const stepBName = + typeof stepB.step.name === "string" + ? stepB.step.name + : String(stepB.step.run).split("\n")[0].slice(0, 40); maskedPairs.push( - `step ${stepA.i + 1} (${stepA.s.name ?? String(stepA.s.run).split("\n")[0].slice(0, 40)})` + - ` can fail and masks step ${stepB.i + 1} (${stepB.s.name ?? String(stepB.s.run).split("\n")[0].slice(0, 40)})` + `step ${stepA.index + 1} (${stepAName})` + + ` can fail and masks step ${stepB.index + 1} (${stepBName})` ); } } diff --git a/tests/unit/triage-bugs-2026-08-02.test.ts b/tests/unit/triage-bugs-2026-08-02.test.ts index 0c329c24d8..87ae1ca047 100644 --- a/tests/unit/triage-bugs-2026-08-02.test.ts +++ b/tests/unit/triage-bugs-2026-08-02.test.ts @@ -61,6 +61,7 @@ test("#9142 Anthropic top-level system prompts must trigger background detection "system_prompt_pattern" ); }); + // #9140 — VS Code routes filter out built-in auto models const { isUsableChatModel } = await import("../../src/app/api/v1/vscode/[token]/usableChatModel.ts"); @@ -77,8 +78,10 @@ test("#9140 VS Code listing must accept built-in auto routing entries", () => { "operator-created combo should still be rejected" ); }); + // ── #9160 model discovery: capabilities.effort_tiers ──────────────────────── +// #9160: model discovery must ingest capabilities.effort_tiers test("#9160 model discovery must ingest capabilities.effort_tiers", () => { assert.deepEqual( detectSupportedThinkingEfforts({ From 0bb17b91c6d3e5fc245ad9dfee71aaba9edf1a3d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:50:58 -0300 Subject: [PATCH 032/100] maint: final follow-up cherry-pick #9812 (#9907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(deps): bump transitive deps for 6 Dependabot + remaining audit vulns on main Same overrides as #9464 (ip-address, hono, fast-uri, socket.io-parser, undici) applied directly to main. Also covers brace-expansion (scoped), js-yaml v4 copies, and mermaid. npm audit: 6→0 vulnerabilities. Closes Dependabot #161-#166. * fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190) Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13 (with monaco-editor scoped override). Closes Dependabot #189, #190. Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge — awaiting Dependabot re-scan. npm audit → 0 vulnerabilities. * fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks) _tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential _tasks symlink can slip in via git add -A and, once pulled, checkout materializes it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks ignores the symlink too, preventing re-capture. * feat(telegram): Mini App chat bridge — initData auth, update webhook, chat proxy Implements the Phase-1 slice of the Telegram Mini App integration (docs/proposals/TELEGRAM-MINIAPP.md): - src/lib/telegram/initData.ts — dependency-free WebApp initData HMAC-SHA256 verification (Telegram Bot API spec), with auth_date freshness check. - src/lib/telegram/config.ts — TELEGRAM_BOT_TOKEN / model / API base / timeout env config; token format validation; enabled gate. - src/lib/telegram/botApi.ts — minimal fetch-based Bot API client (sendMessage, editMessageText, setWebhook) + update shape helpers. - src/lib/telegram/chatProxy.ts — maps a Telegram user to a per-user OmniRoute API key (createApiKey, name telegram:) and proxies prompts through the existing handleChat pipeline. - src/app/api/telegram/update/route.ts — inbound endpoint serving both the Bot API update webhook (/start + chat replies) and the Mini App direct path (initData HMAC verified → 401 on mismatch). Public route prefix; own auth only. - src/app/miniapp/page.tsx — Telegram WebApp SDK chat UI. - Tests: telegram-init-data (7), telegram-botapi (5) — 12/12 pass. - Env docs: TELEGRAM_* vars in .env.example + ENVIRONMENT.md (sync ✓). - Route-validation check: PASS (body validated via Zod). --------- Co-authored-by: diegosouzapw Co-authored-by: benzntech --- .env.example | 206 ++----------- .gitignore | 26 +- docs/reference/ENVIRONMENT.md | 102 +------ package-lock.json | 385 +++++++----------------- package.json | 58 ++-- src/app/api/telegram/update/route.ts | 160 ++++++++++ src/app/miniapp/page.tsx | 169 +++++++++++ src/lib/telegram/botApi.ts | 111 +++++++ src/lib/telegram/chatProxy.ts | 106 +++++++ src/lib/telegram/config.ts | 30 ++ src/lib/telegram/initData.ts | 75 +++++ src/shared/constants/publicApiRoutes.ts | 5 + tests/unit/telegram-botapi.test.ts | 67 +++++ tests/unit/telegram-init-data.test.ts | 73 +++++ 14 files changed, 973 insertions(+), 600 deletions(-) create mode 100644 src/app/api/telegram/update/route.ts create mode 100644 src/app/miniapp/page.tsx create mode 100644 src/lib/telegram/botApi.ts create mode 100644 src/lib/telegram/chatProxy.ts create mode 100644 src/lib/telegram/config.ts create mode 100644 src/lib/telegram/initData.ts create mode 100644 tests/unit/telegram-botapi.test.ts create mode 100644 tests/unit/telegram-init-data.test.ts diff --git a/.env.example b/.env.example index d3b3f4b364..3dd22b027f 100644 --- a/.env.example +++ b/.env.example @@ -67,14 +67,6 @@ DISABLE_SQLITE_AUTO_BACKUP=false # Used by: src/shared/utils/rateLimiter.ts # Example: redis://localhost:6379 (or redis://redis:6379 in Docker) # REDIS_URL=redis://localhost:6379 -# Host interface docker-compose publishes the Redis sidecar on. -# Default: 127.0.0.1 (loopback only). The compose Redis runs WITHOUT -# `requirepass`, and app containers reach it over the compose network -# (redis:6379) — the published port is only for host-side tooling. Setting this -# to 0.0.0.0 exposes an unauthenticated Redis to your whole LAN. -# REDIS_BIND_HOST=127.0.0.1 -# Host port for the compose Redis sidecar. Default: 6379. -# REDIS_PORT=6379 # ═══════════════════════════════════════════════════════════════════════════════ # 3. NETWORK & PORTS @@ -345,18 +337,14 @@ ALLOW_API_KEY_REVEAL=false # OMNIROUTE_CHAT_HEAVY_TOOL_COUNT=64 # Conservative string-size token estimate that classifies a request as heavyweight. Default 32000. # OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS=32000 -# Optional opt-in hard message-count cap; excess receives compact-required 413 before -# compression can run. Unset/0 (the default) means no history cap: heap growth is bounded -# by OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT and the heap-pressure shed instead. Set a positive -# value only on memory-constrained deployments that need a hard ceiling. -# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=0 +# Hard message-count cap; excess receives compact-required 413. Default 800. +# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=800 # Hard cap (bytes) for a non-streaming upstream response buffered fully into memory # (#5152). Past this the upstream reader is cancelled and the request fails fast # instead of growing an unbounded string until the V8 heap is exhausted. # Used by: open-sse/handlers/chatCore/nonStreamingResponseBody.ts # Default: 67108864 (64 MB) -# OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES=768 # OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES=67108864 # CORS configuration — controls which cross-origin browser clients can call the API. @@ -457,13 +445,6 @@ ALLOW_API_KEY_REVEAL=false # Default: false # OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS=false -# Per-model concurrency cap for round-robin combos (#9100). -# Used by: open-sse/services/comboConfig.ts — the round-robin combo semaphore -# was hard-capped at 3 concurrent requests per model with no override, which -# serialized higher-concurrency traffic behind that cap. -# Validated to >= 1, clamped to <= 32. | Default: 3 -# COMBO_CONCURRENCY_PER_MODEL=3 - # ═══════════════════════════════════════════════════════════════════════════════ # 7. URLS & CLOUD SYNC # ═══════════════════════════════════════════════════════════════════════════════ @@ -797,16 +778,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500 # Disable the proactive recovery scheduler entirely (default: false). # OMNIROUTE_DISABLE_CONNECTION_RECOVERY=false -# Proactive Claude warmup scheduler (#8848): fires a trivial request to opted-in -# OAuth connections on a cron schedule (America/Los_Angeles) so accounts do not -# hit the 5-hour sliding window cold. Off by default — set ENABLED=1 and flip -# per-connection flags in settings.claudeWarmup.connections to activate. -# Used by: src/lib/warmupScheduler.ts. -# OMNIROUTE_WARMUP_ENABLED=false -# OMNIROUTE_WARMUP_CRON="0 7 * * *" -# OMNIROUTE_WARMUP_CONCURRENCY=3 -# OMNIROUTE_WARMUP_MODEL= - # Background job interval for budget reset checks (ms). Default: 600000 (10m). # Used by: src/lib/jobs/budgetResetJob.ts. Floor: 10000. #OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS=600000 @@ -871,12 +842,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500 # (>= 3 retrievals = never compressed). 1 disables the ramp (binary skip at the threshold only). # Used by: open-sse/services/compression/engines/ccr/index.ts. Default: 2. #COMPRESSION_CCR_RETRIEVAL_RAMP_FACTOR=2 -# CCR durable block store (#9061). The in-memory store loses blocks to LRU eviction, the TTL, a -# restart, or a retrieve landing on another instance, while the model is told it can retrieve them -# verbatim. Set to false to keep blocks in memory only, at the cost of that promise. Blocks over -# 512KB and cloud runtimes are memory-only regardless. -# Used by: open-sse/services/compression/engines/ccr/index.ts. Default: true. -#COMPRESSION_CCR_DURABLE_STORE=true # T08/H5 — usage-observed prefix freeze (OPT-IN, default off). When enabled, a system prompt seen # >= THRESHOLD times is treated as a stable cacheable prefix and preserved from compression even # for providers the static cache-aware heuristic does not recognize (freeze = preserve, never @@ -1061,17 +1026,6 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 # VISION_BRIDGE_BASE_URL= # VISION_BRIDGE_API_KEY= -# ── Raycast Pro (local auto-import) ── -# Raycast Pro AI is a reverse-engineered, unofficial API — local/personal use -# only (no OAuth client_id/secret; token is captured via macOS Auto-Import -# from the Keychain + local Raycast SQLite DB, or pasted manually). These -# vars are optional manual overrides used by open-sse/services/raycast.ts -# and the direct-probe benchmark script scripts/raycast/usage-benchmark.mjs. -# RAYCAST_BEARER_TOKEN= -# RAYCAST_DEVICE_ID= -# RAYCAST_AID= -# RAYCAST_SIG_SECRET= - # ───────────────────────────────────────────────────────────────────────────── # ⚠️ GOOGLE OAUTH (Antigravity) & OTHER PROVIDERS — REMOTE SERVERS # ───────────────────────────────────────────────────────────────────────────── @@ -1212,17 +1166,6 @@ CURSOR_USER_AGENT="Cursor/3.4" # fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min). # OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS=120000 -# ── Proxy/relay fetch (connection pooling, #9158) ── -# Used by: open-sse/utils/proxyFetch.ts. -# A hung relay must fail BEFORE the client/agent timeout (typically 30s) so the -# caller sees a relay-specific failure instead of a generic upstream timeout. -# Capped at 29000ms so this timeout always fires first. Default: 25000 (25s). -# OMNIROUTE_RELAY_FETCH_TIMEOUT_MS=25000 - -# Shared retry backoff (ms) for the direct/relay/proxy retry-once paths. -# 0 = retry immediately. Default: 10. -# OMNIROUTE_RETRY_BACKOFF_MS=10 - # ── Firecrawl web-fetch executor ── # Point at a self-hosted Firecrawl instance (defaults to the public cloud API). # When set to a non-cloud base URL, the API key becomes optional. @@ -1284,14 +1227,6 @@ CURSOR_USER_AGENT="Cursor/3.4" # OMNIROUTE_BROWSER_POOL=on # WEB_COOKIE_USE_BROWSER=0 -# ── Adobe Firefly browser sign-in (system Chrome/Edge CDP) ── -# Used by: open-sse/services/adobeFireflyBrowserLogin.ts. The Firefly login -# flow drives a real, system-installed Chrome or Microsoft Edge via CDP so the -# user can sign in interactively; the executable is auto-detected from common -# install paths per OS. Set this to override that detection (e.g. a portable -# install or a non-standard path) when auto-detection fails. -# OMNIROUTE_LOGIN_BROWSER_PATH= - # ── Circuit breaker thresholds and reset windows ── # Used by: open-sse/config/constants.ts → src/lib/resilience/settings.ts. # Defaults match historical PROVIDER_PROFILES values (post-scaling for @@ -1403,10 +1338,6 @@ APP_LOG_TO_FILE=true # Default: 100000 # CALL_LOGS_TABLE_MAX_ROWS=100000 -# Force detailed request logging on or off, overriding the dashboard setting. -# Values: true | false | Default: unset (follow dashboard setting) -# ENABLE_REQUEST_LOGS=false - # Maximum age for orphaned active request log entries before the in-memory # pending-request reaper removes them. Accepts milliseconds. # Default: 3600000 (1 hour) @@ -1483,6 +1414,10 @@ APP_LOG_TO_FILE=true # Default: ~/.omniroute/plugins/ Override in dev/CI to point at a local plugin tree. # OMNIROUTE_PLUGIN_PATH= +# Allow plugins to request the 'exec' permission (spawn child processes from the +# plugin worker sandbox). Disabled by default; set to 1 to enable (local operator only). +# OMNIROUTE_PLUGINS_ALLOW_EXEC=0 + # ── Prompt cache (system prompt deduplication) ── # Used by: open-sse/services — caches identical system prompts across requests. # PROMPT_CACHE_MAX_SIZE=50 # Max cached entries (default: 50) @@ -1540,15 +1475,6 @@ APP_LOG_TO_FILE=true # ═══════════════════════════════════════════════════════════════════════════════ # 19. MODEL SYNC (Dev) # ═══════════════════════════════════════════════════════════════════════════════ -# Enable the models.dev capability sync. Default: false (opt-in only). -# Also settable from Dashboard > Settings > AI. This variable wins over that -# setting whenever it is set to anything non-empty, in either direction, so a -# deployment can pin the sync on or off without depending on database state -# surviving a rebuild. Leave it unset to let the dashboard toggle decide. -# On: 1, true, yes or on (any casing). Any other value is off. -# Used by: src/lib/modelsDevSync.ts -# MODELS_DEV_SYNC_ENABLED=false - # Development-time model catalog sync interval in seconds. # Used by: src/lib/modelsDevSync.ts # Default: 86400 (24 hours) @@ -1571,14 +1497,6 @@ APP_LOG_TO_FILE=true # Default: 86400000 (24 hours) # OPENROUTER_CATALOG_TTL_MS=86400000 -# Enrich the dashboard providers list with OpenRouter weekly ranking stats. -# ON by default; set false to skip the background fetch entirely (#9324). -# Used by: src/lib/catalog/openrouterProviderStats.ts -# OPENROUTER_PROVIDER_STATS_ENABLED=true -# Cache TTL for the OpenRouter provider stats snapshot, in ms. -# Default: 86400000 (24 hours) -# OPENROUTER_PROVIDER_STATS_TTL_MS=86400000 - # ── Model catalog response shape ── # Include display-friendly name fields in /v1/models responses. # Disable for clients that expect model IDs only. @@ -1599,13 +1517,6 @@ APP_LOG_TO_FILE=true # DESIGNER_WEB_POLL_TIMEOUT_MS=60000 # Max wait for job completion (default: 60s) # DESIGNER_WEB_POLL_INTERVAL_MS=2000 # Poll frequency (default: 2s) -# ── Adobe Firefly (Image Upscale) ── -# Base delay (ms) for the submit-retry exponential backoff when Adobe Firefly's -# upscale job submission is rate-limited. Used by: -# open-sse/services/adobeFireflyUpscale.ts::submitRetryDelayMs. -# Default: 8000 (20 under NODE_ENV=test/VITEST/NODE_TEST_CONTEXT). -# ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS=8000 - # ── AWS Bedrock (Kiro / Audio) ── # Region used to construct AWS Bedrock endpoints. Used by: # src/lib/providers/validation.ts and open-sse/handlers/audioSpeech.ts. @@ -1700,26 +1611,6 @@ APP_LOG_TO_FILE=true # Used by: src/lib/services/bootstrap.ts, src/app/api/services/mux/_lib.ts # MUX_SERVICE_PORT=8322 -# ── Dario embedded service ── -# Override the host/port the embedded Dario (Claude Code subscription proxy) -# daemon binds to and is reached at. Always bound to 127.0.0.1 — never -# configurable to 0.0.0.0. Rarely needed — defaults to 127.0.0.1:3456. -# Used by: src/lib/services/installers/dario.ts, src/lib/services/bootstrap.ts, -# src/app/api/services/dario/_lib.ts, src/app/api/services/dario/admin/_lib.ts, -# open-sse/executors/dario.ts -# DARIO_HOST=127.0.0.1 -# DARIO_PORT=3456 - -# ── Dario embedded service ── -# Override the host/port the embedded Dario (Claude Code subscription proxy) -# daemon binds to and is reached at. Always bound to 127.0.0.1 — never -# configurable to 0.0.0.0. Rarely needed — defaults to 127.0.0.1:3456. -# Used by: src/lib/services/installers/dario.ts, src/lib/services/bootstrap.ts, -# src/app/api/services/dario/_lib.ts, src/app/api/services/dario/admin/_lib.ts, -# open-sse/executors/dario.ts -# DARIO_HOST=127.0.0.1 -# DARIO_PORT=3456 - # ── Local hostnames (Docker networking) ── # Comma-separated additional hostnames treated as "local" for provider routing. # Used by: open-sse/config/providerRegistry.ts — allows Docker service names. @@ -1952,18 +1843,6 @@ APP_LOG_TO_FILE=true # ── Devin CLI binary path ── # Used by: open-sse/executors/devin-cli.ts. Default: looked up via PATH. # CLI_DEVIN_BIN=devin -# Agentic bridge-only binary override. The bridge still executes ACP stdio only. -# CLI_DEVIN_AGENTIC_BIN=devin -# Required isolated HOME for the agentic Devin child process. -# DEVIN_AGENTIC_HOME=/home/bridge -# Bounded ACP turn timeout in milliseconds. Default: 120000. -# DEVIN_AGENTIC_ACP_TIMEOUT_MS=120000 -# Agentic bridge model aliases. Values must keep the devin-cli-agentic/ prefix. -# DEVIN_BRIDGE_MODEL=devin-cli-agentic/swe-1-7 -# DEVIN_BRIDGE_SONNET_MODEL=devin-cli-agentic/swe-1-7 -# DEVIN_BRIDGE_OPUS_MODEL=devin-cli-agentic/swe-1-7 -# DEVIN_BRIDGE_HAIKU_MODEL=devin-cli-agentic/swe-1-7 -# DEVIN_BRIDGE_SUBAGENT_MODEL=devin-cli-agentic/swe-1-7 # ── Command Code (custom CLI) callback ── # Local port used for OAuth-style callbacks from the Command Code CLI helper. @@ -2018,15 +1897,6 @@ APP_LOG_TO_FILE=true # CHANGELOG_BASE_REF=origin/release/v0.0.0 # ALLOW_CHANGELOG_REMOVALS=1 -# ── Remote audio provider nodes ── -# Used by: src/app/api/v1/_shared/audioProviderNodes.ts — lets the /v1/audio/* -# routes use an OpenAI-compatible provider node hosted outside localhost. -# OFF by default: routing audio to a remote host changes egress identity, so it -# must be an explicit operator decision. Loopback/private nodes (localhost, -# 127.0.0.1, 172.16-31.x) are always allowed and unaffected by this flag. -# When enabled, the node authenticates with the API key stored on its connection. -# AUDIO_REMOTE_PROVIDER_NODES=false - # ── 1Proxy egress pool ── # Used by: src/lib/oneproxySync.ts — fetches proxy nodes from the OmniRoute # CrofAI 1Proxy service. Disable, override URL, or tune the import quality. @@ -2234,11 +2104,6 @@ PLAYGROUND_COMPARE_MAX_COLUMNS=4 # MEMORY_TYPED_DECAY_EPISODIC_DAYS=30 # episodic TTL in days; 0 = episodic immune too # MEMORY_TYPED_DECAY_ACCESS_IMMUNITY=3 # access_count >= N → immune; 0 disables access immunity # MEMORY_TYPED_DECAY_SWEEP_INTERVAL=0 # periodic sweep interval (seconds); 0 = no periodic sweep -# ─── Memory Backend Connectors (Generic HTTP) ────────────────────────────── -# NOTION_API_KEY= -# NOTION_API_URL= -# OBSIDIAN_API_KEY= -# OBSIDIAN_API_URL= # AgentBridge + Traffic Inspector (Group A) # AgentBridge @@ -2254,15 +2119,6 @@ INSPECTOR_MAX_BODY_KB=1024 INSPECTOR_MASK_SECRETS=true INSPECTOR_LLM_HOSTS_EXTRA= INSPECTOR_INTERNAL_INGEST_TOKEN= -# Shared secret for identity-preserving internal REST hops (#9260): when an -# OmniRoute component calls another local OmniRoute route, this token (sent as -# x-omniroute-internal-service-token) marks the request as internal so the -# original caller identity is preserved. OPT-IN: unset disables the mechanism. -# Used by: src/lib/api/internalServiceAuth.ts -# OMNIROUTE_INTERNAL_SERVICE_TOKEN= -# File-based variant (secret-file pattern; wins only when the inline var is -# unset): path to a file whose trimmed content is the token. -# OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE= # Quota Sharing (Group B — planos 16+22) QUOTA_STORE_DRIVER=sqlite # sqlite | redis # QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis) @@ -2373,11 +2229,6 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # Host port for the 1-click Redis launcher. Default: 6379. Bump if the host # already binds 6379. The container's internal port stays 6379. # OMNIROUTE_REDIS_HOST_PORT= -# Host interface the 1-click Redis launcher publishes on. Default: 127.0.0.1 -# (loopback only). The launcher starts Redis WITHOUT a password, so binding -# 0.0.0.0 hands every host on your LAN an unauthenticated Redis — only widen -# this if you also set a password on the instance yourself. -# OMNIROUTE_REDIS_BIND_HOST= # Redis image used by the 1-click Redis launcher. Default: redis:7-alpine. # Override to redis:8-alpine or a private registry mirror as needed. # OMNIROUTE_REDIS_IMAGE= @@ -2480,37 +2331,20 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # ───────────────────────────────────────────────────────────────────────────── # VIBEPROXY_DATA_DIR= -# ── Internal service auth (management-plane service-to-service calls) ───────── -# Inline token for internal service authentication; prefer the _FILE variant in -# containerized deployments so the secret never lands in the environment table. -# OMNIROUTE_INTERNAL_SERVICE_TOKEN= -# Path to a file containing the internal service token (overrides the inline var). -# OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE= +# ───────────────────────────────────────────────────────────────────────────── +# Telegram Mini App (inbound bot webhook + Mini App chat) +# Used by: src/lib/telegram/*, src/app/api/telegram/update/route.ts +# ───────────────────────────────────────────────────────────────────────────── +# Bot token from @BotFather (:). Enables the inbound +# update webhook and doubles as the HMAC secret for Mini App initData +# verification. When unset, /api/telegram/update returns 503. +# TELEGRAM_BOT_TOKEN= -# ═══════════════════════════════════════════════════════════════════════════════ -# 26. RADAR FEED (SELF-HOSTING) -# ═══════════════════════════════════════════════════════════════════════════════ -# Optional add-on (feature flag RADAR_ENABLED, default off — see feature flag -# settings, not an env var) that overlays a signed, freshly-curated free-model -# catalog on top of the release baseline. All four variables below are optional -# and only needed to point the client at a self-hosted/forked feed or -# supporter-key flow instead of the default OmniRoute Radar service. Used by: -# src/lib/radar/sync.ts, src/lib/radar/pinnedKeys.ts, src/lib/radar/links.ts. +# Model used for Telegram chat replies (default: auto/chat). +# TELEGRAM_DEFAULT_MODEL=auto/chat -# Base URL of the Radar feed service. Overrides the built-in default so forks -# and self-hosters can point at their own signed feed. -# RADAR_FEED_URL=https://radar.omniroute.online +# Bot API base URL override (for proxies/self-hosted Bot API servers). +# TELEGRAM_BOT_API_BASE=https://api.telegram.org -# Ed25519 public key (base64-DER SPKI or PEM) used to verify the feed -# signature, replacing the pinned default key. Required when self-hosting a -# feed signed with a different key pair. -# RADAR_FEED_PUBKEY= - -# URL the dashboard's "I'm a contributor" button opens (GitHub OAuth -# supporter-key claim flow). No pricing/value lives in this repo — only the -# link. -# RADAR_CONTRIBUTOR_CLAIM_URL=https://radar.omniroute.online/auth/github - -# URL the dashboard's "Support the project" button opens (payment/plans -# page). No pricing/value lives in this repo — only the link. -# RADAR_SUPPORTER_PLANS_URL=https://radar.omniroute.online/planos +# Timeout (ms) for outbound Bot API calls (sendMessage/setWebhook). +# TELEGRAM_WEBHOOK_TIMEOUT_MS=60000 diff --git a/.gitignore b/.gitignore index ffccb9d763..f2738f3aa7 100644 --- a/.gitignore +++ b/.gitignore @@ -72,7 +72,6 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* !.env.example -!.env.devin-bridge.example !.env.homolog.example # Provider API keys (never commit) *.api-key @@ -172,6 +171,7 @@ config/quality/test-impact-map.json # GitNexus local index .gitnexus .worktrees +bin/omniroute.mjs # Consistent with .dockerignore / .npmignore .omc/ @@ -201,17 +201,12 @@ scripts/i18n/_pending-keys.json .codegraph/ # Fumadocs generated source -/.source/ - -# Temporary local worktrees used to build unpublished npm tarballs -/.deploy-build-*/ +.source/ # AI agent local settings and configs .agents/ .antigravitycli/ .claude/ -!tests/fixtures/devin-bridge/e2e-workspace/.claude/ -!tests/fixtures/devin-bridge/e2e-workspace/.claude/** # PR Reviews and local feedback files pr_reviews*.json @@ -238,10 +233,7 @@ omniroute.md # mise configuration mise.toml -# release-green artifacts (.gitignore has no inline comments — a trailing -# `# ...` becomes part of the pattern, so it must sit on its own line). -# Already covered by /_*/ above; kept explicit for discoverability. -_artifacts/ +_artifacts/ # release-green artifacts .claude-flow/ # ESLint file cache (npm run lint --cache / complexity ratchets) @@ -251,8 +243,6 @@ _artifacts/ # CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.) .artifacts/ -# Isolated Devin bridge workspaces, evidence, and test databases -.sandbox/ # Homologation E2E suite (npm run homolog) — real-environment credentials + report output .env.homolog @@ -260,12 +250,8 @@ tests/homolog/.auth/ tests/homolog/ui/.auth/ homolog-report/ docker-compose.yml.bak -.playwright-cli/ -# Playwright screenshot/log output. Today every artifact happens to land inside -# output/**/.playwright-cli/ (covered above), but anything written directly to -# output/ would otherwise show up as untracked. -/output/ -# _tasks e um repo git SEPARADO (ver AGENTS.md). _tasks/ (com barra) NAO ignora um -# SYMLINK _tasks; /_tasks (ancorado) cobre symlink/dir na raiz (incidente 2026-08-08). +# _tasks e um repo git SEPARADO (ver AGENTS.md). A linha _tasks/ (com barra) NAO +# ignora um SYMLINK chamado _tasks; /_tasks (ancorado) cobre arquivo/symlink/dir na raiz +# e impede que um git add -A recapture o symlink (incidente 2026-08-08). /_tasks diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 3fad2a7ef2..115d8cfb64 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -43,7 +43,6 @@ lastUpdated: 2026-06-28 - [22. Debugging](#22-debugging) - [23. GitHub Integration](#23-github-integration) - [24. Skills Sandbox (v3.8.0+)](#24-skills-sandbox-v380) -- [27. Radar Feed (Self-Hosting)](#27-radar-feed-self-hosting) - [Deployment Scenarios](#deployment-scenarios) - [Audit: Removed / Dead Variables](#audit-removed--dead-variables) @@ -195,16 +194,14 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT` | `200` | `src/shared/middleware/chatBodyAdmission.ts` | Message count that classifies a chat request as heavyweight even when its body is below the byte threshold. | | `OMNIROUTE_CHAT_HEAVY_TOOL_COUNT` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Tool count that classifies a chat request as heavyweight even when its body is below the byte threshold. | | `OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS` | `32000` | `src/shared/middleware/chatBodyAdmission.ts` | Conservative string-size token estimate that classifies a request as heavyweight; this is an admission-cost proxy, not provider billing tokenization. | -| `OMNIROUTE_CHAT_HARD_MAX_MESSAGES` | `0` (disabled) | `src/shared/middleware/chatBodyAdmission.ts` | Optional opt-in chat history cap. Disabled by default: a message count is deployment policy, not a universal property of a request, and capping here rejects conversations with a terminal `413` before the compression pipeline can make them servable. Heap growth is bounded by `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` and the heap-pressure shed. Set a positive value on memory-constrained deployments that need a hard ceiling; excess then receives structured compact-required `413`. | +| `OMNIROUTE_CHAT_HARD_MAX_MESSAGES` | `800` | `src/shared/middleware/chatBodyAdmission.ts` | Hard chat history cap. Requests above it receive structured compact-required `413` before compression, translation, or provider dispatch. | | `OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES` | `67108864` (64 MB) | `open-sse/handlers/chatCore/nonStreamingResponseBody.ts` | Hard cap for a non-streaming upstream response buffered fully into memory. Past this the upstream reader is cancelled and the request fails fast instead of growing an unbounded string until the heap is exhausted. | -| `OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES` | `768` | `open-sse/handlers/chatCore/responseHeaders.ts` | Max wire bytes forwarded from upstream response headers. When the budget is exceeded, lower-priority headers (e.g., custom `x-codex-*`, `x-oai-request-id`) are dropped to stay within common reverse-proxy header limits. Set higher to forward more upstream metadata at the cost of larger response header size. | | `CORS_ORIGIN` | _(unset)_ | `src/server/cors/origins.ts` | Legacy single-origin CORS allowlist. Prefer `CORS_ALLOWED_ORIGINS` for new deployments. CORS is only for cross-origin browser API clients; authenticated dashboard writes use same-origin requests plus session-bound CSRF protection instead. | | `CORS_ALLOWED_ORIGINS` | _(unset)_ | `src/server/cors/origins.ts` | Comma-separated CORS allowlist. No wildcard is sent unless `CORS_ALLOW_ALL=true` is explicitly configured. | | `CORS_ALLOW_ALL` | `false` | `src/server/cors/origins.ts` | Development-only escape hatch to echo any browser `Origin`. Do not enable on shared or production deployments. | | `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | Block provider calls targeting private/loopback/link-local IP ranges. Disable only in isolated test envs. | | `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | `false` | `src/shared/network/outboundUrlGuard.ts` | Allow provider URLs pointing to private/local networks (localhost, 192.168.x.x, 10.x.x.x, etc.). **REQUIRED for self-hosted providers** (LM Studio, Ollama, vLLM, Llamafile, Triton, SearXNG). When `false`, the dashboard rejects validation of local URLs. | | `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | `true` | `src/shared/network/outboundUrlGuard.ts` | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN, private ranges) — scoped to the provider validation path. **Default `true`** (local-first); set `false` to enforce strict public-only blocking. Cloud-metadata endpoints (169.254.169.254, metadata.google.internal) stay blocked regardless. (#5066) | -| `AUDIO_REMOTE_PROVIDER_NODES` | `false` | `src/app/api/v1/_shared/audioProviderNodes.ts` | Let the `/v1/audio/*` routes (transcriptions, speech, translations) use an OpenAI-compatible provider node hosted outside localhost. Off by default — routing audio to a remote host changes egress identity and must be an explicit operator decision. Loopback/private nodes (localhost, 127.0.0.1, 172.16-31.x) are always allowed and unaffected. (#3963) | ### Hardening Checklist @@ -268,7 +265,6 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | `OMNIROUTE_PAYLOAD_RULES_PATH` | `./config/payloadRules.json` | `open-sse/services/payloadRules.ts` | Path to payload manipulation rules JSON file (per-model/protocol upstream tweaks). | | `OMNIROUTE_PAYLOAD_RULES_RELOAD_MS` | `5000` | `open-sse/services/payloadRules.ts` | Reload interval (ms) for hot-reloading the payload rules file. Minimum `1000`. | | `OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS` | `false` | `open-sse/services/model.ts` | Opt-in: route bare `claude-*` model IDs from Claude Code clients through the Claude Code OAuth account instead of requiring a provider prefix. Explicit provider prefixes still win. Also configurable via a dashboard toggle on the Claude provider page. | -| `COMBO_CONCURRENCY_PER_MODEL` | `3` | `open-sse/services/comboConfig.ts` | Per-model concurrency cap for round-robin combos (#9100). The round-robin combo semaphore was hard-capped at 3 concurrent requests per model with no override, serializing higher-concurrency traffic behind that cap. Validated to `>= 1`, clamped to `<= 32`. | --- @@ -382,14 +378,6 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex, | `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Custom path to Qoder CLI binary. | | `CLI_QWEN_BIN` | `qwen` | `src/shared/services/cliRuntime.ts` | Custom path to the Qwen Code CLI binary. | | `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Custom path to the Devin CLI binary (v3.8.0). Used by the Windsurf/Devin executor. | -| `CLI_DEVIN_AGENTIC_BIN` | `devin` | `open-sse/executors/devin-cli-agentic.ts` | Agentic bridge-only Devin CLI override. The executor accepts only the local ACP stdio upstream. | -| `DEVIN_AGENTIC_HOME` | _(required)_ | `open-sse/executors/devin-cli-agentic.ts` | Absolute isolated home for the agentic Devin subprocess; accepted bridge paths are `/home/bridge` and task-local `.sandbox` paths. | -| `DEVIN_AGENTIC_ACP_TIMEOUT_MS` | `120000` | `open-sse/executors/devin-cli-agentic.ts` | Maximum duration of one Devin ACP turn before the bridge terminates the child and returns an explicit timeout. | -| `DEVIN_BRIDGE_MODEL` | `devin-cli-agentic/swe-1-7` | `docker/devin-bridge/compose.yml` | Main Claude Code model alias for the isolated bridge. The live harness replaces the example with a model returned by the current Devin account. | -| `DEVIN_BRIDGE_SONNET_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Sonnet default. | -| `DEVIN_BRIDGE_OPUS_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Opus default. | -| `DEVIN_BRIDGE_HAIKU_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used when Claude Code requests its Haiku default. | -| `DEVIN_BRIDGE_SUBAGENT_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used for Claude Code subagents. | | `AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Absolute-path override for the Augment (Auggie) CLI binary used by the local `auggie` provider. Falls back to `CLI_AUGGIE_BIN`, then a PATH lookup. | | `CLI_AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Alias override for the Augment (Auggie) CLI binary path (checked after `AUGGIE_BIN`). | | `HERMES_HOME` | `~/.hermes` | `src/lib/cli-helper/config-generator/hermesHome.ts` | Hermes Agent home directory where OmniRoute reads/writes the Hermes CLI config. Matches the env var the Hermes PowerShell installer sets on Windows (`%LOCALAPPDATA%\hermes`). | @@ -427,6 +415,7 @@ detection above). | `OMNIROUTE_HTTP_TIMEOUT_MS` | `30000` | `bin/cli/api.mjs` | Per-attempt HTTP timeout (ms) for CLI → server requests. | | `OMNIROUTE_VERBOSE` | `0` | `bin/cli/api.mjs` | Set to `1` to print retry/backoff diagnostics to stderr during CLI commands. | | `OMNIROUTE_PLUGIN_PATH` | _(unset)_ | `bin/cli/plugins.mjs` | Custom directory for CLI plugin discovery (`omniroute-cmd-*` packages). Defaults to `~/.omniroute/plugins/` when unset. | +| `OMNIROUTE_PLUGINS_ALLOW_EXEC` | `0` | `src/lib/plugins/pluginWorker.ts` | Set to `1` to allow plugins to request the `exec` permission (spawn child processes from the worker sandbox). Local operator only. | --- @@ -463,7 +452,6 @@ detection above). | `COMPRESSION_PIPELINE_BREAKER_THRESHOLD` | `3` | `open-sse/services/compression/pipelineEngineBreaker.ts` | Consecutive cross-request failures before an engine's breaker opens. | | `COMPRESSION_PIPELINE_BREAKER_COOLDOWN_MS` | `30000` | `open-sse/services/compression/pipelineEngineBreaker.ts` | Milliseconds an opened engine stays skipped before a half-open probe. | | `COMPRESSION_CCR_RETRIEVAL_RAMP_FACTOR` | `2` | `open-sse/services/compression/engines/ccr/index.ts` | T08/H8 CCR retrieval-feedback ramp: each prior retrieval of a stored block raises its effective `minChars` linearly (frequently-retrieved content compresses less; `>=3` retrievals = never compressed). `1` disables the ramp (binary skip at the threshold only). | -| `COMPRESSION_CCR_DURABLE_STORE` | `true` | `open-sse/services/compression/engines/ccr/index.ts` | CCR durable block store (#9061). Backs the in-memory store with SQLite so a block survives LRU eviction, the TTL, a restart, or a retrieve landing on another instance. Set `false` to keep blocks in memory only. Blocks over 512KB and cloud runtimes stay memory-only regardless. | | `COMPRESSION_PREFIX_FREEZE_ENABLED` | `false` | `open-sse/services/compression/prefixFreeze.ts` | T08/H5 usage-observed prefix freeze master switch. **Opt-in (default off)** — when on, a system prompt observed `>=` the threshold is treated as a stable cacheable prefix and preserved from compression even for providers the static cache heuristic misses (freeze only *preserves*, never mutates). | | `COMPRESSION_PREFIX_FREEZE_THRESHOLD` | `3` | `open-sse/services/compression/prefixFreeze.ts` | Observations of a system prompt before it is treated as a frozen stable prefix. | | `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Set `true` by bootstrap script after initial setup. Controls setup wizard visibility. | @@ -519,12 +507,8 @@ Built-in credentials for **localhost development**. For remote deployments, regi | `OMNIROUTE_QODER_WORKSPACE` | Qoder | Alias for `QODER_CLI_WORKSPACE`. | | `QODER_CLI_CONFIG_DIR` | Qoder | Override the Qoder CLI config dir (isolated PAT session, avoids clobbering a browser login). | | `BLACKBOX_WEB_VALIDATED_TOKEN` | Blackbox Web | Frontend `tk` token to send as `validated` on `/api/chat`. Required when Blackbox enforces token matching; otherwise OmniRoute falls back to a random UUID. See issue #2252. | -| `VISION_BRIDGE_BASE_URL` | Vision Bridge guardrail | OpenAI-compatible base URL for non-Anthropic vision-bridge calls. Defaults to the legacy OpenAI URL env or api.openai.com. Point at OmniRoute's `/v1` self-loop or any OpenAI-compat endpoint (Gemini OpenAI-compat, OpenRouter). Issue #2232. When the URL is OmniRoute's own `/v1`, the describe sub-request sends `x-omniroute-admission-bypass: internal` and authenticates with the resolved self-loop credential (`sk_omniroute` sentinel in local mode, or `OMNIROUTE_API_KEY` / `ROUTER_API_KEY` — #1350) so `REQUIRE_API_KEY=true` deployments work. | +| `VISION_BRIDGE_BASE_URL` | Vision Bridge guardrail | OpenAI-compatible base URL for non-Anthropic vision-bridge calls. Defaults to the legacy OpenAI URL env or api.openai.com. Point at OmniRoute's `/v1` self-loop or any OpenAI-compat endpoint (Gemini OpenAI-compat, OpenRouter). Issue #2232. | | `VISION_BRIDGE_API_KEY` | Vision Bridge guardrail | API key for the URL above. Overrides per-provider OpenAI / Google env vars for non-Anthropic vision-bridge calls. Anthropic models keep their dedicated Anthropic key path. Issue #2232. | -| `RAYCAST_BEARER_TOKEN` | Raycast Pro | Optional manual override for the Raycast access token (normally captured via macOS Auto-Import). No OAuth client_id/secret — reverse-engineered, local/personal use only. | -| `RAYCAST_DEVICE_ID` | Raycast Pro | Optional manual override for the Raycast device ID used to sign requests. | -| `RAYCAST_AID` | Raycast Pro | Optional manual override for the Raycast account/app ID; falls back to the device ID when unset. | -| `RAYCAST_SIG_SECRET` | Raycast Pro | Optional override for the request-signing HMAC secret. Defaults to a community-extracted value in `open-sse/services/raycast.ts`. | > [!WARNING] > @@ -672,8 +656,6 @@ REQUEST_TIMEOUT_MS (global override) | `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Raw socket timeout (0 = disabled). | | `SHUTDOWN_TIMEOUT_MS` | `30000` | Grace period on SIGTERM/SIGINT before force-exit. | | `OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS` | `120000` | Fallback used by `src/shared/utils/fetchTimeout.ts` when `FETCH_TIMEOUT_MS` is unset. | -| `OMNIROUTE_RELAY_FETCH_TIMEOUT_MS` | `25000` | Relay-specific fetch timeout in `open-sse/utils/proxyFetch.ts` (#9158). A hung relay must fail before the client/agent timeout (~30s) so callers see a relay-specific failure instead of a generic upstream timeout. Capped at `29000` so it always fires first. | -| `OMNIROUTE_RETRY_BACKOFF_MS` | `10` | Shared retry backoff for the direct/relay/proxy retry-once paths in `open-sse/utils/proxyFetch.ts` (#9158). `0` = retry immediately. | | `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`chatgptTlsClient.ts`). | | `OMNIROUTE_CHATGPT_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | | `OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS` | `30000` (30s) | Max wait for the first streamed byte from the ChatGPT TLS sidecar (`chatgptTlsClient.ts`) before aborting a dead stream. Raise if upstream cold-starts exceed the window. | @@ -687,7 +669,6 @@ REQUEST_TIMEOUT_MS (global override) | `OMNIROUTE_NOTION_TLS_GRACE_MS` | `10000` | JS-side grace added on top of the wire timeout when the native binding is wedged. | | `OMNIROUTE_BROWSER_POOL` | `on` | Shared Playwright browser pool for browser-backed web-cookie chat (`browserPool.ts`); set `off` to disable. | | `WEB_COOKIE_USE_BROWSER` | `0` | Opt a web-cookie chat request into the browser-backed path (`browserBackedChat.ts`); `1` to enable. | -| `OMNIROUTE_LOGIN_BROWSER_PATH` | _(auto-detected)_ | Path to a system Chrome/Edge executable for the Adobe Firefly interactive browser sign-in (`adobeFireflyBrowserLogin.ts`); overrides per-OS auto-detection. | Combo target attempts inherit the resolved upstream request timeout (`FETCH_TIMEOUT_MS`, or `REQUEST_TIMEOUT_MS` when it supplies the fetch default). Set `targetTimeoutMs` in a combo, @@ -735,7 +716,6 @@ The logging system writes to both stdout and rotated log files. All configuratio | `CALL_LOG_RETENTION_DAYS` | `7` | Days to keep request/call log entries in the database. | | `CALL_LOG_MAX_ENTRIES` | `10000` | Max call log entries in the in-memory buffer. | | `CALL_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `call_logs` SQLite table before pruning. | -| `ENABLE_REQUEST_LOGS` | _(unset)_ | Force detailed request logging on or off, overriding the dashboard setting. | | `MAX_PENDING_REQUEST_AGE_MS` | `3600000` (1 hour) | Max age for orphaned active request log entries before in-memory cleanup. | | `CALL_LOG_PIPELINE_CAPTURE_STREAM_CHUNKS` | `true` | Store stream chunks in pipeline artifacts when `call_log_pipeline_enabled=true`. | | `CALL_LOG_PIPELINE_MAX_SIZE_KB` | `512` | Max pipeline call log artifact size in KB when `call_log_pipeline_enabled=true`. | @@ -781,13 +761,9 @@ Embedding layer, vector store and reranking knobs for the persistent memory subs | `MEMORY_TRANSFORMERS_MODEL` | `Xenova/all-MiniLM-L6-v2` | HF repo id for the opt-in `@huggingface/transformers` local MiniLM pipeline (~23 MB int8, ~400 MB RAM). | | `MEMORY_STATIC_MODEL` | `minishlab/potion-base-8M` | HF repo id for the static potion/Model2Vec lookup-table embedder. Downloaded lazily into the cache dir. | | `MEMORY_STATIC_CACHE_DIR` | `/embeddings` | Directory used to cache the static potion model files. Defaults under `DATA_DIR` when unset. | -| `HF_HUB_ENDPOINT` | `https://huggingface.co` | Override Hugging Face Hub base URL used by `staticPotion.ts` (e.g. mirror endpoint for air-gapped setups). | | `MEMORY_VEC_TOP_K` | `20` | Default top-K used by the `sqlite-vec` brute-force vector search inside `src/lib/memory/vectorStore.ts`. | | `MEMORY_RRF_K` | `60` | Reciprocal Rank Fusion constant `k` for hybrid FTS5 + vector retrieval (sqlite-vec recipe). | -| `NOTION_API_KEY` | _(unset)_ | API key for Notion backend (used by `genericBackend.ts` known backend preset). | -| `NOTION_API_URL` | `https://api.notion.com/v1`| Base URL for Notion API (can override for self-hosted Notion alternatives). | -| `OBSIDIAN_API_KEY` | _(unset)_ | API key for Obsidian Vault backend (used by `genericBackend.ts` known backend preset). | -| `OBSIDIAN_API_URL` | `http://localhost:27123` | Base URL for Obsidian Vault API (can override for remote vault). | +| `HF_HUB_ENDPOINT` | `https://huggingface.co` | Override Hugging Face Hub base URL used by `staticPotion.ts` (e.g. mirror endpoint for air-gapped setups). | | `MEMORY_TYPED_DECAY_ENABLED` | `false` | TV6 typed memory decay master switch. **Opt-in (default off)** — the sweep **deletes** decayed memories. With it off, `access_count`/`last_accessed_at` are pure telemetry and nothing is ever deleted. | | `MEMORY_TYPED_DECAY_EPISODIC_DAYS` | `30` | TTL (days) after which an unused `episodic` memory decays. `0` makes episodic immune too. Durable types (`factual`/`procedural`/`semantic`) are always immune. The decay clock re-bases on `last_accessed_at`. | | `MEMORY_TYPED_DECAY_ACCESS_IMMUNITY` | `3` | A memory injected `>=` this many times becomes immune to decay regardless of type. `0` disables access immunity. | @@ -854,7 +830,6 @@ Reverse-engineered session bridge for hyperagent.com (`src/shared/constants/prov | Variable | Default | Source File | Description | | ----------------------------------- | ------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MODELS_DEV_SYNC_ENABLED` | `false` | `src/lib/modelsDevSync.ts` | Opt-in switch for the models.dev capability sync. Set to anything non-empty it wins over the `modelsDevSyncEnabled` setting (Dashboard > Settings > AI) in either direction, so a deployment can pin the sync on or off without depending on database state surviving a rebuild; unset, it defers to that setting. On for `1`, `true`, `yes` or `on` in any casing; any other value is off. | | `MODELS_DEV_SYNC_INTERVAL` | `86400` (24h) | `src/lib/modelsDevSync.ts` | Development-time model catalog sync interval in seconds. | | `CONTEXT_WINDOW_RECONCILE_INTERVAL` | `86400` (24h) | `src/lib/contextWindowResolver.ts` | Interval (seconds) for the self-correcting context-window reconciler (5004): pins provider-declared windows from `/models` discovery as `auto:discovery` overrides when they diverge from the catalog. Set to `0` to disable. Reuses already-synced data (no new fetch); never overwrites `manual` overrides. | @@ -870,7 +845,6 @@ Reverse-engineered session bridge for hyperagent.com (`src/shared/constants/prov | `NANOBANANA_POLL_INTERVAL_MS` | `2500` | `open-sse/handlers/imageGeneration.ts` | NanoBanana job polling frequency. | | `DESIGNER_WEB_POLL_TIMEOUT_MS` | `60000` | `open-sse/handlers/imageGeneration/providers/designerWeb.ts` | Max wait for microsoft-designer-web image generation jobs. | | `DESIGNER_WEB_POLL_INTERVAL_MS` | `2000` | `open-sse/handlers/imageGeneration/providers/designerWeb.ts` | microsoft-designer-web job polling frequency. | -| `ADOBE_FIREFLY_SUBMIT_BASE_DELAY_MS` | `8000` | `open-sse/services/adobeFireflyUpscale.ts` | Base delay for the Adobe Firefly upscale submit-retry exponential backoff. | | `AWS_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Region used to construct AWS Bedrock endpoints (Kiro, audio). | | `AWS_DEFAULT_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Fallback when `AWS_REGION` is not set. | | `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | Account ID for Cloudflare Workers AI. | @@ -892,10 +866,6 @@ Reverse-engineered session bridge for hyperagent.com (`src/shared/constants/prov | `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge port. | | `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. | | `MUX_SERVICE_PORT` | `8322` | `src/lib/services/bootstrap.ts` | Override the port where the embedded Mux (coder/mux) agent-orchestration daemon listens (always 127.0.0.1). | -| `DARIO_HOST` | `127.0.0.1` | `open-sse/executors/dario.ts` | Dario embedded-service bind/connect host (loopback only by default). | -| `DARIO_PORT` | `3456` | `open-sse/executors/dario.ts` | Dario embedded-service port. | -| `DARIO_HOST` | `127.0.0.1` | `open-sse/executors/dario.ts` | Dario embedded-service bind/connect host (loopback only by default). | -| `DARIO_PORT` | `3456` | `open-sse/executors/dario.ts` | Dario embedded-service port. | | `LOCAL_HOSTNAMES` | _(empty)_ | `open-sse/config/providerRegistry.ts` | Comma-separated additional hostnames treated as "local" (Docker service names, etc.). | `ENABLE_CC_COMPATIBLE_PROVIDER` is only for third-party relays that accept Claude Code clients @@ -1176,13 +1146,6 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `OMNIROUTE_LOCAL_ENDPOINTS_TOKEN` | _(unset)_ | `src/lib/security/localEndpoints.ts` | Bearer token for `/api/local/*` callers that aren't on loopback (e.g. the desktop app). When set, requests from non-loopback IPs must carry `Authorization: Bearer `. Required when `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1` in non-loopback deployments. | | `OMNIROUTE_REDIS_CONTAINER_NAME` | `omniroute-redis` | `bin/cli/commands/redis.mjs` | Container name for the 1-click Redis launcher (`omniroute redis up`). Used by both the CLI and the `RedisLauncherPanel` GUI. | | `OMNIROUTE_REDIS_HOST_PORT` | `6379` | `bin/cli/commands/redis.mjs` | Host port for the 1-click Redis launcher. Bump if the host already binds 6379. The container's internal port stays 6379. | -| `OMNIROUTE_REDIS_BIND_HOST` | `127.0.0.1` | `bin/cli/commands/redis.mjs` | Host interface the 1-click Redis launcher publishes on. The launcher starts Redis WITHOUT a password, so binding `0.0.0.0` hands every host on your LAN an unauthenticated Redis — only widen this if you also set a password on the instance yourself. | -| `REDIS_BIND_HOST` | `127.0.0.1` | `docker-compose.yml` | Host interface docker-compose publishes the Redis sidecar on (#9286). The compose Redis runs without `requirepass`; app containers reach it over the compose network (`redis:6379`) — the published port exists only for host-side tooling. `0.0.0.0` exposes an unauthenticated Redis to the whole LAN. | -| `REDIS_PORT` | `6379` | `docker-compose.yml` | Host port for the compose Redis sidecar. | -| `OMNIROUTE_INTERNAL_SERVICE_TOKEN` | _(unset — mechanism disabled)_ | `src/lib/api/internalServiceAuth.ts` | Shared secret for identity-preserving internal REST hops (#9260): OmniRoute components calling other local OmniRoute routes send it as `x-omniroute-internal-service-token` so the original caller identity is preserved. Compared with `timingSafeEqual`. | -| `OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE` | _(unset)_ | `src/lib/api/internalServiceAuth.ts` | Secret-file variant of the internal service token: path to a file whose trimmed content is the token. Only consulted when the inline var is unset. | -| `OPENROUTER_PROVIDER_STATS_ENABLED` | `true` | `src/lib/catalog/openrouterProviderStats.ts` | Enrich the dashboard providers list with OpenRouter weekly ranking stats (#9324). On by default; set `false` to skip the background fetch entirely (non-blocking, never fatal). | -| `OPENROUTER_PROVIDER_STATS_TTL_MS` | `86400000` (24h) | `src/lib/catalog/openrouterProviderStats.ts` | Cache TTL for the OpenRouter provider-stats snapshot, in milliseconds. | | `OMNIROUTE_REDIS_IMAGE` | `redis:7-alpine` | `bin/cli/commands/redis.mjs` | Redis image used by the 1-click Redis launcher. Override to `redis:8-alpine` or a private registry mirror as needed. | | `QDRANT_HOST` | `qdrant` | _(opt-in cluster profile)_ | Hostname of the Qdrant sidecar when `--profile memory` is active. Default points to the in-network qdrant service name; override for an external deployment. Only consumed when `qdrantEnabled` is `true` in code (`src/lib/memory/vectorStore.ts:108`). | | `QDRANT_PORT` | `6333` | _(opt-in cluster profile)_ | REST port of the Qdrant sidecar. | @@ -1208,17 +1171,6 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `OMNIROUTE_ROTATE_400_THRESHOLD` | `1` | `open-sse/services/rotationConfig.ts` | Number of `400` errors within `OMNIROUTE_ROTATE_400_WINDOW_SECONDS` required before the account is rotated (only consulted when `OMNIROUTE_ROTATE_ON_400=true`). | | `OMNIROUTE_ROTATE_400_WINDOW_SECONDS` | `120` | `open-sse/services/rotationConfig.ts` | Sliding window (seconds) over which `400` errors are counted toward `OMNIROUTE_ROTATE_400_THRESHOLD`. | -### Claude Warmup Scheduler - -Cron-driven warmup for opted-in Anthropic OAuth connections, so the 5-hour rate-limit window is opened by a trivial scheduled request instead of by the first real one (#8848). The scheduler is off unless `OMNIROUTE_WARMUP_ENABLED` is truthy **and** the connection is flagged in `settings.claudeWarmup.connections`; an empty connection list means nothing is warmed even with the env var on. - -| Variable | Default | Source File | Description | -| ----------------------------- | -------------------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `OMNIROUTE_WARMUP_ENABLED` | _(unset → off)_ | `src/lib/warmupScheduler.ts` | Master switch for the warmup scheduler. Accepts `1`/`true`/`yes`/`on` (case-insensitive, trimmed). Any other value, or unset, leaves the scheduler off. | -| `OMNIROUTE_WARMUP_CRON` | `0 7 * * *` | `src/lib/warmupScheduler.ts` | Five-field cron expression for the warmup tick, evaluated in `America/Los_Angeles` (Anthropic's reset timezone) regardless of the host clock. | -| `OMNIROUTE_WARMUP_CONCURRENCY` | `3` | `src/lib/warmupScheduler.ts` | How many connections are warmed in parallel per tick. Clamped to `1`-`10`; a non-numeric value falls back to `3`. | -| `OMNIROUTE_WARMUP_MODEL` | `claude-3-5-haiku-20241022` | `src/lib/warmupScheduler.ts` | Model used for the warmup request. Override only if the default is unavailable on your plan; pick the cheapest model that still opens the window. | - ### Browser-Login VNC Sessions & Data-Dir Alias Containerized Chromium+VNC used for interactive browser-login credential capture (`/api/vnc-session`), plus a legacy `DATA_DIR` alias. All optional — the VNC defaults target the bundled `omniroute-vnc-chromium:local` image and are only overridden for a custom container image, ports, or lifecycle tuning. @@ -1283,25 +1235,6 @@ that should be able to run the docs translator. --- -## 27. Radar Feed (Self-Hosting) - -Optional add-on gated by the RADAR_ENABLED feature flag (default off — a feature -flag toggled via Settings/DB, not an env var; see -[docs/frameworks/RADAR.md](../frameworks/RADAR.md#flag-radar_enabled-default-off)). -The four variables below are optional overrides used only to point the client at a -self-hosted or forked feed / supporter-key flow instead of the default OmniRoute -Radar service. See [docs/frameworks/RADAR.md](../frameworks/RADAR.md) for the full -module doc. - -| Variable | Default | Source File | Description | -| -------------------------------- | --------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------ | -| `RADAR_FEED_URL` | `https://radar.omniroute.online` | `src/lib/radar/sync.ts` | Base URL of the Radar feed service. Override to point at a self-hosted or forked feed. | -| `RADAR_FEED_PUBKEY` | _(pinned default key)_ | `src/lib/radar/pinnedKeys.ts` | Ed25519 public key (base64-DER SPKI or PEM) used to verify feed signatures from a custom feed. | -| `RADAR_CONTRIBUTOR_CLAIM_URL` | `https://radar.omniroute.online/auth/github` | `src/lib/radar/links.ts` | URL the "I'm a contributor" dashboard button opens (GitHub OAuth supporter-key claim flow). | -| `RADAR_SUPPORTER_PLANS_URL` | `https://radar.omniroute.online/planos` | `src/lib/radar/links.ts` | URL the "Support the project" dashboard button opens (payment/plans page). | - ---- - ## Audit: Removed / Dead Variables The following variables appeared in previous versions of `.env.example` but have **no runtime references** in the current codebase. They have been removed: @@ -1369,24 +1302,13 @@ Used by `src/lib/vncSession/manifest.ts` to configure Docker-based headless Chro | `OMNIROUTE_VNC_HARVEST_MS` | `20000` | `src/lib/vncSession/manifest.ts` | Harvest/cleanup timeout (ms). | | `VIBEPROXY_DATA_DIR` | _(unset)_ | `open-sse/services/notionThreadSessions.ts` | Directory for Notion thread session persistence. | -### Internal service auth +### Telegram Mini App -| Variable | Default | Description | -| --- | --- | --- | -| `OMNIROUTE_INTERNAL_SERVICE_TOKEN` | – | Inline token for management-plane service-to-service authentication. | -| `OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE` | – | Path to a file containing the internal service token (preferred in containers; overrides the inline variable). | +Used by `src/lib/telegram/*` and `src/app/api/telegram/update/route.ts` for the inbound bot webhook and Mini App chat proxy. All optional — the endpoint returns 503 when `TELEGRAM_BOT_TOKEN` is unset. -### OpenRouter provider stats - -| Variable | Default | Description | -| --- | --- | --- | -| `OPENROUTER_PROVIDER_STATS_ENABLED` | `true` | Set to `false` to skip fetching OpenRouter per-provider stats for catalog enrichment. | -| `OPENROUTER_PROVIDER_STATS_TTL_MS` | `3600000` | Cache TTL (ms) for the fetched OpenRouter provider stats. | - -### Embedded Redis binding - -| Variable | Default | Description | -| --- | --- | --- | -| `REDIS_BIND_HOST` | `127.0.0.1` | Bind address for the embedded Redis service. | -| `REDIS_PORT` | `6379` | Port for the embedded Redis service. | -| `OMNIROUTE_REDIS_BIND_HOST` | – | OmniRoute-scoped override for the embedded Redis bind address. | +| Variable | Default | Source File | Description | +| ------------------------------ | -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------- | +| `TELEGRAM_BOT_TOKEN` | _(unset)_ | `src/lib/telegram/config.ts` | Bot token from @BotFather (`:`). Enables the inbound webhook; doubles as the HMAC secret for Mini App `initData` verification. | +| `TELEGRAM_DEFAULT_MODEL` | `auto/chat` | `src/lib/telegram/chatProxy.ts` | Model used for Telegram chat replies. | +| `TELEGRAM_BOT_API_BASE` | `https://api.telegram.org` | `src/lib/telegram/config.ts` | Bot API base URL override (proxies / self-hosted Bot API servers). | +| `TELEGRAM_WEBHOOK_TIMEOUT_MS` | `60000` | `src/lib/telegram/config.ts` | Timeout (ms) for outbound Bot API calls (`sendMessage`/`setWebhook`). | diff --git a/package-lock.json b/package-lock.json index 188829e9a6..f83e6a5940 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.50", + "version": "3.8.49", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.50", + "version": "3.8.49", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -80,7 +80,7 @@ "sqlite-vec": "^0.1.9", "tailwind-merge": "^3.6.0", "tsx": "^4.23.0", - "undici": "^8.10.0", + "undici": "^8.3.0", "update-notifier": "^7.3.1", "uuid": "^14.0.0", "ws": "^8.18.0", @@ -133,7 +133,6 @@ "lint-staged": "^17.0.8", "lockfile-lint": "^5.0.0", "node-loader": "^2.1.0", - "opencode-ai": "1.18.8", "playwright-ctrf-json-reporter": "^0.0.29", "prettier": "^3.8.3", "promptfoo": "^0.121.18", @@ -153,7 +152,7 @@ "@atjsh/llmlingua-2": "2.0.3", "@huggingface/transformers": "3.5.2", "@tensorflow/tfjs": "4.22.0", - "better-sqlite3": "^13.0.2", + "better-sqlite3": "^13.0.1", "js-tiktoken": "^1.0.20", "keytar": "^7.9.0", "tls-client-node": "^0.2.0", @@ -3693,6 +3692,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3709,6 +3711,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3725,6 +3730,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3741,6 +3749,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3757,6 +3768,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3773,6 +3787,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3789,6 +3806,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3805,6 +3825,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -3821,6 +3844,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3843,6 +3869,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3865,6 +3894,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3887,6 +3919,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3909,6 +3944,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3931,6 +3969,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3953,6 +3994,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -3975,6 +4019,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -5346,6 +5393,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5362,6 +5412,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5378,6 +5431,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5394,6 +5450,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -10611,6 +10670,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -10628,6 +10690,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -10645,6 +10710,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -10662,6 +10730,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -12708,6 +12779,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "optional": true, "os": [ "linux" @@ -12721,6 +12795,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" @@ -12734,6 +12811,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "optional": true, "os": [ "linux" @@ -12747,6 +12827,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" @@ -12760,6 +12843,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "optional": true, "os": [ "linux" @@ -12773,6 +12859,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" @@ -13598,14 +13687,11 @@ } }, "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } + "license": "MIT" }, "node_modules/base64-js": { "version": "1.5.1", @@ -13670,9 +13756,10 @@ } }, "node_modules/better-sqlite3": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.2.tgz", - "integrity": "sha512-jW6oufeDhXZaiX9Lw5A+oerVClx4iFrI6uDj1zu7SqUAjak9vbJvA0NEcKLNxHiQHb6kYCoFzzXYV0YOauhV3g==", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.1.tgz", + "integrity": "sha512-LYpmOXdkpQYf4wmlxkdzW01XGlOXNIbjLg45yNkh0FQ4814VbK9PdOFmhZpYbej+EZtR/i3FDdhEG98HqZdgnA==", + "hasInstallScript": true, "license": "MIT", "optional": true, "dependencies": { @@ -13954,16 +14041,14 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, "node_modules/braces": { @@ -24392,25 +24477,6 @@ "node": ">= 14" } }, - "node_modules/libxmljs2/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/libxmljs2/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/libxmljs2/node_modules/cacache": { "version": "19.0.1", "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", @@ -26883,24 +26949,6 @@ "node": "*" } }, - "node_modules/minimatch/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/minimatch/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -28739,205 +28787,6 @@ } } }, - "node_modules/opencode-ai": { - "version": "1.18.8", - "resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.18.8.tgz", - "integrity": "sha512-eZvYK0rIc/NUDQ+s3LsO9gyUU3MswsbNOLZz06iPwVhbg/2jF6bkTaroBgiIdFWKwUn5sj+kSMc4TBYxFkMrNQ==", - "cpu": [ - "arm64", - "x64" - ], - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "os": [ - "darwin", - "linux", - "win32" - ], - "bin": { - "opencode": "bin/opencode.exe" - }, - "optionalDependencies": { - "opencode-darwin-arm64": "1.18.8", - "opencode-darwin-x64": "1.18.8", - "opencode-darwin-x64-baseline": "1.18.8", - "opencode-linux-arm64": "1.18.8", - "opencode-linux-arm64-musl": "1.18.8", - "opencode-linux-x64": "1.18.8", - "opencode-linux-x64-baseline": "1.18.8", - "opencode-linux-x64-baseline-musl": "1.18.8", - "opencode-linux-x64-musl": "1.18.8", - "opencode-windows-arm64": "1.18.8", - "opencode-windows-x64": "1.18.8", - "opencode-windows-x64-baseline": "1.18.8" - } - }, - "node_modules/opencode-darwin-arm64": { - "version": "1.18.8", - "resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.8.tgz", - "integrity": "sha512-ZZCIEgTvHxOHk52Aeqhq59t/R0aqs29bPIgu45XE4rkgjmn/XCkTWalCPtyzJHipdcEbq/g0lqsE1OlJV0oNbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/opencode-darwin-x64": { - "version": "1.18.8", - "resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.8.tgz", - "integrity": "sha512-2EXRMJbRKnFPWI9oDU9tb7jDGmKiPmfjCLtwJMe3EF57h5wfcdEH9sP25bR3Og5NbE2M+PtMcJm0jMeHn2XoLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/opencode-darwin-x64-baseline": { - "version": "1.18.8", - "resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.18.8.tgz", - "integrity": "sha512-eLXa2tK9LRuZ5e20QG2k4dmWAA5xnLgJ1afRTSD0/ybE6CAeK02i8vFCnFFDaxuBo+gnq+yqO8AkqvN1m64V/Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/opencode-linux-arm64": { - "version": "1.18.8", - "resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.8.tgz", - "integrity": "sha512-7kj3c9JEdryHgK+o8zE/N9KzTOdbiDn6KpY8dl+hM9n5Cnmxezx4IAlgJeC9QxpIx8Omop6CYuZ+17KfrKdKLw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/opencode-linux-arm64-musl": { - "version": "1.18.8", - "resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.18.8.tgz", - "integrity": "sha512-tww5TF/LIOv/GoTNyzGYgqDRhbJrhoMu8R+p5yD/SpnXPg3rcfYREw2wRy9yikyPU9sAQksuIIteTsyGerPjlA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/opencode-linux-x64": { - "version": "1.18.8", - "resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.8.tgz", - "integrity": "sha512-Sm4fbQ9BdLI6hgN6FYYX8Nql+Sqe/2EKHJu3iWg0UYs93AXN4ROi0rvOmRbMk+ycYgOchb0hL6Ti2opxLx17sg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/opencode-linux-x64-baseline": { - "version": "1.18.8", - "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.18.8.tgz", - "integrity": "sha512-egeEF4tk1rK9flIQjjeSVB9cR/X3zUti0pNAHW6ROJkNkj72z2C2FmjK1hZbfjtteCueMXPLptS23JROHGWL1w==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/opencode-linux-x64-baseline-musl": { - "version": "1.18.8", - "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.18.8.tgz", - "integrity": "sha512-S+438BXs48gLeXX/ya4TSNytDy9mliU3sOAf6j9rfFjzGiF/S08LedemSAnHkr0riBtamik1aRPSmTjhQ0dOBg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/opencode-linux-x64-musl": { - "version": "1.18.8", - "resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.18.8.tgz", - "integrity": "sha512-c+E4Zsp0DYVcuqcDtgxw/4YcFLrVYWdGBR8x4CzpW48ga3RshaH+BlmUiy+GY0yr1x6UR+e2V3w4uzvzm/L9UQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/opencode-windows-arm64": { - "version": "1.18.8", - "resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.8.tgz", - "integrity": "sha512-7NjdtEIiX28kmsKD9jHbFG4bbwBB5T4dAe2UwdnOqCBb2cl+ETV5eO6kbdC/xWxrgOghgZM1Wtw791T5pQPyag==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/opencode-windows-x64": { - "version": "1.18.8", - "resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.8.tgz", - "integrity": "sha512-G+NEgEMvu/dEYshH5IaqHVTmsHVuGdORBvVmgphFiknT7q/NXPuoZCMtMIdfNlEFbu54BlzRDdJCR3Mqe98gUw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/opencode-windows-x64-baseline": { - "version": "1.18.8", - "resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.18.8.tgz", - "integrity": "sha512-IGbjFyWoSN9rdGUJX7TWkQ1Yl673Q3dDna54b5NtqeRcZ839p+Z47zzM5m883HKAxrdKCC6Z22HYuDcXLV0laA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -32174,13 +32023,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/rimraf/node_modules/brace-expansion": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", @@ -35155,9 +34997,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", - "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", "license": "MIT", "engines": { "node": ">=22.19.0" @@ -36948,7 +36790,12 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.50" + "version": "3.8.49", + "dependencies": { + "@toon-format/toon": "^4.1.0", + "safe-regex": "^2.1.1", + "smol-toml": "1.7.1" + } } } } diff --git a/package.json b/package.json index bcc3b19c4e..f90b56d96e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "omniroute", - "version": "3.8.50", - "description": "Unified AI router with 290 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", + "version": "3.8.49", + "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { "omniroute": "bin/omniroute.mjs", @@ -23,7 +23,6 @@ ".env.example", "scripts/build/postinstall.mjs", "scripts/build/fixTlsClientNodeBinary.mjs", - "scripts/build/fixPlaywrightAndroid.mjs", "bin/cli/runtime/", "scripts/postinstall.mjs", "scripts/build/postinstallSupport.mjs", @@ -34,15 +33,11 @@ "scripts/dev/tls-options.mjs", "scripts/check/check-supported-node-runtime.ts", "scripts/dev/sync-env.mjs", - "scripts/build/assembleStandalone.mjs", - "scripts/build/backendOnlyPages.mjs", - "scripts/build/build-next-isolated.mjs", - "scripts/build/build-tproxy-native.mjs", "scripts/build/native-binary-compat.mjs", + "scripts/build/build-next-isolated.mjs", "scripts/build/runtime-env.mjs", "README.md", "LICENSE", - "!**/node_modules/**", "!**/__tests__/**", "!**/*.test.ts", "!**/*.test.tsx", @@ -115,8 +110,6 @@ "test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", "test:unit:ci:shard": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=$TEST_SHARD \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=4096 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD \"tests/unit/serial/**/*.test.ts\"", "test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", - "test:scoped": "bash scripts/quality/test-scoped.sh", - "test:scoped:staged": "bash scripts/quality/test-scoped.sh --staged", "test:unit:shard": "concurrently --kill-others-on-fail -n s1,s2 \"npm:test:unit:shard:1\" \"npm:test:unit:shard:2\"", "test:unit:shard:1": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=1/2 \"tests/unit/serial/**/*.test.ts\"", "test:unit:shard:2": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 \"tests/unit/dashboard/**/*.test.ts\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=2/2 \"tests/unit/serial/**/*.test.ts\"", @@ -150,7 +143,6 @@ "check:node-runtime": "node --import tsx scripts/check/check-supported-node-runtime.ts", "check:pack-artifact": "node --import tsx scripts/build/validate-pack-artifact.ts", "check:pack-boot": "node scripts/check/check-pack-boot.mjs", - "check:install-upgrade": "node scripts/check/check-install-upgrade.mjs", "check:pack-policy": "node --import tsx scripts/build/validate-pack-artifact.ts --policy-only", "check:cli-i18n": "node scripts/check/check-cli-i18n.mjs", "check:openapi-coverage": "node scripts/check/check-openapi-coverage.mjs", @@ -169,7 +161,6 @@ "check:test-masking": "node scripts/check/check-test-masking.mjs", "check:test-runner-api": "node scripts/check/check-test-runner-api.mjs", "check:changelog-integrity": "node scripts/check/check-changelog-integrity.mjs", - "sweep:stale-fragments": "node scripts/release/sweep-stale-fragments.mjs", "changelog:aggregate": "node scripts/release/aggregate-changelog.mjs", "check:agent-skills-sync": "node --import tsx/esm scripts/skills/generate-agent-skills.mjs", "check:build-scope": "node scripts/check/check-build-scope.mjs", @@ -193,7 +184,6 @@ "check:bundle-size": "node scripts/check/check-bundle-size.mjs", "check:circular-deps": "node scripts/check/check-circular-deps.mjs", "check:mutation-ratchet": "node scripts/check/check-mutation-ratchet.mjs", - "check:rtl-ratchet": "node scripts/check/check-rtl-ratchet.mjs", "check:licenses": "node scripts/check/check-licenses.mjs", "check:pr-evidence": "node scripts/check/check-pr-evidence.mjs", "check:vuln-ratchet": "node scripts/check/check-vuln-ratchet.mjs", @@ -210,11 +200,9 @@ "typecheck:core": "tsc --pretty false -p tsconfig.typecheck-core.json", "typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json", "check:dashboard-typecheck": "node scripts/check/check-dashboard-typecheck.mjs", - "check:open-sse-typecheck": "node scripts/check/check-open-sse-typecheck.mjs", "backfill-aggregation": "node --import tsx src/scripts/backfillAggregation.ts", "env:sync": "node scripts/dev/sync-env.mjs", "test:integration": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"", - "test:integration:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"", "test:combo:matrix": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-matrix/*.test.ts\"", "test:combo:live": "cross-env RUN_COMBO_LIVE=1 DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-live/*.live.test.ts\"", "test:combo:live:vps": "node scripts/test/combo-live-vps.mjs", @@ -244,7 +232,6 @@ "prepare": "husky", "system-info": "node scripts/dev/system-info.mjs", "build:cli-api": "node --import tsx/esm scripts/cli/generate-api-commands.mjs", - "postbuild": "node scripts/build/colocate-standalone.mjs", "release:contributors": "node scripts/release/gen-contributors.mjs", "release:uncovered": "node scripts/release/list-uncovered-commits.mjs", "test:coverage:runner": "node --max-old-space-size=8192 --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\" \"tests/unit/**/*.test.mjs\" && cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 \"tests/unit/dashboard/**/*.test.ts\" && npm run test:unit:serial", @@ -318,7 +305,7 @@ "sqlite-vec": "^0.1.9", "tailwind-merge": "^3.6.0", "tsx": "^4.23.0", - "undici": "^8.10.0", + "undici": "^8.3.0", "update-notifier": "^7.3.1", "uuid": "^14.0.0", "ws": "^8.18.0", @@ -331,7 +318,7 @@ "@atjsh/llmlingua-2": "2.0.3", "@huggingface/transformers": "3.5.2", "@tensorflow/tfjs": "4.22.0", - "better-sqlite3": "^13.0.2", + "better-sqlite3": "^13.0.1", "js-tiktoken": "^1.0.20", "keytar": "^7.9.0", "tls-client-node": "^0.2.0", @@ -377,7 +364,6 @@ "lint-staged": "^17.0.8", "lockfile-lint": "^5.0.0", "node-loader": "^2.1.0", - "opencode-ai": "1.18.8", "playwright-ctrf-json-reporter": "^0.0.29", "prettier": "^3.8.3", "promptfoo": "^0.121.18", @@ -409,15 +395,6 @@ "sharp" ] }, - "allowScripts": { - "better-sqlite3": true, - "esbuild": true, - "@swc/core": true, - "@parcel/watcher": true, - "keytar": true, - "protobufjs": true, - "unrs-resolver": true - }, "overrides": { "fast-xml-parser": "^5.10.1", "sharp": "^0.35.0", @@ -448,14 +425,27 @@ "adm-zip": "^0.6.0", "promptfoo": { "js-yaml": "^5.2.2", - "@apidevtools/json-schema-ref-parser": { - "js-yaml": "^4.3.1" - }, "undici": "^7.29.0" }, "socket.io-parser": "^4.2.7", "tar": "^7.5.21", - "nanoid": "^3.3.17", + "brace-expansion": "^5.0.9", + "minimatch": { + "brace-expansion": "^1.1.18" + }, + "libxmljs2": { + "minimatch": { + "brace-expansion": "^2.1.4" + } + }, + "rimraf": { + "minimatch": { + "brace-expansion": "^2.1.4" + } + }, + "@apidevtools/json-schema-ref-parser": { + "js-yaml": "^4.3.1" + }, "@eslint/eslintrc": { "js-yaml": "^4.3.1" }, @@ -465,11 +455,9 @@ "xmlbuilder2": { "js-yaml": "^4.3.1" }, + "nanoid": "^3.3.17", "monaco-editor": { "dompurify": "^3.4.13" - }, - "@apidevtools/json-schema-ref-parser": { - "js-yaml": "^4.3.1" } } } diff --git a/src/app/api/telegram/update/route.ts b/src/app/api/telegram/update/route.ts new file mode 100644 index 0000000000..cc0676106a --- /dev/null +++ b/src/app/api/telegram/update/route.ts @@ -0,0 +1,160 @@ +/** + * Telegram Bot API update webhook + Mini App proxy. + * + * Two callers share this endpoint: + * 1. Telegram POSTs bot updates here when the bot's webhook is registered + * to {publicBase}/api/telegram/update (shape: TelegramUpdate). + * 2. The Mini App frontend POSTs { initData, message } directly; the + * initData HMAC is verified server-side before proxying. + * + * The route: + * 1. Rejects when TELEGRAM_BOT_TOKEN is unset (never silently no-op). + * 2. Verifies initData when present (Mini App path). + * 3. Handles /start (returns the Mini App deep link) and everything else + * as a chat prompt proxied through the OmniRoute pipeline. + */ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import type { TelegramUpdate } from "@/lib/telegram/botApi"; +import { extractChatMessage, sendTelegramMessage } from "@/lib/telegram/botApi"; +import { getTelegramBotToken, isTelegramEnabled } from "@/lib/telegram/config"; +import { verifyInitData, parseInitData } from "@/lib/telegram/initData"; +import { proxyChat } from "@/lib/telegram/chatProxy"; +import { resolveOmniRouteBaseUrl } from "@/shared/utils/resolveOmniRouteBaseUrl"; + +/** + * Telegram update bodies are open-ended (many update types, evolving schema), + * so validation is deliberately loose: a JSON object with optional string + * fields for the two paths we handle. The initData HMAC check (Mini App path) + * and bot-token gate (webhook path) provide the real security. + */ +const telegramBodySchema = z + .object({ + initData: z.string().optional(), + message: z.string().optional(), + update_id: z.number().optional(), + // allow unknown update fields + }) + .passthrough(); + +/** Pull the numeric Telegram user id out of a verified initData string. */ +function extractInitDataUserId(initData: string): number { + try { + const parsed = parseInitData(initData); + const userRaw = parsed["user"]; + if (userRaw) { + const user = JSON.parse(userRaw) as { id?: number }; + if (typeof user.id === "number" && user.id > 0) return user.id; + } + } catch { + // fall through to default + } + return 0; +} + +function buildMiniAppLink(botUsername?: string): string { + const base = resolveOmniRouteBaseUrl(); + // Deep link: t.me/?startapp= opens the Mini App with start_param. + const bot = botUsername || "YOUR_BOT"; + return `https://t.me/${bot}?startapp=miniapp`; +} + +const START_HELP = + "👋 Welcome! This bot bridges Telegram and your OmniRoute gateway.\n\n" + + "• Send any message and I'll route it through your configured models.\n" + + "• Open the Mini App for a full chat UI."; + +export async function POST(request: Request) { + if (!isTelegramEnabled()) { + return NextResponse.json({ ok: false, error: "Telegram not configured" }, { status: 503 }); + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 }); + } + + const validation = validateBody(telegramBodySchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ ok: false, error: "Invalid request" }, { status: 400 }); + } + const body = validation.data as Record; + + // ── Mini App direct path: { initData, message } ────────────────────────── + const initData = typeof body.initData === "string" ? body.initData : ""; + if (initData) { + const botToken = getTelegramBotToken(); + if (!verifyInitData(initData, botToken)) { + return NextResponse.json({ ok: false, error: "Invalid initData signature" }, { status: 401 }); + } + const message = typeof body.message === "string" ? body.message : ""; + if (!message.trim()) { + return NextResponse.json({ ok: false, error: "message is required" }, { status: 400 }); + } + // Resolve the Telegram user id from the verified initData for key mapping. + const telegramUserId = extractInitDataUserId(initData); + // Proxy synchronously and return the reply (Mini App awaits the fetch). + const reply = await proxyChat(telegramUserId, message); + return NextResponse.json({ ok: true, reply: reply || "⚠️ Empty gateway response." }); + } + + // ── Bot webhook path: TelegramUpdate ───────────────────────────────────── + const update = body as unknown as TelegramUpdate; + const chat = extractChatMessage(update); + if (!chat) { + // Non-message updates (callback_query etc.) — acknowledge silently. + return NextResponse.json({ ok: true }); + } + + // Fire-and-forget reply: Telegram retries on 5xx, so always 200 after + // enqueueing the reply. Keep the handler non-blocking. + void handleAndReply(chat.chatId, chat.text, chat.messageId); + + return NextResponse.json({ ok: true }); +} + +async function handleAndReply(chatId: number, text: string, messageId?: number): Promise { + try { + const trimmed = text.trim(); + if (trimmed === "/start" || trimmed === "/start@") { + const link = buildMiniAppLink(); + await sendTelegramMessage({ + chat_id: chatId, + text: `${START_HELP}\n\n🚀 Open the Mini App: ${link}`, + parse_mode: "Markdown", + }); + return; + } + + // Strip bot-command prefixes that aren't /start (e.g. /help). + if (trimmed.startsWith("/")) { + await sendTelegramMessage({ + chat_id: chatId, + text: "Unsupported command. Try /start or just send a message.", + reply_to_message_id: messageId, + }); + return; + } + + const answer = await proxyChat(chatId, trimmed); + const reply = answer || "⚠️ The gateway returned an empty response."; + await sendTelegramMessage({ + chat_id: chatId, + text: reply.length > 4096 ? `${reply.slice(0, 4090)}…` : reply, + parse_mode: "Markdown", + reply_to_message_id: messageId, + }); + } catch (err) { + try { + await sendTelegramMessage({ + chat_id: chatId, + text: `⚠️ Gateway error: ${(err as Error)?.message || "unknown"}`, + }); + } catch { + // Nothing more we can do — the reply channel is down. + } + } +} diff --git a/src/app/miniapp/page.tsx b/src/app/miniapp/page.tsx new file mode 100644 index 0000000000..f194699096 --- /dev/null +++ b/src/app/miniapp/page.tsx @@ -0,0 +1,169 @@ +"use client"; + +/** + * Telegram Mini App — minimal chat UI. + * + * Opens inside Telegram via the WebApp SDK (deep link / inline button). + * Talks to the bot backend at /api/telegram/update with initData attached; + * the backend verifies the HMAC signature server-side. + */ + +import { useEffect, useRef, useState } from "react"; + +declare global { + interface Window { + Telegram?: { + WebApp?: { + ready: () => void; + initData: string; + initDataUnsafe?: { + user?: { id: number; first_name?: string; username?: string }; + }; + close: () => void; + setHeaderColor?: (c: string) => void; + }; + }; + } +} + +interface ChatMessage { + role: "user" | "assistant"; + content: string; +} + +const STREAM_URL = "/api/telegram/update"; + +export default function TelegramMiniApp() { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [busy, setBusy] = useState(false); + const [initData, setInitData] = useState(""); + const [error, setError] = useState(""); + const bottomRef = useRef(null); + + useEffect(() => { + const tg = window.Telegram?.WebApp; + if (tg) { + tg.ready(); + setInitData(tg.initData || ""); + const user = tg.initDataUnsafe?.user; + if (user) { + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: `👋 Hi ${user.first_name || "there"}! Send a message to chat through your OmniRoute gateway.`, + }, + ]); + } + } else { + setError("This page must be opened inside the Telegram Mini App."); + } + }, []); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + async function send() { + const text = input.trim(); + if (!text || busy) return; + setInput(""); + setBusy(true); + setMessages((prev) => [...prev, { role: "user", content: text }]); + + try { + const res = await fetch(STREAM_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + initData, + message: text, + }), + }); + const data = (await res.json().catch(() => null)) as { + reply?: string; + error?: string; + } | null; + const reply = data?.reply || data?.error || "⚠️ No reply from gateway."; + setMessages((prev) => [...prev, { role: "assistant", content: reply }]); + } catch (err) { + setMessages((prev) => [ + ...prev, + { role: "assistant", content: `⚠️ Network error: ${(err as Error).message}` }, + ]); + } finally { + setBusy(false); + } + } + + return ( +
    +

    OmniRoute Mini App

    + {error &&

    {error}

    } + +
    + {messages.map((m, i) => ( +
    + {m.content} +
    + ))} +
    +
    + +
    + setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && send()} + placeholder="Ask anything…" + disabled={busy} + style={{ + flex: 1, + padding: "10px 12px", + borderRadius: 10, + border: "1px solid #ccc", + fontSize: 15, + }} + /> + +
    +
    + ); +} diff --git a/src/lib/telegram/botApi.ts b/src/lib/telegram/botApi.ts new file mode 100644 index 0000000000..4bdc50071a --- /dev/null +++ b/src/lib/telegram/botApi.ts @@ -0,0 +1,111 @@ +/** + * Minimal Telegram Bot API client — the two calls a Mini App backend needs. + * + * Deliberately tiny (fetch-based, no SDK dependency): sendMessage for chat + * replies and setWebhook for webhook registration. Streaming is emulated + * by the caller via progressive edits (sendMessage / editMessageText). + */ +import { getTelegramBotApiBase, getTelegramBotToken, getTelegramWebhookTimeoutMs } from "./config"; + +export interface TelegramSendMessageParams { + chat_id: number | string; + text: string; + parse_mode?: "Markdown" | "HTML"; + reply_to_message_id?: number; + disable_web_page_preview?: boolean; +} + +export interface TelegramEditMessageParams { + chat_id: number | string; + message_id: number; + text: string; + parse_mode?: "Markdown" | "HTML"; +} + +export interface TelegramUser { + id: number; + first_name?: string; + last_name?: string; + username?: string; +} + +export interface TelegramMessage { + message_id: number; + chat: { id: number; type: string }; + text?: string; + from?: TelegramUser; +} + +export interface TelegramUpdate { + update_id: number; + message?: TelegramMessage; + // Mini App payloads arrive as callback_query or message.web_app_data; + // the common shape is message.text (commands) — start with those. + callback_query?: { + id: string; + from: TelegramUser; + message?: TelegramMessage; + data?: string; + }; +} + +async function botFetch(method: string, body: unknown): Promise { + const token = getTelegramBotToken(); + if (!token) throw new Error("TELEGRAM_BOT_TOKEN is not set"); + const url = `${getTelegramBotApiBase()}/bot${token}/${method}`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(getTelegramWebhookTimeoutMs()), + }); + const json = (await res.json().catch(() => null)) as { + ok?: boolean; + description?: string; + result?: T; + } | null; + if (!res.ok || !json?.ok) { + throw new Error(`Telegram API ${method} failed: ${json?.description || res.status}`); + } + return json.result as T; +} + +export async function sendTelegramMessage( + params: TelegramSendMessageParams +): Promise { + return botFetch("sendMessage", params); +} + +export async function editTelegramMessage( + params: TelegramEditMessageParams +): Promise { + return botFetch("editMessageText", params); +} + +/** + * Register (or unregister) the bot webhook. Returns the Bot API result. + * Call this once per deployment (e.g. a CLI command or startup when + * TELEGRAM_WEBHOOK_URL is set). + */ +export async function setTelegramWebhook( + url: string | null, + opts: { dropPending?: boolean } = {} +): Promise<{ url: string; pending_update_count?: number }> { + if (url) { + return botFetch("setWebhook", { url, drop_pending_updates: opts.dropPending ?? true }); + } + return botFetch("deleteWebhook", { drop_pending_updates: opts.dropPending ?? true }); +} + +/** Extract a chat id + text from any update shape we care about. */ +export function extractChatMessage(update: TelegramUpdate): { + chatId: number; + text: string; + messageId?: number; +} | null { + const msg = update.message; + if (msg?.chat && typeof msg.text === "string") { + return { chatId: msg.chat.id, text: msg.text, messageId: msg.message_id }; + } + return null; +} diff --git a/src/lib/telegram/chatProxy.ts b/src/lib/telegram/chatProxy.ts new file mode 100644 index 0000000000..d2b136954e --- /dev/null +++ b/src/lib/telegram/chatProxy.ts @@ -0,0 +1,106 @@ +/** + * Telegram → OmniRoute chat proxy. + * + * Turns a plain Telegram message into a chat.completions call through the + * existing handleChat pipeline and returns the assistant text. Non-streaming + * for Phase 1 (Telegram has no native SSE); streaming is emulated later via + * progressive editMessageText. + * + * Auth model: each Telegram user is mapped to a generated OmniRoute API key + * (createApiKey) so the existing policy/rate-limit/model-allowlist machinery + * applies unchanged. The key is cached in-memory per user id. + */ +import { handleChat } from "@/sse/handlers/chat"; +import { createApiKey, getApiKeys } from "@/lib/db/apiKeys"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { randomUUID } from "node:crypto"; + +const DEFAULT_MODEL = process.env.TELEGRAM_DEFAULT_MODEL || "auto/chat"; + +/** + * Resolve (and lazily mint) an OmniRoute API key for a Telegram user. + * Returns the plaintext key value, cached per user id. + */ +const keyCache = new Map(); + +export async function resolveUserApiKey(telegramUserId: number): Promise { + const cached = keyCache.get(telegramUserId); + if (cached) return cached; + + const machineId = (await getConsistentMachineId().catch(() => null)) || "0000000000000000"; + + // Reuse an existing key whose name matches, else mint one. + const existing = await getApiKeys(); + const match = existing?.find( + (k) => + (k as { name?: string }).name === `telegram:${telegramUserId}` && + typeof (k as { key?: string }).key === "string" && + ((k as { key?: string }).key?.length ?? 0) > 0 + ); + const matchKey = (match as { key?: string } | undefined)?.key; + if (typeof matchKey === "string" && matchKey.length > 0) { + keyCache.set(telegramUserId, matchKey); + return matchKey; + } + + const created = await createApiKey(`telegram:${telegramUserId}`, machineId); + keyCache.set(telegramUserId, created.key); + return created.key; +} + +function buildChatRequest(apiKey: string, prompt: string, model: string): Request { + const body = JSON.stringify({ + model, + messages: [{ role: "user", content: prompt }], + stream: false, + }); + const headers = new Headers({ + "content-type": "application/json", + authorization: `Bearer ${apiKey}`, + }); + return new Request("http://127.0.0.1/v1/chat/completions", { + method: "POST", + headers, + body, + }); +} + +/** Extract plain assistant text from a handleChat Response (stream or not). */ +async function extractResponseText(response: Response): Promise { + if (!response) return ""; + if (response.body) { + // Non-streaming JSON: {"choices":[{"message":{"content": "..."}}]} + try { + const text = await response.text(); + const json = JSON.parse(text) as { + choices?: Array<{ message?: { content?: string }; text?: string }>; + error?: { message?: string }; + }; + if (json.error?.message) return `⚠️ ${json.error.message}`; + const choice = json.choices?.[0]; + return choice?.message?.content ?? choice?.text ?? ""; + } catch { + return ""; + } + } + return ""; +} + +/** + * Proxy one user prompt through the OmniRoute chat pipeline. + * @returns assistant text (may be empty on failure) + */ +export async function proxyChat( + telegramUserId: number, + prompt: string, + model = DEFAULT_MODEL +): Promise { + if (!prompt?.trim()) return ""; + const apiKey = await resolveUserApiKey(telegramUserId); + const request = buildChatRequest(apiKey, prompt.trim(), model); + const response = await handleChat(request, null, null); + return extractResponseText(response); +} + +export { DEFAULT_MODEL }; +export { randomUUID }; diff --git a/src/lib/telegram/config.ts b/src/lib/telegram/config.ts new file mode 100644 index 0000000000..421739ef5e --- /dev/null +++ b/src/lib/telegram/config.ts @@ -0,0 +1,30 @@ +/** + * Telegram Mini App configuration. + * + * The bot token is read from the environment (TELEGRAM_BOT_TOKEN) so it is + * never stored in the DB or committed. It doubles as the HMAC secret for + * initData verification (see ./initData.ts). + */ + +const DEFAULT_WEBHOOK_TIMEOUT_MS = 60_000; + +/** Telegram bot token format: : (min 35 chars after colon). */ +const BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{35,}$/; + +export function getTelegramBotToken(): string { + return process.env.TELEGRAM_BOT_TOKEN || ""; +} + +export function isTelegramEnabled(): boolean { + return BOT_TOKEN_RE.test(getTelegramBotToken()); +} + +export function getTelegramWebhookTimeoutMs(): number { + const raw = process.env.TELEGRAM_WEBHOOK_TIMEOUT_MS; + const parsed = raw ? Number.parseInt(raw, 10) : NaN; + return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_WEBHOOK_TIMEOUT_MS; +} + +export function getTelegramBotApiBase(): string { + return process.env.TELEGRAM_BOT_API_BASE || "https://api.telegram.org"; +} diff --git a/src/lib/telegram/initData.ts b/src/lib/telegram/initData.ts new file mode 100644 index 0000000000..479d9063da --- /dev/null +++ b/src/lib/telegram/initData.ts @@ -0,0 +1,75 @@ +/** + * Telegram WebApp initData verification. + * + * A Telegram Mini App authenticates by passing `initData` (from the + * Telegram.WebApp SDK's `initData` property) to its backend. The only + * trustworthy anchor is the `hash` field: an HMAC-SHA256 over the sorted + * `key=value` pairs (minus `hash`), keyed with SHA256 of the bot token. + * + * Reference: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app + * + * This module is pure and dependency-free (node:crypto only) so it is + * directly unit-testable. Never trust the client-side `initData` alone — + * verification MUST happen server-side. + */ +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; + +/** Parse a URLSearchParams-style initData string into a record. */ +export function parseInitData(initData: string): Record { + const out: Record = {}; + if (!initData) return out; + for (const pair of initData.split("&")) { + const eq = pair.indexOf("="); + if (eq <= 0) continue; + const key = decodeURIComponent(pair.slice(0, eq)); + const value = decodeURIComponent(pair.slice(eq + 1)); + if (key && !(key in out)) out[key] = value; + } + return out; +} + +/** + * Verify a Telegram WebApp initData string against the bot token. + * + * @param initData raw initData string from the Mini App (or `initDataUnsafe` reconstruction) + * @param botToken Telegram bot token (`:`) — the HMAC secret source + * @param maxAgeSec optional freshness bound on `auth_date` (default 24h per Telegram docs) + * @returns true when the signature matches AND (if maxAgeSec set) auth_date is fresh + */ +export function verifyInitData( + initData: string, + botToken: string, + maxAgeSec = 24 * 60 * 60 +): boolean { + if (!initData || !botToken) return false; + const data = parseInitData(initData); + const providedHash = data["hash"]; + if (!providedHash) return false; + + // Optional freshness check on auth_date (unix seconds). + if (maxAgeSec > 0) { + const authDate = Number.parseInt(data["auth_date"] ?? "", 10); + if (!Number.isFinite(authDate) || authDate <= 0) return false; + const now = Math.floor(Date.now() / 1000); + if (now - authDate > maxAgeSec) return false; + } + + // Rebuild the data-check string: sorted key=value pairs, excluding hash. + const pairs = Object.entries(data) + .filter(([k]) => k !== "hash") + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([k, v]) => `${k}=${v}`); + + const dataCheckString = pairs.join("\n"); + + // secret_key = HMAC_SHA256(key="WebAppData", bot_token) + const secretKey = createHmac("sha256", "WebAppData").update(botToken).digest(); + + // expected_hash = HMAC_SHA256(secret_key, data_check_string) hex + const expectedHash = createHmac("sha256", secretKey).update(dataCheckString).digest("hex"); + + const provided = Buffer.from(providedHash, "utf8"); + const expected = Buffer.from(expectedHash, "utf8"); + if (provided.length !== expected.length) return false; + return timingSafeEqual(provided, expected); +} diff --git a/src/shared/constants/publicApiRoutes.ts b/src/shared/constants/publicApiRoutes.ts index ccfe61ccc5..07d8610adf 100644 --- a/src/shared/constants/publicApiRoutes.ts +++ b/src/shared/constants/publicApiRoutes.ts @@ -25,6 +25,11 @@ const PUBLIC_API_ROUTE_PREFIXES = [ // collect/chaos/route.ts. Do not widen this prefix to cover other // /api/skills/collect/* routes without the same per-handler auth. "/api/skills/collect/chaos", + // Telegram Bot API update webhook + Mini App proxy. Telegram POSTs updates + // here without any dashboard cookie/API key; the handler enforces its own + // auth (503 when TELEGRAM_BOT_TOKEN is unset; 401 on invalid initData + // HMAC). See src/app/api/telegram/update/route.ts. Do not widen. + "/api/telegram/", ]; const PUBLIC_READONLY_API_ROUTE_PREFIXES = [ diff --git a/tests/unit/telegram-botapi.test.ts b/tests/unit/telegram-botapi.test.ts new file mode 100644 index 0000000000..26a2dc1434 --- /dev/null +++ b/tests/unit/telegram-botapi.test.ts @@ -0,0 +1,67 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; + +import { extractChatMessage } from "../../src/lib/telegram/botApi"; +import { verifyInitData } from "../../src/lib/telegram/initData"; + +const BOT_TOKEN = "1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij"; + +function buildValidInitData(botToken: string, fields: Record): string { + const pairs = Object.entries(fields).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + const dataCheckString = pairs.map(([k, v]) => `${k}=${v}`).join("\n"); + const secretKey = createHmac("sha256", "WebAppData").update(botToken).digest(); + const hash = createHmac("sha256", secretKey).update(dataCheckString).digest("hex"); + const withHash = [...pairs, ["hash", hash]]; + return withHash.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&"); +} + +test("extractChatMessage returns chatId/text/messageId for a text message", () => { + const chat = extractChatMessage({ + update_id: 1, + message: { + message_id: 42, + chat: { id: 123456789, type: "private" }, + text: "/start", + from: { id: 123456789, first_name: "Benson" }, + }, + }); + assert.deepEqual(chat, { chatId: 123456789, text: "/start", messageId: 42 }); +}); + +test("extractChatMessage returns null for non-message updates", () => { + const chat = extractChatMessage({ update_id: 2, callback_query: { id: "q", from: { id: 1 } } }); + assert.equal(chat, null); +}); + +test("extractChatMessage returns null when text is missing", () => { + const chat = extractChatMessage({ + update_id: 3, + message: { message_id: 1, chat: { id: 1, type: "private" } }, + }); + assert.equal(chat, null); +}); + +test("Mini App initData with real user payload verifies end-to-end", () => { + const initData = buildValidInitData(BOT_TOKEN, { + auth_date: String(Math.floor(Date.now() / 1000)), + query_id: "AAHdF6IQAAAAAN0XohDhrOrc", + user: '{"id":279058397,"first_name":"Benson","last_name":"KB","username":"benzntech"}', + }); + assert.equal(verifyInitData(initData, BOT_TOKEN), true); + // The same initData must fail with a different token (route would 401). + assert.equal(verifyInitData(initData, "9876543210:ZYXWVUTSRQPONMLKJIHGFEDCBAzyxwvu"), false); +}); + +test("Mini App initData fails when user field is swapped after signing", () => { + const initData = buildValidInitData(BOT_TOKEN, { + auth_date: String(Math.floor(Date.now() / 1000)), + user: '{"id":279058397,"first_name":"Benson"}', + }); + // Tamper with the user payload but keep the original hash. + const parts = initData.split("&").filter((p) => !p.startsWith("hash=")); + const tampered = [...parts, "user=%7B%22id%22%3A1%2C%22first_name%22%3A%22Attacker%22%7D"].join( + "&" + ); + assert.equal(verifyInitData(tampered, BOT_TOKEN), false); +}); diff --git a/tests/unit/telegram-init-data.test.ts b/tests/unit/telegram-init-data.test.ts new file mode 100644 index 0000000000..d755acba6a --- /dev/null +++ b/tests/unit/telegram-init-data.test.ts @@ -0,0 +1,73 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; + +import { parseInitData, verifyInitData } from "../../src/lib/telegram/initData"; + +const BOT_TOKEN = "1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij"; + +/** Build a *valid* initData string for a given bot token (test helper). */ +function buildValidInitData(botToken: string, fields: Record): string { + const pairs = Object.entries(fields).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + const dataCheckString = pairs.map(([k, v]) => `${k}=${v}`).join("\n"); + const secretKey = createHmac("sha256", "WebAppData").update(botToken).digest(); + const hash = createHmac("sha256", secretKey).update(dataCheckString).digest("hex"); + const withHash = [...pairs, ["hash", hash]]; + return withHash.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&"); +} + +test("parseInitData decodes URL-encoded key/value pairs", () => { + const parsed = parseInitData("user=%7B%22id%22%3A42%7D&auth_date=1700000000&hash=abc"); + assert.equal(parsed.user, '{"id":42}'); + assert.equal(parsed.auth_date, "1700000000"); + assert.equal(parsed.hash, "abc"); +}); + +test("verifyInitData accepts a valid signature", () => { + const initData = buildValidInitData(BOT_TOKEN, { + auth_date: String(Math.floor(Date.now() / 1000)), + query_id: "AAHdF6IQAAAAAN0XohDhrOrc", + user: '{"id":279058397,"first_name":"Benson","last_name":"KB","username":"benzntech"}', + }); + assert.equal(verifyInitData(initData, BOT_TOKEN), true); +}); + +test("verifyInitData rejects a tampered user field", () => { + const initData = buildValidInitData(BOT_TOKEN, { + auth_date: String(Math.floor(Date.now() / 1000)), + user: '{"id":279058397,"first_name":"Benson"}', + }); + const tampered = initData.replace("Benson", "Attacker"); + assert.equal(verifyInitData(tampered, BOT_TOKEN), false); +}); + +test("verifyInitData rejects a wrong bot token", () => { + const initData = buildValidInitData(BOT_TOKEN, { + auth_date: String(Math.floor(Date.now() / 1000)), + user: '{"id":1}', + }); + assert.equal(verifyInitData(initData, "999:WRONGTOKENWRONGTOKENWRONGTOKENWRONG"), false); +}); + +test("verifyInitData rejects missing hash", () => { + const initData = "auth_date=1700000000&user=%7B%22id%22%3A1%7D"; + assert.equal(verifyInitData(initData, BOT_TOKEN), false); +}); + +test("verifyInitData rejects stale auth_date beyond maxAge", () => { + const initData = buildValidInitData(BOT_TOKEN, { + auth_date: String(Math.floor(Date.now() / 1000) - 48 * 60 * 60), // 48h old + user: '{"id":1}', + }); + assert.equal(verifyInitData(initData, BOT_TOKEN, 24 * 60 * 60), false); + // But passes when the window is generous + assert.equal(verifyInitData(initData, BOT_TOKEN, 7 * 24 * 60 * 60), true); +}); + +test("verifyInitData handles chunked/encoded keys", () => { + const initData = buildValidInitData(BOT_TOKEN, { + auth_date: String(Math.floor(Date.now() / 1000)), + "some-key with spaces": "value with & specials", + }); + assert.equal(verifyInitData(initData, BOT_TOKEN), true); +}); From d9df8bb512ca0b1b2c02f4615bcd93ede7b6ec87 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:51:06 -0300 Subject: [PATCH 033/100] maint: final follow-up cherry-pick #9810 (#9906) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(deps): bump transitive deps for 6 Dependabot + remaining audit vulns on main Same overrides as #9464 (ip-address, hono, fast-uri, socket.io-parser, undici) applied directly to main. Also covers brace-expansion (scoped), js-yaml v4 copies, and mermaid. npm audit: 6→0 vulnerabilities. Closes Dependabot #161-#166. * fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190) Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13 (with monaco-editor scoped override). Closes Dependabot #189, #190. Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge — awaiting Dependabot re-scan. npm audit → 0 vulnerabilities. * fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks) _tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential _tasks symlink can slip in via git add -A and, once pulled, checkout materializes it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks ignores the symlink too, preventing re-capture. * docs(proposals): Telegram Mini App integration feasibility analysis Assess adding a Telegram Mini App chat surface to OmniRoute. Verifies against current main (918fba5e3) what exists (outbound telegram webhook integration, bot-token validation + encryption gate) and what is missing (inbound Bot API listener, WebApp initData HMAC verification, mini app hosting, per-user API key mapping). Concludes: feasible with moderate effort (2-4 dev-days for a working slice). Identifies constraints (public HTTPS webhook, no native streaming to Telegram, server-side initData trust, encryption gate) and a phased next-steps plan (spike, minimal chat slice, hardening). --------- Co-authored-by: diegosouzapw Co-authored-by: benzntech --- docs/proposals/TELEGRAM-MINIAPP.md | 143 +++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/proposals/TELEGRAM-MINIAPP.md diff --git a/docs/proposals/TELEGRAM-MINIAPP.md b/docs/proposals/TELEGRAM-MINIAPP.md new file mode 100644 index 0000000000..884e5c4959 --- /dev/null +++ b/docs/proposals/TELEGRAM-MINIAPP.md @@ -0,0 +1,143 @@ +--- +title: "Feasibility — Telegram Mini App Integration" +version: 3.8.49 +lastUpdated: 2026-08-08 +--- + +# Telegram Mini App Integration — Feasibility Analysis + +**Status: FEASIBLE with moderate effort (estimated 2–4 dev-days for a working slice)** + +## 1. What "Telegram Mini App" means here + +A Telegram Mini App is an iframe-hosted web app opened inside Telegram (via +inline buttons / bot menu buttons) that talks to a bot backend through the +[Telegram WebApp SDK](https://core.telegram.org/bots/webapps). For OmniRoute +the natural shape is: + +- **Bot backend** (new): receives Telegram updates (webhook), validates the + Mini App's `initData` signature, and proxies chat requests to OmniRoute's + existing OpenAI-compatible `/v1/chat/completions` surface. +- **Mini App frontend** (new): a small chat UI served by OmniRoute (Next.js + route or `public/` static bundle), using the Telegram WebApp JS SDK. + +## 2. Current state of the codebase (verified against `main` @ 918fba5e3) + +### Already present — outbound notifications only + +| Piece | Location | What it does | +| ---------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| Telegram webhook integration | `src/lib/webhooks/integrations/telegram.ts` | Builds `sendMessage` payloads for **outbound** gateway events (model, provider, latency, error) | +| Webhook dispatcher | `src/lib/webhookDispatcher.ts` | Routes by kind; decrypts `botToken` from DB metadata for telegram | +| Webhook kinds | `src/lib/db/webhooks.ts` | `slack \| telegram \| discord \| custom` | +| Webhook CRUD + test | `src/app/api/webhooks/*` | Create/update/test; telegram kind skips `url` (uses bot token + chat_id) | +| Bot token validation | `telegram.ts:18` | `BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{35,}$/` | +| Encryption requirement | `webhooks/route.ts:77` | Telegram webhooks require DB encryption enabled (bot tokens stored at rest) | + +### Missing — what a Mini App needs that does not exist yet + +| Gap | Detail | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Inbound Bot API listener** | No `setWebhook` registration, no `/bot/getUpdates` polling, no update handling anywhere. Only the `sendMessage` direction exists. | +| **WebApp `initData` validation** | No HMAC-SHA256 check of `initData` against the bot token (`WebAppData` hash validation from the Bot API docs). | +| **Telegram bot library** | `package.json` has no `telegraf`/`grammy`/`telegram-bot-api` dependency. Would need to add one or hand-roll the (small) HMAC + fetch logic. | +| **Mini App hosting surface** | `public/` exists (static assets) and Next.js routes exist; no `/miniapp` route or static bundle yet. | +| **Session → API key mapping** | Mini App users need to authenticate to `/v1/chat/completions`. Two options: per-user generated OmniRoute API keys (via `src/lib/db/apiKeys`) or a bot-side proxy that injects a shared key. | + +## 3. Constraints + +### 3.1 Architectural + +- **No existing inbound-bot layer.** The webhook system is strictly + event→outbound. A Mini App needs a _new_ Bot API webhook endpoint + (`POST /api/telegram/webhook/` or a dedicated route) plus + update dispatch. This is additive — no conflicts with the existing + `webhooks/` subsystem, but the two must not share the `botToken` storage + semantics blindly (webhooks store bot tokens for _outbound_; the Mini App + needs the same token for _inbound_ signature checks — same token, new use). +- **Public HTTPS required.** Telegram only delivers updates to an HTTPS + endpoint with a valid cert. Self-hosted OmniRoute behind Tailscale/ngrok + needs a public tunnel or Cloudflare Tunnel for the webhook path + (`TELEGRAM_WEBHOOK_URL`-style env). The dashboard can render the current + public origin (`OMNIROUTE_PUBLIC_BASE_URL`) but no webhook registration + helper exists. +- **Encryption gate.** `webhooks/route.ts:77` already refuses telegram + kinds without DB encryption. The Mini App bot token has the same + sensitivity (it _is_ the HMAC secret for initData validation) — same gate + applies, which is a _good_ constraint (no plaintext tokens). + +### 3.2 Telegram platform + +- **initData is the only trust anchor.** Mini App auth = verify + `hash` field of `initData` using HMAC-SHA256(key = SHA256(bot_token), + data = sorted `key=value` pairs minus `hash`). Must be implemented + server-side; never trust the client. +- **No inbound push to arbitrary users.** Telegram bots cannot initiate + conversations. The Mini App works for users who _already_ have the bot — + or you add a `/start` command handler + deep-link (`t.me/bot?startapp=`). +- **Rate limits.** Bot API ~30 msg/s per bot, 20 msg/min per chat group. + Chat responses via `sendMessage`/`answerWebAppQuery` are fine at gateway + scale, but streaming must be emulated (send progressive edits or chunked + messages) — no native SSE into Telegram. +- **WebApp SDK quirks.** `Telegram.WebApp.ready()` must be called; theme + params come from the SDK; the mini app is sandboxed iframe (no + `window.open` to external, clipboard limited). For a chat UI this is fine. + +### 3.3 Security / policy + +- **Per-user key issuance is the clean model.** Rather than exposing the + admin's own API keys, mint a scoped OmniRoute API key per Telegram user + (`apiKeys` table + `isModelAllowedForKey` policy), or proxy with a single + gateway key and map `user_id` → account. Recommendation: per-user keys so + existing rate-limit / model-allowlist / policy code applies unchanged. +- **initData expiry.** `auth_date` in initData must be checked (Telegram + recommends < 24h; short TTLs for chat flows). +- **Secret handling.** Bot token must stay in the encrypted DB / env — + mirror the existing `isEncryptionEnabled()` gate. + +## 4. Required next steps (implementation plan) + +### Phase 0 — Spike (½–1 dev-day) + +1. Add `grammy` or `telegraf` (or ~60 lines of hand-rolled HMAC + fetch). +2. Implement `src/lib/telegram/initData.ts` — `verifyInitData(initData, botToken)`. +3. Stand up a throwaway `POST /api/telegram/miniapp/webhook` route behind + `TELEGRAM_WEBHOOK_SECRET`; register via `setWebhook` once, locally. + +### Phase 1 — Minimal chat slice (1–2 dev-days) + +1. **Webhook endpoint** `POST /api/telegram/bot/update` (or + `/api/telegram/miniapp/update`): parse Update, verify initData, dispatch. +2. **Command handler**: `/start` → reply with deep link + `https://t.me/?startapp=`; `startapp` param carries a + one-time token that maps to a generated OmniRoute API key. +3. **Chat proxy**: map `initData.user.id` → API key → call + `handleChat` (same path as `/v1/chat/completions`) → reply via + `sendMessage` (non-stream) or chunked edits (fake streaming). +4. **Mini App page**: `src/app/(dashboard)/miniapp/page.tsx` (or static + bundle in `public/miniapp/`) — Telegram WebApp SDK init + minimal chat + UI posting to the bot webhook. +5. **Config**: `TELEGRAM_BOT_TOKEN` env (or reuse webhook metadata), + `OMNIROUTE_PUBLIC_BASE_URL` for webhook URL display; doc in + `.env.example` + `ENVIRONMENT.md` (env-doc-sync check). + +### Phase 2 — Production hardening (1 dev-day) + +- Streaming emulation (message edits), error/backpressure mapping to Bot API + limits, per-user key revocation (`/logout` command → revoke API key), + usage/rate-limit surfacing (reuse `enforceApiKeyPolicy`), webhook + registration helper in dashboard settings, i18n for the mini app UI. + +## 5. Verdict + +**Feasible.** The gateway already exposes the exact API a Mini App chat +needs (`/v1/chat/completions` with per-key policy), and the outbound +Telegram webhook shows the team already handles bot tokens safely +(encryption gate + token format validation). The genuinely new surface is +small: an inbound update webhook + initData HMAC verification + a thin +chat proxy + a static Mini App page. No changes to the core SSE/relay +pipeline are required. + +**Primary risks:** (1) public HTTPS requirement for the webhook (tunnel +needed on self-hosted installs), (2) no native streaming to Telegram +(UX tradeoff), (3) initData trust must be strictly server-side. From 2c21f292cda507105223e51e470220b1a6493879 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:51:15 -0300 Subject: [PATCH 034/100] fix(types): accept synced catalog model rows (#9846) Co-authored-by: backryun --- .../api/v1/models/catalogSyncedCoverage.ts | 1 - ...catalog-synced-static-preservation.test.ts | 20 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/app/api/v1/models/catalogSyncedCoverage.ts b/src/app/api/v1/models/catalogSyncedCoverage.ts index 7ca21b7905..7a49445d71 100644 --- a/src/app/api/v1/models/catalogSyncedCoverage.ts +++ b/src/app/api/v1/models/catalogSyncedCoverage.ts @@ -13,7 +13,6 @@ export interface SyncedModelRow { id?: unknown; - [key: string]: unknown; } /** diff --git a/tests/unit/catalog-synced-static-preservation.test.ts b/tests/unit/catalog-synced-static-preservation.test.ts index 2803f83652..d6b6748fb2 100644 --- a/tests/unit/catalog-synced-static-preservation.test.ts +++ b/tests/unit/catalog-synced-static-preservation.test.ts @@ -5,6 +5,7 @@ import { buildSyncedModelIdsByCanonicalProvider, shouldSuppressStaticModelBySyncedCoverage, } from "../../src/app/api/v1/models/catalogSyncedCoverage.ts"; +import type { SyncedAvailableModel } from "../../src/lib/db/models/synced.ts"; test("static model covered by synced list IS suppressed (current behavior kept)", () => { assert.equal( @@ -51,16 +52,17 @@ test("no synced models -> nothing suppressed", () => { }); test("buildSyncedModelIdsByCanonicalProvider groups synced ids by canonical provider", () => { + const syncedModels: Record = { + "command-code": [ + { id: "gpt-5.6-luna", name: "Luna", source: "imported" }, + { id: "moonshotai/Kimi-K3", name: "Kimi K3", source: "imported" }, + { id: "", name: "Invalid", source: "imported" }, + ], + deepseek: [{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", source: "imported" }], + }; const byCanonical = buildSyncedModelIdsByCanonicalProvider( - { - "command-code": [ - { id: "gpt-5.6-luna" }, - { id: "moonshotai/Kimi-K3" }, - { id: "" }, // empty id ignored - ], - deepseek: [{ id: "deepseek-v4-flash" }], - }, - (aliasOrId, fallback) => aliasOrId === "cmd" ? "command-code" : (fallback || aliasOrId), + syncedModels, + (aliasOrId, fallback) => (aliasOrId === "cmd" ? "command-code" : fallback || aliasOrId), {}, { "command-code": "cmd" } ); From fed05a3207e8ceac3601bd02b91113cbcfe9ca7e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:51:22 -0300 Subject: [PATCH 035/100] fix(types): normalize DuckDuckGo request messages (#9847) Co-authored-by: backryun --- open-sse/executors/duckduckgo-web.ts | 21 +++++++++++++++++---- tests/unit/duckduckgo-web-executor.test.ts | 16 ++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/open-sse/executors/duckduckgo-web.ts b/open-sse/executors/duckduckgo-web.ts index 72abad4f84..6b7eba2dce 100644 --- a/open-sse/executors/duckduckgo-web.ts +++ b/open-sse/executors/duckduckgo-web.ts @@ -137,8 +137,23 @@ interface DuckDuckGoModelCapabilities { reasoningEffort: string | null; } +type DuckDuckGoRequestMessage = Record & { + role: string; + content: unknown; +}; + let durablePublicKey: JsonWebKey | null = null; +export function normalizeDuckDuckGoMessages(value: unknown): DuckDuckGoRequestMessage[] { + if (!Array.isArray(value)) return []; + return value.flatMap((message) => { + if (!message || typeof message !== "object" || Array.isArray(message)) return []; + const record = message as Record; + if (typeof record.role !== "string") return []; + return [{ ...record, role: record.role, content: record.content }]; + }); +} + function extractDuckDuckGoContent(data: unknown): string { if (!data || typeof data !== "object") return ""; const record = data as Record; @@ -440,14 +455,12 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { const { model, body, stream, signal, upstreamExtraHeaders } = input; const upstreamModel = normalizeDuckDuckGoModel(model); const bodyObj = (body || {}) as Record; - const rawMessages = Array.isArray((body as { messages?: unknown[] } | null)?.messages) - ? ((body as { messages: unknown[] }).messages as Array>) - : []; + const rawMessages = normalizeDuckDuckGoMessages(bodyObj.messages); const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages( bodyObj, rawMessages ); - const messages = effectiveMessages as Array>; + const messages = effectiveMessages; const isStreaming = stream !== false; const upstreamHeaders = upstreamExtraHeaders || {}; diff --git a/tests/unit/duckduckgo-web-executor.test.ts b/tests/unit/duckduckgo-web-executor.test.ts index 0566efa965..5469185cc7 100644 --- a/tests/unit/duckduckgo-web-executor.test.ts +++ b/tests/unit/duckduckgo-web-executor.test.ts @@ -4,6 +4,7 @@ import { FETCH_TIMEOUT_MS } from "../../open-sse/config/constants.ts"; import { DuckDuckGoWebExecutor, DUCKDUCKGO_BASE, + normalizeDuckDuckGoMessages, STATUS_URL, } from "../../open-sse/executors/duckduckgo-web.ts"; @@ -38,6 +39,21 @@ describe("DuckDuckGoWebExecutor", () => { }); describe("execute method validation", () => { + it("normalizes only role-bearing request messages without dropping metadata", () => { + assert.deepEqual( + normalizeDuckDuckGoMessages([ + { role: "user", content: "hello", name: "caller" }, + { role: "assistant", tool_calls: [{ id: "call-1" }] }, + { content: "missing role" }, + null, + ]), + [ + { role: "user", content: "hello", name: "caller" }, + { role: "assistant", content: undefined, tool_calls: [{ id: "call-1" }] }, + ] + ); + }); + it("should reject empty messages array", async () => { const executor = new DuckDuckGoWebExecutor(); From a2eab58dde0b41628d7d0adf0bb98d59a3ba4db6 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:51:30 -0300 Subject: [PATCH 036/100] fix(types): expose SQLite transaction state (#9848) Co-authored-by: backryun --- src/lib/db/adapters/betterSqliteAdapter.ts | 4 ++++ src/lib/db/adapters/types.ts | 2 ++ tests/unit/db-adapters/betterSqliteAdapter.test.ts | 12 ++++++++++++ 3 files changed, 18 insertions(+) diff --git a/src/lib/db/adapters/betterSqliteAdapter.ts b/src/lib/db/adapters/betterSqliteAdapter.ts index 899ae99d49..20290d345d 100644 --- a/src/lib/db/adapters/betterSqliteAdapter.ts +++ b/src/lib/db/adapters/betterSqliteAdapter.ts @@ -12,6 +12,10 @@ export function createBetterSqliteAdapter(db: import("better-sqlite3").Database) return db.name; }, + get inTransaction() { + return db.inTransaction; + }, + prepare(sql: string): PreparedStatement { const stmt = db.prepare(sql); return { diff --git a/src/lib/db/adapters/types.ts b/src/lib/db/adapters/types.ts index 41e049b203..76cbb90327 100644 --- a/src/lib/db/adapters/types.ts +++ b/src/lib/db/adapters/types.ts @@ -13,6 +13,8 @@ export interface SqliteAdapter { readonly driver: "better-sqlite3" | "node:sqlite" | "bun:sqlite" | "sql.js"; readonly open: boolean; readonly name: string; + /** Driver transaction state when exposed by the underlying SQLite implementation. */ + readonly inTransaction?: boolean; prepare(sql: string): PreparedStatement; exec(sql: string): void; diff --git a/tests/unit/db-adapters/betterSqliteAdapter.test.ts b/tests/unit/db-adapters/betterSqliteAdapter.test.ts index 0281d04331..72c1634e53 100644 --- a/tests/unit/db-adapters/betterSqliteAdapter.test.ts +++ b/tests/unit/db-adapters/betterSqliteAdapter.test.ts @@ -78,4 +78,16 @@ describe("betterSqliteAdapter", () => { assert.equal(count.cnt, 0, "Rollback deve ter desfeito o insert"); adapter.close(); }); + + test("expõe o estado da transação sem vazar o driver bruto", () => { + const adapter = tryOpenSync(":memory:"); + if (!adapter || adapter.driver !== "better-sqlite3") return; + + assert.equal(adapter.inTransaction, false); + const inspect = adapter.transaction(() => adapter.inTransaction); + assert.equal(inspect(), true); + assert.equal(adapter.inTransaction, false); + + adapter.close(); + }); }); From 97a1355037b2ceca1aa55a0454674adb0536d18e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:51:37 -0300 Subject: [PATCH 037/100] fix(types): validate default executor pool config (#9849) Co-authored-by: backryun --- open-sse/executors/default.ts | 5 ++- open-sse/executors/default/poolConfig.ts | 33 +++++++++++++++++++ .../unit/default-pool-config-contract.test.ts | 31 +++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 open-sse/executors/default/poolConfig.ts create mode 100644 tests/unit/default-pool-config-contract.test.ts diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index 8836b79dfb..8814c4262a 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -61,12 +61,11 @@ import { } from "@/lib/providers/validation/urlHelpers"; import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts"; import { resolveZaiUrl } from "./default/zaiFormatOverride.ts"; +import { normalizePoolConfig } from "./default/poolConfig.ts"; import { acquireNvidiaConcurrencySlot } from "./default/nvidiaConcurrencyGate.ts"; import { resolveAlibabaProviderBaseUrl } from "@/shared/constants/alibabaProviderRegions"; import { usesCcWireImage } from "../services/ccWireImageBuiltins.ts"; -import type { PoolConfig } from "../services/sessionPool/types.ts"; - const NVIDIA_TOOL_CALL_ID_PATTERN = /^[A-Za-z0-9]{9}$/; function normalizeNvidiaToolCallId(id: unknown): unknown { @@ -146,7 +145,7 @@ export class DefaultExecutor extends BaseExecutor { super(provider, PROVIDERS[provider] || PROVIDERS.openai); const registryEntry = getRegistryEntry(provider); if (registryEntry?.poolConfig) { - this.poolConfig = registryEntry.poolConfig as PoolConfig; + this.poolConfig = normalizePoolConfig(registryEntry.poolConfig) ?? undefined; } } diff --git a/open-sse/executors/default/poolConfig.ts b/open-sse/executors/default/poolConfig.ts new file mode 100644 index 0000000000..5cb781a3d6 --- /dev/null +++ b/open-sse/executors/default/poolConfig.ts @@ -0,0 +1,33 @@ +import type { PoolConfig } from "../../services/sessionPool/types.ts"; + +export function normalizePoolConfig(value: Record): PoolConfig | null { + const { + minSessions, + maxSessions, + cooldownBase, + cooldownMax, + cooldownJitter, + requestTimeout, + requestJitter, + } = value; + if ( + typeof minSessions !== "number" || + typeof maxSessions !== "number" || + typeof cooldownBase !== "number" || + typeof cooldownMax !== "number" || + typeof cooldownJitter !== "number" || + typeof requestTimeout !== "number" || + typeof requestJitter !== "number" + ) { + return null; + } + return { + minSessions, + maxSessions, + cooldownBase, + cooldownMax, + cooldownJitter, + requestTimeout, + requestJitter, + }; +} diff --git a/tests/unit/default-pool-config-contract.test.ts b/tests/unit/default-pool-config-contract.test.ts new file mode 100644 index 0000000000..a520d00071 --- /dev/null +++ b/tests/unit/default-pool-config-contract.test.ts @@ -0,0 +1,31 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { normalizePoolConfig } from "../../open-sse/executors/default/poolConfig.ts"; + +test("normalizePoolConfig preserves a complete registry pool contract", () => { + assert.deepEqual( + normalizePoolConfig({ + minSessions: 1, + maxSessions: 3, + cooldownBase: 2000, + cooldownMax: 5000, + cooldownJitter: 100, + requestTimeout: 30000, + requestJitter: 50, + }), + { + minSessions: 1, + maxSessions: 3, + cooldownBase: 2000, + cooldownMax: 5000, + cooldownJitter: 100, + requestTimeout: 30000, + requestJitter: 50, + } + ); +}); + +test("normalizePoolConfig rejects incomplete registry values", () => { + assert.equal(normalizePoolConfig({ minSessions: 1, maxSessions: 3 }), null); +}); From 0b5ab6570dbe66a821dfeea4969f41506c44d58d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:51:44 -0300 Subject: [PATCH 038/100] fix(types): preserve The Old LLM proxy contracts (#9850) Co-authored-by: backryun --- open-sse/executors/theoldllm.ts | 10 +++------- tests/unit/theoldllm-provider-proxy.test.ts | 2 ++ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/open-sse/executors/theoldllm.ts b/open-sse/executors/theoldllm.ts index 688e79eb2c..422452e7f2 100644 --- a/open-sse/executors/theoldllm.ts +++ b/open-sse/executors/theoldllm.ts @@ -108,13 +108,9 @@ export function mapModel(model: string): string { const TOKEN_SEED = "oldllm-client-2026"; const UA_PREFIX = CHROME_UA.slice(0, 20); // "Mozilla/5.0 (Windows" -type TheOldLlmProxy = { - type?: string; - host: string; - port: number; - username?: string | null; - password?: string | null; -} | null; +type TheOldLlmProxy = Awaited< + ReturnType +>; interface TheOldLlmFetchDependencies { resolveProxy: () => Promise; diff --git a/tests/unit/theoldllm-provider-proxy.test.ts b/tests/unit/theoldllm-provider-proxy.test.ts index f9092268b1..85505d84bf 100644 --- a/tests/unit/theoldllm-provider-proxy.test.ts +++ b/tests/unit/theoldllm-provider-proxy.test.ts @@ -10,6 +10,8 @@ test("theoldllm dispatches through its provider proxy assignment", async () => { port: 8080, username: "user", password: "secret", + family: "ipv4", + name: "residential-primary", }; let observedProxy: unknown = null; let fetchCalls = 0; From 65dae7040311224b31bb40edb87fb1ed133ca3a3 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:51:49 -0300 Subject: [PATCH 039/100] fix(types): normalize Gemini Business credentials (#9851) Co-authored-by: backryun --- open-sse/executors/gemini-business.ts | 20 ++++++++++---------- tests/unit/gemini-business-provider.test.ts | 21 ++++++++++++++++++--- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/open-sse/executors/gemini-business.ts b/open-sse/executors/gemini-business.ts index ea68969582..efa014357b 100644 --- a/open-sse/executors/gemini-business.ts +++ b/open-sse/executors/gemini-business.ts @@ -80,16 +80,7 @@ export class GeminiBusinessExecutor extends BaseExecutor { // Extract cookies from credentials — check apiKey/cookie first, then // try each __Secure-1PSID* key in providerSpecificData individually. // A user with only __Secure-1PSID (no PSIDTS) is still valid. - const directCookie = - readCredentialString(credentials?.apiKey) || readCredentialString(credentials?.cookie); - const psid = readProviderSpecificString(credentials?.providerSpecificData, [ - "__Secure-1PSID", - "cookie", - ]); - const psidts = readProviderSpecificString(credentials?.providerSpecificData, [ - "__Secure-1PSIDTS", - ]); - const cookie = directCookie || [psid, psidts].filter(Boolean).join("; "); + const cookie = resolveGeminiBusinessCookie(credentials); if (!cookie) { return makeErrorResult( @@ -380,6 +371,15 @@ function readProviderSpecificString(providerSpecificData: unknown, keys: string[ return ""; } +export function resolveGeminiBusinessCookie(credentials: unknown): string { + if (!credentials || typeof credentials !== "object") return ""; + const data = credentials as Record; + const directCookie = readCredentialString(data.apiKey) || readCredentialString(data.cookie); + const psid = readProviderSpecificString(data.providerSpecificData, ["__Secure-1PSID", "cookie"]); + const psidts = readProviderSpecificString(data.providerSpecificData, ["__Secure-1PSIDTS"]); + return directCookie || [psid, psidts].filter(Boolean).join("; "); +} + function extractTextContent(content: unknown): string { if (typeof content === "string") return content.trim(); if (Array.isArray(content)) { diff --git a/tests/unit/gemini-business-provider.test.ts b/tests/unit/gemini-business-provider.test.ts index 2c7b41a992..240a144083 100644 --- a/tests/unit/gemini-business-provider.test.ts +++ b/tests/unit/gemini-business-provider.test.ts @@ -6,9 +6,8 @@ const { WEB_COOKIE_PROVIDERS } = await import("../../src/shared/constants/provid const { WEB_SESSION_CREDENTIAL_REQUIREMENTS } = await import( "../../src/shared/providers/webSessionCredentials.ts" ); -const { GeminiBusinessExecutor, parseStreamResponse } = await import( - "../../open-sse/executors/gemini-business.ts" -); +const { GeminiBusinessExecutor, parseStreamResponse, resolveGeminiBusinessCookie } = + await import("../../open-sse/executors/gemini-business.ts"); // ─── Provider metadata ────────────────────────────────────────────────────── @@ -49,6 +48,22 @@ test("GeminiBusinessExecutor constructs with the correct provider", () => { assert.equal((ex as unknown as { provider: string }).provider, "gemini-business"); }); +test("Gemini Business preserves supported legacy cookie credential placements", () => { + assert.equal( + resolveGeminiBusinessCookie({ cookie: " __Secure-1PSID=legacy " }), + "__Secure-1PSID=legacy" + ); + assert.equal( + resolveGeminiBusinessCookie({ + providerSpecificData: { + "__Secure-1PSID": "__Secure-1PSID=psid", + "__Secure-1PSIDTS": "__Secure-1PSIDTS=psidts", + }, + }), + "__Secure-1PSID=psid; __Secure-1PSIDTS=psidts" + ); +}); + test("GeminiBusinessExecutor.execute returns 401 when no cookies are provided", async () => { const ex = new GeminiBusinessExecutor(); const result = await ex.execute({ From 4fe0fffb316280021c3daa05056bc5498865f258 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:51:56 -0300 Subject: [PATCH 040/100] fix(types): preserve Claude thinking body contracts (#9852) Co-authored-by: backryun --- open-sse/services/claudeAdaptiveThinking.ts | 6 +++--- tests/unit/claude-adaptive-thinking-normalize.test.ts | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/open-sse/services/claudeAdaptiveThinking.ts b/open-sse/services/claudeAdaptiveThinking.ts index d65c79de2b..f50ea6bc21 100644 --- a/open-sse/services/claudeAdaptiveThinking.ts +++ b/open-sse/services/claudeAdaptiveThinking.ts @@ -54,7 +54,7 @@ export function normalizeClaudeAdaptiveThinking Date: Sun, 9 Aug 2026 09:52:02 -0300 Subject: [PATCH 041/100] fix(response): strip internal reasoning placeholder from all reasoning fields (#9853) copyOpenAICompatibleReasoningFields only stripped the sentinel (NON_ANTHROPIC_THINKING_PLACEHOLDER = "(prior reasoning summary unavailable)") from reasoning_content and reasoning. Non-standard reasoning fields (reasoning_text, thinking, thought) and reasoning_details items passed through raw, leaking the internal replay sentinel to clients on providers that use those fields (e.g. Venice), where the model echo surfaces as a bogus thought block and can degrade into empty turns. Strip the sentinel from every forwarded reasoning field, including per-item text/content inside reasoning_details; drop items/fields that strip to nothing while preserving non-text details such as reasoning.encrypted. Fixes #9765 Refs #8081, #9606 Co-authored-by: safeer --- open-sse/utils/reasoningFields.ts | 62 ++++++++-- ...reasoning-fields-placeholder-strip.test.ts | 116 ++++++++++++++++++ 2 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 tests/unit/reasoning-fields-placeholder-strip.test.ts diff --git a/open-sse/utils/reasoningFields.ts b/open-sse/utils/reasoningFields.ts index a8b858e25e..21fc22cab1 100644 --- a/open-sse/utils/reasoningFields.ts +++ b/open-sse/utils/reasoningFields.ts @@ -62,10 +62,38 @@ export function hasAnyReasoningSignal(value: unknown): boolean { ); } +const STRIPPABLE_REASONING_FIELDS = [ + "reasoning_content", + "reasoning", + "reasoning_text", + "thinking", + "thought", +] as const; + +/** + * Strip the internal replay placeholder from a single string reasoning field, + * deleting the field when nothing meaningful remains. Returns true only when a + * present string field was fully stripped to empty (absent/non-string fields + * return false so callers can distinguish "removed" from "never had text"). + */ +function stripPlaceholderFromField(target: JsonRecord, field: string): boolean { + const value = target[field]; + if (typeof value !== "string") return false; + const stripped = stripInternalReasoningPlaceholder(value); + if (stripped === "") { + delete target[field]; + return true; + } + if (stripped !== value) target[field] = stripped; + return false; +} + export function copyOpenAICompatibleReasoningFields(source: JsonRecord, target: JsonRecord) { if (source.reasoning_content !== undefined) target.reasoning_content = source.reasoning_content; if (source.reasoning !== undefined) target.reasoning = source.reasoning; if (source.reasoning_text !== undefined) target.reasoning_text = source.reasoning_text; + if (source.thinking !== undefined) target.thinking = source.thinking; + if (source.thought !== undefined) target.thought = source.thought; if (Array.isArray(source.reasoning_details)) target.reasoning_details = source.reasoning_details; if (!getReadableReasoningValue(target)) { const mirrored = getUnsupportedReasoningValue(source); @@ -73,15 +101,31 @@ export function copyOpenAICompatibleReasoningFields(source: JsonRecord, target: } // ponytail: the internal replay placeholder is request scaffolding, never // real reasoning — models echo it and it poisons client history + the cache - // (#8081 echo). Strip it from anything we forward to the client. - if (typeof target.reasoning_content === "string") { - const stripped = stripInternalReasoningPlaceholder(target.reasoning_content); - if (stripped === "") delete target.reasoning_content; - else if (stripped !== target.reasoning_content) target.reasoning_content = stripped; + // (#8081 echo). Strip it from anything we forward to the client, including + // non-standard reasoning fields (reasoning_text / thinking / thought) and + // reasoning_details items that non-OpenAI-compatible upstreams (e.g. + // Venice) use (#9765 uncovered path). + for (const field of STRIPPABLE_REASONING_FIELDS) { + stripPlaceholderFromField(target, field); } - if (typeof target.reasoning === "string") { - const stripped = stripInternalReasoningPlaceholder(target.reasoning); - if (stripped === "") delete target.reasoning; - else if (stripped !== target.reasoning) target.reasoning = stripped; + if (Array.isArray(target.reasoning_details)) { + const cleaned: unknown[] = []; + for (const detail of target.reasoning_details) { + const record = asReasoningRecord(detail); + const next: JsonRecord = { ...record }; + // Track whether the item originally carried text/content at all so + // non-text details (e.g. `reasoning.encrypted` carrying only `data`) + // survive untouched. + const hadText = typeof next.text === "string"; + const hadContent = typeof next.content === "string"; + stripPlaceholderFromField(next, "text"); + stripPlaceholderFromField(next, "content"); + const textGone = next.text === undefined; + const contentGone = next.content === undefined; + if ((hadText || hadContent) && textGone && contentGone) continue; + cleaned.push(next); + } + if (cleaned.length === 0) delete target.reasoning_details; + else target.reasoning_details = cleaned; } } diff --git a/tests/unit/reasoning-fields-placeholder-strip.test.ts b/tests/unit/reasoning-fields-placeholder-strip.test.ts new file mode 100644 index 0000000000..566d1dee12 --- /dev/null +++ b/tests/unit/reasoning-fields-placeholder-strip.test.ts @@ -0,0 +1,116 @@ +/** + * tests/unit/reasoning-fields-placeholder-strip.test.ts + * + * copyOpenAICompatibleReasoningFields() must never forward the internal + * reasoning-replay placeholder (NON_ANTHROPIC_THINKING_PLACEHOLDER = + * "(prior reasoning summary unavailable)") to clients — it is request + * scaffolding, and models echo it as their own reasoning (#8081, #9765). + * Previously only reasoning_content / reasoning were stripped; non-standard + * fields (reasoning_text, thinking, thought) and reasoning_details items + * passed through raw, leaking the sentinel on providers that use them + * (e.g. Venice). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { NON_ANTHROPIC_THINKING_PLACEHOLDER } from "../../open-sse/utils/reasoningPlaceholder.ts"; +import { copyOpenAICompatibleReasoningFields } from "../../open-sse/utils/reasoningFields.ts"; + +function copy(source: Record): Record { + const target: Record = {}; + copyOpenAICompatibleReasoningFields(source, target); + return target; +} + +test("real reasoning_content is preserved verbatim", () => { + const target = copy({ reasoning_content: "Let me think carefully." }); + assert.equal(target.reasoning_content, "Let me think carefully."); +}); + +test("reasoning_content that is exactly the placeholder is dropped", () => { + const target = copy({ reasoning_content: NON_ANTHROPIC_THINKING_PLACEHOLDER }); + assert.equal("reasoning_content" in target, false); +}); + +test("reasoning alias that is exactly the placeholder is dropped", () => { + const target = copy({ reasoning: NON_ANTHROPIC_THINKING_PLACEHOLDER }); + assert.equal("reasoning" in target, false); +}); + +test("reasoning_text that is exactly the placeholder is dropped (Venice path, #9765)", () => { + const target = copy({ reasoning_text: NON_ANTHROPIC_THINKING_PLACEHOLDER }); + assert.equal("reasoning_text" in target, false); +}); + +test("thinking that is exactly the placeholder is dropped", () => { + const target = copy({ thinking: NON_ANTHROPIC_THINKING_PLACEHOLDER }); + assert.equal("thinking" in target, false); +}); + +test("thought that is exactly the placeholder is dropped", () => { + const target = copy({ thought: NON_ANTHROPIC_THINKING_PLACEHOLDER }); + assert.equal("thought" in target, false); +}); + +test("placeholder embedded in otherwise real reasoning_text is stripped in place", () => { + const target = copy({ + reasoning_text: `First thought. ${NON_ANTHROPIC_THINKING_PLACEHOLDER} Second thought.`, + }); + assert.equal(target.reasoning_text, "First thought. Second thought."); +}); + +test("no mirrored reasoning_content is emitted when the only signal is the placeholder", () => { + const target = copy({ reasoning_text: NON_ANTHROPIC_THINKING_PLACEHOLDER }); + assert.equal("reasoning_content" in target, false); + assert.equal("reasoning_text" in target, false); +}); + +test("all-placeholder reasoning_details are dropped entirely", () => { + const target = copy({ + reasoning_details: [ + { type: "reasoning.text", text: NON_ANTHROPIC_THINKING_PLACEHOLDER }, + { type: "thinking", content: ` ${NON_ANTHROPIC_THINKING_PLACEHOLDER} ` }, + ], + }); + assert.equal("reasoning_details" in target, false); + assert.equal("reasoning_content" in target, false); +}); + +test("mixed reasoning_details keep real text and drop only placeholder items", () => { + const target = copy({ + reasoning_details: [ + { type: "reasoning.text", text: "real first step " }, + { type: "thinking", content: NON_ANTHROPIC_THINKING_PLACEHOLDER }, + { type: "reasoning.text", text: "real second step" }, + ], + }); + assert.deepEqual(target.reasoning_details, [ + { type: "reasoning.text", text: "real first step " }, + { type: "reasoning.text", text: "real second step" }, + ]); +}); + +test("placeholder inside a reasoning_details text item is stripped in place", () => { + const target = copy({ + reasoning_details: [ + { type: "reasoning.text", text: `real ${NON_ANTHROPIC_THINKING_PLACEHOLDER} tail` }, + ], + }); + assert.deepEqual(target.reasoning_details, [{ type: "reasoning.text", text: "real tail" }]); +}); + +test("real reasoning_details still mirror into reasoning_content for readable clients", () => { + const target = copy({ + reasoning_details: [{ type: "reasoning.text", text: "real reasoning here" }], + }); + assert.equal(target.reasoning_content, "real reasoning here"); + assert.deepEqual(target.reasoning_details, [ + { type: "reasoning.text", text: "real reasoning here" }, + ]); +}); + +test("non-text reasoning_details (e.g. reasoning.encrypted) survive untouched", () => { + const target = copy({ + reasoning_details: [{ type: "reasoning.encrypted", data: "sig" }], + }); + assert.deepEqual(target.reasoning_details, [{ type: "reasoning.encrypted", data: "sig" }]); +}); From 48d43240f47089104f58c56c70f180521e73f3b6 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:52:09 -0300 Subject: [PATCH 042/100] fix(api): enforce model permissions on gateway mirrors (#9854) Co-authored-by: Xiangzhe --- .../9788-model-catalog-gateway-permissions.md | 1 + open-sse/utils/functionalGatewayMirrors.ts | 11 +- src/app/api/v1/models/catalogResponse.ts | 48 ++++++- ...log-functional-gateway-permissions.test.ts | 118 ++++++++++++++++++ .../models-catalog-functional-gateway.test.ts | 46 ++++++- 5 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 changelog.d/fixes/9788-model-catalog-gateway-permissions.md create mode 100644 tests/unit/models-catalog-functional-gateway-permissions.test.ts diff --git a/changelog.d/fixes/9788-model-catalog-gateway-permissions.md b/changelog.d/fixes/9788-model-catalog-gateway-permissions.md new file mode 100644 index 0000000000..f2218d1d2e --- /dev/null +++ b/changelog.d/fixes/9788-model-catalog-gateway-permissions.md @@ -0,0 +1 @@ +- **fix(api):** Model catalogs no longer expose functional gateway mirrors unless the API key permits the mirror's final public model ID ([#9788](https://github.com/diegosouzapw/OmniRoute/pull/9788)) — thanks @xz-dev diff --git a/open-sse/utils/functionalGatewayMirrors.ts b/open-sse/utils/functionalGatewayMirrors.ts index 5a8cbca65d..2620980153 100644 --- a/open-sse/utils/functionalGatewayMirrors.ts +++ b/open-sse/utils/functionalGatewayMirrors.ts @@ -19,6 +19,8 @@ export const FUNCTIONAL_GATEWAY_MIRROR_SUFFIX = " (via "; +const FUNCTIONAL_GATEWAY_MIRROR = Symbol("functionalGatewayMirror"); + export interface FunctionalGatewayMirrorsDeps { /** Ordered list of passthrough gateway provider ids to consider as mirrors. */ gatewayProviderIds: string[]; @@ -40,9 +42,14 @@ interface GatewayMirrorCatalogEntry { root?: unknown; name?: unknown; display_name?: unknown; + [FUNCTIONAL_GATEWAY_MIRROR]?: true; [key: string]: unknown; } +export function isFunctionalGatewayMirror(model: GatewayMirrorCatalogEntry): boolean { + return model?.[FUNCTIONAL_GATEWAY_MIRROR] === true; +} + /** * Append `/` mirror entries for every eligible model. * Returns the original array reference unchanged when nothing is eligible. @@ -88,14 +95,14 @@ export function appendFunctionalGatewayMirrors>, + apiKey: string, + isModelAllowed: (key: string, modelId: string) => Promise +): Promise>> { + const filtered: Array> = []; + for (const model of models) { + if (!isFunctionalGatewayMirror(model)) { + filtered.push(model); + continue; + } + + if (typeof model.id === "string" && (await isModelAllowed(apiKey, model.id))) { + filtered.push(model); + } + } + return filtered; +} + /** * Enrich the selected models and serialise the catalog response. * @@ -156,12 +185,25 @@ export function applyCatalogPostFilters( * context length for non-combo entries; the quota path passes a no-op because its * entries are all `owned_by: "combo"`, which skips enrichment entirely. */ -export function finalizeCatalogResponse( +export async function finalizeCatalogResponse( request: Request, finalModels: Array>, getContextFallback: (model: Record) => number | undefined, headers: Record -): Response { +): Promise { + const apiKey = extractApiKey(request); + if (apiKey) { + const { getApiKeyMetadata, isModelAllowedForKey } = await import("@/lib/db/apiKeys"); + const keyMeta = await getApiKeyMetadata(apiKey); + if (keyMeta && keyMeta.id !== "env-key" && !keyMeta.allowedQuotas?.length) { + finalModels = await filterUnauthorizedFunctionalGatewayMirrors( + finalModels, + apiKey, + isModelAllowedForKey + ); + } + } + const includeModelNames = isModelCatalogNamesEnabled(); const enrichedModels = disambiguateCatalogModelNames( finalModels.map((model) => { diff --git a/tests/unit/models-catalog-functional-gateway-permissions.test.ts b/tests/unit/models-catalog-functional-gateway-permissions.test.ts new file mode 100644 index 0000000000..ccdcfb1903 --- /dev/null +++ b/tests/unit/models-catalog-functional-gateway-permissions.test.ts @@ -0,0 +1,118 @@ +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-model-catalog-gateway-permissions-") +); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "catalog-gateway-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const featureFlagsDb = await import("../../src/lib/db/featureFlags.ts"); +const functionalGatewayDb = await import("../../src/lib/db/functionalGatewayMirrors.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); +} + +async function seedConnection( + provider: string, + overrides: { + authType?: string; + apiKey?: string | null; + accessToken?: string; + } = {} +) { + return providersDb.createProviderConnection({ + provider, + authType: overrides.authType || "apikey", + name: `${provider}-catalog-permissions`, + apiKey: overrides.apiKey === undefined ? "sk-test" : overrides.apiKey, + accessToken: overrides.accessToken, + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); +} + +function catalogIds(body: unknown): Set { + if (!body || typeof body !== "object" || !("data" in body) || !Array.isArray(body.data)) { + return new Set(); + } + return new Set( + body.data.flatMap((item) => + item && typeof item === "object" && "id" in item && typeof item.id === "string" + ? [item.id] + : [] + ) + ); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("v1 models catalog requires independent permission for functional gateway mirrors", async () => { + await seedConnection("kimi-coding", { + authType: "oauth", + apiKey: null, + accessToken: "kimi-access", + }); + await seedConnection("agentrouter"); + featureFlagsDb.setFeatureFlagOverride("EXPOSE_FUNCTIONAL_GATEWAY_MIRRORS", "true"); + functionalGatewayDb.setFunctionalGatewayProviderSetting("agentrouter", "on"); + + const restrictedKey = await apiKeysDb.createApiKey( + "catalog-functional-mirror", + "machine-functional" + ); + await apiKeysDb.updateApiKeyPermissions(restrictedKey.id, { + allowedModels: ["kimi-coding/*"], + }); + + const restrictedResponse = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models", { + headers: { Authorization: `Bearer ${restrictedKey.key}` }, + }) + ); + const restrictedIds = catalogIds(await restrictedResponse.json()); + + assert.equal(restrictedResponse.status, 200); + assert.equal(restrictedIds.has("kmc/k3"), true); + assert.equal(restrictedIds.has("agentrouter/kmc/k3"), false); + + const gatewayKey = await apiKeysDb.createApiKey( + "catalog-functional-mirror-allowed", + "machine-functional-allowed" + ); + await apiKeysDb.updateApiKeyPermissions(gatewayKey.id, { + allowedModels: ["kimi-coding/*", "agentrouter/*"], + }); + + const gatewayResponse = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models", { + headers: { Authorization: `Bearer ${gatewayKey.key}` }, + }) + ); + const gatewayIds = catalogIds(await gatewayResponse.json()); + + assert.equal(gatewayResponse.status, 200); + assert.equal(gatewayIds.has("kmc/k3"), true); + assert.equal(gatewayIds.has("agentrouter/kmc/k3"), true); +}); diff --git a/tests/unit/models-catalog-functional-gateway.test.ts b/tests/unit/models-catalog-functional-gateway.test.ts index 99c34c6292..6a3ed07f57 100644 --- a/tests/unit/models-catalog-functional-gateway.test.ts +++ b/tests/unit/models-catalog-functional-gateway.test.ts @@ -1,6 +1,9 @@ import { test, after } from "node:test"; import assert from "node:assert/strict"; -import { applyCatalogPostFilters } from "../../src/app/api/v1/models/catalogResponse.ts"; +import { + applyCatalogPostFilters, + filterUnauthorizedFunctionalGatewayMirrors, +} from "../../src/app/api/v1/models/catalogResponse.ts"; import { removeFeatureFlagOverride, setFeatureFlagOverride, @@ -33,6 +36,47 @@ test("catalog post-filters do not add mirrors when gate off (default)", () => { assert.deepEqual(out, models); }); +test("final catalog permission filtering does not let a mirror inherit base access", async () => { + setFeatureFlagOverride(FLAG_KEY, "true"); + setFunctionalGatewayProviderSetting("agentrouter", "on"); + + const models = [{ id: "kmc/k3", owned_by: "kimi-coding", root: "k3" }]; + const withMirror = applyCatalogPostFilters(makeRequest(), models, { + connections: [ + { + id: "conn-1", + provider: "agentrouter", + isActive: true, + providerSpecificData: {}, + }, + ], + prefixMode: "dual", + aliasToProviderId: {}, + }); + const allowed = await filterUnauthorizedFunctionalGatewayMirrors( + withMirror, + "restricted-key", + async (_key, modelId) => modelId === "kmc/k3" + ); + + assert.deepEqual( + allowed.map((model) => model.id), + ["kmc/k3"], + "a synthesized gateway mirror must authorize its own public ID" + ); + + const gatewayAllowed = await filterUnauthorizedFunctionalGatewayMirrors( + withMirror, + "gateway-key", + async (_key, modelId) => modelId === "agentrouter/kmc/k3" + ); + assert.deepEqual( + gatewayAllowed.map((model) => model.id), + ["kmc/k3", "agentrouter/kmc/k3"], + "an independently authorized gateway mirror must remain visible" + ); +}); + test("catalog post-filters synthesize a gateway mirror when gate on and gateway has a connection", () => { setFeatureFlagOverride(FLAG_KEY, "true"); setFunctionalGatewayProviderSetting("agentrouter", "on"); From 5926d357588c37e6fc9b747f5f6c3f3180ee0c0f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:52:16 -0300 Subject: [PATCH 043/100] cherry-pick(pr-9787): fix(sse): apply Azure param rules on azure-ai and clamp gpt-4o-mini output tokens (#9855) * fix(sse): apply Azure request-param rules on the azure-ai wire path Azure rejects several stock Chat Completions params on its newer deployments and returns HTTP 400 rather than ignoring them: max_tokens -> 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead. reasoning_effort -> Function tools with reasoning_effort are not supported. Those rules lived inline in AzureOpenAIExecutor, so they only covered the azure-openai provider. azure-ai (Azure AI Foundry) had no executor entry and fell through to the bare DefaultExecutor, so the SAME Azure deployment succeeded on one connection and 400'd on the other. Every agentic client sends tools on every turn, so azure-ai failed on the first request. Extract the rules to open-sse/executors/azureParamRules.ts, add an AzureAiExecutor that inherits DefaultExecutor's azure-ai URL/header/apiType handling unchanged and applies the shared rules, and register it for azure-ai. Also widen the deployment pattern to cover gpt-chat-latest: it is a moving alias that resolves to a GPT-5-era model and rejects max_tokens, but carries no version number for the token-boundary pattern to key on. Verified against the base regex - gpt-chat-latest did not match, which is exactly the observed 400. Regression guard: tests/unit/azure-param-rules.test.ts, including an assertion that getExecutor("azure-ai") no longer resolves to a bare DefaultExecutor. * fix(sse): clamp Azure gpt-4o-mini completion tokens to its 16384 ceiling Azure gpt-4o-mini deployments accept at most 16384 completion tokens and 400 on anything larger: max_tokens is too large: 32000. This model supports at most 16384 completion tokens, whereas you provided 32000. The 32000 is OmniRoute's own doing: adjustMaxTokens raises any smaller max_tokens to DEFAULT_MIN_TOKENS (32000) whenever tools are present, to avoid truncated tool arguments. That floor has no upper bound, so an agentic client asking for far less still trips the model ceiling on its first turn. Add scoped maxOutputCap rules in paramSupport.ts for both Azure wire paths. PROVIDER_MAX_TOKENS is the wrong lever here - it is provider-wide, and the same Azure resource also serves GPT-5 deployments with a much higher ceiling. Regression guard: tests/unit/azure-max-output-clamp.test.ts, which also pins that the clamp does not leak to gpt-5.1 or to gpt-4o-mini on other providers. --------- Co-authored-by: Mihaly Bodo --- open-sse/executors/azure-ai.ts | 35 +++++++++ open-sse/executors/azure-openai.ts | 39 ++------- open-sse/executors/azureParamRules.ts | 76 ++++++++++++++++++ open-sse/executors/index.ts | 3 + open-sse/translator/paramSupport.ts | 20 ++++- tests/unit/azure-max-output-clamp.test.ts | 58 ++++++++++++++ tests/unit/azure-param-rules.test.ts | 96 +++++++++++++++++++++++ 7 files changed, 293 insertions(+), 34 deletions(-) create mode 100644 open-sse/executors/azure-ai.ts create mode 100644 open-sse/executors/azureParamRules.ts create mode 100644 tests/unit/azure-max-output-clamp.test.ts create mode 100644 tests/unit/azure-param-rules.test.ts diff --git a/open-sse/executors/azure-ai.ts b/open-sse/executors/azure-ai.ts new file mode 100644 index 0000000000..438a4d1bc5 --- /dev/null +++ b/open-sse/executors/azure-ai.ts @@ -0,0 +1,35 @@ +import { DefaultExecutor } from "./default.ts"; +import type { ProviderCredentials } from "./base.ts"; +import { applyAzureParamRules } from "./azureParamRules.ts"; + +/** + * Azure AI Foundry (`azure-ai`). + * + * URL building, auth headers and the `responses` vs `chat` apiType switch all + * live in `DefaultExecutor`, keyed on the `azure-ai` provider id — this subclass + * inherits them unchanged and adds only the Azure request-param rules. + * + * Before this existed, `azure-ai` fell through to the bare `DefaultExecutor` + * while `azure-openai` had the rules inline, so the same Azure deployment + * behaved differently depending on which connection served it: `azure-openai` + * succeeded and `azure-ai` returned HTTP 400 for `max_tokens` / + * `reasoning_effort`. + */ +export class AzureAiExecutor extends DefaultExecutor { + constructor() { + super("azure-ai"); + } + + override transformRequest( + model: string, + body: unknown, + stream: boolean, + credentials: ProviderCredentials + ): unknown { + return applyAzureParamRules( + model, + body, + super.transformRequest(model, body, stream, credentials) + ); + } +} diff --git a/open-sse/executors/azure-openai.ts b/open-sse/executors/azure-openai.ts index 9b910d5c95..3872757a56 100644 --- a/open-sse/executors/azure-openai.ts +++ b/open-sse/executors/azure-openai.ts @@ -1,9 +1,9 @@ import { DefaultExecutor } from "./default.ts"; import type { ProviderCredentials } from "./base.ts"; import { stripTrailingSlashes } from "../utils/urlSanitize.ts"; +import { applyAzureParamRules } from "./azureParamRules.ts"; const DEFAULT_API_VERSION = "2024-12-01-preview"; -const GPT5_OR_REASONING_DEPLOYMENT = /(?:^|[/_-])(?:gpt-5|o(?:1|3|4))(?:[._-]|$)/i; function normalizeAzureBaseUrl(rawBaseUrl?: string | null): string { const normalized = stripTrailingSlashes((rawBaseUrl || "").trim()); @@ -57,37 +57,10 @@ export class AzureOpenAIExecutor extends DefaultExecutor { stream: boolean, credentials: ProviderCredentials ): unknown { - const transformed = super.transformRequest(model, body, stream, credentials); - if (!GPT5_OR_REASONING_DEPLOYMENT.test(model)) return transformed; - if (!transformed || typeof transformed !== "object" || Array.isArray(transformed)) { - return transformed; - } - - const original = - body && typeof body === "object" && !Array.isArray(body) - ? (body as Record) - : null; - const normalized = { ...(transformed as Record) }; - - if (original?.max_completion_tokens !== undefined) { - normalized.max_completion_tokens = original.max_completion_tokens; - } else if ( - normalized.max_completion_tokens === undefined && - original?.max_tokens !== undefined - ) { - normalized.max_completion_tokens = original.max_tokens; - } - delete normalized.max_tokens; - - if (normalized.temperature !== undefined && normalized.temperature !== 1) { - delete normalized.temperature; - } - - const hasTools = Array.isArray(normalized.tools) && normalized.tools.length > 0; - if (hasTools || normalized.reasoning_effort === "none") { - delete normalized.reasoning_effort; - } - - return normalized; + return applyAzureParamRules( + model, + body, + super.transformRequest(model, body, stream, credentials) + ); } } diff --git a/open-sse/executors/azureParamRules.ts b/open-sse/executors/azureParamRules.ts new file mode 100644 index 0000000000..4bd8eab22a --- /dev/null +++ b/open-sse/executors/azureParamRules.ts @@ -0,0 +1,76 @@ +/** + * Azure Chat Completions param rules, shared by every Azure wire path. + * + * Azure's newer deployments reject a handful of stock OpenAI Chat Completions + * params and return HTTP 400 rather than ignoring them: + * + * - `max_tokens` -> "Unsupported parameter: 'max_tokens' is not supported + * with this model. Use 'max_completion_tokens' instead." + * - `temperature` -> only the default (1) is accepted. + * - `reasoning_effort` -> "Function tools with reasoning_effort are not + * supported ... Please use /v1/responses instead." + * + * This logic previously lived inline in `AzureOpenAIExecutor`, so it only + * covered the `azure-openai` provider. `azure-ai` (Azure AI Foundry) routes + * through `DefaultExecutor` and inherited none of it, which meant an identical + * deployment 400'd on one connection and succeeded on the other. Extracted here + * so both executors apply exactly the same rules. + */ + +/** + * Deployments that require `max_completion_tokens` instead of `max_tokens`. + * + * Matches the GPT-5 family and the o1/o3/o4 reasoning series at a token + * boundary, so a deployment named `my-gpt-5-prod` matches while an unrelated + * `piston-o4-legacy`-style name does not match by accident. `gpt-chat-latest` + * is listed explicitly: it is a moving alias that currently resolves to a + * GPT-5-era model and rejects `max_tokens`, but carries no version number for + * the boundary pattern to key on. + */ +export const AZURE_COMPLETION_TOKEN_DEPLOYMENT = + /(?:^|[/_-])(?:gpt-5|o(?:1|3|4))(?:[._-]|$)|^gpt-chat-latest$/i; + +/** + * Apply the Azure param rules to an already-translated Chat Completions body. + * + * `originalBody` is the pre-translation request, consulted only to recover a + * caller-supplied token budget that translation may have moved or dropped. + * Returns `transformed` untouched when the deployment is unaffected or the body + * is not a plain object, and never mutates either input. + */ +export function applyAzureParamRules( + model: string, + originalBody: unknown, + transformed: unknown +): unknown { + if (!AZURE_COMPLETION_TOKEN_DEPLOYMENT.test(model)) return transformed; + if (!transformed || typeof transformed !== "object" || Array.isArray(transformed)) { + return transformed; + } + + const original = + originalBody && typeof originalBody === "object" && !Array.isArray(originalBody) + ? (originalBody as Record) + : null; + const normalized = { ...(transformed as Record) }; + + if (original?.max_completion_tokens !== undefined) { + normalized.max_completion_tokens = original.max_completion_tokens; + } else if (normalized.max_completion_tokens === undefined && original?.max_tokens !== undefined) { + normalized.max_completion_tokens = original.max_tokens; + } + delete normalized.max_tokens; + + if (normalized.temperature !== undefined && normalized.temperature !== 1) { + delete normalized.temperature; + } + + // Azure 400s on reasoning_effort as soon as tools are present, which is every + // agentic client (Claude Code, Cursor agent) on every turn. + const hasTools = Array.isArray(normalized.tools) && normalized.tools.length > 0; + if (hasTools || normalized.reasoning_effort === "none") { + delete normalized.reasoning_effort; + } + + return normalized; +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index b25f4d9555..6b9477338b 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -25,6 +25,7 @@ import { ChatGptWebExecutor } from "./chatgpt-web.ts"; import { BlackboxWebExecutor } from "./blackbox-web.ts"; import { MuseSparkWebExecutor } from "./muse-spark-web.ts"; import { AzureOpenAIExecutor } from "./azure-openai.ts"; +import { AzureAiExecutor } from "./azure-ai.ts"; import { CommandCodeExecutor } from "./commandCode.ts"; import { GitlabExecutor } from "./gitlab.ts"; import { NlpCloudExecutor } from "./nlpcloud.ts"; @@ -89,6 +90,7 @@ const executors = { glmt: new GlmExecutor("glmt"), cu: new CursorExecutor(), // Alias for cursor "azure-openai": new AzureOpenAIExecutor(), + "azure-ai": new AzureAiExecutor(), "command-code": new CommandCodeExecutor(), cmd: new CommandCodeExecutor(), // Alias gitlab: new GitlabExecutor(), @@ -263,6 +265,7 @@ export { ChatGptWebExecutor } from "./chatgpt-web.ts"; export { BlackboxWebExecutor } from "./blackbox-web.ts"; export { MuseSparkWebExecutor } from "./muse-spark-web.ts"; export { AzureOpenAIExecutor } from "./azure-openai.ts"; +export { AzureAiExecutor } from "./azure-ai.ts"; export { CommandCodeExecutor } from "./commandCode.ts"; export { GitlabExecutor } from "./gitlab.ts"; export { NlpCloudExecutor } from "./nlpcloud.ts"; diff --git a/open-sse/translator/paramSupport.ts b/open-sse/translator/paramSupport.ts index caad2ab5a3..85dcec35ae 100644 --- a/open-sse/translator/paramSupport.ts +++ b/open-sse/translator/paramSupport.ts @@ -63,7 +63,12 @@ const STRIP_RULES: StripRule[] = [ // MoonshotAI/kimi-cli#1124), and by upstream decolua/9router#2460. Scoped to // OmniRoute's actual volcengine Kimi id (not a broad /kimi/i regex) so it // never clamps an unrelated future Kimi listing whose Ark cap may differ. - { provider: "volcengine", match: /^kimi-k2-5-260127$/, maxOutputCap: 32768, clampToModelMaxOutput: true }, + { + provider: "volcengine", + match: /^kimi-k2-5-260127$/, + maxOutputCap: 32768, + clampToModelMaxOutput: true, + }, // #7364: Z.AI's glm-4.6v vision endpoint enforces a 32768 max_tokens ceiling // server-side and 400s when a client sends a larger explicit max_tokens (e.g. a // client defaulting to 65536). Scoped to both wire paths that can reach this @@ -75,6 +80,19 @@ const STRIP_RULES: StripRule[] = [ // glmProvider.ts, maxOutputTokens: 32768, so clampToModelMaxOutput suffices). { provider: "zai", match: /^glm-4\.6v$/i, maxOutputCap: 32768 }, { provider: "glm", match: /^glm-4\.6v$/i, clampToModelMaxOutput: true }, + // Azure gpt-4o-mini deployments cap completion tokens at 16384 and 400 on + // anything larger: "max_tokens is too large: 32000. This model supports at + // most 16384 completion tokens". OmniRoute's own tool-calling floor + // (DEFAULT_MIN_TOKENS = 32000, applied by adjustMaxTokens) raises even a tiny + // explicit max_tokens to 32000 whenever tools are present, so every agentic + // client trips this on its first turn. PROVIDER_MAX_TOKENS is not the right + // lever here: it is provider-wide, and the same Azure resource also serves + // GPT-5 deployments whose ceiling is far higher. Azure deployment names are + // operator-chosen, hence a prefix match rather than an exact id, and the + // models are passthrough (no catalog maxOutputTokens for clampToModelMaxOutput + // to read), hence the fixed cap. + { provider: "azure-openai", match: /^gpt-4o-mini/i, maxOutputCap: 16384 }, + { provider: "azure-ai", match: /^gpt-4o-mini/i, maxOutputCap: 16384 }, ]; function matches(rule: StripRule, model: string): boolean { diff --git a/tests/unit/azure-max-output-clamp.test.ts b/tests/unit/azure-max-output-clamp.test.ts new file mode 100644 index 0000000000..4b3e0a231d --- /dev/null +++ b/tests/unit/azure-max-output-clamp.test.ts @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { stripUnsupportedParams } from "../../open-sse/translator/paramSupport.ts"; + +/** + * Regression guard for the Azure gpt-4o-mini completion-token ceiling. + * + * Observed against a live Azure deployment: + * azure-openai/gpt-4o-mini-dz + * -> 400 "max_tokens is too large: 32000. This model supports at most + * 16384 completion tokens, whereas you provided 32000." + * + * The 32000 is OmniRoute's own doing: `adjustMaxTokens` raises any smaller + * max_tokens to DEFAULT_MIN_TOKENS (32000) whenever tools are present, so an + * agentic client trips this on its first turn even when it asked for far less. + */ + +test("azure gpt-4o-mini clamps max_tokens to the 16384 ceiling", () => { + const out = stripUnsupportedParams("azure-openai", "gpt-4o-mini-dz", { + max_tokens: 32000, + messages: [], + }) as Record; + + assert.equal(out.max_tokens, 16384); +}); + +test("the clamp applies on the azure-ai wire path too", () => { + const out = stripUnsupportedParams("azure-ai", "gpt-4o-mini", { + max_completion_tokens: 32000, + }) as Record; + + assert.equal(out.max_completion_tokens, 16384); +}); + +test("a value already under the ceiling is left alone", () => { + const out = stripUnsupportedParams("azure-openai", "gpt-4o-mini", { + max_tokens: 800, + }) as Record; + + assert.equal(out.max_tokens, 800); +}); + +test("the clamp is scoped — larger Azure deployments keep their budget", () => { + const out = stripUnsupportedParams("azure-ai", "gpt-5.1", { + max_tokens: 32000, + }) as Record; + + assert.equal(out.max_tokens, 32000); +}); + +test("the clamp does not leak to gpt-4o-mini on other providers", () => { + const out = stripUnsupportedParams("openai", "gpt-4o-mini", { + max_tokens: 32000, + }) as Record; + + assert.equal(out.max_tokens, 32000); +}); diff --git a/tests/unit/azure-param-rules.test.ts b/tests/unit/azure-param-rules.test.ts new file mode 100644 index 0000000000..4e46b0788f --- /dev/null +++ b/tests/unit/azure-param-rules.test.ts @@ -0,0 +1,96 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + applyAzureParamRules, + AZURE_COMPLETION_TOKEN_DEPLOYMENT, +} from "../../open-sse/executors/azureParamRules.ts"; +import { getExecutor, AzureAiExecutor } from "../../open-sse/executors/index.ts"; + +/** + * Regression guards for two Azure 400s observed against a live Azure AI Foundry + * resource: + * + * azure-ai/gpt-chat-latest + * -> 400 "Unsupported parameter: 'max_tokens' is not supported with this + * model. Use 'max_completion_tokens' instead." + * azure-ai/ with tools + * -> 400 "Function tools with reasoning_effort are not supported ... + * Please use /v1/responses instead." + * + * Both rules already existed inline in AzureOpenAIExecutor, so the identical + * deployment succeeded on the `azure-openai` connection and failed on + * `azure-ai`, which routed through the bare DefaultExecutor. + */ + +test("gpt-chat-latest converts max_tokens to max_completion_tokens", () => { + const out = applyAzureParamRules( + "gpt-chat-latest", + { max_tokens: 4096 }, + { max_tokens: 4096, messages: [] } + ) as Record; + + assert.equal(out.max_tokens, undefined); + assert.equal(out.max_completion_tokens, 4096); +}); + +test("gpt-5 family converts max_tokens too", () => { + for (const model of ["gpt-5.1", "gpt-5.4-nano", "my-gpt-5-prod", "o3", "o4-mini"]) { + const out = applyAzureParamRules(model, { max_tokens: 100 }, { max_tokens: 100 }) as Record< + string, + unknown + >; + assert.equal(out.max_tokens, undefined, `${model} should drop max_tokens`); + assert.equal(out.max_completion_tokens, 100, `${model} should set max_completion_tokens`); + } +}); + +test("reasoning_effort is dropped when tools are present", () => { + const out = applyAzureParamRules( + "gpt-5.1", + {}, + { reasoning_effort: "high", tools: [{ name: "read_file" }] } + ) as Record; + + assert.equal(out.reasoning_effort, undefined); + assert.equal((out.tools as unknown[]).length, 1); +}); + +test("reasoning_effort survives when there are no tools", () => { + const out = applyAzureParamRules("gpt-5.1", {}, { reasoning_effort: "high" }) as Record< + string, + unknown + >; + assert.equal(out.reasoning_effort, "high"); +}); + +test("non-default temperature is dropped, temperature=1 kept", () => { + const dropped = applyAzureParamRules("gpt-5.1", {}, { temperature: 0.7 }) as Record< + string, + unknown + >; + assert.equal(dropped.temperature, undefined); + + const kept = applyAzureParamRules("gpt-5.1", {}, { temperature: 1 }) as Record; + assert.equal(kept.temperature, 1); +}); + +test("unaffected deployments pass through untouched", () => { + const body = { max_tokens: 500, temperature: 0.2, reasoning_effort: "low" }; + const out = applyAzureParamRules("Phi-4", {}, body); + assert.deepEqual(out, body); +}); + +test("the regex does not match unrelated names by accident", () => { + assert.equal(AZURE_COMPLETION_TOKEN_DEPLOYMENT.test("gpt-4o-mini"), false); + assert.equal(AZURE_COMPLETION_TOKEN_DEPLOYMENT.test("DeepSeek-V4-Flash"), false); + assert.equal(AZURE_COMPLETION_TOKEN_DEPLOYMENT.test("Kimi-K2.7-Code"), false); +}); + +test("azure-ai resolves to AzureAiExecutor, not the bare DefaultExecutor", () => { + const executor = getExecutor("azure-ai"); + assert.ok( + executor instanceof AzureAiExecutor, + "azure-ai must have its own executor so it inherits the Azure param rules" + ); +}); From a102a2d77310960f5d5f7dfe4d9d80424b5d93a6 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:52:23 -0300 Subject: [PATCH 044/100] maint: final follow-up cherry-pick #9783 (#9904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(deps): bump transitive deps for 6 Dependabot + remaining audit vulns on main Same overrides as #9464 (ip-address, hono, fast-uri, socket.io-parser, undici) applied directly to main. Also covers brace-expansion (scoped), js-yaml v4 copies, and mermaid. npm audit: 6→0 vulnerabilities. Closes Dependabot #161-#166. * fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190) Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13 (with monaco-editor scoped override). Closes Dependabot #189, #190. Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge — awaiting Dependabot re-scan. npm audit → 0 vulnerabilities. * fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks) _tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential _tasks symlink can slip in via git add -A and, once pulled, checkout materializes it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks ignores the symlink too, preventing re-capture. * fix(translator): keep Responses namespace identity across the hub-and-spoke pivot Step 1 of the pivot (openai-responses -> openai) flattens namespace sub-tools to a qualified wire name (#8295) and records the `{namespace, name}` pair on a non-enumerable `_toolNameMap`. Step 2 (openai -> target) returns a brand-new object, so the property was dropped for every non-OpenAI target. chatCore then handed `null` to the #7936 response seam and namespace sub-tool calls reached the client under their flattened name, which Codex rejects with `unsupported call: ` — the symptom #7936 was opened to fix. Copying `_toolNameMap` through is not viable: openai-to-claude and openai-to-gemini publish their own `Map` alias map on that same property during step 2, so it carries two incompatible types. This adds a dedicated `_namespaceToolIdentityMap`, propagated by translateRequest across the pivot; chatCore prefers it and falls back to `_toolNameMap` for the non-pivot producers. Both keys are stripped from the cliproxyapi wire body. Fixes #9780 * fix(chat): reduce file size Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(chat): reduce combined file size Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(chat): reduce combined file size Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw Co-authored-by: VXNCXNX --- open-sse/executors/cliproxyapi.ts | 7 +- open-sse/handlers/chatCore.ts | 32 ++-- open-sse/translator/index.ts | 22 ++- .../translator/request/openai-responses.ts | 13 +- .../9780-namespace-identity-pivot.test.ts | 155 ++++++++++++++++++ 5 files changed, 204 insertions(+), 25 deletions(-) create mode 100644 tests/unit/9780-namespace-identity-pivot.test.ts diff --git a/open-sse/executors/cliproxyapi.ts b/open-sse/executors/cliproxyapi.ts index 83095f4d20..f6490b835f 100644 --- a/open-sse/executors/cliproxyapi.ts +++ b/open-sse/executors/cliproxyapi.ts @@ -408,12 +408,13 @@ export class CliproxyapiExecutor extends BaseExecutor { input.log?.info?.("CPA", `CLIProxyAPI → ${url} (model: ${input.model}, shape: ${shape})`); - // _toolNameMap is an in-memory channel to chatCore for response-side - // tool name restoration; never send it over the wire. + // _toolNameMap and _namespaceToolIdentityMap are in-memory channels to + // chatCore for response-side tool name restoration; never send them over + // the wire. const wireBody = transformedBody && typeof transformedBody === "object" ? JSON.stringify(transformedBody, (key, value) => - key === "_toolNameMap" ? undefined : value + key === "_toolNameMap" || key === "_namespaceToolIdentityMap" ? undefined : value ) : JSON.stringify(transformedBody); diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 2b110b47f4..1b31d6a871 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -207,7 +207,6 @@ import { stageTrace } from "./chatCore/stageTrace.ts"; import { attachCompressionUsageReceiptAfterAnalytics as attachCompressionUsageReceiptAfterAnalyticsFor } from "./chatCore/compressionUsageReceipt.ts"; import { prepareUpstreamBody } from "./chatCore/upstreamBody.ts"; import { getQuotaScopeLabelForProvider } from "../services/antigravityQuotaFamily.ts"; - import { getCallLogPipelineCaptureStreamChunks, getCallLogPipelineMaxSizeBytes, @@ -367,9 +366,7 @@ import { isTpmExhausted, isRpmExhausted, } from "../services/geminiRateLimitTracker.ts"; - import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts"; - /** * Core chat handler - shared between SSE and Worker * Returns { success, response, status, error } for caller to handle fallback @@ -389,10 +386,8 @@ import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts"; * @param {boolean} options.isCombo - Whether this request is from a combo * @param {string} options.connectionId - Connection ID for settings lookup */ - // extractSystemRoleMessages extracted to chatCore/claudeSystemRole.ts (#3501); re-exported above so // existing importers (e.g. tests/unit/system-role-extraction.test.ts) keep resolving it from here. - export async function handleChatCore({ body, modelInfo, @@ -428,7 +423,6 @@ export async function handleChatCore({ /* fail open */ } } - // Per-request model-routing metadata (first extracted slice of the request-setup phase). const { apiFormat, customModelTargetFormat, requestedModel } = resolveChatCoreRequestSetup( modelInfo, @@ -442,7 +436,6 @@ export async function handleChatCore({ // (not Math.random) purely to satisfy CodeQL js/insecure-randomness — this id // is a log-correlation token, not a security secret. const traceId = globalThis.crypto.randomUUID().slice(0, 6); - // Emit request.started event for real-time dashboard setImmediate(() => { emit("request.started", { @@ -526,7 +519,6 @@ export async function handleChatCore({ `long-running goal mode enabled: readinessMax=${agentGoalPolicy.readinessMaxTimeoutMs}ms streamRecovery=${agentGoalPolicy.streamRecoveryEnabled}` ); } - let effectiveServiceTier: EffectiveServiceTier = "standard"; // Codex service-tier resolvers extracted to chatCore/serviceTier.ts (#3501); bind the per-request // provider/credentials once and delegate so the existing call sites stay byte-identical. @@ -555,7 +547,6 @@ export async function handleChatCore({ }) ).catch(() => {}); }; - // Key-health updater extracted to chatCore/keyHealth.ts (#3501); bind the per-request log once // and delegate so the existing call sites stay byte-identical. const recordKeyHealthStatus = ( @@ -563,11 +554,9 @@ export async function handleChatCore({ creds: Record | null | undefined, transport?: string ): void => recordKeyHealthStatusFor(status, creds, log, transport); - const persistCodexQuotaState = async (headers: Record | null, status = 0) => { const currentConnectionId = getCurrentConnectionId(); if (provider !== "codex" || !currentConnectionId || !headers) return; - try { const existingProviderData = credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object" @@ -582,28 +571,23 @@ export async function handleChatCore({ status, }); if (!built) return; - if (built.exhaustionLog) { log?.debug?.("CODEX", built.exhaustionLog); } - // Invalidate the preflight cache for this connection so the next // isModelAvailable check fetches fresh quota data. if (status === 429) { invalidateCodexQuotaCache(currentConnectionId); } - await updateProviderConnection(currentConnectionId, { providerSpecificData: built.nextProviderData, }); - credentials.providerSpecificData = built.nextProviderData; } catch (err) { const errMessage = err instanceof Error ? err.message : String(err); log?.debug?.("CODEX", `Failed to persist codex quota state: ${errMessage}`); } }; - // ── Phase 9.2: Idempotency check ── // Resolve the idempotency key once here and reuse it at the Phase 9.2 save site below, // rather than re-deriving it. (#3821-review LEDGER-6) @@ -622,13 +606,11 @@ export async function handleChatCore({ if (idempotencyHit) { return idempotencyHit; } - // T07: Inject connectionId into credentials so executors can rotate API keys // using providerSpecificData.extraApiKeys (API Key Round-Robin feature) if (connectionId && credentials && !credentials.connectionId) { credentials.connectionId = connectionId; } - // Endpoint/format resolution extracted to chatCore/requestFormat.ts (#3501); pure derivation // from the inbound request, destructured so every downstream use stays byte-identical. const { @@ -2264,8 +2246,19 @@ export async function handleChatCore({ // the latter is a Kiro/Claude passthrough alias channel with string values, // while namespace identities carry `{namespace, name}` for the #7936 response // seam. Extract first because Kiro merge may reuse `_toolNameMap` below. + // + // #9780 — prefer the dedicated channel: on a pivot the openai->claude/gemini + // step publishes its own alias map on `_toolNameMap`, so that property alone + // yields aliases here. The `_toolNameMap` read stays as the fallback for the + // non-pivot producers (executors/base.ts, cliproxyapi.ts, antigravity). + const namespaceIdentityMap = translatedBody._namespaceToolIdentityMap; const requestToolIdentityMap = - translatedBody._toolNameMap instanceof Map ? translatedBody._toolNameMap : null; + namespaceIdentityMap instanceof Map + ? namespaceIdentityMap + : translatedBody._toolNameMap instanceof Map + ? translatedBody._toolNameMap + : null; + delete translatedBody._namespaceToolIdentityMap; delete translatedBody._toolNameMap; // Kiro: sanitize tool schemas before dispatch. Kiro returns 400 "Improperly @@ -5025,7 +5018,6 @@ export async function handleChatCore({ }), }; } - export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) { if (!expiresAt) return false; const expiresAtMs = new Date(expiresAt).getTime(); diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 8497989f09..b81acb71e0 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -352,7 +352,27 @@ export function translateRequest( ...(hasProvider ? { _provider: provider } : {}), } : credentials; - result = fromOpenAI(model, result, stream, translationCredentials); + // #9780 — carry the Responses namespace identity map across the pivot. + // Target translators return a brand-new object (buildKiroPayload et + // al.), dropping the non-enumerable property step 1 attached; the + // #7936 seam then gets null and namespace sub-tool calls come back + // flattened, which Codex rejects with `unsupported call: `. + const identityMap = (result as Record)._namespaceToolIdentityMap; + const translated = fromOpenAI(model, result, stream, translationCredentials); + if ( + identityMap instanceof Map && + translated && + typeof translated === "object" && + !((translated as Record)._namespaceToolIdentityMap instanceof Map) + ) { + Object.defineProperty(translated, "_namespaceToolIdentityMap", { + value: identityMap, + enumerable: false, + configurable: true, + writable: true, + }); + } + result = translated; } } } diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 6d7a79b8a4..4a15437527 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -752,8 +752,19 @@ export function openaiResponsesToOpenAIRequest( delete result.prompt_cache_retention; if (namespaceToolIdentityMap.size > 0) { - // chatCore extracts and deletes this transient side channel before dispatch. + // chatCore extracts and deletes these transient side channels before dispatch. // Non-enumerability keeps internal request metadata off the upstream wire. + // + // Two properties on purpose (#9780): `_toolNameMap` is also the alias + // channel for openai-to-claude/gemini, which overwrite it on a pivot, so + // the identity map needs a name of its own. `_toolNameMap` stays populated + // for the existing consumers (executors/base.ts, cliproxyapi, antigravity). + Object.defineProperty(result, "_namespaceToolIdentityMap", { + value: namespaceToolIdentityMap, + enumerable: false, + configurable: true, + writable: true, + }); Object.defineProperty(result, "_toolNameMap", { value: namespaceToolIdentityMap, enumerable: false, diff --git a/tests/unit/9780-namespace-identity-pivot.test.ts b/tests/unit/9780-namespace-identity-pivot.test.ts new file mode 100644 index 0000000000..b49cae6747 --- /dev/null +++ b/tests/unit/9780-namespace-identity-pivot.test.ts @@ -0,0 +1,155 @@ +// #9780 — the Responses namespace identity map must survive the hub-and-spoke +// pivot in translator/index.ts. Step 1 flattens namespace sub-tools (#8295) and +// records `{namespace, name}`; step 2 returns a new object and used to drop it, +// leaving the #7936 seam with null and Codex rejecting `unsupported call`. +// A naive copy-through is not an option: openai-to-claude/gemini publish their +// own alias map on `_toolNameMap`, hence the dedicated channel asserted here. +import test from "node:test"; +import assert from "node:assert/strict"; + +await import("../../open-sse/translator/bootstrap.ts"); +const { translateRequest, initState } = await import("../../open-sse/translator/index.ts"); +const { openaiToOpenAIResponsesResponse } = await import( + "../../open-sse/translator/response/openai-responses.ts" +); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + +type NamespaceIdentity = { namespace: string; name: string }; + +const NAMESPACE_REQUEST = { + model: "any-model", + instructions: "coding agent", + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "go" }] }], + tools: [ + { + type: "namespace", + name: "functions", + tools: [ + { + name: "exec", + description: "Run a shell command", + parameters: { + type: "object", + properties: { cmd: { type: "string" } }, + required: ["cmd"], + }, + }, + ], + }, + ], +}; + +function pivot(targetFormat: string): Record { + return translateRequest( + "openai-responses", + targetFormat, + "any-model", + structuredClone(NAMESPACE_REQUEST), + true, + null, + null, + null + ) as Record; +} + +function identityOf(body: Record) { + const map = body._namespaceToolIdentityMap; + assert.ok(map instanceof Map, "expected a _namespaceToolIdentityMap after the pivot"); + return map as Map; +} + +test("#9780: namespace identity survives the openai-responses -> kiro pivot", () => { + const identity = identityOf(pivot("kiro")); + + assert.equal(identity.size, 1); + assert.deepEqual(identity.get("functions__exec"), { namespace: "functions", name: "exec" }); +}); + +test("#9780: namespace identity survives the openai-responses -> cursor pivot", () => { + const identity = identityOf(pivot("cursor")); + + assert.deepEqual(identity.get("functions__exec"), { namespace: "functions", name: "exec" }); +}); + +// Regression guard: these two appeared to "keep" a map before the fix, but it +// was the alias map. +for (const target of ["claude", "gemini"]) { + test(`#9780: ${target} pivot keeps its alias map AND the namespace identity`, () => { + const body = pivot(target); + const identity = identityOf(body); + + assert.deepEqual(identity.get("functions__exec"), { namespace: "functions", name: "exec" }); + + // The alias channel must be untouched: string values, not identities. + const aliases = body._toolNameMap; + assert.ok(aliases instanceof Map, `${target} must still publish its alias map`); + for (const value of (aliases as Map).values()) { + assert.equal(typeof value, "string", `${target} alias values must stay strings`); + } + }); +} + +// Same-format requests are never flattened, so an absent map is correct here. +test("#9780: same-format openai-responses request is not flattened at all", () => { + const body = pivot("openai-responses"); + const tools = body.tools as Array>; + + assert.equal(tools[0].type, "namespace"); + assert.equal((tools[0].tools as Array<{ name: string }>)[0].name, "exec"); + assert.equal(body._namespaceToolIdentityMap, undefined); +}); + +test("#9780: the identity channel is non-enumerable and never serializes", () => { + const body = pivot("kiro"); + + assert.ok(body._namespaceToolIdentityMap instanceof Map); + assert.equal( + Object.prototype.propertyIsEnumerable.call(body, "_namespaceToolIdentityMap"), + false + ); + assert.equal("_namespaceToolIdentityMap" in JSON.parse(JSON.stringify(body)), false); +}); + +// End-to-end: request pivot + response seam, i.e. what the Codex adjudicator +// actually receives. Before the fix every target emitted `functions__exec` with +// no namespace, which is the reported `unsupported call`. +for (const target of ["kiro", "cursor", "claude", "gemini"]) { + test(`#9780: ${target} round-trip returns the declared name and its namespace`, () => { + const body = pivot(target); + const state = initState(FORMATS.OPENAI_RESPONSES) as Record; + state.requestToolIdentityMap = body._namespaceToolIdentityMap; + + // The upstream echoes the flattened wire name (#8295). + const events = openaiToOpenAIResponsesResponse( + { + id: "chatcmpl-9780", + model: "any-model", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_9780", + type: "function", + function: { name: "functions__exec", arguments: '{"cmd":"git status"}' }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + state + ) as Array<{ event: string; data: { item?: NamespaceIdentity } }>; + + const added = events.find((e) => e.event === "response.output_item.added")?.data.item; + assert.ok(added, "expected response.output_item.added"); + assert.deepEqual( + { name: added.name, namespace: added.namespace }, + { name: "exec", namespace: "functions" } + ); + }); +} From 332c738844b7a8aa776b6036ea2431def6f3faa6 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:52:28 -0300 Subject: [PATCH 045/100] fix(sse): route claude// aliases for catalog-only providers (#9856) The /v1/models catalog mirrors `claude//` ids purely from the alias gate -- ccAliasPredicate.ts consults no provider registry. The request path additionally required the prefix to be an open-sse REGISTRY entry or an operator-defined custom node. Enterprise-cloud providers such as azure-ai / azure-openai live only in the provider catalog (src/shared/constants/providers/apikey/enterprise-cloud.ts). They route fine directly -- `azure-ai/Phi-4` returns 200 -- but have no open-sse registry entry, so the two sides disagreed: the catalog advertised `claude/azure-ai/` while stripCcDiscoveryAlias refused to strip it. The unstripped id then fell through to normal resolution, which splits on the first / and parsed `claude` as the provider. Every Claude Code request for an Azure model was routed to the Claude provider instead: ROUTING: Provider: claude, Model: azure-ai/DeepSeek-V4-Flash Extract the predicate as `isRoutableProviderPrefix()` and widen it to the provider catalog (id + alias) alongside the open-sse registry, so the request path recognises exactly what the catalog can advertise. Regression guard: tests/unit/cc-discovery-alias-routable-prefix.test.ts pins azure-ai/azure-openai/azure as routable, keeps openai/anthropic routable, and keeps an unknown prefix non-routable. Verified failing before the widening. Co-authored-by: Mihaly Bodo --- src/lib/ccDiscoveryAliasResolve.ts | 19 +++++++- ...cc-discovery-alias-routable-prefix.test.ts | 43 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 tests/unit/cc-discovery-alias-routable-prefix.test.ts diff --git a/src/lib/ccDiscoveryAliasResolve.ts b/src/lib/ccDiscoveryAliasResolve.ts index 2112affbec..00a0d7ba83 100644 --- a/src/lib/ccDiscoveryAliasResolve.ts +++ b/src/lib/ccDiscoveryAliasResolve.ts @@ -20,6 +20,7 @@ import { } from "@omniroute/open-sse/handlers/chatCore/ccDiscoveryAliasStrip.ts"; import { getModelsByProviderId } from "@omniroute/open-sse/config/providerModels.ts"; import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { getProviderById, getProviderByAlias } from "@/shared/constants/providers"; import { getCachedProviderNodes } from "@/lib/db/readCache"; import { getComboByName } from "@/lib/db/combos"; import { @@ -136,6 +137,22 @@ export async function resolveCcDiscoveryAliasStripWith( }); } +/** + * True when `prefix` names a provider the router can actually reach. + * + * Deliberately broader than the `open-sse` REGISTRY alone: enterprise-cloud + * providers such as `azure-ai` / `azure-openai` live only in the provider + * CATALOG (src/shared/constants/providers/…) yet route fine, so a registry-only + * check made the request path reject `claude/azure-ai/` ids that the + * catalog had already advertised — see cc-discovery-alias-routable-prefix.test.ts. + */ +export function isRoutableProviderPrefix(prefix: string): boolean { + if (!prefix) return false; + if (getRegistryEntry(prefix) !== null) return true; + if (getProviderById(prefix) !== undefined) return true; + return getProviderByAlias(prefix) !== null; +} + /** * Production entry point: build the real lookups and resolve. Cheap-exit for any * id that does not start with `claude/` (the overwhelmingly common case) so a @@ -169,7 +186,7 @@ export async function resolveCcDiscoveryAliasStrip( const result = await resolveCcDiscoveryAliasStripWith(modelStr, { claudeModelIds, - isRegistryProvider: (prefix) => getRegistryEntry(prefix) !== null, + isRegistryProvider: (prefix) => isRoutableProviderPrefix(prefix), customProviderPrefixes, getCombo: (name) => getComboByName(name), gateGlobal: () => globalEnabled, diff --git a/tests/unit/cc-discovery-alias-routable-prefix.test.ts b/tests/unit/cc-discovery-alias-routable-prefix.test.ts new file mode 100644 index 0000000000..f884a7c17a --- /dev/null +++ b/tests/unit/cc-discovery-alias-routable-prefix.test.ts @@ -0,0 +1,43 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { isRoutableProviderPrefix } from "../../src/lib/ccDiscoveryAliasResolve.ts"; + +/** + * Regression guard for the "listed but rejected" cc-discovery alias mismatch. + * + * The /v1/models catalog mirrors `claude//` ids based purely on + * the alias gate (src/app/api/v1/models/ccAliasPredicate.ts — it does NOT consult + * any provider registry). The request path additionally required the prefix to be + * an `open-sse` REGISTRY entry or an operator-defined custom node. + * + * Enterprise-cloud providers such as `azure-ai` / `azure-openai` live in the + * provider CATALOG (src/shared/constants/providers/apikey/enterprise-cloud.ts) + * and route fine directly (`azure-ai/Phi-4` → 200), but have no `open-sse` + * registry entry. So the catalog advertised `claude/azure-ai/` while the + * request path refused to strip it — the id fell through with `claude` parsed as + * the provider, and every request was routed to the Claude provider instead. + * + * These assertions pin the predicate to "can the router actually reach it", + * which is the property the catalog side already assumes. + */ + +test("catalog-only enterprise-cloud providers are routable (azure-ai regression)", () => { + assert.equal(isRoutableProviderPrefix("azure-ai"), true); + assert.equal(isRoutableProviderPrefix("azure-openai"), true); +}); + +test("open-sse registry providers stay routable", () => { + assert.equal(isRoutableProviderPrefix("openai"), true); + assert.equal(isRoutableProviderPrefix("anthropic"), true); +}); + +test("provider aliases resolve too", () => { + // `azure` is the declared alias of the `azure-openai` catalog entry. + assert.equal(isRoutableProviderPrefix("azure"), true); +}); + +test("an unknown prefix is not routable", () => { + assert.equal(isRoutableProviderPrefix("definitely-not-a-provider-xyz"), false); + assert.equal(isRoutableProviderPrefix(""), false); +}); From 8a17f438499c49bf0693b7b80136c8f5815bd014 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:52:34 -0300 Subject: [PATCH 046/100] fix(i18n): translate validation model keys in 34 locales (#9857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider-connection dialog (AddApiKeyModal / EditConnectionModal) rendered humanized key names instead of real copy for providers.validationModelId{Label,Placeholder,Hint} in 34 of 43 locales — the values read "Validation Model Id Label", "Validation Model Id Placeholder" and "Validation Model Id Hint" verbatim. Each translation follows the terminology and register already used by the neighbouring provider keys in its own file — e.g. de Anbieter/API-Schlüssel with formal Sie, fr fournisseur/clé API, ru провайдер/ключ API — and each locale's own "e.g." convention (z. B., 例:, напр., ör., cth., hal.). Source of truth is en.json, which labels the field "Validation Model" (no "ID"); a few older locales say "validation model ID" and were left untouched rather than propagating that divergence. Co-authored-by: Mihaly Bodo --- src/i18n/messages/az.json | 6 +++--- src/i18n/messages/bg.json | 6 +++--- src/i18n/messages/bn.json | 6 +++--- src/i18n/messages/cs.json | 6 +++--- src/i18n/messages/da.json | 6 +++--- src/i18n/messages/de.json | 6 +++--- src/i18n/messages/fa.json | 6 +++--- src/i18n/messages/fi.json | 6 +++--- src/i18n/messages/fr.json | 6 +++--- src/i18n/messages/gu.json | 6 +++--- src/i18n/messages/he.json | 6 +++--- src/i18n/messages/hi.json | 6 +++--- src/i18n/messages/hu.json | 6 +++--- src/i18n/messages/id.json | 6 +++--- src/i18n/messages/in.json | 6 +++--- src/i18n/messages/it.json | 6 +++--- src/i18n/messages/ja.json | 6 +++--- src/i18n/messages/mr.json | 6 +++--- src/i18n/messages/ms.json | 6 +++--- src/i18n/messages/nl.json | 6 +++--- src/i18n/messages/no.json | 6 +++--- src/i18n/messages/phi.json | 6 +++--- src/i18n/messages/pt.json | 6 +++--- src/i18n/messages/ro.json | 6 +++--- src/i18n/messages/ru.json | 6 +++--- src/i18n/messages/sk.json | 6 +++--- src/i18n/messages/sv.json | 6 +++--- src/i18n/messages/sw.json | 6 +++--- src/i18n/messages/ta.json | 6 +++--- src/i18n/messages/te.json | 6 +++--- src/i18n/messages/th.json | 6 +++--- src/i18n/messages/tr.json | 6 +++--- src/i18n/messages/uk-UA.json | 6 +++--- src/i18n/messages/ur.json | 6 +++--- 34 files changed, 102 insertions(+), 102 deletions(-) diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 85cd05df69..3a7d0d7373 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "API açarını yoxlamaq üçün istifadə olunan model. Provayderin ilk mövcud modelindən istifadə etmək üçün boş buraxın.", + "validationModelIdLabel": "Doğrulama modeli", + "validationModelIdPlaceholder": "məs. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index e15faa33f1..d4c59fc718 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Модел, използван за проверка на API ключа. Оставете празно, за да се използва първият наличен модел на доставчика.", + "validationModelIdLabel": "Модел за валидиране", + "validationModelIdPlaceholder": "напр. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index de69fd9771..331fff05bf 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "API কী যাচাই করতে ব্যবহৃত মডেল। প্রোভাইডারের প্রথম উপলব্ধ মডেল ব্যবহার করতে ফাঁকা রাখুন।", + "validationModelIdLabel": "যাচাইকরণ মডেল", + "validationModelIdPlaceholder": "যেমন: meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index eb0f49c6d9..e3d2315410 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Model použitý k ověření API klíče. Ponechte prázdné, chcete-li použít první dostupný model poskytovatele.", + "validationModelIdLabel": "Ověřovací model", + "validationModelIdPlaceholder": "např. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 476d1dfe43..0ae8c385df 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Model, der bruges til at verificere API-nøglen. Lad feltet stå tomt for at bruge udbyderens første tilgængelige model.", + "validationModelIdLabel": "Valideringsmodel", + "validationModelIdPlaceholder": "f.eks. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 63cbbe5ef1..df452d6912 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Modell, das zur Überprüfung des API-Schlüssels verwendet wird. Leer lassen, um das erste verfügbare Modell des Anbieters zu verwenden.", + "validationModelIdLabel": "Validierungsmodell", + "validationModelIdPlaceholder": "z. B. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 61bd32b10b..895de26a98 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "مدلی که برای تأیید کلید API استفاده می‌شود. برای استفاده از اولین مدل موجود ارائه‌دهنده، خالی بگذارید.", + "validationModelIdLabel": "مدل اعتبارسنجی", + "validationModelIdPlaceholder": "مثلاً meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 01e7fac6b3..6c65e599d7 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Malli, jota käytetään API-avaimen vahvistamiseen. Jätä tyhjäksi, jos haluat käyttää tarjoajan ensimmäistä saatavilla olevaa mallia.", + "validationModelIdLabel": "Vahvistusmalli", + "validationModelIdPlaceholder": "esim. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 28c1654a35..c568a5b32c 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Modèle utilisé pour vérifier la clé API. Laissez vide pour utiliser le premier modèle disponible du fournisseur.", + "validationModelIdLabel": "Modèle de validation", + "validationModelIdPlaceholder": "ex. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 42927b6698..b99adbc2c3 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "API કી ચકાસવા માટે વપરાતું મૉડલ. પ્રદાતાના પ્રથમ ઉપલબ્ધ મૉડલનો ઉપયોગ કરવા માટે ખાલી છોડો.", + "validationModelIdLabel": "માન્યતા મૉડલ", + "validationModelIdPlaceholder": "દા.ત. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 16e43a1cf7..ceca60e989 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "המודל המשמש לאימות מפתח ה-API. השאר ריק כדי להשתמש במודל הזמין הראשון של הספק.", + "validationModelIdLabel": "מודל אימות", + "validationModelIdPlaceholder": "לדוגמה: meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index e7bd434e74..3605074737 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "एपीआई कुंजी सत्यापित करने के लिए उपयोग किया जाने वाला मॉडल। प्रदाता के पहले उपलब्ध मॉडल का उपयोग करने के लिए खाली छोड़ दें।", + "validationModelIdLabel": "सत्यापन मॉडल", + "validationModelIdPlaceholder": "जैसे: meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 551e06a8a8..7e8e68d731 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Az API-kulcs ellenőrzésére használt modell. Hagyja üresen a szolgáltató első elérhető modelljének használatához.", + "validationModelIdLabel": "Ellenőrző modell", + "validationModelIdPlaceholder": "pl. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 3b3bfac60c..eced2a779d 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Model yang digunakan untuk memverifikasi kunci API. Biarkan kosong untuk menggunakan model pertama yang tersedia dari penyedia.", + "validationModelIdLabel": "Model validasi", + "validationModelIdPlaceholder": "mis. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index d6d46ed7f4..f3726d5e18 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Model yang digunakan untuk memverifikasi kunci API. Biarkan kosong untuk menggunakan model pertama yang tersedia dari penyedia.", + "validationModelIdLabel": "Model validasi", + "validationModelIdPlaceholder": "mis. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 2a22929734..e290e69fed 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Modello utilizzato per verificare la chiave API. Lascia vuoto per utilizzare il primo modello disponibile del provider.", + "validationModelIdLabel": "Modello di convalida", + "validationModelIdPlaceholder": "es. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index e957447e5e..c717e0cb85 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "API キーの検証に使用するモデル。プロバイダーの最初に利用可能なモデルを使用する場合は空白のままにしてください。", + "validationModelIdLabel": "検証モデル", + "validationModelIdPlaceholder": "例: meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 31d64243c7..95b7611515 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "API की सत्यापित करण्यासाठी वापरले जाणारे मॉडेल. प्रदात्याचे पहिले उपलब्ध मॉडेल वापरण्यासाठी रिकामे सोडा.", + "validationModelIdLabel": "प्रमाणीकरण मॉडेल", + "validationModelIdPlaceholder": "उदा. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 8ae01a1459..aaa95e15b7 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Model yang digunakan untuk mengesahkan kunci API. Biarkan kosong untuk menggunakan model pertama yang tersedia daripada penyedia.", + "validationModelIdLabel": "Model pengesahan", + "validationModelIdPlaceholder": "cth. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index c10ad70f15..2bdd67709c 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Model dat wordt gebruikt om de API-sleutel te verifiëren. Laat leeg om het eerste beschikbare model van de provider te gebruiken.", + "validationModelIdLabel": "Validatiemodel", + "validationModelIdPlaceholder": "bijv. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 1df7fa6d25..50a958e465 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Modell som brukes til å verifisere API-nøkkelen. La stå tomt for å bruke leverandørens første tilgjengelige modell.", + "validationModelIdLabel": "Valideringsmodell", + "validationModelIdPlaceholder": "f.eks. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 14f279d5d8..82d38a576a 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Modelong ginagamit para i-verify ang API key. Iwanang blangko para gamitin ang unang available na model ng provider.", + "validationModelIdLabel": "Modelo ng validation", + "validationModelIdPlaceholder": "hal. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 9d365c01fe..587159dfcb 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Modelo utilizado para verificar a chave API. Deixe em branco para utilizar o primeiro modelo disponível do fornecedor.", + "validationModelIdLabel": "Modelo de validação", + "validationModelIdPlaceholder": "ex. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index de5118d6c4..c124911355 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Modelul utilizat pentru a verifica cheia API. Lăsați necompletat pentru a utiliza primul model disponibil al furnizorului.", + "validationModelIdLabel": "Model de validare", + "validationModelIdPlaceholder": "de ex. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index caa3fcf282..b90f8d8e71 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Модель, используемая для проверки ключа API. Оставьте пустым, чтобы использовать первую доступную модель провайдера.", + "validationModelIdLabel": "Модель для проверки", + "validationModelIdPlaceholder": "например, meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 4275e80a6e..1e136271ad 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Model použitý na overenie kľúča API. Ponechajte prázdne, ak chcete použiť prvý dostupný model poskytovateľa.", + "validationModelIdLabel": "Overovací model", + "validationModelIdPlaceholder": "napr. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 49e35830a3..7a50f7a4f2 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Modell som används för att verifiera API-nyckeln. Lämna tomt för att använda leverantörens första tillgängliga modell.", + "validationModelIdLabel": "Valideringsmodell", + "validationModelIdPlaceholder": "t.ex. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index 88ed5c77e0..b2272f46ee 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Modeli inayotumika kuthibitisha ufunguo wa API. Acha wazi ili kutumia modeli ya kwanza inayopatikana ya mtoa huduma.", + "validationModelIdLabel": "Modeli ya uthibitishaji", + "validationModelIdPlaceholder": "k.m. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index dad027ca0c..9ba60064f6 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "API விசையைச் சரிபார்க்கப் பயன்படுத்தப்படும் மாடல். வழங்குநரின் முதல் கிடைக்கக்கூடிய மாடலைப் பயன்படுத்த காலியாக விடவும்.", + "validationModelIdLabel": "சரிபார்ப்பு மாடல்", + "validationModelIdPlaceholder": "எ.கா. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index b55ec028c3..a68d9284e5 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "API కీని ధృవీకరించడానికి ఉపయోగించే మోడల్. ప్రొవైడర్ యొక్క మొదటి అందుబాటులో ఉన్న మోడల్‌ను ఉపయోగించడానికి ఖాళీగా ఉంచండి.", + "validationModelIdLabel": "ధృవీకరణ మోడల్", + "validationModelIdPlaceholder": "ఉదా. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index a7bb240713..c91fa79b03 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "โมเดลที่ใช้ตรวจสอบคีย์ API เว้นว่างไว้เพื่อใช้โมเดลแรกที่พร้อมใช้งานของผู้ให้บริการ", + "validationModelIdLabel": "โมเดลสำหรับตรวจสอบ", + "validationModelIdPlaceholder": "เช่น meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index bc2a638914..b30cbf320d 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "API anahtarını doğrulamak için kullanılan model. Sağlayıcının ilk kullanılabilir modelini kullanmak için boş bırakın.", + "validationModelIdLabel": "Doğrulama modeli", + "validationModelIdPlaceholder": "ör. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 82e09c6388..37ea7c0728 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "Модель, яка використовується для перевірки ключа API. Залиште порожнім, щоб використовувати першу доступну модель провайдера.", + "validationModelIdLabel": "Модель для перевірки", + "validationModelIdPlaceholder": "напр. meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index d557e392e0..4f42844bda 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -5619,9 +5619,9 @@ "totalKeysRotating": "{count, plural, one {1 key rotating} other {# keys rotating}}", "unhideModel": "Unhide Model", "upstreamProxyProviders": "Upstream Proxy Providers", - "validationModelIdHint": "Validation Model Id Hint", - "validationModelIdLabel": "Validation Model Id Label", - "validationModelIdPlaceholder": "Validation Model Id Placeholder", + "validationModelIdHint": "API کلید کی توثیق کے لیے استعمال ہونے والا ماڈل۔ فراہم کنندہ کا پہلا دستیاب ماڈل استعمال کرنے کے لیے خالی چھوڑ دیں۔", + "validationModelIdLabel": "توثیقی ماڈل", + "validationModelIdPlaceholder": "مثلاً meta-llama/llama-3.1-8b-instruct", "vertexServiceAccountPlaceholder": "Paste your Service Account JSON ({\"type\":\"service_account\",\"project_id\":\"…\",\"client_email\":\"…\",\"private_key\":\"…\"}) or an OAuth access_token", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", From c4c39b1a4a981e48a5d2f7ca6bc1fdaa13f1df0a Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:52:42 -0300 Subject: [PATCH 047/100] cherry-pick(pr-9770): chore(repo): ignore Electron build output unpacked into repo root (#9858) * chore(repo): ignore Electron build output unpacked into repo root electron-builder (squirrel-windows target) unpacks the packaged app -- the entire Chromium runtime, ~24k files -- directly into the repository root: OmniRoute.exe, chrome_*.pak, *.dll, locales/, resources/, icudtl.dat, snapshot blobs and the Chromium license files. None of it was covered by .gitignore, so `git add -A` would commit the whole runtime. Every rule is root-anchored (leading `/`) because a bare `locales/` or `resources/` would also swallow tracked sources -- notably the CLI translations in bin/cli/locales/*.json. Verified with `git check-ignore`: all artifact paths ignored, and bin/cli/locales/{en,de}.json remain tracked. * chore(electron): sync package-lock for windows installer deps Adds the lockfile entries for the Windows installer/signing toolchain that the electron build now pulls in: electron-builder-squirrel-windows, electron-winstaller and @electron/windows-sign (plus their transitive fs-extra/jsonfile/universalify/mkdirp pins), and bumps app-builder-lib and builder-util-runtime. Lockfile-only change; no source or runtime behaviour is affected. --------- Co-authored-by: Mihaly Bodo --- .gitignore | 20 +++ electron/package-lock.json | 245 +++++++++++++++++++++++++++++++++---- 2 files changed, 240 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index f2738f3aa7..138ada8a89 100644 --- a/.gitignore +++ b/.gitignore @@ -221,6 +221,26 @@ CODEX-SETUP-PROMPT.md # Quality ratchet — métricas efêmeras (baseline commitado em config/quality/; métricas não) config/quality/quality-metrics.json +# Electron desktop build output unpacked into the repo root. +# `electron-builder` (squirrel-windows target) unpacks the packaged app — the +# entire Chromium runtime, ~24k files — directly into the repository root. +# Every rule below is ROOT-ANCHORED (leading `/`) on purpose: a bare `locales/` +# or `resources/` would also swallow tracked sources such as the CLI +# translations in `bin/cli/locales/*.json`. +/OmniRoute.exe +/Uninstall OmniRoute.exe +/uninstallerIcon.ico +/locales/ +/resources/ +/*.pak +/*.dll +/icudtl.dat +/snapshot_blob.bin +/v8_context_snapshot.bin +/vk_swiftshader_icd.json +/LICENSE.electron.txt +/LICENSES.chromium.html + # Runtime logs (diretório local, nunca versionado) /logs/ -home-diegosouzapw-dev-automações-bots-yt-downloader-20260504 .txt diff --git a/electron/package-lock.json b/electron/package-lock.json index 7909fcd7ec..4fdb5b2374 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -55,9 +55,9 @@ "license": "MIT" }, "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -257,9 +257,9 @@ "license": "MIT" }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -297,6 +297,45 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -835,16 +874,16 @@ "optional": true }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/buffer-from": { @@ -1091,6 +1130,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1260,9 +1308,9 @@ "license": "MIT" }, "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -1411,6 +1459,19 @@ "node": ">=14.0.0" } }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" + } + }, "node_modules/electron-publish": { "version": "26.15.3", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", @@ -1445,6 +1506,66 @@ "tiny-typed-emitter": "^2.1.0" } }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1627,9 +1748,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -1792,9 +1913,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -2113,9 +2234,9 @@ } }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "funding": [ { "type": "github", @@ -2359,6 +2480,20 @@ "node": ">= 18" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2622,6 +2757,36 @@ "node": ">=18" } }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -2816,6 +2981,21 @@ "node": ">= 4" } }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -3045,9 +3225,9 @@ } }, "node_modules/tar": { - "version": "7.5.20", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", - "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -3071,6 +3251,21 @@ "node": ">=18" } }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/temp-file": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", From a1833b11596064ed466ff7a69532048e0107e2ea Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:52:48 -0300 Subject: [PATCH 048/100] fix(skills): normalize web fetch credentials (#9859) Co-authored-by: backryun --- src/lib/skills/webFetchExecution.ts | 21 +++++++++++++- .../web-fetch-execution-credentials.test.ts | 28 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/unit/web-fetch-execution-credentials.test.ts diff --git a/src/lib/skills/webFetchExecution.ts b/src/lib/skills/webFetchExecution.ts index d0cce51bb6..e2b4188c19 100644 --- a/src/lib/skills/webFetchExecution.ts +++ b/src/lib/skills/webFetchExecution.ts @@ -61,11 +61,30 @@ function resolvePinnedBackend(input: ExecuteWebFetchInput): WebFetchProviderId | return backend ? FETCH_BACKEND_TO_PROVIDER[backend] : undefined; } +export function normalizeWebFetchCredentials(value: unknown): WebFetchCredentials | null { + if (!value || typeof value !== "object") return null; + const credentials = value as Record; + if (credentials.allRateLimited === true || credentials.allExpired === true) return null; + + const providerSpecificData = + credentials.providerSpecificData && + typeof credentials.providerSpecificData === "object" && + !Array.isArray(credentials.providerSpecificData) + ? (credentials.providerSpecificData as Record) + : undefined; + + return { + ...(typeof credentials.apiKey === "string" && { apiKey: credentials.apiKey }), + ...(typeof credentials.baseUrl === "string" && { baseUrl: credentials.baseUrl }), + ...(providerSpecificData && { providerSpecificData }), + }; +} + async function resolveCredentials( providerId: WebFetchProviderId ): Promise { try { - return (await getProviderCredentialsWithQuotaPreflight(providerId)) ?? null; + return normalizeWebFetchCredentials(await getProviderCredentialsWithQuotaPreflight(providerId)); } catch { return null; } diff --git a/tests/unit/web-fetch-execution-credentials.test.ts b/tests/unit/web-fetch-execution-credentials.test.ts new file mode 100644 index 0000000000..8a004a14eb --- /dev/null +++ b/tests/unit/web-fetch-execution-credentials.test.ts @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { normalizeWebFetchCredentials } = await import("../../src/lib/skills/webFetchExecution.ts"); + +test("web-fetch skills reject unavailable credential sentinels", () => { + assert.equal( + normalizeWebFetchCredentials({ allRateLimited: true, retryAfter: "tomorrow" }), + null + ); + assert.equal(normalizeWebFetchCredentials({ allExpired: true, expiredCount: 2 }), null); +}); + +test("web-fetch skills expose only the credential fields used by fetch executors", () => { + assert.deepEqual( + normalizeWebFetchCredentials({ + apiKey: "secret", + baseUrl: "https://fetch.example.test", + providerSpecificData: { region: "test" }, + accessToken: "must-not-leak-through", + }), + { + apiKey: "secret", + baseUrl: "https://fetch.example.test", + providerSpecificData: { region: "test" }, + } + ); +}); From a7d2dba1eb7722ceadb5100a174c6c7d42cf551f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:52:54 -0300 Subject: [PATCH 049/100] fix(types): narrow DeepSeek tool calls (#9860) Co-authored-by: backryun --- open-sse/executors/deepseek-web.ts | 6 +++--- tests/unit/deepseek-web-tool-result-prompt-4712.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/open-sse/executors/deepseek-web.ts b/open-sse/executors/deepseek-web.ts index 99fda068af..4f3314ca8d 100644 --- a/open-sse/executors/deepseek-web.ts +++ b/open-sse/executors/deepseek-web.ts @@ -515,7 +515,6 @@ export function messagesToPrompt( historyWindow = 0 ): string { if (messages.length === 0) return ""; - const systemParts: string[] = []; const conversation: Array<{ role: string; text: string }> = []; const callNameById = new Map(); @@ -527,8 +526,9 @@ export function messagesToPrompt( } else if (m.role === "user" || m.role === "assistant") { if (text) conversation.push({ role: m.role, text }); if (m.role === "user") lastUserContent = text; - const calls = Array.isArray((m as { tool_calls?: unknown }).tool_calls) - ? (m as { tool_calls: Array<{ id?: string; function?: { name?: string } }> }).tool_calls + const toolCalls = (m as { tool_calls?: unknown }).tool_calls; + const calls = Array.isArray(toolCalls) + ? (toolCalls as Array<{ id?: string; function?: { name?: string } }>) : []; for (const c of calls) { if (c?.id && typeof c.function?.name === "string") callNameById.set(c.id, c.function.name); diff --git a/tests/unit/deepseek-web-tool-result-prompt-4712.test.ts b/tests/unit/deepseek-web-tool-result-prompt-4712.test.ts index 38136f270d..eed0311b8c 100644 --- a/tests/unit/deepseek-web-tool-result-prompt-4712.test.ts +++ b/tests/unit/deepseek-web-tool-result-prompt-4712.test.ts @@ -64,3 +64,11 @@ test("messagesToPrompt still drops empty tool results without crashing (#4712)", assert.match(prompt, /hello/); assert.match(prompt, /world/); }); + +test("messagesToPrompt ignores malformed assistant tool calls", () => { + const prompt = messagesToPrompt([ + { role: "assistant", content: "thinking", tool_calls: { id: "not-an-array" } }, + { role: "user", content: "continue" }, + ]); + assert.match(prompt, /continue/); +}); From efbc7a7ba20d97cbf246d96a588aae75db0a7236 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:52:59 -0300 Subject: [PATCH 050/100] fix(perf): memoize synced pricing reads (#9861) Co-authored-by: chloeassistant <279834366+chloeassistant@users.noreply.github.com> --- src/lib/pricingSync.ts | 21 ++++++- tests/unit/pricing-sync-memoization.test.ts | 68 +++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tests/unit/pricing-sync-memoization.test.ts diff --git a/src/lib/pricingSync.ts b/src/lib/pricingSync.ts index d6faa3d060..e331439eb6 100644 --- a/src/lib/pricingSync.ts +++ b/src/lib/pricingSync.ts @@ -11,7 +11,7 @@ */ import { getDbInstance } from "./db/core"; -import { invalidateDbCache } from "./db/readCache"; +import { invalidateDbCache, getModelCatalogCacheVersion } from "./db/readCache"; import { backupDbFile } from "./db/backup"; // ─── Types ─────────────────────────────────────────────── @@ -232,10 +232,27 @@ function toRecord(value: unknown): Record { return value && typeof value === "object" ? (value as Record) : {}; } +// getSyncedPricing() re-ran the SELECT + JSON.parse of the pricing_synced +// blobs on every call — resolveCatalogPricing() calls it per model lookup, so +// each call rebuilt a fresh object and findInsensitive() (WeakMap keyed by +// object identity) rebuilt its lowercase index per lookup, emitting hundreds +// of 'case-insensitive key collision' warnings per second and pinning CPU. +// Memoized here, invalidated via the same modelCatalogCacheVersion signal +// saveSyncedPricing/clearSyncedPricing already bump through +// invalidateDbCache("pricing") — mirrors getModelsDevPricing() in +// modelsDevSync.ts. +let pricingMemo: PricingByProvider | null = null; +let pricingMemoVersion = -1; // -1: never equals a real cacheVersion (starts at 0), guarantees a miss on the first call + /** * Read synced pricing from `pricing_synced` namespace. */ export function getSyncedPricing(): PricingByProvider { + const currentVersion = getModelCatalogCacheVersion(); + if (pricingMemo !== null && pricingMemoVersion === currentVersion) { + return pricingMemo; + } + const db = getDbInstance(); const rows = db .prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing_synced'") @@ -252,6 +269,8 @@ export function getSyncedPricing(): PricingByProvider { console.warn(`[PRICING_SYNC] Corrupted data for provider "${key}", skipping`); } } + pricingMemo = synced; + pricingMemoVersion = currentVersion; return synced; } diff --git a/tests/unit/pricing-sync-memoization.test.ts b/tests/unit/pricing-sync-memoization.test.ts new file mode 100644 index 0000000000..0554229795 --- /dev/null +++ b/tests/unit/pricing-sync-memoization.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { describe, it, before, after, mock } from "node:test"; +import { getDbInstance } from "../../src/lib/db/core.ts"; +import { + getSyncedPricing, + saveSyncedPricing, + clearSyncedPricing, +} from "../../src/lib/pricingSync.ts"; + +describe("getSyncedPricing memoization", () => { + before(() => { + saveSyncedPricing({ + openai: { + "gpt-4o": { input: 2.5, output: 10 }, + }, + }); + }); + + after(() => { + try { + clearSyncedPricing(); + } catch { + // ignore + } + }); + + it("returns the same object reference for repeated reads within the same cache version", () => { + const first = getSyncedPricing(); + const second = getSyncedPricing(); + const third = getSyncedPricing(); + // The saturation bug rebuilt a fresh object on every call; resolveCatalogPricing() + // calls this per model, so a fresh object per call re-ran the SELECT + JSON.parse + // and rebuilt the findInsensitive() lowercase index per lookup (~400 warnings/s). + assert.equal(second, first); + assert.equal(third, first); + }); + + it("hits the DB once for repeated reads within the same cache version", () => { + const db = getDbInstance(); + const prepareSpy = mock.method(db, "prepare"); + const callsBefore = prepareSpy.mock.calls.length; + + getSyncedPricing(); + getSyncedPricing(); + getSyncedPricing(); + + const callsAfter = prepareSpy.mock.calls.length; + prepareSpy.mock.restore(); + + // Memoized, 3 calls should cost at most 1 real DB round-trip (0 if a prior + // test already warmed the cache at the same version). + assert.ok( + callsAfter - callsBefore <= 1, + `expected at most 1 db.prepare() call across 3 reads, got ${callsAfter - callsBefore}` + ); + }); + + it("returns a new reference with fresh data after a pricing write invalidates the cache", () => { + const warm = getSyncedPricing(); // warm the memo at the current cache version + saveSyncedPricing({ + anthropic: { "claude-x": { input: 1, output: 2 } }, + }); + const pricing = getSyncedPricing(); + assert.notEqual(pricing, warm, "invalidation must rebuild, not reuse the stale object"); + assert.ok(pricing.anthropic, "cache should reflect the write, not a stale snapshot"); + assert.equal(pricing.anthropic["claude-x"].input, 1); + }); +}); From a448b146bf0360697312ac7d431e3bc7fa0fdb09 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:53:07 -0300 Subject: [PATCH 051/100] cherry-pick(pr-9744): test(integration): add general live-test tool for the real "default" combo + rootless wire capture (#9862) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(integration): add general live-test tool for the real "default" combo Temporary WIP commit on this deferred branch — lands in its own separate PR once the bug-fix extraction batch is done (never bundled into a bug-fix PR). Unlike liveGeminiShared.ts (provisions its own narrow 2-model Gemini-only combo), this reads the REAL "default" combo currently configured on the target instance directly from the DB and exercises every provider/model step in it directly, bypassing combo routing, so live-test coverage always matches whatever is actually configured instead of a hardcoded snapshot. Live-verified against omniroute-beta (seeded with the real 18-model, 5-provider default combo): 14/18 models pass consistently across non-streaming + streaming Chat Completions and streaming Responses API. The 4 consistent failures are real external state (cerebras credits_exhausted, one deprecated openrouter free-tier model), not code regressions. (cherry picked from commit c40b13a48fd897259c56f5122e9e57a3dc7654ba) * test(integration): add rootless wire-capture correlation to the live-test tool Temporary WIP commit on this deferred branch — lands in the same final live-test-tool PR as the general default-combo suite, never bundled into a bug-fix PR. liveContainerHarness.ts spins up a dedicated, throwaway podman container (same runner-base image target as the operator's local dev/beta containers) so wire-capture tests are fully self-contained: builds the image if missing, starts the container with a persistent data dir, waits for health, seeds the real "default" combo + provider connections from the operator's local omniroute-dev instance (idempotent — only runs once per data dir), and provisions API keys via the running instance's own auth flow. wireCapture.ts captures the container's actual network traffic via `podman unshare nsenter --net= -- tcpdump` — no root needed, verified working live (this generalizes the root-requiring `sudo nsenter -t $PID` command scripts/sre/tcp-close-analyzer.py already documented for the same rootless-Podman netns problem; that script's docstring now documents both). Capture and analysis needed two real fixes found only by running the pipeline live: `-U` (unbuffered tcpdump writes) plus a `pkill -f ` fallback, since `podman unshare -> nsenter -> tcpdump` is a 3-level subprocess chain and SIGTERM to the top-level process doesn't reach the tcpdump grandchild, leaving an orphaned process and a truncated/unreadable pcap; and filtering on the container's internal listening port (20128) rather than the dynamically-assigned host port, since capture happens inside the container's own network namespace where only the internal port is meaningful. live-default-combo-wire-capture.test.ts (gated on RUN_LIVE_WIRE_CAPTURE=1) ties it together: sends a small representative sample of requests through the real default combo, then cross-checks each one's app-level JSON status against the actual HTTP status line observed on the wire via scripts/sre/tcp-close-analyzer.py's stream reassembly — catching bugs where the app layer claims success but the wire shows a truncated/reset stream, not just what liveDefaultComboShared.ts's existing breadth suite already covers. Live-verified end-to-end: 4/4 sampled requests correlated correctly across 8 captured TCP streams, container + capture process fully torn down afterward (verified no orphaned podman container or tcpdump process left running). sendModelRequest/filterActiveModelTargets (liveDefaultComboShared.ts) gain optional baseUrl/apiKey overrides, defaulting to the existing module-level omniroute-beta target, so the wire-capture suite can point the same request-sending logic at its own dedicated container instead. (cherry picked from commit 914a7e42cbe914f257db9f72eedc902ee1532083) --------- Co-authored-by: Markus Hartung --- scripts/sre/tcp-close-analyzer.py | 22 +- .../live-default-combo-wire-capture.test.ts | 143 ++++++++++ .../live-default-combo-workload.test.ts | 113 ++++++++ tests/integration/liveContainerHarness.ts | 260 +++++++++++++++++ tests/integration/liveDefaultComboShared.ts | 266 ++++++++++++++++++ tests/integration/wireCapture.ts | 154 ++++++++++ 6 files changed, 956 insertions(+), 2 deletions(-) create mode 100644 tests/integration/live-default-combo-wire-capture.test.ts create mode 100644 tests/integration/live-default-combo-workload.test.ts create mode 100644 tests/integration/liveContainerHarness.ts create mode 100644 tests/integration/liveDefaultComboShared.ts create mode 100644 tests/integration/wireCapture.ts diff --git a/scripts/sre/tcp-close-analyzer.py b/scripts/sre/tcp-close-analyzer.py index 77f489799b..6b01ad2034 100755 --- a/scripts/sre/tcp-close-analyzer.py +++ b/scripts/sre/tcp-close-analyzer.py @@ -17,8 +17,8 @@ parsing the libpcap file format and IPv4/TCP headers directly. Good enough for this one question; not a general-purpose pcap toolkit. ──────────────────────────────────────────────────────────────────────────── -CAPTURING (run this yourself — needs root/sudo for CAP_NET_RAW; also see ---show-capture-cmd) +CAPTURING (run this yourself — needs root/sudo for CAP_NET_RAW, UNLESS you +use the rootless method below; also see --show-capture-cmd) ──────────────────────────────────────────────────────────────────────────── Rootless Podman gotcha: there is usually NO `podman3`/`podmanN` bridge @@ -33,6 +33,24 @@ container's OWN namespace via its PID instead: sudo nsenter -t "$PID" -n tcpdump -i any -w /tmp/omniroute-capture.pcap \\ 'host and port 20128' +Rootless alternative (NO sudo needed): a bare `nsenter -t $PID -n` fails +with "Invalid argument" for a rootless container, because its network +namespace lives inside a user namespace you're not in yet. `podman unshare` +puts you in that same user namespace first, so `nsenter --net=` against the +container's netns path succeeds as a plain user — verified working live +(captured a real `POST /v1/chat/completions` request body in cleartext this +way, no root at any point): + + NETNS=$(podman inspect omniroute-dev --format '{{.NetworkSettings.SandboxKey}}') + podman unshare nsenter --net="$NETNS" -- \\ + tcpdump -i any -w /tmp/omniroute-capture.pcap 'port 20128' + +No `sudo chmod` needed afterward either, since the file was never +root-owned. This is also what +tests/integration/wireCapture.ts + liveContainerHarness.ts automate for the +live wire-capture test suite (its own dedicated throwaway container, not +omniroute-dev) — see RUN_LIVE_WIRE_CAPTURE=1 in that test file. + Find the container's IP first with: podman inspect omniroute-dev --format '{{.NetworkSettings.Networks}}' diff --git a/tests/integration/live-default-combo-wire-capture.test.ts b/tests/integration/live-default-combo-wire-capture.test.ts new file mode 100644 index 0000000000..d1454ac012 --- /dev/null +++ b/tests/integration/live-default-combo-wire-capture.test.ts @@ -0,0 +1,143 @@ +/** + * tests/integration/live-default-combo-wire-capture.test.ts + * + * Wire-level correlation test. Spins up a dedicated, throwaway podman + * container (liveContainerHarness.ts), captures its network traffic + * (wireCapture.ts — rootless tcpdump via `podman unshare nsenter`, no root), + * sends a representative sample of requests against the real "default" + * combo, then cross-checks each request's app-level result (JSON status) + * against what actually went out on the wire (HTTP response status line, + * verdict on who closed the connection first). Catches bugs where the app + * layer claims success but the wire shows a truncated/reset stream. + * + * Fully self-contained — does not touch omniroute-beta or omniroute-dev + * (only reads from omniroute-dev's DB once, to seed its own dedicated + * container's data dir). Gated on RUN_LIVE_WIRE_CAPTURE=1: needs podman, + * tcpdump, python3, and a real .env with provider credentials, so it must + * never run in CI. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + LIVE_CONTAINER_ENABLED, + startLiveContainer, + type LiveContainerHandle, +} from "./liveContainerHarness.ts"; +import { + startWireCapture, + analyzeCapture, + indexByCorrelationId, + responseStatusLine, + type CaptureHandle, +} from "./wireCapture.ts"; +import { + getDefaultComboModelTargets, + filterActiveModelTargets, + sendModelRequest, +} from "./liveDefaultComboShared.ts"; + +const skip = !LIVE_CONTAINER_ENABLED + ? "RUN_LIVE_WIRE_CAPTURE not set — skipping wire-capture live test" + : undefined; + +// Wire-level correlation is the point of this suite, not breadth across +// every provider (already covered by live-default-combo-workload.test.ts) — +// keep the sample small so capture/analysis stays fast. +const SAMPLE_SIZE = 4; + +let container: LiveContainerHandle; +let capture: CaptureHandle; + +test.before(async () => { + if (skip) return; + container = await startLiveContainer(); + process.env.DATA_DIR = container.dataDir; + + // PID-scoped so a concurrent session running this same test never + // collides on the capture file or the pkill-by-path cleanup in + // wireCapture.ts's stop(). + const pcapPath = `/tmp/omniroute-live-wire-capture-${process.pid}.pcap`; + // Capture happens INSIDE the container's own netns (podman unshare + // nsenter --net=), so packets there are addressed to the + // container's internal listening port (20128), not the dynamically + // assigned host port used to reach it from outside — filtering on + // hostPort here would silently match nothing. + capture = await startWireCapture(container.netnsPath, pcapPath, "tcp port 20128"); +}); + +test.after(async () => { + if (skip) return; + await capture?.stop(); + await container?.stop(); +}); + +test( + "wire capture: app-level status matches the HTTP status line actually observed on the wire", + { skip }, + async () => { + const allTargets = await getDefaultComboModelTargets(); + assert.ok(allTargets.length > 0, `"default" combo has no model steps — nothing to test`); + + const { active } = await filterActiveModelTargets(allTargets, { + baseUrl: container.baseUrl, + apiKey: container.managementApiKey, + }); + assert.ok(active.length > 0, "no active provider connections in the seeded container"); + + const sample = active.slice(0, SAMPLE_SIZE); + console.log( + `\n [wire-capture] sampling ${sample.length} model(s): ${sample.map((t) => t.model).join(", ")}` + ); + + const results = await Promise.all( + sample.map((t) => + sendModelRequest(t.model, false, "chat", { + baseUrl: container.baseUrl, + apiKey: container.apiKey, + }) + ) + ); + + // Give the capture a moment to flush the last packets before analyzing. + await new Promise((r) => setTimeout(r, 1000)); + await capture.stop(); + const streams = await analyzeCapture(capture.pcapPath); + const byCorrelationId = indexByCorrelationId(streams); + + console.log(` [wire-capture] captured ${streams.length} TCP stream(s)`); + + const mismatches: string[] = []; + for (const r of results) { + if (r.correlationId === "?") { + mismatches.push(`${r.model}: no correlationId returned in response headers`); + continue; + } + const matched = byCorrelationId.get(r.correlationId); + if (!matched || matched.length === 0) { + mismatches.push( + `${r.model}: correlationId ${r.correlationId} not found in any captured wire stream` + ); + continue; + } + const wireStatusLines = matched.map(responseStatusLine).filter(Boolean); + const wireStatusCodes = wireStatusLines.map((line) => line!.split(" ")[1]); + if (!wireStatusCodes.includes(String(r.status))) { + mismatches.push( + `${r.model}: app-level status ${r.status} but wire shows ${wireStatusCodes.join(",") || "no status line"} (cid ${r.correlationId})` + ); + } + } + + if (mismatches.length > 0) { + console.log(`\n Wire/app-level mismatches (${mismatches.length}):`); + for (const m of mismatches) console.log(` ${m}`); + } + + assert.equal( + mismatches.length, + 0, + `${mismatches.length}/${results.length} requests had app-level results that don't match what was observed on the wire` + ); + } +); diff --git a/tests/integration/live-default-combo-workload.test.ts b/tests/integration/live-default-combo-workload.test.ts new file mode 100644 index 0000000000..1e502f278b --- /dev/null +++ b/tests/integration/live-default-combo-workload.test.ts @@ -0,0 +1,113 @@ +/** + * tests/integration/live-default-combo-workload.test.ts + * + * General breadth test against the REAL, currently-configured "default" + * combo on the target instance — unlike live-gemini-workload.test.ts (which + * provisions its own narrow 2-model Gemini-only combo), this targets every + * provider/model step the operator actually has in "default" directly, + * bypassing combo routing. One request per configured model: non-streaming + * + streaming Chat Completions, and streaming Responses API. Skips (never + * fails) any model whose provider connection isn't currently active, so one + * unrelated provider outage doesn't block the rest of the run. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + skip, + getDefaultComboModelTargets, + filterActiveModelTargets, + sendModelRequest, +} from "./liveDefaultComboShared.ts"; + +let modelNames: string[] = []; + +test.before(async () => { + if (skip) return; + const targets = await getDefaultComboModelTargets(); + assert.ok(targets.length > 0, `"default" combo has no model steps — nothing to test`); + + const { active, skipped } = await filterActiveModelTargets(targets); + if (skipped.length > 0) { + console.log(`\n [setup] skipping ${skipped.length} model(s) with inactive provider:`); + for (const s of skipped) console.log(` - ${s}`); + } + + modelNames = active.map((t) => t.model); + console.log(`\n [setup] testing ${modelNames.length} model(s) from the live "default" combo`); +}); + +test( + "[32] default combo: non-streaming chat completions across every configured model", + { skip }, + async () => { + const failures: string[] = []; + for (const model of modelNames) { + const r = await sendModelRequest(model, false, "chat"); + if (r.status !== 200 || r.contentLength === 0) { + failures.push( + `${model}: HTTP ${r.status}${r.error ? ` (${r.error})` : ""}, ${r.contentLength} chars` + ); + } + } + if (failures.length > 0) { + console.log(`\n Non-streaming failures (${failures.length}/${modelNames.length}):`); + for (const f of failures) console.log(` ${f}`); + } + assert.equal( + failures.length, + 0, + `${failures.length}/${modelNames.length} models failed non-streaming chat` + ); + } +); + +test( + "[33] default combo: streaming chat completions across every configured model", + { skip }, + async () => { + const failures: string[] = []; + for (const model of modelNames) { + const r = await sendModelRequest(model, true, "chat"); + if (r.status !== 200 || r.contentLength === 0) { + failures.push( + `${model}: HTTP ${r.status}${r.error ? ` (${r.error})` : ""}, ${r.contentLength} chars` + ); + } + } + if (failures.length > 0) { + console.log(`\n Streaming failures (${failures.length}/${modelNames.length}):`); + for (const f of failures) console.log(` ${f}`); + } + assert.equal( + failures.length, + 0, + `${failures.length}/${modelNames.length} models failed streaming chat` + ); + } +); + +test( + "[34] default combo: streaming responses API across every configured model", + { skip }, + async () => { + const failures: string[] = []; + for (const model of modelNames) { + const r = await sendModelRequest(model, true, "responses"); + if (r.status !== 200 || r.contentLength === 0) { + failures.push( + `${model}: HTTP ${r.status}${r.error ? ` (${r.error})` : ""}, ${r.contentLength} chars` + ); + } + } + if (failures.length > 0) { + console.log(`\n Responses API failures (${failures.length}/${modelNames.length}):`); + for (const f of failures) console.log(` ${f}`); + } + assert.equal( + failures.length, + 0, + `${failures.length}/${modelNames.length} models failed streaming Responses API` + ); + } +); diff --git a/tests/integration/liveContainerHarness.ts b/tests/integration/liveContainerHarness.ts new file mode 100644 index 0000000000..168fa2d211 --- /dev/null +++ b/tests/integration/liveContainerHarness.ts @@ -0,0 +1,260 @@ +/** + * tests/integration/liveContainerHarness.ts + * + * Spins up a dedicated, throwaway podman container running this checkout's + * own code (runner-base target, same as the operator's local dev/beta + * containers) so wire-capture live tests are fully self-contained — no + * dependency on a manually-managed systemd quadlet. + * + * The container's DATA_DIR is a persistent host directory (not wiped between + * runs) so the "default" combo + real provider connections only need + * seeding once; seeding is idempotent and copies from the operator's local + * omniroute-dev instance (same source used for the manual omniroute-beta + * seed earlier this session). + */ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import Database from "better-sqlite3"; + +const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); + +export const LIVE_CONTAINER_ENABLED = process.env.RUN_LIVE_WIRE_CAPTURE === "1"; + +const IMAGE_TAG = process.env.LIVE_CONTAINER_IMAGE || "localhost/omniroute:live-wire-test"; +const CONTAINER_NAME = process.env.LIVE_CONTAINER_NAME || "omniroute-live-wire-test"; +const DATA_DIR_HOST = + process.env.LIVE_CONTAINER_DATA_DIR || "/data/podman-data/omniroute-live-wire-test/data"; +const ENV_FILE = process.env.LIVE_CONTAINER_ENV_FILE || "/data/podman-data/omniroute/omniroute.env"; +// Source DB to seed the "default" combo + provider connections from — the +// operator's local omniroute-dev instance, same source used for the manual +// omniroute-beta seed earlier this session. +const SEED_SOURCE_DB = + process.env.LIVE_CONTAINER_SEED_SOURCE_DB || + "/home/markus/code/podman/OmniRoute/data/storage.sqlite"; +const SEED_PROVIDERS = ["gemini", "openrouter", "mistral", "cerebras"]; + +export interface LiveContainerHandle { + baseUrl: string; + apiKey: string; + managementApiKey: string; + containerName: string; + netnsPath: string; + hostPort: number; + dataDir: string; + stop(): Promise; +} + +function run(cmd: string, args: string[], opts: { input?: string } = {}): string { + const result = spawnSync(cmd, args, { + cwd: REPO_ROOT, + encoding: "utf8", + input: opts.input, + maxBuffer: 64 * 1024 * 1024, + }); + if (result.status !== 0) { + throw new Error( + `${cmd} ${args.join(" ")} failed (exit ${result.status}):\n${result.stderr || result.stdout}` + ); + } + return result.stdout.trim(); +} + +function tryRun(cmd: string, args: string[]): string | null { + const result = spawnSync(cmd, args, { cwd: REPO_ROOT, encoding: "utf8" }); + return result.status === 0 ? result.stdout.trim() : null; +} + +function ensureImageBuilt(): void { + const existing = tryRun("podman", ["images", "-q", IMAGE_TAG]); + if (existing) { + console.log(` [container] image ${IMAGE_TAG} already exists (${existing}) — reusing`); + return; + } + console.log(` [container] building ${IMAGE_TAG} (runner-base target — this takes a while)...`); + run("podman", ["build", "--target", "runner-base", "-t", IMAGE_TAG, "."]); +} + +function stopExistingContainer(): void { + tryRun("podman", ["rm", "-f", CONTAINER_NAME]); +} + +function startContainer(): { hostPort: number; netnsPath: string } { + if (!existsSync(DATA_DIR_HOST)) { + mkdirSync(DATA_DIR_HOST, { recursive: true }); + } + // podman unshare owns the rootless user namespace these directories' + // native uid mappings live in — plain chmod as the host user fails with + // EPERM on files podman previously wrote as a different mapped uid. + tryRun("podman", ["unshare", "chmod", "-R", "a+rwX", DATA_DIR_HOST]); + + run("podman", [ + "run", + "-d", + "--name", + CONTAINER_NAME, + "-p", + "127.0.0.1::20128", + "-v", + `${DATA_DIR_HOST}:/app/data`, + "--env-file", + ENV_FILE, + IMAGE_TAG, + ]); + + const portOutput = run("podman", ["port", CONTAINER_NAME, "20128/tcp"]); + const hostPort = Number(portOutput.split(":").pop()); + if (!Number.isFinite(hostPort)) { + throw new Error(`could not parse assigned host port from: ${portOutput}`); + } + + const netnsPath = run("podman", [ + "inspect", + CONTAINER_NAME, + "--format", + "{{.NetworkSettings.SandboxKey}}", + ]); + + return { hostPort, netnsPath }; +} + +async function waitForHealth(baseUrl: string, timeoutMs = 60_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const res = await fetch(`${baseUrl}/api/monitoring/health`); + if (res.ok) return; + } catch (err) { + lastError = err; + } + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error(`container never became healthy within ${timeoutMs}ms: ${lastError}`); +} + +// Idempotent: only copies rows if the target has no "default" combo yet. +// Direct SQLite access (not the src/lib/db/ CRUD functions) is deliberate +// here, same as liveGeminiShared.ts's ensureGeminiProvider() — cloning an +// existing row's already-encrypted apiKey blob byte-for-byte has no CRUD +// equivalent, and both instances share the same API_KEY_SECRET (same +// --env-file), so the encrypted value decrypts correctly on the target too. +async function seedDefaultComboAndConnections(): Promise { + const targetPath = `${DATA_DIR_HOST}/storage.sqlite`; + if (!existsSync(targetPath)) { + console.log(` [container] target DB not created yet, skipping seed this pass`); + return; + } + if (!existsSync(SEED_SOURCE_DB)) { + console.warn(` [container] seed source DB not found at ${SEED_SOURCE_DB} — skipping seed`); + return; + } + + const target = new Database(targetPath); + const existingCombo = target.prepare("SELECT 1 FROM combos WHERE name = 'default'").get(); + if (existingCombo) { + console.log(` [container] "default" combo already seeded — skipping`); + target.close(); + return; + } + + const source = new Database(SEED_SOURCE_DB, { readonly: true }); + const connCols = source.prepare("PRAGMA table_info(provider_connections)").all() as Array<{ + name: string; + }>; + const colList = connCols.map((c) => `"${c.name}"`).join(","); + const placeholders = connCols.map((c) => `@${c.name}`).join(","); + const insertConn = target.prepare( + `INSERT OR REPLACE INTO provider_connections (${colList}) VALUES (${placeholders})` + ); + + let copied = 0; + for (const provider of SEED_PROVIDERS) { + const rows = source + .prepare("SELECT * FROM provider_connections WHERE provider = ? AND is_active = 1") + .all(provider); + for (const row of rows) { + insertConn.run(row); + copied++; + } + } + + const comboRow = source.prepare("SELECT * FROM combos WHERE name = 'default'").get() as + Record | undefined; + if (comboRow) { + const comboCols = Object.keys(comboRow); + const comboColList = comboCols.map((c) => `"${c}"`).join(","); + const comboPlaceholders = comboCols.map((c) => `@${c}`).join(","); + target + .prepare(`INSERT OR REPLACE INTO combos (${comboColList}) VALUES (${comboPlaceholders})`) + .run(comboRow); + } + + console.log(` [container] seeded "default" combo + ${copied} provider connection(s)`); + source.close(); + target.close(); +} + +async function provisionApiKeys( + baseUrl: string +): Promise<{ apiKey: string; managementApiKey: string }> { + const passwordLine = spawnSync("grep", ["INITIAL_PASSWORD", ENV_FILE], { + encoding: "utf8", + }).stdout.trim(); + const password = passwordLine.split("=").slice(1).join("=") || "CHANGEME"; + + const login = await fetch(`${baseUrl}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + const cookie = login.headers.get("set-cookie"); + if (!cookie) throw new Error("login did not return a session cookie"); + + async function createKey(name: string, scopes?: string[]): Promise { + const res = await fetch(`${baseUrl}/api/keys`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: cookie! }, + body: JSON.stringify({ name, ...(scopes ? { scopes } : {}) }), + }); + if (!res.ok) throw new Error(`failed to create API key "${name}": ${res.status}`); + const data = (await res.json()) as { key: string }; + return data.key; + } + + const apiKey = await createKey("live-wire-capture-test"); + const managementApiKey = await createKey("live-wire-capture-test-mgmt", ["manage"]); + return { apiKey, managementApiKey }; +} + +export async function startLiveContainer(): Promise { + stopExistingContainer(); + ensureImageBuilt(); + const { hostPort, netnsPath } = startContainer(); + const baseUrl = `http://127.0.0.1:${hostPort}`; + + await waitForHealth(baseUrl); + // The container creates storage.sqlite etc. on first boot under its own + // internal uid mapping — chmod again now that those files exist, since + // the earlier pre-start chmod only reached the (then-empty) directory. + // Without this, seedDefaultComboAndConnections()'s direct host-side + // better-sqlite3 open fails with "attempt to write a readonly database" + // (same root cause hit manually with omniroute-beta earlier this session). + tryRun("podman", ["unshare", "chmod", "-R", "a+rwX", DATA_DIR_HOST]); + await seedDefaultComboAndConnections(); + const { apiKey, managementApiKey } = await provisionApiKeys(baseUrl); + + return { + baseUrl, + apiKey, + managementApiKey, + containerName: CONTAINER_NAME, + netnsPath, + hostPort, + dataDir: DATA_DIR_HOST, + async stop() { + tryRun("podman", ["stop", "-t", "5", CONTAINER_NAME]); + tryRun("podman", ["rm", "-f", CONTAINER_NAME]); + }, + }; +} diff --git a/tests/integration/liveDefaultComboShared.ts b/tests/integration/liveDefaultComboShared.ts new file mode 100644 index 0000000000..d5d9291e63 --- /dev/null +++ b/tests/integration/liveDefaultComboShared.ts @@ -0,0 +1,266 @@ +/** + * tests/integration/liveDefaultComboShared.ts + * + * Shared utilities for the general "default combo" live workload test. + * Unlike liveGeminiShared.ts (which provisions its own narrow 2-model + * Gemini-only combo when "default" doesn't already exist), this reads the + * REAL "default" combo currently configured on the target instance directly + * from its own DB (src/lib/db/combos.ts — never raw SQL, per AGENTS.md) and + * exercises every provider/model step in it directly, bypassing combo + * routing, so live-test coverage always matches whatever the operator + * actually has configured instead of a hardcoded snapshot that goes stale + * the moment the combo changes. + */ +import { + API_KEY, + BASE_URL, + readSSEStream, + readResponsesSSEStream, + genSystemMessage, + genUserMessage, + type Message, +} from "./liveGeminiShared.ts"; + +export { API_KEY, BASE_URL }; + +export const skip = !API_KEY ? "OMNIROUTE_API_KEY not set — skipping live test" : undefined; + +export interface ComboModelTarget { + model: string; + providerId: string | null; +} + +async function apiFetch(path: string, options: RequestInit = {}): Promise { + return fetch(`${BASE_URL}${path}`, { + ...options, + headers: { + Authorization: `Bearer ${API_KEY}`, + "Content-Type": "application/json", + ...options.headers, + }, + }); +} + +// Bootstrap seed used ONLY when the target instance has no "default" combo +// at all — mirrors liveGeminiShared.ts's own DEFAULT_COMBO_CONFIG fallback, +// generalized to the real multi-provider spread confirmed live against this +// operator's own production "default" combo (5 providers, 18 models) rather +// than Gemini alone. This is a creation fallback only: whenever a "default" +// combo already exists on the target instance, its actual live config is +// always what gets read and tested — this list never overrides it. +const FALLBACK_COMBO_MODELS: { model: string; providerId: string }[] = [ + { model: "opencode/big-pickle", providerId: "opencode" }, + { model: "opencode/mimo-v2.5-free", providerId: "opencode" }, + { model: "opencode/laguna-s-2.1-free", providerId: "opencode" }, + { model: "openrouter/cohere/north-mini-code:free", providerId: "openrouter" }, + { model: "openrouter/poolside/laguna-m.1:free", providerId: "openrouter" }, + { model: "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free", providerId: "openrouter" }, + { model: "openrouter/nvidia/nemotron-3-super-120b-a12b:free", providerId: "openrouter" }, + { model: "openrouter/nvidia/nemotron-3-nano-30b-a3b:free", providerId: "openrouter" }, + { model: "openrouter/google/gemma-4-26b-a4b-it:free", providerId: "openrouter" }, + { model: "openrouter/google/gemma-4-31b-it:free", providerId: "openrouter" }, + { model: "openrouter/poolside/laguna-s-2.1:free", providerId: "openrouter" }, + { model: "gemini/gemini-3.1-flash-lite", providerId: "gemini" }, + { model: "gemini/gemma-4-31b-it", providerId: "gemini" }, + { model: "gemini/gemma-4-26b-a4b-it", providerId: "gemini" }, + { model: "mistral/mistral-large-latest", providerId: "mistral" }, + { model: "cerebras/gemma-4-31b", providerId: "cerebras" }, + { model: "cerebras/zai-glm-4.7", providerId: "cerebras" }, + { model: "cerebras/gpt-oss-120b", providerId: "cerebras" }, +]; + +async function ensureDefaultComboExists( + getComboByName: (name: string) => Promise | null> +): Promise { + const existing = await getComboByName("default"); + if (existing) return; + + console.log(` [setup] no "default" combo on this instance — creating fallback seed combo`); + const { createCombo } = await import("../../src/lib/db/combos.ts"); + await createCombo({ + name: "default", + strategy: "priority", + models: FALLBACK_COMBO_MODELS.map((m, i) => ({ + kind: "model" as const, + model: m.model, + providerId: m.providerId, + weight: 1, + id: `fallback-${i}`, + })), + }); +} + +// Read the live "default" combo's model steps straight from the DB module — +// intentionally not hardcoded, so this always reflects whatever the operator +// currently has configured on the target instance. Creates a fallback seed +// combo first if none exists at all (see ensureDefaultComboExists above). +export async function getDefaultComboModelTargets(): Promise { + const { getComboByName } = await import("../../src/lib/db/combos.ts"); + await ensureDefaultComboExists(getComboByName); + const combo = (await getComboByName("default")) as Record | null; + const models = + combo && Array.isArray(combo.models) ? (combo.models as Record[]) : []; + + const targets: ComboModelTarget[] = []; + for (const step of models) { + if (step.kind !== "model" || typeof step.model !== "string") continue; + targets.push({ + model: step.model, + providerId: typeof step.providerId === "string" ? step.providerId : null, + }); + } + return targets; +} + +// Skip (never fail) any model whose provider connection isn't currently +// active — this suite's job is breadth across the real combo, not blocking +// the whole run on one unrelated provider outage. baseUrl/apiKey default to +// the module-level omniroute-beta target but can be overridden (see +// sendModelRequest — same rationale, used by the wire-capture suite's +// dedicated container). +export async function filterActiveModelTargets( + targets: ComboModelTarget[], + options: SendModelRequestOptions = {} +): Promise<{ active: ComboModelTarget[]; skipped: string[] }> { + const baseUrl = options.baseUrl ?? BASE_URL; + const apiKey = options.apiKey ?? API_KEY; + const res = await fetch(`${baseUrl}/api/providers`, { + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + }); + if (!res.ok) return { active: targets, skipped: [] }; + + const data = await res.json(); + const connections = (data.connections || data) as Record[]; + // Terminal states (never self-heal — see AGENTS.md "Resilience Runtime + // State" → Connection Cooldown) plus "unavailable" (active cooldown) are + // the only statuses worth pre-filtering; everything else (including + // transient/lazily-recovered cooldowns that have already expired) is left + // for the request itself to prove out. + const DEAD_STATUSES = new Set(["expired", "unavailable", "banned", "credits_exhausted"]); + const activeProviders = new Set( + connections + .filter((c) => c.isActive && !DEAD_STATUSES.has(c.testStatus as string)) + .map((c) => c.provider as string) + ); + + const active: ComboModelTarget[] = []; + const skipped: string[] = []; + for (const t of targets) { + if (!t.providerId || activeProviders.has(t.providerId)) { + active.push(t); + } else { + skipped.push(`${t.model} (provider "${t.providerId}" not active)`); + } + } + return { active, skipped }; +} + +function ts(): string { + return new Date().toISOString().slice(11, 23); // HH:MM:SS.mmm +} + +export interface ModelRequestResult { + model: string; + status: number; + duration: number; + tokens: number; + contentLength: number; + correlationId: string; + error?: string; +} + +export interface SendModelRequestOptions { + baseUrl?: string; + apiKey?: string; +} + +// Deliberately lighter than liveGeminiShared's sendAndValidate (no retry +// loop, one fixed prompt pair): this suite's job is breadth across every +// model in the real combo, not depth on any single provider. baseUrl/apiKey +// default to the module-level omniroute-beta target but can be overridden — +// e.g. by the wire-capture suite, which points requests at its own +// dedicated throwaway container instead (see liveContainerHarness.ts). +export async function sendModelRequest( + model: string, + stream: boolean, + apiFormat: "chat" | "responses" = "chat", + options: SendModelRequestOptions = {} +): Promise { + const baseUrl = options.baseUrl ?? BASE_URL; + const apiKey = options.apiKey ?? API_KEY; + const endpoint = apiFormat === "responses" ? "/v1/responses" : "/v1/chat/completions"; + const messages: Message[] = [genSystemMessage(), genUserMessage()]; + const body = + apiFormat === "responses" + ? { model, input: messages, stream, max_output_tokens: 1024, temperature: 0.3 } + : { model, messages, stream, max_tokens: 1024, temperature: 0.3 }; + + const controller = new AbortController(); + const timeoutMs = Number(process.env.TEST_REQUEST_TIMEOUT_MS) || 120_000; + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const start = performance.now(); + + try { + const response = await fetch(`${baseUrl}${endpoint}`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify(body), + signal: controller.signal, + }); + const duration = performance.now() - start; + clearTimeout(timeout); + const correlationId = response.headers.get("x-correlation-id") || "?"; + + let content = ""; + let totalTokens = 0; + + if (response.status === 200) { + if (stream) { + const streamResult = + apiFormat === "responses" + ? await readResponsesSSEStream(response) + : await readSSEStream(response); + content = streamResult.fullContent; + totalTokens = streamResult.totalTokens; + } else if (apiFormat === "responses") { + const json = await response.json().catch(() => ({})); + const textItem = json?.output?.find((o: Record) => o.type === "message"); + content = textItem?.content?.[0]?.text || ""; + totalTokens = json?.usage?.total_tokens || 0; + } else { + const json = await response.json().catch(() => ({})); + content = json?.choices?.[0]?.message?.content || ""; + totalTokens = json?.usage?.total_tokens || 0; + } + } + + console.log( + `${ts()} ${model.padEnd(40)} HTTP ${response.status} | ` + + `${Math.round(duration).toString().padStart(6)}ms | ` + + `${String(totalTokens).padStart(5)} tok | ` + + `${content.length} chars | cid: ${correlationId}` + ); + + return { + model, + status: response.status, + duration, + tokens: totalTokens, + contentLength: content.length, + correlationId, + }; + } catch (err) { + clearTimeout(timeout); + const errorMessage = err instanceof Error ? err.message : String(err); + console.log(`${ts()} ${model.padEnd(40)} FAILED: ${errorMessage}`); + return { + model, + status: 0, + duration: performance.now() - start, + tokens: 0, + contentLength: 0, + correlationId: "?", + error: errorMessage, + }; + } +} diff --git a/tests/integration/wireCapture.ts b/tests/integration/wireCapture.ts new file mode 100644 index 0000000000..4485c4ea6c --- /dev/null +++ b/tests/integration/wireCapture.ts @@ -0,0 +1,154 @@ +/** + * tests/integration/wireCapture.ts + * + * Rootless wire capture + analysis for live container tests. Uses + * `podman unshare nsenter --net=` to run tcpdump without + * sudo/root (verified working against a rootless podman container — see + * scripts/sre/tcp-close-analyzer.py's docstring for the equivalent + * root-requiring `nsenter -t $PID` command this generalizes from), then + * shells out to that same script to reassemble TCP streams and extract + * HTTP request/response lines + correlationId per stream. + */ +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); +const ANALYZER_SCRIPT = `${REPO_ROOT}scripts/sre/tcp-close-analyzer.py`; + +export interface WireStreamRecord { + streamKey: string; + client: string | null; + server: string | null; + firstTs: number; + lastTs: number; + durationSec: number; + packetCount: number; + correlationId: string | null; + requestId: string | null; + firstLineFromA: string | null; + firstLineFromB: string | null; + closes: Array<{ ts: number; side: string; src: string; dst: string; flags: string }>; + verdict: + | "client_closed_first" + | "server_closed_first" + | "simultaneous" + | "no_close_seen" + | "unknown_side_closed_first"; +} + +export interface CaptureHandle { + pcapPath: string; + stop(): Promise; +} + +// Best-effort HTTP status line finder — checks both reassembled directions +// since we don't know a priori which one carried the response. +export function responseStatusLine(record: WireStreamRecord): string | null { + for (const line of [record.firstLineFromA, record.firstLineFromB]) { + if (line && /^HTTP\/\d\.\d \d{3}/.test(line)) return line; + } + return null; +} + +export function requestLine(record: WireStreamRecord): string | null { + for (const line of [record.firstLineFromA, record.firstLineFromB]) { + if (line && /^(GET|POST|PUT|PATCH|DELETE) /.test(line)) return line; + } + return null; +} + +export async function startWireCapture( + netnsPath: string, + pcapPath: string, + bpfFilter: string +): Promise { + if (existsSync(pcapPath)) unlinkSync(pcapPath); + + // `-U`: flush each packet to disk as captured instead of buffering, so a + // non-graceful stop still leaves a readable pcap. + const child = spawn( + "podman", + [ + "unshare", + "nsenter", + `--net=${netnsPath}`, + "--", + "tcpdump", + "-i", + "any", + "-U", + "-w", + pcapPath, + bpfFilter, + ], + { stdio: ["ignore", "ignore", "pipe"] } + ); + + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("tcpdump did not start listening in time")), + 10_000 + ); + child.stderr?.on("data", (chunk: Buffer) => { + if (chunk.toString().includes("listening on")) { + clearTimeout(timeout); + resolve(); + } + }); + child.on("exit", (code) => { + clearTimeout(timeout); + reject(new Error(`tcpdump exited early with code ${code}`)); + }); + }); + + return { + pcapPath, + async stop() { + // podman unshare -> nsenter -> tcpdump is a 3-level subprocess chain; + // SIGTERM to the top-level `podman` process (the only PID Node's + // child_process handle actually tracks) does not reliably reach the + // tcpdump grandchild, leaving it running as an orphan with a + // never-flushed pcap. pkill by the (unique, per-run) pcap path + // reliably reaches the real tcpdump process regardless of how deep + // the subprocess chain is. + child.kill("SIGTERM"); + spawnSync("pkill", ["-f", `tcpdump.*${pcapPath}`]); + await new Promise((resolve) => { + if (child.exitCode !== null) return resolve(); + child.on("exit", () => resolve()); + setTimeout(resolve, 3_000); + }); + // Give the now-dead tcpdump's OS write buffers a moment to land on + // disk before anything tries to read the pcap. + await new Promise((r) => setTimeout(r, 250)); + }, + }; +} + +export async function analyzeCapture(pcapPath: string): Promise { + const jsonlPath = pcapPath.replace(/\.pcap$/, "") + ".streams.jsonl"; + const result = spawnSync("python3", [ANALYZER_SCRIPT, pcapPath, "--out", jsonlPath], { + encoding: "utf8", + }); + if (result.status !== 0) { + throw new Error(`tcp-close-analyzer.py failed: ${result.stderr || result.stdout}`); + } + if (!existsSync(jsonlPath)) return []; + + return readFileSync(jsonlPath, "utf8") + .split("\n") + .filter((line) => line.trim()) + .map((line) => JSON.parse(line) as WireStreamRecord); +} + +export function indexByCorrelationId(records: WireStreamRecord[]): Map { + const map = new Map(); + for (const record of records) { + if (!record.correlationId) continue; + const existing = map.get(record.correlationId) || []; + existing.push(record); + map.set(record.correlationId, existing); + } + return map; +} From a524fdeaf0abdc506f82ff6d1a9cbd0b3ac06478 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:53:15 -0300 Subject: [PATCH 052/100] maint: follow-up cherry-pick fix-in-place #9741 (conflict-resolved fallback) (#9895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(responses-api): sync reasoning-cache write index with the fixed read side The turn-index-hardcoding fix updated the reasoning-cache read side (translator/index.ts's main replay loop) to key lookups by the assistant message's real position in the messages array, but two other spots still used the old hardcoded convention: - chatCore.ts's write side (both the streaming and non-streaming completion paths) still cached every response under a hardcoded messageIndex: 0. - translator/index.ts's own plain-turn (non-tool-call) cache-key lookup ALSO still hardcoded messageIndex 0 at its call site — a second, previously undiscovered instance of the same class of bug, found while re-verifying this fix against the current upstream tip (the original fix only addressed the write side). Past the first assistant turn these conventions no longer matched, so DeepSeek/Xiaomi-mimo plain-turn reasoning replay silently missed the cache and fell back to the placeholder (or, once #9573 removed the placeholder fallback, to an absent field) in ordinary multi-turn conversations. Compute the write-side index from the incoming request's message count instead, and use the real loop-provided messageIndex on the read-side lookup, both matching the position the response occupies once the client appends it to history for the next turn. Note: this was originally part of a larger squashed fix (output_index collision prevention across reasoning/message/tool_call items, reasoning-content-alias generalization) that has since been superseded by upstream's own independent fix — translator/response/openai-responses.ts now has its own dense-output-index-sort + getReadableReasoningValue implementation (own comment: "mirrors upstream PR #721"). Only this narrower, still-genuinely-broken write/read index sync survives as a distinct bug. Test plan: - TDD: tests/unit/reasoning-cache.test.ts's new end-to-end "write side (chatCore's messageIndex) and read side (translateRequest) agree on the same key end-to-end" test, plus the pre-existing "should inject placeholder for a plain (non-tool-call) DeepSeek turn" and "should replay cached reasoning for a plain (non-tool-call) DeepSeek turn when available" tests — confirmed failing against the pre-fix code on a clean release/v3.8.50 checkout (both the hardcoded-0 write side AND the hardcoded-0 read-side lookup independently reproduce the mismatch), passing after both fixes - npm run typecheck:core — clean - npm run lint — clean - npm run check:file-size — clean (chatCore.ts rebaselined 5034->5042 for the messageIndex computation at both call sites; reasoning-cache.test.ts frozen at 1035, matching the original fix's own rebaseline) - 2 pre-existing, unrelated test failures in the same file ("should replace empty-string reasoning_content with NON_ANTHROPIC_THINKING_PLACEHOLDER on cache miss", "should inject placeholder for a plain (non-tool-call) DeepSeek turn missing reasoning_content") confirmed present on a completely clean, untouched release/v3.8.50 checkout — these test obsolete placeholder-injection behavior the code deliberately removed per #9573 (see the code's own comment); not touched by this PR * fix(chat): reduce file size Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(chat): reconcile file-size baseline Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Markus Hartung --- config/quality/file-size-baseline.json | 7 +- open-sse/handlers/chatCore.ts | 16 +- open-sse/translator/index.ts | 2 +- tests/unit/reasoning-cache.test.ts | 63 ++++- tests/unit/translator-helper-branches.test.ts | 237 +++++++++--------- 5 files changed, 193 insertions(+), 132 deletions(-) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 081460c833..997c081d2f 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,9 @@ { "_rebaseline_2026_08_09_9296_adobe_media_capabilities": "PR #9296 (artickc, fix/adobe-firefly-model-capabilities) own growth: src/app/api/v1/models/catalog.ts 1590->1597 (+7). The image and video catalog serializers now expose the already-normalized Adobe Firefly discovery capability data (media_capabilities, plus the existing video modality/size fields) at their only response-emission chokepoints. The discovery parser and capability normalization remain in open-sse/services/adobeFireflyModels.ts; extracting these seven serialization fields would obscure the catalog contract. Covered by tests/unit/adobe-firefly.test.ts and tests/unit/image-upscale.test.ts.", + "_rebaseline_2026_08_08_v3850_base_drift_batch_9757": "Base drift on release/v3.8.50, not own growth: the 08-06..08-08 merge batches grew 12 already-frozen (or newly-landed) files without carrying their rebaselines — the dedicated rebaseline PR #9616 was closed as 'superseded' but its file-size entries never actually reached the base, and later merges (#8894 combos page, #9539 EditConnectionModal, #8895 models route, #9294/#9293 catalog, #9541 db/core, #8970 tokenHealthCheck, #8925 mcp schemas+server, #8890 accountFallback, #9467 chat.ts, #8931 openai-to-kiro, ProxyRegistryManager) kept growing them. All 12 values re-measured on THIS branch's tree (= pure tip + this PR's 1-line chat.ts fix, which adds zero lines). This PR's own source changes (chat.ts identifier restore, stream.ts format carve-out) do not grow any frozen file past these values.", + "_rebaseline_2026_08_08_migration_135_collision": "fix(db): resolve migration version 135 numbering collision — #9449's 135_connection_runtime_state.sql and #8908's 135_migrate_model_capability_max_token.sql both claimed version 135 (#9449 branched before #8908 merged and never got renumbered before landing on release/v3.8.50), which threw 'Migration version collision detected' the moment ANY code touched the database — a fresh install/deploy from this tip cannot even boot. Renumbered the later-landing file to 140 (next free slot) and added the matching isSchemaAlreadyApplied('140') retroactive guard, matching the established pattern already used for the prior 135/136 -> 137/138 renumber in the same file. Own growth: src/lib/db/migrationRunner.ts 1084->1094 (+10, the new case block) — irreducible, matches the existing per-case guard pattern exactly. Covered by tests/unit/migration-135-numbering-collision.test.ts (2/2), confirmed failing (reproducing the exact live crash) against the pre-fix colliding filenames, passing after.", + "_rebaseline_2026_08_08_9183_reasoning_cache_index_sync": "Extracted fix(responses-api): sync reasoning-cache write index with the fixed read side (from the originally-authored #9183) — chatCore.ts's write side cached every response under a hardcoded messageIndex:0, and translator/index.ts's plain-turn (non-tool-call) cache-key lookup ALSO still hardcoded messageIndex 0 at its call site (a second, previously-undiscovered instance of the same hardcoding bug, found while re-verifying this fix against the current upstream tip — the two never agreed once a conversation went past its first assistant turn, so DeepSeek/Xiaomi-mimo plain-turn reasoning replay silently missed the cache). Own growth: open-sse/handlers/chatCore.ts 5034->5042 (+8, computing messageIndex from the incoming request's message count at both the streaming and non-streaming cache-write call sites) — irreducible call-site wiring. Covered by tests/unit/reasoning-cache.test.ts (new end-to-end write/read regression test, rebaselined below) and tests/unit/translator-helper-branches.test.ts fixture updates. Other #9183 sub-fixes (output_index collision prevention, reasoning-content-alias generalization) were originally assumed already superseded by upstream's own independent fix — a live incident 2026-08-08 disproved that for the message-vs-tool-call collision case specifically (fixed separately in #9822); not re-extracted here since this PR's own scope is the narrower messageIndex sync only.", + "_rebaseline_2026_08_02_9259_rolling_rpm": "PR #9259 (issue #8733) own growth: open-sse/services/rateLimitManager.ts baseline 1060->1167 (+107; final source 1153). The existing withRateLimit chokepoint now composes process-local rolling RPM leases with Bottleneck admission, releases pre-dispatch leases on queue timeout/abort/connection disable, preserves caller abort reasons, and wires 429/header state into the extracted rollingRpmGate.ts. The remaining growth is irreducible lifecycle wiring at the dispatch boundary plus the real watchdog test hooks needed to verify queued-wedge recovery; moving it further would obscure lease ownership and Bottleneck cleanup. Covered by the focused rate-limit manager/sliding-window suite (33/33); distributed multi-instance coordination remains explicitly out of scope.", "_rebaseline_2026_07_24_8470_hyperagent_sticky_thread": "PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) own growth: open-sse/executors/hyperagent.ts 936->1025 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 937->1026, +89, crosses the 1000 cap). Fixes a real bug where a reverse-conversion proxy (text-Intent/JSON to Claude Code native tool_calls) rewrites assistant messages between agentic tool-loop turns, breaking HyperAgent’s conversation-prefix fingerprint and cold-starting the thread mid tool-loop. Adds Anthropic tool_use/tool_result flattening to extractMessageText() plus a new rootUserFingerprint()/root-key lookup tier in resolveHyperAgentThreadBinding()/storeHyperAgentThreadAfterTurn() so the thread stays sticky across the tool loop. Cohesive additions inside the existing single-file executor; not extractable without splitting the executor mid-request-flow. Covered by tests/unit/executor-hyperagent.test.ts (19/19, +5 new cases for tool_result/tool_use flattening + root-key stickiness). Pre-merge review flagged a cross-conversation root-key collision risk (tracked in the PR’s own mandatory pre-merge checklist, not yet addressed) — unrelated to this file-size ratchet, tracked separately by /fix-prs.", "_rebaseline_2026_07_25_8494_capability_filter_fail_closed": "PR #8494 (fix/capability-filters-fail-closed, #8488) own growth: open-sse/services/combo.ts 3640->3693 (+53) adds a fail-closed guard after filterTargetsByRequestCompatibility() — when every eligible target is excluded by request-capability filtering (vision/tools/etc) instead of quota/health, the combo now returns an explicit `capability_mismatch` 400 (describeCapabilityFilterExhaustion, imported from combo/comboStructure.ts) rather than silently falling through to a generic no-targets error, plus a `compatFilterFailOpen` escape hatch (combo config OR settings) mirrored at both the main/auto and round-robin call sites for symmetry. combo/comboStructure.ts (previously under cap, un-frozen) grows 794->918 (+124) — new home for describeCapabilityFilterExhaustion + providerSupportsEmulatedToolCalling (#5240 emulated tool-calling exemption so fail-closed does not regress prompt-emulation-only combos like all-chatgpt-web). Irreducible orchestration wiring at the existing filter chokepoint (same precedent as #7301's universal-cooldown-retry generalization). Companion test tests/unit/combo-routing-engine.test.ts 3409->3449 (+40, fail-closed/fail-open coverage across both call sites) also rebaselined. Covered by tests/unit/8488-capability-filter-fail-closed.test.ts (new) + 95/95 passing across both files. Structural shrink of combo.ts tracked in #3501.", "_rebaseline_2026_07_25_8499_ts7_result_union_predicates": "PR #8499 (backryun, chore/ts7-types-executor-scattered) own growth: muse-spark-web.ts 1396->1405 (+9, irreducible). Under this workspace's `strictNullChecks: false`, the boolean-literal discriminant on `GraphqlResult` (`{ ok: true } | { ok: false; error: string }`) narrows the positive `.ok===true` branch but leaves `!result.ok` at the full union under TS7, making `.error` unreachable to the checker at the two call sites (warmup, mode-switch). Fixed by adding a single `isGraphqlFailure()` type-predicate helper (doc comment + 3-line body) reused at both call sites instead of duplicating the predicate inline — not extractable to a shared module without splitting a single-file executor's local narrowing helper out of its own file. Covered by the existing muse-spark-web executor test suite (no behavior change, pure narrowing fix).", @@ -162,6 +166,7 @@ "cap": 1000, "testCap": 1000, "testFrozen": { + "tests/unit/reasoning-cache.test.ts": 1035, "_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).", "_rebaseline_2026_07_02_5928_base_red": "web-cookie-providers-new.test.ts 845->850: #5928 (test(security) Kimi Web URL host parse, CodeQL #689) grew the file +5 lines and merged into release/v3.8.44 WITHOUT rebaselining, leaving a fast-gates base-red that blocked every subsequent PR->release. Test growth is legitimate (a security regression test); maintainer absorbs the drift here. Frozen at 850.", "_rebaseline_2026_07_09_6126_clinepass_dualauth": "#6126 (ClinePass dual-auth) own test growth: oauth-providers-config.test.ts 842->845 (+3: clinepass key/config/required-fields entries reusing the Cline WorkOS flow config, needed after registering clinepass in the oauth.ts PROVIDERS enum).", @@ -350,7 +355,7 @@ "open-sse/executors/deepseek-web.ts": 1148, "open-sse/executors/grok-web.ts": 1044, "open-sse/executors/muse-spark-web.ts": 1405, - "open-sse/handlers/chatCore.ts": 5034, + "open-sse/handlers/chatCore.ts": 5042, "open-sse/handlers/imageGeneration.ts": 3101, "open-sse/handlers/responseSanitizer.ts": 1128, "open-sse/handlers/search.ts": 1536, diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 1b31d6a871..a872ed6ca7 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -4326,9 +4326,14 @@ export async function handleChatCore({ try { const firstChoice = translatedResponse?.choices?.[0]; const msg = firstChoice?.message; + // The response being cached now will be replayed as history on the *next* + // turn, where the read side (translator/index.ts) keys the lookup by the + // message's real position in that future `messages` array — i.e. right + // after everything the client sent this turn. + const bodyMessages = (body as { messages?: unknown[] } | null | undefined)?.messages; cacheReasoningFromAssistantMessage(msg, provider, model, { requestId: skillRequestId, - messageIndex: 0, + messageIndex: Array.isArray(bodyMessages) ? bodyMessages.length : 0, }); } catch { // Cache capture is non-critical — never block the response @@ -4753,12 +4758,15 @@ export async function handleChatCore({ // with tool_calls so it can be replayed on subsequent turns (DeepSeek V4, Kimi K2, etc.) if (normalizedStreamStatus === 200 && streamResponseBody) { try { - const body = streamResponseBody as Record; - const choices = body.choices as { message?: Record }[] | undefined; + const streamBody = streamResponseBody as Record; + const choices = streamBody.choices as { message?: Record }[] | undefined; const msg = choices?.[0]?.message; + // See the non-streaming capture above: messageIndex must match the + // position this message will occupy in the *next* turn's history. + const bodyMessages = (body as { messages?: unknown[] } | null | undefined)?.messages; cacheReasoningFromAssistantMessage(msg, provider, model, { requestId: skillRequestId, - messageIndex: 0, + messageIndex: Array.isArray(bodyMessages) ? bodyMessages.length : 0, }); } catch { // Cache capture is non-critical — never block the stream diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index b81acb71e0..7b896b76de 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -590,7 +590,7 @@ export function translateRequest( const cacheKey = hasToolCalls ? msg.tool_calls[0]?.id - : getAssistantMessageCacheKey(result, 0); + : getAssistantMessageCacheKey(result, messageIndex); if (cacheKey) { const cached = lookupReasoning(cacheKey); if (cached) { diff --git a/tests/unit/reasoning-cache.test.ts b/tests/unit/reasoning-cache.test.ts index e913a03cc9..1f5b1ce55d 100644 --- a/tests/unit/reasoning-cache.test.ts +++ b/tests/unit/reasoning-cache.test.ts @@ -865,11 +865,12 @@ describe("Reasoning Replay Cache — Translator Replay", () => { }), }, }); - // NOTE: the non-tool-call cache key is built as `getAssistantMessageCacheKey(result, 0)` - // — the message index is hardcoded to 0 in the translator, so the key is always - // `request::message:0` regardless of the assistant message's actual position. + // The non-tool-call cache key is built as `getAssistantMessageCacheKey(result, messageIndex)` + // where messageIndex is the assistant message's real position in the `messages` + // array (index 1 here: user, assistant, user) — matching what the write side + // (chatCore.ts) now caches under once the response is generated. cacheReasoning( - "request:req-plain-1:message:0", + "request:req-plain-1:message:1", "deepseek", "deepseek-v4-pro", "Real cached plain-turn reasoning" @@ -899,6 +900,60 @@ describe("Reasoning Replay Cache — Translator Replay", () => { ); assert.equal(getReasoningCacheServiceStats().replays, 1); }); + + it("write side (chatCore's messageIndex) and read side (translateRequest) agree on the same key end-to-end", () => { + // Regression for a mismatch where chatCore.ts always cached under + // `messageIndex: 0` (the position of the response within *its own* choices + // array) while translateRequest's read side looked up the message's real + // position in the *next* turn's full history — the two never agreed once a + // conversation went past its first assistant turn, so replay silently + // fell back to the placeholder in real multi-turn usage. + clearReasoningCacheAll(); + clearModelsDevCapabilities(); + saveModelsDevCapabilities({ + deepseek: { + "deepseek-v4-pro": buildCapability({ + interleaved_field: "reasoning_content", + reasoning: true, + tool_call: true, + }), + }, + }); + + // Turn 1: the incoming request has a single user message (length 1), so + // the assistant response chatCore is about to cache will occupy index 1 + // once it's appended to history for turn 2 — mirroring + // `messageIndex: bodyMessages.length` in chatCore.ts. + const turn1RequestBody = { messages: [{ role: "user", content: "hi" }] }; + cacheReasoningFromAssistantMessage( + { role: "assistant", content: "Hello! How can I help?", reasoning_content: "real reasoning" }, + "deepseek", + "deepseek-v4-pro", + { requestId: "req-e2e-1", messageIndex: turn1RequestBody.messages.length } + ); + + // Turn 2: client replays the full history including the cached assistant + // turn, now genuinely at index 1. + const translated = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI, + "deepseek-v4-pro", + { + request_id: "req-e2e-1", + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "Hello! How can I help?" }, + { role: "user", content: "tell me more" }, + ], + }, + false, + null, + "deepseek" + ); + + assert.equal(translated.messages[1].reasoning_content, "real reasoning"); + assert.equal(getReasoningCacheServiceStats().replays, 1); + }); }); describe("Reasoning Replay Cache — API Route", () => { diff --git a/tests/unit/translator-helper-branches.test.ts b/tests/unit/translator-helper-branches.test.ts index 4f0f32cfb2..9626d99dbb 100644 --- a/tests/unit/translator-helper-branches.test.ts +++ b/tests/unit/translator-helper-branches.test.ts @@ -632,7 +632,7 @@ test("translateRequest replays cached reasoning-only messages when interleaved f }, }); cacheReasoningByKey( - "request:req_reasoning_only:message:0", + "request:req_reasoning_only:message:1", "deepseek", "deepseek-v4-flash", "cached reasoning only" @@ -690,138 +690,131 @@ test("translateRequest does not replay reasoning-only messages for non-DeepSeek clearReasoningCacheAll(); }); - test("translateRequest uses Kimi Coding's empty thinking marker instead of cached replay", () => { - clearReasoningCacheAll(); - cacheReasoningByKey( - "toolu_kimi_claude", - "kimi-coding", - "kimi-for-coding", - "cached thinking for Kimi tool call" - ); +test("translateRequest uses Kimi Coding's empty thinking marker instead of cached replay", () => { + clearReasoningCacheAll(); + cacheReasoningByKey( + "toolu_kimi_claude", + "kimi-coding", + "kimi-for-coding", + "cached thinking for Kimi tool call" + ); - // Claude-format request: assistant has tool_use in content[] but NO thinking block - // This simulates the scenario that causes infinite loops - const result = translateRequest( - FORMATS.OPENAI, - FORMATS.CLAUDE, - "kimi-for-coding", - { - reasoning_effort: "high", - messages: [ - { role: "user", content: "read the file" }, - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "toolu_kimi_claude", - name: "read_file", - input: { path: "test.ts" }, - }, - ], - }, - { role: "tool", tool_call_id: "toolu_kimi_claude", content: "file data" }, - ], - }, - false, - null, - "kimi-coding" - ); + // Claude-format request: assistant has tool_use in content[] but NO thinking block + // This simulates the scenario that causes infinite loops + const result = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + "kimi-for-coding", + { + reasoning_effort: "high", + messages: [ + { role: "user", content: "read the file" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_kimi_claude", + name: "read_file", + input: { path: "test.ts" }, + }, + ], + }, + { role: "tool", tool_call_id: "toolu_kimi_claude", content: "file data" }, + ], + }, + false, + null, + "kimi-coding" + ); - const assistantMsg = result.messages.find((m) => m.role === "assistant"); - assert.ok(assistantMsg, "assistant message should exist"); - assert.ok(Array.isArray(assistantMsg.content), "content should be array"); + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistantMsg, "assistant message should exist"); + assert.ok(Array.isArray(assistantMsg.content), "content should be array"); - // Kimi Code CLI 0.26 sends an explicit empty thinking marker before tool_use. - const thinkingBlock = assistantMsg.content.find((b) => b?.type === "thinking"); - assert.ok(thinkingBlock, "thinking block should be injected"); - assert.equal(thinkingBlock.thinking, ""); + // Kimi Code CLI 0.26 sends an explicit empty thinking marker before tool_use. + const thinkingBlock = assistantMsg.content.find((b) => b?.type === "thinking"); + assert.ok(thinkingBlock, "thinking block should be injected"); + assert.equal(thinkingBlock.thinking, ""); - // Thinking block should appear before tool_use - const thinkingIdx = assistantMsg.content.indexOf(thinkingBlock); - const toolUseIdx = assistantMsg.content.findIndex((b) => b?.type === "tool_use"); - assert.ok(thinkingIdx < toolUseIdx, "thinking block should be before tool_use"); + // Thinking block should appear before tool_use + const thinkingIdx = assistantMsg.content.indexOf(thinkingBlock); + const toolUseIdx = assistantMsg.content.findIndex((b) => b?.type === "tool_use"); + assert.ok(thinkingIdx < toolUseIdx, "thinking block should be before tool_use"); - assert.equal(getReasoningCacheServiceStats().replays, 0); - clearReasoningCacheAll(); - }); + assert.equal(getReasoningCacheServiceStats().replays, 0); + clearReasoningCacheAll(); +}); - test("translateRequest uses an empty Kimi Coding thinking marker on cache miss", () => { - clearReasoningCacheAll(); +test("translateRequest uses an empty Kimi Coding thinking marker on cache miss", () => { + clearReasoningCacheAll(); - const result = translateRequest( - FORMATS.OPENAI, - FORMATS.CLAUDE, - "kimi-for-coding", - { - reasoning_effort: "high", - messages: [ - { role: "user", content: "do it" }, - { - role: "assistant", - content: [ - { type: "tool_use", id: "toolu_miss", name: "bash", input: { command: "ls" } }, - ], - }, - { role: "tool", tool_call_id: "toolu_miss", content: "output" }, - ], - }, - false, - null, - "kimi-coding" - ); + const result = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + "kimi-for-coding", + { + reasoning_effort: "high", + messages: [ + { role: "user", content: "do it" }, + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_miss", name: "bash", input: { command: "ls" } }], + }, + { role: "tool", tool_call_id: "toolu_miss", content: "output" }, + ], + }, + false, + null, + "kimi-coding" + ); - const assistantMsg = result.messages.find((m) => m.role === "assistant"); - assert.ok(assistantMsg, "assistant message should exist"); + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistantMsg, "assistant message should exist"); - const thinkingBlock = - Array.isArray(assistantMsg.content) && - assistantMsg.content.find((b) => b?.type === "thinking"); - assert.ok(thinkingBlock, "thinking block should be injected on cache miss"); - assert.equal(thinkingBlock.thinking, ""); + const thinkingBlock = + Array.isArray(assistantMsg.content) && assistantMsg.content.find((b) => b?.type === "thinking"); + assert.ok(thinkingBlock, "thinking block should be injected on cache miss"); + assert.equal(thinkingBlock.thinking, ""); - clearReasoningCacheAll(); - }); + clearReasoningCacheAll(); +}); - test("translateRequest does NOT inject duplicate thinking for Claude-format messages with existing thinking block", () => { - clearReasoningCacheAll(); +test("translateRequest does NOT inject duplicate thinking for Claude-format messages with existing thinking block", () => { + clearReasoningCacheAll(); - const result = translateRequest( - FORMATS.OPENAI, - FORMATS.CLAUDE, - "kimi-for-coding", - { - messages: [ - { role: "user", content: "hi" }, - { - role: "assistant", - content: [ - { type: "thinking", thinking: "I already have this" }, - { type: "tool_use", id: "toolu_existing", name: "read", input: {} }, - ], - }, - { role: "tool", tool_call_id: "toolu_existing", content: "data" }, - ], - }, - false, - null, - "kimi-coding" - ); + const result = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + "kimi-for-coding", + { + messages: [ + { role: "user", content: "hi" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "I already have this" }, + { type: "tool_use", id: "toolu_existing", name: "read", input: {} }, + ], + }, + { role: "tool", tool_call_id: "toolu_existing", content: "data" }, + ], + }, + false, + null, + "kimi-coding" + ); - const assistantMsg = result.messages.find((m) => m.role === "assistant"); - const thinkingBlocks = - Array.isArray(assistantMsg.content) && - assistantMsg.content.filter((b) => b?.type === "thinking"); - assert.equal( - thinkingBlocks?.length, - 1, - "should have exactly one thinking block (no duplicate)" - ); - assert.equal( - thinkingBlocks[0].thinking, - "I already have this", - "original thinking should be preserved" - ); + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + const thinkingBlocks = + Array.isArray(assistantMsg.content) && + assistantMsg.content.filter((b) => b?.type === "thinking"); + assert.equal(thinkingBlocks?.length, 1, "should have exactly one thinking block (no duplicate)"); + assert.equal( + thinkingBlocks[0].thinking, + "I already have this", + "original thinking should be preserved" + ); - clearReasoningCacheAll(); - }); + clearReasoningCacheAll(); +}); From e117249baa3042ee224c993833518245036095c6 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:53:21 -0300 Subject: [PATCH 053/100] cherry-pick(pr-9738): feat(logging): make the chat-log truncation limit configurable, bumped default 128x (#9863) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(logging): make the chat-log truncation limit configurable, bumped default 128x The 8KB cap on logged request/response bodies (open-sse/handlers/chatCore/logTruncation.ts::truncateForLog()) was hardcoded — trivially exceeded by any real multi-turn agentic conversation, meaning the dashboard's "Full Conversation" panel could only ever show a placeholder instead of the actual messages for nearly every logged row of any conversation with real substance. - Added CHAT_LOG_MAX_BODY_KB env var (src/lib/logEnv.ts:: getChatLogMaxBodyBytes()), default 1024 KB (1MB) — a 128x bump from the old hardcoded 8KB — following the same configurable-limit pattern as the sibling CHAT_LOG_TEXT_LIMIT/CHAT_LOG_ARRAY_TAIL_ITEMS/etc. vars. - Documented in .env.example and docs/reference/ENVIRONMENT.md. estimateSizeFast() (open-sse/utils/estimateSize.ts) has been substantially rewritten upstream since this bug was first found (now an iterative Frame-based walker with a separate node-visit budget, not the simple stack loop originally patched) — re-implemented the fix against the current algorithm rather than porting the old diff: the byte early-exit was unconditionally the module-level ESTIMATE_SIZE_BYTE_LIMIT (256 KiB) with no way for a caller to raise it, so any caller comparing against a bigger configured threshold could never see a size above ~256 KiB — every payload between 256 KiB and the caller's real limit looked "under threshold" and truncation never fired, the opposite of intended. Added an optional byteLimit parameter (default unchanged at ESTIMATE_SIZE_BYTE_LIMIT, so isSmallEnoughForSemanticCache's existing behavior is untouched) threaded through both the byte-check early-exit and the node-budget-exhaustion fail-closed fallback, with truncateForLog() now passing its own configured getChatLogMaxBodyBytes() value through. * feat(dashboard): show conversation session tag in request detail metadata Adds a "Conversation" field to the request detail panel's metadata grid (after "Combo"), showing the request's conversation id (sessionTag) for quick reference/copy. --------- Co-authored-by: Markus Hartung --- .env.example | 1 + docs/reference/ENVIRONMENT.md | 1 + open-sse/handlers/chatCore/logTruncation.ts | 14 +++--- open-sse/utils/estimateSize.ts | 25 +++++++--- src/lib/logEnv.ts | 10 ++++ src/shared/components/RequestLoggerDetail.tsx | 15 ++++++ tests/unit/chatcore-log-truncation.test.ts | 32 ++++++++++++ tests/unit/estimateSizeFast.test.ts | 49 +++++++++++++++++++ 8 files changed, 135 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 3dd22b027f..d82754aa2b 100644 --- a/.env.example +++ b/.env.example @@ -1360,6 +1360,7 @@ APP_LOG_TO_FILE=true # CHAT_LOG_ARRAY_TAIL_ITEMS=24 # Number of array items retained from tail (default: 24) # CHAT_LOG_MAX_DEPTH=6 # Max nesting depth before truncation (default: 6) # CHAT_LOG_MAX_OBJECT_KEYS=80 # Max object keys retained (default: 80, 0 = no limit) +# CHAT_LOG_MAX_BODY_KB=1024 # Max request/response body size before summarizing, in KB (default: 1024) # Maximum rows in the proxy_logs SQLite table. # Default: 100000 diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 115d8cfb64..8cf4b31dd3 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -725,6 +725,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | `CHAT_LOG_ARRAY_TAIL_ITEMS` | `24` | Number of array items retained from the tail when truncating chat log payloads. | | `CHAT_LOG_MAX_DEPTH` | `6` | Max nesting depth before chat log payloads are truncated. | | `CHAT_LOG_MAX_OBJECT_KEYS` | `80` | Max object keys retained in chat log payloads (0 = unlimited). | +| `CHAT_LOG_MAX_BODY_KB` | `1024` | Max request/response body size before `truncateForLog()` summarizes it, in KB. | | `CHAT_DEBUG_FILE` | `false` | When true, `serializeArtifactForStorage` skips size-based truncation. Debug only. | --- diff --git a/open-sse/handlers/chatCore/logTruncation.ts b/open-sse/handlers/chatCore/logTruncation.ts index e2a4b51c96..03a854ae57 100644 --- a/open-sse/handlers/chatCore/logTruncation.ts +++ b/open-sse/handlers/chatCore/logTruncation.ts @@ -3,11 +3,11 @@ import { getChatLogMaxDepth, getChatLogArrayTailItems, getChatLogMaxObjectKeys, + getChatLogMaxBodyBytes, } from "@/lib/logEnv"; import { estimateSizeFast } from "../../utils/estimateSize.ts"; export const MEMORY_EXTRACTION_TEXT_LIMIT = 64 * 1024; -const MAX_LOG_BODY_CHARS = 8 * 1024; // 8KB cap for logged request/response bodies export function capMemoryExtractionText(value: string): string { if (value.length <= MEMORY_EXTRACTION_TEXT_LIMIT) return value; @@ -60,9 +60,10 @@ export function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown { /** * Truncate a large object for logging. If its JSON representation exceeds - * MAX_LOG_BODY_CHARS, return a lightweight summary instead of the full clone. - * This prevents persistAttemptLogs from holding multi-MB references to - * translatedBody across 17 call sites per request. + * the configured max body size (getChatLogMaxBodyBytes()), return a + * lightweight summary instead of the full clone. This prevents + * persistAttemptLogs from holding multi-MB references to translatedBody + * across 17 call sites per request. * * When the summarized object carries a `tools` definition, re-attach it * (bounded via `cloneBoundedChatLogPayload`) so the request-details view can @@ -75,8 +76,9 @@ export function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown { export function truncateForLog(value: unknown): Record | null | undefined { if (value === null || value === undefined) return value as null | undefined; if (typeof value !== "object") return value as unknown as Record; - const estimatedSize = estimateSizeFast(value); - if (estimatedSize <= MAX_LOG_BODY_CHARS) return value as Record; + const maxBodyBytes = getChatLogMaxBodyBytes(); + const estimatedSize = estimateSizeFast(value, maxBodyBytes); + if (estimatedSize <= maxBodyBytes) return value as Record; // Object is too large — return a summary instead of a deep clone const obj = value as Record; const summary: Record = { diff --git a/open-sse/utils/estimateSize.ts b/open-sse/utils/estimateSize.ts index 8a6f5ef76d..9eb7f178fd 100644 --- a/open-sse/utils/estimateSize.ts +++ b/open-sse/utils/estimateSize.ts @@ -3,15 +3,20 @@ * Safe for circular references (WeakSet). Iterative frames only (no recursive call stack). * * Budgets: - * - ESTIMATE_SIZE_BYTE_LIMIT (256 KiB): early-exit once counted bytes exceed the limit + * - byteLimit param (default ESTIMATE_SIZE_BYTE_LIMIT, 256 KiB): early-exit + * once counted bytes exceed the limit — pass the caller's own threshold + * explicitly rather than relying on the default, since a caller comparing + * against a bigger configured limit would otherwise never see a size + * above 256 KiB. * - ESTIMATE_SIZE_NODE_BUDGET: max value visits (containers + primitives/elements) * * Arrays are walked by index frame (never pre-push/copy every element reference). * Plain objects yield own enumerable values incrementally (no Object.keys materialization). - * Node-budget exhaustion returns a value strictly above 256 KiB so callers fail closed. + * Node-budget exhaustion returns a value strictly above the effective byteLimit + * so callers fail closed. */ -/** Byte early-exit threshold (256 KiB). */ +/** Default byte early-exit threshold (256 KiB) when a caller doesn't pass its own. */ export const ESTIMATE_SIZE_BYTE_LIMIT = 262_144; /** @@ -74,14 +79,22 @@ function expandContainerFrame(stack: Frame[], frame: Exclude) stack.push({ t: "v", v: (frame.o as Record)[next.value] }); } -export function estimateSizeFast(value: unknown): number { +/** + * @param byteLimit - early-exit threshold (default ESTIMATE_SIZE_BYTE_LIMIT, + * 256 KiB). Pass the actual threshold you're comparing against (see + * chatCore/logTruncation.ts::truncateForLog) so raising that threshold + * doesn't silently cap what this function is even capable of reporting — + * the byte check and the node-budget fail-closed fallback both key off this + * value, not the fixed module constant, when a caller supplies one. + */ +export function estimateSizeFast(value: unknown, byteLimit = ESTIMATE_SIZE_BYTE_LIMIT): number { let bytes = 0; let visitsLeft = ESTIMATE_SIZE_NODE_BUDGET; const seen = new WeakSet(); const stack: Frame[] = [{ t: "v", v: value }]; while (stack.length > 0) { - if (visitsLeft <= 0) return ESTIMATE_SIZE_BYTE_LIMIT + 1; + if (visitsLeft <= 0) return byteLimit + 1; const frame = stack.pop()!; if (!isValueFrame(frame)) { @@ -96,7 +109,7 @@ export function estimateSizeFast(value: unknown): number { const ty = typeof v; if (ty === "string" || ty === "number" || ty === "boolean") { bytes = addPrimitiveBytes(bytes, v as string | number | boolean); - if (bytes > ESTIMATE_SIZE_BYTE_LIMIT) return bytes; + if (bytes > byteLimit) return bytes; continue; } if (ty === "object") { diff --git a/src/lib/logEnv.ts b/src/lib/logEnv.ts index 8486628b4e..9f438f1eec 100644 --- a/src/lib/logEnv.ts +++ b/src/lib/logEnv.ts @@ -158,6 +158,16 @@ export function getChatLogMaxObjectKeys(): number { return parseNonNegativeInt(process.env.CHAT_LOG_MAX_OBJECT_KEYS, 80); } +/** + * Was a hardcoded/default 8KB — trivially exceeded by any real multi-turn + * agentic conversation, meaning the dashboard's "Full Conversation" panel + * could only ever show a placeholder instead of the actual messages for + * nearly every logged row of any conversation with real substance. + */ +export function getChatLogMaxBodyBytes(): number { + return parsePositiveInt(process.env.CHAT_LOG_MAX_BODY_KB, 1024) * 1024; +} + export function isChatDebugFileEnabled(): boolean { if (parseBoolean(process.env.CHAT_DEBUG_FILE, false)) return true; return process.env.APP_LOG_LEVEL?.trim().toLowerCase() === "debug"; diff --git a/src/shared/components/RequestLoggerDetail.tsx b/src/shared/components/RequestLoggerDetail.tsx index f6b55ad630..4cf4bf00c8 100644 --- a/src/shared/components/RequestLoggerDetail.tsx +++ b/src/shared/components/RequestLoggerDetail.tsx @@ -672,6 +672,21 @@ export default function RequestLoggerDetail({
    \u2014
    )} +
    +
    + Conversation +
    + {detail?.sessionTag || log.sessionTag ? ( +
    + {(detail?.sessionTag || log.sessionTag).slice(0, 20)}\u2026 +
    + ) : ( +
    \u2014
    + )} +
    )} diff --git a/tests/unit/chatcore-log-truncation.test.ts b/tests/unit/chatcore-log-truncation.test.ts index d050b4f90a..ef4b79fa0c 100644 --- a/tests/unit/chatcore-log-truncation.test.ts +++ b/tests/unit/chatcore-log-truncation.test.ts @@ -242,3 +242,35 @@ test("truncateForLog leaves small requests with `tools` unchanged (no regression // untouched — same reference, not a summary or a clone assert.equal(result, small); }); + +/** + * Real bug: the 8KB cap on logged request/response bodies was hardcoded, + * trivially exceeded by any real multi-turn agentic conversation — the + * dashboard's "Full Conversation" panel could only ever show a placeholder + * instead of the actual messages for nearly every logged row of any + * conversation with real substance. CHAT_LOG_MAX_BODY_KB makes this + * configurable; this pins that truncateForLog() actually reads it (not a + * baked-in literal) by proving a payload just over the OLD 8KB default + * survives untouched under a raised limit, then gets summarized again once + * the limit is lowered below it. + */ +test("truncateForLog honors a configured CHAT_LOG_MAX_BODY_KB instead of a hardcoded cap", () => { + const saved = process.env.CHAT_LOG_MAX_BODY_KB; + const payload = { + model: "gpt-4o", + // ~12KB of content — comfortably over the old hardcoded 8KB cap. + messages: [{ role: "user", content: "x".repeat(12 * 1024) }], + }; + try { + process.env.CHAT_LOG_MAX_BODY_KB = "1"; // 1KB — payload must be summarized + const summarized = truncateForLog(payload) as Record; + assert.equal(summarized._truncated, true, "expected summarization under a 1KB limit"); + + process.env.CHAT_LOG_MAX_BODY_KB = "64"; // 64KB — payload must pass through untouched + const untouched = truncateForLog(payload); + assert.equal(untouched, payload, "expected the payload untouched under a 64KB limit"); + } finally { + if (saved === undefined) delete process.env.CHAT_LOG_MAX_BODY_KB; + else process.env.CHAT_LOG_MAX_BODY_KB = saved; + } +}); diff --git a/tests/unit/estimateSizeFast.test.ts b/tests/unit/estimateSizeFast.test.ts index d7e5a7b6a8..84893097a6 100644 --- a/tests/unit/estimateSizeFast.test.ts +++ b/tests/unit/estimateSizeFast.test.ts @@ -68,6 +68,55 @@ test("estimateSizeFast early-exits at 262144 bytes (256KB)", () => { assert.ok(result >= 262144, `Should early-exit, got ${result}`); }); +/** + * Real bug: the byte early-exit was unconditionally ESTIMATE_SIZE_BYTE_LIMIT + * (256 KiB) with no way for a caller to raise it, so any caller comparing + * against a bigger configured threshold (e.g. logTruncation.ts's + * getChatLogMaxBodyBytes(), default 1 MiB) could never see a size above + * ~256 KiB — every payload up to their real threshold looked "under + * threshold" and truncation never fired for anything between 256 KiB and + * the caller's actual limit, silently letting oversized bodies through. + */ +test("estimateSizeFast respects a caller-supplied byteLimit above the 256KB default", () => { + const oneMiB = 1024 * 1024; + // Multiple 200KB elements: the 2nd element alone already crosses the + // default 256KB limit, so a hardcoded-256KB implementation early-exits + // there and never accumulates the 3rd/4th elements — only a truly + // caller-configurable limit reports the full, accurate total. + const payload = Array.from({ length: 4 }, () => "x".repeat(200_000)); + const trueTotal = payload.reduce((sum, s) => sum + s.length, 0); + + const withDefaultLimit = estimateSizeFast(payload); + assert.ok( + withDefaultLimit < trueTotal, + `sanity: default 256KB limit must early-exit before the true total, got ${withDefaultLimit}` + ); + + const withCustomLimit = estimateSizeFast(payload, oneMiB); + assert.equal( + withCustomLimit, + trueTotal, + "must report the true accumulated size instead of early-exiting at the default 256KB" + ); + assert.ok(withCustomLimit <= oneMiB, "payload must be recognized as under the caller's own limit"); +}); + +test("estimateSizeFast node-budget fail-closed return respects a caller-supplied byteLimit", () => { + const oneMiB = 1024 * 1024; + const hugeSparseArray = new Proxy([] as unknown[], { + get(target, prop, receiver) { + if (prop === "length") return 5_000_000; + if (typeof prop === "string" && /^[0-9]+$/.test(prop)) return null; + return Reflect.get(target, prop, receiver); + }, + }); + const result = estimateSizeFast(hugeSparseArray, oneMiB); + assert.ok( + result > oneMiB, + `node-budget exhaustion must fail closed above the CALLER's limit (${oneMiB}), not the default 256KB — got ${result}` + ); +}); + test("estimateSizeFast checks byte limit after numbers and booleans", () => { const almostForNumber = "x".repeat(ESTIMATE_SIZE_BYTE_LIMIT - 4); const withNumber = estimateSizeFast([almostForNumber, 1]); From 9fb7d6a4934d77a72483011531bf5bf266673600 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:53:26 -0300 Subject: [PATCH 054/100] cherry-pick(pr-9735): feat(logging): bump CHAT_LOG_ARRAY_TAIL_ITEMS default 24 -> 128 (#9864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(logging): bump CHAT_LOG_ARRAY_TAIL_ITEMS default 24 -> 128 Real agentic CLIs with many MCP servers routinely declare 40-50+ tools in a single request — a live OpenClaw session logged 47. The tail-24 default silently dropped the array's earlier entries behind an _omniroute_truncated_array marker, so investigating why a specific tool call (apply_patch) behaved oddly turned up nothing: its declared shape (function vs custom type) was unrecoverable from the call log across 40 recent requests, even though the calls themselves succeeded. Bumped the configurable default to comfortably cover real large tool lists with headroom. Updated .env.example and docs/reference/ ENVIRONMENT.md to match (env-doc-sync check passes). * test(logging): pin CHAT_LOG_ARRAY_TAIL_ITEMS default at 128 The bump commit had no dedicated test asserting the literal default value; the existing chatcore-log-truncation.test.ts derives its expectations from getChatLogArrayTailItems() itself, so it can't discriminate a regression back toward the old, too-small 24 default. --------- Co-authored-by: Markus Hartung --- .env.example | 2 +- docs/reference/ENVIRONMENT.md | 2 +- src/lib/logEnv.ts | 13 ++++++++- .../chat-log-array-tail-items-default.test.ts | 27 +++++++++++++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 tests/unit/chat-log-array-tail-items-default.test.ts diff --git a/.env.example b/.env.example index d82754aa2b..4e66a6b6c7 100644 --- a/.env.example +++ b/.env.example @@ -1357,7 +1357,7 @@ APP_LOG_TO_FILE=true # bodies is retained in the database. # Used by: open-sse/handlers/chatCore.ts — cloneBoundedChatLogPayload() # CHAT_LOG_TEXT_LIMIT=65536 # Max string length before truncation (default: 64 KB) -# CHAT_LOG_ARRAY_TAIL_ITEMS=24 # Number of array items retained from tail (default: 24) +# CHAT_LOG_ARRAY_TAIL_ITEMS=128 # Number of array items retained from tail (default: 128) # CHAT_LOG_MAX_DEPTH=6 # Max nesting depth before truncation (default: 6) # CHAT_LOG_MAX_OBJECT_KEYS=80 # Max object keys retained (default: 80, 0 = no limit) # CHAT_LOG_MAX_BODY_KB=1024 # Max request/response body size before summarizing, in KB (default: 1024) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 8cf4b31dd3..cd6423d4ea 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -722,7 +722,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | `PROXY_LOGS_TABLE_MAX_ROWS` | `100000` | Max rows in the `proxy_logs` SQLite table before pruning. | | `APP_LOG_ROTATION_CHECK_INTERVAL_MS` | `60000` (1 min) | How often `src/lib/logRotation.ts` re-checks the active log file size. | | `CHAT_LOG_TEXT_LIMIT` | `65536` | Max string length retained in chat log artifacts (default 64 KB). | -| `CHAT_LOG_ARRAY_TAIL_ITEMS` | `24` | Number of array items retained from the tail when truncating chat log payloads. | +| `CHAT_LOG_ARRAY_TAIL_ITEMS` | `128` | Number of array items retained from the tail when truncating chat log payloads. | | `CHAT_LOG_MAX_DEPTH` | `6` | Max nesting depth before chat log payloads are truncated. | | `CHAT_LOG_MAX_OBJECT_KEYS` | `80` | Max object keys retained in chat log payloads (0 = unlimited). | | `CHAT_LOG_MAX_BODY_KB` | `1024` | Max request/response body size before `truncateForLog()` summarizes it, in KB. | diff --git a/src/lib/logEnv.ts b/src/lib/logEnv.ts index 9f438f1eec..77195a0b9e 100644 --- a/src/lib/logEnv.ts +++ b/src/lib/logEnv.ts @@ -146,8 +146,19 @@ export function getChatLogTextLimit(): number { return parsePositiveInt(process.env.CHAT_LOG_TEXT_LIMIT, 64 * 1024); } +/** + * Was a hardcoded/default 24 — real agentic CLIs with many MCP servers + * routinely declare 40-50+ tools in a single `tools[]` array (a live + * OpenClaw session logged 47), so the tail-24 default silently dropped the + * array's earlier entries behind an `_omniroute_truncated_array` marker — + * including, in one traced case, the tool actually being called + * (`apply_patch`), making its declared shape unrecoverable from the call + * log even though the call itself succeeded. Bumped to comfortably cover + * real large tool lists with headroom; same configurable-override pattern + * as the sibling CHAT_LOG_TEXT_LIMIT/CHAT_LOG_MAX_BODY_KB vars. + */ export function getChatLogArrayTailItems(): number { - return parsePositiveInt(process.env.CHAT_LOG_ARRAY_TAIL_ITEMS, 24); + return parsePositiveInt(process.env.CHAT_LOG_ARRAY_TAIL_ITEMS, 128); } export function getChatLogMaxDepth(): number { diff --git a/tests/unit/chat-log-array-tail-items-default.test.ts b/tests/unit/chat-log-array-tail-items-default.test.ts new file mode 100644 index 0000000000..fa2bc4fb1f --- /dev/null +++ b/tests/unit/chat-log-array-tail-items-default.test.ts @@ -0,0 +1,27 @@ +/** + * Regression test for the CHAT_LOG_ARRAY_TAIL_ITEMS default bump 24 -> 128. + * + * Real agentic CLIs with many MCP servers routinely declare 40-50+ tools in + * a single request — a live OpenClaw session logged 47. The old tail-24 + * default silently dropped the array's earlier entries behind an + * `_omniroute_truncated_array` marker, including (in one traced case) the + * tool actually being called, making its declared shape unrecoverable from + * the call log even though the call itself succeeded. + * + * Pins the literal default so a future edit can't silently regress it back + * toward the old, too-small value. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { getChatLogArrayTailItems } from "@/lib/logEnv"; + +test("getChatLogArrayTailItems defaults to 128 (not the old 24) when unset", () => { + const saved = process.env.CHAT_LOG_ARRAY_TAIL_ITEMS; + delete process.env.CHAT_LOG_ARRAY_TAIL_ITEMS; + try { + assert.equal(getChatLogArrayTailItems(), 128); + } finally { + if (saved === undefined) delete process.env.CHAT_LOG_ARRAY_TAIL_ITEMS; + else process.env.CHAT_LOG_ARRAY_TAIL_ITEMS = saved; + } +}); From 61cb52399ea24524638fd13f2c006eb40c4f9a0a Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:53:32 -0300 Subject: [PATCH 055/100] fix(logging): use configurable max-depth when bounding logged tool_calls (#9865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requestLogger.ts's cloneBoundedForLog had its own hardcoded depth cap of 6, independent of the existing configurable getChatLogMaxDepth(). A typical Chat Completions response body's responseBody.choices[0].message.tool_calls[0].function sits at exactly depth 6, so every logged tool call's function field (name+arguments) was silently replaced with the literal string "[MaxDepth]" before ever being stored — corrupting the data, not just how it renders. Bumped the shared default 6->20 and switched requestLogger.ts to read it instead of using its own literal. (cherry picked from commit a2df6cf289cbab7cd618b8e55272434812f7a4a7) Co-authored-by: Markus Hartung --- open-sse/utils/requestLogger.ts | 3 +- src/lib/logEnv.ts | 11 +++++- .../unit/request-logger-bounded-clone.test.ts | 34 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/open-sse/utils/requestLogger.ts b/open-sse/utils/requestLogger.ts index f2ef74e84e..79a307c688 100644 --- a/open-sse/utils/requestLogger.ts +++ b/open-sse/utils/requestLogger.ts @@ -1,4 +1,5 @@ import { getPendingById } from "@/lib/usage/usageHistory"; +import { getChatLogMaxDepth } from "@/lib/logEnv"; import { sanitizeErrorMessage } from "./error.ts"; type JsonRecord = Record; @@ -148,7 +149,7 @@ export function cloneBoundedForLog(value: unknown, depth = 0, key: string | null if (ArrayBuffer.isView(value)) { return `[binary ${(value as ArrayBufferView).byteLength} bytes]`; } - if (depth >= 6) return "[MaxDepth]"; + if (depth >= getChatLogMaxDepth()) return "[MaxDepth]"; if (Array.isArray(value)) { // Idempotence (#7847): an already-bounded array is [marker, ...tail] — MAX_LOG_ARRAY_ITEMS + 1 diff --git a/src/lib/logEnv.ts b/src/lib/logEnv.ts index 77195a0b9e..95e9428aa6 100644 --- a/src/lib/logEnv.ts +++ b/src/lib/logEnv.ts @@ -161,8 +161,17 @@ export function getChatLogArrayTailItems(): number { return parsePositiveInt(process.env.CHAT_LOG_ARRAY_TAIL_ITEMS, 128); } +/** + * Was a hardcoded 6 — trivially too shallow for real Chat Completions tool + * calls: `body.choices[0].message.tool_calls[0].function` alone is already + * 6 levels deep (body→choices→[i]→message→tool_calls→[i]→function), so + * EVERY logged tool call got its `function` field (name + arguments) + * replaced outright with the literal string "[MaxDepth]" before the name/ + * arguments one level further in were ever reached — not an edge case, a + * universal truncation of tool-call data in call log artifacts. + */ export function getChatLogMaxDepth(): number { - return parsePositiveInt(process.env.CHAT_LOG_MAX_DEPTH, 6); + return parsePositiveInt(process.env.CHAT_LOG_MAX_DEPTH, 20); } export function getChatLogMaxObjectKeys(): number { diff --git a/tests/unit/request-logger-bounded-clone.test.ts b/tests/unit/request-logger-bounded-clone.test.ts index bcaa8b6cc4..9de71b9b5c 100644 --- a/tests/unit/request-logger-bounded-clone.test.ts +++ b/tests/unit/request-logger-bounded-clone.test.ts @@ -38,6 +38,40 @@ test("cloneBoundedForLog: nested tools field still exempt", () => { assert.equal(result.body.tools.length, 30); }); +// Regression: a Chat Completions response's tool_calls[].function is 6 levels +// deep from the response body (body -> choices -> [i] -> message -> tool_calls +// -> [i] -> function) — the depth cap used to be a hardcoded 6, so every +// logged tool call's `function` (name + arguments) got replaced outright with +// the literal string "[MaxDepth]", not just deeply truncated. This broke tool +// call rendering in the request-detail view for ANY response with a tool +// call — not an edge case, universal. +test("cloneBoundedForLog: tool_calls[].function survives at its natural depth (was clobbered to '[MaxDepth]')", () => { + const body = { + choices: [ + { + index: 0, + message: { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "write", arguments: '{"path":"/tmp/x","content":"hi"}' }, + }, + ], + }, + }, + ], + }; + const result = cloneBoundedForLog(body) as { + choices: Array<{ message: { tool_calls: Array<{ function: unknown }> } }>; + }; + const fn = result.choices[0].message.tool_calls[0].function; + assert.notEqual(fn, "[MaxDepth]", "function must not be clobbered to the MaxDepth placeholder"); + assert.deepEqual(fn, { name: "write", arguments: '{"path":"/tmp/x","content":"hi"}' }); +}); + test("cloneBoundedForLog: top-level array without key context still truncated", () => { const arr = Array.from({ length: 45 }, (_, i) => i); const result = cloneBoundedForLog(arr) as unknown[]; From 356fd5d6061f08da2b19f11a0cee6bf755d49845 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:53:39 -0300 Subject: [PATCH 056/100] fix(executors): repair DuckDuckGo AI Chat challenge solver (418 ERR_CHALLENGE) (#9866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every duckduckgo-web chat request failed with HTTP 418 ERR_CHALLENGE while duck.ai worked normally in a browser from the same IP. Ground truth was established by driving a real headful Chromium at duck.ai from that IP (it returned 200), so the environment was never the problem — the anti-abuse challenge solver was. Six independent defects were found; the first alone disabled the solver completely. 1. Module syntax inside the vm sandbox source. CHALLENGE_STUBS is executed with vm.runInContext, which compiles in SCRIPT mode. A refactor mass-added `export` to the five `function` declarations inside that template literal (they read as ordinary top-level TS functions), so every solve threw SyntaxError. The executor swallows solve failures and posts the raw unsolved challenge, which upstream answers with 418. 2. Double-escaped regex in a String.raw template. `\\s` in __parseCssDisplay reached the sandbox as a literal backslash, so the display regex never matched and a getComputedStyle probe silently read empty. 3. buildHtmlLookup undercounted descendants by one. `count` backs el.querySelectorAll('*').length; that returns DESCENDANTS and countHtmlElements already skips the #document-fragment root, so the `- 1` was wrong. Chromium reports 3 for '
  • HTMLElement -> Element), NodeList identity, a live body.children HTMLCollection, native-code toString, and sloppy-mode `this === window`. Nine of thirteen failed. Notably Math must NOT be sealed — Chromium reports Object.isSealed(Math) === false, and sealing it made our vector differ by one. 5. The solved payload dropped meta.origin / meta.stack / meta.duration. The duck.ai bundle always sends all three; captured browser requests confirm it. Without them upstream returns 418 even when every client_hash is correct. 6. reasoningEffort is now mandatory on duckchat/v1/chat. An otherwise byte-identical payload returns 200 with the field and 400 ERR_BAD_REQUEST without it (A/B verified live, repeated). Also removes the throwaway "seed" chat POST that ran before every real request. It existed to coax a usable challenge out of the upstream while the solver was broken; it only doubled chat calls against an IP-rate-limited endpoint, showing up as spurious 429 ERR_RATE_LIMIT. Verification: the solver now reproduces real Chromium's probe vectors exactly for all 8 captured challenge variants, and the executor returns 200 end-to-end live (non-streaming, streaming, claude-haiku-4-5, and a math prompt returning "42"). Tests: tests/unit/duckduckgo-challenge-solver-regression.test.ts (32 tests) and tests/unit/duckduckgo-reasoning-effort-required.test.ts (5 tests), backed by tests/fixtures/duckduckgo/challenge-variants.json — real captured challenge programs plus the probe vectors a real browser produced for them, so the suite asserts against recorded browser behaviour rather than our own output. Each fix was confirmed to fail its test when individually reverted. Co-authored-by: Mynacol --- open-sse/executors/duckduckgo-web.ts | 52 +--- .../executors/duckduckgo-web/challenge.ts | 180 +++++++++++- .../duckduckgo/challenge-variants.json | 106 +++++++ ...duckgo-challenge-solver-regression.test.ts | 258 ++++++++++++++++++ tests/unit/duckduckgo-challenge-split.test.ts | 79 ++++++ ...ckduckgo-reasoning-effort-required.test.ts | 134 +++++++++ 6 files changed, 757 insertions(+), 52 deletions(-) create mode 100644 tests/fixtures/duckduckgo/challenge-variants.json create mode 100644 tests/unit/duckduckgo-challenge-solver-regression.test.ts create mode 100644 tests/unit/duckduckgo-reasoning-effort-required.test.ts diff --git a/open-sse/executors/duckduckgo-web.ts b/open-sse/executors/duckduckgo-web.ts index 6b7eba2dce..3b066d3c0f 100644 --- a/open-sse/executors/duckduckgo-web.ts +++ b/open-sse/executors/duckduckgo-web.ts @@ -266,11 +266,14 @@ export function normalizeDuckDuckGoModel(model: string | undefined): string { } function getDuckDuckGoModelCapabilities(model: string): DuckDuckGoModelCapabilities { - // Per duckchat/v1/models (2026-07-22): claude-haiku-4-5 and gpt-oss-120b take a "low" - // reasoningEffort on the free tier; the others omit it (duck.ai applies its own default). + // `reasoningEffort` is REQUIRED on every duckchat/v1/chat request. Omitting it + // returns 400 ERR_BAD_REQUEST — A/B verified live against duck.ai with an + // otherwise byte-identical payload (200 with the field, 400 without, repeated). + // The live duck.ai bundle always sends one, so there is no "let the server + // pick a default" path any more. if (model === "claude-haiku-4-5") return { reasoningEffort: "low" }; if (model === "tinfoil/gpt-oss-120b") return { reasoningEffort: "low" }; - return { reasoningEffort: null }; + return { reasoningEffort: "none" }; } function extractDuckDuckGoFeVersion(html: string): string | null { @@ -368,7 +371,6 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { } private warmed = false; - private seeded = false; private feVersion = DEFAULT_FE_VERSION; private pendingVqdHash1: string | null = null; private readonly cookieJar = new Map(); @@ -574,7 +576,12 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { } await this.warmSession(mergedSignal); - await this.seedChallengeChain(upstreamModel, mergedSignal); + // NOTE: the throwaway "seed" chat POST that used to run here has been removed. + // It existed to coax a usable challenge out of the upstream while the solver + // was broken; now that the solver reproduces a real browser's probe vectors + // exactly, the first real request succeeds on its own. Keeping it only doubled + // the chat calls per user request against an IP-rate-limited endpoint, which + // showed up as spurious 429 ERR_RATE_LIMIT. const vqdHeaders = await this.acquireAuthHeaders(mergedSignal); if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) { clearTimeout(timeout); @@ -783,41 +790,6 @@ export class DuckDuckGoWebExecutor extends BaseExecutor { ); } - private async seedChallengeChain(model: string, signal: AbortSignal): Promise { - if (this.seeded || signal.aborted) return; - this.seeded = true; - const seedMessages = [{ role: "user", content: "hi" }]; - const previousPending = this.pendingVqdHash1; - try { - const vqdHeaders = await this.acquireAuthHeaders(signal); - if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) { - this.pendingVqdHash1 = previousPending; - return; - } - const response = await fetch(CHAT_URL, { - method: "POST", - headers: mergeHeadersCaseInsensitive(this.buildRequestHeaders(), { - Accept: "text/event-stream", - "Content-Type": "application/json", - "x-ddg-journey-id": randomUUID().replaceAll("-", ""), - "x-fe-signals": makeDuckDuckGoFeSignals(), - "x-fe-version": this.feVersion, - ...(vqdHeaders.vqd4 ? { "x-vqd-4": vqdHeaders.vqd4 } : {}), - ...(vqdHeaders.vqdHash1 ? { "x-vqd-hash-1": vqdHeaders.vqdHash1 } : {}), - }), - body: JSON.stringify(buildDuckDuckGoPayload(model, seedMessages, false)), - signal, - }); - this.rememberResponseCookies(response); - if (response.ok) this.rememberChallengeHeader(response); - else this.pendingVqdHash1 = previousPending; - await response.body?.cancel().catch(() => {}); - } catch (error) { - void error; - this.pendingVqdHash1 = previousPending; - } - } - private async processResponse( response: Response, streaming: boolean, diff --git a/open-sse/executors/duckduckgo-web/challenge.ts b/open-sse/executors/duckduckgo-web/challenge.ts index 3c0159ba3a..8b4ea22feb 100644 --- a/open-sse/executors/duckduckgo-web/challenge.ts +++ b/open-sse/executors/duckduckgo-web/challenge.ts @@ -5,12 +5,38 @@ import { createHash } from "node:crypto"; import vm from "node:vm"; import { parseFragment, serialize } from "parse5"; +// WARNING: the contents of this template literal are NOT TypeScript — they are plain +// script-mode JavaScript executed via `vm.runInContext`. `vm.runInContext` compiles in +// script (non-module) mode, so an `export` keyword anywhere in here is a hard +// SyntaxError that kills the whole solver. A refactor that mass-added `export` to the +// five `function` declarations below silently broke every DuckDuckGo chat request +// (solve threw -> unsolved challenge sent -> HTTP 418 ERR_CHALLENGE). Do not add +// `export`/`import` to this string; `duckduckgo-challenge-split.test.ts` guards this. export const CHALLENGE_STUBS = String.raw` var __ua = __DDG_REAL_UA__; var __HTML_LOOKUP = __DDG_HTML_LOOKUP__; -export function __makeHtmlElement(tag) { +// Browser-fidelity shims for the DDG "am I a real browser" probes. +// In a browser every built-in stringifies as native code; under a plain vm +// context the user-land re-declarations below would otherwise leak their source. +function __nativeFn(fn, name){ + Object.defineProperty(fn, 'name', { value: name, configurable: true }); + fn.toString = function(){ return 'function ' + name + '() { [native code] }'; }; + return fn; +} +__nativeFn(parseInt, 'parseInt'); +__nativeFn(parseFloat, 'parseFloat'); +__nativeFn(isNaN, 'isNaN'); +__nativeFn(encodeURIComponent, 'encodeURIComponent'); +__nativeFn(decodeURIComponent, 'decodeURIComponent'); +// NOTE: do NOT seal Math. Real Chromium reports Object.isSealed(Math) === false, +// and at least one challenge variant probes exactly that; sealing it here made +// the vector differ from the browser by one and failed the challenge. +function __makeHtmlElement(tag) { var state = { _innerHTML: '', _qsaCount: 0, _cssText: '' }; - var el = { + // Instantiate against the real per-tag constructor so + // document.createElement('div') instanceof HTMLDivElement holds. + var el = Object.create(__ctorForTag(tag).prototype); + Object.assign(el, { tagName: String(tag).toUpperCase(), nodeName: String(tag).toUpperCase(), nodeType: 1, children: [], childNodes: [], classList: [], dataset: {}, offsetWidth: 1, offsetHeight: 1, clientWidth: 1, clientHeight: 1, scrollHeight: 1, scrollWidth: 1, @@ -19,9 +45,9 @@ export function __makeHtmlElement(tag) { getAttribute: function(a){ if(a==='srcdoc') return state._srcdoc||''; return null; }, hasAttribute: function(){ return false; }, appendChild: function(c){ return c; }, removeChild: function(c){ return c; }, addEventListener: function(){}, removeEventListener: function(){}, querySelector: function(){ return null; }, - querySelectorAll: function(s){ if (s === '*') { var arr = []; arr.length = state._qsaCount; return arr; } return []; }, + querySelectorAll: function(s){ if (s === '*') { return __makeNodeList(state._qsaCount); } return __makeNodeList(0); }, cloneNode: function(){ return __makeHtmlElement(tag); } - }; + }); Object.defineProperty(el, 'style', { value: new Proxy({}, { set: function(t, k, v){ t[k] = v; if (k === 'cssText') state._cssText = String(v); return true; }, get: function(t, k){ if (k === 'cssText') return state._cssText; return t[k] || ''; } }), enumerable: true, configurable: true }); Object.defineProperty(el, 'innerHTML', { get: function(){ return state._innerHTML; }, set: function(v){ var key = String(v); var entry = __HTML_LOOKUP && __HTML_LOOKUP[key]; if (entry) { state._innerHTML = String(entry.html); state._qsaCount = entry.count|0; } else { state._innerHTML = key; state._qsaCount = 0; } }, enumerable: true, configurable: true }); Object.defineProperty(el, 'outerHTML', { get: function(){ return '<' + tag + '>' + state._innerHTML + ''; }, enumerable: true }); @@ -30,7 +56,7 @@ export function __makeHtmlElement(tag) { Object.defineProperty(el, 'contentDocument', { get: function(){ return __ifDoc; }, enumerable: true }); return el; } -export function __mkObj(name, base) { +function __mkObj(name, base) { base = base || {}; return new Proxy(base, { get: function(t, k) { @@ -54,18 +80,105 @@ export function __mkObj(name, base) { has: function(t, k){ return k in t; }, set: function(t, k, v){ t[k] = v; return true; } }); } -export function __parseCssDisplay(cssText){ if(!cssText) return ''; var m = String(cssText).match(/(?:^|;)\\s*display\\s*:\\s*([^;]+)/i); return m ? String(m[1]).trim() : ''; } -export function __getComputedStyle(el){ var cssText = el && el.style && el.style.cssText || ''; var display = __parseCssDisplay(cssText); return { getPropertyValue: function(name){ if(String(name).toLowerCase()==='display') return display; return ''; }, cssText: cssText, display: display }; } +function __parseCssDisplay(cssText){ if(!cssText) return ''; var m = String(cssText).match(/(?:^|;)\s*display\s*:\s*([^;]+)/i); return m ? String(m[1]).trim() : ''; } +function __getComputedStyle(el){ var cssText = el && el.style && el.style.cssText || ''; var display = __parseCssDisplay(cssText); return { getPropertyValue: function(name){ if(String(name).toLowerCase()==='display') return display; return ''; }, cssText: cssText, display: display }; } var __ifMeta = __mkObj('meta', { getAttribute: function(a){ return a==='content' ? "default-src 'none'; script-src 'unsafe-inline';" : null; }, hasAttribute: function(a){ return a==='content'; }, tagName: 'META', nodeName: 'META' }); var __ifDoc = __mkObj('iframeDoc', { querySelector: function(s){ if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; if (s === 'meta') return __ifMeta; return null; }, querySelectorAll: function(s){ if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; if (s === 'meta') return [__ifMeta]; return []; }, getElementsByTagName: function(t){ return t && t.toLowerCase()==='meta' ? [__ifMeta] : []; }, body: __mkObj('iframeBody'), head: __mkObj('iframeHead'), documentElement: __mkObj('iframeRoot'), createElement: function(){ return __mkObj('elem', {setAttribute:function(){}, appendChild:function(){}, removeChild:function(){}, getAttribute:function(){return null;}, hasAttribute:function(){return false;}}); }, cookie: '', readyState: 'complete' }); var __iframeEl = __mkObj('iframe', { contentDocument: __ifDoc, contentWindow: __mkObj('iframeWin', { document: __ifDoc, top: undefined, parent: undefined }), document: __ifDoc, getAttribute: function(a){ if (a==='sandbox') return 'allow-scripts allow-same-origin'; if (a==='srcdoc') return ''; if (a==='id') return 'jsa'; return null; }, hasAttribute: function(a){ return a==='sandbox'||a==='id'; }, tagName: 'IFRAME', nodeName: 'IFRAME', id: 'jsa' }); -var document = __mkObj('document', { querySelector: function(s){ if (s === '#jsa') return __iframeEl; if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; return null; }, querySelectorAll: function(s){ if (s === '#jsa') return [__iframeEl]; if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; return []; }, getElementById: function(id){ return id==='jsa' ? __iframeEl : null; }, getElementsByTagName: function(t){ if(t&&t.toLowerCase()==='iframe') return [__iframeEl]; return []; }, getElementsByClassName: function(){ return []; }, body: __mkObj('body', {appendChild:function(){}, removeChild:function(){}, querySelector:function(s){return s==='#jsa'?__iframeEl:null;}, querySelectorAll:function(s){return s==='#jsa'?[__iframeEl]:[];}}), head: __mkObj('head'), documentElement: __mkObj('root'), createElement: function(tag){ return __makeHtmlElement(tag||'div'); }, createTextNode: function(t){ return {nodeType:3, nodeValue:String(t||''), textContent:String(t||'')}; }, cookie: '', readyState: 'complete', title: '', addEventListener: function(){}, removeEventListener: function(){} }); +// document.body keeps a LIVE children collection: challenges append a node and +// assert body.children.length grew by exactly 1, then remove it again. +var __bodyKids = []; +Object.defineProperty(__bodyKids, 'constructor', { value: HTMLCollection, enumerable: false, configurable: true }); +var __body = __mkObj('body', { + appendChild: function(c){ __bodyKids.push(c); return c; }, + removeChild: function(c){ var i = __bodyKids.indexOf(c); if (i !== -1) __bodyKids.splice(i, 1); return c; }, + contains: function(c){ return __bodyKids.indexOf(c) !== -1; }, + querySelector: function(s){ return s === '#jsa' ? __iframeEl : null; }, + querySelectorAll: function(s){ return s === '#jsa' ? [__iframeEl] : __makeNodeList(0); }, + children: __bodyKids, childNodes: __bodyKids, + tagName: 'BODY', nodeName: 'BODY', nodeType: 1 +}); +var document = __mkObj('document', { querySelector: function(s){ if (s === '#jsa') return __iframeEl; if (s && s.indexOf('Content-Security-Policy') !== -1) return __ifMeta; return null; }, querySelectorAll: function(s){ if (s === '#jsa') return [__iframeEl]; if (s && s.indexOf('Content-Security-Policy') !== -1) return [__ifMeta]; return __makeNodeList(__bodyKids.length + 3); }, getElementById: function(id){ return id==='jsa' ? __iframeEl : null; }, getElementsByTagName: function(t){ if(t&&t.toLowerCase()==='iframe') return [__iframeEl]; return []; }, getElementsByClassName: function(){ return []; }, body: __body, head: __mkObj('head'), documentElement: __mkObj('root'), createElement: function(tag){ return __makeHtmlElement(tag||'div'); }, createTextNode: function(t){ return {nodeType:3, nodeValue:String(t||''), textContent:String(t||'')}; }, cookie: '', readyState: 'complete', title: '', addEventListener: function(){}, removeEventListener: function(){} }); var window = __mkObj('window', { document: document, __DDG_BE_VERSION__: 1, __DDG_FE_CHAT_HASH__: 1, navigator: __mkObj('navigator', { userAgent: __ua, webdriver: false, language: 'en-US', languages: ['en-US','en'], platform: 'Linux x86_64', vendor: 'Google Inc.', appVersion: '5.0 (X11)', cookieEnabled: true, onLine: true, hardwareConcurrency: 8, deviceMemory: 8 }), innerWidth: 1280, innerHeight: 800, outerWidth: 1280, outerHeight: 800, devicePixelRatio: 1, screen: __mkObj('screen', { width:1920, height:1080, availWidth:1920, availHeight:1080, colorDepth:24, pixelDepth:24 }), location: __mkObj('location', { href:'https://duck.ai/', origin:'https://duck.ai', host:'duck.ai', hostname:'duck.ai', protocol:'https:', pathname:'/' }), performance: __mkObj('perf', { now: function(){ return 0; }, timeOrigin: 0 }), history: __mkObj('history', { length: 1, state: null }), addEventListener: function(){}, removeEventListener: function(){}, dispatchEvent: function(){return true;}, setTimeout: function(fn){ try{fn();}catch(e){} return 0; }, clearTimeout: function(){}, hasOwnProperty: function(k){ if (k==='__DDG_BE_VERSION__'||k==='__DDG_FE_CHAT_HASH__') return true; return Object.prototype.hasOwnProperty.call(this,k); } }); window.top = window; window.self = window; window.window = window; window.parent = window; window.globalThis = window; +// Object.prototype.toString.call(window) must be "[object Window]". +try { window[Symbol.toStringTag] = 'Window'; } catch (e) {} +// In a browser a sloppy-mode function called with no receiver gets the global +// object, and challenges assert (function(){return this;})() === window. +// In a vm context that is the context's own global, so alias it to window. +try { + var __g = (function(){ return this; })(); + if (__g && __g !== window) { + Object.defineProperty(__g, Symbol.toStringTag, { value: 'Window', configurable: true }); + // Copy by VALUE, not via accessors. Two reasons: + // 1) the var top/self/navigator/... declarations further down are hoisted, + // so those names already exist on the vm global and an "in" guard would + // skip them, leaving window.navigator undefined; + // 2) accessors closing over the window binding would recurse once it is + // rebound to __g below. + // The stub window is static, so a value copy is equivalent. + var __winStub = window; + for (var __k in __winStub) { + try { __g[__k] = __winStub[__k]; } catch (e) {} + } + // hasOwnProperty is probed for the __DDG_* markers; keep the stub's version. + try { __g.hasOwnProperty = function(k){ return __winStub.hasOwnProperty(k); }; } catch (e) {} + window = __g; + window.top = window; window.self = window; window.window = window; window.parent = window; window.globalThis = window; + } +} catch (e) {} var top = window, self = window, parent = window, navigator = window.navigator, location = window.location, screen = window.screen, performance = window.performance, history = window.history; var __R = null, __E = null; -export function __HTMLClass(name){ var c = function(){}; c.prototype = __mkObj(name+'.proto'); return c; } -var HTMLElement = __HTMLClass('HTMLElement'), HTMLDivElement = __HTMLClass('HTMLDivElement'), HTMLIFrameElement = __HTMLClass('HTMLIFrameElement'), HTMLDocument = __HTMLClass('HTMLDocument'), Document = __HTMLClass('Document'), Element = __HTMLClass('Element'), Node = __HTMLClass('Node'), Window = __HTMLClass('Window'), Event = __HTMLClass('Event'), MouseEvent = __HTMLClass('MouseEvent'), KeyboardEvent = __HTMLClass('KeyboardEvent'), TouchEvent = __HTMLClass('TouchEvent'), XMLHttpRequest = __HTMLClass('XMLHttpRequest'), WebSocket = __HTMLClass('WebSocket'), Image = __HTMLClass('Image'), FormData = __HTMLClass('FormData'), Blob = __HTMLClass('Blob'), File = __HTMLClass('File'), FileReader = __HTMLClass('FileReader'), URL = __HTMLClass('URL'), URLSearchParams = __HTMLClass('URLSearchParams'), Headers = __HTMLClass('Headers'), Request = __HTMLClass('Request'), Response = __HTMLClass('Response'); +// Real DOM constructor chain. Some DDG challenge variants assert +// HTMLDivElement.prototype instanceof HTMLElement and +// HTMLElement.prototype instanceof Element, so these cannot be flat +// unrelated stubs — the prototype links have to be real. +function __DomClass(name, parent){ + var c = function(){}; + if (parent) c.prototype = Object.create(parent.prototype); + c.prototype.constructor = c; + Object.defineProperty(c, 'name', { value: name, configurable: true }); + c.toString = function(){ return 'function ' + name + '() { [native code] }'; }; + return c; +} +var EventTarget = __DomClass('EventTarget', null); +var Node = __DomClass('Node', EventTarget); +var Element = __DomClass('Element', Node); +var HTMLElement = __DomClass('HTMLElement', Element); +var HTMLDivElement = __DomClass('HTMLDivElement', HTMLElement); +var HTMLIFrameElement = __DomClass('HTMLIFrameElement', HTMLElement); +var HTMLLIElement = __DomClass('HTMLLIElement', HTMLElement); +var HTMLUnknownElement = __DomClass('HTMLUnknownElement', HTMLElement); +var Document = __DomClass('Document', Node); +var HTMLDocument = __DomClass('HTMLDocument', Document); +var NodeList = __DomClass('NodeList', null); +var HTMLCollection = __DomClass('HTMLCollection', null); +// Map a tag name to the constructor a browser would use, so +// document.createElement('div') instanceof HTMLDivElement holds. +function __ctorForTag(tag){ + var t = String(tag||'div').toLowerCase(); + if (t === 'div') return HTMLDivElement; + if (t === 'iframe') return HTMLIFrameElement; + if (t === 'li') return HTMLLIElement; + return HTMLElement; +} +// A NodeList-like: array-shaped but NOT a real Array, with .constructor.name +// === 'NodeList' — challenges check both !Array.isArray(x) and the ctor name. +function __makeNodeList(length){ + var nl = Object.create(NodeList.prototype); + var n = length|0; + for (var i = 0; i < n; i++) nl[i] = __makeHtmlElement('div'); + Object.defineProperty(nl, 'length', { value: n, enumerable: false, configurable: true }); + nl.item = function(i){ return this[i] || null; }; + nl.forEach = function(fn, thisArg){ for (var i = 0; i < n; i++) fn.call(thisArg, this[i], i, this); }; + nl[Symbol.iterator] = function(){ var i = 0, self = this; return { next: function(){ return i < n ? { value: self[i++], done: false } : { value: undefined, done: true }; } }; }; + return nl; +} +function __HTMLClass(name){ var c = function(){}; c.prototype = __mkObj(name+'.proto'); return c; } +// NOTE: HTMLElement / HTMLDivElement / HTMLIFrameElement / Element / Node / +// Document / HTMLDocument / NodeList are defined above via __DomClass with a +// REAL prototype chain — do not redeclare them here or the instanceof probes break. +var Window = __HTMLClass('Window'), Event = __HTMLClass('Event'), MouseEvent = __HTMLClass('MouseEvent'), KeyboardEvent = __HTMLClass('KeyboardEvent'), TouchEvent = __HTMLClass('TouchEvent'), XMLHttpRequest = __HTMLClass('XMLHttpRequest'), WebSocket = __HTMLClass('WebSocket'), Image = __HTMLClass('Image'), FormData = __HTMLClass('FormData'), Blob = __HTMLClass('Blob'), File = __HTMLClass('File'), FileReader = __HTMLClass('FileReader'), URL = __HTMLClass('URL'), URLSearchParams = __HTMLClass('URLSearchParams'), Headers = __HTMLClass('Headers'), Request = __HTMLClass('Request'), Response = __HTMLClass('Response'); var fetch = function(){ return Promise.resolve(__mkObj('resp', {ok:true, status:200, json:function(){return Promise.resolve({});}, text:function(){return Promise.resolve('');}})); }; var getComputedStyle = __getComputedStyle; `; @@ -90,9 +203,16 @@ export function buildHtmlLookup(js: string): Record
  • { // SECURITY NOTE: This function executes base64-decoded JavaScript from duck.ai via vm.runInContext. // The challenge code is upstream-supplied (supply-chain surface). It is sandboxed with a 5s timeout @@ -121,14 +260,31 @@ export async function solveDuckDuckGoChallenge( ); const context = vm.createContext({}); vm.runInContext(stubs, context, { timeout: 5000 }); + const startedAt = Date.now(); const result = (await vm.runInContext(js, context, { timeout: 5000, })) as DuckDuckGoChallengeResult; + const elapsedMs = Date.now() - startedAt; const clientHashes = Array.isArray(result.client_hashes) ? result.client_hashes : []; if (clientHashes.length === 0) throw new Error("DuckDuckGo challenge returned empty client_hashes"); clientHashes[0] = userAgent; result.client_hashes = clientHashes.map((hash) => sha256Base64(String(hash))); + + // The real frontend augments the challenge's own `meta` with origin / stack / + // duration before sending it back. Omitting them yields 418 ERR_CHALLENGE even + // when every client_hash is correct (confirmed by capturing a real browser's + // x-vqd-hash-1 header, which always carries all three). + const origin = options.origin ?? DUCKDUCKGO_CHALLENGE_ORIGIN; + const bundlePath = options.bundlePath ?? "/dist/duckai-dist/entry.duckai.js"; + const meta = (result.meta ?? {}) as Record; + result.meta = { + ...meta, + origin, + stack: buildChallengeStack(origin, bundlePath), + duration: String(elapsedMs), + }; + return Buffer.from(JSON.stringify(result), "utf8").toString("base64"); } diff --git a/tests/fixtures/duckduckgo/challenge-variants.json b/tests/fixtures/duckduckgo/challenge-variants.json new file mode 100644 index 0000000000..5da2505311 --- /dev/null +++ b/tests/fixtures/duckduckgo/challenge-variants.json @@ -0,0 +1,106 @@ +{ + "variant-0.js": { + "challengeBase64": "KGFzeW5jIGZ1bmN0aW9uKCl7Y29uc3QgXzB4MTk2YjdiPV8weDJiMmM7KGZ1bmN0aW9uKF8weDMyOTEwYyxfMHgzOTY5NDMpe2NvbnN0IF8weDI5MDJiMj1fMHgyYjJjLF8weDIyOGNiZj1fMHgzMjkxMGMoKTt3aGlsZSghIVtdKXt0cnl7Y29uc3QgXzB4MjA2NzE4PS1wYXJzZUludChfMHgyOTAyYjIoMHgxZjMpKS8weDEqKC1wYXJzZUludChfMHgyOTAyYjIoMHgxZDIpKS8weDIpKy1wYXJzZUludChfMHgyOTAyYjIoMHgxZDQpKS8weDMrLXBhcnNlSW50KF8weDI5MDJiMigweDFkYykpLzB4NCooLXBhcnNlSW50KF8weDI5MDJiMigweDFlNykpLzB4NSkrLXBhcnNlSW50KF8weDI5MDJiMigweDFjZCkpLzB4NiooLXBhcnNlSW50KF8weDI5MDJiMigweDFmMikpLzB4NykrcGFyc2VJbnQoXzB4MjkwMmIyKDB4MWNhKSkvMHg4KihwYXJzZUludChfMHgyOTAyYjIoMHgxZDcpKS8weDkpKy1wYXJzZUludChfMHgyOTAyYjIoMHgxZjgpKS8weGEqKC1wYXJzZUludChfMHgyOTAyYjIoMHgxZDUpKS8weGIpKy1wYXJzZUludChfMHgyOTAyYjIoMHgxZTkpKS8weGMqKHBhcnNlSW50KF8weDI5MDJiMigweDFjOCkpLzB4ZCk7aWYoXzB4MjA2NzE4PT09XzB4Mzk2OTQzKWJyZWFrO2Vsc2UgXzB4MjI4Y2JmWydwdXNoJ10oXzB4MjI4Y2JmWydzaGlmdCddKCkpO31jYXRjaChfMHg4NWIxNjApe18weDIyOGNiZlsncHVzaCddKF8weDIyOGNiZlsnc2hpZnQnXSgpKTt9fX0oXzB4M2NhOSwweGQ3YmNmKSk7ZnVuY3Rpb24gXzB4MmIyYyhfMHg0MWI4ODUsXzB4Y2RlN2RlKXtjb25zdCBfMHgzY2E5ZTg9XzB4M2NhOSgpO3JldHVybiBfMHgyYjJjPWZ1bmN0aW9uKF8weDJiMmNhMixfMHgxMTk4NTIpe18weDJiMmNhMj1fMHgyYjJjYTItMHgxYzg7bGV0IF8weDQ0NGMwMj1fMHgzY2E5ZThbXzB4MmIyY2EyXTtyZXR1cm4gXzB4NDQ0YzAyO30sXzB4MmIyYyhfMHg0MWI4ODUsXzB4Y2RlN2RlKTt9Y29uc3QgXzB4MzFlNTliPVtbJ3VhJywhW11dLFtfMHgxOTZiN2IoMHgxZTEpLCFbXV0sW18weDE5NmI3YigweDFmNiksIVtdXV0sXzB4NWI5NWM2PWF3YWl0IFByb21pc2VbJ2FsbCddKFtuYXZpZ2F0b3JbXzB4MTk2YjdiKDB4MWVjKV0sKGZ1bmN0aW9uKCl7Y29uc3QgXzB4NTUwZjBjPV8weDE5NmI3YixfMHg1ODA4ZWQ9ZG9jdW1lbnRbXzB4NTUwZjBjKDB4MWVlKV0oXzB4NTUwZjBjKDB4MWQ4KSk7cmV0dXJuIF8weDU4MDhlZFsnaW5uZXJIVE1MJ109JzxsaT48ZGl2PjwvbGk+PGxpPjwvZGl2JyxTdHJpbmcoMHg3YTgrXzB4NTgwOGVkW18weDU1MGYwYygweDFmNSldWydsZW5ndGgnXSpfMHg1ODA4ZWRbXzB4NTUwZjBjKDB4MWNjKV0oJyonKVtfMHg1NTBmMGMoMHgxZjApXSk7fSgpKSwoZnVuY3Rpb24oKXtjb25zdCBfMHgxNTlkMzg9XzB4MTk2YjdiO3JldHVybiBTdHJpbmcoW25hdmlnYXRvcltfMHgxNTlkMzgoMHgxZDApXT09PSEhW10sKGZ1bmN0aW9uKCl7Y29uc3QgXzB4MzMxZWIxPV8weDE1OWQzOCxfMHhjMjgxMjI9ZG9jdW1lbnRbXzB4MzMxZWIxKDB4MWVlKV0oXzB4MzMxZWIxKDB4MWNiKSk7XzB4YzI4MTIyWydzcmNkb2MnXT1fMHgzMzFlYjEoMHgxZWEpLGRvY3VtZW50W18weDMzMWViMSgweDFkZildW18weDMzMWViMSgweDFkMSldKF8weGMyODEyMik7bGV0IF8weDU3MzI5ZDtyZXR1cm4gXzB4YzI4MTIyWydjb250ZW50V2luZG93J10mJl8weGMyODEyMltfMHgzMzFlYjEoMHgxZTYpXVtfMHgzMzFlYjEoMHgxY2UpXSYmXzB4YzI4MTIyW18weDMzMWViMSgweDFlNildW18weDMzMWViMSgweDFjZSldW18weDMzMWViMSgweDFkMyldP18weDU3MzI5ZD1fMHhjMjgxMjJbXzB4MzMxZWIxKDB4MWU2KV1bXzB4MzMxZWIxKDB4MWNlKV1bXzB4MzMxZWIxKDB4MWQzKV1bJ3RvU3RyaW5nJ10oKTpfMHg1NzMyOWQ9dW5kZWZpbmVkLGRvY3VtZW50W18weDMzMWViMSgweDFkZildWydyZW1vdmVDaGlsZCddKF8weGMyODEyMiksISFfMHg1NzMyOWQ7fSgpKSwoZnVuY3Rpb24oKXtjb25zdCBfMHg0ZTI5MjQ9XzB4MTU5ZDM4LF8weDIzN2I5MT1bJ0FycmF5JyxfMHg0ZTI5MjQoMHgxZDYpLCdQcm9taXNlJyxfMHg0ZTI5MjQoMHgxZGEpLF8weDRlMjkyNCgweDFlZiksJ0pTT04nLF8weDRlMjkyNCgweDFlMCldLF8weDM0OTBhZD1PYmplY3RbXzB4NGUyOTI0KDB4MWRlKV0od2luZG93Wyd0b3AnXSlbXzB4NGUyOTI0KDB4MWY0KV0oXzB4NTBjOGE0PT5fMHgyMzdiOTFbXzB4NGUyOTI0KDB4MWNmKV0oXzB4NDYyMWUwPT5fMHg1MGM4YTQhPT1fMHg0NjIxZTAmJl8weDUwYzhhNFtfMHg0ZTI5MjQoMHgxZjkpXSgnXycrXzB4NDYyMWUwKSYmd2luZG93W18weDRlMjkyNCgweDFlMildW18weDUwYzhhNF09PT13aW5kb3dbJ3RvcCddW18weDQ2MjFlMF0pKTtyZXR1cm4gXzB4MzQ5MGFkW18weDRlMjkyNCgweDFmMCldPjB4MDt9KCkpXVtfMHgxNTlkMzgoMHgxZTMpXShOdW1iZXIpW18weDE1OWQzOCgweDFkOSldKChfMHgzYzU3OGIsXzB4NGMxMDA4KT0+XzB4M2M1NzhiK18weDRjMTAwOCwweDE4ZGYpKTt9KCkpXSksXzB4NDlmOTllPVtdLF8weDMxN2NkNj17fSxfMHg0NjRlOGU9Jzg1OWJlYzU3ZWVlYWYxMjYnO2ZvcihsZXQgXzB4MmE4YWI1PTB4MDtfMHgyYThhYjU8XzB4NWI5NWM2W18weDE5NmI3YigweDFmMCldO18weDJhOGFiNSsrKXtjb25zdCBfMHg0NDdlNTQ9XzB4NWI5NWM2W18weDJhOGFiNV07QXJyYXlbXzB4MTk2YjdiKDB4MWVkKV0oXzB4NDQ3ZTU0KT8oXzB4NDlmOTllW18weDE5NmI3YigweDFjOSldKF8weDQ0N2U1NFsweDBdKSxfMHg0NDdlNTRbJ2xlbmd0aCddPjB4MSYmXzB4MzFlNTliW18weDJhOGFiNV1bMHgxXSYmKF8weDMxN2NkNltfMHgzMWU1OWJbXzB4MmE4YWI1XVsweDBdXT1fMHg0NDdlNTRbMHgxXSkpOl8weDQ5Zjk5ZVtfMHgxOTZiN2IoMHgxYzkpXShfMHg0NDdlNTQpO31jb25zdCBfMHg3NjY2MDQ9QXJyYXlbJ2Zyb20nXShKU09OW18weDE5NmI3YigweDFlYildKF8weDMxN2NkNikpWydtYXAnXSgoXzB4MTJmNjA4LF8weDMwMjc1Myk9PlN0cmluZ1tfMHgxOTZiN2IoMHgxZjEpXShfMHgxMmY2MDhbXzB4MTk2YjdiKDB4MWY3KV0oMHgwKV5fMHg0NjRlOGVbJ2NoYXJDb2RlQXQnXShfMHgzMDI3NTMlXzB4NDY0ZThlW18weDE5NmI3YigweDFmMCldKSkpW18weDE5NmI3YigweDFlOCldKCcnKTtyZXR1cm57J3NlcnZlcl9oYXNoZXMnOltfMHgxOTZiN2IoMHgxZTQpLF8weDE5NmI3YigweDFkYiksXzB4MTk2YjdiKDB4MWU1KV0sJ2NsaWVudF9oYXNoZXMnOl8weDQ5Zjk5ZSwnc2lnbmFscyc6e30sJ21ldGEnOnsndic6JzQnLCdjaGFsbGVuZ2VfaWQnOidlOTgwMzlkOTI0ZWUyMDNiOTI3NWVlNGE4MTRkMmQ0NGIxMmZjYTU0ODhlYzc5ZTQ3OWYzMzJhYTg5MDZmMmQ5aDhqYnQnLCd0aW1lc3RhbXAnOl8weDE5NmI3YigweDFkZCksJ2RlYnVnJzpfMHg3NjY2MDR9fTtmdW5jdGlvbiBfMHgzY2E5KCl7Y29uc3QgXzB4NGJjN2JiPVsnMTc4NjA5MzU3NzM5MScsJ2tleXMnLCdib2R5JywnV2luZG93JywnaDhqYnQnLCd0b3AnLCdtYXAnLCdsK00vblRsbFk4bTAvNEtFMDNHUFhMNEZ2UHQxMmY0Y0xMaGE0YTI4V0ZvPScsJy8rMHB6TGNZdlJzMkpoRzFHWE91RGlSV2RxRzh2MWJlNm1kOUc4T2ptSk09JywnY29udGVudFdpbmRvdycsJzM3ODA5NXhlTXN5dScsJ2pvaW4nLCc2NDIwMHBmZHJBSScsJ0R1Y2tEdWNrR29ceDIwRnJhdWRceDIwJlx4MjBBYnVzZScsJ3N0cmluZ2lmeScsJ3VzZXJBZ2VudCcsJ2lzQXJyYXknLCdjcmVhdGVFbGVtZW50JywnU3ltYm9sJywnbGVuZ3RoJywnZnJvbUNoYXJDb2RlJywnMjFwdVB0d1AnLCcxMzk2NjF2aVBadmYnLCdmaWx0ZXInLCdpbm5lckhUTUwnLCdpM2pwMCcsJ2NoYXJDb2RlQXQnLCczMjYwYnVSaG14JywnZW5kc1dpdGgnLCc2MjUzdnpPa0xnJywncHVzaCcsJzE2MEpuTUNjUicsJ2lmcmFtZScsJ3F1ZXJ5U2VsZWN0b3JBbGwnLCc5Mzc2NjJSQklBUG4nLCdzZWxmJywnc29tZScsJ3dlYmRyaXZlcicsJ2FwcGVuZENoaWxkJywnMTJjV29FWU8nLCdnZXQnLCczMDM0ODQyVWhSbkZTJywnNDc1MzFCYXlld1EnLCdPYmplY3QnLCcyNDQ0NzZyZW1ZemonLCdkaXYnLCdyZWR1Y2UnLCdQcm94eScsJ1lFeGk1Z1dDcGJTNnliazV5YUdsQlgwRG9RMmlNTC9xSmQ3cU9pRGJqdHM9JywnNjRsaEtJbXknXTtfMHgzY2E5PWZ1bmN0aW9uKCl7cmV0dXJuIF8weDRiYzdiYjt9O3JldHVybiBfMHgzY2E5KCk7fX0pKCk=", + "browserProbes": ["2047", "6367"], + "browserReduceVectors": [ + { + "seed": 6367, + "booleans": [0, 0, 0] + } + ] + }, + "variant-1.js": { + "challengeBase64": "KGFzeW5jIGZ1bmN0aW9uKCl7Y29uc3QgXzB4MjcyMTk0PV8weDE1NDQ7ZnVuY3Rpb24gXzB4MTU0NChfMHgyYWZhN2MsXzB4NTlkM2NiKXtjb25zdCBfMHg1NWYzYzg9XzB4NTVmMygpO3JldHVybiBfMHgxNTQ0PWZ1bmN0aW9uKF8weDE1NDRkYyxfMHg0ZmJjMWYpe18weDE1NDRkYz1fMHgxNTQ0ZGMtMHgxZjM7bGV0IF8weGY4MjkwMT1fMHg1NWYzYzhbXzB4MTU0NGRjXTtyZXR1cm4gXzB4ZjgyOTAxO30sXzB4MTU0NChfMHgyYWZhN2MsXzB4NTlkM2NiKTt9KGZ1bmN0aW9uKF8weDM5ZDVlZixfMHgyNTJlM2Qpe2NvbnN0IF8weDU4NjdjYz1fMHgxNTQ0LF8weDIxMWNhNz1fMHgzOWQ1ZWYoKTt3aGlsZSghIVtdKXt0cnl7Y29uc3QgXzB4ODIxMTc9cGFyc2VJbnQoXzB4NTg2N2NjKDB4MjAwKSkvMHgxK3BhcnNlSW50KF8weDU4NjdjYygweDIyMCkpLzB4MitwYXJzZUludChfMHg1ODY3Y2MoMHgyMTgpKS8weDMqKHBhcnNlSW50KF8weDU4NjdjYygweDIwYSkpLzB4NCkrcGFyc2VJbnQoXzB4NTg2N2NjKDB4MjFhKSkvMHg1KigtcGFyc2VJbnQoXzB4NTg2N2NjKDB4MWY2KSkvMHg2KStwYXJzZUludChfMHg1ODY3Y2MoMHgyMDQpKS8weDcrLXBhcnNlSW50KF8weDU4NjdjYygweDIwZCkpLzB4OCstcGFyc2VJbnQoXzB4NTg2N2NjKDB4MjA3KSkvMHg5KihwYXJzZUludChfMHg1ODY3Y2MoMHgxZmIpKS8weGEpO2lmKF8weDgyMTE3PT09XzB4MjUyZTNkKWJyZWFrO2Vsc2UgXzB4MjExY2E3WydwdXNoJ10oXzB4MjExY2E3WydzaGlmdCddKCkpO31jYXRjaChfMHhkZGFlN2Mpe18weDIxMWNhN1sncHVzaCddKF8weDIxMWNhN1snc2hpZnQnXSgpKTt9fX0oXzB4NTVmMywweDliNzI5KSk7Y29uc3QgXzB4MjkxMjk2PVtbJ3VhJywhW11dLFtfMHgyNzIxOTQoMHgyMjYpLCFbXV0sW18weDI3MjE5NCgweDIxMSksIVtdXV0sXzB4MjczMWEzPWF3YWl0IFByb21pc2VbJ2FsbCddKFtuYXZpZ2F0b3JbXzB4MjcyMTk0KDB4MjEwKV0sKGZ1bmN0aW9uKCl7Y29uc3QgXzB4MmU2NjhlPV8weDI3MjE5NCxfMHg0NjEwZjk9W10sXzB4NDVjMThhPXdpbmRvd1tfMHgyZTY2OGUoMHgyMTUpXTtfMHg0NjEwZjlbXzB4MmU2NjhlKDB4MjBlKV0oXzB4NDVjMThhW18weDJlNjY4ZSgweDIyNCldKClbXzB4MmU2NjhlKDB4MjBmKV0oXzB4MmU2NjhlKDB4MjEzKSkpO2NsYXNzIF8weDU3OTIxMyBleHRlbmRzIEFycmF5e31jb25zdCBfMHgxZTM4M2Y9bmV3IF8weDU3OTIxMygweDEsMHgyLDB4MyksXzB4MzMxYjFhPV8weDFlMzgzZltfMHgyZTY2OGUoMHgxZjcpXShfMHgyYTQ5OTA9Pl8weDJhNDk5MCoweDIpO18weDQ2MTBmOVtfMHgyZTY2OGUoMHgyMGUpXShfMHgzMzFiMWEgaW5zdGFuY2VvZiBfMHg1NzkyMTMpLF8weDQ2MTBmOVtfMHgyZTY2OGUoMHgyMGUpXShPYmplY3RbXzB4MmU2NjhlKDB4MWY0KV1bXzB4MmU2NjhlKDB4MjI0KV1bXzB4MmU2NjhlKDB4MWZjKV0od2luZG93KT09PSdbb2JqZWN0XHgyMFdpbmRvd10nKTtjb25zdCBfMHgzMjY0NTU9RXJyb3I7XzB4NDYxMGY5WydwdXNoJ10obmV3IF8weDMyNjQ1NSgpaW5zdGFuY2VvZiBFcnJvciksXzB4NDYxMGY5W18weDJlNjY4ZSgweDIwZSldKF8weDMyNjQ1NVtfMHgyZTY2OGUoMHgyMjkpXT09PXVuZGVmaW5lZHx8dHlwZW9mIF8weDMyNjQ1NVsnY2FwdHVyZVN0YWNrVHJhY2UnXT09PSdmdW5jdGlvbicpLF8weDQ2MTBmOVtfMHgyZTY2OGUoMHgyMGUpXShPYmplY3RbXzB4MmU2NjhlKDB4MjFkKV0oTWF0aCkpLF8weDQ2MTBmOVtfMHgyZTY2OGUoMHgyMGUpXSgoZnVuY3Rpb24oKXtyZXR1cm4gdGhpczt9KCkpPT09d2luZG93KTtjb25zdCBfMHgxNTYzMzM9ZG9jdW1lbnRbXzB4MmU2NjhlKDB4MjE3KV1bXzB4MmU2NjhlKDB4MjA1KV0sXzB4MzViNDMxPV8weDE1NjMzM1snbGVuZ3RoJ10sXzB4MWUwYmJkPWRvY3VtZW50W18weDJlNjY4ZSgweDIwOSldKF8weDJlNjY4ZSgweDIwNikpO2RvY3VtZW50Wydib2R5J11bJ2FwcGVuZENoaWxkJ10oXzB4MWUwYmJkKSxfMHg0NjEwZjlbXzB4MmU2NjhlKDB4MjBlKV0oXzB4MTU2MzMzW18weDJlNjY4ZSgweDIyYSldPT09XzB4MzViNDMxKzB4MSksZG9jdW1lbnRbXzB4MmU2NjhlKDB4MjE3KV1bXzB4MmU2NjhlKDB4MWY4KV0oXzB4MWUwYmJkKTtjb25zdCBfMHgzZjBjNTU9ZG9jdW1lbnRbJ3F1ZXJ5U2VsZWN0b3JBbGwnXSgnKicpO18weDQ2MTBmOVsncHVzaCddKCFBcnJheVtfMHgyZTY2OGUoMHgxZmEpXShfMHgzZjBjNTUpKSxfMHg0NjEwZjlbJ3B1c2gnXShfMHgzZjBjNTVbXzB4MmU2NjhlKDB4MjFmKV1bXzB4MmU2NjhlKDB4MjIxKV09PT1fMHgyZTY2OGUoMHgyMjcpKTtjb25zdCBfMHgzMzE2MTc9ZG9jdW1lbnRbXzB4MmU2NjhlKDB4MjA5KV0oXzB4MmU2NjhlKDB4MjA2KSk7cmV0dXJuIF8weDQ2MTBmOVsncHVzaCddKF8weDMzMTYxNyBpbnN0YW5jZW9mIEhUTUxEaXZFbGVtZW50KSxfMHg0NjEwZjlbXzB4MmU2NjhlKDB4MjBlKV0oSFRNTERpdkVsZW1lbnRbXzB4MmU2NjhlKDB4MWY0KV1pbnN0YW5jZW9mIEhUTUxFbGVtZW50KSxfMHg0NjEwZjlbXzB4MmU2NjhlKDB4MjBlKV0oSFRNTEVsZW1lbnRbJ3Byb3RvdHlwZSddaW5zdGFuY2VvZiBFbGVtZW50KSxTdHJpbmcoXzB4NDYxMGY5W18weDJlNjY4ZSgweDFmNyldKE51bWJlcilbXzB4MmU2NjhlKDB4MjI4KV0oKF8weDNlNjBmOSxfMHg0OGY2MTcpPT5fMHgzZTYwZjkrXzB4NDhmNjE3LDB4NTlhKSk7fSgpKSwoZnVuY3Rpb24oKXtjb25zdCBfMHhiNTRlNDY9XzB4MjcyMTk0O3JldHVybiBTdHJpbmcoW25hdmlnYXRvclsnd2ViZHJpdmVyJ109PT0hIVtdLChmdW5jdGlvbigpe2NvbnN0IF8weDI2MjJjZD1fMHgxNTQ0LF8weDE0M2Y5Yj1kb2N1bWVudFtfMHgyNjIyY2QoMHgyMDkpXShfMHgyNjIyY2QoMHgyMDEpKTtfMHgxNDNmOWJbJ3NyY2RvYyddPSdEdWNrRHVja0dvXHgyMEZyYXVkXHgyMCZceDIwQWJ1c2UnLGRvY3VtZW50W18weDI2MjJjZCgweDIxNyldWydhcHBlbmRDaGlsZCddKF8weDE0M2Y5Yik7bGV0IF8weDNmNjYxMTtyZXR1cm4gXzB4MTQzZjliW18weDI2MjJjZCgweDIyMildJiZfMHgxNDNmOWJbXzB4MjYyMmNkKDB4MjIyKV1bJ3NlbGYnXSYmXzB4MTQzZjliW18weDI2MjJjZCgweDIyMildW18weDI2MjJjZCgweDIxOSldW18weDI2MjJjZCgweDFmMyldP18weDNmNjYxMT1fMHgxNDNmOWJbXzB4MjYyMmNkKDB4MjIyKV1bXzB4MjYyMmNkKDB4MjE5KV1bXzB4MjYyMmNkKDB4MWYzKV1bXzB4MjYyMmNkKDB4MjI0KV0oKTpfMHgzZjY2MTE9dW5kZWZpbmVkLGRvY3VtZW50W18weDI2MjJjZCgweDIxNyldW18weDI2MjJjZCgweDFmOCldKF8weDE0M2Y5YiksISFfMHgzZjY2MTE7fSgpKSwoZnVuY3Rpb24oKXtjb25zdCBfMHgxNmM3MjI9XzB4MTU0NCxfMHg0NzFiOTg9W18weDE2YzcyMigweDIxYiksJ09iamVjdCcsXzB4MTZjNzIyKDB4MjAzKSxfMHgxNmM3MjIoMHgyMWUpLCdTeW1ib2wnLF8weDE2YzcyMigweDIxYyksXzB4MTZjNzIyKDB4MjIzKV0sXzB4NTMwMmIyPU9iamVjdFtfMHgxNmM3MjIoMHgyMDIpXSh3aW5kb3dbXzB4MTZjNzIyKDB4MWY1KV0pWydmaWx0ZXInXShfMHg0NDBkNzg9Pl8weDQ3MWI5OFtfMHgxNmM3MjIoMHgyMGIpXShfMHg0NDNiMjQ9Pl8weDQ0MGQ3OCE9PV8weDQ0M2IyNCYmXzB4NDQwZDc4W18weDE2YzcyMigweDIxNCldKCdfJytfMHg0NDNiMjQpJiZ3aW5kb3dbXzB4MTZjNzIyKDB4MWY1KV1bXzB4NDQwZDc4XT09PXdpbmRvd1sndG9wJ11bXzB4NDQzYjI0XSkpO3JldHVybiBfMHg1MzAyYjJbXzB4MTZjNzIyKDB4MjJhKV0+MHgwO30oKSldW18weGI1NGU0NigweDFmNyldKE51bWJlcilbXzB4YjU0ZTQ2KDB4MjI4KV0oKF8weDFiNWM5YyxfMHg4M2IzNjkpPT5fMHgxYjVjOWMrXzB4ODNiMzY5LDB4MjQwMCkpO30oKSldKSxfMHgyOWY5OTU9W10sXzB4MWIwZGRhPXt9LF8weDFjYjMzZD1fMHgyNzIxOTQoMHgxZmUpO2ZvcihsZXQgXzB4NWIzYTcwPTB4MDtfMHg1YjNhNzA8XzB4MjczMWEzW18weDI3MjE5NCgweDIyYSldO18weDViM2E3MCsrKXtjb25zdCBfMHgxMjg2YmE9XzB4MjczMWEzW18weDViM2E3MF07QXJyYXlbJ2lzQXJyYXknXShfMHgxMjg2YmEpPyhfMHgyOWY5OTVbXzB4MjcyMTk0KDB4MjBlKV0oXzB4MTI4NmJhWzB4MF0pLF8weDEyODZiYVsnbGVuZ3RoJ10+MHgxJiZfMHgyOTEyOTZbXzB4NWIzYTcwXVsweDFdJiYoXzB4MWIwZGRhW18weDI5MTI5NltfMHg1YjNhNzBdWzB4MF1dPV8weDEyODZiYVsweDFdKSk6XzB4MjlmOTk1W18weDI3MjE5NCgweDIwZSldKF8weDEyODZiYSk7fWNvbnN0IF8weDMzYjkwNT1BcnJheVtfMHgyNzIxOTQoMHgyMTIpXShKU09OW18weDI3MjE5NCgweDIyNSldKF8weDFiMGRkYSkpW18weDI3MjE5NCgweDFmNyldKChfMHgxZTNmNGEsXzB4NGIxYjMyKT0+U3RyaW5nW18weDI3MjE5NCgweDFmZCldKF8weDFlM2Y0YVtfMHgyNzIxOTQoMHgyMGMpXSgweDApXl8weDFjYjMzZFtfMHgyNzIxOTQoMHgyMGMpXShfMHg0YjFiMzIlXzB4MWNiMzNkWydsZW5ndGgnXSkpKVtfMHgyNzIxOTQoMHgyMDgpXSgnJyk7ZnVuY3Rpb24gXzB4NTVmMygpe2NvbnN0IF8weDQwYmRiYz1bJ0FycmF5JywnSlNPTicsJ2lzU2VhbGVkJywnUHJveHknLCdjb25zdHJ1Y3RvcicsJzExMzkyMzZ4YWJVYWMnLCduYW1lJywnY29udGVudFdpbmRvdycsJ1dpbmRvdycsJ3RvU3RyaW5nJywnc3RyaW5naWZ5JywncHhqenInLCdOb2RlTGlzdCcsJ3JlZHVjZScsJ2NhcHR1cmVTdGFja1RyYWNlJywnbGVuZ3RoJywnZ2V0JywncHJvdG90eXBlJywndG9wJywnODMyMjE4YVlIYU1LJywnbWFwJywncmVtb3ZlQ2hpbGQnLCczZ0RIaDJpTFRIMXkyRXBiTjd6NktyNllta3RNeU5wWm40SjlKN2NMZkRrPScsJ2lzQXJyYXknLCcxMFRjdVF1SycsJ2NhbGwnLCdmcm9tQ2hhckNvZGUnLCc3MWE5OTJmYjc2NTM1N2EwJywnMTc4NjA5MzU4NDYzMicsJzg5MjIwM01semVlZicsJ2lmcmFtZScsJ2tleXMnLCdQcm9taXNlJywnMTQ1MjYzM3d1Y29tUScsJ2NoaWxkcmVuJywnZGl2JywnMzcyNjk5RERVcFpqJywnam9pbicsJ2NyZWF0ZUVsZW1lbnQnLCc0SHNCQ29YJywnc29tZScsJ2NoYXJDb2RlQXQnLCc2OTczNDY0RHdyYllrJywncHVzaCcsJ2luY2x1ZGVzJywndXNlckFnZW50JywnaTNqcDAnLCdmcm9tJywnW25hdGl2ZVx4MjBjb2RlXScsJ2VuZHNXaXRoJywncGFyc2VJbnQnLCdvL0JiTEJZRHJ6OTlMNGVrUjlxUE1HQlV2WTYwYXNVeDhjOWxHTE0ya2dnPScsJ2JvZHknLCc4ODk3MjhMU0VtUXgnLCdzZWxmJywnMTVLR2dRZ0YnXTtfMHg1NWYzPWZ1bmN0aW9uKCl7cmV0dXJuIF8weDQwYmRiYzt9O3JldHVybiBfMHg1NWYzKCk7fXJldHVybnsnc2VydmVyX2hhc2hlcyc6W18weDI3MjE5NCgweDFmOSksJzY3OUJVOHFoL25jVlBMRkwvK2p2NXl4anJ6WkVMY2lWVFRDcThWSGs3SVU9JyxfMHgyNzIxOTQoMHgyMTYpXSwnY2xpZW50X2hhc2hlcyc6XzB4MjlmOTk1LCdzaWduYWxzJzp7fSwnbWV0YSc6eyd2JzonNCcsJ2NoYWxsZW5nZV9pZCc6JzM5NTkyYmQ2YzM5MWIwNzZjYTY5NDMzYzliMDA5NDIwMWQwOWY1NmY5ZmU0ODk3YjI5YzQ2OTJlNmY1NDFjMzFweGp6cicsJ3RpbWVzdGFtcCc6XzB4MjcyMTk0KDB4MWZmKSwnZGVidWcnOl8weDMzYjkwNX19O30pKCk=", + "browserProbes": ["1446", "9216"], + "browserReduceVectors": [ + { + "seed": 1434, + "booleans": [1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1] + }, + { + "seed": 9216, + "booleans": [0, 0, 0] + } + ] + }, + "variant-2.js": { + "challengeBase64": "KGFzeW5jIGZ1bmN0aW9uKCl7Y29uc3QgXzB4MWVjMGU3PV8weDEwZDQ7KGZ1bmN0aW9uKF8weDNjMDBlYyxfMHg1YjRiOWUpe2NvbnN0IF8weDI1NmNjMT1fMHgxMGQ0LF8weDEyMjgwOT1fMHgzYzAwZWMoKTt3aGlsZSghIVtdKXt0cnl7Y29uc3QgXzB4MTc2N2YxPXBhcnNlSW50KF8weDI1NmNjMSgweDFkNCkpLzB4MStwYXJzZUludChfMHgyNTZjYzEoMHgxZDIpKS8weDIrcGFyc2VJbnQoXzB4MjU2Y2MxKDB4MWQ4KSkvMHgzK3BhcnNlSW50KF8weDI1NmNjMSgweDFkYSkpLzB4NCtwYXJzZUludChfMHgyNTZjYzEoMHgxYjMpKS8weDUqKHBhcnNlSW50KF8weDI1NmNjMSgweDFiNCkpLzB4NikrLXBhcnNlSW50KF8weDI1NmNjMSgweDFkNikpLzB4NyooLXBhcnNlSW50KF8weDI1NmNjMSgweDFlYykpLzB4OCkrLXBhcnNlSW50KF8weDI1NmNjMSgweDFlNCkpLzB4OTtpZihfMHgxNzY3ZjE9PT1fMHg1YjRiOWUpYnJlYWs7ZWxzZSBfMHgxMjI4MDlbJ3B1c2gnXShfMHgxMjI4MDlbJ3NoaWZ0J10oKSk7fWNhdGNoKF8weDQwZTBhMSl7XzB4MTIyODA5WydwdXNoJ10oXzB4MTIyODA5WydzaGlmdCddKCkpO319fShfMHg0NGExLDB4YjhmZTkpKTtjb25zdCBfMHgyMzM5ZDQ9W1sndWEnLCFbXV0sWydweGp6cicsIVtdXSxbJ2kzanAwJywhW11dXSxfMHg1NjQxNjM9YXdhaXQgUHJvbWlzZVtfMHgxZWMwZTcoMHgxZDcpXShbbmF2aWdhdG9yW18weDFlYzBlNygweDFkOSldLChmdW5jdGlvbigpe2NvbnN0IF8weDNmN2ZiMD1fMHgxZWMwZTcsXzB4MjA4YTA4PVtdLF8weDFiOGRkOT13aW5kb3dbXzB4M2Y3ZmIwKDB4MWM3KV07XzB4MjA4YTA4W18weDNmN2ZiMCgweDFjOCldKF8weDFiOGRkOVtfMHgzZjdmYjAoMHgxYjkpXSgpWydpbmNsdWRlcyddKCdbbmF0aXZlXHgyMGNvZGVdJykpO2NsYXNzIF8weDI1NGUyMiBleHRlbmRzIEFycmF5e31jb25zdCBfMHgyMDk0ZDM9bmV3IF8weDI1NGUyMigweDEsMHgyLDB4MyksXzB4MjJhNmJiPV8weDIwOTRkM1tfMHgzZjdmYjAoMHgxY2UpXShfMHgxYmRhZjk9Pl8weDFiZGFmOSoweDIpO18weDIwOGEwOFtfMHgzZjdmYjAoMHgxYzgpXShfMHgyMmE2YmIgaW5zdGFuY2VvZiBfMHgyNTRlMjIpLF8weDIwOGEwOFtfMHgzZjdmYjAoMHgxYzgpXShPYmplY3RbXzB4M2Y3ZmIwKDB4MWM5KV1bJ3RvU3RyaW5nJ11bXzB4M2Y3ZmIwKDB4MWUxKV0od2luZG93KT09PV8weDNmN2ZiMCgweDFlMCkpO2NvbnN0IF8weDVkYWZkZj1FcnJvcjtfMHgyMDhhMDhbXzB4M2Y3ZmIwKDB4MWM4KV0obmV3IF8weDVkYWZkZigpaW5zdGFuY2VvZiBFcnJvciksXzB4MjA4YTA4W18weDNmN2ZiMCgweDFjOCldKF8weDVkYWZkZltfMHgzZjdmYjAoMHgxY2QpXT09PXVuZGVmaW5lZHx8dHlwZW9mIF8weDVkYWZkZlsnY2FwdHVyZVN0YWNrVHJhY2UnXT09PV8weDNmN2ZiMCgweDFjYykpLF8weDIwOGEwOFtfMHgzZjdmYjAoMHgxYzgpXShPYmplY3RbJ2lzU2VhbGVkJ10oTWF0aCkpLF8weDIwOGEwOFtfMHgzZjdmYjAoMHgxYzgpXSgoZnVuY3Rpb24oKXtyZXR1cm4gdGhpczt9KCkpPT09d2luZG93KTtjb25zdCBfMHgyNjEwMWE9ZG9jdW1lbnRbXzB4M2Y3ZmIwKDB4MWM1KV1bXzB4M2Y3ZmIwKDB4MWJiKV0sXzB4MWYzMTU1PV8weDI2MTAxYVtfMHgzZjdmYjAoMHgxYjYpXSxfMHg1N2FkYjg9ZG9jdW1lbnRbXzB4M2Y3ZmIwKDB4MWJjKV0oJ2RpdicpO2RvY3VtZW50W18weDNmN2ZiMCgweDFjNSldW18weDNmN2ZiMCgweDFjYSldKF8weDU3YWRiOCksXzB4MjA4YTA4W18weDNmN2ZiMCgweDFjOCldKF8weDI2MTAxYVtfMHgzZjdmYjAoMHgxYjYpXT09PV8weDFmMzE1NSsweDEpLGRvY3VtZW50Wydib2R5J11bXzB4M2Y3ZmIwKDB4MWNmKV0oXzB4NTdhZGI4KTtjb25zdCBfMHg1NGIwODk9ZG9jdW1lbnRbXzB4M2Y3ZmIwKDB4MWI4KV0oJyonKTtfMHgyMDhhMDhbXzB4M2Y3ZmIwKDB4MWM4KV0oIUFycmF5W18weDNmN2ZiMCgweDFlOCldKF8weDU0YjA4OSkpLF8weDIwOGEwOFtfMHgzZjdmYjAoMHgxYzgpXShfMHg1NGIwODlbXzB4M2Y3ZmIwKDB4MWVhKV1bXzB4M2Y3ZmIwKDB4MWI3KV09PT1fMHgzZjdmYjAoMHgxZGMpKTtjb25zdCBfMHg5MTkzYT1kb2N1bWVudFtfMHgzZjdmYjAoMHgxYmMpXShfMHgzZjdmYjAoMHgxZTcpKTtyZXR1cm4gXzB4MjA4YTA4WydwdXNoJ10oXzB4OTE5M2EgaW5zdGFuY2VvZiBIVE1MRGl2RWxlbWVudCksXzB4MjA4YTA4W18weDNmN2ZiMCgweDFjOCldKEhUTUxEaXZFbGVtZW50W18weDNmN2ZiMCgweDFjOSldaW5zdGFuY2VvZiBIVE1MRWxlbWVudCksXzB4MjA4YTA4W18weDNmN2ZiMCgweDFjOCldKEhUTUxFbGVtZW50Wydwcm90b3R5cGUnXWluc3RhbmNlb2YgRWxlbWVudCksU3RyaW5nKF8weDIwOGEwOFtfMHgzZjdmYjAoMHgxY2UpXShOdW1iZXIpW18weDNmN2ZiMCgweDFlMildKChfMHg1ZTAyZTcsXzB4ZmMzMyk9Pl8weDVlMDJlNytfMHhmYzMzLDB4ZTAyKSk7fSgpKSwoZnVuY3Rpb24oKXtjb25zdCBfMHg0YzJjNWQ9XzB4MWVjMGU3O3JldHVybiBTdHJpbmcoW25hdmlnYXRvcltfMHg0YzJjNWQoMHgxYzQpXT09PSEhW10sKGZ1bmN0aW9uKCl7Y29uc3QgXzB4MmIxZjM2PV8weDRjMmM1ZCxfMHgzN2Y4YTg9ZG9jdW1lbnRbXzB4MmIxZjM2KDB4MWJjKV0oXzB4MmIxZjM2KDB4MWQzKSk7XzB4MzdmOGE4W18weDJiMWYzNigweDFkMSldPV8weDJiMWYzNigweDFkNSksZG9jdW1lbnRbJ2JvZHknXVtfMHgyYjFmMzYoMHgxY2EpXShfMHgzN2Y4YTgpO2xldCBfMHgxYTkzMWY7cmV0dXJuIF8weDM3ZjhhOFsnY29udGVudFdpbmRvdyddJiZfMHgzN2Y4YThbJ2NvbnRlbnRXaW5kb3cnXVtfMHgyYjFmMzYoMHgxY2IpXSYmXzB4MzdmOGE4W18weDJiMWYzNigweDFkZSldW18weDJiMWYzNigweDFjYildW18weDJiMWYzNigweDFjMSldP18weDFhOTMxZj1fMHgzN2Y4YThbXzB4MmIxZjM2KDB4MWRlKV1bXzB4MmIxZjM2KDB4MWNiKV1bXzB4MmIxZjM2KDB4MWMxKV1bJ3RvU3RyaW5nJ10oKTpfMHgxYTkzMWY9dW5kZWZpbmVkLGRvY3VtZW50W18weDJiMWYzNigweDFjNSldWydyZW1vdmVDaGlsZCddKF8weDM3ZjhhOCksISFfMHgxYTkzMWY7fSgpKSwoZnVuY3Rpb24oKXtjb25zdCBfMHgzYzljMjE9XzB4NGMyYzVkLF8weDNjYmY0NT1bXzB4M2M5YzIxKDB4MWU2KSxfMHgzYzljMjEoMHgxZTkpLCdQcm9taXNlJywnUHJveHknLF8weDNjOWMyMSgweDFjMyksXzB4M2M5YzIxKDB4MWQwKSxfMHgzYzljMjEoMHgxZWIpXSxfMHg1Nzg0MWI9T2JqZWN0W18weDNjOWMyMSgweDFjNildKHdpbmRvd1tfMHgzYzljMjEoMHgxZTMpXSlbXzB4M2M5YzIxKDB4MWJhKV0oXzB4NDBkMmUxPT5fMHgzY2JmNDVbXzB4M2M5YzIxKDB4MWJlKV0oXzB4MTMyODhhPT5fMHg0MGQyZTEhPT1fMHgxMzI4OGEmJl8weDQwZDJlMVtfMHgzYzljMjEoMHgxYzIpXSgnXycrXzB4MTMyODhhKSYmd2luZG93W18weDNjOWMyMSgweDFlMyldW18weDQwZDJlMV09PT13aW5kb3dbXzB4M2M5YzIxKDB4MWUzKV1bXzB4MTMyODhhXSkpO3JldHVybiBfMHg1Nzg0MWJbXzB4M2M5YzIxKDB4MWI2KV0+MHgwO30oKSldW18weDRjMmM1ZCgweDFjZSldKE51bWJlcilbXzB4NGMyYzVkKDB4MWUyKV0oKF8weDEyNjk1MixfMHgzMGY4Y2YpPT5fMHgxMjY5NTIrXzB4MzBmOGNmLDB4MWZmYSkpO30oKSldKSxfMHgxZmQzZTI9W10sXzB4MzU4OGVjPXt9LF8weDU2Y2UyMj0nMmQ2Nzg0MGFlZWViNjI0MSc7Zm9yKGxldCBfMHgxNmI2ODI9MHgwO18weDE2YjY4MjxfMHg1NjQxNjNbJ2xlbmd0aCddO18weDE2YjY4MisrKXtjb25zdCBfMHgyNTBkMDg9XzB4NTY0MTYzW18weDE2YjY4Ml07QXJyYXlbXzB4MWVjMGU3KDB4MWU4KV0oXzB4MjUwZDA4KT8oXzB4MWZkM2UyW18weDFlYzBlNygweDFjOCldKF8weDI1MGQwOFsweDBdKSxfMHgyNTBkMDhbXzB4MWVjMGU3KDB4MWI2KV0+MHgxJiZfMHgyMzM5ZDRbXzB4MTZiNjgyXVsweDFdJiYoXzB4MzU4OGVjW18weDIzMzlkNFtfMHgxNmI2ODJdWzB4MF1dPV8weDI1MGQwOFsweDFdKSk6XzB4MWZkM2UyW18weDFlYzBlNygweDFjOCldKF8weDI1MGQwOCk7fWNvbnN0IF8weDI2NDFjOD1BcnJheVtfMHgxZWMwZTcoMHgxZGIpXShKU09OW18weDFlYzBlNygweDFiZildKF8weDM1ODhlYykpW18weDFlYzBlNygweDFjZSldKChfMHg0YWY3NWUsXzB4MTZiYTc4KT0+U3RyaW5nW18weDFlYzBlNygweDFiZCldKF8weDRhZjc1ZVtfMHgxZWMwZTcoMHgxYzApXSgweDApXl8weDU2Y2UyMlsnY2hhckNvZGVBdCddKF8weDE2YmE3OCVfMHg1NmNlMjJbXzB4MWVjMGU3KDB4MWI2KV0pKSlbXzB4MWVjMGU3KDB4MWVlKV0oJycpO2Z1bmN0aW9uIF8weDEwZDQoXzB4MzQ5ZjRkLF8weDViYWNhMSl7Y29uc3QgXzB4NDRhMTE5PV8weDQ0YTEoKTtyZXR1cm4gXzB4MTBkND1mdW5jdGlvbihfMHgxMGQ0YjIsXzB4MjIyN2EzKXtfMHgxMGQ0YjI9XzB4MTBkNGIyLTB4MWIzO2xldCBfMHg0ZDY3NTA9XzB4NDRhMTE5W18weDEwZDRiMl07cmV0dXJuIF8weDRkNjc1MDt9LF8weDEwZDQoXzB4MzQ5ZjRkLF8weDViYWNhMSk7fWZ1bmN0aW9uIF8weDQ0YTEoKXtjb25zdCBfMHg1YzQ1OGI9WydjYXB0dXJlU3RhY2tUcmFjZScsJ21hcCcsJ3JlbW92ZUNoaWxkJywnSlNPTicsJ3NyY2RvYycsJzIyOTI1NzhlclZtcGcnLCdpZnJhbWUnLCc3MTQ0ODlhSEpWS28nLCdEdWNrRHVja0dvXHgyMEZyYXVkXHgyMCZceDIwQWJ1c2UnLCcyODAwMTI2T1VSSFVlJywnYWxsJywnMzY3MjQ5MnZMelZ3UycsJ3VzZXJBZ2VudCcsJzIyMjE2ODhXUERqS1YnLCdmcm9tJywnTm9kZUxpc3QnLCcxNzg2MDkzNTkxODg3JywnY29udGVudFdpbmRvdycsJ2pDOXdVKzRkU3ZKQ2JuSnNvNjhsTERKMmI0RWlUTkFLR2lFT3JFNE84VGs9JywnW29iamVjdFx4MjBXaW5kb3ddJywnY2FsbCcsJ3JlZHVjZScsJ3RvcCcsJzQ1MDAxMDA4UlJMdUdJJywnaUJGWGM0QVNvb2xva1FPN1lPWnBSa3VsVlFGLzZiaTNHVER1UlZaK0tiWT0nLCdBcnJheScsJ2RpdicsJ2lzQXJyYXknLCdPYmplY3QnLCdjb25zdHJ1Y3RvcicsJ1dpbmRvdycsJzI0bnBrdE5ZJywnODM4ZWE4YThiYzFjOTk1YmVkODNkODkzZjAwNzczOWJmYjcyYjEyMmNmNDZmOWQ1YTg3N2NmYmRmZDZhYzRlZHB4anpyJywnam9pbicsJzMzNUFnZkZYVCcsJzgyMTU4V0R5bGhKJywnQktlcmdyN0ZVS2ZhZ3lpN1Ewc1IzQ01qNUxPUXkvUEdzTDRGc3UrSnQrQT0nLCdsZW5ndGgnLCduYW1lJywncXVlcnlTZWxlY3RvckFsbCcsJ3RvU3RyaW5nJywnZmlsdGVyJywnY2hpbGRyZW4nLCdjcmVhdGVFbGVtZW50JywnZnJvbUNoYXJDb2RlJywnc29tZScsJ3N0cmluZ2lmeScsJ2NoYXJDb2RlQXQnLCdnZXQnLCdlbmRzV2l0aCcsJ1N5bWJvbCcsJ3dlYmRyaXZlcicsJ2JvZHknLCdrZXlzJywncGFyc2VJbnQnLCdwdXNoJywncHJvdG90eXBlJywnYXBwZW5kQ2hpbGQnLCdzZWxmJywnZnVuY3Rpb24nXTtfMHg0NGExPWZ1bmN0aW9uKCl7cmV0dXJuIF8weDVjNDU4Yjt9O3JldHVybiBfMHg0NGExKCk7fXJldHVybnsnc2VydmVyX2hhc2hlcyc6W18weDFlYzBlNygweDFiNSksXzB4MWVjMGU3KDB4MWU1KSxfMHgxZWMwZTcoMHgxZGYpXSwnY2xpZW50X2hhc2hlcyc6XzB4MWZkM2UyLCdzaWduYWxzJzp7fSwnbWV0YSc6eyd2JzonNCcsJ2NoYWxsZW5nZV9pZCc6XzB4MWVjMGU3KDB4MWVkKSwndGltZXN0YW1wJzpfMHgxZWMwZTcoMHgxZGQpLCdkZWJ1Zyc6XzB4MjY0MWM4fX07fSkoKQ==", + "browserProbes": ["3598", "8186"], + "browserReduceVectors": [ + { + "seed": 3586, + "booleans": [1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1] + }, + { + "seed": 8186, + "booleans": [0, 0, 0] + } + ] + }, + "variant-3.js": { + "challengeBase64": "KGFzeW5jIGZ1bmN0aW9uKCl7ZnVuY3Rpb24gXzB4M2EwMChfMHgxNjQzYjYsXzB4MjZkOTJjKXtjb25zdCBfMHgxNGRlYTM9XzB4MTRkZSgpO3JldHVybiBfMHgzYTAwPWZ1bmN0aW9uKF8weDNhMDBhOCxfMHg1ZGEwNTUpe18weDNhMDBhOD1fMHgzYTAwYTgtMHg2YTtsZXQgXzB4MWI1YjBlPV8weDE0ZGVhM1tfMHgzYTAwYThdO3JldHVybiBfMHgxYjViMGU7fSxfMHgzYTAwKF8weDE2NDNiNixfMHgyNmQ5MmMpO31mdW5jdGlvbiBfMHgxNGRlKCl7Y29uc3QgXzB4NDNhZjQzPVsnUHJveHknLCd1S2E0cFZFYWxqelJiNkZGY3dOM1dlWDMwSkQ3b3JsUEZSWVIyc2wrSlk0PScsJzQ4OTUzN0F5aUpIWicsJ2g4amJ0Jywnc29tZScsJ2FwcGVuZENoaWxkJywna2V5cycsJzcyNzVKb29XdFAnLCdTeW1ib2wnLCdXaW5kb3cnLCdpZnJhbWUnLCdnZXQnLCdjb250ZW50V2luZG93JywnSlNPTicsJzI0NjRYR2N5bGQnLCdhbGwnLCdjcmVhdGVFbGVtZW50Jywnc3RyaW5naWZ5JywnZnJvbScsJ2xlbmd0aCcsJzU4Mk5SV1VBQicsJ3JlbW92ZUNoaWxkJywnMzE5MDVnYXFoWkUnLCcxMzQ4ODg3akxPcXR6Jywnc3JjZG9jJywnZnJvbUNoYXJDb2RlJywnYm9keScsJ09iamVjdCcsJ3RvcCcsJ08ybUJpdU91bm5FSm1LVkFrUllmTENCMmJCTnhsNCtxeWRiSUE4TGxyclU9Jywnam9pbicsJ2NoYXJDb2RlQXQnLCdwdXNoJywnc2VsZicsJ2lubmVySFRNTCcsJ3JlZHVjZScsJ3F1ZXJ5U2VsZWN0b3JBbGwnLCdmZGE1YThjZTMyODgyZDkwJywneko3aHp2M3dHZWlxUzlGcDVDT1lVbE1QUFQvZ3JHRXJ1Z0lTNk9GNmFJcz0nLCc0bEFrb3ZPJywnMjIyODgyOTBXd3RoQU8nLCdmaWx0ZXInLCdEdWNrRHVja0dvXHgyMEZyYXVkXHgyMCZceDIwQWJ1c2UnLCcyMDUxMzUwekZnZ3J6JywnZGl2JywnaXNBcnJheScsJ0FycmF5JywnZW5kc1dpdGgnLCcxNzg2MDkzNTk5MTQ3JywndG9TdHJpbmcnLCcyMTk3MjQ0ckl1a1hLJ107XzB4MTRkZT1mdW5jdGlvbigpe3JldHVybiBfMHg0M2FmNDM7fTtyZXR1cm4gXzB4MTRkZSgpO31jb25zdCBfMHgzOGViYzc9XzB4M2EwMDsoZnVuY3Rpb24oXzB4MzBhMGFhLF8weDIzZGE4Myl7Y29uc3QgXzB4NDI5YzcxPV8weDNhMDAsXzB4NGZkMjdiPV8weDMwYTBhYSgpO3doaWxlKCEhW10pe3RyeXtjb25zdCBfMHgzYmVhMzc9cGFyc2VJbnQoXzB4NDI5YzcxKDB4OTcpKS8weDErLXBhcnNlSW50KF8weDQyOWM3MSgweDhkKSkvMHgyKy1wYXJzZUludChfMHg0MjljNzEoMHg3OSkpLzB4MyooLXBhcnNlSW50KF8weDQyOWM3MSgweDg5KSkvMHg0KStwYXJzZUludChfMHg0MjljNzEoMHg5YykpLzB4NSoocGFyc2VJbnQoXzB4NDI5YzcxKDB4NzYpKS8weDYpKy1wYXJzZUludChfMHg0MjljNzEoMHg5NCkpLzB4NytwYXJzZUludChfMHg0MjljNzEoMHg3MCkpLzB4OCooLXBhcnNlSW50KF8weDQyOWM3MSgweDc4KSkvMHg5KStwYXJzZUludChfMHg0MjljNzEoMHg4YSkpLzB4YTtpZihfMHgzYmVhMzc9PT1fMHgyM2RhODMpYnJlYWs7ZWxzZSBfMHg0ZmQyN2JbJ3B1c2gnXShfMHg0ZmQyN2JbJ3NoaWZ0J10oKSk7fWNhdGNoKF8weDM5NDhiMCl7XzB4NGZkMjdiWydwdXNoJ10oXzB4NGZkMjdiWydzaGlmdCddKCkpO319fShfMHgxNGRlLDB4ZDY0ODcpKTtjb25zdCBfMHgxNzE0ZTU9W1sndWEnLCFbXV0sW18weDM4ZWJjNygweDk4KSwhW11dLFsnaTNqcDAnLCFbXV1dLF8weDNlYTlmNj1hd2FpdCBQcm9taXNlW18weDM4ZWJjNygweDcxKV0oW25hdmlnYXRvclsndXNlckFnZW50J10sKGZ1bmN0aW9uKCl7Y29uc3QgXzB4NWYyZDA1PV8weDM4ZWJjNyxfMHgxYjBmNjU9ZG9jdW1lbnRbXzB4NWYyZDA1KDB4NzIpXShfMHg1ZjJkMDUoMHg4ZSkpO3JldHVybiBfMHgxYjBmNjVbXzB4NWYyZDA1KDB4ODQpXT0nPGxpPjxkaXY+PC9saT48bGk+PC9kaXYnLFN0cmluZygweDVjOCtfMHgxYjBmNjVbXzB4NWYyZDA1KDB4ODQpXVtfMHg1ZjJkMDUoMHg3NSldKl8weDFiMGY2NVtfMHg1ZjJkMDUoMHg4NildKCcqJylbXzB4NWYyZDA1KDB4NzUpXSk7fSgpKSwoZnVuY3Rpb24oKXtjb25zdCBfMHg0ZGRmYmU9XzB4MzhlYmM3O3JldHVybiBTdHJpbmcoW25hdmlnYXRvclsnd2ViZHJpdmVyJ109PT0hIVtdLChmdW5jdGlvbigpe2NvbnN0IF8weGIxMmVhYT1fMHgzYTAwLF8weDU0MDVjYT1kb2N1bWVudFtfMHhiMTJlYWEoMHg3MildKF8weGIxMmVhYSgweDZjKSk7XzB4NTQwNWNhW18weGIxMmVhYSgweDdhKV09XzB4YjEyZWFhKDB4OGMpLGRvY3VtZW50Wydib2R5J11bXzB4YjEyZWFhKDB4OWEpXShfMHg1NDA1Y2EpO2xldCBfMHhiY2ZjZjc7cmV0dXJuIF8weDU0MDVjYVtfMHhiMTJlYWEoMHg2ZSldJiZfMHg1NDA1Y2FbXzB4YjEyZWFhKDB4NmUpXVtfMHhiMTJlYWEoMHg4MyldJiZfMHg1NDA1Y2FbXzB4YjEyZWFhKDB4NmUpXVtfMHhiMTJlYWEoMHg4MyldW18weGIxMmVhYSgweDZkKV0/XzB4YmNmY2Y3PV8weDU0MDVjYVsnY29udGVudFdpbmRvdyddW18weGIxMmVhYSgweDgzKV1bXzB4YjEyZWFhKDB4NmQpXVtfMHhiMTJlYWEoMHg5MyldKCk6XzB4YmNmY2Y3PXVuZGVmaW5lZCxkb2N1bWVudFtfMHhiMTJlYWEoMHg3YyldW18weGIxMmVhYSgweDc3KV0oXzB4NTQwNWNhKSwhIV8weGJjZmNmNzt9KCkpLChmdW5jdGlvbigpe2NvbnN0IF8weDM5Y2M3Zj1fMHgzYTAwLF8weDNlZmRmYz1bXzB4MzljYzdmKDB4OTApLF8weDM5Y2M3ZigweDdkKSwnUHJvbWlzZScsXzB4MzljYzdmKDB4OTUpLF8weDM5Y2M3ZigweDZhKSxfMHgzOWNjN2YoMHg2ZiksXzB4MzljYzdmKDB4NmIpXSxfMHg1ZGQzZDE9T2JqZWN0W18weDM5Y2M3ZigweDliKV0od2luZG93W18weDM5Y2M3ZigweDdlKV0pW18weDM5Y2M3ZigweDhiKV0oXzB4NGZhYzM5PT5fMHgzZWZkZmNbXzB4MzljYzdmKDB4OTkpXShfMHg0MTQxY2M9Pl8weDRmYWMzOSE9PV8weDQxNDFjYyYmXzB4NGZhYzM5W18weDM5Y2M3ZigweDkxKV0oJ18nK18weDQxNDFjYykmJndpbmRvd1sndG9wJ11bXzB4NGZhYzM5XT09PXdpbmRvd1tfMHgzOWNjN2YoMHg3ZSldW18weDQxNDFjY10pKTtyZXR1cm4gXzB4NWRkM2QxW18weDM5Y2M3ZigweDc1KV0+MHgwO30oKSldWydtYXAnXShOdW1iZXIpW18weDRkZGZiZSgweDg1KV0oKF8weDIwMmY5NSxfMHgxZDY4MzIpPT5fMHgyMDJmOTUrXzB4MWQ2ODMyLDB4Njg1KSk7fSgpKV0pLF8weDE3NzhmNT1bXSxfMHgzY2NjMTY9e30sXzB4MzEyODVjPV8weDM4ZWJjNygweDg3KTtmb3IobGV0IF8weDE2ZGY4OT0weDA7XzB4MTZkZjg5PF8weDNlYTlmNltfMHgzOGViYzcoMHg3NSldO18weDE2ZGY4OSsrKXtjb25zdCBfMHgzMThkMjM9XzB4M2VhOWY2W18weDE2ZGY4OV07QXJyYXlbXzB4MzhlYmM3KDB4OGYpXShfMHgzMThkMjMpPyhfMHgxNzc4ZjVbJ3B1c2gnXShfMHgzMThkMjNbMHgwXSksXzB4MzE4ZDIzW18weDM4ZWJjNygweDc1KV0+MHgxJiZfMHgxNzE0ZTVbXzB4MTZkZjg5XVsweDFdJiYoXzB4M2NjYzE2W18weDE3MTRlNVtfMHgxNmRmODldWzB4MF1dPV8weDMxOGQyM1sweDFdKSk6XzB4MTc3OGY1W18weDM4ZWJjNygweDgyKV0oXzB4MzE4ZDIzKTt9Y29uc3QgXzB4NDM2ZWJjPUFycmF5W18weDM4ZWJjNygweDc0KV0oSlNPTltfMHgzOGViYzcoMHg3MyldKF8weDNjY2MxNikpWydtYXAnXSgoXzB4MzRmNGFhLF8weDJjMzUwYyk9PlN0cmluZ1tfMHgzOGViYzcoMHg3YildKF8weDM0ZjRhYVsnY2hhckNvZGVBdCddKDB4MCleXzB4MzEyODVjW18weDM4ZWJjNygweDgxKV0oXzB4MmMzNTBjJV8weDMxMjg1Y1tfMHgzOGViYzcoMHg3NSldKSkpW18weDM4ZWJjNygweDgwKV0oJycpO3JldHVybnsnc2VydmVyX2hhc2hlcyc6W18weDM4ZWJjNygweDg4KSxfMHgzOGViYzcoMHg5NiksXzB4MzhlYmM3KDB4N2YpXSwnY2xpZW50X2hhc2hlcyc6XzB4MTc3OGY1LCdzaWduYWxzJzp7fSwnbWV0YSc6eyd2JzonNCcsJ2NoYWxsZW5nZV9pZCc6J2ZiMmIwY2ExYzFmZWQwNTMwZGU2OGMwNzdkZThhYTA1NWFhMWQ1YTZiYzA4NjU3MjdlMDY5NzJhY2Q1ZDQ1ZjJoOGpidCcsJ3RpbWVzdGFtcCc6XzB4MzhlYmM3KDB4OTIpLCdkZWJ1Zyc6XzB4NDM2ZWJjfX07fSkoKQ==", + "browserProbes": ["1567", "1669"], + "browserReduceVectors": [ + { + "seed": 1669, + "booleans": [0, 0, 0] + } + ] + }, + "variant-4.js": { + "challengeBase64": "KGFzeW5jIGZ1bmN0aW9uKCl7Y29uc3QgXzB4MzNkM2U1PV8weDJhMjM7KGZ1bmN0aW9uKF8weDMyZDI1NixfMHgzMzQ5MDEpe2NvbnN0IF8weDI4N2EyYj1fMHgyYTIzLF8weDU5MWZmOD1fMHgzMmQyNTYoKTt3aGlsZSghIVtdKXt0cnl7Y29uc3QgXzB4MTFiODUxPS1wYXJzZUludChfMHgyODdhMmIoMHgxZTApKS8weDErLXBhcnNlSW50KF8weDI4N2EyYigweDFlYSkpLzB4MistcGFyc2VJbnQoXzB4Mjg3YTJiKDB4MWYxKSkvMHgzKy1wYXJzZUludChfMHgyODdhMmIoMHgyMDYpKS8weDQqKC1wYXJzZUludChfMHgyODdhMmIoMHgxZmQpKS8weDUpK3BhcnNlSW50KF8weDI4N2EyYigweDIwMSkpLzB4NitwYXJzZUludChfMHgyODdhMmIoMHgxZDYpKS8weDcrcGFyc2VJbnQoXzB4Mjg3YTJiKDB4MWU1KSkvMHg4KigtcGFyc2VJbnQoXzB4Mjg3YTJiKDB4MWUyKSkvMHg5KTtpZihfMHgxMWI4NTE9PT1fMHgzMzQ5MDEpYnJlYWs7ZWxzZSBfMHg1OTFmZjhbJ3B1c2gnXShfMHg1OTFmZjhbJ3NoaWZ0J10oKSk7fWNhdGNoKF8weDM0Njc0ZSl7XzB4NTkxZmY4WydwdXNoJ10oXzB4NTkxZmY4WydzaGlmdCddKCkpO319fShfMHg1Mzg2LDB4YzM0ZWMpKTtjb25zdCBfMHg1MDFjN2M9W1sndWEnLCFbXV0sW18weDMzZDNlNSgweDIwMiksIVtdXSxbXzB4MzNkM2U1KDB4MWQxKSwhW11dXSxfMHgzNWIzNzE9YXdhaXQgUHJvbWlzZVtfMHgzM2QzZTUoMHgyMDcpXShbbmF2aWdhdG9yW18weDMzZDNlNSgweDIwMyldLChmdW5jdGlvbigpe2NvbnN0IF8weDQzNTc5OT1fMHgzM2QzZTUsXzB4MThhNGEyPXdpbmRvd1sndG9wJ10sXzB4Mjk3ODg5PV8weDE4YTRhMltfMHg0MzU3OTkoMHgxZjcpXVtfMHg0MzU3OTkoMHgyMDApXShfMHg0MzU3OTkoMHgxZTYpKTtpZighXzB4Mjk3ODg5KXJldHVybiBTdHJpbmcoMHgxYjM1KTtjb25zdCBfMHgzMDdkNmU9XzB4Mjk3ODg5W18weDQzNTc5OSgweDFkYildfHxfMHgyOTc4ODlbXzB4NDM1Nzk5KDB4MWVjKV0mJl8weDI5Nzg4OVtfMHg0MzU3OTkoMHgxZWMpXVtfMHg0MzU3OTkoMHgxZjcpXTtpZighXzB4MzA3ZDZlKXJldHVybiBTdHJpbmcoMHgxYjM1KTtjb25zdCBfMHgzNzk5YjA9XzB4MzA3ZDZlW18weDQzNTc5OSgweDIwMCldKF8weDQzNTc5OSgweDFkNSkpO2lmKCFfMHgzNzk5YjApcmV0dXJuIFN0cmluZygweDFiMzUpO2NvbnN0IF8weDE3NTZmZT1fMHgzNzk5YjBbXzB4NDM1Nzk5KDB4MWQwKV0oXzB4NDM1Nzk5KDB4MWQ4KSksXzB4NDdlYWYxPV8weDI5Nzg4OVsnZ2V0QXR0cmlidXRlJ10oXzB4NDM1Nzk5KDB4MWZmKSk7cmV0dXJuIFN0cmluZyhbXzB4MTc1NmZlPT09J2RlZmF1bHQtc3JjXHgyMFx4Mjdub25lXHgyNztceDIwc2NyaXB0LXNyY1x4MjBceDI3dW5zYWZlLWlubGluZVx4Mjc7JyxfMHg0N2VhZjE9PT1fMHg0MzU3OTkoMHgxZDIpLF8weDE4YTRhMltfMHg0MzU3OTkoMHgxZDkpXShfMHg0MzU3OTkoMHgxZmMpKSxfMHgxOGE0YTJbXzB4NDM1Nzk5KDB4MWQ5KV0oXzB4NDM1Nzk5KDB4MWZlKSldW18weDQzNTc5OSgweDFlOCldKE51bWJlcilbXzB4NDM1Nzk5KDB4MWUzKV0oKF8weDFkM2Q1ZCxfMHgzNjU4YzMpPT5fMHgxZDNkNWQrXzB4MzY1OGMzLDB4MWIzNSkpO30oKSksKGZ1bmN0aW9uKCl7Y29uc3QgXzB4NWFlM2FiPV8weDMzZDNlNTtyZXR1cm4gU3RyaW5nKFtuYXZpZ2F0b3JbXzB4NWFlM2FiKDB4MWRjKV09PT0hIVtdLChmdW5jdGlvbigpe2NvbnN0IF8weDRiODEyYz1fMHg1YWUzYWIsXzB4MjY4YjU2PWRvY3VtZW50W18weDRiODEyYygweDFmMyldKCdpZnJhbWUnKTtfMHgyNjhiNTZbXzB4NGI4MTJjKDB4MWY0KV09XzB4NGI4MTJjKDB4MWRlKSxkb2N1bWVudFtfMHg0YjgxMmMoMHgxZjApXVtfMHg0YjgxMmMoMHgxZmEpXShfMHgyNjhiNTYpO2xldCBfMHgyMTlmOWM7cmV0dXJuIF8weDI2OGI1NltfMHg0YjgxMmMoMHgxZWMpXSYmXzB4MjY4YjU2Wydjb250ZW50V2luZG93J11bXzB4NGI4MTJjKDB4MWRkKV0mJl8weDI2OGI1NltfMHg0YjgxMmMoMHgxZWMpXVtfMHg0YjgxMmMoMHgxZGQpXVtfMHg0YjgxMmMoMHgxZTkpXT9fMHgyMTlmOWM9XzB4MjY4YjU2W18weDRiODEyYygweDFlYyldW18weDRiODEyYygweDFkZCldW18weDRiODEyYygweDFlOSldW18weDRiODEyYygweDFlNCldKCk6XzB4MjE5ZjljPXVuZGVmaW5lZCxkb2N1bWVudFtfMHg0YjgxMmMoMHgxZjApXVtfMHg0YjgxMmMoMHgxZWUpXShfMHgyNjhiNTYpLCEhXzB4MjE5ZjljO30oKSksKGZ1bmN0aW9uKCl7Y29uc3QgXzB4MWNiMTY2PV8weDVhZTNhYixfMHg0NjM2ZTE9W18weDFjYjE2NigweDFmNiksXzB4MWNiMTY2KDB4MWUxKSxfMHgxY2IxNjYoMHgyMDQpLCdQcm94eScsXzB4MWNiMTY2KDB4MWZiKSxfMHgxY2IxNjYoMHgxZGYpLCdXaW5kb3cnXSxfMHgzMGNiYzk9T2JqZWN0WydrZXlzJ10od2luZG93W18weDFjYjE2NigweDFmOSldKVtfMHgxY2IxNjYoMHgxZWYpXShfMHhjYmVhMWM9Pl8weDQ2MzZlMVsnc29tZSddKF8weDM1Njk2Zj0+XzB4Y2JlYTFjIT09XzB4MzU2OTZmJiZfMHhjYmVhMWNbXzB4MWNiMTY2KDB4MWQzKV0oJ18nK18weDM1Njk2ZikmJndpbmRvd1tfMHgxY2IxNjYoMHgxZjkpXVtfMHhjYmVhMWNdPT09d2luZG93W18weDFjYjE2NigweDFmOSldW18weDM1Njk2Zl0pKTtyZXR1cm4gXzB4MzBjYmM5W18weDFjYjE2NigweDFkNyldPjB4MDt9KCkpXVtfMHg1YWUzYWIoMHgxZTgpXShOdW1iZXIpW18weDVhZTNhYigweDFlMyldKChfMHgyOWZiY2EsXzB4MzExNzY1KT0+XzB4MjlmYmNhK18weDMxMTc2NSwweDFiMmMpKTt9KCkpXSksXzB4NTIyNmQ5PVtdLF8weDJkMDU0MD17fSxfMHhjZGU4Yzk9JzExZThjMjJlODBhNjk5Y2EnO2ZvcihsZXQgXzB4MzExMmE1PTB4MDtfMHgzMTEyYTU8XzB4MzViMzcxW18weDMzZDNlNSgweDFkNyldO18weDMxMTJhNSsrKXtjb25zdCBfMHgzZTVjODk9XzB4MzViMzcxW18weDMxMTJhNV07QXJyYXlbXzB4MzNkM2U1KDB4MWU3KV0oXzB4M2U1Yzg5KT8oXzB4NTIyNmQ5W18weDMzZDNlNSgweDIwNSldKF8weDNlNWM4OVsweDBdKSxfMHgzZTVjODlbXzB4MzNkM2U1KDB4MWQ3KV0+MHgxJiZfMHg1MDFjN2NbXzB4MzExMmE1XVsweDFdJiYoXzB4MmQwNTQwW18weDUwMWM3Y1tfMHgzMTEyYTVdWzB4MF1dPV8weDNlNWM4OVsweDFdKSk6XzB4NTIyNmQ5W18weDMzZDNlNSgweDIwNSldKF8weDNlNWM4OSk7fWNvbnN0IF8weDUzZDRkZj1BcnJheVtfMHgzM2QzZTUoMHgxZDQpXShKU09OWydzdHJpbmdpZnknXShfMHgyZDA1NDApKVsnbWFwJ10oKF8weDVkZDkwZCxfMHg1NzRiMDMpPT5TdHJpbmdbJ2Zyb21DaGFyQ29kZSddKF8weDVkZDkwZFsnY2hhckNvZGVBdCddKDB4MCleXzB4Y2RlOGM5W18weDMzZDNlNSgweDFmOCldKF8weDU3NGIwMyVfMHhjZGU4YzlbXzB4MzNkM2U1KDB4MWQ3KV0pKSlbXzB4MzNkM2U1KDB4MWViKV0oJycpO2Z1bmN0aW9uIF8weDJhMjMoXzB4Mzk2YThkLF8weDRmYTNiMyl7Y29uc3QgXzB4NTM4NjRiPV8weDUzODYoKTtyZXR1cm4gXzB4MmEyMz1mdW5jdGlvbihfMHgyYTIzMTMsXzB4YzljZjliKXtfMHgyYTIzMTM9XzB4MmEyMzEzLTB4MWQwO2xldCBfMHg0N2Q2YmY9XzB4NTM4NjRiW18weDJhMjMxM107cmV0dXJuIF8weDQ3ZDZiZjt9LF8weDJhMjMoXzB4Mzk2YThkLF8weDRmYTNiMyk7fWZ1bmN0aW9uIF8weDUzODYoKXtjb25zdCBfMHgxOWI2NmQ9Wyd0b1N0cmluZycsJzI5MDkzNm1kd29OdCcsJyNqc2EnLCdpc0FycmF5JywnbWFwJywnZ2V0JywnMTE0MTIzOGFNc0VaWCcsJ2pvaW4nLCdjb250ZW50V2luZG93JywnMTRLNmFGakFBSkhXMWhkUzZFOHlRV0JUemdnR2lxSGR0OUNyay9rU1hVMD0nLCdyZW1vdmVDaGlsZCcsJ2ZpbHRlcicsJ2JvZHknLCcxMDE4NUZkR01pYScsJ0ZtcGJUWnh0WXNSc3JvNkkzd2xwUks2dXFobkVVL0NHNEx5a2U2MWhuZUU9JywnY3JlYXRlRWxlbWVudCcsJ3NyY2RvYycsJ25oOW0xdUE5RDNvVUFVdU9HODRSZEV4aE1iZnh4emZHVmVObDVYNk5NNWM9JywnQXJyYXknLCdkb2N1bWVudCcsJ2NoYXJDb2RlQXQnLCd0b3AnLCdhcHBlbmRDaGlsZCcsJ1N5bWJvbCcsJ19fRERHX0JFX1ZFUlNJT05fXycsJzQzNzBHYnNCa2snLCdfX0RER19GRV9DSEFUX0hBU0hfXycsJ3NhbmRib3gnLCdxdWVyeVNlbGVjdG9yJywnMzU5MDU0NGV1eUVaUCcsJ3Z6OTVuJywndXNlckFnZW50JywnUHJvbWlzZScsJ3B1c2gnLCcyOTQ0WWxoT0l1JywnYWxsJywnZ2V0QXR0cmlidXRlJywnaTNqcDAnLCdhbGxvdy1zY3JpcHRzXHgyMGFsbG93LXNhbWUtb3JpZ2luJywnZW5kc1dpdGgnLCdmcm9tJywnbWV0YVtodHRwLWVxdWl2PVx4MjJDb250ZW50LVNlY3VyaXR5LVBvbGljeVx4MjJdJywnMjcyOTYwMXZBdGdJcycsJ2xlbmd0aCcsJ2NvbnRlbnQnLCdoYXNPd25Qcm9wZXJ0eScsJ2UwNDI5OGQxZGIxMjc4MTgwMDk5ODRmNzY0Y2IwMTIxZDFiZmY0Y2RkNTk0MjAyYTU3MWY2ODUwODc5YmRjYzh2ejk1bicsJ2NvbnRlbnREb2N1bWVudCcsJ3dlYmRyaXZlcicsJ3NlbGYnLCdEdWNrRHVja0dvXHgyMEZyYXVkXHgyMCZceDIwQWJ1c2UnLCdKU09OJywnMjIxMjcwWE1IUExTJywnT2JqZWN0JywnOXRlVFRUbicsJ3JlZHVjZSddO18weDUzODY9ZnVuY3Rpb24oKXtyZXR1cm4gXzB4MTliNjZkO307cmV0dXJuIF8weDUzODYoKTt9cmV0dXJueydzZXJ2ZXJfaGFzaGVzJzpbXzB4MzNkM2U1KDB4MWVkKSxfMHgzM2QzZTUoMHgxZjUpLF8weDMzZDNlNSgweDFmMildLCdjbGllbnRfaGFzaGVzJzpfMHg1MjI2ZDksJ3NpZ25hbHMnOnt9LCdtZXRhJzp7J3YnOic0JywnY2hhbGxlbmdlX2lkJzpfMHgzM2QzZTUoMHgxZGEpLCd0aW1lc3RhbXAnOicxNzg2MDkzNjA2Mzk0JywnZGVidWcnOl8weDUzZDRkZn19O30pKCk=", + "browserProbes": ["6969", "6956"], + "browserReduceVectors": [ + { + "seed": 6965, + "booleans": [1, 1, 1, 1] + }, + { + "seed": 6956, + "booleans": [0, 0, 0] + } + ] + }, + "variant-5.js": { + "challengeBase64": "KGFzeW5jIGZ1bmN0aW9uKCl7Y29uc3QgXzB4OTk2NjNiPV8weDNkMTg7ZnVuY3Rpb24gXzB4NTgzMSgpe2NvbnN0IF8weDNjNTAyZj1bJ2Zyb21DaGFyQ29kZScsJ3Njcm9sbEhlaWdodCcsJ0FycmF5Jywnam9pbicsJ29mZnNldEhlaWdodCcsJ1Byb3h5JywnZTY4MjM2NTEwNGJjMjNmOCcsJ3dlYmRyaXZlcicsJ2Zyb20nLCdjb250ZW50V2luZG93JywnZGlzcGxheTppbmxpbmUtYmxvY2s7cGFkZGluZzo4cHg7cG9zaXRpb246YWJzb2x1dGU7dmlzaWJpbGl0eTpoaWRkZW47JywnaTNqcDAnLCdTeW1ib2wnLCdyZWR1Y2UnLCdsZW5ndGgnLCcyc1Z0a1RFWFE5YTdWdTRzSzI4RTloRDU2U0lTYkxCcWxyUVZOWUpsQW9vPScsJ2dldCcsJzc4YTZmMWY5MGU5MzE2NjQ3ODEyNjg2MjcxYWU2ZDMxODYxZDI1NWQyYjY0N2VhZTYxY2Y4MTUwZTk4NTlhNzBxN2tsbScsJ09iamVjdCcsJzg3ODk0cERyd2NDJywncmVtb3ZlQ2hpbGQnLCc5dHVZQnpzJywnaXNBcnJheScsJzhnVXBKem4nLCc2ODE1NjFkQXNQV1UnLCcyODgzNjN1Y0lRUEMnLCd0b3AnLCdjcmVhdGVFbGVtZW50JywnOTg1MzUxMGp3VFdkcCcsJ21hcCcsJ3NyY2RvYycsJ29mZnNldFdpZHRoJywnMTgzMDgxNm9XeWNTUycsJ2Nzc1RleHQnLCc1dGtHRldDJywnV2luZG93JywnYXBwZW5kQ2hpbGQnLCdzdHlsZScsJ2JvZHknLCdlbmRzV2l0aCcsJ2FsbCcsJ3VzZXJBZ2VudCcsJ3dpZHRoJywnc29tZScsJ2RpdicsJzE4MzA5NjlJV0pLb0QnLCcxMGt5ZmFyZicsJ2NoYXJDb2RlQXQnLCdzdHJpbmdpZnknLCdKU09OJywnc2VsZicsJ3B1c2gnLCdxN2tsbScsJzk4MTU3MThzR2NheFgnLCdpZnJhbWUnXTtfMHg1ODMxPWZ1bmN0aW9uKCl7cmV0dXJuIF8weDNjNTAyZjt9O3JldHVybiBfMHg1ODMxKCk7fShmdW5jdGlvbihfMHgzYzI5M2UsXzB4MzA3NWVmKXtjb25zdCBfMHg1MjgyYjY9XzB4M2QxOCxfMHg0YjVmMTM9XzB4M2MyOTNlKCk7d2hpbGUoISFbXSl7dHJ5e2NvbnN0IF8weDJkMzAwYz1wYXJzZUludChfMHg1MjgyYjYoMHhhYykpLzB4MSstcGFyc2VJbnQoXzB4NTI4MmI2KDB4YzIpKS8weDIqKHBhcnNlSW50KF8weDUyODJiNigweGFkKSkvMHgzKStwYXJzZUludChfMHg1MjgyYjYoMHhiNCkpLzB4NCstcGFyc2VJbnQoXzB4NTI4MmI2KDB4YjYpKS8weDUqKC1wYXJzZUludChfMHg1MjgyYjYoMHhhNykpLzB4NikrLXBhcnNlSW50KF8weDUyODJiNigweGMxKSkvMHg3KigtcGFyc2VJbnQoXzB4NTI4MmI2KDB4YWIpKS8weDgpK3BhcnNlSW50KF8weDUyODJiNigweGE5KSkvMHg5KigtcGFyc2VJbnQoXzB4NTI4MmI2KDB4YjApKS8weGEpK3BhcnNlSW50KF8weDUyODJiNigweDkyKSkvMHhiO2lmKF8weDJkMzAwYz09PV8weDMwNzVlZilicmVhaztlbHNlIF8weDRiNWYxM1sncHVzaCddKF8weDRiNWYxM1snc2hpZnQnXSgpKTt9Y2F0Y2goXzB4NGM3NzllKXtfMHg0YjVmMTNbJ3B1c2gnXShfMHg0YjVmMTNbJ3NoaWZ0J10oKSk7fX19KF8weDU4MzEsMHhjZDg4NykpO2NvbnN0IF8weDVjYjlmZj1bWyd1YScsIVtdXSxbXzB4OTk2NjNiKDB4OTEpLCFbXV0sW18weDk5NjYzYigweDlmKSwhW11dXSxfMHg1OWM3ODU9YXdhaXQgUHJvbWlzZVtfMHg5OTY2M2IoMHhiYyldKFtuYXZpZ2F0b3JbXzB4OTk2NjNiKDB4YmQpXSwoZnVuY3Rpb24oKXtjb25zdCBfMHgzNjQ4ZjY9XzB4OTk2NjNiLF8weDRmMzYzMD1bXSxfMHgzZmY3MWE9ZG9jdW1lbnRbXzB4MzY0OGY2KDB4YWYpXShfMHgzNjQ4ZjYoMHhjMCkpO18weDNmZjcxYVtfMHgzNjQ4ZjYoMHhiOSldW18weDM2NDhmNigweGI1KV09XzB4MzY0OGY2KDB4OWUpLF8weDNmZjcxYVsndGV4dENvbnRlbnQnXT0neCcsZG9jdW1lbnRbXzB4MzY0OGY2KDB4YmEpXVtfMHgzNjQ4ZjYoMHhiOCldKF8weDNmZjcxYSksXzB4NGYzNjMwW18weDM2NDhmNigweDkwKV0oXzB4M2ZmNzFhW18weDM2NDhmNigweGIzKV0+MHgwKSxfMHg0ZjM2MzBbXzB4MzY0OGY2KDB4OTApXShfMHgzZmY3MWFbXzB4MzY0OGY2KDB4OTgpXT4weDApO2NvbnN0IF8weDI1NTA5Yj1fMHgzZmY3MWFbJ2dldEJvdW5kaW5nQ2xpZW50UmVjdCddKCk7XzB4NGYzNjMwW18weDM2NDhmNigweDkwKV0oXzB4MjU1MDliW18weDM2NDhmNigweGJlKV0+MHgwJiZfMHgyNTUwOWJbJ2hlaWdodCddPjB4MCk7Y29uc3QgXzB4NTdlMDY5PWdldENvbXB1dGVkU3R5bGUoXzB4M2ZmNzFhKTtyZXR1cm4gXzB4NGYzNjMwW18weDM2NDhmNigweDkwKV0oXzB4NTdlMDY5WydnZXRQcm9wZXJ0eVZhbHVlJ10oJ2Rpc3BsYXknKVsnbGVuZ3RoJ10+MHgwKSxfMHg0ZjM2MzBbXzB4MzY0OGY2KDB4OTApXShfMHgzZmY3MWFbXzB4MzY0OGY2KDB4OTUpXT4weDApLGRvY3VtZW50W18weDM2NDhmNigweGJhKV1bXzB4MzY0OGY2KDB4YTgpXShfMHgzZmY3MWEpLFN0cmluZyhfMHg0ZjM2MzBbJ21hcCddKE51bWJlcilbXzB4MzY0OGY2KDB4YTEpXSgoXzB4YTkzM2E0LF8weDJmZTVjZik9Pl8weGE5MzNhNCtfMHgyZmU1Y2YsMHg1OTMpKTt9KCkpLChmdW5jdGlvbigpe2NvbnN0IF8weDQ2YmJmMD1fMHg5OTY2M2I7cmV0dXJuIFN0cmluZyhbbmF2aWdhdG9yW18weDQ2YmJmMCgweDliKV09PT0hIVtdLChmdW5jdGlvbigpe2NvbnN0IF8weDE2N2JhOT1fMHg0NmJiZjAsXzB4NDg4ZTJhPWRvY3VtZW50W18weDE2N2JhOSgweGFmKV0oXzB4MTY3YmE5KDB4OTMpKTtfMHg0ODhlMmFbXzB4MTY3YmE5KDB4YjIpXT0nRHVja0R1Y2tHb1x4MjBGcmF1ZFx4MjAmXHgyMEFidXNlJyxkb2N1bWVudFsnYm9keSddW18weDE2N2JhOSgweGI4KV0oXzB4NDg4ZTJhKTtsZXQgXzB4NDJkNmNiO3JldHVybiBfMHg0ODhlMmFbXzB4MTY3YmE5KDB4OWQpXSYmXzB4NDg4ZTJhW18weDE2N2JhOSgweDlkKV1bJ3NlbGYnXSYmXzB4NDg4ZTJhW18weDE2N2JhOSgweDlkKV1bXzB4MTY3YmE5KDB4OGYpXVtfMHgxNjdiYTkoMHhhNCldP18weDQyZDZjYj1fMHg0ODhlMmFbXzB4MTY3YmE5KDB4OWQpXVtfMHgxNjdiYTkoMHg4ZildWydnZXQnXVsndG9TdHJpbmcnXSgpOl8weDQyZDZjYj11bmRlZmluZWQsZG9jdW1lbnRbXzB4MTY3YmE5KDB4YmEpXVsncmVtb3ZlQ2hpbGQnXShfMHg0ODhlMmEpLCEhXzB4NDJkNmNiO30oKSksKGZ1bmN0aW9uKCl7Y29uc3QgXzB4MWNhMGQ4PV8weDQ2YmJmMCxfMHg0ODRlNDU9W18weDFjYTBkOCgweDk2KSxfMHgxY2EwZDgoMHhhNiksJ1Byb21pc2UnLF8weDFjYTBkOCgweDk5KSxfMHgxY2EwZDgoMHhhMCksXzB4MWNhMGQ4KDB4YzUpLF8weDFjYTBkOCgweGI3KV0sXzB4MjQ0OTJlPU9iamVjdFsna2V5cyddKHdpbmRvd1tfMHgxY2EwZDgoMHhhZSldKVsnZmlsdGVyJ10oXzB4NDc4MWU5PT5fMHg0ODRlNDVbXzB4MWNhMGQ4KDB4YmYpXShfMHg0NjJjNTg9Pl8weDQ3ODFlOSE9PV8weDQ2MmM1OCYmXzB4NDc4MWU5W18weDFjYTBkOCgweGJiKV0oJ18nK18weDQ2MmM1OCkmJndpbmRvd1tfMHgxY2EwZDgoMHhhZSldW18weDQ3ODFlOV09PT13aW5kb3dbXzB4MWNhMGQ4KDB4YWUpXVtfMHg0NjJjNThdKSk7cmV0dXJuIF8weDI0NDkyZVtfMHgxY2EwZDgoMHhhMildPjB4MDt9KCkpXVsnbWFwJ10oTnVtYmVyKVtfMHg0NmJiZjAoMHhhMSldKChfMHgzMmVmNWIsXzB4M2IyMGQ4KT0+XzB4MzJlZjViK18weDNiMjBkOCwweDFmOGEpKTt9KCkpXSksXzB4NDRiMzY4PVtdLF8weDNiYTM3OD17fSxfMHgxYzQyMDk9XzB4OTk2NjNiKDB4OWEpO2Z1bmN0aW9uIF8weDNkMTgoXzB4NDM4M2Q3LF8weDM2MGVkYSl7Y29uc3QgXzB4NTgzMWU4PV8weDU4MzEoKTtyZXR1cm4gXzB4M2QxOD1mdW5jdGlvbihfMHgzZDE4M2QsXzB4MjU0NGQ3KXtfMHgzZDE4M2Q9XzB4M2QxODNkLTB4OGY7bGV0IF8weDNlZjJlZj1fMHg1ODMxZThbXzB4M2QxODNkXTtyZXR1cm4gXzB4M2VmMmVmO30sXzB4M2QxOChfMHg0MzgzZDcsXzB4MzYwZWRhKTt9Zm9yKGxldCBfMHgxNzc3MDE9MHgwO18weDE3NzcwMTxfMHg1OWM3ODVbJ2xlbmd0aCddO18weDE3NzcwMSsrKXtjb25zdCBfMHgyN2I1NGY9XzB4NTljNzg1W18weDE3NzcwMV07QXJyYXlbXzB4OTk2NjNiKDB4YWEpXShfMHgyN2I1NGYpPyhfMHg0NGIzNjhbJ3B1c2gnXShfMHgyN2I1NGZbMHgwXSksXzB4MjdiNTRmW18weDk5NjYzYigweGEyKV0+MHgxJiZfMHg1Y2I5ZmZbXzB4MTc3NzAxXVsweDFdJiYoXzB4M2JhMzc4W18weDVjYjlmZltfMHgxNzc3MDFdWzB4MF1dPV8weDI3YjU0ZlsweDFdKSk6XzB4NDRiMzY4W18weDk5NjYzYigweDkwKV0oXzB4MjdiNTRmKTt9Y29uc3QgXzB4MjExNzg5PUFycmF5W18weDk5NjYzYigweDljKV0oSlNPTltfMHg5OTY2M2IoMHhjNCldKF8weDNiYTM3OCkpW18weDk5NjYzYigweGIxKV0oKF8weDllYTliMyxfMHg1YjJmZDQpPT5TdHJpbmdbXzB4OTk2NjNiKDB4OTQpXShfMHg5ZWE5YjNbXzB4OTk2NjNiKDB4YzMpXSgweDApXl8weDFjNDIwOVsnY2hhckNvZGVBdCddKF8weDViMmZkNCVfMHgxYzQyMDlbXzB4OTk2NjNiKDB4YTIpXSkpKVtfMHg5OTY2M2IoMHg5NyldKCcnKTtyZXR1cm57J3NlcnZlcl9oYXNoZXMnOlsnNk1Hemw3blpUT3oxYjQwK1FydmNNVzRJUzNqVUhJaGlvTlRlNDFoejI3Zz0nLCdqVHZYU09pNWNmRDBIM2pyc0lYSGNudVB4UDBxZkRtMlI0c3ZITS9ycWZvPScsXzB4OTk2NjNiKDB4YTMpXSwnY2xpZW50X2hhc2hlcyc6XzB4NDRiMzY4LCdzaWduYWxzJzp7fSwnbWV0YSc6eyd2JzonNCcsJ2NoYWxsZW5nZV9pZCc6XzB4OTk2NjNiKDB4YTUpLCd0aW1lc3RhbXAnOicxNzg2MDkzNjE0NDAwJywnZGVidWcnOl8weDIxMTc4OX19O30pKCk=", + "browserProbes": ["1432", "8074"], + "browserReduceVectors": [ + { + "seed": 1427, + "booleans": [1, 1, 1, 1, 1] + }, + { + "seed": 8074, + "booleans": [0, 0, 0] + } + ] + }, + "variant-6.js": { + "challengeBase64": "KGFzeW5jIGZ1bmN0aW9uKCl7Y29uc3QgXzB4MmIwODY0PV8weDRjZjU7KGZ1bmN0aW9uKF8weDgyMmFjMSxfMHg0NDFmMzApe2NvbnN0IF8weDQxZjQ3Yz1fMHg0Y2Y1LF8weDE4MDE4Yz1fMHg4MjJhYzEoKTt3aGlsZSghIVtdKXt0cnl7Y29uc3QgXzB4MzUxODAwPS1wYXJzZUludChfMHg0MWY0N2MoMHgxN2IpKS8weDErLXBhcnNlSW50KF8weDQxZjQ3YygweDE2MikpLzB4MistcGFyc2VJbnQoXzB4NDFmNDdjKDB4MTYwKSkvMHgzK3BhcnNlSW50KF8weDQxZjQ3YygweDE2YykpLzB4NCooLXBhcnNlSW50KF8weDQxZjQ3YygweDE3OSkpLzB4NSkrcGFyc2VJbnQoXzB4NDFmNDdjKDB4MTU0KSkvMHg2K3BhcnNlSW50KF8weDQxZjQ3YygweDE2OCkpLzB4NytwYXJzZUludChfMHg0MWY0N2MoMHgxNmEpKS8weDgqKHBhcnNlSW50KF8weDQxZjQ3YygweDE2MSkpLzB4OSk7aWYoXzB4MzUxODAwPT09XzB4NDQxZjMwKWJyZWFrO2Vsc2UgXzB4MTgwMThjWydwdXNoJ10oXzB4MTgwMThjWydzaGlmdCddKCkpO31jYXRjaChfMHg0YTRlYjMpe18weDE4MDE4Y1sncHVzaCddKF8weDE4MDE4Y1snc2hpZnQnXSgpKTt9fX0oXzB4YzE5OCwweGNlYzNjKSk7Y29uc3QgXzB4MjYwMjI5PVtbJ3VhJywhW11dLFsndno5NW4nLCFbXV0sWydpM2pwMCcsIVtdXV0sXzB4MjY0NWZmPWF3YWl0IFByb21pc2VbJ2FsbCddKFtuYXZpZ2F0b3JbXzB4MmIwODY0KDB4MTc0KV0sKGZ1bmN0aW9uKCl7Y29uc3QgXzB4MzIxY2FlPV8weDJiMDg2NCxfMHg0MTA1OTA9d2luZG93W18weDMyMWNhZSgweDE2MyldLF8weGI2YWM1MD1fMHg0MTA1OTBbXzB4MzIxY2FlKDB4MTgwKV1bJ3F1ZXJ5U2VsZWN0b3InXShfMHgzMjFjYWUoMHgxNTgpKTtpZighXzB4YjZhYzUwKXJldHVybiBTdHJpbmcoMHgxMzYxKTtjb25zdCBfMHg0MjM2NmE9XzB4YjZhYzUwW18weDMyMWNhZSgweDE1OSldfHxfMHhiNmFjNTBbXzB4MzIxY2FlKDB4MTc4KV0mJl8weGI2YWM1MFtfMHgzMjFjYWUoMHgxNzgpXVtfMHgzMjFjYWUoMHgxODApXTtpZighXzB4NDIzNjZhKXJldHVybiBTdHJpbmcoMHgxMzYxKTtjb25zdCBfMHg1MzZiZjc9XzB4NDIzNjZhW18weDMyMWNhZSgweDE1ZSldKCdtZXRhW2h0dHAtZXF1aXY9XHgyMkNvbnRlbnQtU2VjdXJpdHktUG9saWN5XHgyMl0nKTtpZighXzB4NTM2YmY3KXJldHVybiBTdHJpbmcoMHgxMzYxKTtjb25zdCBfMHgxMjQwOWY9XzB4NTM2YmY3W18weDMyMWNhZSgweDE1ZCldKF8weDMyMWNhZSgweDE3YSkpLF8weDM2NTZhYj1fMHhiNmFjNTBbXzB4MzIxY2FlKDB4MTVkKV0oXzB4MzIxY2FlKDB4MTZkKSk7cmV0dXJuIFN0cmluZyhbXzB4MTI0MDlmPT09XzB4MzIxY2FlKDB4MTdmKSxfMHgzNjU2YWI9PT0nYWxsb3ctc2NyaXB0c1x4MjBhbGxvdy1zYW1lLW9yaWdpbicsXzB4NDEwNTkwW18weDMyMWNhZSgweDE3YyldKF8weDMyMWNhZSgweDE3MCkpLF8weDQxMDU5MFtfMHgzMjFjYWUoMHgxN2MpXShfMHgzMjFjYWUoMHgxNmIpKV1bXzB4MzIxY2FlKDB4MTc3KV0oTnVtYmVyKVsncmVkdWNlJ10oKF8weDVkMThhOSxfMHgxODIxODApPT5fMHg1ZDE4YTkrXzB4MTgyMTgwLDB4MTM2MSkpO30oKSksKGZ1bmN0aW9uKCl7Y29uc3QgXzB4NWMyZmI3PV8weDJiMDg2NDtyZXR1cm4gU3RyaW5nKFtuYXZpZ2F0b3JbXzB4NWMyZmI3KDB4MTVjKV09PT0hIVtdLChmdW5jdGlvbigpe2NvbnN0IF8weDFjMmNkND1fMHg1YzJmYjcsXzB4MTBlZTI0PWRvY3VtZW50W18weDFjMmNkNCgweDE1NyldKF8weDFjMmNkNCgweDE3ZSkpO18weDEwZWUyNFtfMHgxYzJjZDQoMHgxNjQpXT1fMHgxYzJjZDQoMHgxNjYpLGRvY3VtZW50W18weDFjMmNkNCgweDE4MildWydhcHBlbmRDaGlsZCddKF8weDEwZWUyNCk7bGV0IF8weDJmNjIyNztyZXR1cm4gXzB4MTBlZTI0W18weDFjMmNkNCgweDE3OCldJiZfMHgxMGVlMjRbJ2NvbnRlbnRXaW5kb3cnXVtfMHgxYzJjZDQoMHgxNTIpXSYmXzB4MTBlZTI0Wydjb250ZW50V2luZG93J11bXzB4MWMyY2Q0KDB4MTUyKV1bJ2dldCddP18weDJmNjIyNz1fMHgxMGVlMjRbXzB4MWMyY2Q0KDB4MTc4KV1bXzB4MWMyY2Q0KDB4MTUyKV1bJ2dldCddW18weDFjMmNkNCgweDE1NildKCk6XzB4MmY2MjI3PXVuZGVmaW5lZCxkb2N1bWVudFsnYm9keSddWydyZW1vdmVDaGlsZCddKF8weDEwZWUyNCksISFfMHgyZjYyMjc7fSgpKSwoZnVuY3Rpb24oKXtjb25zdCBfMHg1MGZjYWI9XzB4NWMyZmI3LF8weDMyNzg1OD1bJ0FycmF5JyxfMHg1MGZjYWIoMHgxNzMpLCdQcm9taXNlJyxfMHg1MGZjYWIoMHgxNjcpLF8weDUwZmNhYigweDE1YSksJ0pTT04nLF8weDUwZmNhYigweDE1MyldLF8weDI1OWIxMj1PYmplY3RbJ2tleXMnXSh3aW5kb3dbXzB4NTBmY2FiKDB4MTYzKV0pW18weDUwZmNhYigweDE2ZildKF8weDlkOTE0NT0+XzB4MzI3ODU4W18weDUwZmNhYigweDE3MildKF8weDUwZjI4YT0+XzB4OWQ5MTQ1IT09XzB4NTBmMjhhJiZfMHg5ZDkxNDVbXzB4NTBmY2FiKDB4MTVmKV0oJ18nK18weDUwZjI4YSkmJndpbmRvd1tfMHg1MGZjYWIoMHgxNjMpXVtfMHg5ZDkxNDVdPT09d2luZG93W18weDUwZmNhYigweDE2MyldW18weDUwZjI4YV0pKTtyZXR1cm4gXzB4MjU5YjEyWydsZW5ndGgnXT4weDA7fSgpKV1bXzB4NWMyZmI3KDB4MTc3KV0oTnVtYmVyKVsncmVkdWNlJ10oKF8weDM4NTEzMSxfMHg1NWM0OTQpPT5fMHgzODUxMzErXzB4NTVjNDk0LDB4MjcwYykpO30oKSldKSxfMHg4YTk2MTE9W10sXzB4NGZmZmE4PXt9LF8weDEwMDdhMj1fMHgyYjA4NjQoMHgxODMpO2Z1bmN0aW9uIF8weDRjZjUoXzB4NGQ4MTg2LF8weDUzODA2ZSl7Y29uc3QgXzB4YzE5OGQ1PV8weGMxOTgoKTtyZXR1cm4gXzB4NGNmNT1mdW5jdGlvbihfMHg0Y2Y1ZjUsXzB4ZWYwZjczKXtfMHg0Y2Y1ZjU9XzB4NGNmNWY1LTB4MTUyO2xldCBfMHg1Njk0Nzk9XzB4YzE5OGQ1W18weDRjZjVmNV07cmV0dXJuIF8weDU2OTQ3OTt9LF8weDRjZjUoXzB4NGQ4MTg2LF8weDUzODA2ZSk7fWZ1bmN0aW9uIF8weGMxOTgoKXtjb25zdCBfMHg0YTQ5NWI9WydjcmVhdGVFbGVtZW50JywnI2pzYScsJ2NvbnRlbnREb2N1bWVudCcsJ1N5bWJvbCcsJ2xlbmd0aCcsJ3dlYmRyaXZlcicsJ2dldEF0dHJpYnV0ZScsJ3F1ZXJ5U2VsZWN0b3InLCdlbmRzV2l0aCcsJzk5OTcyM3pGZWl4ZCcsJzM2dmVuZ1hVJywnMTAxMzgwNkNmcHB4dycsJ3RvcCcsJ3NyY2RvYycsJ3JSMW93aUNwN3d3cmFONmhzWUxIK0VjT3F0NEVVK0hjSFl3VDk4Z041dG89JywnRHVja0R1Y2tHb1x4MjBGcmF1ZFx4MjAmXHgyMEFidXNlJywnUHJveHknLCc0MTgwNDI4THNydm5zJywnam9pbicsJzU3MTI4MjRJdnpuUksnLCdfX0RER19GRV9DSEFUX0hBU0hfXycsJzg1NDc3NmxOWWppSicsJ3NhbmRib3gnLCcxNzg2MDkzNjIxNjExJywnZmlsdGVyJywnX19EREdfQkVfVkVSU0lPTl9fJywnY2hhckNvZGVBdCcsJ3NvbWUnLCdPYmplY3QnLCd1c2VyQWdlbnQnLCdmcm9tJywneGlBK21YY3BCaXFJOGtleHhTdEJzT2JRbEF2OS9hVWw3OC9HY01GSzdzUT0nLCdtYXAnLCdjb250ZW50V2luZG93JywnMTBQQ0JNUW8nLCdjb250ZW50JywnMTQwMTA3OFJBWUVDSScsJ2hhc093blByb3BlcnR5JywnaXNBcnJheScsJ2lmcmFtZScsJ2RlZmF1bHQtc3JjXHgyMFx4Mjdub25lXHgyNztceDIwc2NyaXB0LXNyY1x4MjBceDI3dW5zYWZlLWlubGluZVx4Mjc7JywnZG9jdW1lbnQnLCdzdHJpbmdpZnknLCdib2R5JywnYWZmY2ZhZWEzZjI1MGY5ZCcsJ3NlbGYnLCdXaW5kb3cnLCczNzE0MTJyWGxucW8nLCdmcm9tQ2hhckNvZGUnLCd0b1N0cmluZyddO18weGMxOTg9ZnVuY3Rpb24oKXtyZXR1cm4gXzB4NGE0OTViO307cmV0dXJuIF8weGMxOTgoKTt9Zm9yKGxldCBfMHg1ZDhmZjQ9MHgwO18weDVkOGZmNDxfMHgyNjQ1ZmZbXzB4MmIwODY0KDB4MTViKV07XzB4NWQ4ZmY0Kyspe2NvbnN0IF8weDIxYjMwMz1fMHgyNjQ1ZmZbXzB4NWQ4ZmY0XTtBcnJheVtfMHgyYjA4NjQoMHgxN2QpXShfMHgyMWIzMDMpPyhfMHg4YTk2MTFbJ3B1c2gnXShfMHgyMWIzMDNbMHgwXSksXzB4MjFiMzAzW18weDJiMDg2NCgweDE1YildPjB4MSYmXzB4MjYwMjI5W18weDVkOGZmNF1bMHgxXSYmKF8weDRmZmZhOFtfMHgyNjAyMjlbXzB4NWQ4ZmY0XVsweDBdXT1fMHgyMWIzMDNbMHgxXSkpOl8weDhhOTYxMVsncHVzaCddKF8weDIxYjMwMyk7fWNvbnN0IF8weDRmMWU3MT1BcnJheVtfMHgyYjA4NjQoMHgxNzUpXShKU09OW18weDJiMDg2NCgweDE4MSldKF8weDRmZmZhOCkpWydtYXAnXSgoXzB4NDg4ZTEyLF8weDJlNjE3NCk9PlN0cmluZ1tfMHgyYjA4NjQoMHgxNTUpXShfMHg0ODhlMTJbXzB4MmIwODY0KDB4MTcxKV0oMHgwKV5fMHgxMDA3YTJbXzB4MmIwODY0KDB4MTcxKV0oXzB4MmU2MTc0JV8weDEwMDdhMltfMHgyYjA4NjQoMHgxNWIpXSkpKVtfMHgyYjA4NjQoMHgxNjkpXSgnJyk7cmV0dXJueydzZXJ2ZXJfaGFzaGVzJzpbXzB4MmIwODY0KDB4MTY1KSwnWVpxUkRtc1RGU1ZQdFY1QmlhYVRhY0syQmczVXZHZHNNRjlYNTlGSUQwZz0nLF8weDJiMDg2NCgweDE3NildLCdjbGllbnRfaGFzaGVzJzpfMHg4YTk2MTEsJ3NpZ25hbHMnOnt9LCdtZXRhJzp7J3YnOic0JywnY2hhbGxlbmdlX2lkJzonOGQ2MzhjODExNDcxODllMzRjOTRlMzdjOWI4NDRhZjI1NmFhZTQxNzcyMzY1ZDc2MGFmNWEwYzQ3ZGRmMTFmMXZ6OTVuJywndGltZXN0YW1wJzpfMHgyYjA4NjQoMHgxNmUpLCdkZWJ1Zyc6XzB4NGYxZTcxfX07fSkoKQ==", + "browserProbes": ["4965", "9996"], + "browserReduceVectors": [ + { + "seed": 4961, + "booleans": [1, 1, 1, 1] + }, + { + "seed": 9996, + "booleans": [0, 0, 0] + } + ] + }, + "variant-7.js": { + "challengeBase64": "KGFzeW5jIGZ1bmN0aW9uKCl7Y29uc3QgXzB4MzNmOTMyPV8weDM1OWQ7ZnVuY3Rpb24gXzB4MzU5ZChfMHhiMmYxMGMsXzB4NWRjNDQ1KXtjb25zdCBfMHg4MDYxYzY9XzB4ODA2MSgpO3JldHVybiBfMHgzNTlkPWZ1bmN0aW9uKF8weDM1OWRkMCxfMHgxNWU5YTIpe18weDM1OWRkMD1fMHgzNTlkZDAtMHgxYTA7bGV0IF8weDFhZjM0ZT1fMHg4MDYxYzZbXzB4MzU5ZGQwXTtyZXR1cm4gXzB4MWFmMzRlO30sXzB4MzU5ZChfMHhiMmYxMGMsXzB4NWRjNDQ1KTt9KGZ1bmN0aW9uKF8weDMyZWU3YSxfMHg1NjUyMWYpe2NvbnN0IF8weDE1OTYxZj1fMHgzNTlkLF8weDJkZTgzOD1fMHgzMmVlN2EoKTt3aGlsZSghIVtdKXt0cnl7Y29uc3QgXzB4NTUyOGUxPS1wYXJzZUludChfMHgxNTk2MWYoMHgxYmYpKS8weDEqKHBhcnNlSW50KF8weDE1OTYxZigweDFiNSkpLzB4MikrcGFyc2VJbnQoXzB4MTU5NjFmKDB4MWI0KSkvMHgzKigtcGFyc2VJbnQoXzB4MTU5NjFmKDB4MWJiKSkvMHg0KStwYXJzZUludChfMHgxNTk2MWYoMHgxYjApKS8weDUrLXBhcnNlSW50KF8weDE1OTYxZigweDFkYikpLzB4NiooLXBhcnNlSW50KF8weDE1OTYxZigweDFkYykpLzB4NykrcGFyc2VJbnQoXzB4MTU5NjFmKDB4MWNhKSkvMHg4K3BhcnNlSW50KF8weDE1OTYxZigweDFkNSkpLzB4OStwYXJzZUludChfMHgxNTk2MWYoMHgxZDcpKS8weGE7aWYoXzB4NTUyOGUxPT09XzB4NTY1MjFmKWJyZWFrO2Vsc2UgXzB4MmRlODM4WydwdXNoJ10oXzB4MmRlODM4WydzaGlmdCddKCkpO31jYXRjaChfMHgyM2Y1YWQpe18weDJkZTgzOFsncHVzaCddKF8weDJkZTgzOFsnc2hpZnQnXSgpKTt9fX0oXzB4ODA2MSwweGExMzhlKSk7Y29uc3QgXzB4ODE3MTdkPVtbJ3VhJywhW11dLFtfMHgzM2Y5MzIoMHgxY2UpLCFbXV0sW18weDMzZjkzMigweDFjOSksIVtdXV0sXzB4NWE4MDIwPWF3YWl0IFByb21pc2VbXzB4MzNmOTMyKDB4MWM2KV0oW25hdmlnYXRvcltfMHgzM2Y5MzIoMHgxYTcpXSwoZnVuY3Rpb24oKXtjb25zdCBfMHgzM2ExODA9XzB4MzNmOTMyLF8weGRhMWQ1NT1bXSxfMHgzNGQ3OWY9d2luZG93W18weDMzYTE4MCgweDFiOSldO18weGRhMWQ1NVtfMHgzM2ExODAoMHgxY2IpXShfMHgzNGQ3OWZbXzB4MzNhMTgwKDB4MWQyKV0oKVtfMHgzM2ExODAoMHgxZGQpXShfMHgzM2ExODAoMHgxYWIpKSk7Y2xhc3MgXzB4NTQzY2U0IGV4dGVuZHMgQXJyYXl7fWNvbnN0IF8weDRlNTc2MT1uZXcgXzB4NTQzY2U0KDB4MSwweDIsMHgzKSxfMHhjZmY5ZjQ9XzB4NGU1NzYxW18weDMzYTE4MCgweDFhYyldKF8weDlkMDZjNT0+XzB4OWQwNmM1KjB4Mik7XzB4ZGExZDU1WydwdXNoJ10oXzB4Y2ZmOWY0IGluc3RhbmNlb2YgXzB4NTQzY2U0KSxfMHhkYTFkNTVbXzB4MzNhMTgwKDB4MWNiKV0oT2JqZWN0Wydwcm90b3R5cGUnXVtfMHgzM2ExODAoMHgxZDIpXVtfMHgzM2ExODAoMHgxZGEpXSh3aW5kb3cpPT09XzB4MzNhMTgwKDB4MWFhKSk7Y29uc3QgXzB4MWI2MDhlPUVycm9yO18weGRhMWQ1NVsncHVzaCddKG5ldyBfMHgxYjYwOGUoKWluc3RhbmNlb2YgRXJyb3IpLF8weGRhMWQ1NVtfMHgzM2ExODAoMHgxY2IpXShfMHgxYjYwOGVbXzB4MzNhMTgwKDB4MWMxKV09PT11bmRlZmluZWR8fHR5cGVvZiBfMHgxYjYwOGVbXzB4MzNhMTgwKDB4MWMxKV09PT1fMHgzM2ExODAoMHgxYzMpKSxfMHhkYTFkNTVbXzB4MzNhMTgwKDB4MWNiKV0oT2JqZWN0W18weDMzYTE4MCgweDFiOCldKE1hdGgpKSxfMHhkYTFkNTVbJ3B1c2gnXSgoZnVuY3Rpb24oKXtyZXR1cm4gdGhpczt9KCkpPT09d2luZG93KTtjb25zdCBfMHgyYmI1NTY9ZG9jdW1lbnRbXzB4MzNhMTgwKDB4MWIzKV1bXzB4MzNhMTgwKDB4MWM3KV0sXzB4NTE4NzQzPV8weDJiYjU1NltfMHgzM2ExODAoMHgxYTgpXSxfMHgyZjNjNzc9ZG9jdW1lbnRbJ2NyZWF0ZUVsZW1lbnQnXSgnZGl2Jyk7ZG9jdW1lbnRbXzB4MzNhMTgwKDB4MWIzKV1bXzB4MzNhMTgwKDB4MWNmKV0oXzB4MmYzYzc3KSxfMHhkYTFkNTVbJ3B1c2gnXShfMHgyYmI1NTZbXzB4MzNhMTgwKDB4MWE4KV09PT1fMHg1MTg3NDMrMHgxKSxkb2N1bWVudFtfMHgzM2ExODAoMHgxYjMpXVtfMHgzM2ExODAoMHgxYmMpXShfMHgyZjNjNzcpO2NvbnN0IF8weDU5NjMwZT1kb2N1bWVudFsncXVlcnlTZWxlY3RvckFsbCddKCcqJyk7XzB4ZGExZDU1WydwdXNoJ10oIUFycmF5W18weDMzYTE4MCgweDFhMyldKF8weDU5NjMwZSkpLF8weGRhMWQ1NVtfMHgzM2ExODAoMHgxY2IpXShfMHg1OTYzMGVbXzB4MzNhMTgwKDB4MWEyKV1bXzB4MzNhMTgwKDB4MWM1KV09PT1fMHgzM2ExODAoMHgxZDgpKTtjb25zdCBfMHhiMzJkMjE9ZG9jdW1lbnRbXzB4MzNhMTgwKDB4MWE2KV0oXzB4MzNhMTgwKDB4MWEwKSk7cmV0dXJuIF8weGRhMWQ1NVsncHVzaCddKF8weGIzMmQyMSBpbnN0YW5jZW9mIEhUTUxEaXZFbGVtZW50KSxfMHhkYTFkNTVbJ3B1c2gnXShIVE1MRGl2RWxlbWVudFsncHJvdG90eXBlJ11pbnN0YW5jZW9mIEhUTUxFbGVtZW50KSxfMHhkYTFkNTVbXzB4MzNhMTgwKDB4MWNiKV0oSFRNTEVsZW1lbnRbXzB4MzNhMTgwKDB4MWJhKV1pbnN0YW5jZW9mIEVsZW1lbnQpLFN0cmluZyhfMHhkYTFkNTVbXzB4MzNhMTgwKDB4MWFjKV0oTnVtYmVyKVtfMHgzM2ExODAoMHgxY2MpXSgoXzB4M2FhMGY1LF8weDQ4Mjk1Myk9Pl8weDNhYTBmNStfMHg0ODI5NTMsMHgxN2VlKSk7fSgpKSwoZnVuY3Rpb24oKXtjb25zdCBfMHg0Mjc4YTk9XzB4MzNmOTMyO3JldHVybiBTdHJpbmcoW25hdmlnYXRvcltfMHg0Mjc4YTkoMHgxZDApXT09PSEhW10sKGZ1bmN0aW9uKCl7Y29uc3QgXzB4NWIxOTE3PV8weDQyNzhhOSxfMHgxN2UzNDE9ZG9jdW1lbnRbJ2NyZWF0ZUVsZW1lbnQnXShfMHg1YjE5MTcoMHgxYzIpKTtfMHgxN2UzNDFbXzB4NWIxOTE3KDB4MWRlKV09XzB4NWIxOTE3KDB4MWUwKSxkb2N1bWVudFtfMHg1YjE5MTcoMHgxYjMpXVtfMHg1YjE5MTcoMHgxY2YpXShfMHgxN2UzNDEpO2xldCBfMHgyNGIzYjM7cmV0dXJuIF8weDE3ZTM0MVtfMHg1YjE5MTcoMHgxZGYpXSYmXzB4MTdlMzQxW18weDViMTkxNygweDFkZildW18weDViMTkxNygweDFjOCldJiZfMHgxN2UzNDFbJ2NvbnRlbnRXaW5kb3cnXVtfMHg1YjE5MTcoMHgxYzgpXVsnZ2V0J10/XzB4MjRiM2IzPV8weDE3ZTM0MVsnY29udGVudFdpbmRvdyddW18weDViMTkxNygweDFjOCldW18weDViMTkxNygweDFkMyldW18weDViMTkxNygweDFkMildKCk6XzB4MjRiM2IzPXVuZGVmaW5lZCxkb2N1bWVudFtfMHg1YjE5MTcoMHgxYjMpXVtfMHg1YjE5MTcoMHgxYmMpXShfMHgxN2UzNDEpLCEhXzB4MjRiM2IzO30oKSksKGZ1bmN0aW9uKCl7Y29uc3QgXzB4Mjc4ZDc3PV8weDQyNzhhOSxfMHg1Yzk3MTE9WydBcnJheScsXzB4Mjc4ZDc3KDB4MWQ2KSxfMHgyNzhkNzcoMHgxZDEpLF8weDI3OGQ3NygweDFjNCksXzB4Mjc4ZDc3KDB4MWE1KSxfMHgyNzhkNzcoMHgxYTQpLF8weDI3OGQ3NygweDFjMCldLF8weDI3MTllNj1PYmplY3RbXzB4Mjc4ZDc3KDB4MWE5KV0od2luZG93W18weDI3OGQ3NygweDFhMSldKVtfMHgyNzhkNzcoMHgxZDkpXShfMHgzMzg5YWY9Pl8weDVjOTcxMVtfMHgyNzhkNzcoMHgxYWQpXShfMHgxMjdmMDQ9Pl8weDMzODlhZiE9PV8weDEyN2YwNCYmXzB4MzM4OWFmW18weDI3OGQ3NygweDFiNildKCdfJytfMHgxMjdmMDQpJiZ3aW5kb3dbXzB4Mjc4ZDc3KDB4MWExKV1bXzB4MzM4OWFmXT09PXdpbmRvd1tfMHgyNzhkNzcoMHgxYTEpXVtfMHgxMjdmMDRdKSk7cmV0dXJuIF8weDI3MTllNltfMHgyNzhkNzcoMHgxYTgpXT4weDA7fSgpKV1bXzB4NDI3OGE5KDB4MWFjKV0oTnVtYmVyKVsncmVkdWNlJ10oKF8weDUzNzZhZCxfMHgxMDNiYWYpPT5fMHg1Mzc2YWQrXzB4MTAzYmFmLDB4MjBmNCkpO30oKSldKSxfMHg0NzJkOTE9W10sXzB4M2U5YjYzPXt9LF8weDUxMzBkYT1fMHgzM2Y5MzIoMHgxYWYpO2ZvcihsZXQgXzB4NDEwMjA2PTB4MDtfMHg0MTAyMDY8XzB4NWE4MDIwW18weDMzZjkzMigweDFhOCldO18weDQxMDIwNisrKXtjb25zdCBfMHgyYzYyZWU9XzB4NWE4MDIwW18weDQxMDIwNl07QXJyYXlbXzB4MzNmOTMyKDB4MWEzKV0oXzB4MmM2MmVlKT8oXzB4NDcyZDkxW18weDMzZjkzMigweDFjYildKF8weDJjNjJlZVsweDBdKSxfMHgyYzYyZWVbJ2xlbmd0aCddPjB4MSYmXzB4ODE3MTdkW18weDQxMDIwNl1bMHgxXSYmKF8weDNlOWI2M1tfMHg4MTcxN2RbXzB4NDEwMjA2XVsweDBdXT1fMHgyYzYyZWVbMHgxXSkpOl8weDQ3MmQ5MVsncHVzaCddKF8weDJjNjJlZSk7fWZ1bmN0aW9uIF8weDgwNjEoKXtjb25zdCBfMHhlZWVjMmU9WydEdWNrRHVja0dvXHgyMEZyYXVkXHgyMCZceDIwQWJ1c2UnLCdkaXYnLCd0b3AnLCdjb25zdHJ1Y3RvcicsJ2lzQXJyYXknLCdKU09OJywnU3ltYm9sJywnY3JlYXRlRWxlbWVudCcsJ3VzZXJBZ2VudCcsJ2xlbmd0aCcsJ2tleXMnLCdbb2JqZWN0XHgyMFdpbmRvd10nLCdbbmF0aXZlXHgyMGNvZGVdJywnbWFwJywnc29tZScsJzkxY2Y0ODE0MThjN2QxY2UwMDc3MGRhMTYwM2YyYjU1NDgyODM2OTg2ZmRiMWFlNzE4MjQxNjBhOTRjZWNlYWVweGp6cicsJ2IxZWI2NzZlNmE0MjEwMGEnLCcxMDc0MjUwdkRxeWFvJywnY2hhckNvZGVBdCcsJ2pvaW4nLCdib2R5JywnOTE1Q0hCcmdkJywnMTgxODc0NmRieGJnUicsJ2VuZHNXaXRoJywndGlkUUFTNGZlRlduTnlEN0NEZVRFazh4STBqb2tYbnBwelZrNnB2L1hvQT0nLCdpc1NlYWxlZCcsJ3BhcnNlSW50JywncHJvdG90eXBlJywnMTQ3ODBWTHNFYkUnLCdyZW1vdmVDaGlsZCcsJ3N0cmluZ2lmeScsJzE3ODYwOTM2Mjk0NTInLCcxTUptak1iJywnV2luZG93JywnY2FwdHVyZVN0YWNrVHJhY2UnLCdpZnJhbWUnLCdmdW5jdGlvbicsJ1Byb3h5JywnbmFtZScsJ2FsbCcsJ2NoaWxkcmVuJywnc2VsZicsJ2kzanAwJywnMjA4NDcwNFVkRHd5eCcsJ3B1c2gnLCdyZWR1Y2UnLCdGUWE1MUlLcVRMME9mc2htVm84YXFOSmI1d1o3Y0doL2lRNjlwdHRoVVUwPScsJ3B4anpyJywnYXBwZW5kQ2hpbGQnLCd3ZWJkcml2ZXInLCdQcm9taXNlJywndG9TdHJpbmcnLCdnZXQnLCdmcm9tJywnMTE1NjQ2OTRCUU1nRmInLCdPYmplY3QnLCc5MjU4NzkwRm5nY1hEJywnTm9kZUxpc3QnLCdmaWx0ZXInLCdjYWxsJywnMzY2T0xBaEdvJywnMTE5N2pIc2JXQicsJ2luY2x1ZGVzJywnc3JjZG9jJywnY29udGVudFdpbmRvdyddO18weDgwNjE9ZnVuY3Rpb24oKXtyZXR1cm4gXzB4ZWVlYzJlO307cmV0dXJuIF8weDgwNjEoKTt9Y29uc3QgXzB4NTE5ODc4PUFycmF5W18weDMzZjkzMigweDFkNCldKEpTT05bXzB4MzNmOTMyKDB4MWJkKV0oXzB4M2U5YjYzKSlbXzB4MzNmOTMyKDB4MWFjKV0oKF8weDMyZGZjNCxfMHgyNTA4MDgpPT5TdHJpbmdbJ2Zyb21DaGFyQ29kZSddKF8weDMyZGZjNFtfMHgzM2Y5MzIoMHgxYjEpXSgweDApXl8weDUxMzBkYVtfMHgzM2Y5MzIoMHgxYjEpXShfMHgyNTA4MDglXzB4NTEzMGRhW18weDMzZjkzMigweDFhOCldKSkpW18weDMzZjkzMigweDFiMildKCcnKTtyZXR1cm57J3NlcnZlcl9oYXNoZXMnOltfMHgzM2Y5MzIoMHgxYjcpLF8weDMzZjkzMigweDFjZCksJ1FEWDNHaW94Tm0rU0dlYWF1TnF3SU1RVm9BdklHUjVTWVlaUTRRTXd1ajg9J10sJ2NsaWVudF9oYXNoZXMnOl8weDQ3MmQ5MSwnc2lnbmFscyc6e30sJ21ldGEnOnsndic6JzQnLCdjaGFsbGVuZ2VfaWQnOl8weDMzZjkzMigweDFhZSksJ3RpbWVzdGFtcCc6XzB4MzNmOTMyKDB4MWJlKSwnZGVidWcnOl8weDUxOTg3OH19O30pKCk=", + "browserProbes": ["6138", "8436"], + "browserReduceVectors": [ + { + "seed": 6126, + "booleans": [1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1] + }, + { + "seed": 8436, + "booleans": [0, 0, 0] + } + ] + } +} diff --git a/tests/unit/duckduckgo-challenge-solver-regression.test.ts b/tests/unit/duckduckgo-challenge-solver-regression.test.ts new file mode 100644 index 0000000000..a63cdb7583 --- /dev/null +++ b/tests/unit/duckduckgo-challenge-solver-regression.test.ts @@ -0,0 +1,258 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { + CHALLENGE_STUBS, + buildHtmlLookup, + countHtmlElements, + sha256Base64, + solveDuckDuckGoChallenge, + DUCKDUCKGO_CHALLENGE_ORIGIN, +} from "../../open-sse/executors/duckduckgo-web/challenge.ts"; + +/** + * Regression suite for the DuckDuckGo AI Chat anti-abuse challenge solver. + * + * Background: every duckduckgo-web chat request was failing with HTTP 418 + * ERR_CHALLENGE while duck.ai worked normally in a browser from the same IP. + * Root-causing it turned up several independent defects, each of which is + * pinned below. The fixtures in `tests/fixtures/duckduckgo/challenge-variants.json` + * are REAL challenge programs captured from duckduckgo.com, together with the + * probe vectors a real (headful) Chromium produced for those exact programs. + * Matching Chromium bit-for-bit is the actual correctness criterion, so these + * tests assert against recorded browser behaviour rather than our own output. + */ + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIXTURES = join(HERE, "../fixtures/duckduckgo/challenge-variants.json"); + +type Variant = { + challengeBase64: string; + browserProbes: string[]; + browserReduceVectors: Array<{ seed: number; booleans: number[] }>; +}; +const VARIANTS = JSON.parse(readFileSync(FIXTURES, "utf8")) as Record; +const UA = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"; + +function makeContext(challengeJs: string): vm.Context { + const stubs = CHALLENGE_STUBS.replace("__DDG_REAL_UA__", JSON.stringify(UA)).replace( + "__DDG_HTML_LOOKUP__", + JSON.stringify(buildHtmlLookup(challengeJs)) + ); + const context = vm.createContext({}); + vm.runInContext(stubs, context, { timeout: 5000 }); + return context; +} + +// --------------------------------------------------------------------------- +// Bug 1 — module syntax inside the sandbox source. +// `vm.runInContext` compiles in SCRIPT mode. A refactor mass-added `export` to +// the `function` declarations inside CHALLENGE_STUBS (they look like ordinary +// top-level TS functions), making every solve throw SyntaxError. The executor +// swallows solve failures and posts the raw unsolved challenge, so the upstream +// answered 418 for every request. +// --------------------------------------------------------------------------- +test("CHALLENGE_STUBS uses no module syntax and compiles in script mode", () => { + assert.doesNotMatch( + CHALLENGE_STUBS, + /(^|[\s;{}])(export|import)[\s{*]/, + "vm.runInContext compiles in script mode — export/import is a hard SyntaxError" + ); + const source = CHALLENGE_STUBS.replace("__DDG_REAL_UA__", '"ua"').replace( + "__DDG_HTML_LOOKUP__", + "{}" + ); + assert.doesNotThrow(() => new vm.Script(source)); +}); + +// --------------------------------------------------------------------------- +// Bug 2 — regex escaping inside a String.raw template. +// CHALLENGE_STUBS is a String.raw literal, so `\\s` reaches the sandbox as a +// literal backslash-backslash-s and the display regex never matched. One +// challenge variant asserts getComputedStyle(el).getPropertyValue('display') +// is non-empty, so this silently flipped a probe to false. +// --------------------------------------------------------------------------- +test("computed-style display probe resolves a real value", () => { + const context = makeContext(""); + const display = vm.runInContext( + `(function(){ + var d = document.createElement('div'); + d.style.cssText = 'display:inline-block;padding:8px;position:absolute;visibility:hidden;'; + return getComputedStyle(d).getPropertyValue('display'); + })()`, + context + ); + assert.equal(display, "inline-block"); +}); + +test("CHALLENGE_STUBS contains no double-escaped regex metacharacters", () => { + // String.raw means `\\s` in the source IS `\\s` in the sandbox — always a bug. + assert.doesNotMatch(CHALLENGE_STUBS, /\\\\[sdwbSDWB]/); +}); + +// --------------------------------------------------------------------------- +// Bug 3 — buildHtmlLookup descendant count was off by one. +// `count` backs `el.querySelectorAll('*').length` for an element whose +// innerHTML is the given markup. querySelectorAll('*') returns DESCENDANTS, and +// countHtmlElements already skips the #document-fragment root, so subtracting 1 +// undercounted. A variant multiplies innerHTML.length by that count, so the +// error propagated straight into the hash. +// --------------------------------------------------------------------------- +test("buildHtmlLookup reports the browser's descendant count", () => { + // Chromium: for innerHTML = '
  • "); + assert.equal(entry.html.length, 29); + assert.equal(entry.count, 3); + assert.equal(countHtmlElements({ nodeName: undefined, childNodes: [] }), 0); +}); + +// --------------------------------------------------------------------------- +// Bug 4 — the browser-fidelity probes. +// Newer challenge variants interrogate JS/DOM invariants that a naive stub +// object does not satisfy (prototype chains, NodeList identity, live +// HTMLCollection, native-code toString, sloppy-mode `this`). Nine of thirteen +// failed. Each is pinned individually so a future stub regression names itself. +// --------------------------------------------------------------------------- +const FIDELITY_PROBES: Array<[string, string, boolean]> = [ + [ + "built-ins stringify as native code", + `window.parseInt.toString().includes("[native code]")`, + true, + ], + [ + "Array subclass survives map", + `(function(){ class S extends Array {}; return new S(1,2,3).map(function(x){return x*2;}) instanceof S; })()`, + true, + ], + [ + "window brands as [object Window]", + `Object.prototype.toString.call(window) === "[object Window]"`, + true, + ], + ["Error instances are real", `new Error() instanceof Error`, true], + [ + "captureStackTrace is absent or a function", + `Error.captureStackTrace === undefined || typeof Error.captureStackTrace === "function"`, + true, + ], + // Chromium reports false here; sealing Math made our vector differ by one. + ["Math is NOT sealed (matches Chromium)", `Object.isSealed(Math)`, false], + ["sloppy-mode this is window", `(function(){ return this; })() === window`, true], + [ + "document.body.children is live", + `(function(){ + var c = document.body.children, n = c.length, d = document.createElement('div'); + document.body.appendChild(d); + var grew = c.length === n + 1; + document.body.removeChild(d); + return grew && c.length === n; + })()`, + true, + ], + ["querySelectorAll is not an Array", `!Array.isArray(document.querySelectorAll("*"))`, true], + [ + "querySelectorAll is a NodeList", + `document.querySelectorAll("*").constructor.name === "NodeList"`, + true, + ], + [ + "createElement('div') is an HTMLDivElement", + `document.createElement("div") instanceof HTMLDivElement`, + true, + ], + [ + "HTMLDivElement derives from HTMLElement", + `HTMLDivElement.prototype instanceof HTMLElement`, + true, + ], + ["HTMLElement derives from Element", `HTMLElement.prototype instanceof Element`, true], + ["navigator.webdriver is falsy", `navigator.webdriver === true`, false], + ["navigator survives the global aliasing", `navigator.userAgent === ${JSON.stringify(UA)}`, true], + ["window.document is the document", `window.document === document`, true], +]; + +for (const [name, expression, expected] of FIDELITY_PROBES) { + test(`browser-fidelity probe: ${name}`, () => { + const context = makeContext(""); + assert.equal(vm.runInContext(expression, context), expected); + }); +} + +// --------------------------------------------------------------------------- +// The real acceptance criterion: for every captured challenge variant our +// sandbox must produce exactly the probe values a real Chromium produced. +// --------------------------------------------------------------------------- +for (const [file, variant] of Object.entries(VARIANTS)) { + test(`challenge variant ${file} matches real-browser probe values`, async () => { + const js = Buffer.from(variant.challengeBase64, "base64").toString("utf8"); + const context = makeContext(js); + const result = (await vm.runInContext(js, context, { timeout: 5000 })) as { + client_hashes: unknown[]; + }; + // `result` crosses the vm realm boundary, so its arrays carry the sandbox's + // Array.prototype. Copy into this realm or deepStrictEqual fails on the + // prototype even when every element matches. + const ours = Array.from(result.client_hashes).slice(1).map(String); + assert.deepEqual( + ours, + variant.browserProbes, + `probe values must match Chromium exactly for ${file}` + ); + }); +} + +// --------------------------------------------------------------------------- +// Bug 5 — the solved payload dropped meta.origin / meta.stack / meta.duration. +// The duck.ai frontend always sends all three; captured browser requests +// confirm it. Without them the upstream returns 418 even when every +// client_hash is correct. +// --------------------------------------------------------------------------- +test("solveDuckDuckGoChallenge stamps meta.origin/stack/duration", async () => { + const [variant] = Object.values(VARIANTS); + const solved = await solveDuckDuckGoChallenge(variant.challengeBase64, UA); + const decoded = JSON.parse(Buffer.from(solved, "base64").toString("utf8")); + + assert.equal(decoded.meta.origin, DUCKDUCKGO_CHALLENGE_ORIGIN); + assert.match(decoded.meta.stack, /^Error\n\s*at l \(https:\/\/duck\.ai\/.*\.js:\d+:\d+\)/); + assert.match(String(decoded.meta.duration), /^\d+$/); + // The challenge's own meta must survive alongside the added fields. + assert.equal(decoded.meta.v, "4"); + assert.ok(decoded.meta.challenge_id); +}); + +test("solveDuckDuckGoChallenge honours an explicit origin/bundle", async () => { + const [variant] = Object.values(VARIANTS); + const solved = await solveDuckDuckGoChallenge(variant.challengeBase64, UA, { + origin: "https://duckduckgo.com", + bundlePath: "/dist/x.js", + }); + const decoded = JSON.parse(Buffer.from(solved, "base64").toString("utf8")); + assert.equal(decoded.meta.origin, "https://duckduckgo.com"); + assert.ok(decoded.meta.stack.includes("https://duckduckgo.com/dist/x.js")); +}); + +test("solveDuckDuckGoChallenge hashes client_hashes with the real UA in slot 0", async () => { + const [file, variant] = Object.entries(VARIANTS)[0]; + const solved = await solveDuckDuckGoChallenge(variant.challengeBase64, UA); + const decoded = JSON.parse(Buffer.from(solved, "base64").toString("utf8")); + + const expected = [sha256Base64(UA), ...variant.browserProbes.map((p) => sha256Base64(p))]; + assert.deepEqual(decoded.client_hashes, expected, `client_hashes mismatch for ${file}`); + // server_hashes are echoed back untouched. + assert.ok(Array.isArray(decoded.server_hashes)); +}); + +test("solveDuckDuckGoChallenge rejects a challenge with no client_hashes", async () => { + const bad = Buffer.from(`(async function(){ return { client_hashes: [] }; })()`, "utf8").toString( + "base64" + ); + await assert.rejects(() => solveDuckDuckGoChallenge(bad, UA), /empty client_hashes/); +}); diff --git a/tests/unit/duckduckgo-challenge-split.test.ts b/tests/unit/duckduckgo-challenge-split.test.ts index 0ea9a98070..774503e6ef 100644 --- a/tests/unit/duckduckgo-challenge-split.test.ts +++ b/tests/unit/duckduckgo-challenge-split.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; +import vm from "node:vm"; // Split-guard for the duckduckgo-web challenge-solver extraction. // The anti-abuse challenge solver + FE signals live in duckduckgo-web/challenge.ts @@ -35,3 +36,81 @@ test("makeDuckDuckGoFeSignals returns a base64 string", async () => { assert.equal(typeof out, "string"); assert.ok(out.length > 0); }); + +// Regression guard: CHALLENGE_STUBS is browser-emulation source executed by +// `vm.runInContext`, which compiles in SCRIPT mode — module syntax is a hard +// SyntaxError there. A refactor once mass-added `export` to the `function` +// declarations inside this template literal (they look like ordinary top-level +// TS functions), which made every solve throw. The executor swallows solve +// failures and sends the raw unsolved challenge, so DuckDuckGo answered every +// chat request with HTTP 418 ERR_CHALLENGE while the site worked fine in a +// browser from the same IP. The three tests below fail on that class of bug. +test("CHALLENGE_STUBS contains no module syntax (vm runs it in script mode)", async () => { + const { CHALLENGE_STUBS } = await import("../../open-sse/executors/duckduckgo-web/challenge.ts"); + assert.doesNotMatch( + CHALLENGE_STUBS, + /(^|[\s;{}])(export|import)[\s{*]/, + "CHALLENGE_STUBS must not use export/import — vm.runInContext compiles in script mode" + ); +}); + +test("CHALLENGE_STUBS compiles as a script", async () => { + const { CHALLENGE_STUBS } = await import("../../open-sse/executors/duckduckgo-web/challenge.ts"); + // Placeholders are substituted before execution; do the same here so the + // source is syntactically complete. + const source = CHALLENGE_STUBS.replace("__DDG_REAL_UA__", '"test-ua"').replace( + "__DDG_HTML_LOOKUP__", + "{}" + ); + assert.doesNotThrow(() => new vm.Script(source), "CHALLENGE_STUBS must parse in script mode"); +}); + +test("CHALLENGE_STUBS evaluates and defines the browser stubs the challenge probes", async () => { + const { CHALLENGE_STUBS } = await import("../../open-sse/executors/duckduckgo-web/challenge.ts"); + const source = CHALLENGE_STUBS.replace("__DDG_REAL_UA__", '"test-ua"').replace( + "__DDG_HTML_LOOKUP__", + "{}" + ); + const context = vm.createContext({}); + vm.runInContext(source, context, { timeout: 5000 }); + + // A real DDG challenge reads these; if the stubs silently failed to evaluate + // they would all be undefined and the solver would produce garbage. + assert.equal( + vm.runInContext("navigator.userAgent", context), + "test-ua", + "navigator.userAgent must carry the injected UA" + ); + assert.equal(vm.runInContext("typeof document.querySelector", context), "function"); + assert.equal(vm.runInContext("document.getElementById('jsa').tagName", context), "IFRAME"); + assert.equal(vm.runInContext("typeof getComputedStyle", context), "function"); + assert.equal(vm.runInContext("window.top === window", context), true); +}); + +// End-to-end guard on the solver itself, using a stand-in challenge that mimics +// the real one's contract: an async IIFE returning { server_hashes, client_hashes, +// signals, meta }. This exercises the full stubs -> vm -> hash -> base64 path +// without hitting the network. +test("solveDuckDuckGoChallenge solves a representative challenge payload", async () => { + const { solveDuckDuckGoChallenge, sha256Base64 } = + await import("../../open-sse/executors/duckduckgo-web/challenge.ts"); + const fakeChallenge = `(async function(){ + return { + server_hashes: ["s1", "s2"], + client_hashes: [navigator.userAgent, document.getElementById('jsa').tagName], + signals: {}, + meta: { v: "4", challenge_id: "test" } + }; + })()`; + const ua = "Mozilla/5.0 (X11; Linux x86_64) TestAgent/1.0"; + const solved = await solveDuckDuckGoChallenge( + Buffer.from(fakeChallenge, "utf8").toString("base64"), + ua + ); + const decoded = JSON.parse(Buffer.from(solved, "base64").toString("utf8")); + + assert.deepEqual(decoded.server_hashes, ["s1", "s2"], "server_hashes pass through untouched"); + // Slot 0 is overwritten with the real UA before hashing, then every slot is sha256+base64. + assert.deepEqual(decoded.client_hashes, [sha256Base64(ua), sha256Base64("IFRAME")]); + assert.equal(decoded.meta.challenge_id, "test"); +}); diff --git a/tests/unit/duckduckgo-reasoning-effort-required.test.ts b/tests/unit/duckduckgo-reasoning-effort-required.test.ts new file mode 100644 index 0000000000..3dd3d8fc9c --- /dev/null +++ b/tests/unit/duckduckgo-reasoning-effort-required.test.ts @@ -0,0 +1,134 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { DuckDuckGoWebExecutor } from "../../open-sse/executors/duckduckgo-web.ts"; + +/** + * Regression: duckchat/v1/chat now REQUIRES a `reasoningEffort` field. + * + * The executor previously omitted it for most models on the assumption that the + * upstream would apply its own default. It does not: an otherwise byte-identical + * payload returns 200 with the field and 400 ERR_BAD_REQUEST without it + * (A/B verified live against duck.ai, repeated). The live duck.ai bundle always + * sends one, so every outgoing payload must carry it. + * + * These tests capture the executor's real outgoing request body by stubbing + * fetch, so they assert on the wire format rather than on internal helpers. + */ + +type Captured = { url: string; body: Record }; + +async function captureChatPayload(model: string): Promise { + const realFetch = globalThis.fetch; + const captured: Captured[] = []; + + globalThis.fetch = (async (input: unknown, init: RequestInit = {}) => { + const url = typeof input === "string" ? input : String((input as { url?: string })?.url ?? ""); + + if (url.includes("/duckchat/v1/status")) { + // Hand back a trivially solvable challenge so the executor proceeds to the + // chat POST without touching the network. + const challenge = Buffer.from( + `(async function(){ return { server_hashes: [], client_hashes: ["ua"], signals: {}, meta: {} }; })()`, + "utf8" + ).toString("base64"); + return new Response("{}", { status: 200, headers: { "x-vqd-hash-1": challenge } }); + } + + if (url.includes("/duckchat/v1/chat")) { + captured.push({ url, body: JSON.parse(String(init.body)) }); + return new Response(`data: {"action":"success","message":"OK"}\n\ndata: [DONE]\n\n`, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + + // Warm-up fetches (homepage, country.json, auth/token). + return new Response("", { status: 200 }); + }) as typeof globalThis.fetch; + + try { + const executor = new DuckDuckGoWebExecutor(); + await executor.execute({ + model, + body: { messages: [{ role: "user", content: "Say OK" }] }, + stream: false, + } as never); + } finally { + globalThis.fetch = realFetch; + } + + assert.ok(captured.length > 0, "executor never issued a chat request"); + return captured[captured.length - 1]; +} + +test("chat payload always carries reasoningEffort", async () => { + const { body } = await captureChatPayload("duckduckgo-web/gpt-4o-mini"); + assert.ok( + Object.prototype.hasOwnProperty.call(body, "reasoningEffort"), + "omitting reasoningEffort yields 400 ERR_BAD_REQUEST upstream" + ); + assert.equal(typeof body.reasoningEffort, "string"); + assert.notEqual(body.reasoningEffort, ""); +}); + +test("default models send reasoningEffort 'none'", async () => { + const { body } = await captureChatPayload("duckduckgo-web/gpt-5.4-mini"); + assert.equal(body.model, "gpt-5.4-mini"); + assert.equal(body.reasoningEffort, "none"); +}); + +test("reasoning models keep their 'low' effort", async () => { + const haiku = await captureChatPayload("duckduckgo-web/claude-haiku-4-5"); + assert.equal(haiku.body.model, "claude-haiku-4-5"); + assert.equal(haiku.body.reasoningEffort, "low"); + + const oss = await captureChatPayload("duckduckgo-web/gpt-oss-120b"); + assert.equal(oss.body.model, "tinfoil/gpt-oss-120b"); + assert.equal(oss.body.reasoningEffort, "low"); +}); + +test("retired model ids are still aliased to live wire ids", async () => { + const { body } = await captureChatPayload("duckduckgo-web/gpt-4o-mini"); + assert.equal(body.model, "gpt-5.4-mini"); +}); + +test("executor issues exactly one chat request per call", async () => { + // A throwaway "seed" chat POST used to run before the real one, doubling the + // request volume against an IP-rate-limited endpoint and causing spurious 429s. + const realFetch = globalThis.fetch; + let chatCalls = 0; + + globalThis.fetch = (async (input: unknown, init: RequestInit = {}) => { + const url = typeof input === "string" ? input : String((input as { url?: string })?.url ?? ""); + if (url.includes("/duckchat/v1/status")) { + const challenge = Buffer.from( + `(async function(){ return { server_hashes: [], client_hashes: ["ua"], signals: {}, meta: {} }; })()`, + "utf8" + ).toString("base64"); + return new Response("{}", { status: 200, headers: { "x-vqd-hash-1": challenge } }); + } + if (url.includes("/duckchat/v1/chat")) { + chatCalls++; + void init; + return new Response(`data: {"action":"success","message":"OK"}\n\ndata: [DONE]\n\n`, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + return new Response("", { status: 200 }); + }) as typeof globalThis.fetch; + + try { + const executor = new DuckDuckGoWebExecutor(); + await executor.execute({ + model: "duckduckgo-web/gpt-5.4-mini", + body: { messages: [{ role: "user", content: "Say OK" }] }, + stream: false, + } as never); + } finally { + globalThis.fetch = realFetch; + } + + assert.equal(chatCalls, 1, "expected exactly one POST /duckchat/v1/chat per user request"); +}); From 04b4690f84d131f0bcdc5f6296a614c392112d48 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:53:45 -0300 Subject: [PATCH 057/100] cherry-pick(pr-9730): fix(compression): persist RTK renderer configuration (#9867) * fix(compression): persist RTK renderer configuration * docs(changelog): add fragment for #9730 Adds the changelog.d/fixes/9730-persist-rtk-renderers.md fragment required by check:changelog-integrity for the RTK enableRenderers persistence fix in PR #9730. --------- Co-authored-by: Isaac --- .../fixes/9730-persist-rtk-renderers.md | 1 + src/lib/db/compression.ts | 4 ++ .../compression/rtk-renderers-config.test.ts | 44 +++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 changelog.d/fixes/9730-persist-rtk-renderers.md create mode 100644 tests/unit/compression/rtk-renderers-config.test.ts diff --git a/changelog.d/fixes/9730-persist-rtk-renderers.md b/changelog.d/fixes/9730-persist-rtk-renderers.md new file mode 100644 index 0000000000..ce2c7467b8 --- /dev/null +++ b/changelog.d/fixes/9730-persist-rtk-renderers.md @@ -0,0 +1 @@ +- fix(compression): persist `enableRenderers` through `normalizeRtkConfig` so RTK renderer settings survive a DB round-trip ([#9730](https://github.com/diegosouzapw/OmniRoute/pull/9730)) \ No newline at end of file diff --git a/src/lib/db/compression.ts b/src/lib/db/compression.ts index 7c9b46732c..d927aa2467 100644 --- a/src/lib/db/compression.ts +++ b/src/lib/db/compression.ts @@ -168,6 +168,10 @@ function normalizeRtkConfig(value: unknown): RtkConfig { typeof record.applyToAssistantMessages === "boolean" ? record.applyToAssistantMessages : DEFAULT_RTK_CONFIG.applyToAssistantMessages, + enableRenderers: + typeof record.enableRenderers === "boolean" + ? record.enableRenderers + : (DEFAULT_RTK_CONFIG.enableRenderers ?? false), enabledFilters: Array.isArray(record.enabledFilters) ? record.enabledFilters.filter((filter): filter is string => typeof filter === "string") : DEFAULT_RTK_CONFIG.enabledFilters, diff --git a/tests/unit/compression/rtk-renderers-config.test.ts b/tests/unit/compression/rtk-renderers-config.test.ts new file mode 100644 index 0000000000..2be878169d --- /dev/null +++ b/tests/unit/compression/rtk-renderers-config.test.ts @@ -0,0 +1,44 @@ +import { describe, it, afterEach, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { rtkConfigSchema } from "../../../src/shared/validation/compressionConfigSchemas.ts"; +import { DEFAULT_RTK_CONFIG } from "../../../open-sse/services/compression/types.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-rtk-renderers-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../../src/lib/db/core.ts"); +const { getCompressionSettings, updateCompressionSettings } = + await import("../../../src/lib/db/compression.ts"); + +describe("RTK renderer config persistence", () => { + afterEach(() => { + core.resetDbInstance(); + }); + + after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; + }); + + it("accepts enableRenderers on the strict write schema", () => { + assert.equal(rtkConfigSchema.safeParse({ enableRenderers: true }).success, true); + }); + + it("preserves enableRenderers through a fresh DB read", async () => { + const settings = await updateCompressionSettings({ + rtkConfig: { ...DEFAULT_RTK_CONFIG, enableRenderers: true }, + }); + assert.equal(settings.rtkConfig.enableRenderers, true); + + core.resetDbInstance(); + const reread = await getCompressionSettings(); + assert.equal(reread.rtkConfig.enableRenderers, true); + }); +}); From 09520785f80689623b8cb1ac5014091932ea4bfc Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:53:50 -0300 Subject: [PATCH 058/100] fix(dashboard): unregister leftover service workers in dev mode (#9868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A phone that previously loaded a production build on this origin (or an old dev build from before the registration was gated) kept an active service worker across dev restarts. It intercepted every navigation/asset fetch, occasionally serving a JS chunk that didn't match the running dev server, which tripped Next's dev-client chunk-mismatch auto-reload — visible as an unexplained, unstoppable refresh loop on that device only (confirmed via a clean private tab on the same phone/URL not looping). PwaRegister now actively unregisters any existing service worker registrations and clears their caches outside production, instead of just skipping a new registration. (cherry picked from commit 66a2515cbce7a6132639614d88d48349a83bdcde) Co-authored-by: Markus Hartung --- src/shared/components/PwaRegister.tsx | 20 ++++- tests/unit/PwaRegister.test.tsx | 104 ++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 tests/unit/PwaRegister.test.tsx diff --git a/src/shared/components/PwaRegister.tsx b/src/shared/components/PwaRegister.tsx index 0e49a6070f..367bf93f1c 100644 --- a/src/shared/components/PwaRegister.tsx +++ b/src/shared/components/PwaRegister.tsx @@ -8,8 +8,26 @@ export function PwaRegister() { return; } - // Disable service worker in development to avoid chunk loading / HMR conflicts + // Disable service worker in development to avoid chunk loading / HMR conflicts. + // A visitor who previously loaded a production build on this origin (or an + // older dev build from before this gate existed) can still have one left + // over — it keeps intercepting navigations/assets, occasionally serving a + // JS chunk that doesn't match the currently running dev server, which + // triggers Next's dev-client auto-reload-on-chunk-mismatch recovery. Since + // the stale worker never goes away on its own, that repeats forever + // (visible as an unexplained refresh loop). Proactively unregister and + // drop its caches instead of merely skipping a new registration. if (process.env.NODE_ENV !== "production") { + navigator.serviceWorker + .getRegistrations() + .then((registrations) => Promise.all(registrations.map((r) => r.unregister()))) + .catch(() => {}); + if (typeof caches !== "undefined") { + caches + .keys() + .then((keys) => Promise.all(keys.map((key) => caches.delete(key)))) + .catch(() => {}); + } return; } diff --git a/tests/unit/PwaRegister.test.tsx b/tests/unit/PwaRegister.test.tsx new file mode 100644 index 0000000000..bc32e5fea4 --- /dev/null +++ b/tests/unit/PwaRegister.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment jsdom +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { PwaRegister } from "../../src/shared/components/PwaRegister"; + +const cleanupCallbacks: Array<() => void> = []; + +function makeContainer(): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + cleanupCallbacks.push(() => { + container.remove(); + }); + return container; +} + +function mount() { + const container = makeContainer(); + const root = createRoot(container); + act(() => { + root.render(); + }); + cleanupCallbacks.push(() => root.unmount()); +} + +describe("PwaRegister", () => { + const originalServiceWorker = (navigator as any).serviceWorker; + const originalCaches = (globalThis as any).caches; + + afterEach(() => { + cleanupCallbacks.splice(0).forEach((fn) => fn()); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + Object.defineProperty(navigator, "serviceWorker", { + value: originalServiceWorker, + configurable: true, + }); + (globalThis as any).caches = originalCaches; + }); + + beforeEach(() => { + cleanupCallbacks.length = 0; + }); + + it("unregisters leftover service workers and clears caches outside production", async () => { + vi.stubEnv("NODE_ENV", "development"); + + const unregister1 = vi.fn().mockResolvedValue(true); + const unregister2 = vi.fn().mockResolvedValue(true); + const getRegistrations = vi + .fn() + .mockResolvedValue([{ unregister: unregister1 }, { unregister: unregister2 }]); + const register = vi.fn(); + Object.defineProperty(navigator, "serviceWorker", { + value: { getRegistrations, register }, + configurable: true, + }); + + const cachesDelete = vi.fn().mockResolvedValue(true); + const cachesKeys = vi.fn().mockResolvedValue(["omniroute-pwa-v1", "omniroute-pwa-v2"]); + (globalThis as any).caches = { keys: cachesKeys, delete: cachesDelete }; + + mount(); + // Flush the promise chains kicked off inside the effect. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(getRegistrations).toHaveBeenCalledTimes(1); + expect(unregister1).toHaveBeenCalledTimes(1); + expect(unregister2).toHaveBeenCalledTimes(1); + expect(cachesKeys).toHaveBeenCalledTimes(1); + expect(cachesDelete).toHaveBeenCalledWith("omniroute-pwa-v1"); + expect(cachesDelete).toHaveBeenCalledWith("omniroute-pwa-v2"); + expect(register).not.toHaveBeenCalled(); + }); + + it("registers the service worker in production without unregistering anything", async () => { + vi.stubEnv("NODE_ENV", "production"); + + const getRegistrations = vi.fn().mockResolvedValue([]); + const register = vi.fn().mockResolvedValue({}); + Object.defineProperty(navigator, "serviceWorker", { + value: { getRegistrations, register }, + configurable: true, + }); + + const cachesKeys = vi.fn().mockResolvedValue([]); + (globalThis as any).caches = { keys: cachesKeys, delete: vi.fn() }; + + mount(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(register).toHaveBeenCalledWith("/sw.js"); + expect(getRegistrations).not.toHaveBeenCalled(); + }); +}); From b254890c07b333c52e1623e11371c85dd7f4c4a4 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:53:56 -0300 Subject: [PATCH 059/100] fix(combo): remove stray brace from #9630 error handling (#9894) Co-authored-by: Zartharas <1402357+Zartharas@users.noreply.github.com> --- open-sse/services/combo.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index c817296a0e..5d78454330 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -3020,7 +3020,8 @@ async function handleRoundRobinCombo({ return new Response( JSON.stringify({ error: { - message: "Service temporarily unavailable: all targets were skipped by pre-dispatch filters", + message: + "Service temporarily unavailable: all targets were skipped by pre-dispatch filters", type: "service_unavailable", code: "ALL_TARGETS_SKIPPED", }, From 6f3738b0097d7476bdc14b4a0b16d42ef8ddc8d9 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:54:01 -0300 Subject: [PATCH 060/100] feat(oauth): add Openference OAuth and API key provider integration (#9869) Wire Openference as a first-party OAuth gateway (PKCE, rotating refresh) and an API-key catalog entry on api.openference.com, with live model discovery, connection testing, free-tier badges, and regression tests. Co-authored-by: Anh Tran --- open-sse/config/constants.ts | 15 +- open-sse/config/providers/index.ts | 4 + .../registry/openference-api/index.ts | 18 ++ .../providers/registry/openference/index.ts | 25 +++ open-sse/services/tokenRefresh.ts | 14 +- .../tokenRefresh/providers/openference.ts | 92 ++++++++ public/providers/openference.svg | 5 + .../api/oauth/[provider]/[action]/route.ts | 2 +- .../models/discovery/providerModelsConfig.ts | 16 ++ .../[id]/models/discovery/providerSets.ts | 4 + .../providers/[id]/test/oauthTestConfig.ts | 18 ++ src/lib/oauth/constants/oauth.ts | 14 ++ src/lib/oauth/providers/index.ts | 2 + src/lib/oauth/providers/openference.ts | 125 +++++++++++ src/lib/tokenHealthCheck.ts | 1 + src/shared/components/ProviderIcon.tsx | 1 + .../providers/apikey/inference-hosts.ts | 13 ++ src/shared/constants/providers/oauth.ts | 13 ++ tests/unit/oauth-providers-config.test.ts | 5 + ...rence-apikey-provider-registration.test.ts | 89 ++++++++ tests/unit/openference-oauth-provider.test.ts | 199 ++++++++++++++++++ 21 files changed, 664 insertions(+), 11 deletions(-) create mode 100644 open-sse/config/providers/registry/openference-api/index.ts create mode 100644 open-sse/config/providers/registry/openference/index.ts create mode 100644 open-sse/services/tokenRefresh/providers/openference.ts create mode 100644 public/providers/openference.svg create mode 100644 src/lib/oauth/providers/openference.ts create mode 100644 tests/unit/openference-apikey-provider-registration.test.ts create mode 100644 tests/unit/openference-oauth-provider.test.ts diff --git a/open-sse/config/constants.ts b/open-sse/config/constants.ts index 81e3c29ee1..ad648f39bf 100644 --- a/open-sse/config/constants.ts +++ b/open-sse/config/constants.ts @@ -65,27 +65,27 @@ export const PROVIDERS: Record = new Proxy( {} as Record, { get(_, prop) { - if (typeof prop === 'symbol') return undefined; + if (typeof prop === "symbol") return undefined; return Reflect.get(initProviders(), prop, _providers); }, has(_, prop) { - if (typeof prop === 'symbol') return false; + if (typeof prop === "symbol") return false; return Reflect.has(initProviders(), prop); }, ownKeys() { return Reflect.ownKeys(initProviders()); }, getOwnPropertyDescriptor(_, prop) { - if (typeof prop === 'symbol') return undefined; + if (typeof prop === "symbol") return undefined; return Object.getOwnPropertyDescriptor(initProviders(), prop); }, set(_, prop, value) { - if (typeof prop === 'symbol') return false; + if (typeof prop === "symbol") return false; (initProviders() as Record)[prop] = value; return true; }, deleteProperty(_, prop) { - if (typeof prop === 'symbol') return false; + if (typeof prop === "symbol") return false; return Reflect.deleteProperty(initProviders(), prop); }, } @@ -124,6 +124,11 @@ export const OAUTH_ENDPOINTS = { auth: "https://github.com/login/oauth/authorize", deviceCode: "https://github.com/login/device/code", }, + openference: { + token: "https://openference.com/oauth/token", + auth: "https://openference.com/app/oauth/authorize", + clientId: "omniroute", + }, }; // Cache TTLs (seconds) diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 47a5896784..a64d9b3c04 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -121,6 +121,8 @@ import { chatgpt_webProvider } from "./registry/chatgpt-web/index.ts"; import { openrouterProvider } from "./registry/openrouter/index.ts"; import { cheaperinferenceProvider } from "./registry/cheaperinference/index.ts"; import { openvectaProvider } from "./registry/openvecta/index.ts"; +import { openferenceProvider } from "./registry/openference/index.ts"; +import { openference_apiProvider } from "./registry/openference-api/index.ts"; import { orcarouterProvider } from "./registry/orcarouter/index.ts"; import { copilot_webProvider } from "./registry/copilot-web/index.ts"; import { copilot_m365_webProvider } from "./registry/copilot-m365-web/index.ts"; @@ -345,6 +347,8 @@ export const REGISTRY: Record = { openrouter: openrouterProvider, cheaperinference: cheaperinferenceProvider, openvecta: openvectaProvider, + openference: openferenceProvider, + "openference-api": openference_apiProvider, orcarouter: orcarouterProvider, "copilot-web": copilot_webProvider, "copilot-m365-web": copilot_m365_webProvider, diff --git a/open-sse/config/providers/registry/openference-api/index.ts b/open-sse/config/providers/registry/openference-api/index.ts new file mode 100644 index 0000000000..34a20de303 --- /dev/null +++ b/open-sse/config/providers/registry/openference-api/index.ts @@ -0,0 +1,18 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; + +/** + * Openference API key — OpenAI-compatible gateway (https://openference.com/). + * + * Bearer API keys (`sk-…`) hit the same api.openference.com/v1/* surface as OAuth + * JWTs. Live model discovery uses NAMED_OPENAI_STYLE_PROVIDERS; the seed below is + * the offline fallback when the live fetch fails. + */ +export const openference_apiProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ + id: "openference-api", + alias: "ofa", + baseUrl: "https://api.openference.com/v1/chat/completions", + responsesBaseUrl: "https://api.openference.com/v1/responses", + passthroughModels: true, + models: [{ id: "GLM-5.2", name: "GLM 5.2", contextLength: 850000 }], +}); diff --git a/open-sse/config/providers/registry/openference/index.ts b/open-sse/config/providers/registry/openference/index.ts new file mode 100644 index 0000000000..87af280dcb --- /dev/null +++ b/open-sse/config/providers/registry/openference/index.ts @@ -0,0 +1,25 @@ +import type { RegistryEntry } from "../../shared.ts"; + +/** + * Openference — OpenAI-compatible AI inference gateway (https://openference.com/). + * + * OAuth access tokens are ES256 JWTs accepted as Bearer credentials on + * api.openference.com/v1/*. Live model discovery uses NAMED_OPENAI_STYLE_PROVIDERS; + * seed models below are the offline fallback when the live fetch fails. + */ +export const openferenceProvider: RegistryEntry = { + id: "openference", + alias: "of", + format: "openai", + executor: "default", + baseUrl: "https://api.openference.com/v1/chat/completions", + responsesBaseUrl: "https://api.openference.com/v1/responses", + authType: "oauth", + authHeader: "bearer", + passthroughModels: true, + oauth: { + clientIdDefault: "omniroute", + tokenUrl: "https://openference.com/oauth/token", + }, + models: [{ id: "GLM-5.2", name: "GLM 5.2", contextLength: 850000 }], +}; diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 43dcba1a6d..aba42bfcd8 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -48,6 +48,7 @@ import { refreshGoogleToken } from "./tokenRefresh/providers/google.ts"; import { ensureAntigravityProjectAssigned } from "./antigravityProjectBootstrap.ts"; import { persistDiscoveredAntigravityProjectId } from "./antigravityProjectPersist.ts"; import { refreshCodexToken } from "./tokenRefresh/providers/codex.ts"; +import { refreshOpenferenceToken } from "./tokenRefresh/providers/openference.ts"; import { refreshKiroToken } from "./tokenRefresh/providers/kiro.ts"; import { refreshQoderToken } from "./tokenRefresh/providers/qoder.ts"; import { refreshGitHubToken } from "./tokenRefresh/providers/github.ts"; @@ -62,6 +63,7 @@ export { refreshClaudeOAuthToken, refreshGoogleToken, refreshCodexToken, + refreshOpenferenceToken, refreshKiroToken, refreshQoderToken, refreshGitHubToken, @@ -339,10 +341,7 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: !(credentials.projectId || credentials.providerSpecificData?.projectId) ) { try { - const discovered = await ensureAntigravityProjectAssigned( - result.accessToken, - fetch - ); + const discovered = await ensureAntigravityProjectAssigned(result.accessToken, fetch); if (discovered) { result.projectId = discovered; result.providerSpecificData = { @@ -362,7 +361,8 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: }); } } catch (discoveryError) { - const msg = discoveryError instanceof Error ? discoveryError.message : String(discoveryError); + const msg = + discoveryError instanceof Error ? discoveryError.message : String(discoveryError); log?.warn?.("TOKEN", `Antigravity projectId discovery failed: ${msg}`); } } @@ -376,6 +376,9 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: case "codex": return await refreshCodexToken(credentials.refreshToken, log, proxyConfig); + case "openference": + return await refreshOpenferenceToken(credentials.refreshToken, log, proxyConfig); + case "qoder": return await refreshQoderToken(credentials.refreshToken, log, proxyConfig); @@ -439,6 +442,7 @@ export function supportsTokenRefresh(provider) { "agy", "claude", "codex", + "openference", "qoder", "github", "kiro", diff --git a/open-sse/services/tokenRefresh/providers/openference.ts b/open-sse/services/tokenRefresh/providers/openference.ts new file mode 100644 index 0000000000..5e717acc79 --- /dev/null +++ b/open-sse/services/tokenRefresh/providers/openference.ts @@ -0,0 +1,92 @@ +// @ts-nocheck +import { OAUTH_ENDPOINTS } from "../../../config/constants.ts"; +import { runWithProxyContext } from "../../../utils/proxyFetch.ts"; +import { buildFormParams } from "../shared.ts"; + +/** + * Specialized refresh for Openference OAuth tokens. + * Openference uses rotating (one-time-use) oar_* refresh tokens. + */ +export async function refreshOpenferenceToken(refreshToken, log, proxyConfig: unknown = null) { + try { + const response = await runWithProxyContext(proxyConfig, () => + fetch(OAUTH_ENDPOINTS.openference.token, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: buildFormParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: OAUTH_ENDPOINTS.openference.clientId, + }), + }) + ); + + if (!response.ok) { + const errorText = await response.text(); + + let errorCode = null; + try { + const parsed = JSON.parse(errorText); + errorCode = + parsed?.error?.code || (typeof parsed?.error === "string" ? parsed.error : null); + } catch { + // not JSON, ignore + } + + if ( + errorCode === "invalid_grant" || + errorCode === "token_expired" || + errorCode === "invalid_token" + ) { + log?.error?.( + "TOKEN_REFRESH", + "Openference refresh token already used or invalid. Re-authentication required.", + { + status: response.status, + errorCode, + } + ); + return { error: "unrecoverable_refresh_error", code: errorCode }; + } + + if (response.status === 401) { + const code = errorCode || "unauthorized"; + log?.error?.( + "TOKEN_REFRESH", + "Openference OAuth token endpoint returned 401. Re-authentication required.", + { + status: response.status, + errorCode: code, + } + ); + return { error: "unrecoverable_refresh_error", code }; + } + + log?.error?.("TOKEN_REFRESH", "Failed to refresh Openference token", { + status: response.status, + error: errorText, + }); + return null; + } + + const tokens = await response.json(); + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Openference token", { + hasNewAccessToken: !!tokens.access_token, + hasNewRefreshToken: !!tokens.refresh_token, + expiresIn: tokens.expires_in, + }); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || refreshToken, + expiresIn: tokens.expires_in, + }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", `Network error refreshing Openference token: ${error.message}`); + return null; + } +} diff --git a/public/providers/openference.svg b/public/providers/openference.svg new file mode 100644 index 0000000000..525d9ae0a4 --- /dev/null +++ b/public/providers/openference.svg @@ -0,0 +1,5 @@ + + Openference + + + diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index df82b63071..dcccd3ff98 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -47,7 +47,7 @@ if (!globalThis.__pkceCallbackStates) { } /** Providers that use the PKCE browser callback flow (like Codex). */ -const PKCE_CALLBACK_PROVIDERS = new Set(["codex", "xai-oauth", "grok-cli"]); +const PKCE_CALLBACK_PROVIDERS = new Set(["codex", "xai-oauth", "grok-cli", "openference"]); /** * Providers whose device flow runs in the user's browser (auth.openai.com blocks diff --git a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts index 6d67077914..e94ffecf26 100644 --- a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts +++ b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts @@ -606,6 +606,22 @@ export const PROVIDER_MODELS_CONFIG: Record = authPrefix: "Bearer ", parseResponse: (data) => data.data || data.models || [], }, + openference: { + url: "https://api.openference.com/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, + "openference-api": { + url: "https://api.openference.com/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, fireworks: { url: "https://api.fireworks.ai/inference/v1/models", method: "GET", diff --git a/src/app/api/providers/[id]/models/discovery/providerSets.ts b/src/app/api/providers/[id]/models/discovery/providerSets.ts index 2e3bdef7b3..d443cfabc7 100644 --- a/src/app/api/providers/[id]/models/discovery/providerSets.ts +++ b/src/app/api/providers/[id]/models/discovery/providerSets.ts @@ -71,6 +71,10 @@ export const NAMED_OPENAI_STYLE_PROVIDERS = new Set([ // discovered live from https://api.openvecta.com/v1/models; the registry seed // (registry/openvecta) covers the most-used LLMs as the offline fallback. "openvecta", + // Openference (https://openference.com/) — OAuth JWT or API key on the same + // OpenAI-compatible gateway. Live catalog from api.openference.com/v1/models. + "openference", + "openference-api", // Typhoon (SCB 10X, Thailand) and Inception Labs (Mercury diffusion models) are // OpenAI-compatible providers whose /v1/models endpoint exists and is used for // catalog discovery/key validation (verified 2026-07-22). diff --git a/src/app/api/providers/[id]/test/oauthTestConfig.ts b/src/app/api/providers/[id]/test/oauthTestConfig.ts index ac1aaa1680..2a791bc0e7 100644 --- a/src/app/api/providers/[id]/test/oauthTestConfig.ts +++ b/src/app/api/providers/[id]/test/oauthTestConfig.ts @@ -172,4 +172,22 @@ export const OAUTH_TEST_CONFIG = { extraHeaders: { "User-Agent": "OmniRoute", Accept: "application/vnd.github+json" }, refreshable: true, }, + // Openference: first-party OAuth gateway — list models to verify the JWT without + // consuming inference quota. 402 (no active plan) still means auth succeeded. + openference: { + url: "https://api.openference.com/v1/models", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + acceptStatuses: [402], + }, + of: { + url: "https://api.openference.com/v1/models", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + acceptStatuses: [402], + }, }; diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 02df89e7c9..c89f5fa63e 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -152,6 +152,19 @@ export const XAI_OAUTH_CONFIG = { callbackHost: "127.0.0.1", }; +// Openference OAuth Configuration (Authorization Code Flow with PKCE) +export const OPENFERENCE_CONFIG = { + clientId: "omniroute", + authorizeUrl: "https://openference.com/app/oauth/authorize", + tokenUrl: "https://openference.com/oauth/token", + userinfoUrl: "https://openference.com/oauth/userinfo", + scope: "openid profile email model:invoke offline_access", + codeChallengeMethod: "S256", + loopbackPort: 56123, + callbackPath: "/callback", + callbackHost: "127.0.0.1", +}; + // Kimi Coding OAuth Configuration (Device Code Flow) export const KIMI_CODING_CONFIG = { clientId: resolvePublicCred("kimi_id", "KIMI_CODING_OAUTH_CLIENT_ID"), @@ -544,6 +557,7 @@ export const PROVIDERS = { CODEBUDDY_CN: "codebuddy-cn", GROK_CLI: "grok-cli", XAI_OAUTH: "xai-oauth", + OPENFERENCE: "openference", ZED: "zed", ZED_HOSTED: "zed-hosted", }; diff --git a/src/lib/oauth/providers/index.ts b/src/lib/oauth/providers/index.ts index 06e4813042..97f5f013f6 100644 --- a/src/lib/oauth/providers/index.ts +++ b/src/lib/oauth/providers/index.ts @@ -28,6 +28,7 @@ import { cline } from "./cline"; import { windsurf } from "./windsurf"; import { grokCli } from "./grok-cli"; import { xaiOauth } from "./xai-oauth"; +import { openference } from "./openference"; import { codebuddyCn } from "./codebuddy-cn"; import { zed } from "./zed"; import { zedHosted } from "./zed-hosted"; @@ -60,6 +61,7 @@ export const PROVIDERS = { // under this one entry (#7013) — see grok-cli.ts's mapTokens for the dispatch. "grok-cli": grokCli, "xai-oauth": xaiOauth, + openference, "codebuddy-cn": codebuddyCn, // Zed IDE credential bridge — uses keychain import, not standard OAuth zed, diff --git a/src/lib/oauth/providers/openference.ts b/src/lib/oauth/providers/openference.ts new file mode 100644 index 0000000000..15fb8e8ef8 --- /dev/null +++ b/src/lib/oauth/providers/openference.ts @@ -0,0 +1,125 @@ +import { OPENFERENCE_CONFIG } from "../constants/oauth"; + +const BASE64_BLOCK_SIZE = 4; + +/** Extract display metadata from an Openference id_token (OIDC). */ +export function decodeOpenferenceIdTokenIdentity(idToken: unknown): { + email: string | null; + name: string | null; +} { + if (typeof idToken !== "string") return { email: null, name: null }; + const parts = idToken.split("."); + if (parts.length !== 3) return { email: null, name: null }; + + try { + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padding = (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE; + const payload = JSON.parse( + Buffer.from(base64 + "=".repeat(padding), "base64").toString("utf8") + ); + return { + email: payload.email || payload.preferred_username || null, + name: payload.name || null, + }; + } catch { + return { email: null, name: null }; + } +} + +function getOpenferenceUserEmail(userInfo: Record): string | null { + const candidates = [userInfo.email, userInfo.preferred_username]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim().length > 0) { + return candidate.trim(); + } + } + return null; +} + +function getOpenferenceUserName(userInfo: Record): string | null { + const candidates = [userInfo.name, userInfo.email, userInfo.preferred_username]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim().length > 0) { + return candidate.trim(); + } + } + return null; +} + +export const openference = { + config: OPENFERENCE_CONFIG, + flowType: "authorization_code_pkce" as const, + fixedPort: OPENFERENCE_CONFIG.loopbackPort, + callbackPath: OPENFERENCE_CONFIG.callbackPath, + callbackHost: OPENFERENCE_CONFIG.callbackHost, + + buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + const params = new URLSearchParams({ + response_type: "code", + client_id: config.clientId, + redirect_uri: redirectUri, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + state, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + + exchangeToken: async (config, code, redirectUri, codeVerifier) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Openference token exchange failed: ${error}`); + } + + return response.json(); + }, + + postExchange: async (tokens) => { + const userinfoUrl = OPENFERENCE_CONFIG.userinfoUrl; + const headers = { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + }; + + const userRes = await fetch(userinfoUrl, { headers }); + const userInfo = userRes.ok ? ((await userRes.json()) as Record) : {}; + + return { userInfo }; + }, + + mapTokens: (tokens, extra) => { + const identity = decodeOpenferenceIdTokenIdentity(tokens.id_token); + const userInfo = (extra?.userInfo ?? {}) as Record; + const email = identity.email || getOpenferenceUserEmail(userInfo); + const name = identity.name || getOpenferenceUserName(userInfo) || email; + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + idToken: tokens.id_token, + expiresIn: tokens.expires_in, + email, + name, + providerSpecificData: { + scope: tokens.scope || OPENFERENCE_CONFIG.scope, + tokenType: tokens.token_type || "Bearer", + }, + }; + }, +}; diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts index ce54c2e675..5039e26419 100644 --- a/src/lib/tokenHealthCheck.ts +++ b/src/lib/tokenHealthCheck.ts @@ -723,6 +723,7 @@ export async function checkConnection(conn) { "amazon-q", "gitlab-duo", "claude", + "openference", ]); const isRotatingProvider = ROTATING_REFRESH_PROVIDERS.has( String(conn.provider || "").toLowerCase() diff --git a/src/shared/components/ProviderIcon.tsx b/src/shared/components/ProviderIcon.tsx index 02930e53c7..dc09c3eeb3 100644 --- a/src/shared/components/ProviderIcon.tsx +++ b/src/shared/components/ProviderIcon.tsx @@ -171,6 +171,7 @@ const KNOWN_SVGS = new Set([ "openadapter", "openai", "openclaw", + "openference", "opencode", "openrouter", "orcarouter", diff --git a/src/shared/constants/providers/apikey/inference-hosts.ts b/src/shared/constants/providers/apikey/inference-hosts.ts index 6eeebfdd6a..7f14a46061 100644 --- a/src/shared/constants/providers/apikey/inference-hosts.ts +++ b/src/shared/constants/providers/apikey/inference-hosts.ts @@ -32,6 +32,19 @@ export const APIKEY_PROVIDERS_INFERENCE = { freeNote: "Free credits on signup for OpenAI-compatible inference across LLMs, embeddings, and reasoning models", }, + // Openference — OpenAI-compatible AI inference gateway (https://openference.com/). + // API-key auth via Authorization: Bearer sk-… on the same gateway as OAuth JWTs. + "openference-api": { + id: "openference-api", + alias: "ofa", + name: "Openference API", + icon: "openference", + color: "#6366F1", + textIcon: "OF", + website: "https://openference.com", + hasFree: true, + freeNote: "Free plan: 3-day trial with open-source models — no credit card required", + }, fireworks: { id: "fireworks", alias: "fireworks", diff --git a/src/shared/constants/providers/oauth.ts b/src/shared/constants/providers/oauth.ts index a9bd04b950..e0480da290 100644 --- a/src/shared/constants/providers/oauth.ts +++ b/src/shared/constants/providers/oauth.ts @@ -29,6 +29,19 @@ export const OAUTH_PROVIDERS = { authHint: "Sign in with xAI to use api.x.ai models such as Grok 4.5. This is separate from Grok Build JWT sessions, which use cli-chat-proxy.grok.com and grok-build model aliases.", }, + openference: { + id: "openference", + alias: "of", + name: "Openference", + icon: "openference", + color: "#6366F1", + textIcon: "OF", + website: "https://openference.com", + hasFree: true, + freeNote: "Free plan: 3-day trial with open-source models — no credit card required", + authHint: + "Sign in with your Openference account to route requests through api.openference.com. An active plan is required for inference — OAuth may authenticate but return 402 without one.", + }, "grok-cli": { id: "grok-cli", alias: "gc", diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index ef1ed581eb..f5fffee13a 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -44,6 +44,7 @@ const { TRAE_CONFIG, WINDSURF_CONFIG, XAI_OAUTH_CONFIG, + OPENFERENCE_CONFIG, ZED_HOSTED_CONFIG, } = oauthModule; const { getAntigravityLoadCodeAssistMetadata } = antigravityHeadersModule; @@ -72,6 +73,7 @@ const EXPECTED_PROVIDER_KEYS = [ "devin-cli", "grok-cli", "xai-oauth", + "openference", "codebuddy-cn", "zed", "zed-hosted", @@ -106,6 +108,7 @@ const EXPECTED_CONFIG_BY_PROVIDER = { trae: TRAE_CONFIG, "grok-cli": GROK_BUILD_OAUTH_CONFIG, "xai-oauth": XAI_OAUTH_CONFIG, + openference: OPENFERENCE_CONFIG, "codebuddy-cn": CODEBUDDY_CN_CONFIG, zed: ZED_CONFIG, "zed-hosted": ZED_HOSTED_CONFIG, @@ -154,6 +157,8 @@ const REQUIRED_FIELDS_BY_PROVIDER = { // prettier-ignore "xai-oauth": ["authorizeUrl", "tokenUrl", "scope", "codeChallengeMethod", "clientId", "loopbackPort", "callbackPath", "callbackHost"], // prettier-ignore + openference: ["authorizeUrl", "tokenUrl", "userinfoUrl", "scope", "codeChallengeMethod", "clientId", "loopbackPort", "callbackPath", "callbackHost"], + // prettier-ignore "grok-cli": ["authorizeUrl", "tokenUrl", "scope", "codeChallengeMethod", "clientId", "loopbackPort", "callbackPath", "callbackHost"], // prettier-ignore "zed-hosted": ["webBaseUrl", "cloudBaseUrl", "llmBaseUrl", "userInfoUrl", "llmTokenUrl", "modelsUrl"], diff --git a/tests/unit/openference-apikey-provider-registration.test.ts b/tests/unit/openference-apikey-provider-registration.test.ts new file mode 100644 index 0000000000..4015326cfb --- /dev/null +++ b/tests/unit/openference-apikey-provider-registration.test.ts @@ -0,0 +1,89 @@ +/** + * Coverage for the Openference API key provider (https://openference.com/). + * + * Validates wiring alongside the OAuth `openference` entry: + * 1. APIKEY_PROVIDERS["openference-api"] — catalog entry (id, alias, name, website, hasFree) + * 2. providerRegistry["openference-api"] — format=openai / executor=default / apikey / bearer + * 3. PROVIDER_MODELS_CONFIG — live /v1/models discovery URL + * 4. NAMED_OPENAI_STYLE_PROVIDERS — classified for live-fetch + * 5. Seeded registry catalog — non-empty, unique ids + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); +const { REGISTRY: providerRegistry } = await import("../../open-sse/config/providerRegistry.ts"); +const { NAMED_OPENAI_STYLE_PROVIDERS, isNamedOpenAIStyleProvider } = + await import("../../src/app/api/providers/[id]/models/discovery/providerSets.ts"); +const { PROVIDER_MODELS_CONFIG } = + await import("../../src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts"); + +const SPEC = { + id: "openference-api", + alias: "ofa", + name: "Openference API", + website: "https://openference.com", + chatUrl: "https://api.openference.com/v1/chat/completions", + modelsUrl: "https://api.openference.com/v1/models", + expectedSeedIds: ["GLM-5.2"], +}; + +test("APIKEY_PROVIDERS.openference-api is registered with the canonical identity", () => { + const entry = APIKEY_PROVIDERS[SPEC.id]; + assert.ok(entry, `APIKEY_PROVIDERS.${SPEC.id} must be defined`); + assert.equal(entry.id, SPEC.id); + assert.equal(entry.alias, SPEC.alias); + assert.equal(entry.name, SPEC.name); + assert.equal(entry.website, SPEC.website); + assert.equal(entry.icon, "openference"); + assert.equal(typeof entry.textIcon, "string"); + assert.equal(entry.hasFree, true); + assert.equal(typeof entry.freeNote, "string"); + assert.match(entry.color, /^#[0-9A-Fa-f]{6}$/); +}); + +test("providerRegistry exposes the OpenAI-compatible chat completions URL", () => { + assert.equal(providerRegistry[SPEC.id].baseUrl, SPEC.chatUrl); +}); + +test("PROVIDER_MODELS_CONFIG exposes the live /v1/models discovery URL", () => { + const cfg = PROVIDER_MODELS_CONFIG[SPEC.id]; + assert.ok(cfg, `PROVIDER_MODELS_CONFIG.${SPEC.id} must be defined`); + assert.equal(cfg.url, SPEC.modelsUrl); + assert.equal(cfg.method, "GET"); + assert.equal(cfg.authHeader, "Authorization"); + assert.equal(cfg.authPrefix, "Bearer "); + assert.equal(typeof cfg.parseResponse, "function"); +}); + +test("providerRegistry.openference-api uses OpenAI format with bearer apikey auth", () => { + const entry = providerRegistry[SPEC.id]; + assert.ok(entry, `providerRegistry.${SPEC.id} must be defined`); + assert.equal(entry.id, SPEC.id); + assert.equal(entry.alias, SPEC.alias); + assert.equal(entry.format, "openai"); + assert.equal(entry.executor, "default"); + assert.equal(entry.authType, "apikey"); + assert.equal(entry.authHeader, "bearer"); + assert.equal(entry.baseUrl, SPEC.chatUrl); + assert.equal(entry.passthroughModels, true); +}); + +test("openference-api is classified as a named OpenAI-style provider (live-fetch path)", () => { + assert.ok( + NAMED_OPENAI_STYLE_PROVIDERS.has(SPEC.id), + "openference-api must be in NAMED_OPENAI_STYLE_PROVIDERS for live /v1/models fetch" + ); + assert.equal(isNamedOpenAIStyleProvider(SPEC.id), true); +}); + +test("openference-api ships a non-empty unique seed catalog", () => { + const models = providerRegistry[SPEC.id].models; + assert.ok(Array.isArray(models), "registry models must be an array"); + assert.ok(models.length >= 1, "seed list must be non-empty for the offline fallback"); + const ids = models.map((m: { id: string }) => m.id); + assert.equal(new Set(ids).size, ids.length, "seed model ids must be unique"); + for (const expected of SPEC.expectedSeedIds) { + assert.ok(ids.includes(expected), `seed list must include ${expected}`); + } +}); diff --git a/tests/unit/openference-oauth-provider.test.ts b/tests/unit/openference-oauth-provider.test.ts new file mode 100644 index 0000000000..707a3f83a8 --- /dev/null +++ b/tests/unit/openference-oauth-provider.test.ts @@ -0,0 +1,199 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { generateAuthData } from "../../src/lib/oauth/providers.ts"; +import { + openference, + decodeOpenferenceIdTokenIdentity, +} from "../../src/lib/oauth/providers/openference.ts"; +import { OPENFERENCE_CONFIG } from "../../src/lib/oauth/constants/oauth.ts"; +import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts"; +import { openferenceProvider } from "../../open-sse/config/providers/registry/openference/index.ts"; +import { refreshOpenferenceToken } from "../../open-sse/services/tokenRefresh/providers/openference.ts"; +import { OAUTH_TEST_CONFIG } from "../../src/app/api/providers/[id]/test/oauthTestConfig.ts"; +import { testOAuthConnection } from "../../src/app/api/providers/[id]/test/route.ts"; +import { supportsTokenRefresh } from "../../open-sse/services/tokenRefresh.ts"; +import { NAMED_OPENAI_STYLE_PROVIDERS } from "../../src/app/api/providers/[id]/models/discovery/providerSets.ts"; +import { OAUTH_PROVIDERS } from "../../src/shared/constants/providers/oauth.ts"; +import PROVIDERS from "../../src/lib/oauth/providers/index.ts"; + +const originalFetch = globalThis.fetch; + +function createJwt(payload: Record) { + const encode = (value: Record) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "none" })}.${encode(payload)}.signature`; +} + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test("Openference OAuth builds the PKCE authorization request", () => { + const authData = generateAuthData("openference", "http://127.0.0.1:56123/callback"); + const url = new URL(authData.authUrl); + + assert.equal(url.origin, "https://openference.com"); + assert.equal(url.pathname, "/app/oauth/authorize"); + assert.equal(url.searchParams.get("client_id"), OPENFERENCE_CONFIG.clientId); + assert.equal(url.searchParams.get("scope"), OPENFERENCE_CONFIG.scope); + assert.equal(url.searchParams.get("code_challenge_method"), "S256"); + assert.ok(url.searchParams.get("code_challenge")); + assert.equal(authData.fixedPort, 56123); + assert.equal(authData.callbackPath, "/callback"); + assert.equal(authData.callbackHost, "127.0.0.1"); +}); + +test("Openference OAuth exchanges a code with form-urlencoded PKCE fields", async () => { + globalThis.fetch = async (input, init) => { + assert.equal(String(input), OPENFERENCE_CONFIG.tokenUrl); + assert.equal(init?.method, "POST"); + assert.equal(init?.headers?.["Content-Type"], "application/x-www-form-urlencoded"); + const body = init?.body as URLSearchParams; + assert.equal(body.get("grant_type"), "authorization_code"); + assert.equal(body.get("client_id"), OPENFERENCE_CONFIG.clientId); + assert.equal(body.get("code"), "auth-code"); + assert.equal(body.get("redirect_uri"), "http://127.0.0.1:56123/callback"); + assert.equal(body.get("code_verifier"), "verifier"); + return Response.json({ + access_token: "access", + refresh_token: "oar_refresh", + expires_in: 3600, + id_token: createJwt({ email: "user@openference.com", name: "Openference User" }), + }); + }; + + const tokens = await openference.exchangeToken( + OPENFERENCE_CONFIG, + "auth-code", + "http://127.0.0.1:56123/callback", + "verifier" + ); + assert.equal(tokens.access_token, "access"); +}); + +test("Openference OAuth maps refreshable tokens and id_token display metadata", () => { + const idToken = createJwt({ email: "user@openference.com", name: "Openference User" }); + assert.deepEqual(decodeOpenferenceIdTokenIdentity(idToken), { + email: "user@openference.com", + name: "Openference User", + }); + + const mapped = openference.mapTokens({ + access_token: "access", + refresh_token: "oar_refresh", + id_token: idToken, + expires_in: 3600, + scope: OPENFERENCE_CONFIG.scope, + }); + assert.equal(mapped.accessToken, "access"); + assert.equal(mapped.refreshToken, "oar_refresh"); + assert.equal(mapped.email, "user@openference.com"); + assert.equal(mapped.name, "Openference User"); +}); + +test("Openference OAuth postExchange fetches userinfo when id_token lacks email", async () => { + globalThis.fetch = async (input) => { + assert.equal(String(input), OPENFERENCE_CONFIG.userinfoUrl); + return Response.json({ email: "from-userinfo@openference.com", name: "Userinfo Name" }); + }; + + const extra = await openference.postExchange({ access_token: "access" }); + const mapped = openference.mapTokens( + { access_token: "access", refresh_token: "oar_refresh", expires_in: 3600 }, + extra + ); + assert.equal(mapped.email, "from-userinfo@openference.com"); + assert.equal(mapped.name, "Userinfo Name"); +}); + +test("Openference is registered as an OAuth gateway with default executor", () => { + assert.ok(OAUTH_PROVIDERS.openference); + assert.equal(OAUTH_PROVIDERS.openference.alias, "of"); + assert.equal(OAUTH_PROVIDERS.openference.color, "#6366F1"); + assert.equal(OAUTH_PROVIDERS.openference.hasFree, true); + assert.equal(typeof OAUTH_PROVIDERS.openference.freeNote, "string"); + assert.ok(PROVIDERS.openference); + + assert.equal(openferenceProvider.authType, "oauth"); + assert.equal(openferenceProvider.executor, "default"); + assert.equal(openferenceProvider.baseUrl, "https://api.openference.com/v1/chat/completions"); + assert.deepEqual( + openferenceProvider.models?.map((model) => model.id), + ["GLM-5.2"] + ); + assert.equal(hasSpecializedExecutor("openference"), false); + + const headers = getExecutor("openference").buildHeaders({ accessToken: "oauth-access" }, false); + assert.equal(headers.Authorization, "Bearer oauth-access"); +}); + +test("Openference is classified for live OpenAI-style model discovery", () => { + assert.ok(NAMED_OPENAI_STYLE_PROVIDERS.has("openference")); +}); + +test("OAUTH_TEST_CONFIG covers openference and alias of", () => { + assert.ok((OAUTH_TEST_CONFIG as Record).openference); + assert.ok((OAUTH_TEST_CONFIG as Record).of); +}); + +test("Openference Test Connection probes /v1/models instead of reporting unsupported", async () => { + let calledUrl = ""; + globalThis.fetch = async (url) => { + calledUrl = String(url); + return new Response(JSON.stringify({ data: [{ id: "GLM-5.2" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const result = await testOAuthConnection({ + provider: "openference", + accessToken: "healthy-access-token", + refreshToken: "oar_refresh", + tokenExpiresAt: new Date(Date.now() + 3600_000).toISOString(), + }); + + assert.notEqual(result.diagnosis?.type, "unsupported"); + assert.notEqual(result.error, "Provider test not supported"); + assert.equal(result.valid, true); + assert.equal(calledUrl, "https://api.openference.com/v1/models"); +}); + +test("Openference Test Connection treats 402 as authenticated (plan required for inference)", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ error: "payment_required" }), { + status: 402, + headers: { "content-type": "application/json" }, + }); + + const result = await testOAuthConnection({ + provider: "openference", + accessToken: "healthy-access-token", + refreshToken: "oar_refresh", + tokenExpiresAt: new Date(Date.now() + 3600_000).toISOString(), + }); + + assert.equal(result.valid, true); +}); + +test("Openference refresh rotates oar_* tokens", async () => { + assert.equal(supportsTokenRefresh("openference"), true); + + globalThis.fetch = async (input, init) => { + assert.equal(String(input), OPENFERENCE_CONFIG.tokenUrl); + const body = init?.body as URLSearchParams; + assert.equal(body.get("grant_type"), "refresh_token"); + assert.equal(body.get("client_id"), OPENFERENCE_CONFIG.clientId); + assert.equal(body.get("refresh_token"), "oar_old"); + return Response.json({ + access_token: "new-access", + refresh_token: "oar_new", + expires_in: 3600, + }); + }; + + const refreshed = await refreshOpenferenceToken("oar_old", null, null); + assert.equal(refreshed?.accessToken, "new-access"); + assert.equal(refreshed?.refreshToken, "oar_new"); +}); From ed7a68e1a989d052230a8910979bd661ede0bf48 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:54:07 -0300 Subject: [PATCH 061/100] maint: follow-up cherry-pick fix-in-place #9719 (conflict-resolved fallback) (#9893) * fix(db): clear combo pins when connections are deleted * docs: add changelog entry for #9719 --------- Co-authored-by: Zartharas <1402357+Zartharas@users.noreply.github.com> --- .../fixes/9719-combo-connection-pins.md | 1 + src/app/api/providers/[id]/route.ts | 8 - src/lib/db/combos.ts | 31 ++- src/lib/db/providers/deletion.ts | 40 ++++ ...-connection-clears-combo-pins-8887.test.ts | 221 ++++++++++++++++++ 5 files changed, 288 insertions(+), 13 deletions(-) create mode 100644 changelog.d/fixes/9719-combo-connection-pins.md create mode 100644 tests/unit/delete-provider-connection-clears-combo-pins-8887.test.ts diff --git a/changelog.d/fixes/9719-combo-connection-pins.md b/changelog.d/fixes/9719-combo-connection-pins.md new file mode 100644 index 0000000000..79c3973ee0 --- /dev/null +++ b/changelog.d/fixes/9719-combo-connection-pins.md @@ -0,0 +1 @@ +- fix(db): clear stale combo connection pins when provider connections are deleted (#9719) diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index 460c3d1c8f..c40f15448c 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -25,7 +25,6 @@ import { import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { isApiKeyRevealEnabled, maskStoredApiKey } from "@/lib/apiKeyExposure"; import { cleanupProviderModelsAfterConnectionDelete } from "@/lib/db/models"; -import { cleanupComboConnectionRefs } from "@/lib/db/combos"; import { refreshConnectionRateLimits, enableRateLimitProtection, @@ -367,13 +366,6 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i console.error(`Failed to clean up models for deleted ${connection.provider} connection:`, e); } - // Remove stale connectionId references from combo route steps. - try { - await cleanupComboConnectionRefs(id); - } catch (e) { - console.error("Failed to clean up combo route refs for deleted connection:", e); - } - // Auto sync to Cloud if enabled await syncToCloudIfEnabled(); diff --git a/src/lib/db/combos.ts b/src/lib/db/combos.ts index 6cdebbd85b..7c050084cd 100644 --- a/src/lib/db/combos.ts +++ b/src/lib/db/combos.ts @@ -96,39 +96,60 @@ export function setActiveCombo(name: string, db = getDbInstance()): void { * Called after a provider connection is removed so combo routes don't carry * stale references. */ -export async function cleanupComboConnectionRefs(connectionId: string) { +export async function cleanupComboConnectionRefs(connectionIds: string | string[]) { + const deletedConnectionIds = new Set( + (Array.isArray(connectionIds) ? connectionIds : [connectionIds]).filter(Boolean) + ); + + if (deletedConnectionIds.size === 0) return 0; + const combos = await getCombos(); let touched = 0; + for (const combo of combos) { if (!Array.isArray(combo.models)) continue; + let changed = false; + const models = (combo.models as unknown as Record[]).map((step) => { let out = step; - if (out.connectionId === connectionId) { + + if (typeof out.connectionId === "string" && deletedConnectionIds.has(out.connectionId)) { const { connectionId: _, ...rest } = out; out = rest; changed = true; } + if (Array.isArray(out.allowedConnectionIds)) { const filtered = out.allowedConnectionIds.filter( - (id: string) => id !== connectionId + (id) => typeof id !== "string" || !deletedConnectionIds.has(id) ); + if (filtered.length !== out.allowedConnectionIds.length) { - out = { ...out, allowedConnectionIds: filtered }; + out = { + ...out, + allowedConnectionIds: filtered, + }; changed = true; } } + return out; }); + if (changed && typeof combo.id === "string") { try { const { id, ...rest } = combo; - await updateCombo(combo.id, { ...rest, models }); + await updateCombo(combo.id, { + ...rest, + models, + }); touched++; } catch { // One combo failing should not block cleanup of the rest. } } } + return touched; } diff --git a/src/lib/db/providers/deletion.ts b/src/lib/db/providers/deletion.ts index 38848e30df..44624fb8b9 100644 --- a/src/lib/db/providers/deletion.ts +++ b/src/lib/db/providers/deletion.ts @@ -10,6 +10,7 @@ import { getDbInstance } from "../core"; import { backupDbFile } from "../backup"; +import { cleanupComboConnectionRefs } from "../combos"; import { removeConnectionHealth, removeConnectionIndex, @@ -41,6 +42,29 @@ function _deleteAccountProxyAssignments(db: DbLike, ids: string[]) { ).run(...ids); } +function _selectExistingConnectionIds(db: DbLike, ids: string[]): string[] { + if (ids.length === 0) return []; + + const placeholders = ids.map(() => "?").join(","); + + return db + .prepare(`SELECT id FROM provider_connections WHERE id IN (${placeholders})`) + .all(...ids) + .map((row) => { + const record = toRecord(row); + return typeof record.id === "string" ? record.id : null; + }) + .filter((id): id is string => id !== null); +} + +async function _cleanupDeletedComboConnectionRefs(connectionIds: string | string[]): Promise { + try { + await cleanupComboConnectionRefs(connectionIds); + } catch (error) { + console.error("Failed to clean up combo route refs for deleted connections:", error); + } +} + export async function deleteProviderConnection(id: string) { const db = getDbInstance() as unknown as DbLike; const existing = db.prepare("SELECT provider FROM provider_connections WHERE id = ?").get(id); @@ -51,6 +75,9 @@ export async function deleteProviderConnection(id: string) { db.prepare("DELETE FROM quota_snapshots WHERE connection_id = ?").run(id); db.prepare("DELETE FROM provider_connections WHERE id = ?").run(id); })(); + + await _cleanupDeletedComboConnectionRefs(id); + removeConnectionHealth(id); removeConnectionIndex(id); bumpProxyConfigGeneration(); @@ -68,25 +95,35 @@ export async function deleteProviderConnection(id: string) { export async function deleteProviderConnections(ids: string[]): Promise { if (ids.length === 0) return 0; + const db = getDbInstance() as unknown as DbLike; + const existingIds = _selectExistingConnectionIds(db, ids); const deletedCount = db.transaction(() => { const placeholders = ids.map(() => "?").join(","); + db.prepare(`DELETE FROM quota_snapshots WHERE connection_id IN (${placeholders})`).run(...ids); + _deleteAccountProxyAssignments(db, ids); + const result = db .prepare(`DELETE FROM provider_connections WHERE id IN (${placeholders})`) .run(...ids); + return result.changes ?? 0; })(); + await _cleanupDeletedComboConnectionRefs(existingIds); + for (const id of ids) { removeConnectionHealth(id); removeConnectionIndex(id); } + backupDbFile("pre-write"); invalidateDbCache("connections"); invalidateReasoningRoutingRuleCache(); + return deletedCount; } @@ -111,6 +148,9 @@ export async function deleteProviderConnectionsByProvider(providerId: string) { } return db.prepare("DELETE FROM provider_connections WHERE provider = ?").run(providerId); })(); + + await _cleanupDeletedComboConnectionRefs(connectionIds); + for (const connectionId of connectionIds) { removeConnectionHealth(connectionId); removeConnectionIndex(connectionId); diff --git a/tests/unit/delete-provider-connection-clears-combo-pins-8887.test.ts b/tests/unit/delete-provider-connection-clears-combo-pins-8887.test.ts new file mode 100644 index 0000000000..b38a81c8d0 --- /dev/null +++ b/tests/unit/delete-provider-connection-clears-combo-pins-8887.test.ts @@ -0,0 +1,221 @@ +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-combo-pins-8887-")); + +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const combosDb = await import("../../src/lib/db/combos.ts"); + +type JsonRecord = Record; + +async function resetStorage(): Promise { + core.resetDbInstance(); + + for (let attempt = 0; attempt < 10; attempt++) { + try { + fs.rmSync(TEST_DATA_DIR, { + recursive: true, + force: true, + }); + break; + } catch (error: unknown) { + const code = + error && typeof error === "object" && "code" in error + ? String((error as { code?: unknown }).code) + : ""; + + if ((code === "EBUSY" || code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + continue; + } + + throw error; + } + } + + fs.mkdirSync(TEST_DATA_DIR, { + recursive: true, + }); +} + +async function createConnection(provider: string, name: string): Promise { + const connection = await providersDb.createProviderConnection({ + provider, + authType: "apikey", + name, + apiKey: `test-key-${name}`, + }); + + assert.equal(typeof connection.id, "string", "provider fixture must return a connection id"); + + return connection.id as string; +} + +async function createPinnedCombo(name: string, models: JsonRecord[]): Promise { + await combosDb.createCombo({ + name, + strategy: "priority", + models, + }); +} + +async function readModels(name: string): Promise { + const combo = await combosDb.getComboByName(name); + + assert.ok(combo, `combo ${name} must still exist`); + assert.ok(Array.isArray(combo.models), `combo ${name} must retain a models array`); + + return combo.models as JsonRecord[]; +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + + fs.rmSync(TEST_DATA_DIR, { + recursive: true, + force: true, + }); +}); + +test("#8887: single connection delete clears only matching combo pins", async () => { + const doomedId = await createConnection("openai", "single-doomed"); + const survivorId = await createConnection("openai", "single-survivor"); + + await createPinnedCombo("single-delete-8887", [ + { + provider: "openai", + model: "gpt-5.6-sol", + connectionId: doomedId, + allowedConnectionIds: [doomedId, survivorId], + }, + { + provider: "openai", + model: "gpt-5.6-sol", + connectionId: survivorId, + }, + ]); + + assert.equal(await providersDb.deleteProviderConnection(doomedId), true); + + const models = await readModels("single-delete-8887"); + + assert.equal( + "connectionId" in models[0], + false, + "single delete must remove the deleted direct connection pin" + ); + assert.deepEqual( + models[0].allowedConnectionIds, + [survivorId], + "single delete must remove the deleted id from allowedConnectionIds" + ); + assert.equal( + models[1].connectionId, + survivorId, + "single delete must preserve surviving connection pins" + ); +}); + +test("#8887: bulk connection delete clears every matching combo pin", async () => { + const doomedA = await createConnection("anthropic", "bulk-doomed-a"); + const doomedB = await createConnection("anthropic", "bulk-doomed-b"); + const survivorId = await createConnection("anthropic", "bulk-survivor"); + + await createPinnedCombo("bulk-delete-8887", [ + { + provider: "anthropic", + model: "claude-sonnet-5", + connectionId: doomedA, + allowedConnectionIds: [doomedA, survivorId], + }, + { + provider: "anthropic", + model: "claude-sonnet-5", + connectionId: doomedB, + allowedConnectionIds: [doomedB, survivorId], + }, + { + provider: "anthropic", + model: "claude-sonnet-5", + connectionId: survivorId, + }, + ]); + + assert.equal(await providersDb.deleteProviderConnections([doomedA, doomedB]), 2); + + const models = await readModels("bulk-delete-8887"); + + assert.equal( + "connectionId" in models[0], + false, + "bulk delete must remove the first deleted direct pin" + ); + assert.equal( + "connectionId" in models[1], + false, + "bulk delete must remove the second deleted direct pin" + ); + assert.deepEqual(models[0].allowedConnectionIds, [survivorId]); + assert.deepEqual(models[1].allowedConnectionIds, [survivorId]); + assert.equal(models[2].connectionId, survivorId); +}); + +test("#8887: provider-wide delete clears that provider's combo pins only", async () => { + const doomedA = await createConnection("nvidia", "provider-doomed-a"); + const doomedB = await createConnection("nvidia", "provider-doomed-b"); + const otherProviderId = await createConnection("cerebras", "provider-survivor"); + + await createPinnedCombo("provider-delete-8887", [ + { + provider: "nvidia", + model: "z-ai/glm-5.2", + connectionId: doomedA, + allowedConnectionIds: [doomedA, doomedB, otherProviderId], + }, + { + provider: "nvidia", + model: "deepseek-ai/deepseek-v4-pro", + connectionId: doomedB, + }, + { + provider: "cerebras", + model: "zai-glm-4.7", + connectionId: otherProviderId, + }, + ]); + + assert.equal(await providersDb.deleteProviderConnectionsByProvider("nvidia"), 2); + + const models = await readModels("provider-delete-8887"); + + assert.equal( + "connectionId" in models[0], + false, + "provider delete must remove its first deleted direct pin" + ); + assert.equal( + "connectionId" in models[1], + false, + "provider delete must remove its second deleted direct pin" + ); + assert.deepEqual( + models[0].allowedConnectionIds, + [otherProviderId], + "provider delete must preserve ids belonging to other providers" + ); + assert.equal( + models[2].connectionId, + otherProviderId, + "provider delete must preserve another provider's direct pin" + ); +}); From c8e6b07df52cd4cf1fea334bfaa0a3b05208967e Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 09:54:12 -0300 Subject: [PATCH 062/100] cherry-pick(pr-9718): feat(src): proxy-pool-toolbar-minor-improvements (#9870) * feat(proxy-pool): streamline pool actions * test(proxy-pool): cover toolbar layout * refactor(settings): extract proxy registry helpers Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * refactor(settings): reduce proxy registry component size Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Agnes --- .../components/ProxyRegistryManager.tsx | 427 +++++++++--------- .../components/proxy/ProxyPoolTab.tsx | 102 +---- .../components/proxyRegistryConstants.ts | 88 ++++ .../settings/components/proxyRegistryData.ts | 78 ++++ .../ProxyRegistryManager-tdz-render.test.tsx | 2 + 5 files changed, 391 insertions(+), 306 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/settings/components/proxyRegistryConstants.ts create mode 100644 src/app/(dashboard)/dashboard/settings/components/proxyRegistryData.ts diff --git a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx index b87aa200d8..47d0334041 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx @@ -1,8 +1,7 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; -import { z } from "zod"; import { Button, Card, Modal } from "@/shared/components"; import { useProxyBatchOperations } from "./useProxyBatchOperations"; import { ProxyStatusBadge } from "./ProxyStatusBadge"; @@ -16,90 +15,32 @@ import { } from "./parseBulkProxyImport"; import { POOL_STRATEGY_OPTIONS, isPoolStrategy, type PoolStrategy } from "./proxyStrategyOptions"; import type { ProxyItem } from "./proxyRegistryTypes"; +import { + BULK_IMPORT_PLACEHOLDER, + EMPTY_FORM, + type HealthInfo, + type ProxyRegistryManagerProps, + type TestResult, + type UsageInfo, +} from "./proxyRegistryConstants"; +import { + loadAllProxyUsage, + loadProxyHealth, + loadProxyUsage, + repairRelayResponseSchema, +} from "./proxyRegistryData"; -type UsageInfo = { - count: number; - assignments: Array<{ scope: string; scopeId: string | null }>; -}; - -type HealthInfo = { - proxyId: string; - totalRequests: number; - successRate: number | null; - avgLatencyMs: number | null; - lastSeenAt: string | null; -}; - -type TestResult = { - success: boolean; - publicIp?: string; - latencyMs?: number; - country?: string; - error?: string; -}; - -const EMPTY_FORM = { - id: "", - name: "", - type: "http", - host: "", - port: "8080", - username: "", - password: "", - region: "", - notes: "", - status: "active", - family: "auto", -}; - -const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import -# ───────────────────────────────────────────────────────────────────────────── -# FORMAT 1 — Pipe-delimited (full control): -# NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES -# Required: NAME, HOST, PORT -# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES -# -# FORMAT 2 — Shorthand (one proxy per line, no pipe needed): -# ip:port → no auth, type defaults to socks5 -# ip:port:user:pass → with auth -# user:pass@ip:port → with auth (@-style) -# user:pass:ip:port → with auth (user-pass-first) -# protocol://ip:port → explicit protocol -# protocol://user:pass@ip:port → explicit protocol + auth -# -# FORMAT 3 — Protocol header mode: -# Put a bare protocol (http, https, socks5) on its own line to set -# the default type for all subsequent shorthand lines that don't -# include an explicit protocol:// prefix. -# -# Lines starting with # are ignored. Existing proxies (same host+port) will be updated. -# -# ───────────────────────────────────────────────────────────────────────────── -# Pipe-delimited examples: -# proxy-us|138.99.147.218|50101|myuser|mypass|socks5|US-East|active|US production proxy -# proxy-eu|200.234.177.62|50101|myuser|mypass|socks5|EU-West -# http-proxy|10.0.0.50|8080|||http||active|Internal HTTP proxy -# -# Shorthand examples: -# 138.99.147.218:50101 -# 138.99.147.218:50101:myuser:mypass -# myuser:mypass@138.99.147.218:50101 -# myuser:mypass:138.99.147.218:50101 -# http://10.0.0.50:8080 -# https://admin:secret123@proxy.example.com:443 -# -# Protocol header mode example: -# socks5 -# 138.99.147.218:50101:myuser:mypass -# 200.234.177.62:50101:otheruser:otherpass -#`; - -export default function ProxyRegistryManager({ + export default function ProxyRegistryManager({ onRedeployRelay, -}: { - onRedeployRelay?: (proxy: ProxyItem) => void; -} = {}) { + showVercelRelay = false, + showDenoRelay = false, + showCloudflareRelay = false, + onOpenVercelRelay, + onOpenDenoRelay, + onOpenCloudflareRelay, +}: ProxyRegistryManagerProps = {}) { const t = useTranslations("proxyRegistry"); + const settingsT = useTranslations("settings"); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -135,7 +76,7 @@ export default function ProxyRegistryManager({ const [poolLoaded, setPoolLoaded] = useState(false); const [poolSaving, setPoolSaving] = useState(false); const [bulkImportOpen, setBulkImportOpen] = useState(false); - const [bulkImportText, setBulkImportText] = useState(BULK_IMPORT_TEMPLATE); + const [bulkImportText, setBulkImportText] = useState(""); const [bulkImportParsed, setBulkImportParsed] = useState([]); const [bulkImportErrors, setBulkImportErrors] = useState([]); const [bulkImportSkipped, setBulkImportSkipped] = useState(0); @@ -146,53 +87,40 @@ export default function ProxyRegistryManager({ updated: number; failed: number; } | null>(null); + const [actionsOpen, setActionsOpen] = useState(false); + const [relayMenuOpen, setRelayMenuOpen] = useState(false); + const actionsRef = useRef(null); + const relayRef = useRef(null); + + const showAnyRelay = showVercelRelay || showDenoRelay || showCloudflareRelay; + + useEffect(() => { + if (!actionsOpen && !relayMenuOpen) return; + const onMouseDown = (event: MouseEvent) => { + const target = event.target as Node; + if (actionsOpen && actionsRef.current && !actionsRef.current.contains(target)) { + setActionsOpen(false); + } + if (relayMenuOpen && relayRef.current && !relayRef.current.contains(target)) { + setRelayMenuOpen(false); + } + }; + document.addEventListener("mousedown", onMouseDown); + return () => document.removeEventListener("mousedown", onMouseDown); + }, [actionsOpen, relayMenuOpen]); + + const closeActions = () => { + setActionsOpen(false); + setRelayMenuOpen(false); + }; const editingId = useMemo(() => form.id || "", [form.id]); - const loadHealth = useCallback(async () => { - try { - const res = await fetch("/api/settings/proxies/health?hours=24"); - const data = await res.json().catch(() => ({})); - if (!res.ok) return; - const entries = Array.isArray(data?.items) ? data.items : []; - const mapped = Object.fromEntries( - entries.map((entry: HealthInfo) => [entry.proxyId, entry]) - ) as Record; - setHealthById(mapped); - } catch { - // ignore health loading errors in UI - } - }, []); - - const loadAllUsage = useCallback(async (proxyIds: string[]) => { - if (!proxyIds.length) return; - try { - const results = await Promise.all( - proxyIds.map((id) => - fetch(`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(id)}`) - .then((r) => (r.ok ? r.json() : null)) - .then((data) => { - const rawAssignments: Array<{ scope: string; scopeId: string | null }> = - Array.isArray(data?.items) ? data.items : []; - // Deduplicate by scope+scopeId — prevents double-counting when both - // a provider-scope and account-scope row exist for the same proxy - const seen = new Set(); - const assignments = rawAssignments.filter((a) => { - const key = `${a.scope}:${a.scopeId ?? ""}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - return [id, { count: assignments.length, assignments }] as [string, UsageInfo]; - }) - .catch(() => [id, { count: 0, assignments: [] }] as [string, UsageInfo]) - ) - ); - setUsageById(Object.fromEntries(results)); - } catch { - // ignore - } - }, []); + const loadHealth = useCallback(() => loadProxyHealth(setHealthById), []); + const loadAllUsage = useCallback( + (proxyIds: string[]) => loadAllProxyUsage(proxyIds, setUsageById), + [] + ); const load = useCallback(async () => { setLoading(true); @@ -240,17 +168,9 @@ export default function ProxyRegistryManager({ const allSelected = items.length > 0 && items.every((item) => selectedIds.has(item.id)); - const handleBatchDelete = useCallback(() => { - hookHandleBatchDelete(setError); - }, [hookHandleBatchDelete, setError]); - - const handleBatchActivate = useCallback(() => { - hookHandleBatchActivate(setError, "active"); - }, [hookHandleBatchActivate, setError]); - - const handleAutoTestAll = useCallback(() => { - hookHandleAutoTestAll(setError, setTestById); - }, [hookHandleAutoTestAll, setError, setTestById]); + const handleBatchDelete = () => hookHandleBatchDelete(setError); + const handleBatchActivate = () => hookHandleBatchActivate(setError, "active"); + const handleAutoTestAll = () => hookHandleAutoTestAll(setError, setTestById); useEffect(() => { void load(); @@ -284,33 +204,7 @@ export default function ProxyRegistryManager({ setModalOpen(true); }; - const loadUsage = async (proxyId: string) => { - try { - const res = await fetch( - `/api/settings/proxies/assignments?proxyId=${encodeURIComponent(proxyId)}` - ); - const data = await res.json().catch(() => ({})); - if (!res.ok) return; - const rawAssignments: Array<{ scope: string; scopeId: string | null }> = Array.isArray( - data?.items - ) - ? data.items - : []; - const seen = new Set(); - const assignments = rawAssignments.filter((a) => { - const key = `${a.scope}:${a.scopeId ?? ""}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - setUsageById((prev) => ({ - ...prev, - [proxyId]: { count: assignments.length, assignments }, - })); - } catch { - // ignore usage loading errors in UI - } - }; + const loadUsage = (proxyId: string) => loadProxyUsage(proxyId, setUsageById); const handleTestProxy = async (item: ProxyItem) => { if (testingId) return; @@ -345,12 +239,6 @@ export default function ProxyRegistryManager({ } }; - const repairRelayResponseSchema = z.object({ - repaired: z.boolean().optional(), - mode: z.enum(["noop", "recovered", "redeploy"]).optional(), - error: z.object({ message: z.string() }).optional(), - }); - const handleRepairRelay = async (item: ProxyItem) => { if (repairingId || !item.relayInfo?.isRelay) return; setRepairingId(item.id); @@ -724,7 +612,7 @@ export default function ProxyRegistryManager({ }; const openBulkImport = () => { - setBulkImportText(BULK_IMPORT_TEMPLATE); + setBulkImportText(""); setBulkImportParsed([]); setBulkImportErrors([]); setBulkImportSkipped(0); @@ -736,49 +624,13 @@ export default function ProxyRegistryManager({ return ( <> -
    -
    +
    +

    {t("title")}

    {t("description")}

    -
    - - - - +