bedrock_stream_test.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. package service
  2. import (
  3. "bytes"
  4. "encoding/base64"
  5. "encoding/binary"
  6. "hash/crc32"
  7. "io"
  8. "testing"
  9. "github.com/stretchr/testify/assert"
  10. "github.com/stretchr/testify/require"
  11. "github.com/tidwall/gjson"
  12. )
  13. func TestExtractBedrockChunkData(t *testing.T) {
  14. t.Run("valid base64 payload", func(t *testing.T) {
  15. original := `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}`
  16. b64 := base64.StdEncoding.EncodeToString([]byte(original))
  17. payload := []byte(`{"bytes":"` + b64 + `"}`)
  18. result := extractBedrockChunkData(payload)
  19. require.NotNil(t, result)
  20. assert.JSONEq(t, original, string(result))
  21. })
  22. t.Run("empty bytes field", func(t *testing.T) {
  23. result := extractBedrockChunkData([]byte(`{"bytes":""}`))
  24. assert.Nil(t, result)
  25. })
  26. t.Run("no bytes field", func(t *testing.T) {
  27. result := extractBedrockChunkData([]byte(`{"other":"value"}`))
  28. assert.Nil(t, result)
  29. })
  30. t.Run("invalid base64", func(t *testing.T) {
  31. result := extractBedrockChunkData([]byte(`{"bytes":"not-valid-base64!!!"}`))
  32. assert.Nil(t, result)
  33. })
  34. }
  35. func TestTransformBedrockInvocationMetrics(t *testing.T) {
  36. t.Run("converts metrics to usage", func(t *testing.T) {
  37. input := `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"amazon-bedrock-invocationMetrics":{"inputTokenCount":150,"outputTokenCount":42}}`
  38. result := transformBedrockInvocationMetrics([]byte(input))
  39. // amazon-bedrock-invocationMetrics should be removed
  40. assert.False(t, gjson.GetBytes(result, "amazon-bedrock-invocationMetrics").Exists())
  41. // usage should be set
  42. assert.Equal(t, int64(150), gjson.GetBytes(result, "usage.input_tokens").Int())
  43. assert.Equal(t, int64(42), gjson.GetBytes(result, "usage.output_tokens").Int())
  44. // original fields preserved
  45. assert.Equal(t, "message_delta", gjson.GetBytes(result, "type").String())
  46. assert.Equal(t, "end_turn", gjson.GetBytes(result, "delta.stop_reason").String())
  47. })
  48. t.Run("no metrics present", func(t *testing.T) {
  49. input := `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi"}}`
  50. result := transformBedrockInvocationMetrics([]byte(input))
  51. assert.JSONEq(t, input, string(result))
  52. })
  53. t.Run("does not overwrite existing usage", func(t *testing.T) {
  54. input := `{"type":"message_delta","usage":{"output_tokens":100},"amazon-bedrock-invocationMetrics":{"inputTokenCount":150,"outputTokenCount":42}}`
  55. result := transformBedrockInvocationMetrics([]byte(input))
  56. // metrics removed but existing usage preserved
  57. assert.False(t, gjson.GetBytes(result, "amazon-bedrock-invocationMetrics").Exists())
  58. assert.Equal(t, int64(100), gjson.GetBytes(result, "usage.output_tokens").Int())
  59. })
  60. }
  61. func TestExtractEventStreamHeaderValue(t *testing.T) {
  62. // Build a header with :event-type = "chunk" (string type = 7)
  63. buildStringHeader := func(name, value string) []byte {
  64. var buf bytes.Buffer
  65. // name length (1 byte)
  66. _ = buf.WriteByte(byte(len(name)))
  67. // name
  68. _, _ = buf.WriteString(name)
  69. // value type (7 = string)
  70. _ = buf.WriteByte(7)
  71. // value length (2 bytes, big-endian)
  72. _ = binary.Write(&buf, binary.BigEndian, uint16(len(value)))
  73. // value
  74. _, _ = buf.WriteString(value)
  75. return buf.Bytes()
  76. }
  77. t.Run("find string header", func(t *testing.T) {
  78. headers := buildStringHeader(":event-type", "chunk")
  79. assert.Equal(t, "chunk", extractEventStreamHeaderValue(headers, ":event-type"))
  80. })
  81. t.Run("header not found", func(t *testing.T) {
  82. headers := buildStringHeader(":event-type", "chunk")
  83. assert.Equal(t, "", extractEventStreamHeaderValue(headers, ":message-type"))
  84. })
  85. t.Run("multiple headers", func(t *testing.T) {
  86. var buf bytes.Buffer
  87. _, _ = buf.Write(buildStringHeader(":content-type", "application/json"))
  88. _, _ = buf.Write(buildStringHeader(":event-type", "chunk"))
  89. _, _ = buf.Write(buildStringHeader(":message-type", "event"))
  90. headers := buf.Bytes()
  91. assert.Equal(t, "chunk", extractEventStreamHeaderValue(headers, ":event-type"))
  92. assert.Equal(t, "application/json", extractEventStreamHeaderValue(headers, ":content-type"))
  93. assert.Equal(t, "event", extractEventStreamHeaderValue(headers, ":message-type"))
  94. })
  95. t.Run("empty headers", func(t *testing.T) {
  96. assert.Equal(t, "", extractEventStreamHeaderValue([]byte{}, ":event-type"))
  97. })
  98. }
  99. func TestBedrockEventStreamDecoder(t *testing.T) {
  100. crc32IeeeTab := crc32.MakeTable(crc32.IEEE)
  101. // Build a valid EventStream frame with correct CRC32/IEEE checksums.
  102. buildFrame := func(eventType string, payload []byte) []byte {
  103. // Build headers
  104. var headersBuf bytes.Buffer
  105. // :event-type header
  106. _ = headersBuf.WriteByte(byte(len(":event-type")))
  107. _, _ = headersBuf.WriteString(":event-type")
  108. _ = headersBuf.WriteByte(7) // string type
  109. _ = binary.Write(&headersBuf, binary.BigEndian, uint16(len(eventType)))
  110. _, _ = headersBuf.WriteString(eventType)
  111. // :message-type header
  112. _ = headersBuf.WriteByte(byte(len(":message-type")))
  113. _, _ = headersBuf.WriteString(":message-type")
  114. _ = headersBuf.WriteByte(7)
  115. _ = binary.Write(&headersBuf, binary.BigEndian, uint16(len("event")))
  116. _, _ = headersBuf.WriteString("event")
  117. headers := headersBuf.Bytes()
  118. headersLen := uint32(len(headers))
  119. // total = 12 (prelude) + headers + payload + 4 (message_crc)
  120. totalLen := uint32(12 + len(headers) + len(payload) + 4)
  121. // Prelude: total_length(4) + headers_length(4)
  122. var preludeBuf bytes.Buffer
  123. _ = binary.Write(&preludeBuf, binary.BigEndian, totalLen)
  124. _ = binary.Write(&preludeBuf, binary.BigEndian, headersLen)
  125. preludeBytes := preludeBuf.Bytes()
  126. preludeCRC := crc32.Checksum(preludeBytes, crc32IeeeTab)
  127. // Build frame: prelude + prelude_crc + headers + payload
  128. var frame bytes.Buffer
  129. _, _ = frame.Write(preludeBytes)
  130. _ = binary.Write(&frame, binary.BigEndian, preludeCRC)
  131. _, _ = frame.Write(headers)
  132. _, _ = frame.Write(payload)
  133. // Message CRC covers everything before itself
  134. messageCRC := crc32.Checksum(frame.Bytes(), crc32IeeeTab)
  135. _ = binary.Write(&frame, binary.BigEndian, messageCRC)
  136. return frame.Bytes()
  137. }
  138. t.Run("decode chunk event", func(t *testing.T) {
  139. payload := []byte(`{"bytes":"dGVzdA=="}`) // base64("test")
  140. frame := buildFrame("chunk", payload)
  141. decoder := newBedrockEventStreamDecoder(bytes.NewReader(frame))
  142. result, err := decoder.Decode()
  143. require.NoError(t, err)
  144. assert.Equal(t, payload, result)
  145. })
  146. t.Run("skip non-chunk events", func(t *testing.T) {
  147. // Write initial-response followed by chunk
  148. var buf bytes.Buffer
  149. _, _ = buf.Write(buildFrame("initial-response", []byte(`{}`)))
  150. chunkPayload := []byte(`{"bytes":"aGVsbG8="}`)
  151. _, _ = buf.Write(buildFrame("chunk", chunkPayload))
  152. decoder := newBedrockEventStreamDecoder(&buf)
  153. result, err := decoder.Decode()
  154. require.NoError(t, err)
  155. assert.Equal(t, chunkPayload, result)
  156. })
  157. t.Run("EOF on empty input", func(t *testing.T) {
  158. decoder := newBedrockEventStreamDecoder(bytes.NewReader(nil))
  159. _, err := decoder.Decode()
  160. assert.Equal(t, io.EOF, err)
  161. })
  162. t.Run("corrupted prelude CRC", func(t *testing.T) {
  163. frame := buildFrame("chunk", []byte(`{"bytes":"dGVzdA=="}`))
  164. // Corrupt the prelude CRC (bytes 8-11)
  165. frame[8] ^= 0xFF
  166. decoder := newBedrockEventStreamDecoder(bytes.NewReader(frame))
  167. _, err := decoder.Decode()
  168. require.Error(t, err)
  169. assert.Contains(t, err.Error(), "prelude CRC mismatch")
  170. })
  171. t.Run("corrupted message CRC", func(t *testing.T) {
  172. frame := buildFrame("chunk", []byte(`{"bytes":"dGVzdA=="}`))
  173. // Corrupt the message CRC (last 4 bytes)
  174. frame[len(frame)-1] ^= 0xFF
  175. decoder := newBedrockEventStreamDecoder(bytes.NewReader(frame))
  176. _, err := decoder.Decode()
  177. require.Error(t, err)
  178. assert.Contains(t, err.Error(), "message CRC mismatch")
  179. })
  180. t.Run("castagnoli encoded frame is rejected", func(t *testing.T) {
  181. castagnoliTab := crc32.MakeTable(crc32.Castagnoli)
  182. payload := []byte(`{"bytes":"dGVzdA=="}`)
  183. var headersBuf bytes.Buffer
  184. _ = headersBuf.WriteByte(byte(len(":event-type")))
  185. _, _ = headersBuf.WriteString(":event-type")
  186. _ = headersBuf.WriteByte(7)
  187. _ = binary.Write(&headersBuf, binary.BigEndian, uint16(len("chunk")))
  188. _, _ = headersBuf.WriteString("chunk")
  189. headers := headersBuf.Bytes()
  190. headersLen := uint32(len(headers))
  191. totalLen := uint32(12 + len(headers) + len(payload) + 4)
  192. var preludeBuf bytes.Buffer
  193. _ = binary.Write(&preludeBuf, binary.BigEndian, totalLen)
  194. _ = binary.Write(&preludeBuf, binary.BigEndian, headersLen)
  195. preludeBytes := preludeBuf.Bytes()
  196. var frame bytes.Buffer
  197. _, _ = frame.Write(preludeBytes)
  198. _ = binary.Write(&frame, binary.BigEndian, crc32.Checksum(preludeBytes, castagnoliTab))
  199. _, _ = frame.Write(headers)
  200. _, _ = frame.Write(payload)
  201. _ = binary.Write(&frame, binary.BigEndian, crc32.Checksum(frame.Bytes(), castagnoliTab))
  202. decoder := newBedrockEventStreamDecoder(bytes.NewReader(frame.Bytes()))
  203. _, err := decoder.Decode()
  204. require.Error(t, err)
  205. assert.Contains(t, err.Error(), "prelude CRC mismatch")
  206. })
  207. }
  208. func TestBuildBedrockURL(t *testing.T) {
  209. t.Run("stream URL with colon in model ID", func(t *testing.T) {
  210. url := BuildBedrockURL("us-east-1", "us.anthropic.claude-opus-4-5-20251101-v1:0", true)
  211. assert.Equal(t, "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-opus-4-5-20251101-v1%3A0/invoke-with-response-stream", url)
  212. })
  213. t.Run("non-stream URL with colon in model ID", func(t *testing.T) {
  214. url := BuildBedrockURL("eu-west-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", false)
  215. assert.Equal(t, "https://bedrock-runtime.eu-west-1.amazonaws.com/model/eu.anthropic.claude-sonnet-4-5-20250929-v1%3A0/invoke", url)
  216. })
  217. t.Run("model ID without colon", func(t *testing.T) {
  218. url := BuildBedrockURL("us-east-1", "us.anthropic.claude-sonnet-4-6", true)
  219. assert.Equal(t, "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-sonnet-4-6/invoke-with-response-stream", url)
  220. })
  221. }