mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-12 15:30:53 +02:00
normalize the requests to also store in the log for viewing later
This commit is contained in:
@@ -69,7 +69,7 @@ export const orgs = pgTable("orgs", {
|
|||||||
"settingsLogRetentionDaysAISessions"
|
"settingsLogRetentionDaysAISessions"
|
||||||
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(0),
|
.default(7),
|
||||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||||
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||||
isBillingOrg: boolean("isBillingOrg"),
|
isBillingOrg: boolean("isBillingOrg"),
|
||||||
@@ -1936,8 +1936,15 @@ export const aiSessionLog = pgTable(
|
|||||||
isStream: boolean("isStream").notNull().default(false),
|
isStream: boolean("isStream").notNull().default(false),
|
||||||
requestBody: text("requestBody"),
|
requestBody: text("requestBody"),
|
||||||
responseBody: text("responseBody"),
|
responseBody: text("responseBody"),
|
||||||
// True if requestBody/responseBody were cut short at
|
// Capability-agnostic message transcript (JSON-encoded
|
||||||
// AI_SESSION_LOG_MAX_BODY_CHARS before storage.
|
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
|
||||||
|
// computed at write time so search/display never need per-capability
|
||||||
|
// parsing logic. Null when normalization couldn't recognize the
|
||||||
|
// shape - callers fall back to requestBody/responseBody.
|
||||||
|
normalizedRequest: text("normalizedRequest"),
|
||||||
|
normalizedResponse: text("normalizedResponse"),
|
||||||
|
// True if any of the request/response (raw or normalized) fields
|
||||||
|
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
|
||||||
truncated: boolean("truncated").notNull().default(false),
|
truncated: boolean("truncated").notNull().default(false),
|
||||||
statusCode: integer("statusCode"),
|
statusCode: integer("statusCode"),
|
||||||
createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch ms
|
createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch ms
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const orgs = sqliteTable("orgs", {
|
|||||||
"settingsLogRetentionDaysAISessions"
|
"settingsLogRetentionDaysAISessions"
|
||||||
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(0),
|
.default(7),
|
||||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||||
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||||
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
|
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
|
||||||
@@ -1928,8 +1928,15 @@ export const aiSessionLog = sqliteTable(
|
|||||||
.default(false),
|
.default(false),
|
||||||
requestBody: text("requestBody"),
|
requestBody: text("requestBody"),
|
||||||
responseBody: text("responseBody"),
|
responseBody: text("responseBody"),
|
||||||
// True if requestBody/responseBody were cut short at
|
// Capability-agnostic message transcript (JSON-encoded
|
||||||
// AI_SESSION_LOG_MAX_BODY_CHARS before storage.
|
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
|
||||||
|
// computed at write time so search/display never need per-capability
|
||||||
|
// parsing logic. Null when normalization couldn't recognize the
|
||||||
|
// shape - callers fall back to requestBody/responseBody.
|
||||||
|
normalizedRequest: text("normalizedRequest"),
|
||||||
|
normalizedResponse: text("normalizedResponse"),
|
||||||
|
// True if any of the request/response (raw or normalized) fields
|
||||||
|
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
|
||||||
truncated: integer("truncated", { mode: "boolean" })
|
truncated: integer("truncated", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
|||||||
@@ -0,0 +1,538 @@
|
|||||||
|
import type { AiCapability } from "@server/lib/aiCapabilities";
|
||||||
|
import { sseDataFrames, tryParseJson } from "@server/lib/aiUsageExtraction";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
// Uniform, capability-agnostic representation of a chat message, used so
|
||||||
|
// the AI session log can be searched/displayed the same way regardless of
|
||||||
|
// which provider/capability produced it. Content is flattened to plain text
|
||||||
|
// - non-text parts (images, tool calls/results) are rendered as readable
|
||||||
|
// placeholders rather than preserved as structured data, which is enough for
|
||||||
|
// a transcript-style replay view without a per-capability renderer.
|
||||||
|
export type NormalizedRole = "system" | "user" | "assistant" | "tool";
|
||||||
|
|
||||||
|
export type NormalizedAiMessage = {
|
||||||
|
role: NormalizedRole;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRole(role: unknown): NormalizedRole {
|
||||||
|
if (
|
||||||
|
role === "system" ||
|
||||||
|
role === "user" ||
|
||||||
|
role === "assistant" ||
|
||||||
|
role === "tool"
|
||||||
|
) {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
if (role === "model") return "assistant"; // Gemini
|
||||||
|
if (role === "function") return "tool"; // OpenAI legacy function role
|
||||||
|
return "user";
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJsonStringify(value: unknown): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value ?? {});
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattens one message "part"/"block" (OpenAI content parts, Anthropic
|
||||||
|
* content blocks, Gemini parts, Bedrock converse content blocks - they all
|
||||||
|
* follow the same rough shape) into readable text.
|
||||||
|
*/
|
||||||
|
function flattenContentPart(part: unknown): string {
|
||||||
|
if (typeof part === "string") return part;
|
||||||
|
if (part == null || typeof part !== "object") return "";
|
||||||
|
const p = part as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof p.text === "string") return p.text;
|
||||||
|
|
||||||
|
if (
|
||||||
|
p.type === "image_url" ||
|
||||||
|
p.type === "image" ||
|
||||||
|
p.type === "input_image" ||
|
||||||
|
p.type === "output_image" ||
|
||||||
|
"inlineData" in p
|
||||||
|
) {
|
||||||
|
return "[image]";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anthropic-style tool_use / tool_result blocks
|
||||||
|
if (p.type === "tool_use") {
|
||||||
|
const name = typeof p.name === "string" ? p.name : "tool";
|
||||||
|
return `[tool_call: ${name}(${safeJsonStringify(p.input)})]`;
|
||||||
|
}
|
||||||
|
if (p.type === "tool_result") {
|
||||||
|
const content = p.content;
|
||||||
|
const text =
|
||||||
|
typeof content === "string"
|
||||||
|
? content
|
||||||
|
: Array.isArray(content)
|
||||||
|
? flattenContentParts(content)
|
||||||
|
: "";
|
||||||
|
return `[tool_result: ${text}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini-style functionCall / functionResponse parts
|
||||||
|
if (p.functionCall && typeof p.functionCall === "object") {
|
||||||
|
const fc = p.functionCall as Record<string, unknown>;
|
||||||
|
return `[tool_call: ${fc.name}(${safeJsonStringify(fc.args)})]`;
|
||||||
|
}
|
||||||
|
if (p.functionResponse && typeof p.functionResponse === "object") {
|
||||||
|
const fr = p.functionResponse as Record<string, unknown>;
|
||||||
|
return `[tool_result: ${fr.name}(${safeJsonStringify(fr.response)})]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bedrock converse-style toolUse / toolResult content blocks
|
||||||
|
if (p.toolUse && typeof p.toolUse === "object") {
|
||||||
|
const tu = p.toolUse as Record<string, unknown>;
|
||||||
|
return `[tool_call: ${tu.name}(${safeJsonStringify(tu.input)})]`;
|
||||||
|
}
|
||||||
|
if (p.toolResult && typeof p.toolResult === "object") {
|
||||||
|
const tr = p.toolResult as Record<string, unknown>;
|
||||||
|
const content = tr.content;
|
||||||
|
const text = Array.isArray(content) ? flattenContentParts(content) : "";
|
||||||
|
return `[tool_result: ${text}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenContentParts(parts: unknown[]): string {
|
||||||
|
return parts.map(flattenContentPart).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenContent(content: unknown): string {
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (Array.isArray(content)) return flattenContentParts(content);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort scan for every `"text":"..."` JSON string value in raw text,
|
||||||
|
* concatenated in order. Fallback for streaming formats we can't fully parse
|
||||||
|
* as JSON/SSE (Gemini's array-JSON stream, Bedrock's binary event-stream
|
||||||
|
* framing) - same spirit as aiUsageExtraction's scanNumericFields.
|
||||||
|
*/
|
||||||
|
function scanTextFragments(text: string): string {
|
||||||
|
const out: string[] = [];
|
||||||
|
const re = /"text"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = re.exec(text)) !== null) {
|
||||||
|
try {
|
||||||
|
out.push(JSON.parse(`"${match[1]}"`));
|
||||||
|
} catch {
|
||||||
|
out.push(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Request (input) normalizers - operate on the already-parsed outbound body.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function normalizeOpenAiChatRequest(body: any): NormalizedAiMessage[] {
|
||||||
|
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
||||||
|
return messages.map((m: any) => ({
|
||||||
|
role: normalizeRole(m?.role),
|
||||||
|
content: flattenContent(m?.content)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOpenAiResponsesRequest(body: any): NormalizedAiMessage[] {
|
||||||
|
const out: NormalizedAiMessage[] = [];
|
||||||
|
if (typeof body?.instructions === "string" && body.instructions) {
|
||||||
|
out.push({ role: "system", content: body.instructions });
|
||||||
|
}
|
||||||
|
const input = body?.input;
|
||||||
|
if (typeof input === "string") {
|
||||||
|
out.push({ role: "user", content: input });
|
||||||
|
} else if (Array.isArray(input)) {
|
||||||
|
for (const item of input) {
|
||||||
|
if (item?.role) {
|
||||||
|
out.push({
|
||||||
|
role: normalizeRole(item.role),
|
||||||
|
content: flattenContent(item.content)
|
||||||
|
});
|
||||||
|
} else if (typeof item?.type === "string") {
|
||||||
|
out.push({ role: "tool", content: `[${item.type}]` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAnthropicRequest(body: any): NormalizedAiMessage[] {
|
||||||
|
const out: NormalizedAiMessage[] = [];
|
||||||
|
if (body?.system) {
|
||||||
|
const sys = flattenContent(body.system);
|
||||||
|
if (sys) out.push({ role: "system", content: sys });
|
||||||
|
}
|
||||||
|
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
||||||
|
for (const m of messages) {
|
||||||
|
out.push({
|
||||||
|
role: normalizeRole(m?.role),
|
||||||
|
content: flattenContent(m?.content)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGeminiRequest(body: any): NormalizedAiMessage[] {
|
||||||
|
const out: NormalizedAiMessage[] = [];
|
||||||
|
const sysParts = body?.systemInstruction?.parts;
|
||||||
|
if (Array.isArray(sysParts)) {
|
||||||
|
const text = flattenContentParts(sysParts);
|
||||||
|
if (text) out.push({ role: "system", content: text });
|
||||||
|
}
|
||||||
|
const contents = Array.isArray(body?.contents) ? body.contents : [];
|
||||||
|
for (const c of contents) {
|
||||||
|
out.push({
|
||||||
|
role: normalizeRole(c?.role),
|
||||||
|
content: Array.isArray(c?.parts) ? flattenContentParts(c.parts) : ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBedrockConverseRequest(body: any): NormalizedAiMessage[] {
|
||||||
|
const out: NormalizedAiMessage[] = [];
|
||||||
|
if (Array.isArray(body?.system)) {
|
||||||
|
const text = flattenContentParts(body.system);
|
||||||
|
if (text) out.push({ role: "system", content: text });
|
||||||
|
}
|
||||||
|
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
||||||
|
for (const m of messages) {
|
||||||
|
out.push({
|
||||||
|
role: normalizeRole(m?.role),
|
||||||
|
content: Array.isArray(m?.content)
|
||||||
|
? flattenContentParts(m.content)
|
||||||
|
: ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bedrock_model_invoke and google_raw_predict are passthroughs - the body
|
||||||
|
* shape depends entirely on the underlying model, not the capability. Try
|
||||||
|
* the two shapes we're most likely to see (Anthropic Claude, then plain
|
||||||
|
* OpenAI-style) and give up otherwise, same fallback spirit
|
||||||
|
* aiUsageExtraction.ts uses for these two capabilities' usage extraction.
|
||||||
|
*/
|
||||||
|
function normalizeBestEffortRequest(body: any): NormalizedAiMessage[] | null {
|
||||||
|
if (!Array.isArray(body?.messages)) return null;
|
||||||
|
const looksAnthropicShaped = body.messages.some((m: any) =>
|
||||||
|
Array.isArray(m?.content)
|
||||||
|
);
|
||||||
|
return looksAnthropicShaped
|
||||||
|
? normalizeAnthropicRequest(body)
|
||||||
|
: normalizeOpenAiChatRequest(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Response (output) normalizers - operate on the raw response text, which
|
||||||
|
// may be a single JSON document (non-streaming) or provider-framed streaming
|
||||||
|
// text (SSE `data:` frames, a JSON-array stream, or binary event-stream
|
||||||
|
// framing with JSON payloads embedded in it).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function normalizeOpenAiChatResponse(
|
||||||
|
text: string,
|
||||||
|
isStream: boolean
|
||||||
|
): NormalizedAiMessage[] | null {
|
||||||
|
if (isStream) {
|
||||||
|
let role: unknown = "assistant";
|
||||||
|
let content = "";
|
||||||
|
let found = false;
|
||||||
|
for (const frame of sseDataFrames(text)) {
|
||||||
|
const delta = tryParseJson(frame)?.choices?.[0]?.delta;
|
||||||
|
if (!delta) continue;
|
||||||
|
found = true;
|
||||||
|
if (typeof delta.role === "string") role = delta.role;
|
||||||
|
if (typeof delta.content === "string") content += delta.content;
|
||||||
|
}
|
||||||
|
return found ? [{ role: normalizeRole(role), content }] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = tryParseJson(text)?.choices?.[0]?.message;
|
||||||
|
if (!message) return null;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
role: normalizeRole(message.role),
|
||||||
|
content: flattenContent(message.content)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOpenAiResponsesOutputText(response: any): string | null {
|
||||||
|
if (typeof response?.output_text === "string") return response.output_text;
|
||||||
|
const output = Array.isArray(response?.output) ? response.output : [];
|
||||||
|
const pieces: string[] = [];
|
||||||
|
for (const item of output) {
|
||||||
|
if (item?.type === "message" && Array.isArray(item.content)) {
|
||||||
|
pieces.push(flattenContentParts(item.content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pieces.length > 0 ? pieces.join("") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOpenAiResponsesResponse(
|
||||||
|
text: string,
|
||||||
|
isStream: boolean
|
||||||
|
): NormalizedAiMessage[] | null {
|
||||||
|
if (isStream) {
|
||||||
|
let content = "";
|
||||||
|
let found = false;
|
||||||
|
for (const frame of sseDataFrames(text)) {
|
||||||
|
const parsed = tryParseJson(frame);
|
||||||
|
if (!parsed) continue;
|
||||||
|
if (
|
||||||
|
parsed.type === "response.output_text.delta" &&
|
||||||
|
typeof parsed.delta === "string"
|
||||||
|
) {
|
||||||
|
content += parsed.delta;
|
||||||
|
found = true;
|
||||||
|
} else if (
|
||||||
|
parsed.type === "response.completed" &&
|
||||||
|
parsed.response
|
||||||
|
) {
|
||||||
|
const outputText = extractOpenAiResponsesOutputText(
|
||||||
|
parsed.response
|
||||||
|
);
|
||||||
|
if (outputText != null) {
|
||||||
|
content = outputText;
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found ? [{ role: "assistant", content }] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = tryParseJson(text);
|
||||||
|
const outputText = extractOpenAiResponsesOutputText(
|
||||||
|
parsed?.response ?? parsed
|
||||||
|
);
|
||||||
|
return outputText != null
|
||||||
|
? [{ role: "assistant", content: outputText }]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAnthropicResponse(
|
||||||
|
text: string,
|
||||||
|
isStream: boolean
|
||||||
|
): NormalizedAiMessage[] | null {
|
||||||
|
if (isStream) {
|
||||||
|
let role: unknown = "assistant";
|
||||||
|
let content = "";
|
||||||
|
let found = false;
|
||||||
|
for (const frame of sseDataFrames(text)) {
|
||||||
|
const parsed = tryParseJson(frame);
|
||||||
|
if (!parsed) continue;
|
||||||
|
if (parsed.type === "message_start" && parsed.message?.role) {
|
||||||
|
role = parsed.message.role;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
parsed.type === "content_block_start" &&
|
||||||
|
parsed.content_block?.type === "tool_use"
|
||||||
|
) {
|
||||||
|
const name = parsed.content_block.name ?? "tool";
|
||||||
|
content += `[tool_call: ${name}]`;
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
parsed.type === "content_block_delta" &&
|
||||||
|
typeof parsed.delta?.text === "string"
|
||||||
|
) {
|
||||||
|
content += parsed.delta.text;
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found ? [{ role: normalizeRole(role), content }] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = tryParseJson(text);
|
||||||
|
if (!parsed || !Array.isArray(parsed.content)) return null;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
role: normalizeRole(parsed.role ?? "assistant"),
|
||||||
|
content: flattenContentParts(parsed.content)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function geminiCandidateParts(node: any): string {
|
||||||
|
const parts = node?.candidates?.[0]?.content?.parts;
|
||||||
|
return Array.isArray(parts) ? flattenContentParts(parts) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGeminiResponse(
|
||||||
|
text: string,
|
||||||
|
_isStream: boolean
|
||||||
|
): NormalizedAiMessage[] | null {
|
||||||
|
const frames = sseDataFrames(text);
|
||||||
|
let content = "";
|
||||||
|
let role: unknown = "model";
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
if (frames.length > 0) {
|
||||||
|
for (const frame of frames) {
|
||||||
|
const parsed = tryParseJson(frame);
|
||||||
|
const piece = geminiCandidateParts(parsed);
|
||||||
|
if (piece) {
|
||||||
|
content += piece;
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
const r = parsed?.candidates?.[0]?.content?.role;
|
||||||
|
if (r) role = r;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parsed = tryParseJson(text);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
for (const chunk of parsed) {
|
||||||
|
const piece = geminiCandidateParts(chunk);
|
||||||
|
if (piece) {
|
||||||
|
content += piece;
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
const r = chunk?.candidates?.[0]?.content?.role;
|
||||||
|
if (r) role = r;
|
||||||
|
}
|
||||||
|
} else if (parsed) {
|
||||||
|
const piece = geminiCandidateParts(parsed);
|
||||||
|
if (piece) {
|
||||||
|
content = piece;
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
const r = parsed?.candidates?.[0]?.content?.role;
|
||||||
|
if (r) role = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
const scanned = scanTextFragments(text);
|
||||||
|
return scanned
|
||||||
|
? [{ role: normalizeRole(role), content: scanned }]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return [{ role: normalizeRole(role), content }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBedrockConverseResponse(
|
||||||
|
text: string,
|
||||||
|
isStream: boolean
|
||||||
|
): NormalizedAiMessage[] | null {
|
||||||
|
if (!isStream) {
|
||||||
|
const message = tryParseJson(text)?.output?.message;
|
||||||
|
if (!message) return null;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
role: normalizeRole(message.role ?? "assistant"),
|
||||||
|
content: Array.isArray(message.content)
|
||||||
|
? flattenContentParts(message.content)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// converse-stream uses AWS's binary event-stream framing, but the JSON
|
||||||
|
// payload of each event survives intact inside it (same assumption
|
||||||
|
// aiUsageExtraction.ts makes for usage) - scan for the text pieces.
|
||||||
|
const scanned = scanTextFragments(text);
|
||||||
|
return scanned ? [{ role: "assistant", content: scanned }] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBedrockModelInvokeResponse(
|
||||||
|
text: string,
|
||||||
|
isStream: boolean
|
||||||
|
): NormalizedAiMessage[] | null {
|
||||||
|
const anthropicStyle = normalizeAnthropicResponse(text, isStream);
|
||||||
|
if (anthropicStyle) return anthropicStyle;
|
||||||
|
const scanned = scanTextFragments(text);
|
||||||
|
return scanned ? [{ role: "assistant", content: scanned }] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGoogleRawPredictResponse(
|
||||||
|
text: string,
|
||||||
|
isStream: boolean
|
||||||
|
): NormalizedAiMessage[] | null {
|
||||||
|
const anthropicStyle = normalizeAnthropicResponse(text, isStream);
|
||||||
|
if (anthropicStyle) return anthropicStyle;
|
||||||
|
const scanned = scanTextFragments(text);
|
||||||
|
return scanned ? [{ role: "assistant", content: scanned }] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUEST_NORMALIZERS: Record<
|
||||||
|
AiCapability,
|
||||||
|
(body: any) => NormalizedAiMessage[] | null
|
||||||
|
> = {
|
||||||
|
openai_chat: normalizeOpenAiChatRequest,
|
||||||
|
openai_responses: normalizeOpenAiResponsesRequest,
|
||||||
|
anthropic_messages: normalizeAnthropicRequest,
|
||||||
|
gemini_generate_content: normalizeGeminiRequest,
|
||||||
|
google_generate_content: normalizeGeminiRequest,
|
||||||
|
google_raw_predict: normalizeBestEffortRequest,
|
||||||
|
bedrock_model_invoke: normalizeBestEffortRequest,
|
||||||
|
bedrock_converse: normalizeBedrockConverseRequest
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESPONSE_NORMALIZERS: Record<
|
||||||
|
AiCapability,
|
||||||
|
(text: string, isStream: boolean) => NormalizedAiMessage[] | null
|
||||||
|
> = {
|
||||||
|
openai_chat: normalizeOpenAiChatResponse,
|
||||||
|
openai_responses: normalizeOpenAiResponsesResponse,
|
||||||
|
anthropic_messages: normalizeAnthropicResponse,
|
||||||
|
gemini_generate_content: normalizeGeminiResponse,
|
||||||
|
google_generate_content: normalizeGeminiResponse,
|
||||||
|
google_raw_predict: normalizeGoogleRawPredictResponse,
|
||||||
|
bedrock_model_invoke: normalizeBedrockModelInvokeResponse,
|
||||||
|
bedrock_converse: normalizeBedrockConverseResponse
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes an outbound AI gateway request body into a uniform message
|
||||||
|
* transcript, regardless of capability/provider. Returns null if the body
|
||||||
|
* doesn't contain any recognizable messages (or parsing failed) - callers
|
||||||
|
* should fall back to showing the raw request body.
|
||||||
|
*/
|
||||||
|
export function normalizeAiRequest(
|
||||||
|
capability: AiCapability,
|
||||||
|
body: unknown
|
||||||
|
): NormalizedAiMessage[] | null {
|
||||||
|
try {
|
||||||
|
const result = REQUEST_NORMALIZERS[capability](body);
|
||||||
|
return result && result.length > 0 ? result : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug("Failed to normalize AI request messages", {
|
||||||
|
capability,
|
||||||
|
error
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a completed (non-streaming or fully-accumulated streaming) AI
|
||||||
|
* gateway response into a uniform message transcript. Returns null if
|
||||||
|
* nothing recognizable could be extracted - callers should fall back to
|
||||||
|
* showing the raw response body.
|
||||||
|
*/
|
||||||
|
export function normalizeAiResponse(
|
||||||
|
capability: AiCapability,
|
||||||
|
responseText: string,
|
||||||
|
isStream: boolean
|
||||||
|
): NormalizedAiMessage[] | null {
|
||||||
|
try {
|
||||||
|
const result = RESPONSE_NORMALIZERS[capability](responseText, isStream);
|
||||||
|
return result && result.length > 0 ? result : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug("Failed to normalize AI response messages", {
|
||||||
|
capability,
|
||||||
|
error
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,10 @@ function scanNumericFields(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sseDataFrames(text: string): string[] {
|
// Exported for reuse by server/lib/aiMessageNormalization.ts, which needs
|
||||||
|
// the same SSE-frame/JSON-parsing groundwork to extract message content
|
||||||
|
// instead of usage numbers.
|
||||||
|
export function sseDataFrames(text: string): string[] {
|
||||||
const frames: string[] = [];
|
const frames: string[] = [];
|
||||||
for (const rawFrame of text.split(/\r?\n\r?\n/)) {
|
for (const rawFrame of text.split(/\r?\n\r?\n/)) {
|
||||||
for (const line of rawFrame.split(/\r?\n/)) {
|
for (const line of rawFrame.split(/\r?\n/)) {
|
||||||
@@ -63,7 +66,7 @@ function sseDataFrames(text: string): string[] {
|
|||||||
return frames;
|
return frames;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryParseJson(text: string): any | null {
|
export function tryParseJson(text: string): any | null {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text);
|
return JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -115,7 +118,10 @@ function extractOpenAiResponses(
|
|||||||
if (isStream) {
|
if (isStream) {
|
||||||
for (const frame of sseDataFrames(text)) {
|
for (const frame of sseDataFrames(text)) {
|
||||||
const parsed = tryParseJson(frame);
|
const parsed = tryParseJson(frame);
|
||||||
if (parsed?.type === "response.completed" && parsed?.response?.usage) {
|
if (
|
||||||
|
parsed?.type === "response.completed" &&
|
||||||
|
parsed?.response?.usage
|
||||||
|
) {
|
||||||
usage = parsed.response.usage;
|
usage = parsed.response.usage;
|
||||||
} else if (parsed?.usage) {
|
} else if (parsed?.usage) {
|
||||||
usage = parsed.usage;
|
usage = parsed.usage;
|
||||||
@@ -244,7 +250,10 @@ function extractBedrockConverse(
|
|||||||
if (usage) {
|
if (usage) {
|
||||||
const cacheReadTokens = usage.cacheReadInputTokens ?? 0;
|
const cacheReadTokens = usage.cacheReadInputTokens ?? 0;
|
||||||
return {
|
return {
|
||||||
promptTokens: Math.max(0, (usage.inputTokens ?? 0) - cacheReadTokens),
|
promptTokens: Math.max(
|
||||||
|
0,
|
||||||
|
(usage.inputTokens ?? 0) - cacheReadTokens
|
||||||
|
),
|
||||||
cacheReadTokens,
|
cacheReadTokens,
|
||||||
cacheWriteTokens: usage.cacheWriteInputTokens ?? 0,
|
cacheWriteTokens: usage.cacheWriteInputTokens ?? 0,
|
||||||
completionTokens: usage.outputTokens ?? 0,
|
completionTokens: usage.outputTokens ?? 0,
|
||||||
@@ -437,7 +446,12 @@ export function stripInjectedUsageFrame(sseText: string): string {
|
|||||||
if (dataLine) {
|
if (dataLine) {
|
||||||
const data = dataLine.slice("data:".length).trim();
|
const data = dataLine.slice("data:".length).trim();
|
||||||
const parsed = data !== "[DONE]" ? tryParseJson(data) : null;
|
const parsed = data !== "[DONE]" ? tryParseJson(data) : null;
|
||||||
if (parsed && Array.isArray(parsed.choices) && parsed.choices.length === 0 && parsed.usage) {
|
if (
|
||||||
|
parsed &&
|
||||||
|
Array.isArray(parsed.choices) &&
|
||||||
|
parsed.choices.length === 0 &&
|
||||||
|
parsed.usage
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import cache from "#dynamic/lib/cache";
|
|||||||
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
|
||||||
import { sanitizeString } from "@server/lib/sanitize";
|
import { sanitizeString } from "@server/lib/sanitize";
|
||||||
import type { AiCapability } from "@server/lib/aiCapabilities";
|
import type { AiCapability } from "@server/lib/aiCapabilities";
|
||||||
|
import {
|
||||||
|
normalizeAiRequest,
|
||||||
|
normalizeAiResponse
|
||||||
|
} from "@server/lib/aiMessageNormalization";
|
||||||
|
|
||||||
// Caps how much of the request/response body we keep per row, so a single
|
// Caps how much of the request/response body we keep per row, so a single
|
||||||
// huge multimodal payload can't blow up buffer memory or storage.
|
// huge multimodal payload can't blow up buffer memory or storage.
|
||||||
@@ -197,6 +201,26 @@ export function logAiSession(data: {
|
|||||||
);
|
);
|
||||||
const responseBodyText = truncateBody(data.responseText ?? "");
|
const responseBodyText = truncateBody(data.responseText ?? "");
|
||||||
|
|
||||||
|
// Uniform, capability-agnostic transcript for search/display -
|
||||||
|
// computed from the untruncated originals so normalization sees
|
||||||
|
// the full content; the normalized result gets its own
|
||||||
|
// (typically much smaller) truncation pass below.
|
||||||
|
const normalizedRequestMessages = normalizeAiRequest(
|
||||||
|
data.capability,
|
||||||
|
data.requestBody
|
||||||
|
);
|
||||||
|
const normalizedResponseMessages = normalizeAiResponse(
|
||||||
|
data.capability,
|
||||||
|
data.responseText ?? "",
|
||||||
|
data.isStream
|
||||||
|
);
|
||||||
|
const normalizedRequestText = normalizedRequestMessages
|
||||||
|
? truncateBody(JSON.stringify(normalizedRequestMessages))
|
||||||
|
: null;
|
||||||
|
const normalizedResponseText = normalizedResponseMessages
|
||||||
|
? truncateBody(JSON.stringify(normalizedResponseMessages))
|
||||||
|
: null;
|
||||||
|
|
||||||
// Prevent unbounded buffer growth - drop oldest entries if buffer is too large
|
// Prevent unbounded buffer growth - drop oldest entries if buffer is too large
|
||||||
if (sessionLogBuffer.length >= MAX_BUFFER_SIZE) {
|
if (sessionLogBuffer.length >= MAX_BUFFER_SIZE) {
|
||||||
const dropped = sessionLogBuffer.splice(0, BATCH_SIZE);
|
const dropped = sessionLogBuffer.splice(0, BATCH_SIZE);
|
||||||
@@ -217,8 +241,17 @@ export function logAiSession(data: {
|
|||||||
isStream: data.isStream,
|
isStream: data.isStream,
|
||||||
requestBody: sanitizeString(requestBodyText.value),
|
requestBody: sanitizeString(requestBodyText.value),
|
||||||
responseBody: sanitizeString(responseBodyText.value),
|
responseBody: sanitizeString(responseBodyText.value),
|
||||||
|
normalizedRequest: normalizedRequestText
|
||||||
|
? sanitizeString(normalizedRequestText.value)
|
||||||
|
: undefined,
|
||||||
|
normalizedResponse: normalizedResponseText
|
||||||
|
? sanitizeString(normalizedResponseText.value)
|
||||||
|
: undefined,
|
||||||
truncated:
|
truncated:
|
||||||
requestBodyText.truncated || responseBodyText.truncated,
|
requestBodyText.truncated ||
|
||||||
|
responseBodyText.truncated ||
|
||||||
|
(normalizedRequestText?.truncated ?? false) ||
|
||||||
|
(normalizedResponseText?.truncated ?? false),
|
||||||
statusCode: data.statusCode,
|
statusCode: data.statusCode,
|
||||||
createdAt: Date.now()
|
createdAt: Date.now()
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user