mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 16:08:02 +08:00
Merge pull request #5844 from lyen1688/fix/grok-inline-image-view-image
fix(grok): 避免内联图片与 view_image 工具冲突
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user