feat(usage): raw request detail modal + raw stream capture
* Add /api/usage/request-details/raw endpoint serving a single stored request detail verbatim (raw payloads), with /raw doc clarifying it stays gated by the dashboard auth layer. * Add RawDetailModal opened from a new 'Raw' button in RequestDetailsTab. Modal loads /raw, exposes per-section copy buttons and a 'Copy all (JSON)' that bundles every section. * Capture the raw provider SSE text inside the streaming transform (cap 64KB) and forward it through onStreamComplete.rawProviderText so handler stores it as the providerResponse. response.content stays the extracted user text. Tool-call-only turns remain so the marker. * Accumulate from translated client-facing chunks instead of raw provider shapes so Responses, Claude delta types, and Gemini/Antigravity parts all contribute. * Drop redaction from the list endpoint; raw access is now via the dedicated /raw endpoint. Tests cover the new behavior.
This commit is contained in:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user