UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

491 lines (442 loc) 16 kB
'use strict' /** * Returns the value as a string, JSON-stringifying it when it is not already a string. * Returns the value unchanged when it is `null` or `undefined`. * * @param {unknown} value * @returns {string|undefined|null} */ function stringifyIfNeeded (value) { if (value == null) return value return typeof value === 'string' ? value : JSON.stringify(value) } const FILE_FALLBACK = '[file]' const IMAGE_FALLBACK = '[image]' const OPENAI_RESPONSE_TOOL_CALL_TYPES = new Set([ 'apply_patch_call', 'code_interpreter_call', 'computer_call', 'custom_tool_call', 'file_search_call', 'function_call', 'image_generation_call', 'local_shell_call', 'mcp_call', 'shell_call', 'web_search_call', ]) const OPENAI_RESPONSE_TOOL_OUTPUT_TYPES = new Set([ 'apply_patch_call_output', 'computer_call_output', 'custom_tool_call_output', 'function_call_output', 'local_shell_call_output', 'shell_call_output', ]) /** * Returns a stringified value, falling back to an empty string for absent values. * * @param {unknown} value * @returns {string} */ function stringifyOrEmpty (value) { return stringifyIfNeeded(value) ?? '' } /** * Converts a LanguageModelV2FilePart with an image mediaType to an AI guard style image_url content part. * * @param {{type: 'file', data: URL|string|Uint8Array, mediaType: string}} part * @returns {{type: 'image_url', image_url: {url: string}}|undefined} */ function convertFilePartToImageUrl (part) { const { data, mediaType } = part if (data instanceof URL) { return { type: 'image_url', image_url: { url: data.toString() } } } if (typeof data === 'string') { if (data.startsWith('http') || data.startsWith('data:')) { return { type: 'image_url', image_url: { url: data } } } return { type: 'image_url', image_url: { url: `data:${mediaType};base64,${data}` } } } if (data instanceof Uint8Array) { return { type: 'image_url', image_url: { url: `data:${mediaType};base64,${Buffer.from(data).toString('base64')}` } } } } /** * Converts a LanguageModelV2Prompt to the AI guard style message format. * * Vercel AI v2 prompt entries use content arrays with typed parts (e.g. { type: 'text', text }, * { type: 'file', data, mediaType }). This function converts them to AI guard style messages. * When file parts with image media types are present, the content is an array of text and * image_url parts; otherwise it is a plain string. * * @param {Array<{role: string, content: string|Array<{type: string}>}>} prompt * @returns {Array<{role: string, content?: string|Array<{type: string}>, tool_calls?: Array, tool_call_id?: string}>} */ function convertVercelPromptToMessages (prompt) { if (!Array.isArray(prompt)) return [] const messages = [] for (const msg of prompt) { switch (msg.role) { case 'system': messages.push({ role: 'system', content: typeof msg.content === 'string' ? msg.content : '' }) break case 'user': { if (!Array.isArray(msg.content)) break const contentParts = [] for (const part of msg.content) { if (part.type === 'text') { contentParts.push({ type: 'text', text: part.text }) } else if (part.type === 'file' && part.mediaType?.startsWith('image/')) { const converted = convertFilePartToImageUrl(part) if (converted) contentParts.push(converted) } } if (contentParts.length === 0) break const hasImages = contentParts.some(p => p.type === 'image_url') if (hasImages) { messages.push({ role: 'user', content: contentParts }) } else { messages.push({ role: 'user', content: contentParts.map(p => p.text).join('\n') }) } break } case 'assistant': { const textParts = [] const toolCalls = [] if (!Array.isArray(msg.content)) break for (const part of msg.content) { if (part.type === 'text') { textParts.push(part.text) } else if (part.type === 'tool-call') { toolCalls.push({ id: part.toolCallId, function: { name: part.toolName, arguments: stringifyIfNeeded(part.args ?? part.input), }, }) } } if (toolCalls.length > 0) { messages.push({ role: 'assistant', tool_calls: toolCalls }) } else if (textParts.length > 0) { messages.push({ role: 'assistant', content: textParts.join('\n') }) } break } case 'tool': { if (!Array.isArray(msg.content)) break for (const part of msg.content) { if (part.type === 'tool-result') { messages.push({ role: 'tool', tool_call_id: part.toolCallId, content: stringifyIfNeeded(part.result ?? part.output), }) } } break } } } return messages } /** * Converts OpenAI chat-completions messages to the message format expected by AI Guard. * * Modern `tool_calls` messages already match the expected shape. Deprecated chat * completions `function_call` and `function` role messages are normalized to the * equivalent tool-call shape so AI Guard can classify them as tool interactions. * * @param {Array<object>} messages * @returns {Array<object>|undefined} */ function normalizeOpenAIChatMessages (messages) { if (!Array.isArray(messages) || messages.length === 0) return const normalizedMessages = [] for (const message of messages) { const normalized = normalizeOpenAIChatMessage(message) if (normalized) normalizedMessages.push(normalized) } return normalizedMessages.length ? normalizedMessages : undefined } /** * Converts one OpenAI chat-completions message to AI Guard's expected shape. * * @param {object} message * @returns {object|undefined} */ function normalizeOpenAIChatMessage (message) { if (!message || typeof message !== 'object') return if (message.role === 'function') { return { role: 'tool', tool_call_id: message.tool_call_id ?? message.name, content: stringifyOrEmpty(message.content), } } if (!message.function_call) return message const { function_call: functionCall, ...normalized } = message const name = functionCall.name normalized.tool_calls ??= [{ id: message.tool_call_id ?? name, function: { name, arguments: stringifyOrEmpty(functionCall.arguments), }, }] return normalized } /** * Converts LLM output tool calls to AI guard style message format. * * @param {Array<object>} inputMessages - The input messages already in AI guard style format * @param {Array<{toolCallId: string, toolName: string, args?: unknown, input?: unknown}>} toolCalls * @returns {Array<object>} */ function buildToolCallOutputMessages (inputMessages, toolCalls) { return [ ...inputMessages, { role: 'assistant', tool_calls: toolCalls.map(tc => ({ id: tc.toolCallId, function: { name: tc.toolName, arguments: stringifyIfNeeded(tc.args ?? tc.input), }, })), }, ] } /** * Builds OpenAI-style output messages for the assistant's text response. * * @param {Array<object>} inputMessages - The input messages already in AI guard style format * @param {string} text - The assistant's text response * @returns {Array<object>} */ function buildTextOutputMessages (inputMessages, text) { return [ ...inputMessages, { role: 'assistant', content: text }, ] } /** * Parses a Vercel AI content array and dispatches to the appropriate output message builder. * * @param {Array<object>} inputMessages - The input messages already in AI guard style format * @param {Array<{type: string}>} content - Vercel AI content array from doGenerate/doStream result * @returns {Array<object>} */ function buildOutputMessages (inputMessages, content) { const toolCalls = content.filter(c => c.type === 'tool-call') const text = content.filter(c => c.type === 'text').map(c => c.text).join('\n') if (toolCalls.length) return buildToolCallOutputMessages(inputMessages, toolCalls) if (text) return buildTextOutputMessages(inputMessages, text) return inputMessages } /** * Converts OpenAI Responses API input/output items to OpenAI chat-style messages. * * @param {string|Array<object>|undefined} items * @param {string} defaultRole * @returns {Array<object>} */ function convertOpenAIResponseItemsToMessages (items, defaultRole) { if (typeof items === 'string') return [{ role: defaultRole, content: items }] if (!Array.isArray(items)) return [] const messages = [] for (const item of items) { const converted = openAIResponseItemToMessage(item, defaultRole) if (Array.isArray(converted)) { for (const message of converted) messages.push(message) } else if (converted) { messages.push(converted) } } return messages } /** * Converts OpenAI reusable prompt variables to user messages for AI Guard. * * The reusable prompt template body is not available on the request, but its * variables are user/application-provided content that OpenAI substitutes into * the prompt. Screening them closes prompt-only `responses.create({ prompt })` * calls and prompt variables used alongside `input`. * * @param {{variables?: Record<string, string|object>|null}|undefined|null} prompt * @returns {Array<object>} */ function convertOpenAIResponsePromptToMessages (prompt) { const variables = prompt?.variables if (!variables || typeof variables !== 'object') return [] const messages = [] for (const value of Object.values(variables)) { const content = openAIResponsePromptVariableToMessageContent(value) if (content != null) messages.push({ role: 'user', content }) } return messages } /** * Converts one OpenAI reusable prompt variable value to message content. * * Routes every variable through `openAIResponseContentToMessageContent` so the * result follows the same string-when-text-only / array-when-multimodal shape * convention used elsewhere in this file. Media variables that produce no * usable content (e.g. an `input_image` with no URL or `file_id`) fall back to * a stable text marker so AI Guard still observes that a media variable was * attached. * * @param {string|object} value * @returns {string|Array<{type: string, text?: string, image_url?: {url: string}}>|undefined} */ function openAIResponsePromptVariableToMessageContent (value) { let part if (typeof value === 'string') { part = { type: 'input_text', text: value } } else if (value && typeof value === 'object') { part = value } else { return } const content = openAIResponseContentToMessageContent([part]) if (content != null) return content if (part.type === 'input_image') return IMAGE_FALLBACK } /** * Converts one OpenAI Responses API item to an OpenAI chat-style message. * * @param {object} item * @param {string} defaultRole * @returns {object|Array<object>|undefined} */ function openAIResponseItemToMessage (item, defaultRole) { if (!item || typeof item !== 'object') return const type = item.type ?? 'message' if (type === 'message') { const content = openAIResponseContentToMessageContent(item.content) if (content != null) return { role: item.role || defaultRole, content } } else if (OPENAI_RESPONSE_TOOL_CALL_TYPES.has(type)) { return openAIResponseToolCallToMessages(item) } else if (OPENAI_RESPONSE_TOOL_OUTPUT_TYPES.has(type)) { return openAIResponseToolOutputToMessage(item) } } /** * Converts a Responses API tool-call item to one or more chat-style messages. * * Most tool-call items represent only the assistant's tool request. MCP and * image-generation items can also carry tool output on the same item, so include * a linked tool message when output-like fields are present. * * @param {object} item * @returns {object|Array<object>} */ function openAIResponseToolCallToMessages (item) { const toolCallId = item.call_id ?? item.id ?? item.name ?? item.type const message = { role: 'assistant', tool_calls: [{ id: toolCallId, function: { name: item.name ?? item.server_label ?? item.type, arguments: stringifyOrEmpty(item.arguments ?? item.input ?? item.action), }, }], } if (item.output == null && item.result == null && item.error == null) return message return [message, openAIResponseToolOutputToMessage(item)] } /** * Converts a Responses API tool-output item to a chat-style tool message. * * @param {object} item * @returns {object} */ function openAIResponseToolOutputToMessage (item) { return { role: 'tool', tool_call_id: item.call_id ?? item.id, content: openAIResponseOutputValueToMessageContent(item.output ?? item.result ?? item.error), } } /** * Converts Responses API tool output to message content. * * @param {unknown} output * @returns {string|Array<{type: string, text?: string, image_url?: {url: string}}>} */ function openAIResponseOutputValueToMessageContent (output) { const content = openAIResponseContentToMessageContent(output) return content ?? stringifyOrEmpty(output) } /** * Converts OpenAI Responses API content to OpenAI chat-style message content. * * @param {string|Array<string|{type?: string, text?: string, refusal?: string, * image_url?: string|{url?: string}, file_id?: string, file_url?: string, * filename?: string}>|undefined} content * @returns {string|Array<{type: string, text?: string, image_url?: {url: string}}>|undefined} */ function openAIResponseContentToMessageContent (content) { if (typeof content === 'string') return content if (!Array.isArray(content)) return const parts = [] let hasImages = false for (const part of content) { if (!part) continue if (typeof part === 'string') { parts.push({ type: 'text', text: part }) } else if ((part.type === 'input_text' || part.type === 'output_text' || part.type === 'text') && typeof part.text === 'string') { parts.push({ type: 'text', text: part.text }) } else if (part.type === 'refusal' && typeof part.refusal === 'string') { parts.push({ type: 'text', text: part.refusal }) } else if (part.type === 'input_image' || part.type === 'image_url') { const image = openAIResponseImageContentPart(part) if (image) { hasImages = true parts.push(image) } } else if (part.type === 'input_file') { parts.push({ type: 'text', text: openAIResponseFileContentPart(part) }) } } if (!parts.length) return if (hasImages) return parts return parts.map(part => part.text).join('\n') } /** * Converts an OpenAI image content part to AI Guard image_url content. * * @param {{image_url?: string|{url?: string}, file_id?: string, url?: string}} part * @returns {{type: 'image_url', image_url: {url: string}}|undefined} */ function openAIResponseImageContentPart (part) { const url = typeof part.image_url === 'string' ? part.image_url : part.image_url?.url ?? part.url if (url) return { type: 'image_url', image_url: { url } } if (part.file_id) return { type: 'image_url', image_url: { url: part.file_id } } } /** * Extracts a stable text marker from an OpenAI file content part. * * @param {{file_id?: string|null, file_url?: string, filename?: string, file_data?: string}} part * @returns {string} */ function openAIResponseFileContentPart (part) { return part.file_id ?? part.file_url ?? part.filename ?? FILE_FALLBACK } module.exports = { convertVercelPromptToMessages, convertFilePartToImageUrl, normalizeOpenAIChatMessages, buildToolCallOutputMessages, buildTextOutputMessages, buildOutputMessages, convertOpenAIResponseItemsToMessages, convertOpenAIResponsePromptToMessages, openAIResponseContentToMessageContent, }