diff --git a/package-lock.json b/package-lock.json index e9a2c56..31f9975 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ "prettier": "^3.8.1", "vhtml": "^2.2.0", "vite": "^6.0.11", - "vite-plugin-singlefile": "^2.0.5" + "vite-plugin-singlefile": "^2.0.5", + "zod": "^3.25.76" }, "devDependencies": { "@types/chai": "^5.2.3", @@ -2026,6 +2027,15 @@ "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 8dbfd14..04acdc6 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "prettier": "^3.8.1", "vhtml": "^2.2.0", "vite": "^6.0.11", - "vite-plugin-singlefile": "^2.0.5" + "vite-plugin-singlefile": "^2.0.5", + "zod": "^3.25.76" }, "devDependencies": { "@types/chai": "^5.2.3", diff --git a/src/runner/templates/agents/node/manual/template.njk b/src/runner/templates/agents/node/manual/template.njk index cbf6aca..936ac6c 100644 --- a/src/runner/templates/agents/node/manual/template.njk +++ b/src/runner/templates/agents/node/manual/template.njk @@ -139,8 +139,8 @@ const TOOLS = {{ agent.tools | dump }}; } const toolDefinitionsJson{{ loop.index }} = JSON.stringify(toolDefinitions{{ loop.index }}); - const outputParts{{ loop.index }} = [{ type: "text", text: responseText{{ loop.index }} }, ...outputToolCallParts{{ loop.index }}]; - const outputMessages{{ loop.index }} = JSON.stringify([{ role: "assistant", parts: outputParts{{ loop.index }} }]); + const outputParts{{ loop.index }} = [{ type: "text", content: responseText{{ loop.index }} }, ...outputToolCallParts{{ loop.index }}]; + const outputMessages{{ loop.index }} = JSON.stringify([{ role: "assistant", parts: outputParts{{ loop.index }}, finish_reason: "stop" }]); // Agent span (parent) const agentDesc{{ loop.index }} = `invoke_agent ${AGENT_NAME}`; diff --git a/src/runner/templates/agents/python/manual/template.njk b/src/runner/templates/agents/python/manual/template.njk index f888adb..d104982 100644 --- a/src/runner/templates/agents/python/manual/template.njk +++ b/src/runner/templates/agents/python/manual/template.njk @@ -136,9 +136,9 @@ TOOLS = {{ agent.tools | dump }} tool_definitions_json = json.dumps(tool_definitions) # Build output messages - output_parts = [{"type": "text", "text": response_text}] + output_parts = [{"type": "text", "content": response_text}] output_parts.extend(output_tool_call_parts) - output_messages = json.dumps([{"role": "assistant", "parts": output_parts}]) + output_messages = json.dumps([{"role": "assistant", "parts": output_parts, "finish_reason": "stop"}]) # Agent span (parent) agent_desc = f"invoke_agent {AGENT_NAME}" diff --git a/src/runner/templates/llm/node/manual/template.njk b/src/runner/templates/llm/node/manual/template.njk index 114d48f..551f4c7 100644 --- a/src/runner/templates/llm/node/manual/template.njk +++ b/src/runner/templates/llm/node/manual/template.njk @@ -117,7 +117,7 @@ function buildSentryMessages(messages) { const responseText{{ loop.index }} = "This is a simulated response for turn {{ loop.index }}."; const outputMessages{{ loop.index }} = JSON.stringify([ - { role: "assistant", parts: [{ type: "text", text: responseText{{ loop.index }} }] }, + { role: "assistant", parts: [{ type: "text", content: responseText{{ loop.index }} }], finish_reason: "stop" }, ]); const desc{{ loop.index }} = `chat ${model{{ loop.index }}}`; diff --git a/src/runner/templates/llm/python/manual/template.njk b/src/runner/templates/llm/python/manual/template.njk index 77b2f0a..20ee15c 100644 --- a/src/runner/templates/llm/python/manual/template.njk +++ b/src/runner/templates/llm/python/manual/template.njk @@ -110,7 +110,7 @@ def build_sentry_messages(messages): response_text = "This is a simulated response for turn {{ loop.index }}." output_messages = json.dumps([ - {"role": "assistant", "parts": [{"type": "text", "text": response_text}]} + {"role": "assistant", "parts": [{"type": "text", "content": response_text}], "finish_reason": "stop"} ]) description = f"chat {model}" diff --git a/src/test-cases/checks.ts b/src/test-cases/checks.ts index b360498..2b01455 100644 --- a/src/test-cases/checks.ts +++ b/src/test-cases/checks.ts @@ -12,6 +12,8 @@ * collect or report deprecation warnings. */ +import { z } from "zod"; + import { CapturedSpan, ErrorLocation, Check } from "../types.js"; import { CheckError } from "../validator.js"; import { @@ -152,6 +154,53 @@ function parseInputMessages( return { messages: result.value as unknown[], attribute: result.usedAttribute! }; } +const OutputMessagePartSchema = z.object({ type: z.string() }).passthrough(); +const OutputMessagesSchema = z.array( + z.object({ + role: z.string(), + parts: z.array(OutputMessagePartSchema), + name: z.string().nullable().optional(), + finish_reason: z.string(), + }).passthrough(), +); + +function formatZodPath(path: Array): string { + return path.reduce((formatted, segment) => { + if (typeof segment === "number") { + return `${formatted}[${segment}]`; + } + return formatted ? `${formatted}.${segment}` : segment; + }, "messages"); +} + +/** + * Validate gen_ai.output.messages against the OTEL semantic convention schema: + * https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-output-messages.json + * + * The schema's GenericPart branch allows provider-specific part payloads, so this + * mirrors the schema envelope instead of requiring stricter fields for each type. + */ +function validateOutputMessagesSchema( + value: unknown, + attribute = "gen_ai.output.messages", +): string[] { + let parsedValue = value; + if (typeof value === "string") { + try { + parsedValue = JSON.parse(value); + } catch { + return [`Invalid JSON in ${attribute}`]; + } + } + + const result = OutputMessagesSchema.safeParse(parsedValue); + if (result.success) return []; + + return result.error.issues.map( + (issue) => `${formatZodPath(issue.path)} ${issue.message}`, + ); +} + function getMessageText(message: unknown): string | undefined { if (typeof message !== "object" || message === null) { return undefined; @@ -329,8 +378,9 @@ function assertOnlyLastInputMessage( * - description equals " " * - gen_ai.operation.name matches AI_CLIENT_OPERATION_NAME_PATTERN * - gen_ai.request.model matches expected model - * - gen_ai.request.messages exists - * - gen_ai.response.text exists + * - gen_ai.input.messages exists (or deprecated gen_ai.request.messages fallback) + * - gen_ai.output.messages exists and matches the OTEL output messages schema + * - deprecated gen_ai.response.text is not present * - gen_ai.usage.input_tokens exists * - gen_ai.usage.output_tokens exists * @@ -373,17 +423,23 @@ export const checkChatSpanAttributes: Check = { locations.push({ spanId: span.span_id, attribute: "gen_ai.input.messages", message: "Missing messages attribute" }); } - const responseResult = getAttributeWithFallback( - span, - "gen_ai.output.messages", - "gen_ai.response.text" - ); - - if (responseResult.value === undefined) { - const hasToolCalls = !!span.data?.["gen_ai.response.tool_calls"]; - if (!hasToolCalls) { - errors.push("Should have gen_ai.output.messages, gen_ai.response.text, or gen_ai.response.tool_calls"); - locations.push({ spanId: span.span_id, attribute: "gen_ai.output.messages", message: "Missing response attribute" }); + if (span.data?.["gen_ai.response.text"] !== undefined) { + const msg = 'Deprecated attribute "gen_ai.response.text" is not allowed; use "gen_ai.output.messages" instead'; + errors.push(msg); + locations.push({ spanId: span.span_id, attribute: "gen_ai.response.text", message: msg }); + } + + const outputMessages = span.data?.["gen_ai.output.messages"]; + + if (outputMessages === undefined) { + const msg = "Should have gen_ai.output.messages"; + errors.push(msg); + locations.push({ spanId: span.span_id, attribute: "gen_ai.output.messages", message: "Missing output messages attribute" }); + } else { + const schemaErrors = validateOutputMessagesSchema(outputMessages); + for (const schemaError of schemaErrors) { + errors.push(schemaError); + locations.push({ spanId: span.span_id, attribute: "gen_ai.output.messages", message: schemaError }); } } }