Merge pull request #5844 from lyen1688/fix/grok-inline-image-view-image

fix(grok): 避免内联图片与 view_image 工具冲突
This commit is contained in:
Wesley Liddick
2026-08-19 19:22:08 +08:00
committed by GitHub
5 changed files with 442 additions and 0 deletions
@@ -105,6 +105,13 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions(
return nil, policyErr
}
upstreamBody = updatedBody
if account.Platform == PlatformGrok {
strippedBody, stripErr := stripRedundantGrokChatViewImageTool(upstreamBody)
if stripErr != nil {
return nil, fmt.Errorf("strip redundant Grok Chat view_image tool: %w", stripErr)
}
upstreamBody = strippedBody
}
// Grok Composer does not accept image_url parts directly, but Grok Build
// can describe the images first. Bridge only this exact failure mode.
@@ -495,6 +495,10 @@ func patchGrokResponsesBodyBase(body []byte, upstreamModel string) ([]byte, erro
if err != nil {
return nil, err
}
out, err = stripRedundantGrokViewImageTool(out)
if err != nil {
return nil, err
}
out, err = sanitizeGrokReasoningNullContent(out)
if err != nil {
return nil, err
@@ -767,6 +771,69 @@ func sanitizeGrokResponsesInput(body []byte) ([]byte, error) {
return sjson.SetRawBytes(body, "tools", encodedTools)
}
// An inline input_image is already visible to Grok. Keeping Codex's local
// view_image tool in the same turn can make Grok announce a tool call without
// actually calling it, so remove only that redundant automatic choice.
func stripRedundantGrokViewImageTool(body []byte) ([]byte, error) {
input := gjson.GetBytes(body, "input")
if !input.IsArray() {
return body, nil
}
items := input.Array()
if len(items) == 0 {
return body, nil
}
current := items[len(items)-1]
if strings.TrimSpace(current.Get("role").String()) != "user" ||
!openAIJSONValueMayContainImageInput(current) {
return body, nil
}
toolChoice := gjson.GetBytes(body, "tool_choice")
if toolChoice.IsObject() && strings.TrimSpace(toolChoice.Get("type").String()) == "function" {
choiceName := strings.TrimSpace(toolChoice.Get("name").String())
if choiceName == "" {
choiceName = strings.TrimSpace(toolChoice.Get("function.name").String())
}
if choiceName == "view_image" {
return body, nil
}
}
tools := gjson.GetBytes(body, "tools")
if !tools.IsArray() {
return body, nil
}
filtered := make([]json.RawMessage, 0, len(tools.Array()))
changed := false
for _, tool := range tools.Array() {
if strings.TrimSpace(tool.Get("type").String()) == "function" &&
strings.TrimSpace(tool.Get("name").String()) == "view_image" {
changed = true
continue
}
filtered = append(filtered, json.RawMessage(tool.Raw))
}
if !changed {
return body, nil
}
if len(filtered) == 0 && strings.TrimSpace(toolChoice.String()) == "required" {
return body, nil
}
if len(filtered) == 0 {
out, err := sjson.DeleteBytes(body, "tools")
if err != nil {
return nil, err
}
return sjson.DeleteBytes(out, "parallel_tool_calls")
}
encoded, err := json.Marshal(filtered)
if err != nil {
return nil, err
}
return sjson.SetRawBytes(body, "tools", encoded)
}
func grokResponsesToolDedupKey(tool gjson.Result) string {
toolType := strings.TrimSpace(tool.Get("type").String())
if toolType != "" {
@@ -0,0 +1,83 @@
package service
import (
"encoding/json"
"strings"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// 当前轮的内联 image_url 已可由 Grok 直接读取;若同时保留客户端本地
// view_image,Grok 可能只预告调用工具而不继续作答,因此只移除这一冗余自动选择。
func stripRedundantGrokChatViewImageTool(body []byte) ([]byte, error) {
messages := gjson.GetBytes(body, "messages")
if !messages.IsArray() {
return body, nil
}
items := messages.Array()
if len(items) == 0 {
return body, nil
}
current := items[len(items)-1]
if strings.TrimSpace(current.Get("role").String()) != "user" ||
!openAIJSONValueMayContainImageInput(current) {
return body, nil
}
toolChoice := gjson.GetBytes(body, "tool_choice")
if toolChoice.IsObject() && strings.TrimSpace(toolChoice.Get("type").String()) == "function" {
choiceName := strings.TrimSpace(toolChoice.Get("function.name").String())
if choiceName == "" {
choiceName = strings.TrimSpace(toolChoice.Get("name").String())
}
if choiceName == "view_image" {
return body, nil
}
}
tools := gjson.GetBytes(body, "tools")
if !tools.IsArray() {
return body, nil
}
filtered := make([]json.RawMessage, 0, len(tools.Array()))
changed := false
for _, tool := range tools.Array() {
toolName := strings.TrimSpace(tool.Get("function.name").String())
if toolName == "" {
toolName = strings.TrimSpace(tool.Get("name").String())
}
if strings.TrimSpace(tool.Get("type").String()) == "function" && toolName == "view_image" {
changed = true
continue
}
filtered = append(filtered, json.RawMessage(tool.Raw))
}
if !changed {
return body, nil
}
if len(filtered) == 0 && strings.TrimSpace(toolChoice.String()) == "required" {
return body, nil
}
if len(filtered) > 0 {
encoded, err := json.Marshal(filtered)
if err != nil {
return nil, err
}
return sjson.SetRawBytes(body, "tools", encoded)
}
out, err := sjson.DeleteBytes(body, "tools")
if err != nil {
return nil, err
}
out, err = sjson.DeleteBytes(out, "parallel_tool_calls")
if err != nil {
return nil, err
}
if strings.TrimSpace(toolChoice.String()) == "auto" {
out, err = sjson.DeleteBytes(out, "tool_choice")
}
return out, err
}
@@ -0,0 +1,184 @@
//go:build unit
package service
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestForwardGrokChatViaResponsesDropsRedundantViewImage(t *testing.T) {
gin.SetMode(gin.TestMode)
body := grokChatInlineImageRequest()
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
c.Set("api_key", &APIKey{ID: 7991})
account := grokChatBridgeTestAccount(799)
repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
accountsByID: map[int64]*Account{account.ID: account},
}}
upstream := &httpUpstreamRecorder{resp: grokChatBridgeCompletedResponse("resp_chat_image", 0)}
svc := &OpenAIGatewayService{
httpUpstream: upstream,
grokTokenProvider: NewGrokTokenProvider(repo, nil),
accountRepo: repo,
}
result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String())
require.Equal(t, "input_image", gjson.GetBytes(upstream.lastBody, "input.0.content.1.type").String())
assertGrokUpstreamKeepsOtherToolAndDropsViewImage(t, upstream.lastBody, "tools.#(name==\"%s\")")
}
func TestForwardGrokRawChatDropsRedundantViewImage(t *testing.T) {
gin.SetMode(gin.TestMode)
body := grokChatInlineImageRequest()
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
account := &Account{
ID: 800, Platform: PlatformGrok, Type: AccountTypeAPIKey, Concurrency: 1,
Credentials: map[string]any{"api_key": "test-key", "base_url": "https://grok.example.test/v1"},
}
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(
`{"id":"chatcmpl","object":"chat.completion","model":"grok-4.6","choices":[],"usage":{"prompt_tokens":1,"completion_tokens":1}}`,
)),
}}
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "https://grok.example.test/v1/chat/completions", upstream.lastReq.URL.String())
require.Equal(t, "image_url", gjson.GetBytes(upstream.lastBody, "messages.0.content.1.type").String())
assertGrokUpstreamKeepsOtherToolAndDropsViewImage(t, upstream.lastBody, "tools.#(function.name==\"%s\")")
}
func TestForwardGrokMessagesDropsRedundantViewImage(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{
"model":"grok-4.6","max_tokens":32,"stream":false,
"messages":[{"role":"user","content":[
{"type":"text","text":"What text is in this image?"},
{"type":"image","source":{"type":"base64","media_type":"image/png","data":"AA=="}}
]}],
"tools":[
{"name":"view_image","input_schema":{"type":"object","properties":{"path":{"type":"string"}}}},
{"name":"shell_command","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}}}}
],
"tool_choice":{"type":"auto"}
}`)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
c.Set("api_key", &APIKey{ID: 7992})
account := healthyGrokOAuthGatewayTestAccount(801, "access-token")
repo := &grokQuotaAccountRepo{mockAccountRepoForPlatform: &mockAccountRepoForPlatform{
accountsByID: map[int64]*Account{account.ID: account},
}}
upstream := &httpUpstreamRecorder{resp: grokMessagesSSECompletedResponse("resp_messages_image", 0)}
svc := &OpenAIGatewayService{
httpUpstream: upstream,
grokTokenProvider: NewGrokTokenProvider(repo, nil),
accountRepo: repo,
}
result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, xai.DefaultCLIBaseURL+"/responses", upstream.lastReq.URL.String())
require.Equal(t, "input_image", gjson.GetBytes(upstream.lastBody, "input.0.content.1.type").String())
assertGrokUpstreamKeepsOtherToolAndDropsViewImage(t, upstream.lastBody, "tools.#(name==\"%s\")")
}
func TestStripRedundantGrokChatViewImageToolLeavesNonTargetRequestsByteExact(t *testing.T) {
t.Parallel()
tests := []struct {
name string
body string
}{
{
name: "current turn has no inline image",
body: `{"messages":[{"role":"user","content":"Inspect a local image"}],"tools":[{"type":"function","function":{"name":"view_image"}}]}`,
},
{
name: "inline image is only historical",
body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}]},{"role":"assistant","content":"Done"},{"role":"user","content":"Inspect another local image"}],"tools":[{"type":"function","function":{"name":"view_image"}}]}`,
},
{
name: "view image is explicitly selected",
body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}]}],"tools":[{"type":"function","function":{"name":"view_image"}}],"tool_choice":{"type":"function","function":{"name":"view_image"}}}`,
},
{
name: "required with view image as the only tool",
body: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}]}],"tools":[{"type":"function","function":{"name":"view_image"}}],"tool_choice":"required"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := []byte(tt.body)
patched, err := stripRedundantGrokChatViewImageTool(body)
require.NoError(t, err)
require.Equal(t, body, patched)
})
}
}
func TestStripRedundantGrokChatViewImageToolDropsOnlyToolMetadata(t *testing.T) {
t.Parallel()
body := []byte(`{
"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}]}],
"tools":[{"type":"function","function":{"name":"view_image"}}],
"tool_choice":"auto",
"parallel_tool_calls":true
}`)
patched, err := stripRedundantGrokChatViewImageTool(body)
require.NoError(t, err)
require.False(t, gjson.GetBytes(patched, "tools").Exists())
require.False(t, gjson.GetBytes(patched, "tool_choice").Exists())
require.False(t, gjson.GetBytes(patched, "parallel_tool_calls").Exists())
}
func grokChatInlineImageRequest() []byte {
return []byte(`{
"model":"grok-4.6",
"messages":[{"role":"user","content":[
{"type":"text","text":"What text is in this image?"},
{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}
]}],
"stream":false,
"tools":[
{"type":"function","function":{"name":"view_image","parameters":{"type":"object","properties":{"path":{"type":"string"}}}}},
{"type":"function","function":{"name":"shell_command","parameters":{"type":"object","properties":{"cmd":{"type":"string"}}}}}
],
"tool_choice":"auto"
}`)
}
func assertGrokUpstreamKeepsOtherToolAndDropsViewImage(t *testing.T, body []byte, pathTemplate string) {
t.Helper()
require.False(t, gjson.GetBytes(body, strings.Replace(pathTemplate, "%s", "view_image", 1)).Exists(), string(body))
require.True(t, gjson.GetBytes(body, strings.Replace(pathTemplate, "%s", "shell_command", 1)).Exists(), string(body))
}
@@ -43,6 +43,107 @@ func TestPatchGrokResponsesBodySetsMappedModelAndDropsUnsupportedFields(t *testi
require.Equal(t, "high", gjson.GetBytes(patched, "reasoning.effort").String())
}
func TestPatchGrokResponsesBodyDropsRedundantViewImageForCurrentInlineImage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
body string
}{
{
name: "top-level tools",
body: `{
"model":"grok-4.6",
"input":[{"type":"message","role":"user","content":[
{"type":"input_text","text":"What text is in this image?"},
{"type":"input_image","image_url":"data:image/png;base64,AA=="}
]}],
"tools":[
{"type":"function","name":"view_image","parameters":{"type":"object"}},
{"type":"function","name":"shell_command","parameters":{"type":"object"}}
]
}`,
},
{
name: "Responses Lite additional tools",
body: `{
"model":"grok-4.6",
"input":[
{"type":"additional_tools","role":"developer","tools":[
{"type":"function","name":"view_image","parameters":{"type":"object"}},
{"type":"function","name":"shell_command","parameters":{"type":"object"}}
]},
{"type":"message","role":"user","content":[
{"type":"input_text","text":"What text is in this image?"},
{"type":"input_image","image_url":"data:image/png;base64,AA=="}
]}
]
}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
patched, err := patchGrokResponsesBody([]byte(tt.body), "grok-4.6")
require.NoError(t, err)
require.False(t, gjson.GetBytes(patched, `tools.#(name=="view_image")`).Exists())
require.Equal(t, "shell_command", gjson.GetBytes(patched, "tools.0.name").String())
})
}
}
func TestPatchGrokResponsesBodyKeepsNonRedundantViewImage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
body string
}{
{
name: "current turn has no inline image",
body: `{"input":[{"role":"user","content":[{"type":"input_text","text":"Inspect a local image"}]}],"tools":[{"type":"function","name":"view_image"}]}`,
},
{
name: "inline image is only historical",
body: `{"input":[{"role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]},{"role":"assistant","content":[{"type":"output_text","text":"Done"}]},{"role":"user","content":[{"type":"input_text","text":"Inspect another local image"}]}],"tools":[{"type":"function","name":"view_image"}]}`,
},
{
name: "view image is explicitly selected",
body: `{"input":[{"role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}],"tools":[{"type":"function","name":"view_image"}],"tool_choice":{"type":"function","name":"view_image"}}`,
},
{
name: "required with view image as the only tool",
body: `{"input":[{"role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}],"tools":[{"type":"function","name":"view_image"}],"tool_choice":"required"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
patched, err := patchGrokResponsesBody([]byte(tt.body), "grok-4.6")
require.NoError(t, err)
require.Equal(t, "view_image", gjson.GetBytes(patched, "tools.0.name").String())
})
}
}
func TestPatchGrokResponsesBodyDropsViewImageOnlyToolMetadata(t *testing.T) {
t.Parallel()
body := []byte(`{
"input":[{"role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}],
"tools":[{"type":"function","name":"view_image"}],
"tool_choice":"auto",
"parallel_tool_calls":true
}`)
patched, err := patchGrokResponsesBody(body, "grok-4.6")
require.NoError(t, err)
require.False(t, gjson.GetBytes(patched, "tools").Exists())
require.False(t, gjson.GetBytes(patched, "tool_choice").Exists())
require.False(t, gjson.GetBytes(patched, "parallel_tool_calls").Exists())
}
func TestPatchGrokResponsesBodySanitizesComposerReasoningParameters(t *testing.T) {
t.Parallel()