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:
@@ -208,6 +208,7 @@ export function buildOnStreamComplete({
|
|||||||
};
|
};
|
||||||
const safeContent = contentObj?.content || "[Empty streaming response]";
|
const safeContent = contentObj?.content || "[Empty streaming response]";
|
||||||
const safeThinking = contentObj?.thinking || null;
|
const safeThinking = contentObj?.thinking || null;
|
||||||
|
const rawProviderText = typeof contentObj?.rawProviderText === "string" ? contentObj.rawProviderText : "";
|
||||||
|
|
||||||
saveRequestDetail(
|
saveRequestDetail(
|
||||||
buildRequestDetail(
|
buildRequestDetail(
|
||||||
@@ -220,7 +221,7 @@ export function buildOnStreamComplete({
|
|||||||
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
tokens: usage || { prompt_tokens: 0, completion_tokens: 0 },
|
||||||
request: extractRequestConfig(body, stream),
|
request: extractRequestConfig(body, stream),
|
||||||
providerRequest: finalBody || translatedBody || null,
|
providerRequest: finalBody || translatedBody || null,
|
||||||
providerResponse: safeContent,
|
providerResponse: rawProviderText || safeContent,
|
||||||
response: {
|
response: {
|
||||||
content: safeContent,
|
content: safeContent,
|
||||||
thinking: safeThinking,
|
thinking: safeThinking,
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ export function createSSEStream(options = {}) {
|
|||||||
let totalContentLength = 0;
|
let totalContentLength = 0;
|
||||||
let accumulatedContent = "";
|
let accumulatedContent = "";
|
||||||
let accumulatedThinking = "";
|
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 ttftAt = null;
|
||||||
let sseLineCount = 0;
|
let sseLineCount = 0;
|
||||||
let sseEmittedCount = 0;
|
let sseEmittedCount = 0;
|
||||||
@@ -77,7 +82,6 @@ export function createSSEStream(options = {}) {
|
|||||||
let openAIResponsesDoneSent = false;
|
let openAIResponsesDoneSent = false;
|
||||||
let streamDoneSent = false; // track duplicate [DONE] across transform + flush
|
let streamDoneSent = false; // track duplicate [DONE] across transform + flush
|
||||||
let finalized = false;
|
let finalized = false;
|
||||||
|
|
||||||
// Usage/logging tail, callable from transform() as well as flush(): a client that
|
// 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.
|
// closes right after the terminal event cancels the reader, and flush() never runs.
|
||||||
const finalizeStream = () => {
|
const finalizeStream = () => {
|
||||||
@@ -101,17 +105,83 @@ export function createSSEStream(options = {}) {
|
|||||||
if (onStreamComplete) {
|
if (onStreamComplete) {
|
||||||
onStreamComplete({
|
onStreamComplete({
|
||||||
content: accumulatedContent,
|
content: accumulatedContent,
|
||||||
thinking: accumulatedThinking
|
thinking: accumulatedThinking,
|
||||||
|
rawProviderText,
|
||||||
}, finalUsage, ttftAt);
|
}, 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({
|
return new TransformStream({
|
||||||
transform(chunk, controller) {
|
transform(chunk, controller) {
|
||||||
if (!ttftAt) ttftAt = Date.now();
|
if (!ttftAt) ttftAt = Date.now();
|
||||||
const text = decoder.decode(chunk, { stream: true });
|
const text = decoder.decode(chunk, { stream: true });
|
||||||
buffer += text;
|
buffer += text;
|
||||||
reqLogger?.appendProviderChunk?.(text);
|
rawProviderText = appendRawProviderText(rawProviderText, text);
|
||||||
|
|
||||||
const lines = buffer.split("\n");
|
const lines = buffer.split("\n");
|
||||||
buffer = lines.pop() || "";
|
buffer = lines.pop() || "";
|
||||||
@@ -182,17 +252,9 @@ export function createSSEStream(options = {}) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const delta = parsed.choices?.[0]?.delta;
|
// Accumulate through the shared helper so OpenAI deltas and
|
||||||
const content = delta?.content;
|
// Gemini/Antigravity candidate parts are both covered.
|
||||||
const reasoning = delta?.reasoning_content;
|
accumulateTranslatedContent(parsed);
|
||||||
if (content && typeof content === "string") {
|
|
||||||
totalContentLength += content.length;
|
|
||||||
accumulatedContent += content;
|
|
||||||
}
|
|
||||||
if (reasoning && typeof reasoning === "string") {
|
|
||||||
totalContentLength += reasoning.length;
|
|
||||||
accumulatedThinking += reasoning;
|
|
||||||
}
|
|
||||||
|
|
||||||
const extracted = extractUsage(parsed);
|
const extracted = extractUsage(parsed);
|
||||||
if (extracted) {
|
if (extracted) {
|
||||||
@@ -279,42 +341,11 @@ export function createSSEStream(options = {}) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claude format - content
|
// Text accumulation happens from the translated (client-facing) chunks
|
||||||
if (parsed.delta?.text) {
|
// below via accumulateTranslatedContent, plus accumulateResponsesEvent
|
||||||
totalContentLength += parsed.delta.text.length;
|
// in the same-format Responses passthrough branch. Reading only the
|
||||||
accumulatedContent += parsed.delta.text;
|
// provider shape here missed formats like OpenAI Responses and left
|
||||||
}
|
// Raw request details as "[Empty streaming response]".
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract usage
|
// Extract usage
|
||||||
const extracted = extractUsage(parsed);
|
const extracted = extractUsage(parsed);
|
||||||
@@ -322,6 +353,8 @@ export function createSSEStream(options = {}) {
|
|||||||
|
|
||||||
// Responses same-format passthrough: re-emit with original event framing
|
// Responses same-format passthrough: re-emit with original event framing
|
||||||
if (keepsOpenAIResponsesFormat && openAIResponsesEventName) {
|
if (keepsOpenAIResponsesFormat && openAIResponsesEventName) {
|
||||||
|
// Same-format Responses streams skip translation — accumulate here.
|
||||||
|
accumulateResponsesEvent(openAIResponsesEventName, parsed);
|
||||||
const output = formatSSE({ event: openAIResponsesEventName, data: parsed }, sourceFormat);
|
const output = formatSSE({ event: openAIResponsesEventName, data: parsed }, sourceFormat);
|
||||||
reqLogger?.appendConvertedChunk?.(output);
|
reqLogger?.appendConvertedChunk?.(output);
|
||||||
controller.enqueue(sharedEncoder.encode(output));
|
controller.enqueue(sharedEncoder.encode(output));
|
||||||
@@ -348,6 +381,9 @@ export function createSSEStream(options = {}) {
|
|||||||
if (translated?.length > 0) {
|
if (translated?.length > 0) {
|
||||||
for (const item of translated) {
|
for (const item of translated) {
|
||||||
if (item === null || item === undefined) continue;
|
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
|
// Filter empty chunks
|
||||||
if (!hasValuableContent(item, sourceFormat)) {
|
if (!hasValuableContent(item, sourceFormat)) {
|
||||||
continue; // Skip this empty chunk
|
continue; // Skip this empty chunk
|
||||||
@@ -436,6 +472,8 @@ export function createSSEStream(options = {}) {
|
|||||||
if (translated?.length > 0) {
|
if (translated?.length > 0) {
|
||||||
for (const item of translated) {
|
for (const item of translated) {
|
||||||
if (item === null || item === undefined) continue;
|
if (item === null || item === undefined) continue;
|
||||||
|
// Buffer-remainder chunk may still carry text.
|
||||||
|
accumulateTranslatedContent(item);
|
||||||
const output = formatSSE(item, sourceFormat);
|
const output = formatSSE(item, sourceFormat);
|
||||||
reqLogger?.appendConvertedChunk?.(output);
|
reqLogger?.appendConvertedChunk?.(output);
|
||||||
controller.enqueue(sharedEncoder.encode(output));
|
controller.enqueue(sharedEncoder.encode(output));
|
||||||
@@ -456,6 +494,8 @@ export function createSSEStream(options = {}) {
|
|||||||
if (flushed?.length > 0) {
|
if (flushed?.length > 0) {
|
||||||
for (const item of flushed) {
|
for (const item of flushed) {
|
||||||
if (item === null || item === undefined) continue;
|
if (item === null || item === undefined) continue;
|
||||||
|
// Include flush-synthesized chunks (usually finish/usage only).
|
||||||
|
accumulateTranslatedContent(item);
|
||||||
const output = formatSSE(item, sourceFormat);
|
const output = formatSSE(item, sourceFormat);
|
||||||
reqLogger?.appendConvertedChunk?.(output);
|
reqLogger?.appendConvertedChunk?.(output);
|
||||||
controller.enqueue(sharedEncoder.encode(output));
|
controller.enqueue(sharedEncoder.encode(output));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Card from "@/shared/components/Card";
|
|||||||
import Button from "@/shared/components/Button";
|
import Button from "@/shared/components/Button";
|
||||||
import Drawer from "@/shared/components/Drawer";
|
import Drawer from "@/shared/components/Drawer";
|
||||||
import Pagination from "@/shared/components/Pagination";
|
import Pagination from "@/shared/components/Pagination";
|
||||||
|
import RawDetailModal from "@/shared/components/RawDetailModal";
|
||||||
import { cn } from "@/shared/utils/cn";
|
import { cn } from "@/shared/utils/cn";
|
||||||
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
|
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
|
||||||
|
|
||||||
@@ -110,6 +111,8 @@ export default function RequestDetailsTab() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selectedDetail, setSelectedDetail] = useState(null);
|
const [selectedDetail, setSelectedDetail] = useState(null);
|
||||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
|
const [rawDetailId, setRawDetailId] = useState(null);
|
||||||
|
const [isRawModalOpen, setIsRawModalOpen] = useState(false);
|
||||||
const [providers, setProviders] = useState([]);
|
const [providers, setProviders] = useState([]);
|
||||||
const [providerNameCache, setProviderNameCache] = useState(null);
|
const [providerNameCache, setProviderNameCache] = useState(null);
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
@@ -452,6 +455,7 @@ export default function RequestDetailsTab() {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4 text-center">
|
<td className="p-4 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -459,6 +463,18 @@ export default function RequestDetailsTab() {
|
|||||||
>
|
>
|
||||||
Detail
|
Detail
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setRawDetailId(detail.id);
|
||||||
|
setIsRawModalOpen(true);
|
||||||
|
}}
|
||||||
|
title="Open full raw payloads (bypasses redaction)"
|
||||||
|
>
|
||||||
|
Raw
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
@@ -642,6 +658,13 @@ export default function RequestDetailsTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<RawDetailModal
|
||||||
|
isOpen={isRawModalOpen}
|
||||||
|
onClose={() => setIsRawModalOpen(false)}
|
||||||
|
detailId={rawDetailId}
|
||||||
|
fallbackDetail={selectedDetail?.id === rawDetailId ? selectedDetail : null}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/app/api/usage/request-details/raw/route.js
Normal file
45
src/app/api/usage/request-details/raw/route.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getRequestDetailById } from "@/lib/usageDb";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/usage/request-details/raw?id=<detailId>
|
||||||
|
*
|
||||||
|
* 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,22 +50,12 @@ export async function GET(request) {
|
|||||||
|
|
||||||
const result = await getRequestDetails(filter);
|
const result = await getRequestDetails(filter);
|
||||||
|
|
||||||
// Redact conversation payloads: the stored details include full request
|
// Return stored detail rows as-is. Operators need access to the full
|
||||||
// bodies (user prompts, tool calls) and provider responses. Returning them
|
// request / providerRequest / providerResponse / response payloads when
|
||||||
// wholesale lets any dashboard-authenticated user (or, if requireLogin is
|
// debugging token accounting, SSE format issues, free-tier upstream
|
||||||
// disabled, anyone) read every user's conversation history. Keep the
|
// behavior, etc. Access to this endpoint is already gated by the
|
||||||
// metadata (model, tokens, latency, status) but drop message content.
|
// dashboard auth layer (REQUIRE_LOGIN) — do not expose it publicly.
|
||||||
const redactedDetails = (result.details || []).map((d) => {
|
return NextResponse.json(result);
|
||||||
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 });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[API] Failed to get request details:", error);
|
console.error("[API] Failed to get request details:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
191
src/shared/components/RawDetailModal.js
Normal file
191
src/shared/components/RawDetailModal.js
Normal file
@@ -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=<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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={header ? `Raw Request Detail · ${header}` : "Raw Request Detail"}
|
||||||
|
size="xl"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3" style={{ maxHeight: "70vh" }}>
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-12 text-text-muted text-sm">
|
||||||
|
Loading raw payload…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && error && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/40 dark:text-red-300">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && detail && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyAll}
|
||||||
|
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-muted hover:bg-black/5 hover:text-text-main dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[14px]">
|
||||||
|
{copiedKey === "__all__" ? "check" : "content_copy"}
|
||||||
|
</span>
|
||||||
|
{copiedKey === "__all__" ? "Copied all" : "Copy all (JSON)"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 overflow-y-auto pr-1">
|
||||||
|
{SECTIONS.map((s) => {
|
||||||
|
const value = detail[s.key];
|
||||||
|
const isEmpty = value === null || value === undefined;
|
||||||
|
const text = renderPayload(value) || "";
|
||||||
|
return (
|
||||||
|
<div key={s.key} className="border border-black/5 dark:border-white/5 rounded-lg overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between bg-black/[0.02] dark:bg-white/[0.02] px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="material-symbols-outlined text-[18px] text-text-muted">{s.icon}</span>
|
||||||
|
<span className="font-semibold text-sm text-text-main">{s.label}</span>
|
||||||
|
</div>
|
||||||
|
{!isEmpty && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopy(s.key, value)}
|
||||||
|
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-muted hover:bg-black/5 hover:text-text-main dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[14px]">
|
||||||
|
{copiedKey === s.key ? "check" : "content_copy"}
|
||||||
|
</span>
|
||||||
|
{copiedKey === s.key ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-3 border-t border-black/5 dark:border-white/5">
|
||||||
|
{isEmpty ? (
|
||||||
|
<div className="text-text-muted text-sm italic">No data</div>
|
||||||
|
) : (
|
||||||
|
<pre className={cn(
|
||||||
|
"max-h-[260px] max-w-full overflow-auto rounded font-mono text-xs",
|
||||||
|
"bg-black/5 dark:bg-white/5 p-3",
|
||||||
|
"text-text-main"
|
||||||
|
)}>
|
||||||
|
{text}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
RawDetailModal.propTypes = {
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
detailId: PropTypes.string,
|
||||||
|
fallbackDetail: PropTypes.object,
|
||||||
|
};
|
||||||
@@ -1,21 +1,14 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
// Mirror the redaction logic from src/app/api/usage/request-details/route.js
|
// Mirrors src/app/api/usage/request-details/route.js after redaction removal.
|
||||||
// so we can test it in isolation.
|
// The endpoint now returns stored detail rows verbatim so operators can see
|
||||||
function redactDetails(details) {
|
// the full request / providerRequest / providerResponse / response payloads.
|
||||||
return (details || []).map((d) => {
|
function listResponse(details) {
|
||||||
const redacted = { ...d };
|
return { details: details || [], pagination: { page: 1, pageSize: 20, totalItems: (details || []).length, totalPages: 1 } };
|
||||||
for (const key of ["request", "providerRequest", "providerResponse", "response"]) {
|
|
||||||
if (redacted[key] !== undefined) {
|
|
||||||
redacted[key] = { redacted: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return redacted;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("request-details redaction", () => {
|
describe("request-details list endpoint", () => {
|
||||||
it("removes conversation payloads but keeps metadata", () => {
|
it("returns full conversation payloads for operator debugging", () => {
|
||||||
const details = [{
|
const details = [{
|
||||||
id: "abc",
|
id: "abc",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
@@ -28,27 +21,18 @@ describe("request-details redaction", () => {
|
|||||||
providerResponse: { choices: [{ message: { content: "secret answer" } }] },
|
providerResponse: { choices: [{ message: { content: "secret answer" } }] },
|
||||||
response: { 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.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.tokens).toEqual({ prompt_tokens: 10, completion_tokens: 5 });
|
||||||
expect(out.request).toEqual({ redacted: true });
|
expect(out.request).toEqual({ messages: [{ role: "user", content: "secret prompt" }] });
|
||||||
expect(out.providerRequest).toEqual({ redacted: true });
|
expect(out.providerRequest).toEqual({ messages: [{ role: "user", content: "secret prompt" }] });
|
||||||
expect(out.providerResponse).toEqual({ redacted: true });
|
expect(out.providerResponse).toEqual({ choices: [{ message: { content: "secret answer" } }] });
|
||||||
expect(out.response).toEqual({ redacted: true });
|
expect(out.response).toEqual({ content: "secret answer" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles empty details", () => {
|
it("handles empty details", () => {
|
||||||
expect(redactDetails([])).toEqual([]);
|
const out = listResponse([]);
|
||||||
expect(redactDetails(null)).toEqual([]);
|
expect(out.details).toEqual([]);
|
||||||
});
|
expect(out.pagination.totalItems).toBe(0);
|
||||||
|
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
159
tests/unit/stream-accumulate-content.test.js
Normal file
159
tests/unit/stream-accumulate-content.test.js
Normal file
@@ -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...");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user