openai_responses_ingress_compat.go 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. package service
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "strings"
  6. "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
  7. )
  8. // normalizeOpenAIResponsesLegacyIngress accepts the Chat Completions-shaped
  9. // payloads that a few Responses clients still emit. Native Responses input is
  10. // always authoritative because this path has no separate full-replay attempt.
  11. func normalizeOpenAIResponsesLegacyIngress(body []byte) ([]byte, bool, error) {
  12. if len(body) == 0 {
  13. return body, false, nil
  14. }
  15. var request map[string]any
  16. if err := decodeOpenAIJSONUseNumber(body, &request); err != nil {
  17. return body, false, fmt.Errorf("normalize legacy Responses ingress: %w", err)
  18. }
  19. changed := false
  20. messagesValue, hasMessages := request["messages"]
  21. if hasMessages {
  22. messages, messagesAreArray := messagesValue.([]any)
  23. if messagesAreArray && len(messages) > 0 {
  24. legacy, err := convertLegacyResponsesMessages(body)
  25. if err != nil {
  26. return body, false, err
  27. }
  28. input, hasInput := request["input"]
  29. hasNativeInput := hasInput && input != nil
  30. if !hasNativeInput {
  31. request["input"] = legacy.input
  32. applyLegacyResponsesTopLevelFields(request, legacy)
  33. delete(request, "previous_response_id")
  34. }
  35. }
  36. // messages is not a Responses field. When native input is present but the
  37. // two histories are ambiguous, retain input and drop only the legacy alias.
  38. delete(request, "messages")
  39. changed = true
  40. }
  41. if prompt, hasPrompt := request["prompt"]; hasPrompt {
  42. // Only the legacy string alias is unambiguously equivalent to Responses
  43. // input. Objects are native reusable prompt templates and must remain in
  44. // prompt; arrays and other shapes are left for upstream validation rather
  45. // than being relabeled as a structurally different input value.
  46. if promptText, isLegacyString := prompt.(string); isLegacyString {
  47. if input, hasInput := request["input"]; !hasInput || input == nil {
  48. request["input"] = promptText
  49. }
  50. delete(request, "prompt")
  51. changed = true
  52. }
  53. }
  54. if _, hasCommands := request["commands"]; hasCommands {
  55. delete(request, "commands")
  56. changed = true
  57. }
  58. if !changed {
  59. return body, false, nil
  60. }
  61. normalized, err := marshalOpenAIUpstreamJSON(request)
  62. if err != nil {
  63. return body, false, fmt.Errorf("serialize legacy Responses ingress: %w", err)
  64. }
  65. return normalized, true, nil
  66. }
  67. type convertedLegacyResponsesMessages struct {
  68. input any
  69. instructions string
  70. maxOutputTokens *int
  71. temperature *float64
  72. topP *float64
  73. tools any
  74. reasoning any
  75. toolChoice any
  76. serviceTier string
  77. hasChatTools bool
  78. hasChatToolChoice bool
  79. hasReasoningEffort bool
  80. }
  81. func convertLegacyResponsesMessages(body []byte) (convertedLegacyResponsesMessages, error) {
  82. var converted convertedLegacyResponsesMessages
  83. var chatRequest apicompat.ChatCompletionsRequest
  84. if err := json.Unmarshal(body, &chatRequest); err != nil {
  85. return converted, fmt.Errorf("normalize legacy Responses messages: %w", err)
  86. }
  87. responsesRequest, err := apicompat.ChatCompletionsToResponses(&chatRequest)
  88. if err != nil {
  89. return converted, fmt.Errorf("normalize legacy Responses messages: %w", err)
  90. }
  91. if len(responsesRequest.Input) == 0 {
  92. return converted, fmt.Errorf("normalize legacy Responses messages: empty input")
  93. }
  94. if err := json.Unmarshal(responsesRequest.Input, &converted.input); err != nil {
  95. return converted, fmt.Errorf("decode normalized legacy Responses input: %w", err)
  96. }
  97. converted.instructions = strings.TrimSpace(responsesRequest.Instructions)
  98. converted.maxOutputTokens = responsesRequest.MaxOutputTokens
  99. converted.temperature = responsesRequest.Temperature
  100. converted.topP = responsesRequest.TopP
  101. converted.serviceTier = strings.TrimSpace(responsesRequest.ServiceTier)
  102. converted.hasReasoningEffort = strings.TrimSpace(chatRequest.ReasoningEffort) != ""
  103. if responsesRequest.Reasoning != nil {
  104. converted.reasoning = responsesRequest.Reasoning
  105. }
  106. var rawRequest map[string]any
  107. if err := decodeOpenAIJSONUseNumber(body, &rawRequest); err != nil {
  108. return converted, fmt.Errorf("inspect legacy Responses fields: %w", err)
  109. }
  110. converted.hasChatTools = hasChatStyleTools(rawRequest) || rawRequest["functions"] != nil
  111. if converted.hasChatTools && len(responsesRequest.Tools) > 0 {
  112. converted.tools = responsesRequest.Tools
  113. }
  114. converted.hasChatToolChoice = hasChatStyleToolChoice(rawRequest) || rawRequest["function_call"] != nil
  115. if converted.hasChatToolChoice && len(responsesRequest.ToolChoice) > 0 {
  116. choice, err := normalizeLegacyResponsesToolChoice(responsesRequest.ToolChoice)
  117. if err != nil {
  118. return converted, err
  119. }
  120. converted.toolChoice = choice
  121. }
  122. return converted, nil
  123. }
  124. func hasChatStyleTools(request map[string]any) bool {
  125. tools, ok := request["tools"].([]any)
  126. if !ok {
  127. return false
  128. }
  129. for _, rawTool := range tools {
  130. tool, _ := rawTool.(map[string]any)
  131. if _, ok := tool["function"].(map[string]any); ok {
  132. return true
  133. }
  134. }
  135. return false
  136. }
  137. func hasChatStyleToolChoice(request map[string]any) bool {
  138. choice, _ := request["tool_choice"].(map[string]any)
  139. _, ok := choice["function"].(map[string]any)
  140. return ok
  141. }
  142. func normalizeLegacyResponsesToolChoice(raw json.RawMessage) (any, error) {
  143. var choice any
  144. if err := json.Unmarshal(raw, &choice); err != nil {
  145. return nil, fmt.Errorf("normalize legacy Responses tool_choice: %w", err)
  146. }
  147. object, ok := choice.(map[string]any)
  148. if !ok || strings.TrimSpace(firstNonEmptyString(object["type"])) != "function" {
  149. return choice, nil
  150. }
  151. function, ok := object["function"].(map[string]any)
  152. if !ok {
  153. return choice, nil
  154. }
  155. name := strings.TrimSpace(firstNonEmptyString(function["name"]))
  156. if name == "" {
  157. return choice, nil
  158. }
  159. return map[string]any{"type": "function", "name": name}, nil
  160. }
  161. func applyLegacyResponsesTopLevelFields(request map[string]any, legacy convertedLegacyResponsesMessages) {
  162. if legacy.instructions != "" && strings.TrimSpace(firstNonEmptyString(request["instructions"])) == "" {
  163. request["instructions"] = legacy.instructions
  164. }
  165. if legacy.maxOutputTokens != nil {
  166. request["max_output_tokens"] = *legacy.maxOutputTokens
  167. }
  168. if legacy.temperature != nil {
  169. request["temperature"] = *legacy.temperature
  170. }
  171. if legacy.topP != nil {
  172. request["top_p"] = *legacy.topP
  173. }
  174. if legacy.hasChatTools && legacy.tools != nil {
  175. request["tools"] = legacy.tools
  176. }
  177. if legacy.hasReasoningEffort && legacy.reasoning != nil {
  178. request["reasoning"] = legacy.reasoning
  179. }
  180. if legacy.hasChatToolChoice && legacy.toolChoice != nil {
  181. request["tool_choice"] = legacy.toolChoice
  182. }
  183. if legacy.serviceTier != "" {
  184. request["service_tier"] = legacy.serviceTier
  185. }
  186. for _, key := range []string{"max_tokens", "max_completion_tokens", "reasoning_effort", "functions", "function_call"} {
  187. delete(request, key)
  188. }
  189. }