openai_responses_lite_tools.go 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. package service
  2. import (
  3. "fmt"
  4. "reflect"
  5. "strings"
  6. )
  7. type openAIResponsesLiteValidationError struct {
  8. param string
  9. message string
  10. }
  11. func (e *openAIResponsesLiteValidationError) Error() string { return e.message }
  12. func newOpenAIResponsesLiteValidationError(param, format string, args ...any) error {
  13. return &openAIResponsesLiteValidationError{param: param, message: fmt.Sprintf(format, args...)}
  14. }
  15. // normalizeOpenAIResponsesLiteTools applies the Responses Lite request
  16. // contract: reasoning must cover all turns, and private namespace declarations
  17. // use the input.additional_tools carrier. Other top-level tools must belong to
  18. // the small set accepted by the Lite endpoint; rejecting unsupported hosted
  19. // tools is intentional because silently dropping them would change behavior.
  20. func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) {
  21. if reqBody == nil {
  22. return false, nil
  23. }
  24. if parallel, exists := reqBody["parallel_tool_calls"]; exists {
  25. if _, ok := parallel.(bool); !ok {
  26. return false, newOpenAIResponsesLiteValidationError("parallel_tool_calls", "responses Lite requires parallel_tool_calls to be a boolean")
  27. }
  28. }
  29. if rawReasoning, exists := reqBody["reasoning"]; exists && rawReasoning != nil {
  30. if _, ok := rawReasoning.(map[string]any); !ok {
  31. return false, newOpenAIResponsesLiteValidationError("reasoning", "responses Lite requires reasoning to be an object")
  32. }
  33. }
  34. rawTools, exists := reqBody["tools"]
  35. if !exists || rawTools == nil {
  36. changed, err := ensureOpenAIResponsesLiteReasoningContext(reqBody)
  37. if err != nil {
  38. return false, err
  39. }
  40. return ensureOpenAIResponsesLiteParallelToolCalls(reqBody, changed)
  41. }
  42. tools, ok := rawTools.([]any)
  43. if !ok {
  44. return false, newOpenAIResponsesLiteValidationError("tools", "responses Lite requires tools to be an array")
  45. }
  46. topLevelTools := make([]any, 0, len(tools))
  47. namespaceTools := make([]any, 0, len(tools))
  48. for index, rawTool := range tools {
  49. if customTool, ok := rawTool.(string); ok {
  50. if strings.TrimSpace(customTool) == "" {
  51. return false, fmt.Errorf("responses Lite custom tool at index %d must not be empty", index)
  52. }
  53. topLevelTools = append(topLevelTools, rawTool)
  54. continue
  55. }
  56. tool, ok := rawTool.(map[string]any)
  57. if !ok {
  58. return false, fmt.Errorf("responses Lite tool at index %d must be an object", index)
  59. }
  60. toolType := strings.TrimSpace(firstNonEmptyString(tool["type"]))
  61. switch toolType {
  62. case "function", "custom", "tool_search":
  63. topLevelTools = append(topLevelTools, rawTool)
  64. case "namespace":
  65. namespaceTools = append(namespaceTools, rawTool)
  66. case "":
  67. return false, fmt.Errorf("responses Lite tool at index %d is missing type", index)
  68. default:
  69. return false, fmt.Errorf("responses Lite does not support top-level tool type %q at index %d", toolType, index)
  70. }
  71. }
  72. if len(namespaceTools) == 0 {
  73. changed, err := ensureOpenAIResponsesLiteReasoningContext(reqBody)
  74. if err != nil {
  75. return false, err
  76. }
  77. return ensureOpenAIResponsesLiteParallelToolCalls(reqBody, changed)
  78. }
  79. input, err := appendOpenAIResponsesLiteAdditionalTools(reqBody["input"], namespaceTools)
  80. if err != nil {
  81. return false, err
  82. }
  83. if _, err := ensureOpenAIResponsesLiteReasoningContext(reqBody); err != nil {
  84. return false, err
  85. }
  86. reqBody["input"] = input
  87. if len(topLevelTools) == 0 {
  88. delete(reqBody, "tools")
  89. } else {
  90. reqBody["tools"] = topLevelTools
  91. }
  92. return ensureOpenAIResponsesLiteParallelToolCalls(reqBody, true)
  93. }
  94. func ensureOpenAIResponsesLiteParallelToolCalls(reqBody map[string]any, changed bool) (bool, error) {
  95. parallel, exists := reqBody["parallel_tool_calls"]
  96. if exists {
  97. if _, ok := parallel.(bool); !ok {
  98. return false, newOpenAIResponsesLiteValidationError("parallel_tool_calls", "responses Lite requires parallel_tool_calls to be a boolean")
  99. }
  100. }
  101. if parallel == false {
  102. return changed, nil
  103. }
  104. reqBody["parallel_tool_calls"] = false
  105. return true, nil
  106. }
  107. func ensureOpenAIResponsesLiteReasoningContext(reqBody map[string]any) (bool, error) {
  108. rawReasoning, exists := reqBody["reasoning"]
  109. if !exists || rawReasoning == nil {
  110. reqBody["reasoning"] = map[string]any{"context": "all_turns"}
  111. return true, nil
  112. }
  113. reasoning, ok := rawReasoning.(map[string]any)
  114. if !ok {
  115. return false, newOpenAIResponsesLiteValidationError("reasoning", "responses Lite requires reasoning to be an object")
  116. }
  117. if context, ok := reasoning["context"].(string); ok && context == "all_turns" {
  118. return false, nil
  119. }
  120. reasoning["context"] = "all_turns"
  121. return true, nil
  122. }
  123. func appendOpenAIResponsesLiteAdditionalTools(input any, namespaceTools []any) ([]any, error) {
  124. var items []any
  125. switch typed := input.(type) {
  126. case nil:
  127. items = make([]any, 0, 1)
  128. case string:
  129. items = []any{map[string]any{
  130. "type": "message",
  131. "role": "user",
  132. "content": typed,
  133. }}
  134. case []any:
  135. items = typed
  136. default:
  137. return nil, fmt.Errorf("responses Lite namespace tools require input to be a string or array")
  138. }
  139. var target map[string]any
  140. var targetTools []any
  141. var allAdditionalTools []any
  142. for _, rawItem := range items {
  143. item, ok := rawItem.(map[string]any)
  144. if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" {
  145. continue
  146. }
  147. rawAdditionalTools, exists := item["tools"]
  148. additionalTools := []any(nil)
  149. toolsOK := true
  150. if exists && rawAdditionalTools != nil {
  151. additionalTools, toolsOK = rawAdditionalTools.([]any)
  152. }
  153. if !toolsOK {
  154. return nil, fmt.Errorf("responses Lite input.additional_tools tools must be an array")
  155. }
  156. if target == nil {
  157. target = item
  158. targetTools = additionalTools
  159. }
  160. allAdditionalTools = append(allAdditionalTools, additionalTools...)
  161. }
  162. merged, err := mergeOpenAIResponsesLiteAdditionalTools(allAdditionalTools, namespaceTools)
  163. if err != nil {
  164. return nil, err
  165. }
  166. newTools := merged[len(allAdditionalTools):]
  167. if target != nil {
  168. if len(newTools) > 0 {
  169. target["tools"] = append(append([]any(nil), targetTools...), newTools...)
  170. }
  171. return items, nil
  172. }
  173. items = append(items, map[string]any{
  174. "type": "additional_tools",
  175. "role": "developer",
  176. "tools": newTools,
  177. })
  178. return items, nil
  179. }
  180. func mergeOpenAIResponsesLiteAdditionalTools(existing []any, moved []any) ([]any, error) {
  181. merged := append([]any(nil), existing...)
  182. seen := make(map[string]any, len(existing)+len(moved))
  183. for _, rawTool := range existing {
  184. if identity := openAIResponsesLiteToolIdentity(rawTool); identity != "" {
  185. if previous, exists := seen[identity]; exists && !reflect.DeepEqual(previous, rawTool) {
  186. return nil, fmt.Errorf("responses Lite additional_tools contains conflicting definitions for %s", openAIResponsesLiteToolIdentityForError(rawTool))
  187. }
  188. seen[identity] = rawTool
  189. }
  190. }
  191. for _, rawTool := range moved {
  192. identity := openAIResponsesLiteToolIdentity(rawTool)
  193. if identity != "" {
  194. if previous, exists := seen[identity]; exists {
  195. if reflect.DeepEqual(previous, rawTool) {
  196. continue
  197. }
  198. return nil, fmt.Errorf("responses Lite additional_tools conflicts with migrated %s", openAIResponsesLiteToolIdentityForError(rawTool))
  199. }
  200. seen[identity] = rawTool
  201. }
  202. merged = append(merged, rawTool)
  203. }
  204. return merged, nil
  205. }
  206. func openAIResponsesLiteToolIdentity(rawTool any) string {
  207. tool, ok := rawTool.(map[string]any)
  208. if !ok {
  209. return ""
  210. }
  211. toolType := strings.TrimSpace(firstNonEmptyString(tool["type"]))
  212. name := strings.TrimSpace(firstNonEmptyString(tool["name"]))
  213. if toolType == "" || name == "" {
  214. return ""
  215. }
  216. return toolType + "\x00" + name
  217. }
  218. func openAIResponsesLiteToolIdentityForError(rawTool any) string {
  219. tool, _ := rawTool.(map[string]any)
  220. return fmt.Sprintf("tool type %q name %q", strings.TrimSpace(firstNonEmptyString(tool["type"])), strings.TrimSpace(firstNonEmptyString(tool["name"])))
  221. }
  222. func normalizeOpenAIResponsesLiteToolsPayload(body []byte) ([]byte, bool, error) {
  223. var requestBody map[string]any
  224. if err := decodeOpenAIJSONUseNumber(body, &requestBody); err != nil {
  225. return body, false, fmt.Errorf("decode responses Lite request body: %w", err)
  226. }
  227. changed, err := normalizeOpenAIResponsesLiteTools(requestBody)
  228. if err != nil || !changed {
  229. return body, false, err
  230. }
  231. rebuilt, err := marshalOpenAIUpstreamJSON(requestBody)
  232. if err != nil {
  233. return body, false, fmt.Errorf("encode responses Lite request body: %w", err)
  234. }
  235. return rebuilt, true, nil
  236. }
  237. func normalizeOpenAIResponsesLiteParallelToolCallsPayload(body []byte) ([]byte, bool, error) {
  238. var requestBody map[string]any
  239. if err := decodeOpenAIJSONUseNumber(body, &requestBody); err != nil {
  240. return body, false, fmt.Errorf("decode responses Lite request body: %w", err)
  241. }
  242. changed, err := ensureOpenAIResponsesLiteParallelToolCalls(requestBody, false)
  243. if err != nil || !changed {
  244. return body, false, err
  245. }
  246. rebuilt, err := marshalOpenAIUpstreamJSON(requestBody)
  247. if err != nil {
  248. return body, false, fmt.Errorf("encode responses Lite request body: %w", err)
  249. }
  250. return rebuilt, true, nil
  251. }
  252. func normalizeOpenAIResponsesLitePayloadForAccount(body []byte, account *Account) ([]byte, bool, error) {
  253. if account == nil || !account.IsOpenAI() {
  254. return body, false, nil
  255. }
  256. if account.IsOpenAIOAuthLike() {
  257. return normalizeOpenAIResponsesLiteToolsPayload(body)
  258. }
  259. return normalizeOpenAIResponsesLiteParallelToolCallsPayload(body)
  260. }