428 lines
13 KiB
TypeScript
428 lines
13 KiB
TypeScript
import { getLimits } from "./limits.js";
|
|
import { createCapturePolicy, type CapturePolicy } from "./capture-policy.js";
|
|
import { redactValue } from "./redaction.js";
|
|
import { state } from "./state.js";
|
|
|
|
export function getCapturePolicy(): CapturePolicy {
|
|
return state.config?.capturePolicy ?? createCapturePolicy();
|
|
}
|
|
|
|
export { getLimits };
|
|
|
|
export function truncate(value: string, maxLength = getLimits().maxString): string {
|
|
return value.length > maxLength ? `${value.slice(0, maxLength)}... [truncated]` : value;
|
|
}
|
|
|
|
export function tryParseJson(value: string): unknown {
|
|
const trimmed = value.trim();
|
|
if (!trimmed || !["{", "["].includes(trimmed[0])) {
|
|
return value;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(trimmed);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
const PAYLOAD_TOO_LARGE = "[payload too large]";
|
|
|
|
export function shapePayload(
|
|
value: unknown,
|
|
options: {
|
|
maxString?: number;
|
|
depth?: number;
|
|
maxNodes?: number;
|
|
maxArrayItems?: number;
|
|
maxObjectKeys?: number;
|
|
redact?: boolean;
|
|
parseJson?: boolean;
|
|
} = {},
|
|
): unknown {
|
|
const limits = getLimits();
|
|
const maxString = options.maxString ?? limits.maxString;
|
|
const depth = options.depth ?? limits.maxDepth;
|
|
const maxNodes = options.maxNodes ?? limits.maxNodes;
|
|
const maxArrayItems = options.maxArrayItems ?? limits.maxArrayItems;
|
|
const maxObjectKeys = options.maxObjectKeys ?? limits.maxObjectKeys;
|
|
const budget = { exhausted: false, nodeCount: 0 };
|
|
|
|
function visit(item: unknown, remainingDepth: number, seen: WeakSet<object>): unknown {
|
|
if (budget.exhausted) {
|
|
return PAYLOAD_TOO_LARGE;
|
|
}
|
|
|
|
budget.nodeCount++;
|
|
if (budget.nodeCount > maxNodes) {
|
|
budget.exhausted = true;
|
|
return PAYLOAD_TOO_LARGE;
|
|
}
|
|
|
|
if (typeof item === "string") {
|
|
const truncated = truncate(item, maxString);
|
|
if (options.parseJson === false) {
|
|
return truncated;
|
|
}
|
|
const parsed = tryParseJson(truncated);
|
|
if (parsed === truncated) {
|
|
return truncated;
|
|
}
|
|
return visit(parsed, remainingDepth - 1, seen);
|
|
}
|
|
|
|
if (
|
|
item === null ||
|
|
typeof item === "undefined" ||
|
|
typeof item === "number" ||
|
|
typeof item === "boolean"
|
|
) {
|
|
return item;
|
|
}
|
|
|
|
if (typeof item === "bigint") {
|
|
return item.toString();
|
|
}
|
|
|
|
if (typeof item === "function" || typeof item === "symbol") {
|
|
return `[${typeof item}]`;
|
|
}
|
|
|
|
if (remainingDepth <= 0) {
|
|
return `[max depth ${depth} reached]`;
|
|
}
|
|
|
|
if (Array.isArray(item)) {
|
|
const output: unknown[] = [];
|
|
const limit = Math.min(item.length, maxArrayItems);
|
|
for (let index = 0; index < limit; index++) {
|
|
output.push(visit(item[index], remainingDepth - 1, seen));
|
|
if (budget.exhausted) {
|
|
break;
|
|
}
|
|
}
|
|
return output;
|
|
}
|
|
|
|
if (item instanceof Error) {
|
|
return {
|
|
name: item.name,
|
|
message: item.message,
|
|
stack: item.stack ? truncate(item.stack, maxString) : undefined,
|
|
};
|
|
}
|
|
|
|
if (typeof item === "object") {
|
|
if (seen.has(item)) {
|
|
return "[circular]";
|
|
}
|
|
seen.add(item);
|
|
|
|
const output: Record<string, unknown> = {};
|
|
let keyCount = 0;
|
|
for (const key in item as Record<string, unknown>) {
|
|
if (!Object.hasOwn(item, key)) {
|
|
continue;
|
|
}
|
|
output[key] = visit((item as Record<string, unknown>)[key], remainingDepth - 1, seen);
|
|
keyCount++;
|
|
if (budget.exhausted || keyCount >= maxObjectKeys) {
|
|
break;
|
|
}
|
|
}
|
|
return output;
|
|
}
|
|
|
|
return String(item);
|
|
}
|
|
|
|
const shaped = visit(value, depth, new WeakSet<object>());
|
|
return options.redact === false
|
|
? shaped
|
|
: redactValue(shaped, {
|
|
maxDepth: depth,
|
|
maxStringLength: maxString,
|
|
maxArrayItems,
|
|
maxObjectKeys,
|
|
});
|
|
}
|
|
|
|
export function safeSerialize(value: unknown, maxLength = getLimits().maxToolPayload): string {
|
|
try {
|
|
return truncate(JSON.stringify(shapePayload(value, { maxString: maxLength }), null, 2), maxLength);
|
|
} catch {
|
|
return `[unserializable ${typeof value}]`;
|
|
}
|
|
}
|
|
|
|
export function estimatePayloadBytes(value: unknown, maxLength = getLimits().maxToolPayload): number {
|
|
return new TextEncoder().encode(safeSerialize(value, maxLength)).length;
|
|
}
|
|
|
|
export function extractTextContent(content: unknown, maxLength?: number): string | undefined {
|
|
if (typeof content === "string") {
|
|
return maxLength ? truncate(content, maxLength) : content;
|
|
}
|
|
|
|
if (!Array.isArray(content)) {
|
|
return undefined;
|
|
}
|
|
|
|
const text = content
|
|
.map((item) => {
|
|
if (!item || typeof item !== "object") return "";
|
|
const block = item as { type?: string; text?: string; thinking?: string };
|
|
return block.type === "text" && block.text ? block.text : "";
|
|
})
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
|
|
if (!text) {
|
|
return undefined;
|
|
}
|
|
|
|
return maxLength ? truncate(text, maxLength) : text;
|
|
}
|
|
|
|
export function normalizeContentForLangfuse(content: unknown, api?: string): unknown {
|
|
if (!Array.isArray(content)) {
|
|
return content;
|
|
}
|
|
|
|
const toolCallItems = content.filter((item) => {
|
|
return item && typeof item === "object" && (item as { type?: string }).type === "toolCall";
|
|
});
|
|
if (toolCallItems.length === 0) {
|
|
return content;
|
|
}
|
|
|
|
const text = content
|
|
.map((item) => {
|
|
if (!item || typeof item !== "object") return "";
|
|
const block = item as { type?: string; text?: string };
|
|
return block.type === "text" && typeof block.text === "string" ? block.text : "";
|
|
})
|
|
.filter(Boolean)
|
|
.join("");
|
|
|
|
if (api === "anthropic-messages") {
|
|
const blocks: unknown[] = [];
|
|
if (text) {
|
|
blocks.push({ type: "text", text });
|
|
}
|
|
for (const item of toolCallItems) {
|
|
const toolCall = item as { id?: unknown; name?: unknown; arguments?: unknown };
|
|
const toolInput = shapePayload(toolCall.arguments, { parseJson: false });
|
|
blocks.push({
|
|
type: "tool_use",
|
|
id: String(toolCall.id ?? ""),
|
|
name: String(toolCall.name ?? "tool"),
|
|
input: toolInput,
|
|
});
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
return {
|
|
role: "assistant",
|
|
content: text || null,
|
|
tool_calls: toolCallItems.map((item) => {
|
|
const toolCall = item as { id?: unknown; name?: unknown; arguments?: unknown };
|
|
const toolArguments = shapePayload(toolCall.arguments ?? {}, { parseJson: false });
|
|
return {
|
|
id: String(toolCall.id ?? ""),
|
|
type: "function",
|
|
function: {
|
|
name: String(toolCall.name ?? "tool"),
|
|
arguments: typeof toolArguments === "string" ? toolArguments : JSON.stringify(toolArguments),
|
|
},
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
export function extractToolCalls(message: Record<string, unknown>): unknown | undefined {
|
|
return (
|
|
message.toolCalls ??
|
|
message.tool_calls ??
|
|
message.function_calls ??
|
|
(message.content && Array.isArray(message.content)
|
|
? message.content.filter((block) => {
|
|
return block && typeof block === "object" && ["tool_use", "tool_call", "toolCall"].includes(String((block as { type?: string }).type));
|
|
})
|
|
: undefined)
|
|
);
|
|
}
|
|
|
|
export function extractAssistantOutput(message: unknown): unknown | undefined {
|
|
if (!message || typeof message !== "object") {
|
|
return undefined;
|
|
}
|
|
|
|
const msg = message as Record<string, unknown>;
|
|
const normalizedContent = normalizeContentForLangfuse(msg.content, typeof msg.api === "string" ? msg.api : undefined);
|
|
if (normalizedContent !== msg.content) {
|
|
return shapePayload(normalizedContent, { parseJson: false });
|
|
}
|
|
|
|
const text = extractTextContent(msg.content);
|
|
if (text) {
|
|
return text;
|
|
}
|
|
|
|
const toolCalls = extractToolCalls(msg);
|
|
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
|
|
return { toolCalls: shapePayload(toolCalls) };
|
|
}
|
|
|
|
if (toolCalls) {
|
|
return { toolCalls: shapePayload(toolCalls) };
|
|
}
|
|
|
|
return shapePayload(msg);
|
|
}
|
|
|
|
export function extractFinalAssistant(messages: unknown): Record<string, unknown> | undefined {
|
|
if (!Array.isArray(messages)) {
|
|
return undefined;
|
|
}
|
|
return messages.filter((message) => message?.role === "assistant").pop() as Record<string, unknown> | undefined;
|
|
}
|
|
|
|
export function getRequestKey(event: Record<string, unknown>, fallback: string): string {
|
|
return String(
|
|
event.requestId ??
|
|
event.providerRequestId ??
|
|
event.messageId ??
|
|
event.turnId ??
|
|
event.turnIndex ??
|
|
event.id ??
|
|
fallback,
|
|
);
|
|
}
|
|
|
|
export function getToolCallId(event: Record<string, unknown>): string | undefined {
|
|
const id = event.toolCallId ?? event.id ?? event.callId ?? event.tool_use_id ?? event.toolUseId;
|
|
return id === undefined || id === null ? undefined : String(id);
|
|
}
|
|
|
|
export function getToolName(event: Record<string, unknown>): string {
|
|
return String(
|
|
event.toolName ??
|
|
event.name ??
|
|
event.tool ??
|
|
event.functionName ??
|
|
(event.call && typeof event.call === "object" ? (event.call as Record<string, unknown>).name : undefined) ??
|
|
"tool",
|
|
);
|
|
}
|
|
|
|
export function getToolInput(event: Record<string, unknown>): unknown {
|
|
return (
|
|
event.input ??
|
|
event.args ??
|
|
event.arguments ??
|
|
event.params ??
|
|
(event.call && typeof event.call === "object" ? (event.call as Record<string, unknown>).input : undefined) ??
|
|
event
|
|
);
|
|
}
|
|
|
|
export function getProviderPayload(event: Record<string, unknown>): unknown {
|
|
return event.request ?? event.payload ?? event.body ?? event.providerPayload ?? event.messages ?? event;
|
|
}
|
|
|
|
export function extractModelParameters(payload: unknown): Record<string, string | number> | undefined {
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
return undefined;
|
|
}
|
|
|
|
const params: Record<string, string | number> = {};
|
|
const record = payload as Record<string, unknown>;
|
|
for (const key of [
|
|
"temperature",
|
|
"top_p",
|
|
"topP",
|
|
"max_tokens",
|
|
"maxTokens",
|
|
"max_completion_tokens",
|
|
"presence_penalty",
|
|
"frequency_penalty",
|
|
"reasoning_effort",
|
|
]) {
|
|
const value = record[key];
|
|
if (typeof value === "string" || typeof value === "number") {
|
|
params[key] = value;
|
|
}
|
|
}
|
|
|
|
return Object.keys(params).length > 0 ? params : undefined;
|
|
}
|
|
|
|
export function getMessageFromEvent(event: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
if (event.message && typeof event.message === "object") {
|
|
return event.message as Record<string, unknown>;
|
|
}
|
|
if (event.role || event.content) {
|
|
return event;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function extractUsage(messageOrEvent: Record<string, unknown>): Record<string, number> | undefined {
|
|
const usage = (messageOrEvent.usage ??
|
|
(messageOrEvent.message && typeof messageOrEvent.message === "object"
|
|
? (messageOrEvent.message as Record<string, unknown>).usage
|
|
: undefined)) as Record<string, unknown> | undefined;
|
|
if (!usage || typeof usage !== "object") {
|
|
return undefined;
|
|
}
|
|
|
|
const input = Number(usage.input ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens ?? 0);
|
|
const output = Number(usage.output ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens ?? 0);
|
|
const total = Number(usage.total ?? usage.totalTokens ?? usage.total_tokens ?? input + output);
|
|
const cacheRead = Number(usage.cacheRead ?? usage.cache_read ?? usage.cachedTokens ?? 0);
|
|
const cacheWrite = Number(usage.cacheWrite ?? usage.cache_write ?? 0);
|
|
|
|
return {
|
|
input,
|
|
output,
|
|
total,
|
|
...(cacheRead ? { cacheRead } : {}),
|
|
...(cacheWrite ? { cacheWrite } : {}),
|
|
};
|
|
}
|
|
|
|
export function extractCostDetails(messageOrEvent: Record<string, unknown>): Record<string, number> | undefined {
|
|
const usage = (messageOrEvent.usage ??
|
|
(messageOrEvent.message && typeof messageOrEvent.message === "object"
|
|
? (messageOrEvent.message as Record<string, unknown>).usage
|
|
: undefined)) as Record<string, unknown> | undefined;
|
|
const cost = (messageOrEvent.cost ?? usage?.cost ?? messageOrEvent.costDetails) as Record<string, unknown> | undefined;
|
|
if (!cost || typeof cost !== "object") {
|
|
return undefined;
|
|
}
|
|
|
|
const input = Number(cost.input ?? cost.inputCost ?? 0);
|
|
const output = Number(cost.output ?? cost.outputCost ?? 0);
|
|
const total = Number(cost.total ?? cost.totalCost ?? input + output);
|
|
if (input === 0 && output === 0 && total === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return { input, output, total };
|
|
}
|
|
|
|
export function extractResponseMetadata(event: Record<string, unknown>): Record<string, unknown> {
|
|
return shapePayload(
|
|
{
|
|
status: event.status ?? event.statusCode ?? event.httpStatus,
|
|
headers: event.headers,
|
|
responseHeaders: event.responseHeaders,
|
|
providerMetadata: event.providerMetadata ?? event.metadata,
|
|
requestId: event.requestId ?? event.providerRequestId,
|
|
},
|
|
{ depth: 4, maxString: 4_000 },
|
|
) as Record<string, unknown>;
|
|
}
|