/** * Conversation normalizer — converts OpenAI / Anthropic / Gemini request + * response payloads into a single provider-agnostic shape. * * 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. */ import { mergeStream, parseSseStream } from "./sseMerger.ts"; import type { InterceptedRequest, NormalizedBlock, NormalizedConversation, NormalizedTurn, } from "./types.ts"; type NormalizedRole = NormalizedTurn["role"]; function asRecord(value: unknown): Record | null { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record; } return null; } function tryParseJson(value: string | null | undefined): unknown { if (!value) return null; try { return JSON.parse(value); } 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"; } /** * 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; } } 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, }); } return blocks; } /** * Build NormalizedTurn[] from OpenAI / Anthropic chat messages. */ function turnsFromOpenAiMessages(messages: unknown[]): NormalizedTurn[] { const out: NormalizedTurn[] = []; for (const raw of messages) { const msg = asRecord(raw); if (!msg) 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 }); } 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 }); } 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 (!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 }; } 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 }; } 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. */ 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); return { request: requestTurns, response: responseTurns, contextKey: req.contextKey ?? null, }; }