Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -11341,6 +11341,8 @@
"copy": "Copy",
"autoscrollOn": "Autoscroll: on",
"autoscrollOff": "Autoscroll: off",
"rawViewOn": "Raw view: on",
"rawViewOff": "Raw view: off",
"collapseAllLevels": "Collapse all",
"collapseOneLevel": "Collapse one level",
"currentExpandLevel": "Current expand level",
Expand Down
123 changes: 105 additions & 18 deletions src/shared/components/RequestLoggerDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,61 @@ const STREAM_TIMESTAMP_PREFIX = /\[\d{2}:\d{2}:\d{2}\.\d{3}\] /g;
type StreamSegment =
{ type: "text"; value: string } | { type: "json"; value: unknown; raw: string };

// Gemini's own stream-chunk capture concatenates multiple complete JSON
// objects back-to-back with no separator between them ("}{"), unlike the
// SSE blank-line-per-event framing every other provider's capture uses
// here. A `data:` payload can therefore hold several complete top-level
// JSON values, not one -- JSON.parse correctly rejects the whole thing
// ("Unexpected non-whitespace character after JSON") and used to make the
// entire payload fall back to plain text. Recover by scanning bracket/
// string depth to split the payload into individual JSON values instead of
// assuming exactly one JSON.parse per payload; each recovered value renders
// as its own collapsible node.
export function splitConcatenatedJsonValues(payload: string): unknown[] | null {
const values: unknown[] = [];
let i = 0;
const len = payload.length;
while (i < len) {
while (i < len && /\s/.test(payload[i])) i++;
if (i >= len) break;
const start = i;
if (payload[i] !== "{" && payload[i] !== "[") return null; // not a bare-value stream
let depth = 0;
let inString = false;
let escaped = false;
for (; i < len; i++) {
const ch = payload[i];
if (inString) {
if (escaped) escaped = false;
else if (ch === "\\") escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === "{" || ch === "[") depth++;
else if (ch === "}" || ch === "]") {
depth--;
if (depth === 0) {
i++;
break;
}
}
}
if (depth !== 0) return null; // unterminated -- a genuinely incomplete capture, not this case
try {
values.push(JSON.parse(payload.slice(start, i)));
} catch {
return null;
}
}
return values.length > 1 ? values : null;
}

// Splits a raw joined SSE capture into renderable segments: each `data:`
// line that parses as JSON becomes its own segment (rendered as a
// collapsible tree), everything else (comments, keep-alives, [DONE],
// non-JSON payloads) stays as plain text, byte-identical to the raw capture.
function parseStreamIntoSegments(joined: string): StreamSegment[] {
export function parseStreamIntoSegments(joined: string): StreamSegment[] {
const text = joined.replace(STREAM_TIMESTAMP_PREFIX, "");
const events = text.split(/(?<=\n\n)/); // keep event boundaries, preserve exact text
const segments: StreamSegment[] = [];
Expand All @@ -105,7 +155,12 @@ function parseStreamIntoSegments(joined: string): StreamSegment[] {
try {
segments.push({ type: "json", value: JSON.parse(payload), raw: event });
} catch {
segments.push({ type: "text", value: event });
const values = splitConcatenatedJsonValues(payload);
if (values) {
for (const value of values) segments.push({ type: "json", value, raw: event });
} else {
segments.push({ type: "text", value: event });
}
}
}
return segments;
Expand All @@ -123,6 +178,17 @@ function StreamSection({ title, sectionId, json, onCopy }) {
return true;
}
});
// Default to the rendered/parsed view (segments below) -- raw is an
// explicit opt-in for inspecting exactly what was captured on the wire,
// timestamp markers and all, when the rendered tree hides something the
// parser got wrong (e.g. a chunk-boundary split like this PR fixes).
const [showRaw, setShowRaw] = useState(() => {
try {
return localStorage.getItem("pref:stream:raw") === "1";
} catch {
return false;
}
});
const ref = useRef(null);
const { isDark } = useTheme();
const resolvedSectionId = sectionId || title;
Expand Down Expand Up @@ -157,6 +223,14 @@ function StreamSection({ title, sectionId, json, onCopy }) {
} catch {}
};

const toggleRaw = () => {
const next = !showRaw;
setShowRaw(next);
try {
localStorage.setItem("pref:stream:raw", next ? "1" : "0");
} catch {}
};

useTimestampTitles(ref, open);

return (
Expand All @@ -177,6 +251,15 @@ function StreamSection({ title, sectionId, json, onCopy }) {
</button>
</div>
<div className="flex items-center gap-2">
<button
onClick={toggleRaw}
title={showRaw ? t("rawViewOn") : t("rawViewOff")}
aria-label={showRaw ? t("rawViewOn") : t("rawViewOff")}
className={`p-1 rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors ${showRaw ? "text-primary" : ""}`}
aria-pressed={showRaw}
>
<span className="material-symbols-outlined text-[18px]">code</span>
</button>
<button
onClick={toggleAutoscroll}
title={autoscroll ? t("autoscrollOn") : t("autoscrollOff")}
Expand All @@ -195,7 +278,7 @@ function StreamSection({ title, sectionId, json, onCopy }) {
</span>
{copied ? t("copied") : t("copy")}
</button>
{segments.some((s) => s.type === "json") && (
{!showRaw && segments.some((s) => s.type === "json") && (
<JsonTreeExpandControls sectionId={resolvedSectionId} />
)}
</div>
Expand All @@ -205,21 +288,25 @@ function StreamSection({ title, sectionId, json, onCopy }) {
ref={ref}
className="p-4 rounded-xl bg-black/5 dark:bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-main max-h-150 overflow-y-auto leading-relaxed"
>
{segments.map((segment, i) =>
segment.type === "json" ? (
<div key={i} className="my-1">
<JsonView
src={segment.value}
dark={isDark}
collapsed={expandLevel}
customizeNode={timestampMarkerCustomizeNode}
displaySize
/>
</div>
) : (
<span key={i} className="whitespace-pre-wrap break-words">
{segment.value}
</span>
{showRaw ? (
<pre className="whitespace-pre-wrap break-words">{json}</pre>
) : (
segments.map((segment, i) =>
segment.type === "json" ? (
<div key={i} className="my-1">
<JsonView
src={segment.value}
dark={isDark}
collapsed={expandLevel}
customizeNode={timestampMarkerCustomizeNode}
displaySize
/>
</div>
) : (
<span key={i} className="whitespace-pre-wrap break-words">
{segment.value}
</span>
)
)
)}
</div>
Expand Down
48 changes: 48 additions & 0 deletions tests/unit/request-log-detail-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,54 @@ test("event stream hidden when debugEnabled is false", () => {
);
});

// Regression: the raw/rendered toggle added alongside this PR's concatenated-JSON
// recovery must default to the rendered (parsed JsonView tree) view, with the
// raw byte-for-byte capture only shown on explicit opt-in -- not the reverse.
test("Provider Event Stream defaults to the rendered JsonView tree, not the raw capture", () => {
const html = renderToStaticMarkup(
React.createElement(RequestLoggerDetail, {
log: {
status: 200,
method: "POST",
path: "/v1/chat/completions",
timestamp: "2026-04-09T21:27:08.000Z",
duration: 2500,
provider: "gemini",
sourceFormat: "openai-chat",
model: "test-model",
tokens: { in: 1, out: 1 },
},
detail: {
pipelinePayloads: {
streamChunks: { provider: ['data: {"content": "hello"}\n\n'] },
},
responseBody: "{}",
},
loading: false,
debugEnabled: true,
onClose: () => {},
onCopy: async () => true,
})
);

// The raw-view toggle button is present, labeled from the real en.json copy...
assert.notEqual(
html.indexOf('aria-label="Raw view: off"'),
-1,
"raw-view toggle should render, defaulting to off"
);
// ...but with no localStorage available during SSR (and no prior opt-in), the
// panel itself must render the parsed JsonView tree, not a raw dump of the
// literal captured line -- JsonView renders the key/value as separate nodes,
// so the exact raw `data: {...}` line never appears as one contiguous
// substring unless the raw-view toggle is on.
assert.equal(
html.indexOf('data: {"content": "hello"}'),
-1,
"default view should be the rendered JsonView tree, not the raw captured line"
);
});

test("status discrepancy shows both OmniRoute and provider statuses", () => {
const html = renderToStaticMarkup(
React.createElement(RequestLoggerDetail, {
Expand Down
85 changes: 85 additions & 0 deletions tests/unit/request-logger-stream-concatenated-json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Gemini's own stream-chunk capture concatenates multiple complete JSON
// objects back-to-back with NO separator between them ("}{"), unlike the
// SSE blank-line-per-event framing every other provider's capture uses. A
// single `data:` payload can therefore hold several complete top-level JSON
// values, not one -- JSON.parse correctly rejects the whole thing
// ("Unexpected non-whitespace character after JSON") and the entire payload
// fell back to plain text in the "Provider Event Stream" viewer, even
// though it was a perfectly well-formed sequence of JSON events.
//
// Observed live: 161 of 663 recent streaming call-log artifacts (all
// gemini) hit this, some with as many as 11 concatenated objects in one
// capture. Confirmed by replaying real stored streamChunks.provider arrays
// from production call-log artifacts through this exact code path.

import test from "node:test";
import assert from "node:assert/strict";
import {
parseStreamIntoSegments,
splitConcatenatedJsonValues,
} from "../../src/shared/components/RequestLoggerDetail.tsx";

test("splitConcatenatedJsonValues: recovers multiple back-to-back JSON objects", () => {
const payload = '{"a":1}{"b":2}{"c":[1,2,3]}';
const values = splitConcatenatedJsonValues(payload);
assert.deepEqual(values, [{ a: 1 }, { b: 2 }, { c: [1, 2, 3] }]);
});

test("splitConcatenatedJsonValues: tolerates braces and escaped quotes inside strings", () => {
const payload = '{"text":"a { b } \\"quoted\\""}{"next":true}';
const values = splitConcatenatedJsonValues(payload);
assert.deepEqual(values, [{ text: 'a { b } "quoted"' }, { next: true }]);
});

test("splitConcatenatedJsonValues: returns null for a single JSON value (not this case)", () => {
assert.equal(splitConcatenatedJsonValues('{"a":1}'), null);
});

test("splitConcatenatedJsonValues: returns null for genuinely non-JSON text", () => {
assert.equal(splitConcatenatedJsonValues("not json at all"), null);
});

test("splitConcatenatedJsonValues: returns null for a genuinely truncated/incomplete value", () => {
// An actually-incomplete capture (mid-stream cutoff) must still fall back
// to plain text rather than being silently accepted as valid.
assert.equal(splitConcatenatedJsonValues('{"a":1}{"b":'), null);
});

type GeminiChunk = { candidates: [{ content: { parts: [{ text: string }] } }] };

test("parseStreamIntoSegments: a data: line with concatenated JSON objects becomes multiple json segments", () => {
const raw =
'data: {"candidates":[{"content":{"parts":[{"text":"A"}]}}]}' +
'{"candidates":[{"content":{"parts":[{"text":"B"}]}}]}\n\n';
const segments = parseStreamIntoSegments(raw);
assert.equal(segments.length, 2);
assert.ok(segments.every((s) => s.type === "json"));
const [first, second] = segments as Array<{ type: "json"; value: GeminiChunk }>;
assert.equal(first.value.candidates[0].content.parts[0].text, "A");
assert.equal(second.value.candidates[0].content.parts[0].text, "B");
});

test("parseStreamIntoSegments: real production artifact shape (10 concatenated Gemini chunks) recovers with zero text fallback", () => {
// Reproduces the exact shape stored in a real call-log artifact
// (streamChunks.provider joined) that triggered this bug live.
const events = [
{ text: "A" },
{ text: "hello " },
{ text: "world", finishReason: "STOP" },
];
const raw =
"data: " + events.map((e) => JSON.stringify({ candidates: [{ content: { parts: [e] } }] })).join("") +
"\n\n";
const segments = parseStreamIntoSegments(raw);
assert.equal(segments.length, 3, "each concatenated object must recover as its own segment");
assert.ok(
segments.every((s) => s.type === "json"),
"no segment should fall back to plain text"
);
});

test("parseStreamIntoSegments: unrelated non-JSON text (comments, keep-alives) still falls back to text as before", () => {
const raw = ": OPENROUTER PROCESSING\n\ndata: [DONE]\n\n";
const segments = parseStreamIntoSegments(raw);
assert.ok(segments.every((s) => s.type === "text"), "no regression for genuine non-JSON content");
});
Loading