Skip to content

Commit 6a83abf

Browse files
author
Ryan Kadri
authored
feat: Added support for Claude 3+ Chat API in Bedrock (#2870)
1 parent 7dceae9 commit 6a83abf

17 files changed

+413
-101
lines changed

lib/instrumentation/aws-sdk/v3/bedrock.js

+25-14
Original file line numberDiff line numberDiff line change
@@ -118,27 +118,34 @@ function recordChatCompletionMessages({
118118
isError: err !== null
119119
})
120120

121-
const msg = new LlmChatCompletionMessage({
122-
agent,
123-
segment,
124-
bedrockCommand,
125-
bedrockResponse,
126-
transaction,
127-
index: 0,
128-
completionId: summary.id
121+
// Record context message(s)
122+
const promptContextMessages = bedrockCommand.prompt
123+
promptContextMessages.forEach((contextMessage, promptIndex) => {
124+
const msg = new LlmChatCompletionMessage({
125+
agent,
126+
segment,
127+
transaction,
128+
bedrockCommand,
129+
content: contextMessage.content,
130+
role: contextMessage.role,
131+
bedrockResponse,
132+
index: promptIndex,
133+
completionId: summary.id
134+
})
135+
recordEvent({ agent, type: 'LlmChatCompletionMessage', msg })
129136
})
130-
recordEvent({ agent, type: 'LlmChatCompletionMessage', msg })
131137

132-
bedrockResponse.completions.forEach((content, index) => {
138+
bedrockResponse.completions.forEach((content, completionIndex) => {
133139
const chatCompletionMessage = new LlmChatCompletionMessage({
134140
agent,
135141
segment,
136142
transaction,
137143
bedrockCommand,
138144
bedrockResponse,
139145
isResponse: true,
140-
index: index + 1,
146+
index: promptContextMessages.length + completionIndex,
141147
content,
148+
role: 'assistant',
142149
completionId: summary.id
143150
})
144151
recordEvent({ agent, type: 'LlmChatCompletionMessage', msg: chatCompletionMessage })
@@ -179,18 +186,22 @@ function recordEmbeddingMessage({
179186
return
180187
}
181188

182-
const embedding = new LlmEmbedding({
189+
const embeddings = bedrockCommand.prompt.map(prompt => new LlmEmbedding({
183190
agent,
184191
segment,
185192
transaction,
186193
bedrockCommand,
194+
input: prompt.content,
187195
bedrockResponse,
188196
isError: err !== null
197+
}))
198+
199+
embeddings.forEach(embedding => {
200+
recordEvent({ agent, type: 'LlmEmbedding', msg: embedding })
189201
})
190202

191-
recordEvent({ agent, type: 'LlmEmbedding', msg: embedding })
192203
if (err) {
193-
const llmError = new LlmError({ bedrockResponse, err, embedding })
204+
const llmError = new LlmError({ bedrockResponse, err, embedding: embeddings.length === 1 ? embeddings[0] : undefined })
194205
agent.errors.add(transaction, err, llmError)
195206
}
196207
}

lib/llm-events/aws-bedrock/bedrock-command.js

+55-21
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
'use strict'
77

8+
const { stringifyClaudeChunkedMessage } = require('./utils')
9+
810
/**
911
* Parses an AWS invoke command instance into a re-usable entity.
1012
*/
@@ -68,37 +70,34 @@ class BedrockCommand {
6870
/**
6971
* The question posed to the LLM.
7072
*
71-
* @returns {string|string[]|undefined}
73+
* @returns {object[]} The array of context messages passed to the LLM (or a single user prompt for legacy "non-chat" models)
7274
*/
7375
get prompt() {
74-
let result
7576
if (this.isTitan() === true || this.isTitanEmbed() === true) {
76-
result = this.#body.inputText
77+
return [
78+
{
79+
role: 'user',
80+
content: this.#body.inputText
81+
}
82+
]
7783
} else if (this.isCohereEmbed() === true) {
78-
result = this.#body.texts.join(' ')
84+
return [
85+
{
86+
role: 'user',
87+
content: this.#body.texts.join(' ')
88+
}
89+
]
7990
} else if (
80-
this.isClaude() === true ||
91+
this.isClaudeTextCompletionApi() === true ||
8192
this.isAi21() === true ||
8293
this.isCohere() === true ||
8394
this.isLlama() === true
8495
) {
85-
result = this.#body.prompt
86-
} else if (this.isClaude3() === true) {
87-
const collected = []
88-
for (const message of this.#body?.messages) {
89-
if (message?.role === 'assistant') {
90-
continue
91-
}
92-
if (typeof message?.content === 'string') {
93-
collected.push(message?.content)
94-
continue
95-
}
96-
const mappedMsgObj = message?.content.map((msgContent) => msgContent.text)
97-
collected.push(mappedMsgObj)
98-
}
99-
result = collected.join(' ')
96+
return [{ role: 'user', content: this.#body.prompt }]
97+
} else if (this.isClaudeMessagesApi() === true) {
98+
return normalizeClaude3Messages(this.#body?.messages)
10099
}
101-
return result
100+
return []
102101
}
103102

104103
/**
@@ -151,6 +150,41 @@ class BedrockCommand {
151150
isTitanEmbed() {
152151
return this.#modelId.startsWith('amazon.titan-embed')
153152
}
153+
154+
isClaudeMessagesApi() {
155+
return (this.isClaude3() === true || this.isClaude() === true) && 'messages' in this.#body
156+
}
157+
158+
isClaudeTextCompletionApi() {
159+
return this.isClaude() === true && 'prompt' in this.#body
160+
}
161+
}
162+
163+
/**
164+
* Claude v3 requests in Bedrock can have two different "chat" flavors. This function normalizes them into a consistent
165+
* format per the AIM agent spec
166+
*
167+
* @param messages - The raw array of messages passed to the invoke API
168+
* @returns {number|undefined} - The normalized messages
169+
*/
170+
function normalizeClaude3Messages(messages) {
171+
const result = []
172+
for (const message of messages ?? []) {
173+
if (message == null) {
174+
continue
175+
}
176+
if (typeof message.content === 'string') {
177+
// Messages can be specified with plain string content
178+
result.push({ role: message.role, content: message.content })
179+
} else if (Array.isArray(message.content)) {
180+
// Or in a "chunked" format for multi-modal support
181+
result.push({
182+
role: message.role,
183+
content: stringifyClaudeChunkedMessage(message.content)
184+
})
185+
}
186+
}
187+
return result
154188
}
155189

156190
module.exports = BedrockCommand

lib/llm-events/aws-bedrock/bedrock-response.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
'use strict'
77

8+
const { stringifyClaudeChunkedMessage } = require('./utils')
9+
810
/**
911
* @typedef {object} AwsBedrockMiddlewareResponse
1012
* @property {object} response Has a `body` property that is an IncomingMessage,
@@ -63,7 +65,7 @@ class BedrockResponse {
6365
// Streamed response
6466
this.#completions = body.completions
6567
} else {
66-
this.#completions = body?.content?.map((c) => c.text)
68+
this.#completions = [stringifyClaudeChunkedMessage(body?.content)]
6769
}
6870
this.#id = body.id
6971
} else if (cmd.isCohere() === true) {

lib/llm-events/aws-bedrock/chat-completion-message.js

+4-13
Original file line numberDiff line numberDiff line change
@@ -39,28 +39,19 @@ class LlmChatCompletionMessage extends LlmEvent {
3939
params = Object.assign({}, defaultParams, params)
4040
super(params)
4141

42-
const { agent, content, isResponse, index, completionId } = params
42+
const { agent, content, isResponse, index, completionId, role } = params
4343
const recordContent = agent.config?.ai_monitoring?.record_content?.enabled
4444
const tokenCB = agent?.llm?.tokenCountCallback
4545

4646
this.is_response = isResponse
4747
this.completion_id = completionId
4848
this.sequence = index
4949
this.content = recordContent === true ? content : undefined
50-
this.role = ''
50+
this.role = role
5151

5252
this.#setId(index)
53-
if (this.is_response === true) {
54-
this.role = 'assistant'
55-
if (typeof tokenCB === 'function') {
56-
this.token_count = tokenCB(this.bedrockCommand.modelId, content)
57-
}
58-
} else {
59-
this.role = 'user'
60-
this.content = recordContent === true ? this.bedrockCommand.prompt : undefined
61-
if (typeof tokenCB === 'function') {
62-
this.token_count = tokenCB(this.bedrockCommand.modelId, this.bedrockCommand.prompt)
63-
}
53+
if (typeof tokenCB === 'function') {
54+
this.token_count = tokenCB(this.bedrockCommand.modelId, content)
6455
}
6556
}
6657

lib/llm-events/aws-bedrock/chat-completion-summary.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class LlmChatCompletionSummary extends LlmEvent {
3636
const cmd = this.bedrockCommand
3737
this[cfr] = this.bedrockResponse.finishReason
3838
this[rt] = cmd.temperature
39-
this[nm] = 1 + this.bedrockResponse.completions.length
39+
this[nm] = (this.bedrockCommand.prompt.length) + this.bedrockResponse.completions.length
4040
}
4141
}
4242

lib/llm-events/aws-bedrock/embedding.js

+6-4
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const LlmEvent = require('./event')
1010
/**
1111
* @typedef {object} LlmEmbeddingParams
1212
* @augments LlmEventParams
13-
* @property
13+
* @property {string} input - The input message for the embedding call
1414
*/
1515
/**
1616
* @type {LlmEmbeddingParams}
@@ -20,16 +20,18 @@ const defaultParams = {}
2020
class LlmEmbedding extends LlmEvent {
2121
constructor(params = defaultParams) {
2222
super(params)
23-
const { agent } = params
23+
const { agent, input } = params
2424
const tokenCb = agent?.llm?.tokenCountCallback
2525

2626
this.input = agent.config?.ai_monitoring?.record_content?.enabled
27-
? this.bedrockCommand.prompt
27+
? input
2828
: undefined
2929
this.error = params.isError
3030
this.duration = params.segment.getDurationInMillis()
31+
32+
// Even if not recording content, we should use the local token counting callback to record token usage
3133
if (typeof tokenCb === 'function') {
32-
this.token_count = tokenCb(this.bedrockCommand.modelId, this.bedrockCommand.prompt)
34+
this.token_count = tokenCb(this.bedrockCommand.modelId, input)
3335
}
3436
}
3537
}

lib/llm-events/aws-bedrock/utils.js

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright 2024 New Relic Corporation. All rights reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
'use strict'
7+
8+
/**
9+
*
10+
* @param {object[]} chunks - The "chunks" that make up a single conceptual message. In a multi-modal scenario, a single message
11+
* might have a number of different-typed chunks interspersed
12+
* @returns {string} - A stringified version of the message. We make a best-effort effort attempt to represent non-text chunks. In the future
13+
* we may want to extend the agent to support these non-text chunks in a richer way. Placeholders are represented in an XML-like format but
14+
* are NOT intended to be parsed as valid XML
15+
*/
16+
function stringifyClaudeChunkedMessage(chunks) {
17+
const stringifiedChunks = chunks.map((msgContent) => {
18+
switch (msgContent.type) {
19+
case 'text':
20+
return msgContent.text
21+
case 'image':
22+
return '<image>'
23+
case 'tool_use':
24+
return `<tool_use>${msgContent.name}</tool_use>`
25+
case 'tool_result':
26+
return `<tool_result>${msgContent.content}</tool_result>`
27+
default:
28+
return '<unknown_chunk>'
29+
}
30+
})
31+
return stringifiedChunks.join('\n\n')
32+
}
33+
34+
module.exports = {
35+
stringifyClaudeChunkedMessage
36+
}

test/lib/aws-server-stubs/ai-server/index.js

+6
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ function handler(req, res) {
101101
break
102102
}
103103

104+
// Chunked claude model
105+
case 'anthropic.claude-3-5-sonnet-20240620-v1:0': {
106+
response = responses.claude3.get(payload?.messages?.[0]?.content?.[0].text)
107+
break
108+
}
109+
104110
case 'cohere.command-text-v14':
105111
case 'cohere.command-light-text-v14': {
106112
response = responses.cohere.get(payload.prompt)

test/lib/aws-server-stubs/ai-server/responses/claude3.js

+34
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,40 @@ responses.set('text claude3 ultimate question', {
3434
}
3535
})
3636

37+
responses.set('text claude3 ultimate question chunked', {
38+
headers: {
39+
'content-type': contentType,
40+
'x-amzn-requestid': reqId,
41+
'x-amzn-bedrock-invocation-latency': '926',
42+
'x-amzn-bedrock-output-token-count': '36',
43+
'x-amzn-bedrock-input-token-count': '14'
44+
},
45+
statusCode: 200,
46+
body: {
47+
id: 'msg_bdrk_019V7ABaw8ZZZYuRDSTWK7VE',
48+
type: 'message',
49+
role: 'assistant',
50+
model: 'claude-3-haiku-20240307',
51+
stop_sequence: null,
52+
usage: { input_tokens: 30, output_tokens: 265 },
53+
content: [
54+
{
55+
type: 'text',
56+
text: "Here's a nice picture of a 42"
57+
},
58+
{
59+
type: 'image',
60+
source: {
61+
type: 'base64',
62+
media_type: 'image/jpeg',
63+
data: 'U2hoLiBUaGlzIGlzbid0IHJlYWxseSBhbiBpbWFnZQ=='
64+
}
65+
}
66+
],
67+
stop_reason: 'endoftext'
68+
}
69+
})
70+
3771
responses.set('text claude3 ultimate question streamed', {
3872
headers: {
3973
'content-type': 'application/vnd.amazon.eventstream',

0 commit comments

Comments
 (0)