responses_stream_event_wire.go 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. package apicompat
  2. import "encoding/json"
  3. // MarshalJSON renders a ResponsesStreamEvent into its wire form.
  4. //
  5. // The OpenAI Responses streaming protocol requires several fields to be present
  6. // even when they hold a zero value: output_index/content_index/summary_index are
  7. // meaningful at 0, a function_call item must always carry call_id/name/arguments
  8. // (arguments may be ""), a message item must carry content:[] and an output_text
  9. // part must carry text/annotations/logprobs. Go's `omitempty` drops exactly those
  10. // zero values, and strict clients (Codex CLI) reject items/deltas whose required
  11. // fields are missing.
  12. //
  13. // Rather than marshalling with omitempty and patching the JSON afterwards, every
  14. // streamed event type is constructed explicitly here — the Go analogue of the
  15. // reference gateways' (cc-switch, CCX) per-event object construction. This is the
  16. // single source of truth for Responses SSE field presence and applies uniformly
  17. // to every emitter (Chat→Responses bridge and Anthropic→Responses converter).
  18. //
  19. // Event types not listed fall back to the default struct marshalling, which
  20. // bounds the blast radius of this method to the streamed item/part/text/tool
  21. // events.
  22. func (e ResponsesStreamEvent) MarshalJSON() ([]byte, error) {
  23. switch e.Type {
  24. case "response.output_text.delta", "response.output_text.done":
  25. m := e.wireBase()
  26. e.putItemID(m)
  27. m["output_index"] = e.OutputIndex
  28. m["content_index"] = e.ContentIndex
  29. if e.Type == "response.output_text.done" {
  30. m["text"] = e.Text
  31. } else {
  32. m["delta"] = e.Delta
  33. }
  34. return json.Marshal(m)
  35. case "response.content_part.added", "response.content_part.done":
  36. m := e.wireBase()
  37. e.putItemID(m)
  38. m["output_index"] = e.OutputIndex
  39. m["content_index"] = e.ContentIndex
  40. m["part"] = outputTextPartWire(e.Part)
  41. return json.Marshal(m)
  42. case "response.reasoning_summary_text.delta", "response.reasoning_summary_text.done":
  43. m := e.wireBase()
  44. e.putItemID(m)
  45. m["output_index"] = e.OutputIndex
  46. m["summary_index"] = e.SummaryIndex
  47. if e.Type == "response.reasoning_summary_text.done" {
  48. m["text"] = e.Text
  49. } else {
  50. m["delta"] = e.Delta
  51. }
  52. return json.Marshal(m)
  53. case "response.reasoning_summary_part.added", "response.reasoning_summary_part.done":
  54. m := e.wireBase()
  55. e.putItemID(m)
  56. m["output_index"] = e.OutputIndex
  57. m["summary_index"] = e.SummaryIndex
  58. m["part"] = summaryTextPartWire(e.Part)
  59. return json.Marshal(m)
  60. case "response.output_item.added", "response.output_item.done":
  61. m := e.wireBase()
  62. m["output_index"] = e.OutputIndex
  63. m["item"] = responsesItemWire(e.Item)
  64. return json.Marshal(m)
  65. case "response.function_call_arguments.delta", "response.function_call_arguments.done":
  66. m := e.wireBase()
  67. e.putItemID(m)
  68. m["output_index"] = e.OutputIndex
  69. if e.CallID != "" {
  70. m["call_id"] = e.CallID
  71. }
  72. if e.Name != "" {
  73. m["name"] = e.Name
  74. }
  75. if e.Type == "response.function_call_arguments.done" {
  76. m["arguments"] = e.Arguments
  77. } else {
  78. m["delta"] = e.Delta
  79. }
  80. return json.Marshal(m)
  81. case "response.custom_tool_call_input.delta", "response.custom_tool_call_input.done":
  82. m := e.wireBase()
  83. e.putItemID(m)
  84. m["output_index"] = e.OutputIndex
  85. if e.CallID != "" {
  86. m["call_id"] = e.CallID
  87. }
  88. if e.Name != "" {
  89. m["name"] = e.Name
  90. }
  91. if e.Type == "response.custom_tool_call_input.done" {
  92. m["input"] = e.Input
  93. } else {
  94. m["delta"] = e.Delta
  95. }
  96. return json.Marshal(m)
  97. default:
  98. // response.created / completed / done / failed / incomplete and any
  99. // event type not shaped above keep the default struct marshalling.
  100. type alias ResponsesStreamEvent
  101. return json.Marshal(alias(e))
  102. }
  103. }
  104. func (e ResponsesStreamEvent) wireBase() map[string]any {
  105. m := map[string]any{
  106. "type": e.Type,
  107. "sequence_number": e.SequenceNumber,
  108. }
  109. return m
  110. }
  111. func (e ResponsesStreamEvent) putItemID(m map[string]any) {
  112. if e.ItemID != "" {
  113. m["item_id"] = e.ItemID
  114. }
  115. }
  116. // outputTextPartWire renders a content part for a message's output_text, always
  117. // carrying text/annotations/logprobs (matching cc-switch's push_text_delta).
  118. func outputTextPartWire(part *ResponsesContentPart) map[string]any {
  119. text := ""
  120. if part != nil {
  121. text = part.Text
  122. }
  123. return map[string]any{
  124. "type": "output_text",
  125. "text": text,
  126. "annotations": []any{},
  127. "logprobs": []any{},
  128. }
  129. }
  130. // summaryTextPartWire renders a reasoning summary part.
  131. func summaryTextPartWire(part *ResponsesContentPart) map[string]any {
  132. text := ""
  133. if part != nil {
  134. text = part.Text
  135. }
  136. return map[string]any{
  137. "type": "summary_text",
  138. "text": text,
  139. }
  140. }
  141. // responsesItemWire renders an output_item with every field the item's type
  142. // requires to be present, including the empty arrays/strings that omitempty
  143. // would otherwise drop. Mirrors cc-switch's response_function_call_item and the
  144. // message/reasoning item shapes codex expects.
  145. func responsesItemWire(item *ResponsesOutput) map[string]any {
  146. if item == nil {
  147. return map[string]any{}
  148. }
  149. m := map[string]any{
  150. "type": item.Type,
  151. "id": item.ID,
  152. }
  153. if item.Status != "" {
  154. m["status"] = item.Status
  155. }
  156. switch item.Type {
  157. case "message":
  158. role := item.Role
  159. if role == "" {
  160. role = "assistant"
  161. }
  162. m["role"] = role
  163. m["content"] = messageContentWire(item.Content)
  164. case "reasoning":
  165. m["summary"] = reasoningSummaryWire(item.Summary)
  166. if item.EncryptedContent != "" {
  167. m["encrypted_content"] = item.EncryptedContent
  168. }
  169. case "function_call":
  170. m["call_id"] = item.CallID
  171. m["name"] = item.Name
  172. m["arguments"] = item.Arguments
  173. // namespace 子工具的还原调用:codex 按 namespace+name 路由,缺少该字段
  174. // 会被判为 unsupported call。
  175. if item.Namespace != "" {
  176. m["namespace"] = item.Namespace
  177. }
  178. case "custom_tool_call":
  179. // custom/freeform 工具调用(如 codex 的 exec):input 为自由文本。缺少
  180. // call_id/name 时 codex 无法路由该调用(表现为 unsupported call)。
  181. m["call_id"] = item.CallID
  182. m["name"] = item.Name
  183. m["input"] = item.Input
  184. case "tool_search_call":
  185. // tool_search 调用还原项:execution 必须为 "client"(否则 codex 忽略该
  186. // 调用),arguments 在线上是 JSON 对象而非字符串。
  187. m["call_id"] = item.CallID
  188. m["execution"] = "client"
  189. m["arguments"] = toolSearchCallArgumentsJSON(item.Arguments)
  190. }
  191. return m
  192. }
  193. // messageContentWire renders a message item's content array; always an array
  194. // (never null), with each output_text part carrying its text.
  195. func messageContentWire(parts []ResponsesContentPart) []map[string]any {
  196. out := make([]map[string]any, 0, len(parts))
  197. for _, p := range parts {
  198. typ := p.Type
  199. if typ == "" {
  200. typ = "output_text"
  201. }
  202. out = append(out, map[string]any{"type": typ, "text": p.Text})
  203. }
  204. return out
  205. }
  206. // reasoningSummaryWire renders a reasoning item's summary array; always an array.
  207. func reasoningSummaryWire(summary []ResponsesSummary) []map[string]any {
  208. out := make([]map[string]any, 0, len(summary))
  209. for _, s := range summary {
  210. typ := s.Type
  211. if typ == "" {
  212. typ = "summary_text"
  213. }
  214. out = append(out, map[string]any{"type": typ, "text": s.Text})
  215. }
  216. return out
  217. }