Skip to content

Commit 0614f43

Browse files
sabrennerrochdev
authored andcommitted
fix(llmobs, anthropic): do not mutate anthropic create parameters (#8119)
* fix parameter modification * use Object.freeze * refactor to process system message like any other message
1 parent 3e0fda9 commit 0614f43

4 files changed

Lines changed: 194 additions & 63 deletions

File tree

packages/dd-trace/src/llmobs/plugins/anthropic.js renamed to packages/dd-trace/src/llmobs/plugins/anthropic/index.js

Lines changed: 5 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use strict'
22

3-
const { UNKNOWN_MODEL_PROVIDER } = require('../constants/tags')
4-
const LLMObsPlugin = require('./base')
3+
const { UNKNOWN_MODEL_PROVIDER } = require('../../constants/tags')
4+
const LLMObsPlugin = require('../base')
5+
const { appendMessage } = require('./util')
56

67
const ALLOWED_METADATA_KEYS = new Set([
78
'max_tokens',
@@ -144,51 +145,11 @@ class AnthropicLLMObsPlugin extends LLMObsPlugin {
144145
const inputMessages = []
145146

146147
if (system) {
147-
messages.unshift({ content: system, role: 'system' })
148+
appendMessage(inputMessages, { role: 'system', content: system })
148149
}
149150

150151
for (const message of messages) {
151-
const { content, role } = message
152-
153-
if (typeof content === 'string') {
154-
inputMessages.push({ content, role })
155-
continue
156-
}
157-
158-
for (const block of content) {
159-
if (block.type === 'text') {
160-
inputMessages.push({ content: block.text, role })
161-
} else if (block.type === 'image') {
162-
inputMessages.push({ content: '([IMAGE DETECTED])', role })
163-
} else if (block.type === 'tool_use') {
164-
const { text, name, id, type } = block
165-
let input = block.input
166-
if (typeof input === 'string') {
167-
input = JSON.parse(input)
168-
}
169-
170-
const toolCall = {
171-
name,
172-
arguments: input,
173-
toolId: id,
174-
type,
175-
}
176-
177-
inputMessages.push({ content: text ?? '', role, toolCalls: [toolCall] })
178-
} else if (block.type === 'tool_result') {
179-
const { content } = block
180-
const formattedContent = this.#formatAnthropicToolResultContent(content)
181-
const toolResult = {
182-
result: formattedContent,
183-
toolId: block.tool_use_id,
184-
type: 'tool_result',
185-
}
186-
187-
inputMessages.push({ content: '', role, toolResults: [toolResult] })
188-
} else {
189-
inputMessages.push({ content: JSON.stringify(block), role })
190-
}
191-
}
152+
appendMessage(inputMessages, message)
192153
}
193154

194155
this._tagger.tagLLMIO(span, inputMessages)
@@ -273,25 +234,6 @@ class AnthropicLLMObsPlugin extends LLMObsPlugin {
273234

274235
this._tagger.tagMetrics(span, metrics)
275236
}
276-
277-
// maybe can make into a util file
278-
#formatAnthropicToolResultContent (content) {
279-
if (typeof content === 'string') {
280-
return content
281-
} else if (Array.isArray(content)) {
282-
const formattedContent = []
283-
for (const toolResultBlock of content) {
284-
if (toolResultBlock.text) {
285-
formattedContent.push(toolResultBlock.text)
286-
} else if (toolResultBlock.type === 'image') {
287-
formattedContent.push('([IMAGE DETECTED])')
288-
}
289-
}
290-
291-
return formattedContent.join(',')
292-
}
293-
return JSON.stringify(content)
294-
}
295237
}
296238

297239
module.exports = AnthropicLLMObsPlugin
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
'use strict'
2+
3+
/**
4+
* @typedef {{type: 'text', text: string}} TextBlock
5+
* @typedef {{type: 'image'}} ImageBlock
6+
* @typedef {{
7+
* type: 'tool_use', text: string, name: string, id: string, input: string | Record<string, unknown>
8+
* }} ToolUseBlock
9+
* @typedef {{
10+
* type: 'tool_result',
11+
* tool_use_id: string,
12+
* content: string | Array<{type: string, text?: string}>
13+
* }} ToolResultBlock
14+
*
15+
* @typedef {{
16+
* content: string,
17+
* role: string,
18+
* toolCalls?: Array<{
19+
* name: string,
20+
* arguments: string | Record<string, unknown>,
21+
* toolId: string,
22+
* type: string
23+
* }>,
24+
* toolResults?: Array<{
25+
* result: string,
26+
* toolId: string,
27+
* type: 'tool_result'
28+
* }>
29+
* }} AnthropicLlmObsMessage
30+
*/
31+
32+
/**
33+
* Formats tool result into LLM Observability compatible contents
34+
* @param {ToolResultBlock['content']} content
35+
*/
36+
function formatAnthropicToolResultContent (content) {
37+
if (typeof content === 'string') {
38+
return content
39+
} else if (Array.isArray(content)) {
40+
const formattedContent = []
41+
for (const toolResultBlock of content) {
42+
if (toolResultBlock.text) {
43+
formattedContent.push(toolResultBlock.text)
44+
} else if (toolResultBlock.type === 'image') {
45+
formattedContent.push('([IMAGE DETECTED])')
46+
}
47+
}
48+
49+
return formattedContent.join(',')
50+
}
51+
return JSON.stringify(content)
52+
}
53+
54+
/**
55+
* Normalizes and formats a message into LLM Observability compatible contents.
56+
* Can be spread into a list of other messages.
57+
*
58+
* @param {AnthropicLlmObsMessage[]} messages
59+
* @param {{ role: string, content: string | Array<TextBlock | ImageBlock | ToolUseBlock | ToolResultBlock> }} message
60+
* @returns {void}
61+
*/
62+
function appendMessage (messages, { role, content }) {
63+
if (typeof content === 'string') {
64+
messages.push({ content, role })
65+
return
66+
}
67+
68+
for (const block of content) {
69+
if (block.type === 'text') {
70+
messages.push({ content: block.text, role })
71+
} else if (block.type === 'image') {
72+
messages.push({ content: '([IMAGE DETECTED])', role })
73+
} else if (block.type === 'tool_use') {
74+
const { text, name, id, type } = block
75+
let input = block.input
76+
if (typeof input === 'string') {
77+
input = JSON.parse(input)
78+
}
79+
80+
const toolCall = {
81+
name,
82+
arguments: input,
83+
toolId: id,
84+
type,
85+
}
86+
87+
messages.push({ content: text ?? '', role, toolCalls: [toolCall] })
88+
} else if (block.type === 'tool_result') {
89+
const { content } = block
90+
const formattedContent = formatAnthropicToolResultContent(content)
91+
const toolResult = {
92+
result: formattedContent,
93+
toolId: block.tool_use_id,
94+
type: 'tool_result',
95+
}
96+
97+
messages.push({ content: '', role, toolResults: [toolResult] })
98+
} else {
99+
messages.push({ content: JSON.stringify(block), role })
100+
}
101+
}
102+
}
103+
104+
module.exports = {
105+
appendMessage,
106+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
{
2+
"request": {
3+
"method": "POST",
4+
"url": "https://api.anthropic.com/v1/messages",
5+
"headers": {
6+
"Content-Length": "207",
7+
"Accept": "application/json",
8+
"Content-Type": "application/json",
9+
"User-Agent": "Anthropic/JS 0.14.0",
10+
"x-stainless-lang": "js",
11+
"x-stainless-package-version": "0.14.0",
12+
"x-stainless-os": "MacOS",
13+
"x-stainless-arch": "arm64",
14+
"x-stainless-runtime": "node",
15+
"x-stainless-runtime-version": "v22.22.0",
16+
"anthropic-version": "2023-06-01",
17+
"Accept-Encoding": "gzip,deflate",
18+
"Connection": "keep-alive"
19+
},
20+
"body": "{\n \"model\": \"claude-haiku-4-5-20251001\",\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"Hello, world!\"\n }\n ],\n \"max_tokens\": 100,\n \"temperature\": 0.5,\n \"system\": \"talk like a pirate\"\n}"
21+
},
22+
"response": {
23+
"status": {
24+
"code": 200,
25+
"message": "OK"
26+
},
27+
"headers": {
28+
"Date": "Mon, 27 Apr 2026 14:50:16 GMT",
29+
"Content-Type": "application/json",
30+
"Transfer-Encoding": "chunked",
31+
"Connection": "keep-alive",
32+
"anthropic-ratelimit-input-tokens-limit": "4000000",
33+
"anthropic-ratelimit-input-tokens-remaining": "4000000",
34+
"anthropic-ratelimit-input-tokens-reset": "2026-04-27T14:50:15Z",
35+
"anthropic-ratelimit-output-tokens-limit": "800000",
36+
"anthropic-ratelimit-output-tokens-remaining": "800000",
37+
"anthropic-ratelimit-output-tokens-reset": "2026-04-27T14:50:16Z",
38+
"anthropic-ratelimit-requests-limit": "20000",
39+
"anthropic-ratelimit-requests-remaining": "19999",
40+
"anthropic-ratelimit-requests-reset": "2026-04-27T14:50:14Z",
41+
"anthropic-ratelimit-tokens-limit": "4800000",
42+
"anthropic-ratelimit-tokens-remaining": "4800000",
43+
"anthropic-ratelimit-tokens-reset": "2026-04-27T14:50:15Z",
44+
"request-id": "req_011CaUTmk2FLvdWM43HMZLmE",
45+
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
46+
"anthropic-organization-id": "4257e925-ee99-4ee8-9c62-8e53716d5203",
47+
"Server": "cloudflare",
48+
"x-envoy-upstream-service-time": "2173",
49+
"Content-Encoding": "gzip",
50+
"vary": "Accept-Encoding",
51+
"server-timing": "x-originResponse;dur=2175",
52+
"cf-cache-status": "DYNAMIC",
53+
"set-cookie": "_cfuvid=3CRpc_0a9gxyGuNXhoN51PFn2mr63pI_kYkO7id.QHg-1777301413.9938958-1.0.1.1-Fxl8JCfOO.SKtEhUEVDiVprDj4dC3xJ26cagZqkSQ3I; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com",
54+
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
55+
"X-Robots-Tag": "none",
56+
"CF-RAY": "9f2ea36d78574204-EWR"
57+
},
58+
"body": "{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01DQvvsACySRRMzscQyhw4GY\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Ahoy, matey! \ud83c\udff4\u200d\u2620\ufe0f\\n\\nShiver me timbers, 'tis a fine day on the seven seas! Welcome aboard, ye scallywag! \\n\\nWhat brings ye to these waters, eh? Be ye lookin' fer treasure, adventure, or perhaps some wisdom from an old sea dog like meself? \\n\\nSpeak up now, and don't be shy! \u2693\"}],\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":16,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":99,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}"
59+
}
60+
}

packages/dd-trace/test/llmobs/plugins/anthropic/index.spec.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,29 @@ describe('Plugin', () => {
107107
assertLLMObsSpan(apmSpans, llmobsSpans)
108108
})
109109
})
110+
111+
it('does not modify the `system` property', async () => {
112+
const params = Object.freeze({
113+
model: 'claude-haiku-4-5-20251001',
114+
messages: Object.freeze([Object.freeze({ role: 'user', content: 'Hello, world!' })]),
115+
max_tokens: 100,
116+
temperature: 0.5,
117+
system: 'talk like a pirate',
118+
})
119+
120+
const response = await client.messages.create(params)
121+
122+
assert.deepEqual(params.messages, [{ role: 'user', content: 'Hello, world!' }])
123+
assert.ok(response)
124+
125+
const { llmobsSpans } = await getEvents()
126+
assert.equal(llmobsSpans.length, 1)
127+
128+
assert.deepEqual(llmobsSpans[0].meta.input.messages, [
129+
{ role: 'system', content: 'talk like a pirate' },
130+
{ role: 'user', content: 'Hello, world!' },
131+
])
132+
})
110133
})
111134

112135
describe('messages.stream', () => {

0 commit comments

Comments
 (0)