anthropic_to_responses.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. package apicompat
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "strings"
  6. )
  7. // AnthropicToResponses converts an Anthropic Messages request directly into
  8. // a Responses API request. This preserves fields that would be lost in a
  9. // Chat Completions intermediary round-trip (e.g. thinking, cache_control,
  10. // structured system prompts).
  11. func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) {
  12. input, err := convertAnthropicToResponsesInput(req.System, req.Messages)
  13. if err != nil {
  14. return nil, err
  15. }
  16. inputJSON, err := json.Marshal(input)
  17. if err != nil {
  18. return nil, err
  19. }
  20. out := &ResponsesRequest{
  21. Model: req.Model,
  22. Input: inputJSON,
  23. Stream: req.Stream,
  24. Include: []string{"reasoning.encrypted_content"},
  25. }
  26. // Reasoning models (gpt-5.x) served via the Responses API do not accept
  27. // sampling parameters. Sending temperature or top_p causes a 400
  28. // "Unsupported parameter" error, so we only forward them for non-reasoning
  29. // models.
  30. if !isReasoningModel(req.Model) {
  31. out.Temperature = req.Temperature
  32. out.TopP = req.TopP
  33. }
  34. storeFalse := false
  35. out.Store = &storeFalse
  36. parallelToolCalls := true
  37. out.ParallelToolCalls = &parallelToolCalls
  38. out.Text = &ResponsesText{Verbosity: "medium"}
  39. if req.MaxTokens > 0 {
  40. v := req.MaxTokens
  41. if v < minMaxOutputTokens {
  42. v = minMaxOutputTokens
  43. }
  44. out.MaxOutputTokens = &v
  45. }
  46. if len(req.Tools) > 0 {
  47. out.Tools = convertAnthropicToolsToResponses(req.Tools)
  48. }
  49. // Determine reasoning effort: only output_config.effort controls the
  50. // level; thinking.type is ignored. Default follows Codex CLI / airgate's
  51. // Anthropic bridge shape, which uses medium when unset.
  52. // Anthropic levels map 1:1 to OpenAI: low→low, medium→medium, high→high, max→xhigh.
  53. effort := "medium"
  54. if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
  55. effort = req.OutputConfig.Effort
  56. }
  57. out.Reasoning = &ResponsesReasoning{
  58. Effort: mapAnthropicEffortToResponses(effort),
  59. Summary: "auto",
  60. }
  61. // Convert tool_choice
  62. if len(req.ToolChoice) > 0 {
  63. tc, err := convertAnthropicToolChoiceToResponses(req.ToolChoice)
  64. if err != nil {
  65. return nil, fmt.Errorf("convert tool_choice: %w", err)
  66. }
  67. out.ToolChoice = tc
  68. }
  69. return out, nil
  70. }
  71. // convertAnthropicToolChoiceToResponses maps Anthropic tool_choice to Responses format.
  72. //
  73. // {"type":"auto"} → "auto"
  74. // {"type":"any"} → "required"
  75. // {"type":"none"} → "none"
  76. // {"type":"tool","name":"X"} → {"type":"function","name":"X"}
  77. func convertAnthropicToolChoiceToResponses(raw json.RawMessage) (json.RawMessage, error) {
  78. var tc struct {
  79. Type string `json:"type"`
  80. Name string `json:"name"`
  81. }
  82. if err := json.Unmarshal(raw, &tc); err != nil {
  83. return nil, err
  84. }
  85. switch tc.Type {
  86. case "auto":
  87. return json.Marshal("auto")
  88. case "any":
  89. return json.Marshal("required")
  90. case "none":
  91. return json.Marshal("none")
  92. case "tool":
  93. return json.Marshal(map[string]any{
  94. "type": "function",
  95. "name": tc.Name,
  96. })
  97. default:
  98. // Pass through unknown types as-is
  99. return raw, nil
  100. }
  101. }
  102. // convertAnthropicToResponsesInput builds the Responses API input items array
  103. // from the Anthropic system field and message list.
  104. func convertAnthropicToResponsesInput(system json.RawMessage, msgs []AnthropicMessage) ([]ResponsesInputItem, error) {
  105. var out []ResponsesInputItem
  106. // System prompt → developer role input item. ChatGPT Codex SSE behaves like
  107. // Codex CLI here: keeping Anthropic system text in input preserves the
  108. // conversation/cache shape better than moving it into instructions.
  109. if len(system) > 0 {
  110. sysParts, err := parseAnthropicSystemContentParts(system)
  111. if err != nil {
  112. return nil, err
  113. }
  114. if len(sysParts) > 0 {
  115. content, _ := json.Marshal(sysParts)
  116. out = append(out, ResponsesInputItem{
  117. Type: "message",
  118. Role: "developer",
  119. Content: content,
  120. })
  121. }
  122. }
  123. for _, m := range msgs {
  124. items, err := anthropicMsgToResponsesItems(m)
  125. if err != nil {
  126. return nil, err
  127. }
  128. out = append(out, items...)
  129. }
  130. return out, nil
  131. }
  132. // parseAnthropicSystemContentParts handles the Anthropic system field which can
  133. // be a plain string or an array of text blocks. Claude Code may include an
  134. // x-anthropic-billing-header block; airgate drops it before sending to Codex.
  135. func parseAnthropicSystemContentParts(raw json.RawMessage) ([]ResponsesContentPart, error) {
  136. var s string
  137. if err := json.Unmarshal(raw, &s); err == nil {
  138. if isAnthropicBillingHeaderText(s) || s == "" {
  139. return nil, nil
  140. }
  141. return []ResponsesContentPart{{Type: "input_text", Text: s}}, nil
  142. }
  143. var blocks []AnthropicContentBlock
  144. if err := json.Unmarshal(raw, &blocks); err != nil {
  145. return nil, err
  146. }
  147. var parts []ResponsesContentPart
  148. for _, b := range blocks {
  149. if b.Type == "text" && b.Text != "" && !isAnthropicBillingHeaderText(b.Text) {
  150. parts = append(parts, ResponsesContentPart{Type: "input_text", Text: b.Text})
  151. }
  152. }
  153. return parts, nil
  154. }
  155. func isAnthropicBillingHeaderText(text string) bool {
  156. return strings.HasPrefix(text, "x-anthropic-billing-header: ")
  157. }
  158. // anthropicMsgToResponsesItems converts a single Anthropic message into one
  159. // or more Responses API input items.
  160. func anthropicMsgToResponsesItems(m AnthropicMessage) ([]ResponsesInputItem, error) {
  161. switch m.Role {
  162. case "user":
  163. return anthropicUserToResponses(m.Content)
  164. case "assistant":
  165. return anthropicAssistantToResponses(m.Content)
  166. default:
  167. return anthropicUserToResponses(m.Content)
  168. }
  169. }
  170. // anthropicUserToResponses handles an Anthropic user message. Content can be a
  171. // plain string or an array of blocks. tool_result blocks are extracted into
  172. // function_call_output items. Image blocks are converted to input_image parts.
  173. func anthropicUserToResponses(raw json.RawMessage) ([]ResponsesInputItem, error) {
  174. // Try plain string.
  175. var s string
  176. if err := json.Unmarshal(raw, &s); err == nil {
  177. parts := []ResponsesContentPart{{Type: "input_text", Text: s}}
  178. partsJSON, err := json.Marshal(parts)
  179. if err != nil {
  180. return nil, err
  181. }
  182. return []ResponsesInputItem{{Type: "message", Role: "user", Content: partsJSON}}, nil
  183. }
  184. var blocks []AnthropicContentBlock
  185. if err := json.Unmarshal(raw, &blocks); err != nil {
  186. return nil, err
  187. }
  188. var out []ResponsesInputItem
  189. var toolResultImageParts []ResponsesContentPart
  190. // Extract tool_result blocks → function_call_output items.
  191. // Images inside tool_results are extracted separately because the
  192. // Responses API function_call_output.output only accepts strings.
  193. for _, b := range blocks {
  194. if b.Type != "tool_result" {
  195. continue
  196. }
  197. outputText, imageParts := convertToolResultOutput(b)
  198. out = append(out, ResponsesInputItem{
  199. Type: "function_call_output",
  200. CallID: toResponsesCallID(b.ToolUseID),
  201. Output: outputText,
  202. })
  203. toolResultImageParts = append(toolResultImageParts, imageParts...)
  204. }
  205. // Remaining text + image blocks → user message with content parts.
  206. // Also include images extracted from tool_results so the model can see them.
  207. var parts []ResponsesContentPart
  208. for _, b := range blocks {
  209. switch b.Type {
  210. case "text":
  211. if b.Text != "" {
  212. parts = append(parts, ResponsesContentPart{Type: "input_text", Text: b.Text})
  213. }
  214. case "image":
  215. if uri := anthropicImageToDataURI(b.Source); uri != "" {
  216. parts = append(parts, ResponsesContentPart{Type: "input_image", ImageURL: uri})
  217. }
  218. }
  219. }
  220. parts = append(parts, toolResultImageParts...)
  221. if len(parts) > 0 {
  222. content, err := json.Marshal(parts)
  223. if err != nil {
  224. return nil, err
  225. }
  226. out = append(out, ResponsesInputItem{Type: "message", Role: "user", Content: content})
  227. }
  228. return out, nil
  229. }
  230. // anthropicAssistantToResponses handles an Anthropic assistant message.
  231. // Text content → assistant message with output_text parts.
  232. // tool_use blocks → function_call items.
  233. // thinking blocks with signature → reasoning items (encrypted_content) so
  234. // multi-turn Grok/Codex prompt cache can reuse prior reasoning prefixes.
  235. // thinking without signature remains ignored (not accepted as plain text input).
  236. func anthropicAssistantToResponses(raw json.RawMessage) ([]ResponsesInputItem, error) {
  237. // Try plain string.
  238. var s string
  239. if err := json.Unmarshal(raw, &s); err == nil {
  240. parts := []ResponsesContentPart{{Type: "output_text", Text: s}}
  241. partsJSON, err := json.Marshal(parts)
  242. if err != nil {
  243. return nil, err
  244. }
  245. return []ResponsesInputItem{{Type: "message", Role: "assistant", Content: partsJSON}}, nil
  246. }
  247. var blocks []AnthropicContentBlock
  248. if err := json.Unmarshal(raw, &blocks); err != nil {
  249. return nil, err
  250. }
  251. var items []ResponsesInputItem
  252. // Preserve turn order: reasoning → assistant text → tool calls. xAI/Codex
  253. // multi-turn cache and tool continuations expect reasoning before the
  254. // assistant message that followed it.
  255. for _, b := range blocks {
  256. if b.Type != "thinking" {
  257. continue
  258. }
  259. sig := strings.TrimSpace(b.Signature)
  260. // Only replay provider ciphertext. Skip GPT/Codex-style gAAAA blobs and
  261. // empty placeholders — xAI returns 400 on decrypt for foreign signatures.
  262. if sig == "" || strings.HasPrefix(sig, "gAAAA") {
  263. continue
  264. }
  265. items = append(items, ResponsesInputItem{
  266. Type: "reasoning",
  267. EncryptedContent: sig,
  268. })
  269. }
  270. // Text content → assistant message with output_text content parts.
  271. text := extractAnthropicTextFromBlocks(blocks)
  272. if text != "" {
  273. parts := []ResponsesContentPart{{Type: "output_text", Text: text}}
  274. partsJSON, err := json.Marshal(parts)
  275. if err != nil {
  276. return nil, err
  277. }
  278. items = append(items, ResponsesInputItem{Type: "message", Role: "assistant", Content: partsJSON})
  279. }
  280. // tool_use → function_call items.
  281. for _, b := range blocks {
  282. if b.Type != "tool_use" {
  283. continue
  284. }
  285. args := "{}"
  286. if len(b.Input) > 0 {
  287. args = string(b.Input)
  288. }
  289. fcID := toResponsesCallID(b.ID)
  290. items = append(items, ResponsesInputItem{
  291. Type: "function_call",
  292. CallID: fcID,
  293. Name: b.Name,
  294. Arguments: args,
  295. })
  296. }
  297. return items, nil
  298. }
  299. // toResponsesCallID preserves Anthropic tool IDs as Responses call_id values.
  300. // Claude Code sends tool_result.tool_use_id back verbatim, and ChatGPT Codex
  301. // continuation expects that call_id to match the original tool_use id.
  302. func toResponsesCallID(id string) string {
  303. return id
  304. }
  305. // fromResponsesCallID reverses old prefixed IDs while preserving current IDs.
  306. func fromResponsesCallID(id string) string {
  307. if after, ok := strings.CutPrefix(id, "fc_"); ok {
  308. // Only strip if the remainder doesn't look like it was already "fc_" prefixed.
  309. // E.g. "fc_toolu_xxx" → "toolu_xxx", "fc_call_xxx" → "call_xxx"
  310. if strings.HasPrefix(after, "toolu_") || strings.HasPrefix(after, "call_") {
  311. return after
  312. }
  313. }
  314. return id
  315. }
  316. // anthropicImageToDataURI converts an AnthropicImageSource to a data URI string.
  317. // Returns "" if the source is nil or has no data.
  318. func anthropicImageToDataURI(src *AnthropicImageSource) string {
  319. if src == nil || src.Data == "" {
  320. return ""
  321. }
  322. mediaType := src.MediaType
  323. if mediaType == "" {
  324. mediaType = "image/png"
  325. }
  326. return "data:" + mediaType + ";base64," + src.Data
  327. }
  328. // convertToolResultOutput extracts text and image content from a tool_result
  329. // block. Returns the text as a string for the function_call_output Output
  330. // field, plus any image parts that must be sent in a separate user message
  331. // (the Responses API output field only accepts strings).
  332. func convertToolResultOutput(b AnthropicContentBlock) (string, []ResponsesContentPart) {
  333. if len(b.Content) == 0 {
  334. return "(empty)", nil
  335. }
  336. // Try plain string content.
  337. var s string
  338. if err := json.Unmarshal(b.Content, &s); err == nil {
  339. if s == "" {
  340. s = "(empty)"
  341. }
  342. return s, nil
  343. }
  344. // Array of content blocks — may contain text and/or images.
  345. var inner []AnthropicContentBlock
  346. if err := json.Unmarshal(b.Content, &inner); err != nil {
  347. return "(empty)", nil
  348. }
  349. // Separate text (for function_call_output) from images (for user message).
  350. var textParts []string
  351. var imageParts []ResponsesContentPart
  352. for _, ib := range inner {
  353. switch ib.Type {
  354. case "text":
  355. if ib.Text != "" {
  356. textParts = append(textParts, ib.Text)
  357. }
  358. case "image":
  359. if uri := anthropicImageToDataURI(ib.Source); uri != "" {
  360. imageParts = append(imageParts, ResponsesContentPart{Type: "input_image", ImageURL: uri})
  361. }
  362. }
  363. }
  364. text := strings.Join(textParts, "\n\n")
  365. if text == "" {
  366. text = "(empty)"
  367. }
  368. return text, imageParts
  369. }
  370. // extractAnthropicTextFromBlocks joins all text blocks, ignoring thinking/
  371. // tool_use/tool_result blocks.
  372. func extractAnthropicTextFromBlocks(blocks []AnthropicContentBlock) string {
  373. var parts []string
  374. for _, b := range blocks {
  375. if b.Type == "text" && b.Text != "" {
  376. parts = append(parts, b.Text)
  377. }
  378. }
  379. return strings.Join(parts, "\n\n")
  380. }
  381. // mapAnthropicEffortToResponses converts Anthropic reasoning effort levels to
  382. // OpenAI Responses API effort levels.
  383. //
  384. // Both APIs default to "high". The mapping is 1:1 for shared levels;
  385. // only Anthropic's "max" (Opus 4.6 exclusive) maps to OpenAI's "xhigh"
  386. // (GPT-5.2+ exclusive) as both represent the highest reasoning tier.
  387. //
  388. // low → low
  389. // medium → medium
  390. // high → high
  391. // max → xhigh
  392. func mapAnthropicEffortToResponses(effort string) string {
  393. if effort == "max" {
  394. return "xhigh"
  395. }
  396. return effort // low→low, medium→medium, high→high, unknown→passthrough
  397. }
  398. // convertAnthropicToolsToResponses maps Anthropic tool definitions to
  399. // Responses API tools. Server-side tools like web_search are mapped to their
  400. // OpenAI equivalents; regular tools become function tools.
  401. func convertAnthropicToolsToResponses(tools []AnthropicTool) []ResponsesTool {
  402. var out []ResponsesTool
  403. for _, t := range tools {
  404. // Anthropic server tools like "web_search_20250305" → OpenAI {"type":"web_search"}
  405. if strings.HasPrefix(t.Type, "web_search") {
  406. out = append(out, ResponsesTool{Type: "web_search"})
  407. continue
  408. }
  409. out = append(out, ResponsesTool{
  410. Type: "function",
  411. Name: t.Name,
  412. Description: t.Description,
  413. Parameters: normalizeToolParameters(t.InputSchema),
  414. Strict: boolPtr(false),
  415. })
  416. }
  417. return out
  418. }
  419. func boolPtr(v bool) *bool {
  420. return &v
  421. }
  422. // isReasoningModel reports whether model is a reasoning model that does not
  423. // support sampling parameters (temperature, top_p) via the Responses API.
  424. // All gpt-5.x models are reasoning-only; the Responses API returns
  425. // "Unsupported parameter: temperature" if these fields are present.
  426. func isReasoningModel(model string) bool {
  427. return strings.HasPrefix(model, "gpt-5")
  428. }
  429. // normalizeToolParameters ensures the tool parameter schema is valid for
  430. // OpenAI's Responses API, which requires "properties" on object schemas.
  431. //
  432. // - nil/empty → {"type":"object","properties":{}}
  433. // - type=object without properties → adds "properties": {}
  434. // - otherwise → returned unchanged
  435. func normalizeToolParameters(schema json.RawMessage) json.RawMessage {
  436. if len(schema) == 0 || string(schema) == "null" {
  437. return json.RawMessage(`{"type":"object","properties":{}}`)
  438. }
  439. var m map[string]json.RawMessage
  440. if err := json.Unmarshal(schema, &m); err != nil {
  441. return schema
  442. }
  443. typ := m["type"]
  444. if string(typ) != `"object"` {
  445. return schema
  446. }
  447. if _, ok := m["properties"]; ok {
  448. return schema
  449. }
  450. m["properties"] = json.RawMessage(`{}`)
  451. out, err := json.Marshal(m)
  452. if err != nil {
  453. return schema
  454. }
  455. return out
  456. }