|
12 | 12 | * collect or report deprecation warnings. |
13 | 13 | */ |
14 | 14 |
|
| 15 | +import { z } from "zod"; |
| 16 | + |
15 | 17 | import { CapturedSpan, ErrorLocation, Check } from "../types.js"; |
16 | 18 | import { CheckError } from "../validator.js"; |
17 | 19 | import { |
@@ -152,6 +154,53 @@ function parseInputMessages( |
152 | 154 | return { messages: result.value as unknown[], attribute: result.usedAttribute! }; |
153 | 155 | } |
154 | 156 |
|
| 157 | +const OutputMessagePartSchema = z.object({ type: z.string() }).passthrough(); |
| 158 | +const OutputMessagesSchema = z.array( |
| 159 | + z.object({ |
| 160 | + role: z.string(), |
| 161 | + parts: z.array(OutputMessagePartSchema), |
| 162 | + name: z.string().nullable().optional(), |
| 163 | + finish_reason: z.string(), |
| 164 | + }).passthrough(), |
| 165 | +); |
| 166 | + |
| 167 | +function formatZodPath(path: Array<string | number>): string { |
| 168 | + return path.reduce<string>((formatted, segment) => { |
| 169 | + if (typeof segment === "number") { |
| 170 | + return `${formatted}[${segment}]`; |
| 171 | + } |
| 172 | + return formatted ? `${formatted}.${segment}` : segment; |
| 173 | + }, "messages"); |
| 174 | +} |
| 175 | + |
| 176 | +/** |
| 177 | + * Validate gen_ai.output.messages against the OTEL semantic convention schema: |
| 178 | + * https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-output-messages.json |
| 179 | + * |
| 180 | + * The schema's GenericPart branch allows provider-specific part payloads, so this |
| 181 | + * mirrors the schema envelope instead of requiring stricter fields for each type. |
| 182 | + */ |
| 183 | +function validateOutputMessagesSchema( |
| 184 | + value: unknown, |
| 185 | + attribute = "gen_ai.output.messages", |
| 186 | +): string[] { |
| 187 | + let parsedValue = value; |
| 188 | + if (typeof value === "string") { |
| 189 | + try { |
| 190 | + parsedValue = JSON.parse(value); |
| 191 | + } catch { |
| 192 | + return [`Invalid JSON in ${attribute}`]; |
| 193 | + } |
| 194 | + } |
| 195 | + |
| 196 | + const result = OutputMessagesSchema.safeParse(parsedValue); |
| 197 | + if (result.success) return []; |
| 198 | + |
| 199 | + return result.error.issues.map( |
| 200 | + (issue) => `${formatZodPath(issue.path)} ${issue.message}`, |
| 201 | + ); |
| 202 | +} |
| 203 | + |
155 | 204 | function getMessageText(message: unknown): string | undefined { |
156 | 205 | if (typeof message !== "object" || message === null) { |
157 | 206 | return undefined; |
@@ -329,8 +378,9 @@ function assertOnlyLastInputMessage( |
329 | 378 | * - description equals "<gen_ai.operation.name> <gen_ai.request.model>" |
330 | 379 | * - gen_ai.operation.name matches AI_CLIENT_OPERATION_NAME_PATTERN |
331 | 380 | * - gen_ai.request.model matches expected model |
332 | | - * - gen_ai.request.messages exists |
333 | | - * - gen_ai.response.text exists |
| 381 | + * - gen_ai.input.messages exists (or deprecated gen_ai.request.messages fallback) |
| 382 | + * - gen_ai.output.messages exists and matches the OTEL output messages schema |
| 383 | + * - deprecated gen_ai.response.text is not present |
334 | 384 | * - gen_ai.usage.input_tokens exists |
335 | 385 | * - gen_ai.usage.output_tokens exists |
336 | 386 | * |
@@ -373,17 +423,23 @@ export const checkChatSpanAttributes: Check = { |
373 | 423 | locations.push({ spanId: span.span_id, attribute: "gen_ai.input.messages", message: "Missing messages attribute" }); |
374 | 424 | } |
375 | 425 |
|
376 | | - const responseResult = getAttributeWithFallback( |
377 | | - span, |
378 | | - "gen_ai.output.messages", |
379 | | - "gen_ai.response.text" |
380 | | - ); |
381 | | - |
382 | | - if (responseResult.value === undefined) { |
383 | | - const hasToolCalls = !!span.data?.["gen_ai.response.tool_calls"]; |
384 | | - if (!hasToolCalls) { |
385 | | - errors.push("Should have gen_ai.output.messages, gen_ai.response.text, or gen_ai.response.tool_calls"); |
386 | | - locations.push({ spanId: span.span_id, attribute: "gen_ai.output.messages", message: "Missing response attribute" }); |
| 426 | + if (span.data?.["gen_ai.response.text"] !== undefined) { |
| 427 | + const msg = 'Deprecated attribute "gen_ai.response.text" is not allowed; use "gen_ai.output.messages" instead'; |
| 428 | + errors.push(msg); |
| 429 | + locations.push({ spanId: span.span_id, attribute: "gen_ai.response.text", message: msg }); |
| 430 | + } |
| 431 | + |
| 432 | + const outputMessages = span.data?.["gen_ai.output.messages"]; |
| 433 | + |
| 434 | + if (outputMessages === undefined) { |
| 435 | + const msg = "Should have gen_ai.output.messages"; |
| 436 | + errors.push(msg); |
| 437 | + locations.push({ spanId: span.span_id, attribute: "gen_ai.output.messages", message: "Missing output messages attribute" }); |
| 438 | + } else { |
| 439 | + const schemaErrors = validateOutputMessagesSchema(outputMessages); |
| 440 | + for (const schemaError of schemaErrors) { |
| 441 | + errors.push(schemaError); |
| 442 | + locations.push({ spanId: span.span_id, attribute: "gen_ai.output.messages", message: schemaError }); |
387 | 443 | } |
388 | 444 | } |
389 | 445 | } |
|
0 commit comments