Path: blob/main/extensions/copilot/src/platform/otel/common/messageFormatters.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45/**6* Converts internal message types to OTel GenAI JSON schema format.7* @see https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-input-messages.json8* @see https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-output-messages.json9*/1011/**12* Maximum size (in characters) for a single OTel span/log attribute value.13* Aligned with common backend limits (Jaeger 64KB, Tempo 100KB).14* Matches gemini-cli's approach of capping content to prevent OTLP batch failures.15*/16const MAX_OTEL_ATTRIBUTE_LENGTH = 64_000;1718/**19* Truncate a string to fit within OTel attribute size limits.20* Returns the original string if within bounds, otherwise truncates with a suffix.21*/22export function truncateForOTel(value: string, maxLength: number = MAX_OTEL_ATTRIBUTE_LENGTH): string {23if (value.length <= maxLength) {24return value;25}26const suffix = `...[truncated, original ${value.length} chars]`;27return value.substring(0, maxLength - suffix.length) + suffix;28}2930export interface OTelChatMessage {31role: string | undefined;32parts: OTelMessagePart[];33}3435export interface OTelOutputMessage extends OTelChatMessage {36finish_reason?: string;37}3839export type OTelMessagePart =40| { type: 'text'; content: string }41| { type: 'tool_call'; id: string; name: string; arguments: unknown }42| { type: 'tool_call_response'; id: string; response: unknown }43| { type: 'reasoning'; content: string };4445export type OTelSystemInstruction = Array<{ type: 'text'; content: string }>;4647export interface OTelToolDefinition {48type: 'function';49name: string;50description?: string;51parameters?: unknown;52}5354/**55* Convert an array of internal messages to OTel input message format.56* Handles OpenAI format (tool_calls, tool_call_id) natively.57*/58export function toInputMessages(messages: ReadonlyArray<{ role?: string; content?: string; tool_calls?: ReadonlyArray<{ id: string; function: { name: string; arguments: string } }>; tool_call_id?: string }>): OTelChatMessage[] {59return messages.map(msg => {60const parts: OTelMessagePart[] = [];6162// OpenAI tool-result message (role=tool): map to tool_call_response63if (msg.role === 'tool' && msg.tool_call_id) {64parts.push({ type: 'tool_call_response', id: msg.tool_call_id, response: msg.content ?? '' });65return { role: msg.role, parts };66}6768if (msg.content) {69parts.push({ type: 'text', content: msg.content });70}7172if (msg.tool_calls) {73for (const tc of msg.tool_calls) {74let args: unknown;75try { args = JSON.parse(tc.function.arguments); } catch { args = tc.function.arguments; }76parts.push({77type: 'tool_call',78id: tc.id,79name: tc.function.name,80arguments: args,81});82}83}8485return { role: msg.role, parts };86});87}8889/**90* Convert model response choices to OTel output message format.91*/92export function toOutputMessages(choices: ReadonlyArray<{93message?: { role?: string; content?: string; tool_calls?: ReadonlyArray<{ id: string; function: { name: string; arguments: string } }> };94finish_reason?: string;95}>): OTelOutputMessage[] {96return choices.map(choice => {97const parts: OTelMessagePart[] = [];98const msg = choice.message;99100if (msg?.content) {101parts.push({ type: 'text', content: msg.content });102}103104if (msg?.tool_calls) {105for (const tc of msg.tool_calls) {106let args: unknown;107try { args = JSON.parse(tc.function.arguments); } catch { args = tc.function.arguments; }108parts.push({109type: 'tool_call',110id: tc.id,111name: tc.function.name,112arguments: args,113});114}115}116117return {118role: msg?.role ?? 'assistant',119parts,120finish_reason: choice.finish_reason,121};122});123}124125/**126* Convert system message to OTel system instruction format.127*/128export function toSystemInstructions(systemMessage: string | undefined): OTelSystemInstruction | undefined {129if (!systemMessage) {130return undefined;131}132return [{ type: 'text', content: systemMessage }];133}134135/**136* Normalize provider-specific messages (Anthropic content blocks, OpenAI tool messages)137* to OTel GenAI semantic convention format.138*139* Handles:140* - Anthropic content block arrays: tool_use → tool_call, tool_result → tool_call_response141* - OpenAI format: tool_calls, role=tool with tool_call_id142* - Plain string content143*/144export function normalizeProviderMessages(messages: ReadonlyArray<Record<string, unknown>>): OTelChatMessage[] {145return messages.map(msg => {146const role = msg.role as string | undefined;147const parts: OTelMessagePart[] = [];148const content = msg.content;149150// OpenAI tool-result message151if (role === 'tool' && typeof msg.tool_call_id === 'string') {152parts.push({ type: 'tool_call_response', id: msg.tool_call_id, response: content ?? '' });153return { role, parts };154}155156if (typeof content === 'string' && content.length > 0) {157parts.push({ type: 'text', content });158} else if (Array.isArray(content)) {159// Anthropic content block array160for (const block of content) {161if (!block || typeof block !== 'object') { continue; }162const b = block as Record<string, unknown>;163switch (b.type) {164case 'text':165if (typeof b.text === 'string') {166parts.push({ type: 'text', content: b.text });167}168break;169case 'tool_use':170parts.push({171type: 'tool_call',172id: String(b.id ?? ''),173name: String(b.name ?? ''),174arguments: b.input,175});176break;177case 'tool_result':178parts.push({179type: 'tool_call_response',180id: String(b.tool_use_id ?? ''),181response: b.content ?? '',182});183break;184case 'thinking':185if (typeof b.thinking === 'string') {186parts.push({ type: 'reasoning', content: b.thinking });187}188break;189default:190// Unknown block type — include as text fallback191parts.push({ type: 'text', content: JSON.stringify(b) });192break;193}194}195}196197// OpenAI tool_calls198const toolCalls = msg.tool_calls;199if (Array.isArray(toolCalls)) {200for (const tc of toolCalls) {201if (!tc || typeof tc !== 'object') { continue; }202const call = tc as Record<string, unknown>;203const fn = call.function as Record<string, unknown> | undefined;204if (fn) {205let args: unknown;206try { args = typeof fn.arguments === 'string' ? JSON.parse(fn.arguments) : fn.arguments; } catch { args = fn.arguments; }207parts.push({208type: 'tool_call',209id: String(call.id ?? ''),210name: String(fn.name ?? ''),211arguments: args,212});213}214}215}216217return { role, parts };218});219}220221/**222* Convert tool definitions to OTel `gen_ai.tool.definitions` format.223*224* Accepts the variants emitted by the different request bodies/providers:225* - OpenAI Chat Completions: `{ type: 'function', function: { name, description, parameters } }`226* - OpenAI Responses API: `{ type: 'function', name, description, parameters }`227* - Anthropic Messages API: `{ name, description, input_schema }`228* - VS Code tool info: `{ name, description, inputSchema }`229*230* Tools without a name (e.g. OpenAI client-side `tool_search`) are skipped231* because OTel `gen_ai.tool.definitions` requires a name per entry.232*233* @see https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/#gen-ai-tool-definitions234*/235export function toToolDefinitions(tools: ReadonlyArray<{236type?: string;237name?: string;238description?: string;239parameters?: unknown;240input_schema?: unknown;241inputSchema?: unknown;242function?: { name?: string; description?: string; parameters?: unknown };243}> | undefined): OTelToolDefinition[] | undefined {244if (!tools || tools.length === 0) {245return undefined;246}247const out: OTelToolDefinition[] = [];248for (const t of tools) {249const name = t.function?.name ?? t.name;250if (!name) {251continue;252}253const description = t.function?.description ?? t.description;254const parameters = t.function?.parameters ?? t.parameters ?? t.input_schema ?? t.inputSchema;255out.push({256type: 'function',257name,258description,259parameters,260});261}262return out.length > 0 ? out : undefined;263}264265266