anthropic_to_responses_stream_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. package apicompat
  2. import "testing"
  3. // TestAnthropicEventToResponses_TextEmitsContentPart pins that a message text
  4. // stream emits response.content_part.added, and that it precedes the first
  5. // output_text.delta for that part.
  6. //
  7. // Why: the OpenAI SDK's accumulating stream helper (client.responses.stream)
  8. // only appends a content part to the message item when it sees
  9. // content_part.added. The item is added with content: [], so a missing event
  10. // makes the following output_text.delta index output.content[content_index] and
  11. // raise IndexError. Raw event iteration does not accumulate, so a regression
  12. // here is easy to miss.
  13. func TestAnthropicEventToResponses_TextEmitsContentPart(t *testing.T) {
  14. state := NewAnthropicEventToResponsesState()
  15. state.Model = "claude-sonnet-4-5"
  16. var types []string
  17. feed := func(evt *AnthropicStreamEvent) {
  18. for _, out := range AnthropicEventToResponsesEvents(evt, state) {
  19. types = append(types, out.Type)
  20. }
  21. }
  22. idx := 0
  23. feed(&AnthropicStreamEvent{Type: "message_start", Message: &AnthropicResponse{ID: "msg_1", Model: "claude-sonnet-4-5"}})
  24. feed(&AnthropicStreamEvent{Type: "content_block_start", Index: &idx, ContentBlock: &AnthropicContentBlock{Type: "text"}})
  25. feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{Type: "text_delta", Text: "Hel"}})
  26. feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{Type: "text_delta", Text: "lo"}})
  27. feed(&AnthropicStreamEvent{Type: "content_block_stop", Index: &idx})
  28. feed(&AnthropicStreamEvent{Type: "message_stop"})
  29. posOf := func(target string) int {
  30. for i, ty := range types {
  31. if ty == target {
  32. return i
  33. }
  34. }
  35. return -1
  36. }
  37. partAdded := posOf("response.content_part.added")
  38. firstDelta := posOf("response.output_text.delta")
  39. if partAdded < 0 {
  40. t.Fatalf("response.content_part.added was not emitted; got %v", types)
  41. }
  42. if firstDelta < 0 {
  43. t.Fatalf("response.output_text.delta was not emitted; got %v", types)
  44. }
  45. if partAdded > firstDelta {
  46. t.Errorf("content_part.added must precede the first output_text.delta; got %v", types)
  47. }
  48. if posOf("response.content_part.done") < 0 {
  49. t.Errorf("response.content_part.done was not emitted; got %v", types)
  50. }
  51. }
  52. // TestAnthropicEventToResponses_DoneEventsCarryFullText pins that done events
  53. // carry the part's full text (deltas carry increments only).
  54. func TestAnthropicEventToResponses_DoneEventsCarryFullText(t *testing.T) {
  55. state := NewAnthropicEventToResponsesState()
  56. state.Model = "claude-sonnet-4-5"
  57. var events []ResponsesStreamEvent
  58. feed := func(evt *AnthropicStreamEvent) {
  59. events = append(events, AnthropicEventToResponsesEvents(evt, state)...)
  60. }
  61. idx := 0
  62. feed(&AnthropicStreamEvent{Type: "message_start", Message: &AnthropicResponse{ID: "msg_1"}})
  63. feed(&AnthropicStreamEvent{Type: "content_block_start", Index: &idx, ContentBlock: &AnthropicContentBlock{Type: "text"}})
  64. feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{Type: "text_delta", Text: "Hello "}})
  65. feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{Type: "text_delta", Text: "world"}})
  66. feed(&AnthropicStreamEvent{Type: "content_block_stop", Index: &idx})
  67. const want = "Hello world"
  68. var sawTextDone, sawPartDone bool
  69. for _, e := range events {
  70. switch e.Type {
  71. case "response.output_text.done":
  72. sawTextDone = true
  73. if e.Text != want {
  74. t.Errorf("output_text.done text = %q, want %q", e.Text, want)
  75. }
  76. case "response.content_part.done":
  77. sawPartDone = true
  78. if e.Part == nil || e.Part.Text != want {
  79. t.Errorf("content_part.done part = %+v, want text %q", e.Part, want)
  80. }
  81. }
  82. }
  83. if !sawTextDone || !sawPartDone {
  84. t.Errorf("missing done events: output_text.done=%v content_part.done=%v", sawTextDone, sawPartDone)
  85. }
  86. }
  87. // TestAnthropicEventToResponses_CompletedCarriesOutput pins that
  88. // response.completed carries the full output list. The SDK's
  89. // get_final_response() and tracing integrations parse the terminal event's
  90. // response directly; an empty output leaves them with nothing (the text still
  91. // renders from deltas, which is why this is invisible when only watching the
  92. // stream).
  93. func TestAnthropicEventToResponses_CompletedCarriesOutput(t *testing.T) {
  94. state := NewAnthropicEventToResponsesState()
  95. state.Model = "claude-sonnet-4-5"
  96. var events []ResponsesStreamEvent
  97. feed := func(evt *AnthropicStreamEvent) {
  98. events = append(events, AnthropicEventToResponsesEvents(evt, state)...)
  99. }
  100. idx := 0
  101. feed(&AnthropicStreamEvent{Type: "message_start", Message: &AnthropicResponse{ID: "msg_1"}})
  102. feed(&AnthropicStreamEvent{Type: "content_block_start", Index: &idx, ContentBlock: &AnthropicContentBlock{Type: "text"}})
  103. feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{Type: "text_delta", Text: "4826"}})
  104. feed(&AnthropicStreamEvent{Type: "content_block_stop", Index: &idx})
  105. feed(&AnthropicStreamEvent{Type: "message_stop"})
  106. var completed *ResponsesStreamEvent
  107. for i := range events {
  108. if events[i].Type == "response.completed" {
  109. completed = &events[i]
  110. }
  111. }
  112. if completed == nil || completed.Response == nil {
  113. t.Fatalf("response.completed was not emitted")
  114. }
  115. if len(completed.Response.Output) == 0 {
  116. t.Fatalf("response.completed carries an empty output; clients would see no result")
  117. }
  118. msg := completed.Response.Output[0]
  119. if msg.Type != "message" || len(msg.Content) == 0 {
  120. t.Fatalf("output[0] = %+v, want a message with content", msg)
  121. }
  122. if msg.Content[0].Text != "4826" {
  123. t.Errorf("output[0].content[0].text = %q, want %q", msg.Content[0].Text, "4826")
  124. }
  125. }
  126. // TestAnthropicEventToResponses_ToolCallCompletedCarriesArguments pins that a
  127. // function call's accumulated arguments survive into output_item.done and
  128. // response.completed.
  129. func TestAnthropicEventToResponses_ToolCallCompletedCarriesArguments(t *testing.T) {
  130. state := NewAnthropicEventToResponsesState()
  131. state.Model = "claude-sonnet-4-5"
  132. var events []ResponsesStreamEvent
  133. feed := func(evt *AnthropicStreamEvent) {
  134. events = append(events, AnthropicEventToResponsesEvents(evt, state)...)
  135. }
  136. idx := 0
  137. feed(&AnthropicStreamEvent{Type: "message_start", Message: &AnthropicResponse{ID: "msg_1"}})
  138. feed(&AnthropicStreamEvent{Type: "content_block_start", Index: &idx, ContentBlock: &AnthropicContentBlock{
  139. Type: "tool_use", ID: "toolu_1", Name: "get_weather",
  140. }})
  141. feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{
  142. Type: "input_json_delta", PartialJSON: `{"city":`,
  143. }})
  144. feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{
  145. Type: "input_json_delta", PartialJSON: `"SH"}`,
  146. }})
  147. feed(&AnthropicStreamEvent{Type: "content_block_stop", Index: &idx})
  148. feed(&AnthropicStreamEvent{Type: "message_stop"})
  149. var completed *ResponsesStreamEvent
  150. for i := range events {
  151. if events[i].Type == "response.completed" {
  152. completed = &events[i]
  153. }
  154. }
  155. if completed == nil || completed.Response == nil || len(completed.Response.Output) == 0 {
  156. t.Fatalf("response.completed carries no output")
  157. }
  158. fc := completed.Response.Output[0]
  159. if fc.Type != "function_call" {
  160. t.Fatalf("output[0].type = %q, want function_call", fc.Type)
  161. }
  162. if fc.Arguments != `{"city":"SH"}` {
  163. t.Errorf("arguments = %q, want %q", fc.Arguments, `{"city":"SH"}`)
  164. }
  165. if fc.Name != "get_weather" {
  166. t.Errorf("name = %q, want get_weather", fc.Name)
  167. }
  168. }
  169. // TestAnthropicEventToResponses_ThinkingAfterTextKeepsMessageOutput pins that a
  170. // thinking block arriving after a text block closes the open message item
  171. // instead of silently replacing it.
  172. //
  173. // Why: a message item is deliberately left open when its text block stops
  174. // (more text blocks may follow in the same item). content_block_start therefore
  175. // has to close whatever is open before starting a new item — tool_use already
  176. // did, thinking did not, so it overwrote CurrentItemType/CurrentItemID and the
  177. // accumulated CurrentContent never reached state.Outputs. response.completed
  178. // then carried only the reasoning item and the client saw a successful response
  179. // with no assistant text. Interleaved thinking (anthropic-beta
  180. // interleaved-thinking-2025-05-14, forwarded by this gateway) produces exactly
  181. // this text → thinking ordering.
  182. func TestAnthropicEventToResponses_ThinkingAfterTextKeepsMessageOutput(t *testing.T) {
  183. state := NewAnthropicEventToResponsesState()
  184. state.Model = "claude-sonnet-4-5"
  185. var events []ResponsesStreamEvent
  186. feed := func(evt *AnthropicStreamEvent) {
  187. events = append(events, AnthropicEventToResponsesEvents(evt, state)...)
  188. }
  189. i0, i1 := 0, 1
  190. feed(&AnthropicStreamEvent{Type: "message_start", Message: &AnthropicResponse{ID: "msg_1"}})
  191. feed(&AnthropicStreamEvent{Type: "content_block_start", Index: &i0, ContentBlock: &AnthropicContentBlock{Type: "text"}})
  192. feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &i0, Delta: &AnthropicDelta{Type: "text_delta", Text: "answer"}})
  193. feed(&AnthropicStreamEvent{Type: "content_block_stop", Index: &i0})
  194. feed(&AnthropicStreamEvent{Type: "content_block_start", Index: &i1, ContentBlock: &AnthropicContentBlock{Type: "thinking"}})
  195. feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &i1, Delta: &AnthropicDelta{Type: "thinking_delta", Thinking: "hmm"}})
  196. feed(&AnthropicStreamEvent{Type: "content_block_stop", Index: &i1})
  197. feed(&AnthropicStreamEvent{Type: "message_stop"})
  198. var completed *ResponsesStreamEvent
  199. for i := range events {
  200. if events[i].Type == "response.completed" {
  201. completed = &events[i]
  202. }
  203. }
  204. if completed == nil || completed.Response == nil {
  205. t.Fatalf("response.completed was not emitted")
  206. }
  207. outputs := completed.Response.Output
  208. if len(outputs) != 2 {
  209. t.Fatalf("response.completed carries %d output items, want 2 (message + reasoning): %+v", len(outputs), outputs)
  210. }
  211. if outputs[0].Type != "message" {
  212. t.Fatalf("output[0].type = %q, want message", outputs[0].Type)
  213. }
  214. if len(outputs[0].Content) != 1 || outputs[0].Content[0].Text != "answer" {
  215. t.Errorf("assistant text lost: output[0].content = %+v", outputs[0].Content)
  216. }
  217. if outputs[1].Type != "reasoning" {
  218. t.Errorf("output[1].type = %q, want reasoning", outputs[1].Type)
  219. }
  220. // The two items must also occupy distinct output_index values; sharing one
  221. // index is how the reasoning item used to land on top of the message item.
  222. var addedIndexes []int
  223. for _, evt := range events {
  224. if evt.Type == "response.output_item.added" {
  225. addedIndexes = append(addedIndexes, evt.OutputIndex)
  226. }
  227. }
  228. if len(addedIndexes) != 2 || addedIndexes[0] == addedIndexes[1] {
  229. t.Errorf("output_item.added indexes = %v, want two distinct values", addedIndexes)
  230. }
  231. }
  232. // TestAnthropicEventToResponses_MultipleTextBlocksAdvanceContentIndex pins that
  233. // each text block within one message item gets its own content_index.
  234. //
  235. // Why: content_index was assigned when the item opened and never advanced, so a
  236. // second text block re-emitted content_part.added at index 0. The OpenAI SDK's
  237. // accumulating stream helper writes parts at output.content[content_index], so
  238. // the second part overwrote the first and the earlier text disappeared from the
  239. // accumulated response.
  240. func TestAnthropicEventToResponses_MultipleTextBlocksAdvanceContentIndex(t *testing.T) {
  241. state := NewAnthropicEventToResponsesState()
  242. state.Model = "claude-sonnet-4-5"
  243. var events []ResponsesStreamEvent
  244. feed := func(evt *AnthropicStreamEvent) {
  245. events = append(events, AnthropicEventToResponsesEvents(evt, state)...)
  246. }
  247. i0, i1 := 0, 1
  248. feed(&AnthropicStreamEvent{Type: "message_start", Message: &AnthropicResponse{ID: "msg_1"}})
  249. feed(&AnthropicStreamEvent{Type: "content_block_start", Index: &i0, ContentBlock: &AnthropicContentBlock{Type: "text"}})
  250. feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &i0, Delta: &AnthropicDelta{Type: "text_delta", Text: "first"}})
  251. feed(&AnthropicStreamEvent{Type: "content_block_stop", Index: &i0})
  252. feed(&AnthropicStreamEvent{Type: "content_block_start", Index: &i1, ContentBlock: &AnthropicContentBlock{Type: "text"}})
  253. feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &i1, Delta: &AnthropicDelta{Type: "text_delta", Text: "second"}})
  254. feed(&AnthropicStreamEvent{Type: "content_block_stop", Index: &i1})
  255. feed(&AnthropicStreamEvent{Type: "message_stop"})
  256. var partAdded []int
  257. for _, evt := range events {
  258. if evt.Type == "response.content_part.added" {
  259. partAdded = append(partAdded, evt.ContentIndex)
  260. }
  261. }
  262. if len(partAdded) != 2 {
  263. t.Fatalf("content_part.added emitted %d times, want 2", len(partAdded))
  264. }
  265. if partAdded[0] != 0 || partAdded[1] != 1 {
  266. t.Errorf("content_part.added indexes = %v, want [0 1]", partAdded)
  267. }
  268. // Every delta/done for a part must carry that part's index, otherwise the
  269. // SDK appends the text to the wrong slot.
  270. byIndex := map[int]string{}
  271. for _, evt := range events {
  272. if evt.Type == "response.output_text.delta" {
  273. byIndex[evt.ContentIndex] += evt.Delta
  274. }
  275. }
  276. if byIndex[0] != "first" || byIndex[1] != "second" {
  277. t.Errorf("output_text.delta grouped by content_index = %v, want {0:first 1:second}", byIndex)
  278. }
  279. }
  280. // TestAnthropicEventToResponses_ItemLifecycleIsBalanced is the invariant behind
  281. // both regressions above: the converter must never leave an item that was
  282. // announced with response.output_item.added without a matching
  283. // response.output_item.done, and the terminal event must carry one output entry
  284. // per announced item. Any future block type that forgets to close the open item
  285. // breaks this, whatever the symptom looks like.
  286. func TestAnthropicEventToResponses_ItemLifecycleIsBalanced(t *testing.T) {
  287. for _, tc := range []struct {
  288. name string
  289. blocks []*AnthropicContentBlock
  290. }{
  291. {"text then thinking", []*AnthropicContentBlock{{Type: "text"}, {Type: "thinking"}}},
  292. {"thinking then text", []*AnthropicContentBlock{{Type: "thinking"}, {Type: "text"}}},
  293. {"text then tool_use", []*AnthropicContentBlock{{Type: "text"}, {Type: "tool_use", ID: "toolu_1", Name: "t"}}},
  294. {"text thinking text", []*AnthropicContentBlock{{Type: "text"}, {Type: "thinking"}, {Type: "text"}}},
  295. {"thinking text tool_use", []*AnthropicContentBlock{{Type: "thinking"}, {Type: "text"}, {Type: "tool_use", ID: "toolu_2", Name: "t"}}},
  296. } {
  297. t.Run(tc.name, func(t *testing.T) {
  298. state := NewAnthropicEventToResponsesState()
  299. state.Model = "claude-sonnet-4-5"
  300. var events []ResponsesStreamEvent
  301. feed := func(evt *AnthropicStreamEvent) {
  302. events = append(events, AnthropicEventToResponsesEvents(evt, state)...)
  303. }
  304. feed(&AnthropicStreamEvent{Type: "message_start", Message: &AnthropicResponse{ID: "msg_1"}})
  305. for i, block := range tc.blocks {
  306. idx := i
  307. feed(&AnthropicStreamEvent{Type: "content_block_start", Index: &idx, ContentBlock: block})
  308. switch block.Type {
  309. case "text":
  310. feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{Type: "text_delta", Text: "t"}})
  311. case "thinking":
  312. feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{Type: "thinking_delta", Thinking: "r"}})
  313. case "tool_use":
  314. feed(&AnthropicStreamEvent{Type: "content_block_delta", Index: &idx, Delta: &AnthropicDelta{Type: "input_json_delta", PartialJSON: "{}"}})
  315. }
  316. feed(&AnthropicStreamEvent{Type: "content_block_stop", Index: &idx})
  317. }
  318. feed(&AnthropicStreamEvent{Type: "message_stop"})
  319. openIDs := map[string]int{}
  320. var order []string
  321. for _, evt := range events {
  322. switch evt.Type {
  323. case "response.output_item.added":
  324. if evt.Item == nil {
  325. t.Fatalf("output_item.added without item")
  326. }
  327. openIDs[evt.Item.ID]++
  328. order = append(order, evt.Item.ID)
  329. case "response.output_item.done":
  330. if evt.Item == nil {
  331. t.Fatalf("output_item.done without item")
  332. }
  333. openIDs[evt.Item.ID]--
  334. }
  335. }
  336. for id, n := range openIDs {
  337. if n != 0 {
  338. t.Errorf("item %s: output_item.added/done imbalance %+d", id, n)
  339. }
  340. }
  341. var completed *ResponsesStreamEvent
  342. for i := range events {
  343. if events[i].Type == "response.completed" {
  344. completed = &events[i]
  345. }
  346. }
  347. if completed == nil || completed.Response == nil {
  348. t.Fatalf("response.completed was not emitted")
  349. }
  350. if len(completed.Response.Output) != len(order) {
  351. t.Fatalf("response.completed carries %d outputs, want %d (one per announced item)",
  352. len(completed.Response.Output), len(order))
  353. }
  354. for i, id := range order {
  355. if completed.Response.Output[i].ID != id {
  356. t.Errorf("output[%d].id = %q, want %q (announcement order must be preserved)",
  357. i, completed.Response.Output[i].ID, id)
  358. }
  359. }
  360. })
  361. }
  362. }