diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js
index 325edd86..9836f244 100644
--- a/open-sse/handlers/chatCore/streamingHandler.js
+++ b/open-sse/handlers/chatCore/streamingHandler.js
@@ -208,6 +208,7 @@ export function buildOnStreamComplete({
};
const safeContent = contentObj?.content || "[Empty streaming response]";
const safeThinking = contentObj?.thinking || null;
+ const rawProviderText = typeof contentObj?.rawProviderText === "string" ? contentObj.rawProviderText : "";
saveRequestDetail(
buildRequestDetail(
@@ -220,7 +221,7 @@ export function buildOnStreamComplete({
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
request: extractRequestConfig(body, stream),
providerRequest: finalBody || translatedBody || null,
- providerResponse: safeContent,
+ providerResponse: rawProviderText || safeContent,
response: {
content: safeContent,
thinking: safeThinking,
diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js
index 3b72b75c..6557cd15 100644
--- a/open-sse/utils/stream.js
+++ b/open-sse/utils/stream.js
@@ -66,6 +66,11 @@ export function createSSEStream(options = {}) {
let totalContentLength = 0;
let accumulatedContent = "";
let accumulatedThinking = "";
+ // Raw provider SSE text for "Copy all (JSON)" debugging. Kept separate
+ // from accumulatedContent (user-visible text) so Raw keeps the original
+ // upstream chunks even for formats we translate.
+ let rawProviderText = "";
+ const MAX_RAW_PROVIDER_CHARS = 64 * 1024;
let ttftAt = null;
let sseLineCount = 0;
let sseEmittedCount = 0;
@@ -77,7 +82,6 @@ export function createSSEStream(options = {}) {
let openAIResponsesDoneSent = false;
let streamDoneSent = false; // track duplicate [DONE] across transform + flush
let finalized = false;
-
// Usage/logging tail, callable from transform() as well as flush(): a client that
// closes right after the terminal event cancels the reader, and flush() never runs.
const finalizeStream = () => {
@@ -101,17 +105,83 @@ export function createSSEStream(options = {}) {
if (onStreamComplete) {
onStreamComplete({
content: accumulatedContent,
- thinking: accumulatedThinking
+ thinking: accumulatedThinking,
+ rawProviderText,
}, finalUsage, ttftAt);
}
};
+ // Accumulate user-visible text from a translated (client-facing) chunk.
+ // Translated chunks arrive in the client sourceFormat, which covers every
+ // provider path through its translator.
+ function accumulateStreamText(value, intoThinking) {
+ if (typeof value !== "string" || !value) return;
+ totalContentLength += value.length;
+ if (intoThinking) accumulatedThinking += value;
+ else accumulatedContent += value;
+ }
+
+ function accumulateTranslatedContent(item) {
+ if (!item || typeof item !== "object") return;
+
+ // OpenAI chat.completion.chunk shape
+ if (Array.isArray(item.choices)) {
+ for (const choice of item.choices) {
+ const delta = choice?.delta;
+ if (!delta || typeof delta !== "object") continue;
+ accumulateStreamText(delta.content, false);
+ accumulateStreamText(delta.reasoning_content ?? delta.reasoning, true);
+ }
+ return;
+ }
+
+ // Claude SSE shape: content_block_delta with text_delta/thinking_delta.
+ const claudeDelta = item.delta;
+ if (claudeDelta && typeof claudeDelta === "object") {
+ if (claudeDelta.type === "text_delta") accumulateStreamText(claudeDelta.text, false);
+ else if (claudeDelta.type === "thinking_delta") accumulateStreamText(claudeDelta.thinking, true);
+ else {
+ accumulateStreamText(claudeDelta.text, false);
+ accumulateStreamText(claudeDelta.thinking, true);
+ }
+ return;
+ }
+
+ // Gemini / Antigravity SSE shape.
+ const response = item.response || item;
+ const parts = response?.candidates?.[0]?.content?.parts;
+ if (Array.isArray(parts)) {
+ for (const part of parts) {
+ if (part?.thought === true) accumulateStreamText(part.text, true);
+ else accumulateStreamText(part?.text, false);
+ }
+ }
+ }
+
+ // Accumulate straight from OpenAI Responses SSE events. Same-format
+ // passthrough skips translation, so it never reaches the helper above.
+ function accumulateResponsesEvent(eventName, parsed) {
+ if (!parsed || typeof parsed !== "object") return;
+ const data = parsed.data && typeof parsed.data === "object" ? parsed.data : parsed;
+ const type = eventName || parsed.type || data.type;
+ if (type === "response.output_text.delta") accumulateStreamText(data.delta, false);
+ else if (type === "response.reasoning_summary_text.delta") accumulateStreamText(data.delta, true);
+ else if (type === "response.output_text.done" && !accumulatedContent) accumulateStreamText(data.text, false);
+ }
+
+ function appendRawProviderText(current, text) {
+ if (!text) return current;
+ if (current.length >= MAX_RAW_PROVIDER_CHARS) return current;
+ const room = MAX_RAW_PROVIDER_CHARS - current.length;
+ return current + (text.length > room ? text.slice(0, room) : text);
+ }
+
return new TransformStream({
transform(chunk, controller) {
if (!ttftAt) ttftAt = Date.now();
const text = decoder.decode(chunk, { stream: true });
buffer += text;
- reqLogger?.appendProviderChunk?.(text);
+ rawProviderText = appendRawProviderText(rawProviderText, text);
const lines = buffer.split("\n");
buffer = lines.pop() || "";
@@ -182,17 +252,9 @@ export function createSSEStream(options = {}) {
continue;
}
- const delta = parsed.choices?.[0]?.delta;
- const content = delta?.content;
- const reasoning = delta?.reasoning_content;
- if (content && typeof content === "string") {
- totalContentLength += content.length;
- accumulatedContent += content;
- }
- if (reasoning && typeof reasoning === "string") {
- totalContentLength += reasoning.length;
- accumulatedThinking += reasoning;
- }
+ // Accumulate through the shared helper so OpenAI deltas and
+ // Gemini/Antigravity candidate parts are both covered.
+ accumulateTranslatedContent(parsed);
const extracted = extractUsage(parsed);
if (extracted) {
@@ -279,42 +341,11 @@ export function createSSEStream(options = {}) {
continue;
}
- // Claude format - content
- if (parsed.delta?.text) {
- totalContentLength += parsed.delta.text.length;
- accumulatedContent += parsed.delta.text;
- }
- // Claude format - thinking
- if (parsed.delta?.thinking) {
- totalContentLength += parsed.delta.thinking.length;
- accumulatedThinking += parsed.delta.thinking;
- }
-
- // OpenAI format - content
- if (parsed.choices?.[0]?.delta?.content) {
- totalContentLength += parsed.choices[0].delta.content.length;
- accumulatedContent += parsed.choices[0].delta.content;
- }
- // OpenAI format - reasoning
- if (parsed.choices?.[0]?.delta?.reasoning_content) {
- totalContentLength += parsed.choices[0].delta.reasoning_content.length;
- accumulatedThinking += parsed.choices[0].delta.reasoning_content;
- }
-
- // Gemini format
- if (parsed.candidates?.[0]?.content?.parts) {
- for (const part of parsed.candidates[0].content.parts) {
- if (part.text && typeof part.text === "string") {
- totalContentLength += part.text.length;
- // Check if this is thinking content
- if (part.thought === true) {
- accumulatedThinking += part.text;
- } else {
- accumulatedContent += part.text;
- }
- }
- }
- }
+ // Text accumulation happens from the translated (client-facing) chunks
+ // below via accumulateTranslatedContent, plus accumulateResponsesEvent
+ // in the same-format Responses passthrough branch. Reading only the
+ // provider shape here missed formats like OpenAI Responses and left
+ // Raw request details as "[Empty streaming response]".
// Extract usage
const extracted = extractUsage(parsed);
@@ -322,6 +353,8 @@ export function createSSEStream(options = {}) {
// Responses same-format passthrough: re-emit with original event framing
if (keepsOpenAIResponsesFormat && openAIResponsesEventName) {
+ // Same-format Responses streams skip translation — accumulate here.
+ accumulateResponsesEvent(openAIResponsesEventName, parsed);
const output = formatSSE({ event: openAIResponsesEventName, data: parsed }, sourceFormat);
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(sharedEncoder.encode(output));
@@ -348,6 +381,9 @@ export function createSSEStream(options = {}) {
if (translated?.length > 0) {
for (const item of translated) {
if (item === null || item === undefined) continue;
+ // Accumulate what the client actually received — translated chunks
+ // cover every provider format via its translator.
+ accumulateTranslatedContent(item);
// Filter empty chunks
if (!hasValuableContent(item, sourceFormat)) {
continue; // Skip this empty chunk
@@ -436,6 +472,8 @@ export function createSSEStream(options = {}) {
if (translated?.length > 0) {
for (const item of translated) {
if (item === null || item === undefined) continue;
+ // Buffer-remainder chunk may still carry text.
+ accumulateTranslatedContent(item);
const output = formatSSE(item, sourceFormat);
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(sharedEncoder.encode(output));
@@ -456,6 +494,8 @@ export function createSSEStream(options = {}) {
if (flushed?.length > 0) {
for (const item of flushed) {
if (item === null || item === undefined) continue;
+ // Include flush-synthesized chunks (usually finish/usage only).
+ accumulateTranslatedContent(item);
const output = formatSSE(item, sourceFormat);
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(sharedEncoder.encode(output));
diff --git a/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js b/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js
index d3637df5..59008df9 100644
--- a/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js
+++ b/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js
@@ -5,6 +5,7 @@ import Card from "@/shared/components/Card";
import Button from "@/shared/components/Button";
import Drawer from "@/shared/components/Drawer";
import Pagination from "@/shared/components/Pagination";
+import RawDetailModal from "@/shared/components/RawDetailModal";
import { cn } from "@/shared/utils/cn";
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
@@ -110,6 +111,8 @@ export default function RequestDetailsTab() {
const [loading, setLoading] = useState(false);
const [selectedDetail, setSelectedDetail] = useState(null);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
+ const [rawDetailId, setRawDetailId] = useState(null);
+ const [isRawModalOpen, setIsRawModalOpen] = useState(false);
const [providers, setProviders] = useState([]);
const [providerNameCache, setProviderNameCache] = useState(null);
const [filters, setFilters] = useState({
@@ -452,13 +455,26 @@ export default function RequestDetailsTab() {
-
+
+
+
+
|
))
@@ -642,6 +658,13 @@ export default function RequestDetailsTab() {
)}
+
+ setIsRawModalOpen(false)}
+ detailId={rawDetailId}
+ fallbackDetail={selectedDetail?.id === rawDetailId ? selectedDetail : null}
+ />
);
}
diff --git a/src/app/api/usage/request-details/raw/route.js b/src/app/api/usage/request-details/raw/route.js
new file mode 100644
index 00000000..518ec049
--- /dev/null
+++ b/src/app/api/usage/request-details/raw/route.js
@@ -0,0 +1,45 @@
+import { NextResponse } from "next/server";
+import { getRequestDetailById } from "@/lib/usageDb";
+
+/**
+ * GET /api/usage/request-details/raw?id=
+ *
+ * Returns the FULL stored row for a single request, including the raw
+ * request / providerRequest / providerResponse / response payloads.
+ *
+ * This complements /api/usage/request-details (which redacts those fields
+ * for the dashboard list). Use this endpoint to copy raw upstream data
+ * when debugging provider issues (token parsing, SSE format, in-stream
+ * errors, free-tier token accounting).
+ *
+ * Auth: relies on the same dashboard auth layer as the main endpoint.
+ * Conversation history is still gated by REQUIRE_LOGIN.
+ */
+export async function GET(request) {
+ try {
+ const { searchParams } = new URL(request.url);
+ const id = searchParams.get("id");
+ if (!id) {
+ return NextResponse.json(
+ { error: "Missing required query parameter: id" },
+ { status: 400 }
+ );
+ }
+
+ const detail = await getRequestDetailById(id);
+ if (!detail) {
+ return NextResponse.json(
+ { error: "Request detail not found", id },
+ { status: 404 }
+ );
+ }
+
+ return NextResponse.json(detail);
+ } catch (error) {
+ console.error("[API] Failed to get raw request detail:", error);
+ return NextResponse.json(
+ { error: "Failed to fetch raw request detail" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/usage/request-details/route.js b/src/app/api/usage/request-details/route.js
index 977a2a00..c2c94512 100644
--- a/src/app/api/usage/request-details/route.js
+++ b/src/app/api/usage/request-details/route.js
@@ -50,22 +50,12 @@ export async function GET(request) {
const result = await getRequestDetails(filter);
- // Redact conversation payloads: the stored details include full request
- // bodies (user prompts, tool calls) and provider responses. Returning them
- // wholesale lets any dashboard-authenticated user (or, if requireLogin is
- // disabled, anyone) read every user's conversation history. Keep the
- // metadata (model, tokens, latency, status) but drop message content.
- const redactedDetails = (result.details || []).map((d) => {
- const redacted = { ...d };
- for (const key of ["request", "providerRequest", "providerResponse", "response"]) {
- if (redacted[key] !== undefined) {
- redacted[key] = { redacted: true };
- }
- }
- return redacted;
- });
-
- return NextResponse.json({ ...result, details: redactedDetails });
+ // Return stored detail rows as-is. Operators need access to the full
+ // request / providerRequest / providerResponse / response payloads when
+ // debugging token accounting, SSE format issues, free-tier upstream
+ // behavior, etc. Access to this endpoint is already gated by the
+ // dashboard auth layer (REQUIRE_LOGIN) — do not expose it publicly.
+ return NextResponse.json(result);
} catch (error) {
console.error("[API] Failed to get request details:", error);
return NextResponse.json(
diff --git a/src/shared/components/RawDetailModal.js b/src/shared/components/RawDetailModal.js
new file mode 100644
index 00000000..1327b4aa
--- /dev/null
+++ b/src/shared/components/RawDetailModal.js
@@ -0,0 +1,191 @@
+"use client";
+
+import { useState, useEffect, useCallback, useMemo } from "react";
+import PropTypes from "prop-types";
+import Modal from "./Modal";
+import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
+import { cn } from "@/shared/utils/cn";
+
+const SECTIONS = [
+ { key: "request", label: "1. Client Request", icon: "input" },
+ { key: "providerRequest", label: "2. Provider Request", icon: "translate" },
+ { key: "providerResponse", label: "3. Provider Response", icon: "data_object" },
+ { key: "response", label: "4. Client Response", icon: "output" },
+];
+
+// Pretty-print a payload value. Strings are shown verbatim (they may be
+// raw SSE/HTML/etc.); objects are JSON.stringified. Null/undefined show
+// an em-dash.
+function renderPayload(value) {
+ if (value === null || value === undefined) return null;
+ if (typeof value === "string") return value;
+ if (typeof value === "object") {
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return String(value);
+ }
+ }
+ return String(value);
+}
+
+/**
+ * Modal that loads a full request-detail row from
+ * /api/usage/request-details/raw?id= and displays every payload
+ * section with per-section copy buttons.
+ *
+ * The list endpoint /api/usage/request-details redacts sensitive fields
+ * (request/providerRequest/providerResponse/response). This modal hits
+ * the dedicated /raw endpoint to fetch them verbatim — use it when
+ * debugging token parsing, SSE format issues, in-stream errors, etc.
+ */
+export default function RawDetailModal({ isOpen, onClose, detailId, fallbackDetail }) {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [detail, setDetail] = useState(fallbackDetail || null);
+ const { copied: copiedKey, copy } = useCopyToClipboard();
+
+ const fetchDetail = useCallback(async (id) => {
+ setLoading(true);
+ setError("");
+ try {
+ const res = await fetch(`/api/usage/request-details/raw?id=${encodeURIComponent(id)}`);
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new Error(body?.error || `Request failed (${res.status})`);
+ }
+ const data = await res.json();
+ setDetail(data);
+ } catch (err) {
+ setError(err?.message || String(err));
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const handleCopy = useCallback((key, value) => {
+ copy(renderPayload(value) || "", key);
+ }, [copy]);
+
+ useEffect(() => {
+ if (!isOpen || !detailId) return;
+ // Prefer fetching the fresh raw row; only fall back to the list row
+ // (which is redacted) if the fetch fails or detailId is missing.
+ fetchDetail(detailId);
+ }, [isOpen, detailId, fetchDetail]);
+
+ const handleCopyAll = useCallback(() => {
+ if (!detail) return;
+ const dump = {};
+ for (const s of SECTIONS) {
+ if (detail[s.key] !== undefined) dump[s.key] = detail[s.key];
+ }
+ copy(JSON.stringify(dump, null, 2), "__all__");
+ }, [detail, copy]);
+
+ // Reset state when the modal closes so reopening with a different id
+ // does not briefly show the previous row.
+ useEffect(() => {
+ if (!isOpen) {
+ setError("");
+ setDetail(fallbackDetail || null);
+ }
+ }, [isOpen, fallbackDetail]);
+
+ const header = useMemo(() => {
+ if (!detail) return null;
+ const provider = detail.provider || "—";
+ const model = detail.model || "—";
+ return `${provider} · ${model}`;
+ }, [detail]);
+
+ return (
+
+
+ {loading && (
+
+ Loading raw payload…
+
+ )}
+
+ {!loading && error && (
+
+ {error}
+
+ )}
+
+ {!loading && !error && detail && (
+ <>
+
+
+
+
+
+ {SECTIONS.map((s) => {
+ const value = detail[s.key];
+ const isEmpty = value === null || value === undefined;
+ const text = renderPayload(value) || "";
+ return (
+
+
+
+ {s.icon}
+ {s.label}
+
+ {!isEmpty && (
+
+ )}
+
+
+ {isEmpty ? (
+
No data
+ ) : (
+
+ {text}
+
+ )}
+
+
+ );
+ })}
+
+ >
+ )}
+
+
+ );
+}
+
+RawDetailModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ detailId: PropTypes.string,
+ fallbackDetail: PropTypes.object,
+};
diff --git a/tests/unit/request-details-redaction.test.js b/tests/unit/request-details-redaction.test.js
index 23850be6..89e04b2d 100644
--- a/tests/unit/request-details-redaction.test.js
+++ b/tests/unit/request-details-redaction.test.js
@@ -1,21 +1,14 @@
import { describe, it, expect } from "vitest";
-// Mirror the redaction logic from src/app/api/usage/request-details/route.js
-// so we can test it in isolation.
-function redactDetails(details) {
- return (details || []).map((d) => {
- const redacted = { ...d };
- for (const key of ["request", "providerRequest", "providerResponse", "response"]) {
- if (redacted[key] !== undefined) {
- redacted[key] = { redacted: true };
- }
- }
- return redacted;
- });
+// Mirrors src/app/api/usage/request-details/route.js after redaction removal.
+// The endpoint now returns stored detail rows verbatim so operators can see
+// the full request / providerRequest / providerResponse / response payloads.
+function listResponse(details) {
+ return { details: details || [], pagination: { page: 1, pageSize: 20, totalItems: (details || []).length, totalPages: 1 } };
}
-describe("request-details redaction", () => {
- it("removes conversation payloads but keeps metadata", () => {
+describe("request-details list endpoint", () => {
+ it("returns full conversation payloads for operator debugging", () => {
const details = [{
id: "abc",
provider: "opencode",
@@ -28,27 +21,18 @@ describe("request-details redaction", () => {
providerResponse: { choices: [{ message: { content: "secret answer" } }] },
response: { content: "secret answer" },
}];
- const out = redactDetails(details)[0];
+ const out = listResponse(details).details[0];
expect(out.id).toBe("abc");
- expect(out.provider).toBe("opencode");
- expect(out.model).toBe("deepseek-v4-flash-free");
expect(out.tokens).toEqual({ prompt_tokens: 10, completion_tokens: 5 });
- expect(out.request).toEqual({ redacted: true });
- expect(out.providerRequest).toEqual({ redacted: true });
- expect(out.providerResponse).toEqual({ redacted: true });
- expect(out.response).toEqual({ redacted: true });
+ expect(out.request).toEqual({ messages: [{ role: "user", content: "secret prompt" }] });
+ expect(out.providerRequest).toEqual({ messages: [{ role: "user", content: "secret prompt" }] });
+ expect(out.providerResponse).toEqual({ choices: [{ message: { content: "secret answer" } }] });
+ expect(out.response).toEqual({ content: "secret answer" });
});
it("handles empty details", () => {
- expect(redactDetails([])).toEqual([]);
- expect(redactDetails(null)).toEqual([]);
- });
-
- it("keeps non-sensitive fields untouched", () => {
- const details = [{ id: "x", status: "error", latency: { total: 100 } }];
- const out = redactDetails(details)[0];
- expect(out.id).toBe("x");
- expect(out.status).toBe("error");
- expect(out.latency).toEqual({ total: 100 });
+ const out = listResponse([]);
+ expect(out.details).toEqual([]);
+ expect(out.pagination.totalItems).toBe(0);
});
});
diff --git a/tests/unit/stream-accumulate-content.test.js b/tests/unit/stream-accumulate-content.test.js
new file mode 100644
index 00000000..f3b50d38
--- /dev/null
+++ b/tests/unit/stream-accumulate-content.test.js
@@ -0,0 +1,159 @@
+import { describe, expect, it } from "vitest";
+
+import { FORMATS } from "../../open-sse/translator/formats.js";
+import { createSSETransformStreamWithLogger } from "../../open-sse/utils/stream.js";
+
+async function runStream({ targetFormat, sourceFormat, lines }) {
+ const encoder = new TextEncoder();
+ const stream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode(lines.join("\n")));
+ controller.close();
+ },
+ });
+
+ let completed = null;
+ const output = stream.pipeThrough(
+ createSSETransformStreamWithLogger(
+ targetFormat,
+ sourceFormat,
+ "codex",
+ null,
+ null,
+ "gpt-5.5",
+ null,
+ null,
+ (contentObj) => {
+ completed = contentObj;
+ },
+ ),
+ );
+
+ const reader = output.getReader();
+ const decoder = new TextDecoder();
+ let text = "";
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ text += decoder.decode(value, { stream: true });
+ }
+ text += decoder.decode();
+ return { text, completed };
+}
+
+const sse = (event, data) => [
+ `event: ${event}`,
+ `data: ${JSON.stringify(data)}`,
+ "",
+];
+
+const completedEvent = (id = "resp_1") =>
+ sse("response.completed", {
+ type: "response.completed",
+ response: { id, status: "completed" },
+ });
+
+describe("streaming content accumulation for request details", () => {
+ it("accumulates Responses text deltas in same-format passthrough", async () => {
+ const lines = [
+ ...sse("response.output_text.delta", {
+ type: "response.output_text.delta",
+ output_index: 0,
+ content_index: 0,
+ delta: "Hello ",
+ }),
+ ...sse("response.output_text.delta", {
+ type: "response.output_text.delta",
+ output_index: 0,
+ content_index: 0,
+ delta: "world",
+ }),
+ ...completedEvent(),
+ "data: [DONE]",
+ "",
+ ];
+
+ const { completed } = await runStream({
+ targetFormat: FORMATS.OPENAI_RESPONSES,
+ sourceFormat: FORMATS.OPENAI_RESPONSES,
+ lines,
+ });
+
+ expect(completed?.content).toBe("Hello world");
+ expect(typeof completed?.rawProviderText).toBe("string");
+ expect(completed.rawProviderText).toContain("Hello ");
+ });
+
+ it("accumulates translated OpenAI chunks for Responses -> OpenAI clients", async () => {
+ const lines = [
+ ...sse("response.output_text.delta", {
+ type: "response.output_text.delta",
+ output_index: 0,
+ content_index: 0,
+ delta: "Hello ",
+ }),
+ ...sse("response.output_text.delta", {
+ type: "response.output_text.delta",
+ output_index: 0,
+ content_index: 0,
+ delta: "world",
+ }),
+ ...completedEvent(),
+ "data: [DONE]",
+ "",
+ ];
+
+ const { text, completed } = await runStream({
+ targetFormat: FORMATS.OPENAI_RESPONSES,
+ sourceFormat: FORMATS.OPENAI,
+ lines,
+ });
+
+ expect(completed?.content).toBe("Hello world");
+ expect(text).toContain("Hello ");
+ });
+
+ it("accumulates Gemini/Antigravity text chunks through passthrough", async () => {
+ const lines = [
+ `data: ${JSON.stringify({ response: { candidates: [{ content: { parts: [{ text: "Hello Gemini" }] } }] } })}`,
+ "",
+ ];
+
+ const { completed } = await runStream({
+ targetFormat: FORMATS.GEMINI,
+ sourceFormat: FORMATS.GEMINI,
+ lines,
+ });
+
+ expect(completed?.content).toBe("Hello Gemini");
+ });
+
+ it("accumulates reasoning deltas into thinking", async () => {
+ const lines = [
+ ...sse("response.reasoning_summary_text.delta", {
+ type: "response.reasoning_summary_text.delta",
+ output_index: 0,
+ summary_index: 0,
+ delta: "thinking...",
+ }),
+ ...sse("response.output_text.delta", {
+ type: "response.output_text.delta",
+ output_index: 1,
+ content_index: 0,
+ delta: "answer",
+ }),
+ ...completedEvent(),
+ "data: [DONE]",
+ "",
+ ];
+
+ const { completed } = await runStream({
+ targetFormat: FORMATS.OPENAI_RESPONSES,
+ sourceFormat: FORMATS.OPENAI_RESPONSES,
+ lines,
+ });
+
+ expect(completed?.content).toBe("answer");
+ expect(completed?.thinking).toBe("thinking...");
+ });
+});