|
| 1 | +import * as http2 from "http2" |
| 2 | +import * as net from "net" |
| 3 | +import { crc32 } from "zlib" |
| 4 | + |
| 5 | +export interface BedrockMockServer { |
| 6 | + url: string |
| 7 | + close(): Promise<void> |
| 8 | +} |
| 9 | + |
| 10 | +// AWS binary event stream encoder — matches the real Bedrock wire format. |
| 11 | +// Derived from the live converse-stream capture: |
| 12 | +// { contentBlockDelta: { contentBlockIndex: 0, delta: { toolUse: { input: "..." } } } } |
| 13 | +// aimock's builder nests the payload one level too deep (contentBlockDelta inside contentBlockDelta), |
| 14 | +// causing the AWS SDK deserializer's take() to miss the delta field entirely. |
| 15 | +function encodeHeaders(headers: Record<string, string>): Buffer { |
| 16 | + const parts: Buffer[] = [] |
| 17 | + for (const [name, value] of Object.entries(headers)) { |
| 18 | + const nameBytes = Buffer.from(name, "utf8") |
| 19 | + const valueBytes = Buffer.from(value, "utf8") |
| 20 | + const buf = Buffer.alloc(1 + nameBytes.length + 1 + 2 + valueBytes.length) |
| 21 | + let off = 0 |
| 22 | + buf.writeUInt8(nameBytes.length, off) |
| 23 | + off += 1 |
| 24 | + nameBytes.copy(buf, off) |
| 25 | + off += nameBytes.length |
| 26 | + buf.writeUInt8(7, off) |
| 27 | + off += 1 // type 7 = string |
| 28 | + buf.writeUInt16BE(valueBytes.length, off) |
| 29 | + off += 2 |
| 30 | + valueBytes.copy(buf, off) |
| 31 | + parts.push(buf) |
| 32 | + } |
| 33 | + return Buffer.concat(parts) |
| 34 | +} |
| 35 | + |
| 36 | +function encodeFrame(eventType: string, payload: object): Buffer { |
| 37 | + const hdrs = encodeHeaders({ |
| 38 | + ":content-type": "application/json", |
| 39 | + ":event-type": eventType, |
| 40 | + ":message-type": "event", |
| 41 | + }) |
| 42 | + const body = Buffer.from(JSON.stringify(payload), "utf8") |
| 43 | + const total = 12 + hdrs.length + body.length + 4 |
| 44 | + const frame = Buffer.alloc(total) |
| 45 | + let off = 0 |
| 46 | + frame.writeUInt32BE(total, off) |
| 47 | + off += 4 |
| 48 | + frame.writeUInt32BE(hdrs.length, off) |
| 49 | + off += 4 |
| 50 | + frame.writeUInt32BE(crc32(frame.subarray(0, 8)) >>> 0, off) |
| 51 | + off += 4 |
| 52 | + hdrs.copy(frame, off) |
| 53 | + off += hdrs.length |
| 54 | + body.copy(frame, off) |
| 55 | + off += body.length |
| 56 | + frame.writeUInt32BE(crc32(frame.subarray(0, total - 4)) >>> 0, off) |
| 57 | + return frame |
| 58 | +} |
| 59 | + |
| 60 | +function buildToolCallFrames(toolName: string, toolUseId: string, argsJson: string): Buffer[] { |
| 61 | + const frames: Buffer[] = [] |
| 62 | + frames.push(encodeFrame("messageStart", { role: "assistant" })) |
| 63 | + frames.push( |
| 64 | + encodeFrame("contentBlockStart", { |
| 65 | + contentBlockIndex: 0, |
| 66 | + start: { toolUse: { name: toolName, toolUseId } }, |
| 67 | + }), |
| 68 | + ) |
| 69 | + const CHUNK = 20 |
| 70 | + for (let i = 0; i < argsJson.length; i += CHUNK) { |
| 71 | + frames.push( |
| 72 | + encodeFrame("contentBlockDelta", { |
| 73 | + contentBlockIndex: 0, |
| 74 | + delta: { toolUse: { input: argsJson.slice(i, i + CHUNK) } }, |
| 75 | + }), |
| 76 | + ) |
| 77 | + } |
| 78 | + frames.push(encodeFrame("contentBlockStop", { contentBlockIndex: 0 })) |
| 79 | + frames.push(encodeFrame("messageStop", { stopReason: "tool_use" })) |
| 80 | + frames.push( |
| 81 | + encodeFrame("metadata", { |
| 82 | + metrics: { latencyMs: 1 }, |
| 83 | + usage: { inputTokens: 100, outputTokens: 10, totalTokens: 110, serverToolUsage: {} }, |
| 84 | + }), |
| 85 | + ) |
| 86 | + return frames |
| 87 | +} |
| 88 | + |
| 89 | +export async function startBedrockMockServer(): Promise<BedrockMockServer> { |
| 90 | + // HTTP/2 cleartext (h2c) — matches what @aws-sdk/client-bedrock-runtime uses by default. |
| 91 | + const server = http2.createServer() |
| 92 | + |
| 93 | + server.on("stream", (stream, headers) => { |
| 94 | + const path = headers[":path"] as string |
| 95 | + const method = headers[":method"] as string |
| 96 | + |
| 97 | + if (!path?.includes("converse-stream") || method !== "POST") { |
| 98 | + stream.respond({ ":status": 404 }) |
| 99 | + stream.end(JSON.stringify({ error: { message: "Not found", type: "not_found" } })) |
| 100 | + return |
| 101 | + } |
| 102 | + |
| 103 | + // Drain the request body before responding (AWS SDK sends the full request before reading). |
| 104 | + stream.resume() |
| 105 | + stream.on("end", () => { |
| 106 | + stream.respond({ |
| 107 | + ":status": 200, |
| 108 | + "content-type": "application/vnd.amazon.eventstream", |
| 109 | + }) |
| 110 | + const frames = buildToolCallFrames( |
| 111 | + "attempt_completion", |
| 112 | + "tooluse_bedrock_mock_001", |
| 113 | + JSON.stringify({ result: "4" }), |
| 114 | + ) |
| 115 | + for (const frame of frames) { |
| 116 | + stream.write(frame) |
| 117 | + } |
| 118 | + stream.end() |
| 119 | + }) |
| 120 | + }) |
| 121 | + |
| 122 | + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)) |
| 123 | + const addr = server.address() as net.AddressInfo |
| 124 | + |
| 125 | + return { |
| 126 | + url: `http://127.0.0.1:${addr.port}`, |
| 127 | + close: () => new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))), |
| 128 | + } |
| 129 | +} |
0 commit comments