mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
refactor(mitm): replace inspector utilities clean-room (#11748)
Merged via /merge-batch (v3.8.51 provenance sweep). Boarded and validated together with the batch's other provenance PRs in a combined worktree — full gate suite green. Thank you.
This commit is contained in:
committed by
GitHub
parent
84504e3f1f
commit
3026183120
@@ -1621,7 +1621,7 @@ OmniRoute stands on the shoulders of giants. It started as a fork of **[9router]
|
||||
|
||||
<table>
|
||||
<tr><th align="left">Project</th><th align="center">⭐</th><th align="left">How it inspired OmniRoute</th></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/chouzz/llm-interceptor">llm-interceptor</a></b></td><td align="center">66</td><td>MITM interception/analysis of coding-assistant ↔ LLM traffic — our Traffic Inspector ports its SSE merge, conversation normalization, host passthrough and secret masking. The upstream's complete license text is still under provenance review.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/chouzz/llm-interceptor">llm-interceptor</a></b></td><td align="center">66</td><td>MITM interception/analysis of coding-assistant ↔ LLM traffic informed early Traffic Inspector requirements. Four previously derived modules — SSE merging, conversation normalization, secret masking and header sanitization — have been replaced by independent clean-room implementations based on public protocol standards. The two host-passthrough surfaces (<code>passthrough.ts</code> and <code>_internal/bypass.cjs</code>) remain OmniRoute-internal implementations classified independently; they were not rewritten as part of that replacement.</td></tr>
|
||||
<tr><td nowrap><b><a href="https://github.com/InterceptSuite/ProxyBridge">ProxyBridge</a></b></td><td align="center">5,995</td><td>Transparent per-process proxy routing — inspired our crash-safe MITM teardown, socket idle-timeouts, <code>/proc</code> process attribution and TPROXY capture.</td></tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- **refactor(mitm):** replace four previously derived Traffic Inspector utilities — conversation normalization, SSE merging, secret masking, and header sanitization — with independent clean-room implementations based on public provider schemas, WHATWG SSE, and RFC 6750, while keeping the two independently classified passthrough surfaces unchanged ([#11748](https://github.com/diegosouzapw/OmniRoute/pull/11748)).
|
||||
@@ -172,11 +172,16 @@ When set, configures `undici`'s global dispatcher with the extra CA cert, allowi
|
||||
|
||||
### 2.7 Secret masking (`src/mitm/maskSecrets.ts`)
|
||||
|
||||
Applied to all request bodies and headers **before** they enter the Traffic Inspector buffer or any log:
|
||||
The independent clean-room scanner is applied to request bodies and credential headers
|
||||
**before** they enter the Traffic Inspector buffer or any log. It performs a single linear pass:
|
||||
|
||||
- `sk-` / `ak-` / `pk-` prefixed tokens (OpenAI/Anthropic-style)
|
||||
- `Authorization: Bearer <token>` headers
|
||||
- Generic long tokens (≥40 chars)
|
||||
- RFC 6750 `Authorization: Bearer <token>` credentials, with whole-token precedence
|
||||
- Generic long opaque tokens (≥40 chars), including dotted and padded forms
|
||||
|
||||
`sanitizeHeaders()` lowercases retained names, joins array values deterministically, drops the
|
||||
shared hop-by-hop/framing denylist (including proxy authentication), fully redacts `cookie` and
|
||||
`set-cookie`, and delegates credential values to the scanner.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ This is a substantial subsystem with its own dedicated operator guide — see **
|
||||
| Control | Action |
|
||||
| ---------------- | --------------------------------------------------------------------- |
|
||||
| ⎉ Pause | Stops rendering new requests; "X new" badge accumulates |
|
||||
| 🗑 Clear | Clears the UI list (server buffer is not affected) |
|
||||
| 🗑 Clear | Clears the UI list (server buffer is not affected) |
|
||||
| ⬇ Export .har | Downloads current filtered list as HAR file |
|
||||
| ● Record session | Starts a named recording session |
|
||||
| Profile selector | LLM only / Custom hosts / All |
|
||||
@@ -207,12 +207,18 @@ Custom hosts added via Mode 2 inherit their `kind` from the form input (defaults
|
||||
|
||||
### 4.2 SSE merger (`src/mitm/inspector/sseMerger.ts`)
|
||||
|
||||
**MIT port from [chouzz/llm-interceptor](https://github.com/chouzz/llm-interceptor)**
|
||||
**Independent clean-room implementation.** Event parsing follows the
|
||||
[WHATWG server-sent events algorithm](https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream),
|
||||
while reconstruction follows the public [OpenAI](https://platform.openai.com/docs/api-reference/chat/create),
|
||||
[Anthropic](https://platform.claude.com/docs/en/build-with-claude/streaming), and
|
||||
[Gemini](https://ai.google.dev/api/generate-content#method:-models.streamgeneratecontent)
|
||||
streaming schemas.
|
||||
|
||||
Reconstructs the final assistant message from raw SSE delta events:
|
||||
|
||||
- **Anthropic**: accumulates `content_block_delta` by index; handles `text_delta`, `input_json_delta` (tool calls), `thinking_delta`
|
||||
- **OpenAI**: accumulates `choices[i].delta.content` and `tool_calls` by index
|
||||
- **OpenAI**: accumulates Chat Completions choices/tool calls and Responses API output items
|
||||
by index
|
||||
- **Gemini**: accumulates `candidates[i].content.parts`
|
||||
- **Unknown**: returns raw events as-is
|
||||
|
||||
@@ -220,7 +226,9 @@ The Response tab shows a toggle: **"Raw events ↔ Merged"**.
|
||||
|
||||
### 4.3 Conversation normalizer (`src/mitm/inspector/conversationNormalizer.ts`)
|
||||
|
||||
**MIT port from [chouzz/llm-interceptor](https://github.com/chouzz/llm-interceptor)**
|
||||
**Independent clean-room implementation.** Normalization is defined by local black-box
|
||||
contracts and the public OpenAI, Anthropic, and Gemini message schemas; no upstream
|
||||
implementation source is used.
|
||||
|
||||
Converts OpenAI, Anthropic, and Gemini message formats to a single `NormalizedConversation` before rendering:
|
||||
|
||||
@@ -337,14 +345,14 @@ Export via `GET /api/tools/traffic-inspector/sessions/{id}/export.har` or the
|
||||
|
||||
Traffic Inspector shows **all intercepted HTTPS traffic**, including authorization headers and request bodies. The following controls are in place:
|
||||
|
||||
| Control | Details |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **LOCAL_ONLY** | All routes and the WebSocket endpoint are loopback-only (enforced in `routeGuard.ts` before auth) |
|
||||
| **Secret masking** | `maskSecrets()` applied to all headers and bodies before `TrafficBuffer.push()` — enabled by default (`INSPECTOR_MASK_SECRETS=true`) |
|
||||
| **Body size cap** | Bodies > `INSPECTOR_MAX_BODY_KB` (default 1024 KB) are truncated with `"(truncated for performance)"` notice |
|
||||
| **Sensitive header masking** | `authorization`, `cookie`, `api-key`, `x-api-key`, `proxy-authorization` → `Bearer ***` in Headers tab; "Show secrets" toggle |
|
||||
| **CSP** | Strict Content Security Policy on Traffic Inspector pages to prevent XSS via injected response bodies |
|
||||
| **No persistence by default** | The `TrafficBuffer` is in-memory and lost on server restart. Sessions are persisted only when explicitly recorded |
|
||||
| Control | Details |
|
||||
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **LOCAL_ONLY** | All routes and the WebSocket endpoint are loopback-only (enforced in `routeGuard.ts` before auth) |
|
||||
| **Secret masking** | Linear `maskSecret()` scanner redacts RFC 6750 Bearer credentials, provider-prefixed keys and long opaque tokens before `TrafficBuffer.push()` |
|
||||
| **Body size cap** | Bodies > `INSPECTOR_MAX_BODY_KB` (default 1024 KB) are truncated with `"(truncated for performance)"` notice |
|
||||
| **Header sanitization** | Names are lowercased; framing/hop-by-hop and proxy-auth headers are dropped; cookies are fully redacted; credential values delegate to `maskSecret()` |
|
||||
| **CSP** | Strict Content Security Policy on Traffic Inspector pages to prevent XSS via injected response bodies |
|
||||
| **No persistence by default** | The `TrafficBuffer` is in-memory and lost on server restart. Sessions are persisted only when explicitly recorded |
|
||||
|
||||
### Hard Rules applied
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/**
|
||||
* Conversation normalizer — converts OpenAI / Anthropic / Gemini request +
|
||||
* response payloads into a single provider-agnostic shape.
|
||||
* Provider-neutral conversation projection for Traffic Inspector views.
|
||||
*
|
||||
* MIT — port from https://github.com/chouzz/llm-interceptor (ui/utils.ts)
|
||||
*
|
||||
* Returns `null` for non-LLM requests or payloads we cannot understand —
|
||||
* never throws — so the renderer can fall back to the raw view.
|
||||
* This is an independent implementation of the public OpenAI, Anthropic, and
|
||||
* Gemini payload shapes. It intentionally emits only the normalized block
|
||||
* types consumed by the inspector UI.
|
||||
*/
|
||||
|
||||
import { mergeStream, parseSseStream } from "./sseMerger.ts";
|
||||
@@ -16,435 +14,333 @@ import type {
|
||||
NormalizedTurn,
|
||||
} from "./types.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type NormalizedRole = NormalizedTurn["role"];
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
const WRAPPER_KEYS = ["body", "payload", "data", "request", "requestBody", "response"];
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as JsonRecord)
|
||||
: null;
|
||||
}
|
||||
|
||||
function tryParseJson(value: string | null | undefined): unknown {
|
||||
if (!value) return null;
|
||||
function tryParseJson(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
return JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRole(raw: unknown): NormalizedRole {
|
||||
if (raw === "system" || raw === "user" || raw === "assistant" || raw === "tool") {
|
||||
return raw;
|
||||
}
|
||||
if (raw === "model") return "assistant";
|
||||
if (raw === "function") return "tool";
|
||||
return "user";
|
||||
function hasConversationShape(record: JsonRecord): boolean {
|
||||
return (
|
||||
"messages" in record ||
|
||||
"input" in record ||
|
||||
"contents" in record ||
|
||||
"system" in record ||
|
||||
"systemInstruction" in record ||
|
||||
"choices" in record ||
|
||||
"candidates" in record ||
|
||||
"content" in record ||
|
||||
"output" in record
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI / Anthropic message content can be a string, or an array of blocks.
|
||||
* Returns a list of normalized blocks.
|
||||
*/
|
||||
function blocksFromOpenAiContent(content: unknown): NormalizedBlock[] {
|
||||
if (content == null) return [];
|
||||
if (typeof content === "string") {
|
||||
if (content.length === 0) return [];
|
||||
return [{ type: "text", text: content }];
|
||||
}
|
||||
if (!Array.isArray(content)) return [];
|
||||
const out: NormalizedBlock[] = [];
|
||||
for (const raw of content) {
|
||||
if (typeof raw === "string") {
|
||||
out.push({ type: "text", text: raw });
|
||||
continue;
|
||||
}
|
||||
const block = asRecord(raw);
|
||||
if (!block) continue;
|
||||
const type = block.type;
|
||||
if (type === "text" || type === "output_text") {
|
||||
const text = typeof block.text === "string" ? block.text : "";
|
||||
out.push({ type: "text", text });
|
||||
} else if (type === "input_text") {
|
||||
const text = typeof block.text === "string" ? block.text : "";
|
||||
out.push({ type: "text", text });
|
||||
} else if (type === "tool_use") {
|
||||
out.push({
|
||||
type: "tool_use",
|
||||
id: typeof block.id === "string" ? block.id : "",
|
||||
name: typeof block.name === "string" ? block.name : "",
|
||||
input: block.input ?? {},
|
||||
});
|
||||
} else if (type === "tool_result") {
|
||||
out.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: typeof block.tool_use_id === "string" ? block.tool_use_id : "",
|
||||
content: block.content ?? null,
|
||||
});
|
||||
} else if (typeof block.text === "string") {
|
||||
out.push({ type: "text", text: block.text });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI assistant messages may declare `tool_calls`. Each becomes a
|
||||
* `tool_use` block alongside any text content.
|
||||
*/
|
||||
function appendOpenAiToolCalls(blocks: NormalizedBlock[], toolCalls: unknown): NormalizedBlock[] {
|
||||
if (!Array.isArray(toolCalls)) return blocks;
|
||||
for (const raw of toolCalls) {
|
||||
const tc = asRecord(raw);
|
||||
if (!tc) continue;
|
||||
const fn = asRecord(tc.function) ?? {};
|
||||
let parsedInput: unknown = {};
|
||||
if (typeof fn.arguments === "string") {
|
||||
try {
|
||||
parsedInput = JSON.parse(fn.arguments);
|
||||
} catch {
|
||||
parsedInput = fn.arguments;
|
||||
function unwrapPayload(value: unknown): unknown {
|
||||
let current = tryParseJson(value);
|
||||
for (let depth = 0; depth < 5; depth += 1) {
|
||||
const record = asRecord(current);
|
||||
if (!record || hasConversationShape(record)) return current;
|
||||
let next: unknown = undefined;
|
||||
for (const key of WRAPPER_KEYS) {
|
||||
if (record[key] !== undefined) {
|
||||
next = record[key];
|
||||
break;
|
||||
}
|
||||
} else if (fn.arguments != null) {
|
||||
parsedInput = fn.arguments;
|
||||
}
|
||||
blocks.push({
|
||||
type: "tool_use",
|
||||
id: typeof tc.id === "string" ? tc.id : "",
|
||||
name: typeof fn.name === "string" ? fn.name : "",
|
||||
input: parsedInput,
|
||||
});
|
||||
if (next === undefined) return current;
|
||||
current = tryParseJson(next);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function normalizedRole(value: unknown): NormalizedRole | null {
|
||||
if (value === "system" || value === "user" || value === "assistant" || value === "tool") {
|
||||
return value;
|
||||
}
|
||||
if (value === "model") return "assistant";
|
||||
if (value === "function") return "tool";
|
||||
return null;
|
||||
}
|
||||
|
||||
function textBlock(value: unknown): NormalizedBlock | null {
|
||||
return typeof value === "string" && value.length > 0 ? { type: "text", text: value } : null;
|
||||
}
|
||||
|
||||
function parseArguments(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value ?? {};
|
||||
if (!value.trim()) return {};
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function toolUseBlock(value: JsonRecord): NormalizedBlock | null {
|
||||
const directFunction = asRecord(value.function);
|
||||
const geminiFunction = asRecord(value.functionCall);
|
||||
const source = directFunction ?? geminiFunction ?? value;
|
||||
const name = typeof source.name === "string" ? source.name : null;
|
||||
if (!name) return null;
|
||||
|
||||
const idValue = value.id ?? value.call_id ?? geminiFunction?.id ?? name;
|
||||
const input =
|
||||
source.arguments !== undefined
|
||||
? parseArguments(source.arguments)
|
||||
: source.args !== undefined
|
||||
? source.args
|
||||
: source.input !== undefined
|
||||
? source.input
|
||||
: {};
|
||||
return {
|
||||
type: "tool_use",
|
||||
id: typeof idValue === "string" ? idValue : name,
|
||||
name,
|
||||
input,
|
||||
};
|
||||
}
|
||||
|
||||
function toolResultBlock(value: JsonRecord): NormalizedBlock | null {
|
||||
const geminiResponse = asRecord(value.functionResponse);
|
||||
const idValue =
|
||||
value.tool_use_id ??
|
||||
value.tool_call_id ??
|
||||
value.call_id ??
|
||||
geminiResponse?.id ??
|
||||
geminiResponse?.name;
|
||||
if (typeof idValue !== "string") return null;
|
||||
const content =
|
||||
value.output !== undefined
|
||||
? value.output
|
||||
: value.content !== undefined
|
||||
? value.content
|
||||
: geminiResponse?.response;
|
||||
return { type: "tool_result", tool_use_id: idValue, content };
|
||||
}
|
||||
|
||||
function reasoningBlocks(value: JsonRecord): NormalizedBlock[] {
|
||||
const blocks: NormalizedBlock[] = [];
|
||||
const summary = Array.isArray(value.summary) ? value.summary : [];
|
||||
for (const entry of summary) {
|
||||
const record = asRecord(entry);
|
||||
const block = textBlock(record?.text);
|
||||
if (block) blocks.push(block);
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build NormalizedTurn[] from OpenAI / Anthropic chat messages.
|
||||
*/
|
||||
/** Responses API reasoning items carry `summary: [{type: "summary_text", text}]`. */
|
||||
function reasoningSummaryText(summary: unknown): string {
|
||||
if (!Array.isArray(summary)) return "";
|
||||
const parts: string[] = [];
|
||||
for (const raw of summary) {
|
||||
const block = asRecord(raw);
|
||||
if (block && typeof block.text === "string") parts.push(block.text);
|
||||
function blocksFromPart(value: unknown): NormalizedBlock[] {
|
||||
if (typeof value === "string") {
|
||||
const block = textBlock(value);
|
||||
return block ? [block] : [];
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
const part = asRecord(value);
|
||||
if (!part) return [];
|
||||
|
||||
function turnsFromOpenAiMessages(messages: unknown[]): NormalizedTurn[] {
|
||||
const out: NormalizedTurn[] = [];
|
||||
for (const raw of messages) {
|
||||
const msg = asRecord(raw);
|
||||
if (!msg) continue;
|
||||
|
||||
// Responses API items for tool activity/reasoning carry no `role` at
|
||||
// all — they're distinguished by `type` instead. Handle these before the
|
||||
// role-based branches below, which would otherwise silently drop them
|
||||
// (empty `content`, no `tool_calls`, `normalizeRole(undefined)` defaults
|
||||
// to "user") — the exact gap that made a real OpenClaw request's
|
||||
// function_call/function_call_output items vanish from the Conversation
|
||||
// Context panel entirely (2026-08-06).
|
||||
if (msg.type === "function_call") {
|
||||
let parsedInput: unknown = {};
|
||||
if (typeof msg.arguments === "string") {
|
||||
try {
|
||||
parsedInput = JSON.parse(msg.arguments);
|
||||
} catch {
|
||||
parsedInput = msg.arguments;
|
||||
}
|
||||
} else if (msg.arguments != null) {
|
||||
parsedInput = msg.arguments;
|
||||
}
|
||||
out.push({
|
||||
role: "assistant",
|
||||
blocks: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: typeof msg.call_id === "string" ? msg.call_id : "",
|
||||
name: typeof msg.name === "string" ? msg.name : "",
|
||||
input: parsedInput,
|
||||
},
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (msg.type === "function_call_output") {
|
||||
out.push({
|
||||
role: "tool",
|
||||
blocks: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: typeof msg.call_id === "string" ? msg.call_id : "",
|
||||
content: msg.output ?? null,
|
||||
},
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (msg.type === "reasoning") {
|
||||
const text = reasoningSummaryText(msg.summary);
|
||||
if (!text) continue;
|
||||
out.push({ role: "assistant", blocks: [{ type: "text", text }] });
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = normalizeRole(msg.role);
|
||||
|
||||
if (msg.role === "tool" || msg.role === "function") {
|
||||
const content = msg.content;
|
||||
out.push({
|
||||
role: "tool",
|
||||
blocks: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id:
|
||||
typeof msg.tool_call_id === "string"
|
||||
? msg.tool_call_id
|
||||
: typeof msg.name === "string"
|
||||
? msg.name
|
||||
: "",
|
||||
content,
|
||||
},
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const blocks = blocksFromOpenAiContent(msg.content);
|
||||
if ("tool_calls" in msg) {
|
||||
appendOpenAiToolCalls(blocks, msg.tool_calls);
|
||||
}
|
||||
if (blocks.length === 0 && msg.content == null && !("tool_calls" in msg)) {
|
||||
continue;
|
||||
}
|
||||
out.push({ role, blocks });
|
||||
const type = typeof part.type === "string" ? part.type : "";
|
||||
if (type === "tool_use" || type === "function_call" || part.functionCall) {
|
||||
const block = toolUseBlock(part);
|
||||
return block ? [block] : [];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini contents have a different shape: `[{role, parts: [{text|...}]}]`.
|
||||
*/
|
||||
function turnsFromGeminiContents(contents: unknown[]): NormalizedTurn[] {
|
||||
const out: NormalizedTurn[] = [];
|
||||
for (const raw of contents) {
|
||||
const turn = asRecord(raw);
|
||||
if (!turn) continue;
|
||||
const role = normalizeRole(turn.role);
|
||||
const blocks: NormalizedBlock[] = [];
|
||||
if (Array.isArray(turn.parts)) {
|
||||
for (const partRaw of turn.parts) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
if (typeof part.text === "string") {
|
||||
blocks.push({ type: "text", text: part.text });
|
||||
} else if (part.functionCall) {
|
||||
const fc = asRecord(part.functionCall) ?? {};
|
||||
blocks.push({
|
||||
type: "tool_use",
|
||||
id: typeof fc.name === "string" ? fc.name : "",
|
||||
name: typeof fc.name === "string" ? fc.name : "",
|
||||
input: fc.args ?? {},
|
||||
});
|
||||
} else if (part.functionResponse) {
|
||||
const fr = asRecord(part.functionResponse) ?? {};
|
||||
blocks.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: typeof fr.name === "string" ? fr.name : "",
|
||||
content: fr.response ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (blocks.length > 0) out.push({ role, blocks });
|
||||
if (type === "tool_result" || type === "function_call_output" || part.functionResponse) {
|
||||
const block = toolResultBlock(part);
|
||||
return block ? [block] : [];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic Messages API requests carry a top-level `system` field (string
|
||||
* or array of `{type:"text"|text}` blocks). Convert to a `system` turn.
|
||||
*/
|
||||
function systemTurnFromAnthropic(system: unknown): NormalizedTurn | null {
|
||||
if (!system) return null;
|
||||
if (typeof system === "string") {
|
||||
return system.length === 0
|
||||
? null
|
||||
: { role: "system", blocks: [{ type: "text", text: system }] };
|
||||
if (type === "reasoning") return reasoningBlocks(part);
|
||||
if (
|
||||
type === "text" ||
|
||||
type === "input_text" ||
|
||||
type === "output_text" ||
|
||||
type === "summary_text" ||
|
||||
type === ""
|
||||
) {
|
||||
const block = textBlock(part.text);
|
||||
return block ? [block] : [];
|
||||
}
|
||||
if (!Array.isArray(system)) return null;
|
||||
const blocks: NormalizedBlock[] = [];
|
||||
for (const raw of system) {
|
||||
const item = asRecord(raw);
|
||||
if (item && typeof item.text === "string") {
|
||||
blocks.push({ type: "text", text: item.text });
|
||||
} else if (typeof raw === "string") {
|
||||
blocks.push({ type: "text", text: raw });
|
||||
}
|
||||
}
|
||||
if (blocks.length === 0) return null;
|
||||
return { role: "system", blocks };
|
||||
}
|
||||
|
||||
export function buildRequestTurns(body: unknown): NormalizedTurn[] | null {
|
||||
const obj = asRecord(body);
|
||||
if (!obj) return null;
|
||||
|
||||
if (Array.isArray(obj.messages)) {
|
||||
const turns: NormalizedTurn[] = [];
|
||||
const systemTurn = systemTurnFromAnthropic(obj.system);
|
||||
if (systemTurn) turns.push(systemTurn);
|
||||
turns.push(...turnsFromOpenAiMessages(obj.messages));
|
||||
return turns;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj.contents)) {
|
||||
const turns: NormalizedTurn[] = [];
|
||||
const sysObj = asRecord(obj.systemInstruction);
|
||||
if (sysObj && Array.isArray(sysObj.parts)) {
|
||||
const parts: NormalizedBlock[] = [];
|
||||
for (const partRaw of sysObj.parts) {
|
||||
const p = asRecord(partRaw);
|
||||
if (p && typeof p.text === "string") parts.push({ type: "text", text: p.text });
|
||||
}
|
||||
if (parts.length > 0) turns.push({ role: "system", blocks: parts });
|
||||
}
|
||||
turns.push(...turnsFromGeminiContents(obj.contents));
|
||||
return turns;
|
||||
}
|
||||
|
||||
if (typeof obj.prompt === "string") {
|
||||
return [{ role: "user", blocks: [{ type: "text", text: obj.prompt }] }];
|
||||
}
|
||||
if (typeof obj.input === "string") {
|
||||
return [{ role: "user", blocks: [{ type: "text", text: obj.input }] }];
|
||||
}
|
||||
if (Array.isArray(obj.input)) {
|
||||
return turnsFromOpenAiMessages(obj.input);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSseResponse(req: InterceptedRequest): boolean {
|
||||
const accept = req.requestHeaders["accept"] ?? req.requestHeaders["Accept"] ?? "";
|
||||
const ct = req.responseHeaders["content-type"] ?? req.responseHeaders["Content-Type"] ?? "";
|
||||
return (
|
||||
accept.includes("event-stream") ||
|
||||
ct.includes("event-stream") ||
|
||||
/^\s*event:|^\s*data:/m.test(req.responseBody ?? "")
|
||||
);
|
||||
}
|
||||
|
||||
function extractAnthropicResponseTurn(message: unknown): NormalizedTurn | null {
|
||||
const obj = asRecord(message);
|
||||
if (!obj) return null;
|
||||
const content = obj.content;
|
||||
if (!Array.isArray(content)) return null;
|
||||
const blocks: NormalizedBlock[] = [];
|
||||
for (const raw of content) {
|
||||
const block = asRecord(raw);
|
||||
if (!block) continue;
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
blocks.push({ type: "text", text: block.text });
|
||||
} else if (block.type === "tool_use") {
|
||||
blocks.push({
|
||||
type: "tool_use",
|
||||
id: typeof block.id === "string" ? block.id : "",
|
||||
name: typeof block.name === "string" ? block.name : "",
|
||||
input: block.input ?? {},
|
||||
});
|
||||
} else if (block.type === "thinking" && typeof block.thinking === "string") {
|
||||
blocks.push({ type: "text", text: block.thinking });
|
||||
}
|
||||
}
|
||||
if (blocks.length === 0) return null;
|
||||
return { role: "assistant", blocks };
|
||||
}
|
||||
|
||||
function extractOpenAiResponseTurn(message: unknown): NormalizedTurn | null {
|
||||
const obj = asRecord(message);
|
||||
if (!obj || !Array.isArray(obj.choices)) return null;
|
||||
const first = asRecord(obj.choices[0]);
|
||||
if (!first) return null;
|
||||
const msg = asRecord(first.message) ?? asRecord(first.delta);
|
||||
if (!msg) return null;
|
||||
const blocks = blocksFromOpenAiContent(msg.content);
|
||||
if ("tool_calls" in msg) appendOpenAiToolCalls(blocks, msg.tool_calls);
|
||||
if (blocks.length === 0) return null;
|
||||
return { role: "assistant", blocks };
|
||||
}
|
||||
|
||||
function extractGeminiResponseTurn(message: unknown): NormalizedTurn | null {
|
||||
const obj = asRecord(message);
|
||||
if (!obj || !Array.isArray(obj.candidates)) return null;
|
||||
const first = asRecord(obj.candidates[0]);
|
||||
if (!first) return null;
|
||||
const content = asRecord(first.content);
|
||||
if (!content || !Array.isArray(content.parts)) return null;
|
||||
const blocks: NormalizedBlock[] = [];
|
||||
for (const partRaw of content.parts) {
|
||||
const part = asRecord(partRaw);
|
||||
if (!part) continue;
|
||||
if (typeof part.text === "string") {
|
||||
blocks.push({ type: "text", text: part.text });
|
||||
} else if (part.functionCall) {
|
||||
const fc = asRecord(part.functionCall) ?? {};
|
||||
blocks.push({
|
||||
type: "tool_use",
|
||||
id: typeof fc.name === "string" ? fc.name : "",
|
||||
name: typeof fc.name === "string" ? fc.name : "",
|
||||
input: fc.args ?? {},
|
||||
});
|
||||
}
|
||||
}
|
||||
if (blocks.length === 0) return null;
|
||||
return { role: "assistant", blocks };
|
||||
}
|
||||
|
||||
export function buildResponseTurns(req: InterceptedRequest): NormalizedTurn[] {
|
||||
const raw = req.responseBody ?? "";
|
||||
if (!raw) return [];
|
||||
|
||||
let payload: unknown = null;
|
||||
|
||||
if (isSseResponse(req)) {
|
||||
const merged = mergeStream(parseSseStream(raw));
|
||||
payload = merged.message ?? null;
|
||||
} else {
|
||||
payload = tryParseJson(raw);
|
||||
}
|
||||
|
||||
if (!payload) return [];
|
||||
|
||||
const anth = extractAnthropicResponseTurn(payload);
|
||||
if (anth) return [anth];
|
||||
const oai = extractOpenAiResponseTurn(payload);
|
||||
if (oai) return [oai];
|
||||
const gem = extractGeminiResponseTurn(payload);
|
||||
if (gem) return [gem];
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an intercepted LLM request + response into a provider-agnostic
|
||||
* conversation. Returns `null` for non-LLM requests or unparseable payloads.
|
||||
*/
|
||||
function blocksFromContent(value: unknown): NormalizedBlock[] {
|
||||
if (Array.isArray(value)) return value.flatMap(blocksFromPart);
|
||||
return blocksFromPart(value);
|
||||
}
|
||||
|
||||
function turnFromMessage(value: unknown): NormalizedTurn | null {
|
||||
const message = asRecord(value);
|
||||
if (!message) return null;
|
||||
const type = typeof message.type === "string" ? message.type : "";
|
||||
|
||||
if (type === "function_call") {
|
||||
const block = toolUseBlock(message);
|
||||
return block ? { role: "assistant", blocks: [block] } : null;
|
||||
}
|
||||
if (type === "function_call_output") {
|
||||
const block = toolResultBlock(message);
|
||||
return block ? { role: "tool", blocks: [block] } : null;
|
||||
}
|
||||
if (type === "reasoning") {
|
||||
const blocks = reasoningBlocks(message);
|
||||
return blocks.length > 0 ? { role: "assistant", blocks } : null;
|
||||
}
|
||||
|
||||
const role = normalizedRole(message.role);
|
||||
if (!role) return null;
|
||||
if (role === "tool") {
|
||||
const block = toolResultBlock(message);
|
||||
return block ? { role, blocks: [block] } : null;
|
||||
}
|
||||
|
||||
const blocks = blocksFromContent(message.content ?? message.parts);
|
||||
for (const rawCall of Array.isArray(message.tool_calls) ? message.tool_calls : []) {
|
||||
const call = asRecord(rawCall);
|
||||
const block = call ? toolUseBlock(call) : null;
|
||||
if (block) blocks.push(block);
|
||||
}
|
||||
const legacyCall = asRecord(message.function_call);
|
||||
if (legacyCall) {
|
||||
const block = toolUseBlock({
|
||||
id: message.tool_call_id ?? legacyCall.name,
|
||||
function: legacyCall,
|
||||
});
|
||||
if (block) blocks.push(block);
|
||||
}
|
||||
if (blocks.length === 0) return null;
|
||||
const projectedRole =
|
||||
role === "user" && blocks.every((block) => block.type === "tool_result") ? "tool" : role;
|
||||
return { role: projectedRole, blocks };
|
||||
}
|
||||
|
||||
function turnsFromItems(items: unknown[]): NormalizedTurn[] {
|
||||
const turns: NormalizedTurn[] = [];
|
||||
for (const item of items) {
|
||||
const turn = turnFromMessage(item);
|
||||
if (turn) turns.push(turn);
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
function systemTurn(value: unknown): NormalizedTurn | null {
|
||||
const record = asRecord(value);
|
||||
const content = record?.parts ?? value;
|
||||
const blocks = blocksFromContent(content);
|
||||
return blocks.length > 0 ? { role: "system", blocks } : null;
|
||||
}
|
||||
|
||||
/** Normalize a decoded request payload, or a JSON string/wrapper containing one. */
|
||||
export function buildRequestTurns(body: unknown): NormalizedTurn[] | null {
|
||||
const payload = asRecord(unwrapPayload(body));
|
||||
if (!payload) {
|
||||
const block = textBlock(typeof body === "string" ? body : null);
|
||||
return block ? [{ role: "user", blocks: [block] }] : null;
|
||||
}
|
||||
|
||||
const turns: NormalizedTurn[] = [];
|
||||
const topLevelSystem = payload.systemInstruction ?? payload.system ?? payload.instructions;
|
||||
if (topLevelSystem !== undefined) {
|
||||
const turn = systemTurn(topLevelSystem);
|
||||
if (turn) turns.push(turn);
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.messages)) turns.push(...turnsFromItems(payload.messages));
|
||||
if (Array.isArray(payload.contents)) turns.push(...turnsFromItems(payload.contents));
|
||||
if (Array.isArray(payload.input)) turns.push(...turnsFromItems(payload.input));
|
||||
if (typeof payload.input === "string") {
|
||||
const block = textBlock(payload.input);
|
||||
if (block) turns.push({ role: "user", blocks: [block] });
|
||||
}
|
||||
|
||||
return turns.length > 0 ? turns : null;
|
||||
}
|
||||
|
||||
function turnFromAnthropicResponse(payload: JsonRecord): NormalizedTurn | null {
|
||||
if (!Array.isArray(payload.content)) return null;
|
||||
const blocks = blocksFromContent(payload.content);
|
||||
return blocks.length > 0 ? { role: "assistant", blocks } : null;
|
||||
}
|
||||
|
||||
function turnsFromOpenAiResponse(payload: JsonRecord): NormalizedTurn[] {
|
||||
const turns: NormalizedTurn[] = [];
|
||||
for (const rawChoice of Array.isArray(payload.choices) ? payload.choices : []) {
|
||||
const choice = asRecord(rawChoice);
|
||||
const turn = choice ? turnFromMessage(choice.message ?? choice.delta) : null;
|
||||
if (turn) turns.push(turn);
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
function turnsFromGeminiResponse(payload: JsonRecord): NormalizedTurn[] {
|
||||
const turns: NormalizedTurn[] = [];
|
||||
for (const rawCandidate of Array.isArray(payload.candidates) ? payload.candidates : []) {
|
||||
const candidate = asRecord(rawCandidate);
|
||||
const content = asRecord(candidate?.content);
|
||||
const blocks = blocksFromContent(content?.parts);
|
||||
if (blocks.length > 0) turns.push({ role: "assistant", blocks });
|
||||
}
|
||||
return turns;
|
||||
}
|
||||
|
||||
function turnsFromResponsesApi(payload: JsonRecord): NormalizedTurn[] {
|
||||
return turnsFromItems(Array.isArray(payload.output) ? payload.output : []);
|
||||
}
|
||||
|
||||
function isSseResponse(req: InterceptedRequest): boolean {
|
||||
const contentType =
|
||||
req.responseHeaders["content-type"] ?? req.responseHeaders["Content-Type"] ?? "";
|
||||
const body = req.responseBody ?? "";
|
||||
return (
|
||||
contentType.toLowerCase().includes("text/event-stream") ||
|
||||
body.startsWith("data:") ||
|
||||
body.startsWith("event:") ||
|
||||
body.includes("\ndata:") ||
|
||||
body.includes("\nevent:")
|
||||
);
|
||||
}
|
||||
|
||||
/** Normalize the response side of an intercepted request. */
|
||||
export function buildResponseTurns(req: InterceptedRequest): NormalizedTurn[] {
|
||||
if (!req.responseBody) return [];
|
||||
let decoded: unknown;
|
||||
if (isSseResponse(req)) {
|
||||
decoded = mergeStream(parseSseStream(req.responseBody)).message;
|
||||
} else {
|
||||
decoded = unwrapPayload(req.responseBody);
|
||||
}
|
||||
const payload = asRecord(unwrapPayload(decoded));
|
||||
if (!payload) return [];
|
||||
|
||||
const turns = turnsFromOpenAiResponse(payload);
|
||||
turns.push(...turnsFromGeminiResponse(payload));
|
||||
turns.push(...turnsFromResponsesApi(payload));
|
||||
const anthropic = turnFromAnthropicResponse(payload);
|
||||
if (anthropic) turns.push(anthropic);
|
||||
if (turns.length > 0) return turns;
|
||||
|
||||
const nestedMessage = turnFromMessage(payload.message);
|
||||
return nestedMessage ? [nestedMessage] : [];
|
||||
}
|
||||
|
||||
export function normalizeConversation(req: InterceptedRequest): NormalizedConversation | null {
|
||||
if (req.detectedKind !== "llm") return null;
|
||||
|
||||
const requestBody = tryParseJson(req.requestBody);
|
||||
const requestTurns = buildRequestTurns(requestBody);
|
||||
if (!requestTurns) return null;
|
||||
|
||||
const responseTurns = buildResponseTurns(req);
|
||||
|
||||
if (req.detectedKind !== undefined && req.detectedKind !== "llm") return null;
|
||||
const request = buildRequestTurns(req.requestBody);
|
||||
if (!request || request.length === 0) return null;
|
||||
return {
|
||||
request: requestTurns,
|
||||
response: responseTurns,
|
||||
request,
|
||||
response: buildResponseTurns(req),
|
||||
contextKey: req.contextKey ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
/**
|
||||
* SSE merger — reconstructs complete LLM response from streaming SSE chunks.
|
||||
* Server-sent event parsing and provider response reconstruction.
|
||||
*
|
||||
* MIT — port from https://github.com/chouzz/llm-interceptor (merger.py)
|
||||
*
|
||||
* Detects API format by chunk shape (not URL — robust to URL rewrite) and
|
||||
* rebuilds Anthropic / OpenAI / Gemini responses. Falls back to a raw event
|
||||
* list when the format is unrecognised so the caller never crashes.
|
||||
* This module is an independent implementation based on the WHATWG event-stream
|
||||
* algorithm and the public OpenAI, Anthropic, and Gemini streaming schemas.
|
||||
*/
|
||||
|
||||
export type ApiFormat = "anthropic" | "openai" | "gemini" | "unknown";
|
||||
@@ -23,294 +20,475 @@ export interface MergedResponse {
|
||||
raw?: SseEvent[];
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
interface AnthropicBlockState {
|
||||
block: JsonRecord;
|
||||
partialInput: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect chunk shapes to determine the upstream API. Matches on the first
|
||||
* recognisable hint; returns `"unknown"` if none match.
|
||||
*/
|
||||
export function detectApiFormat(chunks: SseEvent[]): ApiFormat {
|
||||
for (const c of chunks) {
|
||||
const j = asRecord(c.json);
|
||||
if (!j) continue;
|
||||
if (j.type === "message_start" || j.type === "content_block_delta") return "anthropic";
|
||||
if (Array.isArray(j.choices)) {
|
||||
const first = j.choices[0];
|
||||
if (first && typeof first === "object" && "delta" in first) return "openai";
|
||||
interface OpenAiToolState {
|
||||
value: JsonRecord;
|
||||
functionValue: JsonRecord;
|
||||
}
|
||||
|
||||
interface OpenAiChoiceState {
|
||||
value: JsonRecord;
|
||||
message: JsonRecord;
|
||||
tools: Map<number, OpenAiToolState>;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as JsonRecord)
|
||||
: null;
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function asIndex(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function appendString(record: JsonRecord, key: string, value: unknown): void {
|
||||
if (typeof value !== "string") return;
|
||||
const current = typeof record[key] === "string" ? record[key] : "";
|
||||
record[key] = current + value;
|
||||
}
|
||||
|
||||
function mergeRecord(previous: unknown, next: unknown): JsonRecord {
|
||||
return { ...(asRecord(previous) ?? {}), ...(asRecord(next) ?? {}) };
|
||||
}
|
||||
|
||||
function dispatchSseEvent(
|
||||
events: SseEvent[],
|
||||
dataLines: string[],
|
||||
sawDataField: boolean,
|
||||
eventName: string
|
||||
): void {
|
||||
if (!sawDataField) return;
|
||||
|
||||
const data = dataLines.join("\n");
|
||||
const event: SseEvent = { data };
|
||||
if (eventName) event.event = eventName;
|
||||
|
||||
if (data !== "[DONE]") {
|
||||
try {
|
||||
event.json = JSON.parse(data) as unknown;
|
||||
} catch {
|
||||
// Raw data is still useful to callers when a provider emits a sentinel or malformed JSON.
|
||||
}
|
||||
if (Array.isArray(j.candidates)) return "gemini";
|
||||
}
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
/** Parse a complete `text/event-stream` payload using WHATWG field semantics. */
|
||||
export function parseSseStream(raw: string): SseEvent[] {
|
||||
const input = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
||||
const events: SseEvent[] = [];
|
||||
let dataLines: string[] = [];
|
||||
let eventName = "";
|
||||
let sawDataField = false;
|
||||
let lineStart = 0;
|
||||
|
||||
const processLine = (line: string): void => {
|
||||
if (line.length === 0) {
|
||||
dispatchSseEvent(events, dataLines, sawDataField, eventName);
|
||||
dataLines = [];
|
||||
eventName = "";
|
||||
sawDataField = false;
|
||||
return;
|
||||
}
|
||||
if (line.startsWith(":")) return;
|
||||
|
||||
const colon = line.indexOf(":");
|
||||
const field = colon === -1 ? line : line.slice(0, colon);
|
||||
let value = colon === -1 ? "" : line.slice(colon + 1);
|
||||
if (value.startsWith(" ")) value = value.slice(1);
|
||||
|
||||
if (field === "data") {
|
||||
dataLines.push(value);
|
||||
sawDataField = true;
|
||||
} else if (field === "event") {
|
||||
eventName = value;
|
||||
}
|
||||
// `id`, `retry`, comments, and extension fields do not alter the public event shape.
|
||||
};
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const code = input.charCodeAt(index);
|
||||
if (code !== 0x0a && code !== 0x0d) continue;
|
||||
|
||||
processLine(input.slice(lineStart, index));
|
||||
if (code === 0x0d && input.charCodeAt(index + 1) === 0x0a) index += 1;
|
||||
lineStart = index + 1;
|
||||
}
|
||||
|
||||
// WHATWG does not dispatch an event that lacks its terminating blank line.
|
||||
return events;
|
||||
}
|
||||
|
||||
export function detectApiFormat(chunks: SseEvent[]): ApiFormat {
|
||||
for (const chunk of chunks) {
|
||||
const namedEvent = chunk.event ?? "";
|
||||
if (
|
||||
namedEvent === "message_start" ||
|
||||
namedEvent === "message_delta" ||
|
||||
namedEvent === "message_stop" ||
|
||||
namedEvent.startsWith("content_block_")
|
||||
) {
|
||||
return "anthropic";
|
||||
}
|
||||
const payload = asRecord(chunk.json);
|
||||
if (!payload) continue;
|
||||
|
||||
const type = typeof payload.type === "string" ? payload.type : "";
|
||||
if (
|
||||
type === "message_start" ||
|
||||
type === "message_delta" ||
|
||||
type === "message_stop" ||
|
||||
type.startsWith("content_block_")
|
||||
) {
|
||||
return "anthropic";
|
||||
}
|
||||
if (Array.isArray(payload.choices) || type.startsWith("response.")) return "openai";
|
||||
if (Array.isArray(payload.candidates) || asRecord(payload.usageMetadata)) return "gemini";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw SSE stream (the response body string captured by the proxy)
|
||||
* into discrete events. Empty blocks and `[DONE]` terminators are skipped
|
||||
* silently; malformed JSON payloads are kept as raw `data` (no `json`).
|
||||
*/
|
||||
export function parseSseStream(raw: string): SseEvent[] {
|
||||
const events: SseEvent[] = [];
|
||||
if (!raw) return events;
|
||||
// SSE blocks separated by blank lines — accept both LF and CRLF.
|
||||
for (const block of raw.split(/\r?\n\r?\n/)) {
|
||||
if (!block.trim()) continue;
|
||||
const ev: SseEvent = {};
|
||||
for (const line of block.split(/\r?\n/)) {
|
||||
if (line.startsWith("event:")) {
|
||||
ev.event = line.slice(6).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
ev.data = (ev.data ?? "") + line.slice(5).trim();
|
||||
export function rebuildAnthropic(chunks: SseEvent[]): MergedResponse {
|
||||
let message: JsonRecord = { type: "message", role: "assistant", content: [] };
|
||||
const blocks = new Map<number, AnthropicBlockState>();
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const payload = asRecord(chunk.json);
|
||||
if (!payload) continue;
|
||||
const type = typeof payload.type === "string" ? payload.type : "";
|
||||
|
||||
if (type === "message_start") {
|
||||
const startedMessage = asRecord(payload.message);
|
||||
if (startedMessage) {
|
||||
message = { ...startedMessage };
|
||||
for (const [position, value] of asArray(startedMessage.content).entries()) {
|
||||
const block = asRecord(value);
|
||||
if (block) blocks.set(position, { block: { ...block }, partialInput: "" });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ev.data === undefined) continue;
|
||||
if (ev.data === "[DONE]") {
|
||||
events.push(ev);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
ev.json = JSON.parse(ev.data);
|
||||
} catch {
|
||||
// keep raw data only
|
||||
|
||||
const index = asIndex(payload.index, blocks.size);
|
||||
if (type === "content_block_start") {
|
||||
const contentBlock = asRecord(payload.content_block);
|
||||
if (contentBlock) {
|
||||
blocks.set(index, { block: { ...contentBlock }, partialInput: "" });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
events.push(ev);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
interface AnthropicBlock {
|
||||
type: string;
|
||||
text?: string;
|
||||
thinking?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild an Anthropic Messages API response from streaming events.
|
||||
* Handles `text_delta`, `thinking_delta`, and `input_json_delta` deltas;
|
||||
* applies `JSON.parse` (best-effort) on accumulated tool-use input.
|
||||
*/
|
||||
export function rebuildAnthropic(chunks: SseEvent[]): MergedResponse {
|
||||
const blocks: AnthropicBlock[] = [];
|
||||
let message: Record<string, unknown> | null = null;
|
||||
const inputJsonByIndex: Record<number, string> = {};
|
||||
|
||||
for (const c of chunks) {
|
||||
const j = asRecord(c.json);
|
||||
if (!j) continue;
|
||||
const t = j.type;
|
||||
|
||||
if (t === "message_start") {
|
||||
const m = asRecord(j.message);
|
||||
message = m ? { ...m } : {};
|
||||
} else if (t === "content_block_start") {
|
||||
const idx = typeof j.index === "number" ? j.index : blocks.length;
|
||||
const cb = asRecord(j.content_block);
|
||||
const block: AnthropicBlock = { type: "text" };
|
||||
if (cb) {
|
||||
for (const [k, v] of Object.entries(cb)) (block as Record<string, unknown>)[k] = v;
|
||||
if (type === "content_block_delta") {
|
||||
const delta = asRecord(payload.delta);
|
||||
if (!delta) continue;
|
||||
const state = blocks.get(index) ?? { block: {}, partialInput: "" };
|
||||
const deltaType = typeof delta.type === "string" ? delta.type : "";
|
||||
if (deltaType === "text_delta") appendString(state.block, "text", delta.text);
|
||||
if (deltaType === "thinking_delta") appendString(state.block, "thinking", delta.thinking);
|
||||
if (deltaType === "signature_delta") {
|
||||
appendString(state.block, "signature", delta.signature);
|
||||
}
|
||||
if (block.type === "text" && block.text === undefined) block.text = "";
|
||||
if (block.type === "thinking" && block.thinking === undefined) block.thinking = "";
|
||||
if (block.type === "tool_use" && block.input === undefined) block.input = {};
|
||||
blocks[idx] = block;
|
||||
} else if (t === "content_block_delta") {
|
||||
const idx = typeof j.index === "number" ? j.index : 0;
|
||||
const d = asRecord(j.delta);
|
||||
if (!d) continue;
|
||||
// Ensure a block slot exists (some streams skip content_block_start).
|
||||
const slot = blocks[idx] ?? (blocks[idx] = { type: "text", text: "" });
|
||||
const dType = d.type;
|
||||
if (dType === "text_delta" && typeof d.text === "string") {
|
||||
slot.text = (slot.text ?? "") + d.text;
|
||||
} else if (dType === "thinking_delta" && typeof d.thinking === "string") {
|
||||
slot.thinking = (slot.thinking ?? "") + d.thinking;
|
||||
} else if (dType === "input_json_delta" && typeof d.partial_json === "string") {
|
||||
inputJsonByIndex[idx] = (inputJsonByIndex[idx] ?? "") + d.partial_json;
|
||||
if (deltaType === "input_json_delta" && typeof delta.partial_json === "string") {
|
||||
state.partialInput += delta.partial_json;
|
||||
}
|
||||
} else if (t === "content_block_stop") {
|
||||
const idx = typeof j.index === "number" ? j.index : 0;
|
||||
const slot = blocks[idx];
|
||||
if (slot && slot.type === "tool_use" && inputJsonByIndex[idx]) {
|
||||
blocks.set(index, state);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "content_block_stop") {
|
||||
const state = blocks.get(index);
|
||||
if (state?.partialInput) {
|
||||
try {
|
||||
slot.input = JSON.parse(inputJsonByIndex[idx]);
|
||||
state.block.input = JSON.parse(state.partialInput) as unknown;
|
||||
} catch {
|
||||
// keep accumulated string for forensic visibility
|
||||
slot.input = inputJsonByIndex[idx];
|
||||
state.block.input = state.partialInput;
|
||||
}
|
||||
}
|
||||
} else if (t === "message_delta") {
|
||||
if (!message) message = {};
|
||||
const d = asRecord(j.delta);
|
||||
if (d && typeof d.stop_reason === "string") {
|
||||
message.stop_reason = d.stop_reason;
|
||||
}
|
||||
const usage = asRecord(j.usage);
|
||||
if (usage) {
|
||||
const prev = asRecord(message.usage) ?? {};
|
||||
message.usage = { ...prev, ...usage };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "message_delta") {
|
||||
Object.assign(message, asRecord(payload.delta) ?? {});
|
||||
if (payload.usage !== undefined) {
|
||||
message.usage = mergeRecord(message.usage, payload.usage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filledBlocks = blocks.filter((b) => b !== undefined);
|
||||
return {
|
||||
format: "anthropic",
|
||||
message: { ...(message ?? {}), content: filledBlocks },
|
||||
message.content = [...blocks.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([, state]) => state.block);
|
||||
return { format: "anthropic", message };
|
||||
}
|
||||
|
||||
function getOpenAiChoice(
|
||||
choices: Map<number, OpenAiChoiceState>,
|
||||
index: number
|
||||
): OpenAiChoiceState {
|
||||
const existing = choices.get(index);
|
||||
if (existing) return existing;
|
||||
const created: OpenAiChoiceState = {
|
||||
value: { index },
|
||||
message: { role: "assistant", content: "" },
|
||||
tools: new Map(),
|
||||
};
|
||||
choices.set(index, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
interface OpenAiToolCall {
|
||||
index: number;
|
||||
id?: string;
|
||||
type?: string;
|
||||
function: { name: string; arguments: string };
|
||||
function mergeOpenAiTools(state: OpenAiChoiceState, toolDeltas: unknown[]): void {
|
||||
for (const [position, rawTool] of toolDeltas.entries()) {
|
||||
const tool = asRecord(rawTool);
|
||||
if (!tool) continue;
|
||||
const index = asIndex(tool.index, position);
|
||||
const current = state.tools.get(index) ?? { value: { index }, functionValue: {} };
|
||||
|
||||
for (const [key, value] of Object.entries(tool)) {
|
||||
if (key !== "function" && key !== "index" && value !== undefined) {
|
||||
current.value[key] = value;
|
||||
}
|
||||
}
|
||||
const functionDelta = asRecord(tool.function);
|
||||
if (functionDelta) {
|
||||
if (typeof functionDelta.name === "string") {
|
||||
appendString(current.functionValue, "name", functionDelta.name);
|
||||
}
|
||||
if (typeof functionDelta.arguments === "string") {
|
||||
appendString(current.functionValue, "arguments", functionDelta.arguments);
|
||||
}
|
||||
current.value.function = current.functionValue;
|
||||
}
|
||||
state.tools.set(index, current);
|
||||
}
|
||||
}
|
||||
|
||||
interface OpenAiChoice {
|
||||
index: number;
|
||||
message: {
|
||||
role: string;
|
||||
content: string;
|
||||
tool_calls?: OpenAiToolCall[];
|
||||
refusal?: string;
|
||||
function rebuildOpenAiResponses(chunks: SseEvent[]): JsonRecord {
|
||||
let response: JsonRecord = { object: "response", output: [] };
|
||||
const items = new Map<number, JsonRecord>();
|
||||
const contentByItem = new Map<number, Map<number, JsonRecord>>();
|
||||
|
||||
const getItem = (outputIndex: number): JsonRecord => {
|
||||
const existing = items.get(outputIndex);
|
||||
if (existing) return existing;
|
||||
const created: JsonRecord = { type: "message", role: "assistant", content: [] };
|
||||
items.set(outputIndex, created);
|
||||
return created;
|
||||
};
|
||||
finish_reason: string | null;
|
||||
|
||||
const getPart = (outputIndex: number, contentIndex: number): JsonRecord => {
|
||||
let content = contentByItem.get(outputIndex);
|
||||
if (!content) {
|
||||
content = new Map();
|
||||
contentByItem.set(outputIndex, content);
|
||||
}
|
||||
const existing = content.get(contentIndex);
|
||||
if (existing) return existing;
|
||||
const created: JsonRecord = { type: "output_text", text: "" };
|
||||
content.set(contentIndex, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const mergeItem = (outputIndex: number, rawItem: unknown): void => {
|
||||
const item = asRecord(rawItem);
|
||||
if (!item) return;
|
||||
const current = getItem(outputIndex);
|
||||
for (const [key, value] of Object.entries(item)) {
|
||||
if (key !== "content" && value !== undefined) current[key] = value;
|
||||
}
|
||||
for (const [contentIndex, rawPart] of asArray(item.content).entries()) {
|
||||
const part = asRecord(rawPart);
|
||||
if (part) Object.assign(getPart(outputIndex, contentIndex), part);
|
||||
}
|
||||
};
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const payload = asRecord(chunk.json);
|
||||
if (!payload) continue;
|
||||
const embeddedResponse = asRecord(payload.response);
|
||||
if (embeddedResponse) {
|
||||
for (const [key, value] of Object.entries(embeddedResponse)) {
|
||||
if (key !== "output" && value !== undefined) response[key] = value;
|
||||
}
|
||||
for (const [outputIndex, item] of asArray(embeddedResponse.output).entries()) {
|
||||
mergeItem(outputIndex, item);
|
||||
}
|
||||
}
|
||||
|
||||
const outputIndex = asIndex(payload.output_index, 0);
|
||||
const contentIndex = asIndex(payload.content_index, 0);
|
||||
if (
|
||||
payload.type === "response.output_item.added" ||
|
||||
payload.type === "response.output_item.done"
|
||||
) {
|
||||
mergeItem(outputIndex, payload.item);
|
||||
}
|
||||
if (
|
||||
payload.type === "response.content_part.added" ||
|
||||
payload.type === "response.content_part.done"
|
||||
) {
|
||||
const part = asRecord(payload.part);
|
||||
if (part) Object.assign(getPart(outputIndex, contentIndex), part);
|
||||
}
|
||||
if (payload.type === "response.output_text.delta") {
|
||||
appendString(getPart(outputIndex, contentIndex), "text", payload.delta);
|
||||
}
|
||||
if (payload.type === "response.output_text.done" && typeof payload.text === "string") {
|
||||
getPart(outputIndex, contentIndex).text = payload.text;
|
||||
}
|
||||
if (payload.type === "response.refusal.delta") {
|
||||
const part = getPart(outputIndex, contentIndex);
|
||||
part.type = "refusal";
|
||||
appendString(part, "refusal", payload.delta);
|
||||
}
|
||||
if (payload.type === "response.refusal.done" && typeof payload.refusal === "string") {
|
||||
const part = getPart(outputIndex, contentIndex);
|
||||
part.type = "refusal";
|
||||
part.refusal = payload.refusal;
|
||||
}
|
||||
if (payload.type === "response.function_call_arguments.delta") {
|
||||
const item = getItem(outputIndex);
|
||||
item.type = "function_call";
|
||||
appendString(item, "arguments", payload.delta);
|
||||
}
|
||||
if (
|
||||
payload.type === "response.function_call_arguments.done" &&
|
||||
typeof payload.arguments === "string"
|
||||
) {
|
||||
const item = getItem(outputIndex);
|
||||
item.type = "function_call";
|
||||
item.arguments = payload.arguments;
|
||||
}
|
||||
}
|
||||
|
||||
response.output = [...items.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([outputIndex, item]) => {
|
||||
const content = contentByItem.get(outputIndex);
|
||||
if (content && content.size > 0) {
|
||||
item.content = [...content.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([, part]) => part);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild an OpenAI Chat Completions response from streaming events.
|
||||
* Accumulates content text and tool-call fragments per choice/index.
|
||||
*/
|
||||
export function rebuildOpenAI(chunks: SseEvent[]): MergedResponse {
|
||||
const choicesByIdx: Record<number, OpenAiChoice> = {};
|
||||
let model: string | null = null;
|
||||
let usage: unknown = null;
|
||||
let id: string | null = null;
|
||||
const hasChatChunks = chunks.some((chunk) => Array.isArray(asRecord(chunk.json)?.choices));
|
||||
if (!hasChatChunks) {
|
||||
return { format: "openai", message: rebuildOpenAiResponses(chunks) };
|
||||
}
|
||||
|
||||
for (const c of chunks) {
|
||||
const j = asRecord(c.json);
|
||||
if (!j) continue;
|
||||
if (typeof j.model === "string") model = j.model;
|
||||
if (typeof j.id === "string") id = j.id;
|
||||
if (j.usage != null) usage = j.usage;
|
||||
if (!Array.isArray(j.choices)) continue;
|
||||
const result: JsonRecord = {};
|
||||
const choices = new Map<number, OpenAiChoiceState>();
|
||||
|
||||
for (const raw of j.choices) {
|
||||
const ch = asRecord(raw);
|
||||
if (!ch) continue;
|
||||
const idx = typeof ch.index === "number" ? ch.index : 0;
|
||||
const slot = (choicesByIdx[idx] ??= {
|
||||
index: idx,
|
||||
message: { role: "assistant", content: "" },
|
||||
finish_reason: null,
|
||||
});
|
||||
const delta = asRecord(ch.delta) ?? {};
|
||||
if (typeof delta.role === "string") slot.message.role = delta.role;
|
||||
if (typeof delta.content === "string") slot.message.content += delta.content;
|
||||
if (typeof delta.refusal === "string") {
|
||||
slot.message.refusal = (slot.message.refusal ?? "") + delta.refusal;
|
||||
}
|
||||
if (Array.isArray(delta.tool_calls)) {
|
||||
slot.message.tool_calls ??= [];
|
||||
for (const tcRaw of delta.tool_calls) {
|
||||
const tc = asRecord(tcRaw);
|
||||
if (!tc) continue;
|
||||
const ti = typeof tc.index === "number" ? tc.index : 0;
|
||||
const tcSlot =
|
||||
slot.message.tool_calls[ti] ??
|
||||
(slot.message.tool_calls[ti] = {
|
||||
index: ti,
|
||||
function: { name: "", arguments: "" },
|
||||
});
|
||||
if (typeof tc.id === "string") tcSlot.id = tc.id;
|
||||
if (typeof tc.type === "string") tcSlot.type = tc.type;
|
||||
const fn = asRecord(tc.function);
|
||||
if (fn) {
|
||||
if (typeof fn.name === "string") tcSlot.function.name += fn.name;
|
||||
if (typeof fn.arguments === "string") tcSlot.function.arguments += fn.arguments;
|
||||
}
|
||||
for (const chunk of chunks) {
|
||||
const payload = asRecord(chunk.json);
|
||||
if (!payload) continue;
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key !== "choices" && key !== "usage" && value !== undefined) result[key] = value;
|
||||
}
|
||||
if (payload.usage !== undefined) result.usage = payload.usage;
|
||||
|
||||
for (const [position, rawChoice] of asArray(payload.choices).entries()) {
|
||||
const choice = asRecord(rawChoice);
|
||||
if (!choice) continue;
|
||||
const index = asIndex(choice.index, position);
|
||||
const state = getOpenAiChoice(choices, index);
|
||||
const delta = asRecord(choice.delta);
|
||||
|
||||
if (delta) {
|
||||
if (typeof delta.role === "string") state.message.role = delta.role;
|
||||
appendString(state.message, "content", delta.content);
|
||||
appendString(state.message, "refusal", delta.refusal);
|
||||
mergeOpenAiTools(state, asArray(delta.tool_calls));
|
||||
|
||||
const functionCall = asRecord(delta.function_call);
|
||||
if (functionCall) {
|
||||
const current = asRecord(state.message.function_call) ?? {};
|
||||
appendString(current, "name", functionCall.name);
|
||||
appendString(current, "arguments", functionCall.arguments);
|
||||
state.message.function_call = current;
|
||||
}
|
||||
}
|
||||
if (typeof ch.finish_reason === "string") slot.finish_reason = ch.finish_reason;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
format: "openai",
|
||||
message: {
|
||||
id,
|
||||
model,
|
||||
choices: Object.values(choicesByIdx).sort((a, b) => a.index - b.index),
|
||||
usage,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a Gemini `generateContent`-style response from streaming events.
|
||||
* Concatenates all parts across emitted candidates into a single candidate.
|
||||
*/
|
||||
export function rebuildGemini(chunks: SseEvent[]): MergedResponse {
|
||||
const parts: unknown[] = [];
|
||||
let usageMetadata: unknown = null;
|
||||
let finishReason: unknown = null;
|
||||
let modelVersion: string | null = null;
|
||||
|
||||
for (const c of chunks) {
|
||||
const j = asRecord(c.json);
|
||||
if (!j) continue;
|
||||
if (j.usageMetadata != null) usageMetadata = j.usageMetadata;
|
||||
if (typeof j.modelVersion === "string") modelVersion = j.modelVersion;
|
||||
if (!Array.isArray(j.candidates)) continue;
|
||||
for (const candRaw of j.candidates) {
|
||||
const cand = asRecord(candRaw);
|
||||
if (!cand) continue;
|
||||
if (cand.finishReason != null) finishReason = cand.finishReason;
|
||||
const content = asRecord(cand.content);
|
||||
if (!content) continue;
|
||||
const ps = content.parts;
|
||||
if (Array.isArray(ps)) {
|
||||
for (const p of ps) parts.push(p);
|
||||
for (const [key, value] of Object.entries(choice)) {
|
||||
if (key !== "delta" && value !== null && value !== undefined) state.value[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
format: "gemini",
|
||||
message: {
|
||||
candidates: [
|
||||
{
|
||||
content: { parts, role: "model" },
|
||||
...(finishReason != null ? { finishReason } : {}),
|
||||
},
|
||||
],
|
||||
...(modelVersion ? { modelVersion } : {}),
|
||||
...(usageMetadata != null ? { usageMetadata } : {}),
|
||||
},
|
||||
};
|
||||
result.choices = [...choices.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([, state]) => {
|
||||
if (state.tools.size > 0) {
|
||||
state.message.tool_calls = [...state.tools.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([, tool]) => tool.value);
|
||||
}
|
||||
return { ...state.value, message: state.message };
|
||||
});
|
||||
return { format: "openai", message: result };
|
||||
}
|
||||
|
||||
export function rebuildGemini(chunks: SseEvent[]): MergedResponse {
|
||||
const result: JsonRecord = {};
|
||||
const candidates = new Map<number, JsonRecord>();
|
||||
const parts = new Map<number, unknown[]>();
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const payload = asRecord(chunk.json);
|
||||
if (!payload) continue;
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key !== "candidates" && value !== undefined) result[key] = value;
|
||||
}
|
||||
|
||||
for (const [position, rawCandidate] of asArray(payload.candidates).entries()) {
|
||||
const candidate = asRecord(rawCandidate);
|
||||
if (!candidate) continue;
|
||||
const index = asIndex(candidate.index, position);
|
||||
const current = candidates.get(index) ?? { index };
|
||||
const content = asRecord(candidate.content);
|
||||
const currentParts = parts.get(index) ?? [];
|
||||
if (content) currentParts.push(...asArray(content.parts));
|
||||
parts.set(index, currentParts);
|
||||
|
||||
for (const [key, value] of Object.entries(candidate)) {
|
||||
if (key !== "content" && value !== undefined) current[key] = value;
|
||||
}
|
||||
if (content) {
|
||||
current.content = {
|
||||
...asRecord(current.content),
|
||||
...content,
|
||||
parts: currentParts,
|
||||
};
|
||||
}
|
||||
candidates.set(index, current);
|
||||
}
|
||||
}
|
||||
|
||||
result.candidates = [...candidates.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([, candidate]) => candidate);
|
||||
return { format: "gemini", message: result };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an array of SSE events into a single rebuilt response. Returns
|
||||
* `{ format: "unknown", raw }` (no throw) for unrecognised shapes.
|
||||
*/
|
||||
export function mergeStream(chunks: SseEvent[]): MergedResponse {
|
||||
const format = detectApiFormat(chunks);
|
||||
switch (format) {
|
||||
case "anthropic":
|
||||
return rebuildAnthropic(chunks);
|
||||
case "openai":
|
||||
return rebuildOpenAI(chunks);
|
||||
case "gemini":
|
||||
return rebuildGemini(chunks);
|
||||
default:
|
||||
return { format: "unknown", raw: chunks };
|
||||
}
|
||||
if (format === "anthropic") return rebuildAnthropic(chunks);
|
||||
if (format === "openai") return rebuildOpenAI(chunks);
|
||||
if (format === "gemini") return rebuildGemini(chunks);
|
||||
return { format: "unknown", raw: chunks };
|
||||
}
|
||||
|
||||
@@ -1,31 +1,122 @@
|
||||
/**
|
||||
* Secret masking utilities for MITM traffic inspection.
|
||||
* Applied to all headers/bodies before any log or broadcast.
|
||||
* Regex patterns are pre-compiled (order matters: BEARER first).
|
||||
* Linear secret scanner for captured Traffic Inspector text.
|
||||
*
|
||||
* Pattern sources: plano 11 §4.8 (origin: llm-interceptor proxy.py:310)
|
||||
* Bearer credentials follow RFC 6750's token alphabet and take precedence over
|
||||
* heuristic key masking so no suffix of a credential survives a partial match.
|
||||
*/
|
||||
|
||||
// Pre-compiled regex patterns — ORDER IS SIGNIFICANT (BEARER must run first).
|
||||
// BEARER matches the token after a standalone "Bearer " — NOT only after a
|
||||
// literal "authorization:" prefix. sanitizeHeaders() masks header *values*
|
||||
// ("Bearer <token>") with the key already stripped, so a prefix-anchored regex
|
||||
// never fired there and short/opaque-but-<40 tokens leaked into the inspector
|
||||
// (found by the AgentBridge live capture). The char class is bounded + linear
|
||||
// (no nested quantifiers) to stay ReDoS-safe.
|
||||
const BEARER = /(\bBearer\s+)[A-Za-z0-9._~+/-]+=*/gi;
|
||||
const SK_KEY = /\b(sk|ak|pk)-[A-Za-z0-9_-]{16,}\b/g;
|
||||
const LONG_TOKEN = /\b[A-Za-z0-9_-]{40,}\b/g;
|
||||
const PREFIXED_KEY_BODY_MIN = 16;
|
||||
const OPAQUE_TOKEN_MIN = 40;
|
||||
|
||||
/**
|
||||
* Mask secrets in a string value.
|
||||
* - Bearer tokens: replaces the token after any "Bearer " with "***"
|
||||
* - sk-/ak-/pk- keys: keeps first 6 chars + last 2 chars
|
||||
* - Long opaque tokens (≥40 chars): keeps first 4 chars + last 2 chars
|
||||
*/
|
||||
interface Match {
|
||||
start: number;
|
||||
end: number;
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
function isAsciiLetter(code: number): boolean {
|
||||
return (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a);
|
||||
}
|
||||
|
||||
function isAsciiDigit(code: number): boolean {
|
||||
return code >= 0x30 && code <= 0x39;
|
||||
}
|
||||
|
||||
function isKeyCharCode(code: number): boolean {
|
||||
return isAsciiLetter(code) || isAsciiDigit(code) || code === 0x2d || code === 0x5f;
|
||||
}
|
||||
|
||||
function isBearerCoreCharCode(code: number): boolean {
|
||||
return isKeyCharCode(code) || code === 0x2e || code === 0x7e || code === 0x2b || code === 0x2f;
|
||||
}
|
||||
|
||||
function isOpaqueCharCode(code: number): boolean {
|
||||
return isBearerCoreCharCode(code);
|
||||
}
|
||||
|
||||
function equalsAsciiIgnoreCase(value: string, index: number, expected: string): boolean {
|
||||
if (index + expected.length > value.length) return false;
|
||||
for (let offset = 0; offset < expected.length; offset += 1) {
|
||||
const actualCode = value.charCodeAt(index + offset);
|
||||
const expectedCode = expected.charCodeAt(offset);
|
||||
const folded = actualCode >= 0x41 && actualCode <= 0x5a ? actualCode + 0x20 : actualCode;
|
||||
if (folded !== expectedCode) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function bearerMatch(value: string, index: number): Match | null {
|
||||
if (!equalsAsciiIgnoreCase(value, index, "bearer")) return null;
|
||||
if (index > 0 && isKeyCharCode(value.charCodeAt(index - 1))) return null;
|
||||
|
||||
const schemeEnd = index + 6;
|
||||
if (value.charCodeAt(schemeEnd) !== 0x20) return null;
|
||||
let tokenStart = schemeEnd;
|
||||
while (value.charCodeAt(tokenStart) === 0x20) tokenStart += 1;
|
||||
|
||||
let end = tokenStart;
|
||||
while (end < value.length && isBearerCoreCharCode(value.charCodeAt(end))) end += 1;
|
||||
if (end === tokenStart) return null;
|
||||
while (value.charCodeAt(end) === 0x3d) end += 1;
|
||||
|
||||
return { start: tokenStart, end, replacement: "***" };
|
||||
}
|
||||
|
||||
function prefixedKeyMatch(value: string, index: number): Match | null {
|
||||
if (index > 0 && isOpaqueCharCode(value.charCodeAt(index - 1))) return null;
|
||||
const prefix = value.slice(index, index + 3);
|
||||
if (prefix !== "sk-" && prefix !== "ak-" && prefix !== "pk-") return null;
|
||||
|
||||
let end = index + 3;
|
||||
while (end < value.length && isOpaqueCharCode(value.charCodeAt(end))) end += 1;
|
||||
if (end - (index + 3) < PREFIXED_KEY_BODY_MIN) return null;
|
||||
while (value.charCodeAt(end) === 0x3d) end += 1;
|
||||
|
||||
const token = value.slice(index, end);
|
||||
return {
|
||||
start: index,
|
||||
end,
|
||||
replacement: `${token.slice(0, 6)}…${token.slice(-2)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function opaqueTokenMatch(value: string, index: number): Match | null {
|
||||
if (!isOpaqueCharCode(value.charCodeAt(index))) return null;
|
||||
if (index > 0 && isOpaqueCharCode(value.charCodeAt(index - 1))) return null;
|
||||
|
||||
let end = index + 1;
|
||||
while (end < value.length && isOpaqueCharCode(value.charCodeAt(end))) end += 1;
|
||||
if (end - index < OPAQUE_TOKEN_MIN) return null;
|
||||
while (value.charCodeAt(end) === 0x3d) end += 1;
|
||||
|
||||
const token = value.slice(index, end);
|
||||
return {
|
||||
start: index,
|
||||
end,
|
||||
replacement: `${token.slice(0, 4)}…${token.slice(-2)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mask Bearer credentials, provider-style keys, and long opaque tokens. */
|
||||
export function maskSecret(value: string): string {
|
||||
return value
|
||||
.replace(BEARER, "$1***")
|
||||
.replace(SK_KEY, (m) => `${m.slice(0, 6)}…${m.slice(-2)}`)
|
||||
.replace(LONG_TOKEN, (m) => `${m.slice(0, 4)}…${m.slice(-2)}`);
|
||||
let output = "";
|
||||
let literalStart = 0;
|
||||
let index = 0;
|
||||
|
||||
while (index < value.length) {
|
||||
const match =
|
||||
bearerMatch(value, index) ?? prefixedKeyMatch(value, index) ?? opaqueTokenMatch(value, index);
|
||||
if (!match) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += value.slice(literalStart, match.start);
|
||||
output += match.replacement;
|
||||
index = match.end;
|
||||
literalStart = match.end;
|
||||
}
|
||||
|
||||
if (literalStart === 0) return value;
|
||||
return output + value.slice(literalStart);
|
||||
}
|
||||
|
||||
@@ -1,61 +1,37 @@
|
||||
import type { IncomingHttpHeaders } from "node:http";
|
||||
import { isForbiddenUpstreamHeaderName } from "@/shared/constants/upstreamHeaders";
|
||||
import { maskSecret } from "./maskSecrets";
|
||||
import { isForbiddenUpstreamHeaderName } from "../shared/constants/upstreamHeaders.ts";
|
||||
import { maskSecret } from "./maskSecrets.ts";
|
||||
|
||||
/**
|
||||
* Header names whose values must be masked (case-insensitive).
|
||||
* These carry credentials/tokens that must not appear in logs or broadcasts.
|
||||
*/
|
||||
const SECRET_HEADER_NAMES = new Set([
|
||||
const MASKED_CREDENTIAL_HEADERS = new Set([
|
||||
"authorization",
|
||||
"cookie",
|
||||
// `set-cookie` is the RESPONSE-side credential header — upstream session/CSRF
|
||||
// cookies must be masked too, else they leak verbatim into inspector JSON.
|
||||
"set-cookie",
|
||||
"x-api-key",
|
||||
"api-key",
|
||||
"bearer",
|
||||
"proxy-authorization",
|
||||
"x-api-key",
|
||||
"x-auth-token",
|
||||
"x-goog-api-key",
|
||||
]);
|
||||
const FULLY_REDACTED_HEADERS = new Set(["cookie", "set-cookie"]);
|
||||
|
||||
function isSecretHeader(name: string): boolean {
|
||||
return SECRET_HEADER_NAMES.has(name.toLowerCase());
|
||||
function sanitizeCredentialValue(value: string): string {
|
||||
const masked = maskSecret(value);
|
||||
return masked === value ? "[REDACTED]" : masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize HTTP headers for safe logging/broadcasting.
|
||||
*
|
||||
* - Removes headers in the upstream denylist (hop-by-hop, Host, etc.)
|
||||
* - Applies maskSecret() to values of authorization/cookie/key headers
|
||||
* - Coerces array values to comma-joined strings
|
||||
* - Returns a plain Record<string, string> (never undefined values)
|
||||
*/
|
||||
export function sanitizeHeaders(
|
||||
headers: IncomingHttpHeaders | Record<string, string | string[] | undefined>,
|
||||
headers: IncomingHttpHeaders | Record<string, string | string[] | undefined>
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
// Remove denylist headers (hop-by-hop, framing)
|
||||
if (isForbiddenUpstreamHeaderName(lowerKey)) continue;
|
||||
|
||||
// Normalize array values
|
||||
const strValue = Array.isArray(value) ? value.join(", ") : String(value);
|
||||
|
||||
// Mask secret header values
|
||||
if (isSecretHeader(lowerKey)) {
|
||||
// `set-cookie` carries an entire session/CSRF cookie value that maskSecret's
|
||||
// format heuristics (Bearer / sk- / ≥40-char) do NOT catch, so it would leak
|
||||
// verbatim into inspector JSON — fully redact it (SECURITY_AUDIT M6).
|
||||
result[lowerKey] = lowerKey === "set-cookie" ? "[REDACTED]" : maskSecret(strValue);
|
||||
} else {
|
||||
result[lowerKey] = strValue;
|
||||
const sanitized: Record<string, string> = {};
|
||||
for (const [rawName, rawValue] of Object.entries(headers)) {
|
||||
const name = rawName.trim().toLowerCase();
|
||||
if (!name) continue;
|
||||
if (isForbiddenUpstreamHeaderName(name)) continue;
|
||||
if (FULLY_REDACTED_HEADERS.has(name)) {
|
||||
sanitized[name] = "[REDACTED]";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (rawValue === undefined) continue;
|
||||
|
||||
return result;
|
||||
const value = Array.isArray(rawValue) ? rawValue.join(", ") : rawValue;
|
||||
sanitized[name] = MASKED_CREDENTIAL_HEADERS.has(name) ? sanitizeCredentialValue(value) : value;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
@@ -165,6 +165,26 @@ test("normalizes Responses API reasoning items (no `role` field) into an assista
|
||||
assert.equal((conv.request[0].blocks[0] as { text: string }).text, "Thinking about the request.");
|
||||
});
|
||||
|
||||
test("normalizes Responses API instructions as a system turn before input", () => {
|
||||
const req = makeReq({
|
||||
path: "/v1/responses",
|
||||
requestBody: JSON.stringify({
|
||||
instructions: "Answer tersely.",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Hello" }] }],
|
||||
}),
|
||||
});
|
||||
const conv = normalizeConversation(req);
|
||||
assert.ok(conv);
|
||||
assert.deepEqual(
|
||||
conv.request.map((turn) => turn.role),
|
||||
["system", "user"]
|
||||
);
|
||||
assert.equal(
|
||||
(conv.request[0].blocks[0] as { type: "text"; text: string }).text,
|
||||
"Answer tersely."
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizes Anthropic request with top-level system + tool_use response", () => {
|
||||
const req = makeReq({
|
||||
host: "api.anthropic.com",
|
||||
@@ -190,6 +210,29 @@ test("normalizes Anthropic request with top-level system + tool_use response", (
|
||||
assert.equal(conv.response[0].blocks[1].type, "tool_use");
|
||||
});
|
||||
|
||||
test("normalizes Anthropic user-carried tool_result content as a tool turn", () => {
|
||||
const req = makeReq({
|
||||
host: "api.anthropic.com",
|
||||
path: "/v1/messages",
|
||||
requestBody: JSON.stringify({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "sunny" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const conv = normalizeConversation(req);
|
||||
assert.ok(conv);
|
||||
assert.equal(conv.request[0].role, "tool");
|
||||
assert.deepEqual(conv.request[0].blocks[0], {
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_1",
|
||||
content: "sunny",
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes Gemini request contents + functionCall response", () => {
|
||||
const req = makeReq({
|
||||
host: "generativelanguage.googleapis.com",
|
||||
|
||||
@@ -15,9 +15,7 @@ function jsonChunks(payloads: unknown[]): SseEvent[] {
|
||||
}
|
||||
|
||||
test("detectApiFormat — message_start → anthropic", () => {
|
||||
const chunks = jsonChunks([
|
||||
{ type: "message_start", message: { id: "msg_1" } },
|
||||
]);
|
||||
const chunks = jsonChunks([{ type: "message_start", message: { id: "msg_1" } }]);
|
||||
assert.equal(detectApiFormat(chunks), "anthropic");
|
||||
});
|
||||
|
||||
@@ -28,16 +26,24 @@ test("detectApiFormat — content_block_delta → anthropic", () => {
|
||||
assert.equal(detectApiFormat(chunks), "anthropic");
|
||||
});
|
||||
|
||||
test("detectApiFormat — Anthropic SSE event name is authoritative when JSON type is absent", () => {
|
||||
const chunks: SseEvent[] = [{ event: "message_start", data: "{}", json: {} }];
|
||||
assert.equal(detectApiFormat(chunks), "anthropic");
|
||||
});
|
||||
|
||||
test("detectApiFormat — choices[].delta → openai", () => {
|
||||
const chunks = jsonChunks([
|
||||
{ choices: [{ index: 0, delta: { content: "Hi" } }] },
|
||||
]);
|
||||
const chunks = jsonChunks([{ choices: [{ index: 0, delta: { content: "Hi" } }] }]);
|
||||
assert.equal(detectApiFormat(chunks), "openai");
|
||||
});
|
||||
|
||||
test("detectApiFormat — candidates → gemini", () => {
|
||||
const chunks = jsonChunks([{ candidates: [{ content: { parts: [{ text: "Hello" }] } }] }]);
|
||||
assert.equal(detectApiFormat(chunks), "gemini");
|
||||
});
|
||||
|
||||
test("detectApiFormat — Gemini usage-only terminal chunk remains identifiable", () => {
|
||||
const chunks = jsonChunks([
|
||||
{ candidates: [{ content: { parts: [{ text: "Hello" }] } }] },
|
||||
{ usageMetadata: { promptTokenCount: 3, candidatesTokenCount: 2 }, modelVersion: "gemini" },
|
||||
]);
|
||||
assert.equal(detectApiFormat(chunks), "gemini");
|
||||
});
|
||||
@@ -57,7 +63,11 @@ test("rebuildAnthropic — concat text_delta by index", () => {
|
||||
]);
|
||||
const merged = rebuildAnthropic(chunks);
|
||||
assert.equal(merged.format, "anthropic");
|
||||
const msg = merged.message as { content: Array<{ text: string }>; stop_reason: string; usage: { output_tokens: number } };
|
||||
const msg = merged.message as {
|
||||
content: Array<{ text: string }>;
|
||||
stop_reason: string;
|
||||
usage: { output_tokens: number };
|
||||
};
|
||||
assert.equal(msg.content[0].text, "Hello world");
|
||||
assert.equal(msg.stop_reason, "end_turn");
|
||||
assert.equal(msg.usage.output_tokens, 7);
|
||||
@@ -71,12 +81,22 @@ test("rebuildAnthropic — input_json_delta merges and JSON.parses", () => {
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "tu_1", name: "search" },
|
||||
},
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"q":' } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '"hi"}' } },
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"q":' },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '"hi"}' },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
]);
|
||||
const merged = rebuildAnthropic(chunks);
|
||||
const msg = merged.message as { content: Array<{ type: string; input: { q: string }; name: string }> };
|
||||
const msg = merged.message as {
|
||||
content: Array<{ type: string; input: { q: string }; name: string }>;
|
||||
};
|
||||
assert.equal(msg.content[0].type, "tool_use");
|
||||
assert.equal(msg.content[0].name, "search");
|
||||
assert.deepEqual(msg.content[0].input, { q: "hi" });
|
||||
@@ -86,7 +106,11 @@ test("rebuildAnthropic — thinking_delta accumulates", () => {
|
||||
const chunks = jsonChunks([
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "thinking" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "I am " } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "thinking..." } },
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "thinking_delta", thinking: "thinking..." },
|
||||
},
|
||||
]);
|
||||
const merged = rebuildAnthropic(chunks);
|
||||
const msg = merged.message as { content: Array<{ thinking: string }> };
|
||||
@@ -95,7 +119,11 @@ test("rebuildAnthropic — thinking_delta accumulates", () => {
|
||||
|
||||
test("rebuildOpenAI — concat delta.content per choice index", () => {
|
||||
const chunks = jsonChunks([
|
||||
{ id: "c1", model: "gpt-4", choices: [{ index: 0, delta: { role: "assistant", content: "Hel" } }] },
|
||||
{
|
||||
id: "c1",
|
||||
model: "gpt-4",
|
||||
choices: [{ index: 0, delta: { role: "assistant", content: "Hel" } }],
|
||||
},
|
||||
{ choices: [{ index: 0, delta: { content: "lo" }, finish_reason: null }] },
|
||||
{ choices: [{ index: 0, delta: {}, finish_reason: "stop" }] },
|
||||
{ usage: { prompt_tokens: 3, completion_tokens: 1 } },
|
||||
@@ -123,7 +151,14 @@ test("rebuildOpenAI — merges tool_calls per (choice, tool) index", () => {
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{ index: 0, id: "tc_1", type: "function", function: { name: "search", arguments: '{"q":' } }],
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "tc_1",
|
||||
type: "function",
|
||||
function: { name: "search", arguments: '{"q":' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -152,6 +187,46 @@ test("rebuildOpenAI — merges tool_calls per (choice, tool) index", () => {
|
||||
assert.equal(msg.choices[0].message.tool_calls[0].function.arguments, '{"q":"hi"}');
|
||||
});
|
||||
|
||||
test("rebuildOpenAI — reconstructs Responses API text items and completion metadata", () => {
|
||||
const chunks = jsonChunks([
|
||||
{ type: "response.created", response: { id: "resp_1", object: "response", output: [] } },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { id: "msg_1", type: "message", role: "assistant", content: [] },
|
||||
},
|
||||
{
|
||||
type: "response.content_part.added",
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
part: { type: "output_text", text: "" },
|
||||
},
|
||||
{ type: "response.output_text.delta", output_index: 0, content_index: 0, delta: "Hel" },
|
||||
{ type: "response.output_text.delta", output_index: 0, content_index: 0, delta: "lo" },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
object: "response",
|
||||
status: "completed",
|
||||
output: [],
|
||||
usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const merged = rebuildOpenAI(chunks);
|
||||
const response = merged.message as {
|
||||
status: string;
|
||||
usage: { total_tokens: number };
|
||||
output: Array<{ id: string; content: Array<{ text: string }> }>;
|
||||
};
|
||||
assert.equal(response.status, "completed");
|
||||
assert.equal(response.usage.total_tokens, 3);
|
||||
assert.equal(response.output[0].id, "msg_1");
|
||||
assert.equal(response.output[0].content[0].text, "Hello");
|
||||
});
|
||||
|
||||
test("rebuildGemini — merges parts across candidates", () => {
|
||||
const chunks = jsonChunks([
|
||||
{ candidates: [{ content: { parts: [{ text: "Hello " }] } }] },
|
||||
@@ -171,10 +246,7 @@ test("rebuildGemini — merges parts across candidates", () => {
|
||||
});
|
||||
|
||||
test("mergeStream — unknown format returns raw fallback (no crash)", () => {
|
||||
const chunks: SseEvent[] = [
|
||||
{ data: "garbage" },
|
||||
{ data: "{}", json: { foo: 1 } },
|
||||
];
|
||||
const chunks: SseEvent[] = [{ data: "garbage" }, { data: "{}", json: { foo: 1 } }];
|
||||
const merged = mergeStream(chunks);
|
||||
assert.equal(merged.format, "unknown");
|
||||
assert.ok(Array.isArray(merged.raw));
|
||||
@@ -182,10 +254,7 @@ test("mergeStream — unknown format returns raw fallback (no crash)", () => {
|
||||
});
|
||||
|
||||
test("parseSseStream — parses event/data blocks separated by blank lines", () => {
|
||||
const raw =
|
||||
"event: foo\ndata: 1\n\n" +
|
||||
'data: {"x":1}\n\n' +
|
||||
"data: [DONE]\n\n";
|
||||
const raw = "event: foo\ndata: 1\n\n" + 'data: {"x":1}\n\n' + "data: [DONE]\n\n";
|
||||
const events = parseSseStream(raw);
|
||||
assert.equal(events.length, 3);
|
||||
assert.equal(events[0].event, "foo");
|
||||
|
||||
@@ -39,6 +39,23 @@ test("maskSecret — long opaque token (≥40 chars) is masked", () => {
|
||||
assert.ok(result.length < longToken.length);
|
||||
});
|
||||
|
||||
test("maskSecret — dotted opaque tokens are redacted as one integral credential", () => {
|
||||
const token =
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2NvcGUiOiJhZG1pbiJ9.signature1234567890";
|
||||
const result = maskSecret(token);
|
||||
assert.ok(result.startsWith("eyJh"));
|
||||
assert.ok(result.endsWith("…90"));
|
||||
assert.ok(!result.includes("eyJzdWIi"), "the middle JWT-like segment must not survive");
|
||||
assert.equal((result.match(/…/g) ?? []).length, 1, "the credential must be masked once");
|
||||
});
|
||||
|
||||
test("maskSecret — padded opaque tokens include trailing padding in one redaction", () => {
|
||||
const token = `${"A".repeat(42)}==`;
|
||||
const result = maskSecret(token);
|
||||
assert.equal(result, "AAAA…==");
|
||||
assert.equal((result.match(/…/g) ?? []).length, 1);
|
||||
});
|
||||
|
||||
test("maskSecret — string without secrets is unchanged", () => {
|
||||
const safe = "Content-Type: application/json";
|
||||
assert.equal(maskSecret(safe), safe);
|
||||
|
||||
@@ -26,9 +26,31 @@ test("sanitizeHeaders — Set-Cookie header name is matched case-insensitively",
|
||||
assert.equal(out["set-cookie"], "[REDACTED]");
|
||||
});
|
||||
|
||||
test("sanitizeHeaders — request cookies are fully redacted regardless of token shape", () => {
|
||||
const out = sanitizeHeaders({ Cookie: "session=short; csrf=also-short" });
|
||||
assert.deepEqual(out, { cookie: "[REDACTED]" });
|
||||
});
|
||||
|
||||
test("sanitizeHeaders — authorization bearer token is still masked, not leaked", () => {
|
||||
const out = sanitizeHeaders({ authorization: "Bearer sk-proj-abcdefghijklmnop" });
|
||||
assert.ok(!out["authorization"].includes("sk-proj-abcdefghijklmnop"), "bearer token must be masked");
|
||||
assert.ok(
|
||||
!out["authorization"].includes("sk-proj-abcdefghijklmnop"),
|
||||
"bearer token must be masked"
|
||||
);
|
||||
});
|
||||
|
||||
test("sanitizeHeaders — drops framing, hop-by-hop, and proxy credential headers", () => {
|
||||
const out = sanitizeHeaders({
|
||||
Host: "provider.example",
|
||||
Connection: "keep-alive",
|
||||
"Content-Length": "42",
|
||||
"Proxy-Authorization": "Basic c2VjcmV0",
|
||||
"Proxy-Authenticate": "Basic realm=proxy",
|
||||
TE: "trailers",
|
||||
Upgrade: "websocket",
|
||||
"X-Keep": "visible",
|
||||
});
|
||||
assert.deepEqual(out, { "x-keep": "visible" });
|
||||
});
|
||||
|
||||
test("sanitizeHeaders — non-secret headers pass through unchanged", () => {
|
||||
@@ -36,3 +58,11 @@ test("sanitizeHeaders — non-secret headers pass through unchanged", () => {
|
||||
assert.equal(out["content-type"], "application/json");
|
||||
assert.equal(out["x-request-id"], "req-42");
|
||||
});
|
||||
|
||||
test("sanitizeHeaders — lowercases names, joins arrays predictably, and omits undefined", () => {
|
||||
const out = sanitizeHeaders({
|
||||
"X-Trace": ["edge-a", "edge-b"],
|
||||
"X-Missing": undefined,
|
||||
});
|
||||
assert.deepEqual(out, { "x-trace": "edge-a, edge-b" });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user