image_output_accounting_test.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. package service
  2. import (
  3. "testing"
  4. )
  5. func TestOpenAIImageOutputCounter_TextOnlyMessage(t *testing.T) {
  6. // Simulate a text-only response from /v1/responses
  7. // The response.output_item.done event for a text message
  8. sseBody := `data: {"type":"response.created","response":{"id":"resp_123"}}
  9. data: {"type":"response.in_progress","response":{"id":"resp_123"}}
  10. data: {"type":"response.output_item.added","item":{"id":"item_1","type":"message","role":"assistant","status":"in_progress"}}
  11. data: {"type":"response.output_text.delta","item_id":"item_1","output_index":0,"content_index":0,"delta":"Hello"}
  12. data: {"type":"response.output_text.done","item_id":"item_1","output_index":0,"content_index":0,"text":"Hello"}
  13. data: {"type":"response.output_item.done","item":{"id":"item_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Hello"}]}}
  14. data: {"type":"response.completed","response":{"id":"resp_123","output":[{"id":"item_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Hello"}]}],"usage":{"input_tokens":10,"output_tokens":5}}}
  15. data: [DONE]`
  16. count := countOpenAIImageOutputsFromSSEBody(sseBody)
  17. if count != 0 {
  18. t.Errorf("expected 0 images for text-only message, got %d", count)
  19. }
  20. }
  21. func TestOpenAIImageOutputCounter_NestedSub2API_StandardResponse(t *testing.T) {
  22. // Simulate what a nested sub2api setup receives
  23. // When the upstream sub2api returns a standard response, the downstream
  24. // sub2api receives SSE events. This test verifies that no false positives
  25. // occur.
  26. sseBody := `data: {"type":"response.created","response":{"id":"resp_nested_1","object":"response","status":"in_progress"}}
  27. data: {"type":"response.in_progress","response":{"id":"resp_nested_1","object":"response","status":"in_progress"}}
  28. data: {"type":"response.output_item.added","item":{"id":"item_nested_1","type":"message","role":"assistant","status":"in_progress"}}
  29. data: {"type":"response.output_text.delta","item_id":"item_nested_1","output_index":0,"content_index":0,"delta":"你好"}
  30. data: {"type":"response.output_text.done","item_id":"item_nested_1","output_index":0,"content_index":0,"text":"你好!有什么我可以帮助你的吗?"}
  31. data: {"type":"response.output_item.done","item":{"id":"item_nested_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"你好!有什么我可以帮助你的吗?"}]}}
  32. data: {"type":"response.completed","response":{"id":"resp_nested_1","object":"response","status":"completed","output":[{"id":"item_nested_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"你好!有什么我可以帮助你的吗?"}]}],"usage":{"input_tokens":15,"output_tokens":12}}}
  33. data: [DONE]`
  34. count := countOpenAIImageOutputsFromSSEBody(sseBody)
  35. if count != 0 {
  36. t.Errorf("expected 0 images for nested sub2api text-only response, got %d", count)
  37. }
  38. }
  39. func TestOpenAIImageOutputCounter_JSONResponse_TextOnly(t *testing.T) {
  40. // Simulate JSON response (non-streaming) for text-only message
  41. jsonBody := `{
  42. "id": "resp_json_1",
  43. "object": "response",
  44. "status": "completed",
  45. "output": [
  46. {
  47. "id": "item_json_1",
  48. "type": "message",
  49. "role": "assistant",
  50. "status": "completed",
  51. "content": [
  52. {
  53. "type": "output_text",
  54. "text": "Hello!"
  55. }
  56. ]
  57. }
  58. ],
  59. "usage": {
  60. "input_tokens": 10,
  61. "output_tokens": 5
  62. }
  63. }`
  64. count := countOpenAIResponseImageOutputsFromJSONBytes([]byte(jsonBody))
  65. if count != 0 {
  66. t.Errorf("expected 0 images for text-only JSON response, got %d", count)
  67. }
  68. }
  69. func TestOpenAIImageOutputCounter_DataArray_FalsePositive(t *testing.T) {
  70. // Test: SSE events in /v1/responses should NOT have a "data" array field,
  71. // but if one is present with actual image URLs, it should be counted.
  72. // The bug was that addDataArray counted ALL array elements without checking
  73. // if they contained actual image output (url/b64_json).
  74. //
  75. // Test 1: data array with non-image objects should NOT count
  76. sseWithNonImageData := `data: {"type":"response.completed","response":{"id":"resp_1","output":[{"id":"item_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Hello"}]}]},"data":[{"id":"not_an_image","status":"done"}]}
  77. data: [DONE]`
  78. count := countOpenAIImageOutputsFromSSEBody(sseWithNonImageData)
  79. if count != 0 {
  80. t.Errorf("expected 0 images for data array without image output, got %d", count)
  81. }
  82. // Test 2: data array with actual image output should still count correctly
  83. sseWithImageData := `data: {"type":"response.completed","response":{"id":"resp_1","output":[]},"data":[{"url":"https://example.com/img.png"}]}
  84. data: [DONE]`
  85. count2 := countOpenAIImageOutputsFromSSEBody(sseWithImageData)
  86. if count2 != 1 {
  87. t.Errorf("expected 1 image for data array with image URL, got %d", count2)
  88. }
  89. }
  90. func TestOpenAIImageOutputCounter_JSONResponse_DataArray_FalsePositive(t *testing.T) {
  91. // Test: JSON responses in /v1/responses should NOT have a "data" array field.
  92. // But if one is present with non-image objects, it should NOT cause false counting.
  93. jsonWithNonImageData := `{
  94. "id": "resp_1",
  95. "object": "response",
  96. "status": "completed",
  97. "output": [
  98. {
  99. "id": "item_1",
  100. "type": "message",
  101. "role": "assistant",
  102. "status": "completed",
  103. "content": [{"type": "output_text", "text": "Hello"}]
  104. }
  105. ],
  106. "data": [
  107. {"id": "not_an_image", "status": "done"}
  108. ],
  109. "usage": {"input_tokens": 10, "output_tokens": 5}
  110. }`
  111. count := countOpenAIResponseImageOutputsFromJSONBytes([]byte(jsonWithNonImageData))
  112. if count != 0 {
  113. t.Errorf("expected 0 images for data array without image output, got %d", count)
  114. }
  115. // Test: data array with actual image output should still count correctly
  116. jsonWithImageData := `{
  117. "id": "resp_1",
  118. "object": "response",
  119. "output": [],
  120. "data": [
  121. {"url": "https://example.com/img.png"}
  122. ]
  123. }`
  124. count2 := countOpenAIResponseImageOutputsFromJSONBytes([]byte(jsonWithImageData))
  125. if count2 != 1 {
  126. t.Errorf("expected 1 image for data array with image URL, got %d", count2)
  127. }
  128. }
  129. func TestOpenAIImageOutputCounter_PassthroughSSEToJSON_Conversion(t *testing.T) {
  130. // Simulate the scenario in handlePassthroughSSEToJSON where extractCodexFinalResponse
  131. // successfully extracts the response JSON, then countOpenAIResponseImageOutputsFromJSONBytes
  132. // is called on the extracted response object.
  133. //
  134. // The extracted response is just the "response" field from the "response.done" event.
  135. // This should NOT have a "data" array.
  136. extractedResponse := `{
  137. "id": "resp_extracted_1",
  138. "object": "response",
  139. "status": "completed",
  140. "output": [
  141. {
  142. "id": "item_ext_1",
  143. "type": "message",
  144. "role": "assistant",
  145. "status": "completed",
  146. "content": [{"type": "output_text", "text": "Hello"}]
  147. }
  148. ],
  149. "usage": {"input_tokens": 10, "output_tokens": 5}
  150. }`
  151. count := countOpenAIResponseImageOutputsFromJSONBytes([]byte(extractedResponse))
  152. if count != 0 {
  153. t.Errorf("expected 0 images for extracted response JSON, got %d", count)
  154. }
  155. }
  156. func TestOpenAIImageOutputCounter_AddDataArray_FromVariousSources(t *testing.T) {
  157. // Test various JSON payloads that might trigger addDataArray
  158. tests := []struct {
  159. name string
  160. json string
  161. expected int
  162. }{
  163. {
  164. name: "standard response.done event",
  165. json: `{"type":"response.done","response":{"id":"r1","output":[{"type":"message","id":"m1","content":[{"type":"output_text","text":"hi"}]}]}}`,
  166. },
  167. {
  168. name: "response with null data field",
  169. json: `{"type":"response.done","response":{"id":"r1","output":[]},"data":null}`,
  170. },
  171. {
  172. name: "response with empty data array",
  173. json: `{"type":"response.done","response":{"id":"r1","output":[]},"data":[]}`,
  174. },
  175. {
  176. name: "response with single-element data array",
  177. json: `{"type":"response.done","response":{"id":"r1","output":[]},"data":[{"url":"https://example.com/img.png"}]}`,
  178. },
  179. {
  180. name: "image_generation.completed event without result",
  181. json: `{"type":"image_generation.completed","item":{"type":"image_generation.completed","id":"call_1"}}`,
  182. },
  183. {
  184. name: "output_item with empty type",
  185. json: `{"type":"response.output_item.done","item":{"id":"x1","status":"completed"}}`,
  186. },
  187. }
  188. for _, tt := range tests {
  189. t.Run(tt.name, func(t *testing.T) {
  190. counter := newOpenAIImageOutputCounter()
  191. counter.AddSSEData([]byte(tt.json))
  192. count := counter.Count()
  193. t.Logf(" count=%d (maxDataCount=%d, count=%d)", count, counter.maxDataCount, counter.count)
  194. })
  195. }
  196. }
  197. func TestOpenAIImageOutputCounter_AddJSONResponse_Exported(t *testing.T) {
  198. tests := []struct {
  199. name string
  200. json string
  201. }{
  202. {
  203. name: "standard /v1/responses JSON response",
  204. json: `{"id":"r1","object":"response","output":[{"type":"message","id":"m1","content":[{"type":"output_text","text":"hi"}]}],"usage":{"input_tokens":10,"output_tokens":5}}`,
  205. },
  206. {
  207. name: "response with data field (like /v1/images/generations)",
  208. json: `{"id":"r1","object":"response","output":[{"type":"message","id":"m1","content":[{"type":"output_text","text":"hi"}]}],"data":[{"url":"https://example.com/img.png"}],"usage":{"input_tokens":10,"output_tokens":5}}`,
  209. },
  210. }
  211. for _, tt := range tests {
  212. t.Run(tt.name, func(t *testing.T) {
  213. count := countOpenAIResponseImageOutputsFromJSONBytes([]byte(tt.json))
  214. t.Logf(" count=%d", count)
  215. })
  216. }
  217. }