chatcompletions_responses_tool_output_media_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. package apicompat
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "strings"
  6. "testing"
  7. "github.com/stretchr/testify/require"
  8. )
  9. const (
  10. testToolImageDataURL = "data:image/png;base64,AQID"
  11. testToolImageRemote = "https://example.com/tool-output.png"
  12. )
  13. func TestResponsesToolOutputMedia_ExtractsSupportedShapes(t *testing.T) {
  14. tests := []struct {
  15. name string
  16. call string
  17. outputType string
  18. output string
  19. imageURL string
  20. toolText string
  21. }{
  22. {
  23. name: "image-only array",
  24. call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`,
  25. outputType: "function_call_output",
  26. output: `[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]`,
  27. imageURL: testToolImageDataURL,
  28. },
  29. {
  30. name: "text and nested image URL",
  31. call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`,
  32. outputType: "function_call_output",
  33. output: `[{"type":"input_text","text":"render complete"},{"type":"image_url","image_url":{"url":"https://example.com/tool-output.png"}}]`,
  34. imageURL: testToolImageRemote,
  35. toolText: "render complete",
  36. },
  37. {
  38. name: "top-level image object",
  39. call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`,
  40. outputType: "function_call_output",
  41. output: `{"type":"input_image","image_url":"data:image/png;base64,AQID"}`,
  42. imageURL: testToolImageDataURL,
  43. },
  44. {
  45. name: "content wrapper",
  46. call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`,
  47. outputType: "function_call_output",
  48. output: `{"status":"ok","content":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}],"unknown":{"score":0.9,"large":9007199254740993}}`,
  49. imageURL: testToolImageDataURL,
  50. toolText: `"large":9007199254740993`,
  51. },
  52. {
  53. name: "JSON string output",
  54. call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`,
  55. outputType: "function_call_output",
  56. output: `"[{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,AQID\"}]"`,
  57. imageURL: testToolImageDataURL,
  58. },
  59. {
  60. name: "bare data URL",
  61. call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`,
  62. outputType: "function_call_output",
  63. output: `"data:image/png;base64,AQID"`,
  64. imageURL: testToolImageDataURL,
  65. },
  66. {
  67. name: "custom tool output",
  68. call: `{"type":"custom_tool_call","call_id":"call_image","name":"view_image","input":"{}"}`,
  69. outputType: "custom_tool_call_output",
  70. output: `[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]`,
  71. imageURL: testToolImageDataURL,
  72. },
  73. {
  74. name: "tool search output",
  75. call: `{"type":"tool_search_call","call_id":"call_image","arguments":{"query":"image"}}`,
  76. outputType: "tool_search_output",
  77. output: `[{"type":"image_url","image_url":{"url":"https://example.com/tool-output.png"}}]`,
  78. imageURL: testToolImageRemote,
  79. },
  80. }
  81. for _, tt := range tests {
  82. t.Run(tt.name, func(t *testing.T) {
  83. input := fmt.Sprintf(`[%s,{"type":%q,"call_id":"call_image","output":%s}]`, tt.call, tt.outputType, tt.output)
  84. messages := convertToolOutputMedia(t, input)
  85. require.Len(t, messages, 3)
  86. require.Equal(t, []string{"assistant", "tool", "user"}, chatMessageRoles(messages))
  87. require.Equal(t, "call_image", messages[1].ToolCallID)
  88. toolText := chatToolContentString(t, messages[1])
  89. require.Contains(t, toolText, "[Tool output media moved to the following user message]")
  90. require.NotContains(t, toolText, tt.imageURL)
  91. if tt.toolText != "" {
  92. require.Contains(t, toolText, tt.toolText)
  93. }
  94. parts := chatContentParts(t, messages[2])
  95. require.Len(t, parts, 2)
  96. require.Equal(t, "text", parts[0].Type)
  97. require.Equal(t, "[Tool output media for call call_image]", parts[0].Text)
  98. require.Equal(t, "image_url", parts[1].Type)
  99. require.NotNil(t, parts[1].ImageURL)
  100. require.Equal(t, tt.imageURL, parts[1].ImageURL.URL)
  101. })
  102. }
  103. }
  104. func TestResponsesToolOutputMedia_ParallelBatchUsesCallOrder(t *testing.T) {
  105. messages := convertToolOutputMedia(t, `[
  106. {"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"},
  107. {"type":"function_call","call_id":"call_B","name":"view_image","arguments":"{}"},
  108. {"type":"function_call_output","call_id":"call_B","output":[{"type":"input_image","image_url":{"url":"https://example.com/b.png"}}]},
  109. {"type":"function_call_output","call_id":"call_A","output":[{"type":"input_image","image_url":{"url":"https://example.com/a.png"}}]}
  110. ]`)
  111. require.Len(t, messages, 4)
  112. require.Equal(t, []string{"assistant", "tool", "tool", "user"}, chatMessageRoles(messages))
  113. require.Equal(t, []string{"call_A", "call_B"}, []string{messages[1].ToolCallID, messages[2].ToolCallID})
  114. parts := chatContentParts(t, messages[3])
  115. require.Len(t, parts, 4)
  116. require.Equal(t, "[Tool output media for call call_A]", parts[0].Text)
  117. require.Equal(t, "https://example.com/a.png", parts[1].ImageURL.URL)
  118. require.Equal(t, "[Tool output media for call call_B]", parts[2].Text)
  119. require.Equal(t, "https://example.com/b.png", parts[3].ImageURL.URL)
  120. }
  121. func TestResponsesToolOutputMedia_PreservesRichSiblingWhenRewriting(t *testing.T) {
  122. messages := convertToolOutputMedia(t, `[
  123. {"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"},
  124. {"type":"function_call_output","call_id":"call_image","output":[
  125. {"type":"result","url":"https://example.com/result","score":0.9,"text":"complete","extra":{"count":2}},
  126. {"type":"input_image","image_url":"data:image/png;base64,AQID"}
  127. ]}
  128. ]`)
  129. toolText := chatToolContentString(t, messages[1])
  130. require.JSONEq(t, `[
  131. {"type":"result","url":"https://example.com/result","score":0.9,"text":"complete","extra":{"count":2}},
  132. {"type":"input_text","text":"[Tool output media moved to the following user message]"}
  133. ]`, toolText)
  134. }
  135. func TestResponsesToolOutputMedia_InterleavedMessagesFollowMediaBatch(t *testing.T) {
  136. messages := convertToolOutputMedia(t, `[
  137. {"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"},
  138. {"type":"message","role":"developer","content":[{"type":"input_text","text":"approval saved"}]},
  139. {"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]},
  140. {"type":"function_call_output","call_id":"call_A","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}
  141. ]`)
  142. require.Equal(t, []string{"assistant", "tool", "user", "system", "user"}, chatMessageRoles(messages))
  143. require.Equal(t, "[Tool output media for call call_A]", chatContentParts(t, messages[2])[0].Text)
  144. require.JSONEq(t, `"approval saved"`, string(messages[3].Content))
  145. require.JSONEq(t, `"continue"`, string(messages[4].Content))
  146. }
  147. func TestResponsesToolOutputMedia_DropsOrphanAndUnansweredCallMedia(t *testing.T) {
  148. t.Run("orphan", func(t *testing.T) {
  149. messages := convertToolOutputMedia(t, `[
  150. {"type":"function_call_output","call_id":"call_ghost","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}
  151. ]`)
  152. require.Empty(t, messages)
  153. })
  154. t.Run("unanswered parallel call", func(t *testing.T) {
  155. messages := convertToolOutputMedia(t, `[
  156. {"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"},
  157. {"type":"function_call","call_id":"call_B","name":"view_image","arguments":"{}"},
  158. {"type":"function_call_output","call_id":"call_A","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}
  159. ]`)
  160. require.Len(t, messages, 3)
  161. require.Len(t, messages[0].ToolCalls, 1)
  162. require.Equal(t, "call_A", messages[0].ToolCalls[0].ID)
  163. parts := chatContentParts(t, messages[2])
  164. require.Len(t, parts, 2)
  165. require.NotContains(t, string(messages[2].Content), "call_B")
  166. })
  167. }
  168. func TestResponsesToolOutputMedia_PreservesMediaFreeOutputBytes(t *testing.T) {
  169. tests := []struct {
  170. name string
  171. output string
  172. }{
  173. {
  174. name: "rich unknown object",
  175. output: `{"type":"result","url":"https://example.com/result","score":0.9,"text":"complete","extra":{"count":2}}`,
  176. },
  177. {
  178. name: "no-image array",
  179. output: `[ { "type": "input_text", "text": "ok" }, {"unknown":true} ]`,
  180. },
  181. {
  182. name: "plain string",
  183. output: `"plain output"`,
  184. },
  185. {
  186. name: "JSON string without image",
  187. output: `"{ \"ok\": true }"`,
  188. },
  189. {
  190. name: "embedded data URL text",
  191. output: `"prefix data:image/png;base64,AQID suffix"`,
  192. },
  193. }
  194. for _, tt := range tests {
  195. t.Run(tt.name, func(t *testing.T) {
  196. input := fmt.Sprintf(`[
  197. {"type":"function_call","call_id":"call_text","name":"exec","arguments":"{}"},
  198. {"type":"function_call_output","call_id":"call_text","output":%s}
  199. ]`, tt.output)
  200. messages := convertToolOutputMedia(t, input)
  201. require.Len(t, messages, 2)
  202. var expected string
  203. if err := json.Unmarshal([]byte(tt.output), &expected); err != nil {
  204. expected = tt.output
  205. }
  206. expectedContent, err := json.Marshal(expected)
  207. require.NoError(t, err)
  208. require.Equal(t, string(expectedContent), string(messages[1].Content))
  209. })
  210. }
  211. }
  212. func TestResponsesToolOutputMedia_DuplicateCallIDIsLastWins(t *testing.T) {
  213. t.Run("later media replaces earlier media", func(t *testing.T) {
  214. messages := convertToolOutputMedia(t, `[
  215. {"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"},
  216. {"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":{"url":"https://example.com/first.png"}}]},
  217. {"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":{"url":"https://example.com/last.png"}}]}
  218. ]`)
  219. require.Len(t, messages, 3)
  220. require.NotContains(t, string(messages[1].Content), "first.png")
  221. require.NotContains(t, string(messages[2].Content), "first.png")
  222. require.Contains(t, string(messages[2].Content), "last.png")
  223. })
  224. t.Run("later text clears earlier media", func(t *testing.T) {
  225. messages := convertToolOutputMedia(t, `[
  226. {"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"},
  227. {"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]},
  228. {"type":"function_call_output","call_id":"call_image","output":"latest text"}
  229. ]`)
  230. require.Len(t, messages, 2)
  231. require.Equal(t, "latest text", chatToolContentString(t, messages[1]))
  232. })
  233. }
  234. func TestResponsesToChatCompletionsRequest_ToolContentNeverContainsExtractedMedia(t *testing.T) {
  235. req := &ResponsesRequest{
  236. Model: "vision-model",
  237. Input: json.RawMessage(`[
  238. {"type":"function_call","call_id":"call_function","name":"view_image","arguments":"{}"},
  239. {"type":"custom_tool_call","call_id":"call_custom","name":"custom_image","input":"{}"},
  240. {"type":"tool_search_call","call_id":"call_search","arguments":{"query":"image"}},
  241. {"type":"function_call_output","call_id":"call_function","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]},
  242. {"type":"custom_tool_call_output","call_id":"call_custom","output":{"content":[{"type":"image_url","image_url":{"url":"https://example.com/custom.png"}}]}},
  243. {"type":"tool_search_output","call_id":"call_search","output":"data:image/jpeg;base64,BAUG"}
  244. ]`),
  245. }
  246. out, err := ResponsesToChatCompletionsRequest(req)
  247. require.NoError(t, err)
  248. assertChatInvariants(t, out.Messages)
  249. var toolCount int
  250. for _, message := range out.Messages {
  251. if message.Role != "tool" {
  252. continue
  253. }
  254. toolCount++
  255. content := string(message.Content)
  256. require.NotContains(t, content, "data:image/")
  257. require.NotContains(t, content, "https://example.com/custom.png")
  258. }
  259. require.Equal(t, 3, toolCount)
  260. require.Equal(t, []string{"assistant", "tool", "tool", "tool", "user"}, chatMessageRoles(out.Messages))
  261. }
  262. func convertToolOutputMedia(t *testing.T, input string) []ChatMessage {
  263. t.Helper()
  264. messages, err := responsesInputToChatMessages("", json.RawMessage(input))
  265. require.NoError(t, err)
  266. assertChatInvariants(t, messages)
  267. return messages
  268. }
  269. func chatToolContentString(t *testing.T, message ChatMessage) string {
  270. t.Helper()
  271. require.Equal(t, "tool", message.Role)
  272. var content string
  273. require.NoError(t, json.Unmarshal(message.Content, &content))
  274. return content
  275. }
  276. func chatContentParts(t *testing.T, message ChatMessage) []ChatContentPart {
  277. t.Helper()
  278. var parts []ChatContentPart
  279. require.NoError(t, json.Unmarshal(message.Content, &parts))
  280. for _, part := range parts {
  281. if part.Type == "image_url" {
  282. require.NotNil(t, part.ImageURL)
  283. require.False(t, strings.TrimSpace(part.ImageURL.URL) == "")
  284. }
  285. }
  286. return parts
  287. }