From 848fca7eb023e5f5945804279fdc4cce831b72ff Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 4 Aug 2026 03:49:35 -0300 Subject: [PATCH 1/3] 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 2/3] 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 3ff25a484ff2906568ec12c8a0834447386486d7 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 9 Aug 2026 18:47:22 -0300 Subject: [PATCH 3/3] fix(stream): type empty-choice collector events --- open-sse/utils/streamEmptyChoices.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/open-sse/utils/streamEmptyChoices.ts b/open-sse/utils/streamEmptyChoices.ts index 20d2ec50e6..05f8d8e4b9 100644 --- a/open-sse/utils/streamEmptyChoices.ts +++ b/open-sse/utils/streamEmptyChoices.ts @@ -20,8 +20,15 @@ import { buildErrorBody } from "./error.ts"; import { buildStreamSummaryFromEvents } from "./streamPayloadCollector.ts"; +type StructuredSSEEventLike = { + index: number; + timestamp?: string; + event?: string; + data: unknown; +}; + type StructuredSSECollectorLike = { - getEvents: () => unknown[]; + getEvents: () => StructuredSSEEventLike[]; build: (summary?: unknown, opts?: { includeEvents?: boolean }) => unknown; };