| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562 |
- package service
- import (
- "bytes"
- "encoding/json"
- "fmt"
- "strings"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
- )
- // sanitizeGrokResponsesModelInput converts replay items from OpenAI and Chat
- // shapes into the subset accepted by xAI's ModelInput decoder. Call IDs are
- // assigned in a separate pass so an output can safely appear before its call.
- func sanitizeGrokResponsesModelInput(body []byte) ([]byte, error) {
- input := gjson.GetBytes(body, "input")
- if !input.Exists() || input.Type == gjson.String {
- return body, nil
- }
- if input.IsObject() {
- var err error
- body, err = sjson.SetRawBytes(body, "input", []byte("["+input.Raw+"]"))
- if err != nil {
- return nil, fmt.Errorf("wrap Grok Responses model input: %w", err)
- }
- input = gjson.GetBytes(body, "input")
- }
- if !input.IsArray() {
- return body, nil
- }
- var items []any
- decoder := json.NewDecoder(bytes.NewReader([]byte(input.Raw)))
- decoder.UseNumber()
- if err := decoder.Decode(&items); err != nil {
- return nil, fmt.Errorf("parse Grok Responses model input: %w", err)
- }
- callIDs, outputIDs := pairGrokReplayCallIDs(items)
- filtered := make([]any, 0, len(items))
- pendingOutputImages := make([]grokToolOutputImage, 0)
- for index, rawItem := range items {
- item, ok := rawItem.(map[string]any)
- if !ok {
- filtered = appendGrokToolOutputImageMessage(filtered, pendingOutputImages)
- pendingOutputImages = pendingOutputImages[:0]
- if text, ok := rawItem.(string); ok && strings.TrimSpace(text) != "" {
- filtered = append(filtered, map[string]any{"type": "message", "role": "user", "content": text})
- }
- continue
- }
- itemType := strings.ToLower(strings.TrimSpace(grokStringValue(item["type"])))
- role := strings.ToLower(strings.TrimSpace(grokStringValue(item["role"])))
- if role == "tool" || role == "function" || isGrokReplayOutputType(itemType) {
- callID := outputIDs[index]
- rawOutput := firstNonNilGrokJSONValue(item["output"], item["content"], item["results"])
- output, nestedImages := normalizeGrokToolOutput(rawOutput, callID)
- filtered = append(filtered, map[string]any{
- "type": "function_call_output",
- "call_id": callID,
- "output": output,
- })
- pendingOutputImages = append(pendingOutputImages, extractGrokToolOutputImages(item, callID)...)
- pendingOutputImages = append(pendingOutputImages, nestedImages...)
- continue
- }
- filtered = appendGrokToolOutputImageMessage(filtered, pendingOutputImages)
- pendingOutputImages = pendingOutputImages[:0]
- switch itemType {
- case "text", "input_text", "output_text":
- text := strings.TrimSpace(grokStringValue(item["text"]))
- if text == "" {
- continue
- }
- if role == "" {
- role = "user"
- }
- item = map[string]any{"type": "message", "role": role, "content": text}
- itemType = "message"
- case "custom_tool_call", "tool_search_call":
- originalType := itemType
- item["type"] = "function_call"
- itemType = "function_call"
- if strings.TrimSpace(grokStringValue(item["name"])) == "" {
- if originalType == "tool_search_call" {
- item["name"] = "tool_search"
- }
- }
- }
- if itemType == "" && role != "" {
- itemType = "message"
- item["type"] = itemType
- }
- if itemType == "message" {
- if role == "" {
- role = "user"
- item["role"] = role
- }
- content, keep := sanitizeGrokMessageContent(item["content"])
- if !keep {
- continue
- }
- item["content"] = content
- if role == "assistant" && !grokIsCompleteOutputMessage(item) {
- if text, ok := collapseGrokAssistantOutputText(content); ok {
- item["content"] = text
- }
- delete(item, "id")
- delete(item, "status")
- }
- } else if itemType == "reasoning" {
- delete(item, "status")
- if content, exists := item["content"]; exists && content == nil {
- delete(item, "content")
- }
- } else if itemType == "function_call" {
- name := strings.TrimSpace(grokStringValue(item["name"]))
- arguments := item["arguments"]
- if function, ok := item["function"].(map[string]any); ok {
- if name == "" {
- name = strings.TrimSpace(grokStringValue(function["name"]))
- }
- if arguments == nil {
- arguments = function["arguments"]
- }
- }
- if arguments == nil {
- arguments = firstNonNilGrokJSONValue(item["input"], item["query"])
- if _, custom := item["input"]; custom {
- arguments = map[string]any{"input": arguments}
- }
- }
- if name == "" {
- name = "unknown_tool"
- }
- item["call_id"] = callIDs[index]
- item["name"] = name
- item["arguments"] = grokModelInputString(arguments, "{}")
- for _, field := range []string{"id", "status", "tool_call_id", "function", "input", "query", "execution"} {
- delete(item, field)
- }
- }
- if shouldStripOpenAIResponsesNonPairCallID(itemType) {
- delete(item, "call_id")
- }
- filtered = append(filtered, item)
- }
- filtered = appendGrokToolOutputImageMessage(filtered, pendingOutputImages)
- encoded, err := json.Marshal(filtered)
- if err != nil {
- return nil, fmt.Errorf("serialize Grok Responses model input: %w", err)
- }
- updated, err := sjson.SetRawBytes(body, "input", encoded)
- if err != nil {
- return nil, fmt.Errorf("set Grok Responses model input: %w", err)
- }
- return updated, nil
- }
- type grokToolOutputImage struct {
- callID string
- url string
- }
- // Grok 1.0.5 returns read_file media as structured output parts rather than
- // top-level images. xAI requires function_call_output.output to be a string,
- // so remove those media parts before stringifying and lift them separately.
- func normalizeGrokToolOutput(value any, callID string) (string, []grokToolOutputImage) {
- stripped, images, keep := stripGrokToolOutputImages(value, callID)
- if len(images) == 0 {
- return grokModelInputString(value, "(empty)"), nil
- }
- if !keep {
- return "(empty)", images
- }
- return grokStructuredToolOutputString(stripped, "(empty)"), images
- }
- func stripGrokToolOutputImages(value any, callID string) (any, []grokToolOutputImage, bool) {
- switch typed := value.(type) {
- case []any:
- filtered := make([]any, 0, len(typed))
- images := make([]grokToolOutputImage, 0)
- for _, item := range typed {
- stripped, nested, keep := stripGrokToolOutputImages(item, callID)
- images = append(images, nested...)
- if keep {
- filtered = append(filtered, stripped)
- }
- }
- return filtered, images, len(filtered) > 0
- case map[string]any:
- partType := strings.ToLower(strings.TrimSpace(grokStringValue(typed["type"])))
- if partType == "image" || partType == "image_url" || partType == "input_image" {
- url := grokToolOutputImageURL(typed)
- if url == "" || isEmptyBase64DataURI(url) {
- return nil, nil, false
- }
- return nil, []grokToolOutputImage{{callID: callID, url: url}}, false
- }
- filtered := make(map[string]any, len(typed))
- for key, item := range typed {
- filtered[key] = item
- }
- images := make([]grokToolOutputImage, 0)
- if rawImages, exists := filtered["images"]; exists {
- if values, ok := rawImages.([]any); ok {
- for _, rawImage := range values {
- url := grokToolOutputImageURL(rawImage)
- if url != "" && !isEmptyBase64DataURI(url) {
- images = append(images, grokToolOutputImage{callID: callID, url: url})
- }
- }
- delete(filtered, "images")
- }
- }
- for _, field := range []string{"content", "output", "results"} {
- item, exists := filtered[field]
- if !exists {
- continue
- }
- stripped, nested, keep := stripGrokToolOutputImages(item, callID)
- images = append(images, nested...)
- if keep {
- filtered[field] = stripped
- } else {
- delete(filtered, field)
- }
- }
- return filtered, images, len(filtered) > 0
- default:
- return value, nil, value != nil
- }
- }
- func grokStructuredToolOutputString(value any, fallback string) string {
- parts, ok := value.([]any)
- if !ok || len(parts) == 0 {
- return grokModelInputString(value, fallback)
- }
- texts := make([]string, 0, len(parts))
- for _, rawPart := range parts {
- part, ok := rawPart.(map[string]any)
- if !ok {
- return grokModelInputString(value, fallback)
- }
- partType := strings.ToLower(strings.TrimSpace(grokStringValue(part["type"])))
- if partType != "text" && partType != "input_text" && partType != "output_text" {
- return grokModelInputString(value, fallback)
- }
- if text := strings.TrimSpace(grokStringValue(part["text"])); text != "" {
- texts = append(texts, text)
- }
- }
- if len(texts) == 0 {
- return fallback
- }
- return strings.Join(texts, "\n")
- }
- // Grok Shell attaches local image results to function_call_output.images.
- // xAI's ModelInput accepts only a string output, so keep the tool replies
- // consecutive and lift their media into one following user input_image turn.
- func extractGrokToolOutputImages(item map[string]any, callID string) []grokToolOutputImage {
- rawImages, ok := item["images"].([]any)
- if !ok || len(rawImages) == 0 {
- return nil
- }
- images := make([]grokToolOutputImage, 0, len(rawImages))
- for _, rawImage := range rawImages {
- url := grokToolOutputImageURL(rawImage)
- if url == "" || isEmptyBase64DataURI(url) {
- continue
- }
- images = append(images, grokToolOutputImage{callID: callID, url: url})
- }
- return images
- }
- func grokToolOutputImageURL(value any) string {
- switch image := value.(type) {
- case string:
- return strings.TrimSpace(image)
- case map[string]any:
- for _, field := range []string{"url", "image_url", "file_url"} {
- raw := image[field]
- switch typed := raw.(type) {
- case string:
- if url := strings.TrimSpace(typed); url != "" {
- return url
- }
- case map[string]any:
- if url := strings.TrimSpace(grokStringValue(typed["url"])); url != "" {
- return url
- }
- }
- }
- }
- return ""
- }
- func appendGrokToolOutputImageMessage(items []any, images []grokToolOutputImage) []any {
- if len(images) == 0 {
- return items
- }
- content := make([]any, 0, len(images)*2)
- lastCallID := ""
- for _, image := range images {
- if image.callID != lastCallID {
- content = append(content, map[string]any{
- "type": "input_text",
- "text": fmt.Sprintf("[Tool output media for call %s]", image.callID),
- })
- lastCallID = image.callID
- }
- content = append(content, map[string]any{
- "type": "input_image",
- "image_url": image.url,
- })
- }
- return append(items, map[string]any{
- "type": "message",
- "role": "user",
- "content": content,
- })
- }
- func pairGrokReplayCallIDs(items []any) (map[int]string, map[int]string) {
- callIDs := make(map[int]string)
- outputIDs := make(map[int]string)
- aliases := make(map[string]string)
- conflictingAliases := make(map[string]struct{})
- pendingCalls := make([]string, 0)
- nextID := 0
- synthetic := func() string {
- nextID++
- return fmt.Sprintf("grok_replay_call_%d", nextID)
- }
- registerAlias := func(alias, canonical string) {
- alias = strings.TrimSpace(alias)
- if alias == "" {
- return
- }
- if _, conflict := conflictingAliases[alias]; conflict {
- return
- }
- if existing, exists := aliases[alias]; exists && existing != canonical {
- delete(aliases, alias)
- conflictingAliases[alias] = struct{}{}
- return
- }
- aliases[alias] = canonical
- }
- for index, rawItem := range items {
- item, ok := rawItem.(map[string]any)
- if !ok {
- continue
- }
- itemType := strings.ToLower(strings.TrimSpace(grokStringValue(item["type"])))
- if !isGrokReplayCallType(itemType) {
- continue
- }
- callID := firstNonEmptyGrokString(item["call_id"], item["tool_call_id"])
- if callID == "" {
- callID = synthetic()
- }
- callIDs[index] = callID
- pendingCalls = append(pendingCalls, callID)
- registerAlias(callID, callID)
- registerAlias(grokStringValue(item["call_id"]), callID)
- registerAlias(grokStringValue(item["tool_call_id"]), callID)
- registerAlias(grokStringValue(item["id"]), callID)
- }
- consumedCalls := make(map[string]struct{})
- consumeNextCall := func() string {
- for _, callID := range pendingCalls {
- if _, consumed := consumedCalls[callID]; consumed {
- continue
- }
- consumedCalls[callID] = struct{}{}
- return callID
- }
- return synthetic()
- }
- for index, rawItem := range items {
- item, ok := rawItem.(map[string]any)
- if !ok {
- continue
- }
- itemType := strings.ToLower(strings.TrimSpace(grokStringValue(item["type"])))
- role := strings.ToLower(strings.TrimSpace(grokStringValue(item["role"])))
- if role != "tool" && role != "function" && !isGrokReplayOutputType(itemType) {
- continue
- }
- alias := firstNonEmptyGrokString(item["call_id"], item["tool_call_id"], item["id"])
- _, conflict := conflictingAliases[alias]
- if canonical := aliases[alias]; canonical != "" && !conflict {
- outputIDs[index] = canonical
- consumedCalls[canonical] = struct{}{}
- } else if firstNonEmptyGrokString(item["call_id"], item["tool_call_id"]) != "" {
- // Explicit call identifiers remain authoritative even when an invalid
- // transcript reuses the same identifier for more than one call.
- outputIDs[index] = alias
- } else if conflict {
- // An item ID shared by multiple calls cannot safely identify either
- // one. Keep it out of the call_id namespace and leave the output orphaned.
- outputIDs[index] = synthetic()
- } else {
- outputIDs[index] = consumeNextCall()
- }
- }
- return callIDs, outputIDs
- }
- func isGrokReplayCallType(itemType string) bool {
- switch itemType {
- case "function_call", "custom_tool_call", "tool_search_call":
- return true
- default:
- return false
- }
- }
- func isGrokReplayOutputType(itemType string) bool {
- switch itemType {
- case "function_call_output", "custom_tool_call_output", "tool_search_output", "tool_search_call_output":
- return true
- default:
- return false
- }
- }
- func firstNonEmptyGrokString(values ...any) string {
- for _, value := range values {
- if text := strings.TrimSpace(grokStringValue(value)); text != "" {
- return text
- }
- }
- return ""
- }
- func firstNonNilGrokJSONValue(values ...any) any {
- for _, value := range values {
- if value != nil {
- return value
- }
- }
- return nil
- }
- func sanitizeGrokMessageContent(content any) (any, bool) {
- switch value := content.(type) {
- case nil:
- return nil, false
- case string:
- return value, strings.TrimSpace(value) != ""
- case []any:
- filtered := make([]any, 0, len(value))
- for _, rawPart := range value {
- part, ok := rawPart.(map[string]any)
- if !ok {
- if text, isText := rawPart.(string); !isText || strings.TrimSpace(text) != "" {
- filtered = append(filtered, rawPart)
- }
- continue
- }
- switch strings.ToLower(strings.TrimSpace(grokStringValue(part["type"]))) {
- case "text", "input_text", "output_text":
- if strings.TrimSpace(grokStringValue(part["text"])) == "" {
- continue
- }
- case "image_url", "input_image":
- if !grokContentPartHasImageURL(part) {
- continue
- }
- }
- filtered = append(filtered, part)
- }
- return filtered, len(filtered) > 0
- default:
- return content, true
- }
- }
- func grokContentPartHasImageURL(part map[string]any) bool {
- for _, field := range []string{"file_id", "file_data"} {
- if strings.TrimSpace(grokStringValue(part[field])) != "" {
- return true
- }
- }
- raw := firstNonNilGrokJSONValue(part["image_url"], part["file_url"])
- switch value := raw.(type) {
- case string:
- value = strings.TrimSpace(value)
- return value != "" && !isEmptyBase64DataURI(value)
- case map[string]any:
- url := strings.TrimSpace(grokStringValue(value["url"]))
- return url != "" && !isEmptyBase64DataURI(url)
- default:
- return false
- }
- }
- func grokStringValue(value any) string {
- text, _ := value.(string)
- return text
- }
- func grokModelInputString(value any, fallback string) string {
- if text, ok := value.(string); ok {
- if strings.TrimSpace(text) == "" {
- return fallback
- }
- return text
- }
- if value == nil {
- return fallback
- }
- encoded, err := json.Marshal(value)
- if err != nil || len(encoded) == 0 || string(encoded) == "null" {
- return fallback
- }
- return string(encoded)
- }
- func grokIsCompleteOutputMessage(item map[string]any) bool {
- return strings.TrimSpace(grokStringValue(item["id"])) != "" &&
- strings.TrimSpace(grokStringValue(item["status"])) != ""
- }
- func collapseGrokAssistantOutputText(content any) (string, bool) {
- parts, ok := content.([]any)
- if !ok || len(parts) == 0 {
- return "", false
- }
- var text strings.Builder
- for _, rawPart := range parts {
- part, ok := rawPart.(map[string]any)
- if !ok || strings.ToLower(strings.TrimSpace(grokStringValue(part["type"]))) != "output_text" {
- return "", false
- }
- value, ok := part["text"].(string)
- if !ok {
- return "", false
- }
- _, _ = text.WriteString(value)
- }
- if strings.TrimSpace(text.String()) == "" {
- return "", false
- }
- return text.String(), true
- }
|