openai_gateway_grok_model_input.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. package service
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "fmt"
  6. "strings"
  7. "github.com/tidwall/gjson"
  8. "github.com/tidwall/sjson"
  9. )
  10. // sanitizeGrokResponsesModelInput converts replay items from OpenAI and Chat
  11. // shapes into the subset accepted by xAI's ModelInput decoder. Call IDs are
  12. // assigned in a separate pass so an output can safely appear before its call.
  13. func sanitizeGrokResponsesModelInput(body []byte) ([]byte, error) {
  14. input := gjson.GetBytes(body, "input")
  15. if !input.Exists() || input.Type == gjson.String {
  16. return body, nil
  17. }
  18. if input.IsObject() {
  19. var err error
  20. body, err = sjson.SetRawBytes(body, "input", []byte("["+input.Raw+"]"))
  21. if err != nil {
  22. return nil, fmt.Errorf("wrap Grok Responses model input: %w", err)
  23. }
  24. input = gjson.GetBytes(body, "input")
  25. }
  26. if !input.IsArray() {
  27. return body, nil
  28. }
  29. var items []any
  30. decoder := json.NewDecoder(bytes.NewReader([]byte(input.Raw)))
  31. decoder.UseNumber()
  32. if err := decoder.Decode(&items); err != nil {
  33. return nil, fmt.Errorf("parse Grok Responses model input: %w", err)
  34. }
  35. callIDs, outputIDs := pairGrokReplayCallIDs(items)
  36. filtered := make([]any, 0, len(items))
  37. pendingOutputImages := make([]grokToolOutputImage, 0)
  38. for index, rawItem := range items {
  39. item, ok := rawItem.(map[string]any)
  40. if !ok {
  41. filtered = appendGrokToolOutputImageMessage(filtered, pendingOutputImages)
  42. pendingOutputImages = pendingOutputImages[:0]
  43. if text, ok := rawItem.(string); ok && strings.TrimSpace(text) != "" {
  44. filtered = append(filtered, map[string]any{"type": "message", "role": "user", "content": text})
  45. }
  46. continue
  47. }
  48. itemType := strings.ToLower(strings.TrimSpace(grokStringValue(item["type"])))
  49. role := strings.ToLower(strings.TrimSpace(grokStringValue(item["role"])))
  50. if role == "tool" || role == "function" || isGrokReplayOutputType(itemType) {
  51. callID := outputIDs[index]
  52. rawOutput := firstNonNilGrokJSONValue(item["output"], item["content"], item["results"])
  53. output, nestedImages := normalizeGrokToolOutput(rawOutput, callID)
  54. filtered = append(filtered, map[string]any{
  55. "type": "function_call_output",
  56. "call_id": callID,
  57. "output": output,
  58. })
  59. pendingOutputImages = append(pendingOutputImages, extractGrokToolOutputImages(item, callID)...)
  60. pendingOutputImages = append(pendingOutputImages, nestedImages...)
  61. continue
  62. }
  63. filtered = appendGrokToolOutputImageMessage(filtered, pendingOutputImages)
  64. pendingOutputImages = pendingOutputImages[:0]
  65. switch itemType {
  66. case "text", "input_text", "output_text":
  67. text := strings.TrimSpace(grokStringValue(item["text"]))
  68. if text == "" {
  69. continue
  70. }
  71. if role == "" {
  72. role = "user"
  73. }
  74. item = map[string]any{"type": "message", "role": role, "content": text}
  75. itemType = "message"
  76. case "custom_tool_call", "tool_search_call":
  77. originalType := itemType
  78. item["type"] = "function_call"
  79. itemType = "function_call"
  80. if strings.TrimSpace(grokStringValue(item["name"])) == "" {
  81. if originalType == "tool_search_call" {
  82. item["name"] = "tool_search"
  83. }
  84. }
  85. }
  86. if itemType == "" && role != "" {
  87. itemType = "message"
  88. item["type"] = itemType
  89. }
  90. if itemType == "message" {
  91. if role == "" {
  92. role = "user"
  93. item["role"] = role
  94. }
  95. content, keep := sanitizeGrokMessageContent(item["content"])
  96. if !keep {
  97. continue
  98. }
  99. item["content"] = content
  100. if role == "assistant" && !grokIsCompleteOutputMessage(item) {
  101. if text, ok := collapseGrokAssistantOutputText(content); ok {
  102. item["content"] = text
  103. }
  104. delete(item, "id")
  105. delete(item, "status")
  106. }
  107. } else if itemType == "reasoning" {
  108. delete(item, "status")
  109. if content, exists := item["content"]; exists && content == nil {
  110. delete(item, "content")
  111. }
  112. } else if itemType == "function_call" {
  113. name := strings.TrimSpace(grokStringValue(item["name"]))
  114. arguments := item["arguments"]
  115. if function, ok := item["function"].(map[string]any); ok {
  116. if name == "" {
  117. name = strings.TrimSpace(grokStringValue(function["name"]))
  118. }
  119. if arguments == nil {
  120. arguments = function["arguments"]
  121. }
  122. }
  123. if arguments == nil {
  124. arguments = firstNonNilGrokJSONValue(item["input"], item["query"])
  125. if _, custom := item["input"]; custom {
  126. arguments = map[string]any{"input": arguments}
  127. }
  128. }
  129. if name == "" {
  130. name = "unknown_tool"
  131. }
  132. item["call_id"] = callIDs[index]
  133. item["name"] = name
  134. item["arguments"] = grokModelInputString(arguments, "{}")
  135. for _, field := range []string{"id", "status", "tool_call_id", "function", "input", "query", "execution"} {
  136. delete(item, field)
  137. }
  138. }
  139. if shouldStripOpenAIResponsesNonPairCallID(itemType) {
  140. delete(item, "call_id")
  141. }
  142. filtered = append(filtered, item)
  143. }
  144. filtered = appendGrokToolOutputImageMessage(filtered, pendingOutputImages)
  145. encoded, err := json.Marshal(filtered)
  146. if err != nil {
  147. return nil, fmt.Errorf("serialize Grok Responses model input: %w", err)
  148. }
  149. updated, err := sjson.SetRawBytes(body, "input", encoded)
  150. if err != nil {
  151. return nil, fmt.Errorf("set Grok Responses model input: %w", err)
  152. }
  153. return updated, nil
  154. }
  155. type grokToolOutputImage struct {
  156. callID string
  157. url string
  158. }
  159. // Grok 1.0.5 returns read_file media as structured output parts rather than
  160. // top-level images. xAI requires function_call_output.output to be a string,
  161. // so remove those media parts before stringifying and lift them separately.
  162. func normalizeGrokToolOutput(value any, callID string) (string, []grokToolOutputImage) {
  163. stripped, images, keep := stripGrokToolOutputImages(value, callID)
  164. if len(images) == 0 {
  165. return grokModelInputString(value, "(empty)"), nil
  166. }
  167. if !keep {
  168. return "(empty)", images
  169. }
  170. return grokStructuredToolOutputString(stripped, "(empty)"), images
  171. }
  172. func stripGrokToolOutputImages(value any, callID string) (any, []grokToolOutputImage, bool) {
  173. switch typed := value.(type) {
  174. case []any:
  175. filtered := make([]any, 0, len(typed))
  176. images := make([]grokToolOutputImage, 0)
  177. for _, item := range typed {
  178. stripped, nested, keep := stripGrokToolOutputImages(item, callID)
  179. images = append(images, nested...)
  180. if keep {
  181. filtered = append(filtered, stripped)
  182. }
  183. }
  184. return filtered, images, len(filtered) > 0
  185. case map[string]any:
  186. partType := strings.ToLower(strings.TrimSpace(grokStringValue(typed["type"])))
  187. if partType == "image" || partType == "image_url" || partType == "input_image" {
  188. url := grokToolOutputImageURL(typed)
  189. if url == "" || isEmptyBase64DataURI(url) {
  190. return nil, nil, false
  191. }
  192. return nil, []grokToolOutputImage{{callID: callID, url: url}}, false
  193. }
  194. filtered := make(map[string]any, len(typed))
  195. for key, item := range typed {
  196. filtered[key] = item
  197. }
  198. images := make([]grokToolOutputImage, 0)
  199. if rawImages, exists := filtered["images"]; exists {
  200. if values, ok := rawImages.([]any); ok {
  201. for _, rawImage := range values {
  202. url := grokToolOutputImageURL(rawImage)
  203. if url != "" && !isEmptyBase64DataURI(url) {
  204. images = append(images, grokToolOutputImage{callID: callID, url: url})
  205. }
  206. }
  207. delete(filtered, "images")
  208. }
  209. }
  210. for _, field := range []string{"content", "output", "results"} {
  211. item, exists := filtered[field]
  212. if !exists {
  213. continue
  214. }
  215. stripped, nested, keep := stripGrokToolOutputImages(item, callID)
  216. images = append(images, nested...)
  217. if keep {
  218. filtered[field] = stripped
  219. } else {
  220. delete(filtered, field)
  221. }
  222. }
  223. return filtered, images, len(filtered) > 0
  224. default:
  225. return value, nil, value != nil
  226. }
  227. }
  228. func grokStructuredToolOutputString(value any, fallback string) string {
  229. parts, ok := value.([]any)
  230. if !ok || len(parts) == 0 {
  231. return grokModelInputString(value, fallback)
  232. }
  233. texts := make([]string, 0, len(parts))
  234. for _, rawPart := range parts {
  235. part, ok := rawPart.(map[string]any)
  236. if !ok {
  237. return grokModelInputString(value, fallback)
  238. }
  239. partType := strings.ToLower(strings.TrimSpace(grokStringValue(part["type"])))
  240. if partType != "text" && partType != "input_text" && partType != "output_text" {
  241. return grokModelInputString(value, fallback)
  242. }
  243. if text := strings.TrimSpace(grokStringValue(part["text"])); text != "" {
  244. texts = append(texts, text)
  245. }
  246. }
  247. if len(texts) == 0 {
  248. return fallback
  249. }
  250. return strings.Join(texts, "\n")
  251. }
  252. // Grok Shell attaches local image results to function_call_output.images.
  253. // xAI's ModelInput accepts only a string output, so keep the tool replies
  254. // consecutive and lift their media into one following user input_image turn.
  255. func extractGrokToolOutputImages(item map[string]any, callID string) []grokToolOutputImage {
  256. rawImages, ok := item["images"].([]any)
  257. if !ok || len(rawImages) == 0 {
  258. return nil
  259. }
  260. images := make([]grokToolOutputImage, 0, len(rawImages))
  261. for _, rawImage := range rawImages {
  262. url := grokToolOutputImageURL(rawImage)
  263. if url == "" || isEmptyBase64DataURI(url) {
  264. continue
  265. }
  266. images = append(images, grokToolOutputImage{callID: callID, url: url})
  267. }
  268. return images
  269. }
  270. func grokToolOutputImageURL(value any) string {
  271. switch image := value.(type) {
  272. case string:
  273. return strings.TrimSpace(image)
  274. case map[string]any:
  275. for _, field := range []string{"url", "image_url", "file_url"} {
  276. raw := image[field]
  277. switch typed := raw.(type) {
  278. case string:
  279. if url := strings.TrimSpace(typed); url != "" {
  280. return url
  281. }
  282. case map[string]any:
  283. if url := strings.TrimSpace(grokStringValue(typed["url"])); url != "" {
  284. return url
  285. }
  286. }
  287. }
  288. }
  289. return ""
  290. }
  291. func appendGrokToolOutputImageMessage(items []any, images []grokToolOutputImage) []any {
  292. if len(images) == 0 {
  293. return items
  294. }
  295. content := make([]any, 0, len(images)*2)
  296. lastCallID := ""
  297. for _, image := range images {
  298. if image.callID != lastCallID {
  299. content = append(content, map[string]any{
  300. "type": "input_text",
  301. "text": fmt.Sprintf("[Tool output media for call %s]", image.callID),
  302. })
  303. lastCallID = image.callID
  304. }
  305. content = append(content, map[string]any{
  306. "type": "input_image",
  307. "image_url": image.url,
  308. })
  309. }
  310. return append(items, map[string]any{
  311. "type": "message",
  312. "role": "user",
  313. "content": content,
  314. })
  315. }
  316. func pairGrokReplayCallIDs(items []any) (map[int]string, map[int]string) {
  317. callIDs := make(map[int]string)
  318. outputIDs := make(map[int]string)
  319. aliases := make(map[string]string)
  320. conflictingAliases := make(map[string]struct{})
  321. pendingCalls := make([]string, 0)
  322. nextID := 0
  323. synthetic := func() string {
  324. nextID++
  325. return fmt.Sprintf("grok_replay_call_%d", nextID)
  326. }
  327. registerAlias := func(alias, canonical string) {
  328. alias = strings.TrimSpace(alias)
  329. if alias == "" {
  330. return
  331. }
  332. if _, conflict := conflictingAliases[alias]; conflict {
  333. return
  334. }
  335. if existing, exists := aliases[alias]; exists && existing != canonical {
  336. delete(aliases, alias)
  337. conflictingAliases[alias] = struct{}{}
  338. return
  339. }
  340. aliases[alias] = canonical
  341. }
  342. for index, rawItem := range items {
  343. item, ok := rawItem.(map[string]any)
  344. if !ok {
  345. continue
  346. }
  347. itemType := strings.ToLower(strings.TrimSpace(grokStringValue(item["type"])))
  348. if !isGrokReplayCallType(itemType) {
  349. continue
  350. }
  351. callID := firstNonEmptyGrokString(item["call_id"], item["tool_call_id"])
  352. if callID == "" {
  353. callID = synthetic()
  354. }
  355. callIDs[index] = callID
  356. pendingCalls = append(pendingCalls, callID)
  357. registerAlias(callID, callID)
  358. registerAlias(grokStringValue(item["call_id"]), callID)
  359. registerAlias(grokStringValue(item["tool_call_id"]), callID)
  360. registerAlias(grokStringValue(item["id"]), callID)
  361. }
  362. consumedCalls := make(map[string]struct{})
  363. consumeNextCall := func() string {
  364. for _, callID := range pendingCalls {
  365. if _, consumed := consumedCalls[callID]; consumed {
  366. continue
  367. }
  368. consumedCalls[callID] = struct{}{}
  369. return callID
  370. }
  371. return synthetic()
  372. }
  373. for index, rawItem := range items {
  374. item, ok := rawItem.(map[string]any)
  375. if !ok {
  376. continue
  377. }
  378. itemType := strings.ToLower(strings.TrimSpace(grokStringValue(item["type"])))
  379. role := strings.ToLower(strings.TrimSpace(grokStringValue(item["role"])))
  380. if role != "tool" && role != "function" && !isGrokReplayOutputType(itemType) {
  381. continue
  382. }
  383. alias := firstNonEmptyGrokString(item["call_id"], item["tool_call_id"], item["id"])
  384. _, conflict := conflictingAliases[alias]
  385. if canonical := aliases[alias]; canonical != "" && !conflict {
  386. outputIDs[index] = canonical
  387. consumedCalls[canonical] = struct{}{}
  388. } else if firstNonEmptyGrokString(item["call_id"], item["tool_call_id"]) != "" {
  389. // Explicit call identifiers remain authoritative even when an invalid
  390. // transcript reuses the same identifier for more than one call.
  391. outputIDs[index] = alias
  392. } else if conflict {
  393. // An item ID shared by multiple calls cannot safely identify either
  394. // one. Keep it out of the call_id namespace and leave the output orphaned.
  395. outputIDs[index] = synthetic()
  396. } else {
  397. outputIDs[index] = consumeNextCall()
  398. }
  399. }
  400. return callIDs, outputIDs
  401. }
  402. func isGrokReplayCallType(itemType string) bool {
  403. switch itemType {
  404. case "function_call", "custom_tool_call", "tool_search_call":
  405. return true
  406. default:
  407. return false
  408. }
  409. }
  410. func isGrokReplayOutputType(itemType string) bool {
  411. switch itemType {
  412. case "function_call_output", "custom_tool_call_output", "tool_search_output", "tool_search_call_output":
  413. return true
  414. default:
  415. return false
  416. }
  417. }
  418. func firstNonEmptyGrokString(values ...any) string {
  419. for _, value := range values {
  420. if text := strings.TrimSpace(grokStringValue(value)); text != "" {
  421. return text
  422. }
  423. }
  424. return ""
  425. }
  426. func firstNonNilGrokJSONValue(values ...any) any {
  427. for _, value := range values {
  428. if value != nil {
  429. return value
  430. }
  431. }
  432. return nil
  433. }
  434. func sanitizeGrokMessageContent(content any) (any, bool) {
  435. switch value := content.(type) {
  436. case nil:
  437. return nil, false
  438. case string:
  439. return value, strings.TrimSpace(value) != ""
  440. case []any:
  441. filtered := make([]any, 0, len(value))
  442. for _, rawPart := range value {
  443. part, ok := rawPart.(map[string]any)
  444. if !ok {
  445. if text, isText := rawPart.(string); !isText || strings.TrimSpace(text) != "" {
  446. filtered = append(filtered, rawPart)
  447. }
  448. continue
  449. }
  450. switch strings.ToLower(strings.TrimSpace(grokStringValue(part["type"]))) {
  451. case "text", "input_text", "output_text":
  452. if strings.TrimSpace(grokStringValue(part["text"])) == "" {
  453. continue
  454. }
  455. case "image_url", "input_image":
  456. if !grokContentPartHasImageURL(part) {
  457. continue
  458. }
  459. }
  460. filtered = append(filtered, part)
  461. }
  462. return filtered, len(filtered) > 0
  463. default:
  464. return content, true
  465. }
  466. }
  467. func grokContentPartHasImageURL(part map[string]any) bool {
  468. for _, field := range []string{"file_id", "file_data"} {
  469. if strings.TrimSpace(grokStringValue(part[field])) != "" {
  470. return true
  471. }
  472. }
  473. raw := firstNonNilGrokJSONValue(part["image_url"], part["file_url"])
  474. switch value := raw.(type) {
  475. case string:
  476. value = strings.TrimSpace(value)
  477. return value != "" && !isEmptyBase64DataURI(value)
  478. case map[string]any:
  479. url := strings.TrimSpace(grokStringValue(value["url"]))
  480. return url != "" && !isEmptyBase64DataURI(url)
  481. default:
  482. return false
  483. }
  484. }
  485. func grokStringValue(value any) string {
  486. text, _ := value.(string)
  487. return text
  488. }
  489. func grokModelInputString(value any, fallback string) string {
  490. if text, ok := value.(string); ok {
  491. if strings.TrimSpace(text) == "" {
  492. return fallback
  493. }
  494. return text
  495. }
  496. if value == nil {
  497. return fallback
  498. }
  499. encoded, err := json.Marshal(value)
  500. if err != nil || len(encoded) == 0 || string(encoded) == "null" {
  501. return fallback
  502. }
  503. return string(encoded)
  504. }
  505. func grokIsCompleteOutputMessage(item map[string]any) bool {
  506. return strings.TrimSpace(grokStringValue(item["id"])) != "" &&
  507. strings.TrimSpace(grokStringValue(item["status"])) != ""
  508. }
  509. func collapseGrokAssistantOutputText(content any) (string, bool) {
  510. parts, ok := content.([]any)
  511. if !ok || len(parts) == 0 {
  512. return "", false
  513. }
  514. var text strings.Builder
  515. for _, rawPart := range parts {
  516. part, ok := rawPart.(map[string]any)
  517. if !ok || strings.ToLower(strings.TrimSpace(grokStringValue(part["type"]))) != "output_text" {
  518. return "", false
  519. }
  520. value, ok := part["text"].(string)
  521. if !ok {
  522. return "", false
  523. }
  524. _, _ = text.WriteString(value)
  525. }
  526. if strings.TrimSpace(text.String()) == "" {
  527. return "", false
  528. }
  529. return text.String(), true
  530. }