Skip to content

Commit 7e3cab9

Browse files
authored
feat: Added support for Anthropic Claude 3 messages API (#2278)
1 parent 8f96c73 commit 7e3cab9

File tree

13 files changed

+315
-6
lines changed

13 files changed

+315
-6
lines changed

ai-support.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
{
1616
"title": "Image",
1717
"supported": false
18+
},
19+
{
20+
"title": "Vision",
21+
"supported": false
1822
}
1923
]
2024
},

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ class BedrockCommand {
3535
result = this.#body.maxTokens
3636
} else if (this.isClaude() === true) {
3737
result = this.#body.max_tokens_to_sample
38-
} else if (this.isCohere() === true) {
38+
} else if (this.isClaude3() === true || this.isCohere() === true) {
3939
result = this.#body.max_tokens
4040
} else if (this.isLlama2() === true) {
4141
result = this.#body.max_gen_length
@@ -83,6 +83,11 @@ class BedrockCommand {
8383
this.isLlama2() === true
8484
) {
8585
result = this.#body.prompt
86+
} else if (this.isClaude3() === true) {
87+
result = this.#body?.messages?.reduce((acc, curr) => {
88+
acc += curr?.content ?? ''
89+
return acc
90+
}, '')
8691
}
8792
return result
8893
}
@@ -96,6 +101,7 @@ class BedrockCommand {
96101
result = this.#body.textGenerationConfig?.temperature
97102
} else if (
98103
this.isClaude() === true ||
104+
this.isClaude3() === true ||
99105
this.isAi21() === true ||
100106
this.isCohere() === true ||
101107
this.isLlama2() === true
@@ -110,7 +116,11 @@ class BedrockCommand {
110116
}
111117

112118
isClaude() {
113-
return this.#modelId.startsWith('anthropic.claude')
119+
return this.#modelId.startsWith('anthropic.claude-v')
120+
}
121+
122+
isClaude3() {
123+
return this.#modelId.startsWith('anthropic.claude-3')
114124
}
115125

116126
isCohere() {

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@ class BedrockResponse {
3030
#completions = []
3131
#id
3232

33+
/* eslint-disable sonarjs/cognitive-complexity */
3334
/**
3435
* @param {object} params
3536
* @param {AwsBedrockMiddlewareResponse} params.response
3637
* @param {BedrockCommand} params.bedrockCommand
38+
* @param params.isError
3739
*/
3840
constructor({ response, bedrockCommand, isError = false }) {
3941
this.#innerResponse = isError ? response.$response : response.response
@@ -57,6 +59,14 @@ class BedrockResponse {
5759
} else if (cmd.isClaude() === true) {
5860
// TODO: can we make this thing give more than one completion?
5961
body.completion && this.#completions.push(body.completion)
62+
} else if (cmd.isClaude3() === true) {
63+
if (body?.type === 'message_stop') {
64+
// Streamed response
65+
this.#completions = body.completions
66+
} else {
67+
this.#completions = body?.content?.map((c) => c.text)
68+
}
69+
this.#id = body.id
6070
} else if (cmd.isCohere() === true) {
6171
this.#completions = body.generations?.map((g) => g.text) ?? []
6272
this.#id = body.id
@@ -66,6 +76,7 @@ class BedrockResponse {
6676
this.#completions = body.results?.map((r) => r.outputText) ?? []
6777
}
6878
}
79+
/* eslint-enable sonarjs/cognitive-complexity */
6980

7081
/**
7182
* The prompt responses returned by the model.
@@ -92,7 +103,7 @@ class BedrockResponse {
92103
const cmd = this.#command
93104
if (cmd.isAi21() === true) {
94105
result = this.#parsedBody.completions?.[0]?.finishReason.reason
95-
} else if (cmd.isClaude() === true) {
106+
} else if (cmd.isClaude() === true || cmd.isClaude3() === true) {
96107
result = this.#parsedBody.stop_reason
97108
} else if (cmd.isCohere() === true) {
98109
result = this.#parsedBody.generations?.find((r) => r.finish_reason !== null)?.finish_reason

lib/llm-events/aws-bedrock/stream-handler.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ class StreamHandler {
105105
if (bedrockCommand.isClaude() === true) {
106106
this.stopReasonKey = 'stop_reason'
107107
this.generator = handleClaude
108+
} else if (bedrockCommand.isClaude3() === true) {
109+
this.stopReasonKey = 'stop_reason'
110+
this.generator = handleClaude3
108111
} else if (bedrockCommand.isCohere() === true) {
109112
this.stopReasonKey = 'generations.0.finish_reason'
110113
this.generator = handleCohere
@@ -207,6 +210,31 @@ async function* handleClaude() {
207210
}
208211
}
209212

213+
async function* handleClaude3() {
214+
let currentBody = {}
215+
let stopReason
216+
const completions = []
217+
218+
try {
219+
for await (const event of this.stream) {
220+
yield event
221+
const parsed = this.parseEvent(event)
222+
this.updateHeaders(parsed)
223+
currentBody = parsed
224+
if (parsed.type === 'content_block_delta') {
225+
completions.push(parsed.delta.text)
226+
} else if (parsed.type === 'message_delta') {
227+
stopReason = parsed.delta.stop_reason
228+
}
229+
}
230+
} finally {
231+
currentBody.completions = completions
232+
currentBody.stop_reason = stopReason
233+
this.response.output.body = currentBody
234+
this.finish()
235+
}
236+
}
237+
210238
async function* handleCohere() {
211239
let currentBody = {}
212240
const generations = []

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ function handler(req, res) {
7272
// https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids-arns.html
7373
const [, model] = /model\/(.+)\/invoke/.exec(req.url)
7474
let response
75-
switch (model) {
75+
switch (decodeURIComponent(model)) {
7676
case 'ai21.j2-mid-v1':
7777
case 'ai21.j2-ultra-v1': {
7878
response = responses.ai21.get(payload.prompt)
@@ -94,6 +94,13 @@ function handler(req, res) {
9494
break
9595
}
9696

97+
case 'anthropic.claude-3-haiku-20240307-v1:0':
98+
case 'anthropic.claude-3-opus-20240229-v1:0':
99+
case 'anthropic.claude-3-sonnet-20240229-v1:0': {
100+
response = responses.claude3.get(payload?.messages?.[0]?.content)
101+
break
102+
}
103+
97104
case 'cohere.command-text-v14':
98105
case 'cohere.command-light-text-v14': {
99106
response = responses.cohere.get(payload.prompt)
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* Copyright 2024 New Relic Corporation. All rights reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
'use strict'
7+
8+
const responses = new Map()
9+
const { contentType, reqId } = require('./constants')
10+
11+
responses.set('text claude3 ultimate question', {
12+
headers: {
13+
'content-type': contentType,
14+
'x-amzn-requestid': reqId,
15+
'x-amzn-bedrock-invocation-latency': '926',
16+
'x-amzn-bedrock-output-token-count': '36',
17+
'x-amzn-bedrock-input-token-count': '14'
18+
},
19+
statusCode: 200,
20+
body: {
21+
id: 'msg_bdrk_019V7ABaw8ZZZYuRDSTWK7VE',
22+
type: 'message',
23+
role: 'assistant',
24+
model: 'claude-3-haiku-20240307',
25+
stop_sequence: null,
26+
usage: { input_tokens: 30, output_tokens: 265 },
27+
content: [
28+
{
29+
type: 'text',
30+
text: '42'
31+
}
32+
],
33+
stop_reason: 'endoftext'
34+
}
35+
})
36+
37+
responses.set('text claude3 ultimate question streamed', {
38+
headers: {
39+
'content-type': 'application/vnd.amazon.eventstream',
40+
'x-amzn-requestid': reqId,
41+
'x-amzn-bedrock-content-type': contentType
42+
},
43+
statusCode: 200,
44+
// Please do not simplify the set of chunks. This set represents a minimal
45+
// streaming response from the "Messages API". Such a stream is different from
46+
// the other streamed responses, and we need an example of what a Messages API
47+
// stream looks like.
48+
chunks: [
49+
{
50+
body: {
51+
type: 'message_start',
52+
message: {
53+
content: [],
54+
id: 'msg_bdrk_sljfaofk',
55+
model: 'claude-3-sonnet-20240229',
56+
role: 'assistant',
57+
stop_reason: null,
58+
stop_sequence: null,
59+
type: 'message',
60+
usage: {
61+
input_tokens: 30,
62+
output_tokens: 1
63+
}
64+
}
65+
},
66+
headers: {
67+
':event-type': { type: 'string', value: 'chunk' },
68+
':content-type': { type: 'string', value: 'application/json' },
69+
':message-type': { type: 'string', value: 'event' }
70+
}
71+
},
72+
{
73+
body: {
74+
type: 'content_block_start',
75+
index: 0,
76+
content_block: { type: 'text', text: '' }
77+
},
78+
headers: {
79+
':event-type': { type: 'string', value: 'chunk' },
80+
':content-type': { type: 'string', value: 'application/json' },
81+
':message-type': { type: 'string', value: 'event' }
82+
}
83+
},
84+
{
85+
body: {
86+
type: 'content_block_delta',
87+
index: 0,
88+
delta: { type: 'text_delta', text: '42' }
89+
},
90+
headers: {
91+
':event-type': { type: 'string', value: 'chunk' },
92+
':content-type': { type: 'string', value: 'application/json' },
93+
':message-type': { type: 'string', value: 'event' }
94+
}
95+
},
96+
{
97+
body: {
98+
type: 'content_block_stop',
99+
index: 0
100+
},
101+
headers: {
102+
':event-type': { type: 'string', value: 'chunk' },
103+
':content-type': { type: 'string', value: 'application/json' },
104+
':message-type': { type: 'string', value: 'event' }
105+
}
106+
},
107+
{
108+
body: {
109+
type: 'message_delta',
110+
usage: { output_tokens: 1 },
111+
delta: {
112+
// The actual reason from the API will be `max_tokens` if the maximum
113+
// allowed tokens have been reached. But our tests expect "endoftext".
114+
stop_reason: 'endoftext',
115+
stop_sequence: null
116+
}
117+
},
118+
headers: {
119+
':event-type': { type: 'string', value: 'chunk' },
120+
':content-type': { type: 'string', value: 'application/json' },
121+
':message-type': { type: 'string', value: 'event' }
122+
}
123+
},
124+
{
125+
body: {
126+
type: 'message_stop',
127+
['amazon-bedrock-invocationMetrics']: {
128+
inputTokenCount: 8,
129+
outputTokenCount: 4,
130+
invocationLatency: 511,
131+
firstByteLatency: 358
132+
}
133+
},
134+
headers: {
135+
':event-type': { type: 'string', value: 'chunk' },
136+
':content-type': { type: 'string', value: 'application/json' },
137+
':message-type': { type: 'string', value: 'event' }
138+
}
139+
}
140+
]
141+
})
142+
143+
responses.set('text claude3 ultimate question error', {
144+
headers: {
145+
'content-type': contentType,
146+
'x-amzn-requestid': reqId,
147+
'x-amzn-errortype': 'ValidationException:http://internal.amazon.com/coral/com.amazon.bedrock/'
148+
},
149+
statusCode: 400,
150+
body: {
151+
message:
152+
'Malformed input request: 2 schema violations found, please reformat your input and try again.'
153+
}
154+
})
155+
156+
module.exports = responses

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
const ai21 = require('./ai21')
99
const amazon = require('./amazon')
1010
const claude = require('./claude')
11+
const claude3 = require('./claude3')
1112
const cohere = require('./cohere')
1213
const llama2 = require('./llama2')
1314

1415
module.exports = {
1516
ai21,
1617
amazon,
1718
claude,
19+
claude3,
1820
cohere,
1921
llama2
2022
}

test/unit/llm-events/aws-bedrock/bedrock-command.test.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ const claude = {
2323
}
2424
}
2525

26+
const claude3 = {
27+
modelId: 'anthropic.claude-3-haiku-20240307-v1:0',
28+
body: {
29+
messages: [{ content: 'who are you' }]
30+
}
31+
}
32+
2633
const cohere = {
2734
modelId: 'cohere.command-text-v14',
2835
body: {
@@ -75,6 +82,7 @@ tap.test('non-conforming command is handled gracefully', async (t) => {
7582
for (const model of [
7683
'Ai21',
7784
'Claude',
85+
'Claude3',
7886
'Cohere',
7987
'CohereEmbed',
8088
'Llama2',
@@ -140,6 +148,31 @@ tap.test('claude complete command works', async (t) => {
140148
t.equal(cmd.temperature, payload.body.temperature)
141149
})
142150

151+
tap.test('claude3 minimal command works', async (t) => {
152+
t.context.updatePayload(structuredClone(claude3))
153+
const cmd = new BedrockCommand(t.context.input)
154+
t.equal(cmd.isClaude3(), true)
155+
t.equal(cmd.maxTokens, undefined)
156+
t.equal(cmd.modelId, claude3.modelId)
157+
t.equal(cmd.modelType, 'completion')
158+
t.equal(cmd.prompt, claude3.body.messages[0].content)
159+
t.equal(cmd.temperature, undefined)
160+
})
161+
162+
tap.test('claude3 complete command works', async (t) => {
163+
const payload = structuredClone(claude3)
164+
payload.body.max_tokens = 25
165+
payload.body.temperature = 0.5
166+
t.context.updatePayload(payload)
167+
const cmd = new BedrockCommand(t.context.input)
168+
t.equal(cmd.isClaude3(), true)
169+
t.equal(cmd.maxTokens, 25)
170+
t.equal(cmd.modelId, payload.modelId)
171+
t.equal(cmd.modelType, 'completion')
172+
t.equal(cmd.prompt, payload.body.messages[0].content)
173+
t.equal(cmd.temperature, payload.body.temperature)
174+
})
175+
143176
tap.test('cohere minimal command works', async (t) => {
144177
t.context.updatePayload(structuredClone(cohere))
145178
const cmd = new BedrockCommand(t.context.input)

test/unit/llm-events/aws-bedrock/bedrock-response.test.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ tap.beforeEach((t) => {
7373
isClaude() {
7474
return false
7575
},
76+
isClaude3() {
77+
return false
78+
},
7679
isCohere() {
7780
return false
7881
},

0 commit comments

Comments
 (0)